From 846bb112e28ae8831d46ec9ed656407eecce87d3 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 19:28:52 +0000 Subject: [PATCH] =?UTF-8?q?Construction=20axes,=20points=20and=20planes=20?= =?UTF-8?q?from=20model=20geometry=20(SOLID=20=E2=96=B8=20CONSTRUCT):=20ax?= =?UTF-8?q?es=20through=20a=20cylinder/cone/torus,=20perpendicular=20to=20?= =?UTF-8?q?a=20face=20at=20a=20point,=20through=20two=20planes,=20two=20po?= =?UTF-8?q?ints=20or=20an=20edge;=20points=20at=20a=20vertex,=20through=20?= =?UTF-8?q?two=20edges,=20three=20planes,=20at=20the=20centre=20of=20a=20c?= =?UTF-8?q?ircle/sphere/torus,=20at=20an=20edge=20and=20a=20plane,=20along?= =?UTF-8?q?=20a=20path;=20midplane,=20planes=20through=20two=20edges=20or?= =?UTF-8?q?=20three=20points,=20tangent=20to=20a=20face,=20along=20a=20pat?= =?UTF-8?q?h.=20References=20(doc::construct::GeoRef)=20keep=20picked=20fa?= =?UTF-8?q?ces=20and=20edges=20by=20persistent=20name=20and=20resolve=20on?= =?UTF-8?q?=20each=20evaluation=20(they=20follow=20the=20model);=20results?= =?UTF-8?q?=20live=20in=20ModelState.construct,=20in=20the=20component's?= =?UTF-8?q?=20frame;=20view::construction=5Faxes/points/planes=20for=20the?= =?UTF-8?q?=20viewport;=20kernel::face=5Fsurface=20for=20a=20face's=20anal?= =?UTF-8?q?ytic=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/assembly.rs | 15 + crates/doc/src/construct.rs | 643 +++++++++++++++++++++++++ crates/doc/src/document.rs | 74 ++- crates/doc/src/eval.rs | 35 +- crates/doc/src/eval_more.rs | 2 +- crates/doc/src/eval_surface.rs | 2 +- crates/doc/src/lib.rs | 1 + crates/doc/src/params.rs | 18 + crates/doc/src/project.rs | 2 +- crates/engine/src/cmd/browser.rs | 2 +- crates/engine/src/cmd/construct.rs | 251 ++++++++++ crates/engine/src/cmd/features.rs | 4 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/sketch.rs | 2 +- crates/engine/src/cmd/sketch_canvas.rs | 2 +- crates/engine/src/construct_tests.rs | 259 ++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/view.rs | 72 ++- crates/kernel/src/lib.rs | 2 + crates/kernel/src/offset.rs | 4 +- crates/kernel/src/surface_kind.rs | 52 ++ 21 files changed, 1400 insertions(+), 46 deletions(-) create mode 100644 crates/doc/src/construct.rs create mode 100644 crates/engine/src/cmd/construct.rs create mode 100644 crates/engine/src/construct_tests.rs create mode 100644 crates/kernel/src/surface_kind.rs diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 6dc2ffb4..c6df1ebc 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -144,9 +144,22 @@ fn map_plane_ref(m: &Mat, p: &mut PlaneRef) { } // Named origin planes and construction planes are the component's own. PlaneRef::Origin { .. } | PlaneRef::Construction { .. } => {} + PlaneRef::Tangent { face, at } => { + *at = apply_point(m, *at); + map_geo_ref(m, face); + } + PlaneRef::Midplane { .. } | PlaneRef::TwoEdges { .. } | PlaneRef::ThreePoints { .. } | PlaneRef::AlongPath { .. } => { + for r in p.refs_mut() { + map_geo_ref(m, r); + } + } } } +fn map_geo_ref(m: &Mat, g: &mut crate::construct::GeoRef) { + g.map_points(&mut |p: &mut Vec3| *p = apply_point(m, *p), &mut |pl: &mut PlaneRef| map_plane_ref(m, pl)); +} + impl FeatureKind { /// Map the feature's world geometry (points, directions, explicit planes) by `m`. pub fn transform_geometry(&mut self, m: &Mat) { @@ -156,6 +169,8 @@ impl FeatureKind { FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Mirror { plane, .. } => { map_plane_ref(m, plane) } + FeatureKind::ConstructionAxis { def } => def.refs_mut().into_iter().for_each(|r| map_geo_ref(m, r)), + FeatureKind::ConstructionPoint { def } => def.refs_mut().into_iter().for_each(|r| map_geo_ref(m, r)), FeatureKind::Split { plane, tool, .. } => { map_plane_ref(m, plane); if let Some(t) = tool { diff --git a/crates/doc/src/construct.rs b/crates/doc/src/construct.rs new file mode 100644 index 00000000..0fd1f9aa --- /dev/null +++ b/crates/doc/src/construct.rs @@ -0,0 +1,643 @@ +//! Construction geometry from the model: references to vertices, edges, faces, planes, axes, +//! points and sketch entities, resolved on each evaluation, and the axes, points and planes +//! built from them (Fusion's CONSTRUCT panel). +//! +//! Face and edge references carry the persistent name of what was picked (see `naming`), so +//! they follow the model when earlier features change; the picked point is the fallback. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use solvecraft_geom::{Plane, Vec2, Vec3}; +use solvecraft_kernel::{self as kernel, FaceSurface}; + +use crate::eval::{ModelBody, ModelState}; +use crate::expr::{Kind, Value}; +use crate::{DocError, Document, PlaneRef, Result}; + +/// Something picked to build construction geometry from. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum GeoRef { + /// A fixed point. + Point { p: Vec3 }, + /// The design origin. + Origin, + /// A body vertex (the one nearest `at`). + Vertex { at: Vec3 }, + /// A body edge: the one named `name`, else the one nearest `at`. + Edge { + at: Vec3, + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, + }, + /// A body face: the one named `name`, else the one at `at`. + Face { + at: Vec3, + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, + }, + /// A plane (origin, construction, offset…). + Plane { plane: PlaneRef }, + /// World X, Y or Z axis. + World { axis: String }, + /// A construction axis feature, by name. + Axis { name: String }, + /// A construction point feature, by name. + ConstructPoint { name: String }, + /// A sketch point (by sketch feature id and point id). + SketchPoint { sketch: u64, point: String }, + /// A sketch curve (by sketch feature id and curve id). + SketchCurve { sketch: u64, curve: String }, +} + +/// How a construction axis is defined. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AxisDef { + /// The axis of a cylinder, cone or torus face (or a circular edge). + ThroughFace { face: GeoRef }, + /// Perpendicular to a face, through a point (projected onto the face). + NormalToFace { face: GeoRef, point: GeoRef }, + /// Where two planes meet. + TwoPlanes { a: GeoRef, b: GeoRef }, + /// Through two points. + TwoPoints { a: GeoRef, b: GeoRef }, + /// Along a straight edge (or sketch line). + Edge { edge: GeoRef }, +} + +/// How a construction point is defined. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PointDef { + /// At a vertex, sketch point or fixed point. + At { point: GeoRef }, + /// Where two edges (lines) meet, or come closest. + TwoEdges { a: GeoRef, b: GeoRef }, + /// Where three planes meet. + ThreePlanes { a: GeoRef, b: GeoRef, c: GeoRef }, + /// The centre of a circular edge, a sphere or a torus. + Center { of: GeoRef }, + /// Where an edge (line) crosses a plane. + EdgeAndPlane { edge: GeoRef, plane: GeoRef }, + /// At a fraction `t` (0…1) of the way along an edge or sketch curve. + AlongPath { path: GeoRef, t: String }, +} + +/// Resolved construction geometry, kept in the model state by feature. +#[derive(Clone, Debug, PartialEq)] +pub enum ConstructGeom { + Plane(Plane), + Axis { origin: Vec3, dir: Vec3 }, + Point(Vec3), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Construct { + pub feature: u64, + pub name: String, + pub geom: ConstructGeom, +} + +/// Everything a reference needs to be resolved. +pub(crate) struct Ctx<'a> { + pub doc: &'a Document, + pub vals: &'a BTreeMap, + pub st: &'a ModelState, +} + +fn bad(m: impl Into) -> DocError { + DocError::Invalid(m.into()) +} + +fn bodies(st: &ModelState) -> impl Iterator { + st.bodies.iter().filter(|b| !b.body.is_mesh()) +} + +fn construct<'a>(st: &'a ModelState, name: &str) -> Result<&'a ConstructGeom> { + st.construct.iter().find(|c| c.name == name).map(|c| &c.geom).ok_or_else(|| DocError::Unknown(format!("construction `{name}`"))) +} + +/// The face picked: (body, face index, point on it). +fn face_of<'a>(st: &'a ModelState, at: Vec3, name: &Option) -> Result<(&'a ModelBody, usize, Vec3)> { + let at = match name.as_deref().and_then(|n| crate::naming::point_on_face(st, n, at)) { + Some((q, _)) => q, + None => at, + }; + for b in bodies(st) { + if let Some(fi) = crate::appearance::face_index_at(b, at) { + return Ok((b, fi, at)); + } + } + Err(bad(format!("no face at {at:?}"))) +} + +/// The edge picked, as a polyline. +fn edge_points(st: &ModelState, at: Vec3, name: &Option) -> Result> { + let mut at = at; + if let Some(n) = name { + for b in bodies(st) { + if let Ok((q, _)) = crate::naming::find_edge(b, n, at) { + at = q; + break; + } + } + } + let mut best: Option<(f64, Vec)> = None; + for b in bodies(st) { + let tol = (b.body.size() * 1e-3).max(1e-3); + let Ok(edges) = b.body.edges(tol) else { continue }; + for e in edges { + let d = e.points.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::MAX, f64::min); + if best.as_ref().is_none_or(|(bd, _)| d < *bd) { + best = Some((d, e.points)); + } + } + } + match best { + Some((d, pts)) if pts.len() >= 2 && d <= 1e-3 + 1e-2 * pts.iter().fold(0.0, |m: f64, p| m.max(p.len())).max(1.0) => Ok(pts), + _ => Err(bad(format!("no edge at {at:?}"))), + } +} + +/// A polyline that is straight: its line. +fn as_line(pts: &[Vec3]) -> Option<(Vec3, Vec3)> { + let (a, b) = (*pts.first()?, *pts.last()?); + let d = (b - a).normalized()?; + let len = (b - a).len(); + pts.iter().all(|p| p.dist_to_segment(a, b) <= len * 1e-6 + 1e-9).then_some((a, d)) +} + +/// A polyline on a circle: (centre, unit normal, radius). +fn as_circle(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64)> { + let n = pts.len(); + if n < 4 { + return None; + } + let (a, b, c) = (*pts.first()?, *pts.get(n / 3)?, *pts.get(2 * n / 3)?); + let (ab, ac) = (b - a, c - a); + let nrm = ab.cross(ac); + let l2 = nrm.len2(); + if l2 < 1e-24 { + return None; + } + // Circumcentre of abc. + let centre = a + (nrm.cross(ab) * ac.len2() + ac.cross(nrm) * ab.len2()) * (1.0 / (2.0 * l2)); + let r = (a - centre).len(); + let unit = nrm.normalized()?; + pts.iter() + .all(|p| ((*p - centre).len() - r).abs() <= r * 1e-3 + 1e-6 && (*p - centre).dot(unit).abs() <= r * 1e-3 + 1e-6) + .then_some((centre, unit, r)) +} + +fn sketch_world(ctx: &Ctx, sketch: u64, q: Vec2) -> Result { + let ss = ctx.st.sketch(sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?; + Ok(ss.plane.to_world(q)) +} + +fn sketch_curve_points(ctx: &Ctx, sketch: u64, curve: &str) -> Result> { + let ss = ctx.st.sketch(sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?; + let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("curve `{curve}`")))?; + let pl = ss.sketch.polyline(ci); + if pl.len() < 2 { + return Err(bad(format!("curve `{curve}` has no length"))); + } + Ok(pl.into_iter().map(|q| ss.plane.to_world(q)).collect()) +} + +/// A reference as a point. +pub(crate) fn point(ctx: &Ctx, g: &GeoRef) -> Result { + match g { + GeoRef::Point { p } => Ok(*p), + GeoRef::Origin => Ok(Vec3::ZERO), + GeoRef::Vertex { at } => { + let mut best: Option<(f64, Vec3)> = None; + for b in bodies(ctx.st) { + let tol = (b.body.size() * 1e-3).max(1e-3); + let Ok(edges) = b.body.edges(tol) else { continue }; + for v in edges.iter().flat_map(|e| [e.points.first().copied(), e.points.last().copied()]).flatten() { + let d = v.dist(*at); + if best.is_none_or(|(bd, _)| d < bd) { + best = Some((d, v)); + } + } + } + best.map(|(_, v)| v).ok_or_else(|| bad("there is no vertex")) + } + GeoRef::ConstructPoint { name } => match construct(ctx.st, name)? { + ConstructGeom::Point(p) => Ok(*p), + _ => Err(bad(format!("`{name}` is not a point"))), + }, + GeoRef::SketchPoint { sketch, point } => { + let ss = ctx.st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?; + let q = + ss.sketch.point_index(point).and_then(|i| ss.sketch.point(i)).ok_or_else(|| DocError::Unknown(format!("sketch point `{point}`")))?; + sketch_world(ctx, *sketch, q) + } + // A round edge or face: its centre. + GeoRef::Edge { .. } | GeoRef::Face { .. } | GeoRef::SketchCurve { .. } => centre(ctx, g), + _ => Err(bad("pick a point, vertex or sketch point")), + } +} + +/// A reference as a line (origin, unit direction). +pub(crate) fn line(ctx: &Ctx, g: &GeoRef) -> Result<(Vec3, Vec3)> { + match g { + GeoRef::Edge { at, name } => { + let pts = edge_points(ctx.st, *at, name)?; + as_line(&pts).or_else(|| as_circle(&pts).map(|(c, n, _)| (c, n))).ok_or_else(|| bad("the edge is neither straight nor circular")) + } + GeoRef::SketchCurve { sketch, curve } => { + let pts = sketch_curve_points(ctx, *sketch, curve)?; + as_line(&pts).ok_or_else(|| bad(format!("sketch curve `{curve}` is not a line"))) + } + GeoRef::World { axis } => { + let d = match axis.to_ascii_uppercase().as_str() { + "X" => Vec3::X, + "Y" => Vec3::Y, + "Z" => Vec3::Z, + _ => return Err(DocError::Unknown(format!("axis `{axis}`"))), + }; + Ok((Vec3::ZERO, d)) + } + GeoRef::Axis { name } => match construct(ctx.st, name)? { + ConstructGeom::Axis { origin, dir } => Ok((*origin, *dir)), + _ => Err(bad(format!("`{name}` is not an axis"))), + }, + GeoRef::Face { at, name } => { + let (b, fi, _) = face_of(ctx.st, *at, name)?; + match kernel::face_surface(&b.body, fi) { + Some(FaceSurface::Cylinder { origin, axis, .. }) => Ok((origin, axis)), + Some(FaceSurface::Cone { apex, axis, .. }) => Ok((apex, axis)), + Some(FaceSurface::Torus { center, axis, .. }) => Ok((center, axis)), + _ => Err(bad("the face is not a cylinder, cone or torus")), + } + } + _ => Err(bad("pick an edge, line or axis")), + } +} + +/// A reference as a plane. +pub(crate) fn plane(ctx: &Ctx, g: &GeoRef) -> Result { + match g { + GeoRef::Plane { plane } => ctx.doc.resolve_plane_in(ctx.vals, plane, 0, Some(ctx.st)), + GeoRef::Face { at, name } => { + let (b, fi, q) = face_of(ctx.st, *at, name)?; + match kernel::face_surface(&b.body, fi) { + Some(FaceSurface::Plane { normal, point }) => { + let o = q - normal * (q - point).dot(normal); + Plane::from_normal(o, normal).ok_or_else(|| bad("degenerate face")) + } + _ => Err(bad("the face is not planar")), + } + } + _ => Err(bad("pick a plane or planar face")), + } +} + +/// The centre of a round edge, sketch circle, sphere or torus. +fn centre(ctx: &Ctx, g: &GeoRef) -> Result { + match g { + GeoRef::Edge { at, name } => { + let pts = edge_points(ctx.st, *at, name)?; + as_circle(&pts).map(|(c, _, _)| c).ok_or_else(|| bad("the edge is not circular")) + } + GeoRef::SketchCurve { sketch, curve } => { + let pts = sketch_curve_points(ctx, *sketch, curve)?; + as_circle(&pts).map(|(c, _, _)| c).ok_or_else(|| bad(format!("sketch curve `{curve}` is not a circle or arc"))) + } + GeoRef::Face { at, name } => { + let (b, fi, _) = face_of(ctx.st, *at, name)?; + match kernel::face_surface(&b.body, fi) { + Some(FaceSurface::Sphere { center, .. }) | Some(FaceSurface::Torus { center, .. }) => Ok(center), + _ => Err(bad("the face is not a sphere or torus")), + } + } + _ => Err(bad("pick a circular edge, a sphere or a torus")), + } +} + +/// Point and unit tangent at fraction `t` along a polyline. +fn along(pts: &[Vec3], t: f64) -> Option<(Vec3, Vec3)> { + let total: f64 = pts.windows(2).map(|w| (w[1] - w[0]).len()).sum(); + if !(total > 0.0) { + return None; + } + let mut left = t.clamp(0.0, 1.0) * total; + for w in pts.windows(2) { + let l = (w[1] - w[0]).len(); + if l <= 0.0 { + continue; + } + if left <= l { + return Some((w[0] + (w[1] - w[0]) * (left / l), (w[1] - w[0]) * (1.0 / l))); + } + left -= l; + } + let (a, b) = (pts.get(pts.len() - 2)?, pts.last()?); + Some((*b, (*b - *a).normalized()?)) +} + +fn path_points(ctx: &Ctx, g: &GeoRef) -> Result> { + match g { + GeoRef::Edge { at, name } => edge_points(ctx.st, *at, name), + GeoRef::SketchCurve { sketch, curve } => sketch_curve_points(ctx, *sketch, curve), + _ => Err(bad("pick an edge or sketch curve as the path")), + } +} + +/// The surface's unit normal at (the point of the surface nearest) `p`, and that point. +fn surface_normal(s: FaceSurface, p: Vec3) -> Option<(Vec3, Vec3)> { + match s { + FaceSurface::Plane { normal, point } => Some((p - normal * (p - point).dot(normal), normal)), + FaceSurface::Cylinder { origin, axis, radius } => { + let w = p - origin; + let r = (w - axis * w.dot(axis)).normalized()?; + Some((origin + axis * w.dot(axis) + r * radius, r)) + } + FaceSurface::Sphere { center, radius } => { + let r = (p - center).normalized()?; + Some((center + r * radius, r)) + } + FaceSurface::Cone { apex, axis, half_angle } => { + let w = p - apex; + let radial = (w - axis * w.dot(axis)).normalized()?; + // The generator through p's side, and the normal square to it in the axial plane. + let line = axis * half_angle.cos() + radial * half_angle.sin(); + let n = (radial * half_angle.cos() - axis * half_angle.sin()).normalized()?; + Some((apex + line * w.dot(line), n)) + } + FaceSurface::Torus { center, axis, major, minor } => { + let w = p - center; + let radial = (w - axis * w.dot(axis)).normalized()?; + let tube = center + radial * major; + let n = (p - tube).normalized()?; + Some((tube + n * minor, n)) + } + } +} + +fn face_surface_at(ctx: &Ctx, g: &GeoRef) -> Result<(FaceSurface, Vec3)> { + match g { + GeoRef::Face { at, name } => { + let (b, fi, q) = face_of(ctx.st, *at, name)?; + kernel::face_surface(&b.body, fi).map(|s| (s, q)).ok_or_else(|| bad("the face's surface is not a plane, cylinder, cone, sphere or torus")) + } + GeoRef::Plane { .. } => { + let pl = plane(ctx, g)?; + Ok((FaceSurface::Plane { normal: pl.normal(), point: pl.origin }, pl.origin)) + } + _ => Err(bad("pick a face")), + } +} + +/// Where two planes meet. +fn plane_plane(a: &Plane, b: &Plane) -> Option<(Vec3, Vec3)> { + let (n1, n2) = (a.normal(), b.normal()); + let d = n1.cross(n2); + let l2 = d.len2(); + if l2 < 1e-18 { + return None; + } + let (h1, h2) = (n1.dot(a.origin), n2.dot(b.origin)); + let o = (n2.cross(d) * h1 + d.cross(n1) * h2) * (1.0 / l2); + Some((o, d.normalized()?)) +} + +/// Closest points of two lines: their midpoint (where they meet, if they do). +fn line_line(a: (Vec3, Vec3), b: (Vec3, Vec3)) -> Option { + let ((p, u), (q, v)) = (a, b); + let w = p - q; + let (uu, uv, vv, uw, vw) = (u.dot(u), u.dot(v), v.dot(v), u.dot(w), v.dot(w)); + let den = uu * vv - uv * uv; + if den.abs() < 1e-12 { + return None; + } + let s = (uv * vw - vv * uw) / den; + let t = (uu * vw - uv * uw) / den; + Some(((p + u * s) + (q + v * t)) * 0.5) +} + +pub(crate) fn resolve_axis(ctx: &Ctx, d: &AxisDef) -> Result<(Vec3, Vec3)> { + match d { + AxisDef::ThroughFace { face } => line(ctx, face), + AxisDef::Edge { edge } => { + let (o, dir) = line(ctx, edge)?; + if let GeoRef::Edge { at, name } = edge + && as_line(&edge_points(ctx.st, *at, name)?).is_none() + { + return Err(bad("the edge is not straight")); + } + Ok((o, dir)) + } + AxisDef::NormalToFace { face, point: p } => { + let (s, _) = face_surface_at(ctx, face)?; + let q = point(ctx, p)?; + let (on, n) = surface_normal(s, q).ok_or_else(|| bad("the point is on the face's axis"))?; + Ok((on, n)) + } + AxisDef::TwoPlanes { a, b } => plane_plane(&plane(ctx, a)?, &plane(ctx, b)?).ok_or_else(|| bad("the planes are parallel")), + AxisDef::TwoPoints { a, b } => { + let (p, q) = (point(ctx, a)?, point(ctx, b)?); + Ok((p, (q - p).normalized().ok_or_else(|| bad("the two points coincide"))?)) + } + } +} + +pub(crate) fn resolve_point(ctx: &Ctx, d: &PointDef) -> Result { + match d { + PointDef::At { point: p } => point(ctx, p), + PointDef::Center { of } => centre(ctx, of), + PointDef::TwoEdges { a, b } => line_line(line(ctx, a)?, line(ctx, b)?).ok_or_else(|| bad("the edges are parallel")), + PointDef::ThreePlanes { a, b, c } => { + let (pa, pb, pc) = (plane(ctx, a)?, plane(ctx, b)?, plane(ctx, c)?); + let (o, dir) = plane_plane(&pa, &pb).ok_or_else(|| bad("two of the planes are parallel"))?; + line_plane(o, dir, &pc).ok_or_else(|| bad("the planes don't meet in a point")) + } + PointDef::EdgeAndPlane { edge, plane: p } => { + let (o, dir) = line(ctx, edge)?; + line_plane(o, dir, &plane(ctx, p)?).ok_or_else(|| bad("the edge is parallel to the plane")) + } + PointDef::AlongPath { path, t } => { + let t = Document::eval_in(ctx.vals, t, Kind::Unitless)?; + along(&path_points(ctx, path)?, t).map(|(p, _)| p).ok_or_else(|| bad("the path has no length")) + } + } +} + +fn line_plane(o: Vec3, d: Vec3, p: &Plane) -> Option { + let n = p.normal(); + let den = d.dot(n); + if den.abs() < 1e-12 { + return None; + } + Some(o + d * ((p.origin - o).dot(n) / den)) +} + +/// Planes built from model geometry (the `PlaneRef` kinds that need the model). +pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result { + let degenerate = || bad("degenerate plane"); + match p { + PlaneRef::Midplane { a, b } => { + let (pa, pb) = (plane(ctx, a)?, plane(ctx, b)?); + let (na, nb) = (pa.normal(), pb.normal()); + if na.cross(nb).len() < 1e-9 { + // Parallel: halfway between, facing as the first. + let half = nb * ((pb.origin - pa.origin).dot(nb) * 0.5); + return Plane::new(pa.origin + half, pa.x, pa.y).ok_or_else(degenerate); + } + // Meeting at an angle: the plane through their line that halves the angle. + let (o, dir) = plane_plane(&pa, &pb).ok_or_else(degenerate)?; + let n = (na - nb).normalized().ok_or_else(degenerate)?; + Plane::new(o, dir, n.cross(dir)).ok_or_else(degenerate) + } + PlaneRef::TwoEdges { a, b } => { + let ((o1, d1), (o2, d2)) = (line(ctx, a)?, line(ctx, b)?); + let n = match d1.cross(d2).normalized() { + Some(n) if (o2 - o1).dot(n).abs() <= 1e-6 * (1.0 + (o2 - o1).len()) => n, + Some(_) => return Err(bad("the edges are not in one plane")), + None => d1.cross(o2 - o1).normalized().ok_or_else(|| bad("the edges are on one line"))?, + }; + Plane::new(o1, d1, n.cross(d1)).ok_or_else(degenerate) + } + PlaneRef::ThreePoints { a, b, c } => { + let (pa, pb, pc) = (point(ctx, a)?, point(ctx, b)?, point(ctx, c)?); + if (pb - pa).cross(pc - pa).len() < 1e-9 { + return Err(bad("the three points are on one line")); + } + Plane::new(pa, pb - pa, pc - pa).ok_or_else(degenerate) + } + PlaneRef::Tangent { face, at } => { + let (s, _) = face_surface_at(ctx, face)?; + let (on, n) = surface_normal(s, *at).ok_or_else(|| bad("the point is on the face's axis"))?; + Plane::from_normal(on, n).ok_or_else(degenerate) + } + PlaneRef::AlongPath { path, t } => { + let t = Document::eval_in(ctx.vals, t, Kind::Unitless)?; + let (p, tan) = along(&path_points(ctx, path)?, t).ok_or_else(|| bad("the path has no length"))?; + Plane::from_normal(p, tan).ok_or_else(degenerate) + } + _ => ctx.doc.resolve_plane_in(ctx.vals, p, 0, Some(ctx.st)), + } +} + +impl GeoRef { + /// Map the reference's world points (component frames). + pub fn map_points(&mut self, pt: &mut dyn FnMut(&mut Vec3), plane: &mut dyn FnMut(&mut PlaneRef)) { + match self { + GeoRef::Point { p } => pt(p), + GeoRef::Vertex { at } | GeoRef::Edge { at, .. } | GeoRef::Face { at, .. } => pt(at), + GeoRef::Plane { plane: p } => plane(p), + _ => {} + } + } + + /// Picked faces and edges get their persistent names (and only those still missing one). + pub fn name_picks(&mut self, st: &ModelState) { + match self { + GeoRef::Face { at, name: name @ None } => { + *name = crate::naming::face_names_at(st, &[*at]).into_iter().next().filter(|n| !n.is_empty()); + } + GeoRef::Edge { at, name: name @ None } => { + for b in bodies(st) { + let tol = (b.body.size() * 1e-3).max(1e-3); + if b.body.nearest_edge(*at, tol).ok().flatten().is_some_and(|(_, d)| d <= tol * 10.0) { + *name = crate::naming::edge_names_at(b, &[*at]).into_iter().next().filter(|n| !n.is_empty()); + break; + } + } + } + GeoRef::Plane { plane } => plane.name_picks(st), + _ => {} + } + } +} + +impl GeoRef { + fn plane_ref(&self) -> Option<&PlaneRef> { + match self { + GeoRef::Plane { plane } => Some(plane), + _ => None, + } + } +} + +impl AxisDef { + pub fn refs(&self) -> Vec<&GeoRef> { + match self { + AxisDef::ThroughFace { face } => vec![face], + AxisDef::Edge { edge } => vec![edge], + AxisDef::NormalToFace { face, point } => vec![face, point], + AxisDef::TwoPlanes { a, b } | AxisDef::TwoPoints { a, b } => vec![a, b], + } + } + + /// Planes it refers to (whose expressions it depends on). + pub fn planes(&self) -> Vec<&PlaneRef> { + self.refs().into_iter().filter_map(GeoRef::plane_ref).collect() + } + + pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> { + match self { + AxisDef::ThroughFace { face } => vec![face], + AxisDef::Edge { edge } => vec![edge], + AxisDef::NormalToFace { face, point } => vec![face, point], + AxisDef::TwoPlanes { a, b } | AxisDef::TwoPoints { a, b } => vec![a, b], + } + } +} + +impl PointDef { + pub fn refs(&self) -> Vec<&GeoRef> { + match self { + PointDef::At { point } => vec![point], + PointDef::Center { of } => vec![of], + PointDef::TwoEdges { a, b } => vec![a, b], + PointDef::ThreePlanes { a, b, c } => vec![a, b, c], + PointDef::EdgeAndPlane { edge, plane } => vec![edge, plane], + PointDef::AlongPath { path, .. } => vec![path], + } + } + + pub fn planes(&self) -> Vec<&PlaneRef> { + self.refs().into_iter().filter_map(GeoRef::plane_ref).collect() + } + + pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> { + match self { + PointDef::At { point } => vec![point], + PointDef::Center { of } => vec![of], + PointDef::TwoEdges { a, b } => vec![a, b], + PointDef::ThreePlanes { a, b, c } => vec![a, b, c], + PointDef::EdgeAndPlane { edge, plane } => vec![edge, plane], + PointDef::AlongPath { path, .. } => vec![path], + } + } +} + +impl PlaneRef { + /// The references of a plane built from model geometry. + pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> { + match self { + PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => vec![a.as_mut(), b.as_mut()], + PlaneRef::ThreePoints { a, b, c } => vec![a.as_mut(), b.as_mut(), c.as_mut()], + PlaneRef::Tangent { face, .. } => vec![face.as_mut()], + PlaneRef::AlongPath { path, .. } => vec![path.as_mut()], + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => base.refs_mut(), + _ => Vec::new(), + } + } + + pub fn name_picks(&mut self, st: &ModelState) { + for r in self.refs_mut() { + r.name_picks(st); + } + } + + /// Does resolving it need the model (edges, faces, construction axes and points)? + pub fn needs_model(&self) -> bool { + let mut k = self.clone(); + !k.refs_mut().is_empty() + } +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index b173ab00..14ed1687 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use solvecraft_geom::{Plane, Vec2, Vec3}; use solvecraft_sketch::Sketch; +use crate::construct::{AxisDef, GeoRef, PointDef}; use crate::expr::{self, Kind}; use crate::{DocError, Result}; @@ -43,6 +44,16 @@ pub enum PlaneRef { AtAngle { base: Box, axis_origin: Vec3, axis_dir: Vec3, angle: String }, /// A construction plane feature (by name). Construction { name: String }, + /// Halfway between two planes (or planar faces); bisecting them when they meet at an angle. + Midplane { a: Box, b: Box }, + /// Through two straight edges (lines) that lie in one plane. + TwoEdges { a: Box, b: Box }, + /// Through three points. + ThreePoints { a: Box, b: Box, c: Box }, + /// Tangent to a face (cylinder, cone, sphere, torus) where the point `at` lies over it. + Tangent { face: Box, at: Vec3 }, + /// Square to an edge or sketch curve, a fraction `t` (expression, 0…1) of the way along it. + AlongPath { path: Box, t: String }, /// A planar body face: found again on each evaluation (the face with this plane's normal /// nearest `at`), so sketches on it follow when earlier features change; `plane` is the /// plane as picked (its frame, and the fallback when the face is gone). `name` is the face's @@ -355,6 +366,14 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Option::is_none")] tool: Option, }, + /// A construction axis (Fusion's CONSTRUCT axes). + ConstructionAxis { + def: AxisDef, + }, + /// A construction point (Fusion's CONSTRUCT points). + ConstructionPoint { + def: PointDef, + }, /// Split faces (at the given points) where a tool meets them, the solid unchanged: a plane, /// a face of a body (`tool`, its surface extended) or a sketch's curves (`sketch`, swept /// along its normal). The pieces are named after the face (`#k`). @@ -896,6 +915,10 @@ fn plane_exprs<'a>(p: &'a PlaneRef, v: &mut Vec<&'a str>) { v.push(angle); p = base; } + PlaneRef::AlongPath { t, .. } => { + v.push(t); + break; + } _ => break, } } @@ -950,6 +973,8 @@ impl FeatureKind { FeatureKind::Draft { .. } => "DraftFeature", FeatureKind::Split { .. } => "SplitBodyFeature", FeatureKind::SplitFace { .. } => "SplitFaceFeature", + FeatureKind::ConstructionAxis { .. } => "ConstructionAxis", + FeatureKind::ConstructionPoint { .. } => "ConstructionPoint", FeatureKind::Move { .. } => "MoveFeature", FeatureKind::Scale { .. } => "ScaleFeature", FeatureKind::OffsetFace { .. } => "OffsetFacesFeature", @@ -1012,6 +1037,8 @@ impl FeatureKind { FeatureKind::Draft { .. } => "Draft", FeatureKind::Split { .. } => "Split", FeatureKind::SplitFace { .. } => "SplitFace", + FeatureKind::ConstructionAxis { .. } => "Axis", + FeatureKind::ConstructionPoint { .. } => "Point", FeatureKind::Move { .. } => "Move", FeatureKind::Scale { .. } => "Scale", FeatureKind::OffsetFace { .. } => "OffsetFace", @@ -1076,6 +1103,13 @@ impl FeatureKind { pub fn expressions(&self) -> Vec<&str> { let mut v: Vec<&str> = Vec::new(); match self { + FeatureKind::ConstructionAxis { def } => def.planes().into_iter().for_each(|p| plane_exprs(p, &mut v)), + FeatureKind::ConstructionPoint { def } => { + if let PointDef::AlongPath { t, .. } = def { + v.push(t); + } + def.planes().into_iter().for_each(|p| plane_exprs(p, &mut v)); + } FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Split { plane, .. } @@ -1712,6 +1746,18 @@ impl Document { /// Resolve a plane reference. pub fn resolve_plane(&self, vals: &std::collections::BTreeMap, p: &PlaneRef, depth: usize) -> Result { + self.resolve_plane_in(vals, p, depth, None) + } + + /// Resolve a plane; planes built from model geometry (edges, faces, construction axes and + /// points) need the model state `st`, and construction planes are read from it when given. + pub fn resolve_plane_in( + &self, + vals: &std::collections::BTreeMap, + p: &PlaneRef, + depth: usize, + st: Option<&crate::ModelState>, + ) -> Result { if depth > 32 { return Err(DocError::Invalid("plane offsets nested too deeply".into())); } @@ -1721,21 +1767,37 @@ impl Document { Plane::new(plane.origin, plane.x, plane.y).ok_or_else(|| DocError::Invalid("degenerate plane".into())) } PlaneRef::Offset { base, distance } => { - let b = self.resolve_plane(vals, base, depth + 1)?; + let b = self.resolve_plane_in(vals, base, depth + 1, st)?; Ok(b.offset(Self::eval_in(vals, distance, Kind::Length)?)) } PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => { - let b = self.resolve_plane(vals, base, depth + 1)?; + let b = self.resolve_plane_in(vals, base, depth + 1, st)?; let a = Self::eval_in(vals, angle, Kind::Angle)?; let d = axis_dir.normalized().ok_or_else(|| DocError::Invalid("rotation axis".into()))?; let rot = |v: Vec3| v * a.cos() + d.cross(v) * a.sin() + d * (d.dot(v) * (1.0 - a.cos())); let o = *axis_origin + rot(b.origin - *axis_origin); Plane::new(o, rot(b.x), rot(b.y)).ok_or_else(|| DocError::Invalid("degenerate plane".into())) } - PlaneRef::Construction { name } => match self.find_feature(name).map(|f| &f.kind) { - Some(FeatureKind::ConstructionPlane { plane }) => self.resolve_plane(vals, plane, depth + 1), - _ => Err(DocError::Unknown(format!("construction plane `{name}`"))), - }, + PlaneRef::Construction { name } => { + // Evaluated already: as it came out. + if let Some(c) = st.and_then(|st| st.construct.iter().find(|c| &c.name == name)) + && let crate::construct::ConstructGeom::Plane(pl) = &c.geom + { + return Ok(*pl); + } + match self.find_feature(name).map(|f| &f.kind) { + Some(FeatureKind::ConstructionPlane { plane }) => self.resolve_plane_in(vals, plane, depth + 1, st), + _ => Err(DocError::Unknown(format!("construction plane `{name}`"))), + } + } + PlaneRef::Midplane { .. } + | PlaneRef::TwoEdges { .. } + | PlaneRef::ThreePoints { .. } + | PlaneRef::Tangent { .. } + | PlaneRef::AlongPath { .. } => { + let st = st.ok_or_else(|| DocError::Invalid("this plane is built from the model: evaluate first".into()))?; + crate::construct::resolve_geo_plane(&crate::construct::Ctx { doc: self, vals, st }, p) + } } } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 3c40c5fd..ed61e36f 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -239,6 +239,8 @@ pub struct ModelState { pub body_counter: usize, /// Sheet metal bodies (by body name) and their flat patterns. pub sheets: Vec, + /// Construction planes, axes and points as evaluated (by feature). + pub construct: Vec, /// Evaluation-cache keys of the sketches (by sketch feature id). sketch_keys: BTreeMap, } @@ -410,7 +412,9 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_ba /// metal and plastic rules (and the parameters they use), components and body placement. fn context_digest(doc: &Document, vals: &BTreeMap) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); - for f in doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })) { + for f in doc.features.iter().filter(|f| { + matches!(f.kind, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. }) + }) { fingerprint(0, f, vals, false).hash(&mut h); } doc.units.hash(&mut h); @@ -620,7 +624,7 @@ impl Model { let mats = match &f.kind { FeatureKind::Pattern { pattern, .. } => pattern_transforms(&vals, &state, pattern).unwrap_or_default(), FeatureKind::Mirror { plane, .. } => { - doc.resolve_plane(&vals, plane, 0).map(|pl| vec![mirror_matrix(&pl)]).unwrap_or_default() + doc.resolve_plane_in(&vals, plane, 0, Some(&state)).map(|pl| vec![mirror_matrix(&pl)]).unwrap_or_default() } _ => Vec::new(), }; @@ -2255,7 +2259,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: FeatureKind::Sketch { plane, sketch } => { let (plane, face_warning) = match plane { PlaneRef::Face { plane: picked, at, name } => face_plane(st, picked, *at, name.as_deref()), - other => (doc.resolve_plane(vals, other, 0)?, None), + other => (doc.resolve_plane_in(vals, other, 0, Some(st))?, None), }; let mut sk = sketch.clone(); let mut warns = crate::project::refresh_links(doc, vals, st, &plane, &mut sk); @@ -2371,7 +2375,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } FeatureKind::Draft { faces, angle, neutral, pull, body } => { let a = val(vals, angle, Kind::Angle)?; - let pl = doc.resolve_plane(vals, neutral, 0)?; + let pl = doc.resolve_plane_in(vals, neutral, 0, Some(st))?; let i = body_at(st, body, faces)?; let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) }; let nb = kernel::draft(&mb.body, faces, &pl, *pull, a)?; @@ -2381,7 +2385,22 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: Ok(()) } FeatureKind::ConstructionPlane { plane } => { - doc.resolve_plane(vals, plane, 0)?; + let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?; + st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Plane(pl) }); + Ok(()) + } + FeatureKind::ConstructionAxis { def } => { + let (origin, dir) = crate::construct::resolve_axis(&crate::construct::Ctx { doc, vals, st }, def)?; + st.construct.push(crate::construct::Construct { + feature: f.id, + name: f.name.clone(), + geom: crate::construct::ConstructGeom::Axis { origin, dir }, + }); + Ok(()) + } + FeatureKind::ConstructionPoint { def } => { + let p = crate::construct::resolve_point(&crate::construct::Ctx { doc, vals, st }, def)?; + st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Point(p) }); Ok(()) } FeatureKind::BoundaryFill { tools, cells, operation, remove_tools } => { @@ -2445,7 +2464,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: kernel::split_faces(&mb.body, &picked, &kernel::SplitTool::Curves { plane: ss.plane, curves })? } (None, None) => { - let pl = doc.resolve_plane(vals, plane, 0)?; + let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?; kernel::split_faces(&mb.body, &picked, &kernel::SplitTool::Plane(pl))? } }; @@ -2466,7 +2485,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: kernel::split_body(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face })? } None => { - let pl = doc.resolve_plane(vals, plane, 0)?; + let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?; kernel::split_by_plane(&mb.body, &pl)? } }; @@ -2503,7 +2522,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: Ok(()) } FeatureKind::Mirror { features, plane, bodies, combine } => { - let pl = doc.resolve_plane(vals, plane, 0)?; + let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?; if bodies.is_empty() { return replay(doc, vals, f, st, features, &[mirror_matrix(&pl)]); } diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 1eb92373..320ccbe0 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -45,7 +45,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, apply_op(st, f, tools, Operation::Join, &[]) } FeatureKind::ReplaceFace { faces, target, body } => { - let tp = doc.resolve_plane(vals, target, 0)?; + let tp = doc.resolve_plane_in(vals, target, 0, Some(st))?; let tn = tp.normal(); let i = body_at(st, body, faces)?; let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) }; diff --git a/crates/doc/src/eval_surface.rs b/crates/doc/src/eval_surface.rs index aef3d1ba..1010c9e6 100644 --- a/crates/doc/src/eval_surface.rs +++ b/crates/doc/src/eval_surface.rs @@ -67,7 +67,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, .ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?; kernel::trim(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face }, *keep)? } - None => kernel::trim(&mb.body, &kernel::SplitTool::Plane(doc.resolve_plane(vals, plane, 0)?), *keep)?, + None => kernel::trim(&mb.body, &kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?), *keep)?, }; if let Some(slot) = st.bodies.get_mut(i) { *slot = ModelBody::new(mb.name, trimmed, mb.feature); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 067f6a65..10856e30 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -10,6 +10,7 @@ mod assembly; pub mod canvas; mod clipboard; pub mod config; +pub mod construct; mod document; mod eval; pub mod expr; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index a1763874..c2f0a2f3 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -21,6 +21,7 @@ fn plane_inputs_mut<'a>(p: &'a mut PlaneRef, v: &mut Vec<(&'static str, &'a mut v.push(("Angle", angle, Kind::Angle)); plane_inputs_mut(base, v); } + PlaneRef::AlongPath { t, .. } => v.push(("Distance (0 to 1)", t, Kind::Unitless)), _ => {} } } @@ -36,6 +37,23 @@ impl FeatureKind { | FeatureKind::Split { plane, .. } | FeatureKind::SplitFace { plane, .. } | FeatureKind::Mirror { plane, .. } => plane_inputs_mut(plane, &mut v), + FeatureKind::ConstructionAxis { def } => { + for r in def.refs_mut() { + if let crate::construct::GeoRef::Plane { plane } = r { + plane_inputs_mut(plane, &mut v); + } + } + } + FeatureKind::ConstructionPoint { def } => match def { + crate::construct::PointDef::AlongPath { t, .. } => v.push(("Distance (0 to 1)", t, U)), + other => { + for r in other.refs_mut() { + if let crate::construct::GeoRef::Plane { plane } = r { + plane_inputs_mut(plane, &mut v); + } + } + } + }, FeatureKind::Extrude { extent, .. } => { v.push(("Distance", &mut extent.distance, L)); if let Some(d) = &mut extent.distance2 { diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index be0c36de..d61a79fd 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -257,7 +257,7 @@ fn resolve_uncached( LinkSource::Plane { name } => { let pr = if Plane::named(name).is_some() { PlaneRef::Origin { name: name.clone() } } else { PlaneRef::Construction { name: name.clone() } }; - let other = doc.resolve_plane(vals, &pr, 0).map_err(|_| lost(&format!("plane {name}")))?; + let other = doc.resolve_plane_in(vals, &pr, 0, Some(st)).map_err(|_| lost(&format!("plane {name}")))?; plane_trace(plane, &other).into_iter().collect() } }; diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 30642cb6..86ab2922 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -358,7 +358,7 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result { } let plane = super::sketch::plane_ref(s, p, cmd)?; let (vals, _) = s.doc.param_values(); - s.doc.resolve_plane(&vals, &plane, 0)?; + s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?; if let Some(FeatureKind::Sketch { plane: slot, .. }) = s.doc_mut().feature_mut(id).map(|f| &mut f.kind) { *slot = plane; } diff --git a/crates/engine/src/cmd/construct.rs b/crates/engine/src/cmd/construct.rs new file mode 100644 index 00000000..d9b3611d --- /dev/null +++ b/crates/engine/src/cmd/construct.rs @@ -0,0 +1,251 @@ +//! SOLID ▸ CONSTRUCT: construction axes, points and the planes built from model geometry. +//! +//! References are given as `{face: [x,y,z]}`, `{edge: [x,y,z]}`, `{vertex: [x,y,z]}`, +//! `{point: [x,y,z]}`, `{plane: XY|name|{origin, normal}}`, `{axis: X|Y|Z|name}`, +//! `{construct_point: name}`, `{sketch, point}` or `{sketch, curve}`, or as a tagged +//! `GeoRef` (`{"type": "edge", "at": …}`). Picked faces and edges are stored with their +//! persistent names. + +use serde_json::Value; +use solvecraft_doc::construct::{AxisDef, GeoRef, PointDef}; +use solvecraft_doc::{FeatureKind, PlaneRef}; + +use super::CommandSpec; +use super::features::{add_feature, check_expr, plane_param, sketch_id}; +use crate::params::{bad, req_expr, str_, vec3}; +use crate::{Result, Session}; + +const REF: &str = + "{face|edge|vertex|point: [x,y,z]} | {plane: XY|name|{origin, normal}} | {axis: X|Y|Z|name} | {construct_point: name} | {sketch, point|curve}"; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("construct.axis.through_cylinder", "Axis Through Cylinder/Cone/Torus", axis_face) + .at("SOLID", "CONSTRUCT") + .icon("axis") + .params("face: ref (a cylinder, cone or torus face, or a circular edge); name?"), + CommandSpec::new("construct.axis.normal_to_face", "Axis Perpendicular To Face", axis_normal) + .at("SOLID", "CONSTRUCT") + .icon("axis") + .params("face: ref; point: ref (the axis goes through it, square to the face); name?"), + CommandSpec::new("construct.axis.two_planes", "Axis Through Two Planes", axis_planes) + .at("SOLID", "CONSTRUCT") + .icon("axis") + .params("a, b: plane or planar face refs; name?"), + CommandSpec::new("construct.axis.two_points", "Axis Through Two Points", axis_points) + .at("SOLID", "CONSTRUCT") + .icon("axis") + .params("a, b: point refs (vertices, sketch points, points); name?"), + CommandSpec::new("construct.axis.edge", "Axis Through Edge", axis_edge) + .at("SOLID", "CONSTRUCT") + .icon("axis") + .params("edge: ref (a straight edge or sketch line); name?"), + CommandSpec::new("construct.point.vertex", "Point At Vertex", point_at) + .at("SOLID", "CONSTRUCT") + .icon("point") + .params("point: ref (vertex, sketch point, point); name?"), + CommandSpec::new("construct.point.two_edges", "Point Through Two Edges", point_edges) + .at("SOLID", "CONSTRUCT") + .icon("point") + .params("a, b: edge or line refs (where they meet, or come closest); name?"), + CommandSpec::new("construct.point.three_planes", "Point Through Three Planes", point_planes) + .at("SOLID", "CONSTRUCT") + .icon("point") + .params("a, b, c: plane or planar face refs; name?"), + CommandSpec::new("construct.point.center", "Point At Center Of Circle/Sphere/Torus", point_center) + .at("SOLID", "CONSTRUCT") + .icon("point") + .params("of: ref (circular edge, sketch circle or arc, sphere or torus face); name?"), + CommandSpec::new("construct.point.edge_plane", "Point At Edge And Plane", point_edge_plane) + .at("SOLID", "CONSTRUCT") + .icon("point") + .params("edge: edge or line ref; plane: plane or planar face ref; name?"), + CommandSpec::new("construct.point.along_path", "Point Along Path", point_along) + .at("SOLID", "CONSTRUCT") + .icon("point") + .params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name?"), + CommandSpec::new("construct.plane.midplane", "Midplane", plane_mid) + .at("SOLID", "CONSTRUCT") + .icon("plane") + .params("a, b: plane or planar face refs (halfway between; bisecting when they meet at an angle); name?"), + CommandSpec::new("construct.plane.two_edges", "Plane Through Two Edges", plane_edges) + .at("SOLID", "CONSTRUCT") + .icon("plane") + .params("a, b: straight edge or line refs in one plane; name?"), + CommandSpec::new("construct.plane.three_points", "Plane Through Three Points", plane_points) + .at("SOLID", "CONSTRUCT") + .icon("plane") + .params("a, b, c: point refs; name?"), + CommandSpec::new("construct.plane.tangent", "Tangent Plane", plane_tangent) + .at("SOLID", "CONSTRUCT") + .icon("plane") + .params("face: ref (cylinder, cone, sphere or torus); at?: [x,y,z] where it touches (default: the face pick); name?"), + CommandSpec::new("construct.plane.along_path", "Plane Along Path", plane_along) + .at("SOLID", "CONSTRUCT") + .icon("plane") + .params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name? The plane is square to the path there"), +]; + +/// A reference from its JSON form. +fn geo(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result { + let v = v.ok_or_else(|| bad(cmd, format!("`{what}` is required: {REF}")))?; + let p3 = |k: &str| v.get(k).and_then(vec3); + let mut g = if v.get("type").is_some() { + serde_json::from_value::(v.clone()).map_err(|e| bad(cmd, format!("`{what}`: {e}")))? + } else if let Some(at) = p3("face") { + GeoRef::Face { at, name: None } + } else if let Some(at) = p3("edge") { + GeoRef::Edge { at, name: None } + } else if let Some(at) = p3("vertex") { + GeoRef::Vertex { at } + } else if let Some(sk) = v.get("sketch") { + let sketch = sketch_id(s, Some(sk), cmd, what)?; + match (str_(v, "point"), str_(v, "curve")) { + (Some(point), _) => GeoRef::SketchPoint { sketch, point: point.to_string() }, + (None, Some(curve)) => GeoRef::SketchCurve { sketch, curve: curve.to_string() }, + _ => return Err(bad(cmd, format!("`{what}`: a sketch reference needs `point` or `curve`"))), + } + } else if let Some(p) = p3("point") { + GeoRef::Point { p } + } else if v.get("plane").is_some() { + GeoRef::Plane { plane: plane_param(s, v.get("plane"), cmd)? } + } else if let Some(a) = str_(v, "axis") { + if ["x", "y", "z"].contains(&a.to_ascii_lowercase().as_str()) { + GeoRef::World { axis: a.to_ascii_uppercase() } + } else { + match s.doc.find_feature(a).map(|f| &f.kind) { + Some(FeatureKind::ConstructionAxis { .. }) => GeoRef::Axis { name: a.to_string() }, + _ => return Err(bad(cmd, format!("no axis `{a}`"))), + } + } + } else if let Some(n) = str_(v, "construct_point") { + match s.doc.find_feature(n).map(|f| &f.kind) { + Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.to_string() }, + _ => return Err(bad(cmd, format!("no construction point `{n}`"))), + } + } else if v.as_str().is_some() { + // A bare string: a plane. + GeoRef::Plane { plane: plane_param(s, Some(v), cmd)? } + } else { + return Err(bad(cmd, format!("`{what}` must be {REF}"))); + }; + g.name_picks(&s.model.state()); + Ok(g) +} + +fn t_param(s: &Session, p: &Value, cmd: &str) -> Result { + let t = if p.get("t").is_some() { req_expr(cmd, p, "t")? } else { "0.5".to_string() }; + check_expr(s, &t, solvecraft_doc::expr::Kind::Unitless, cmd, "t")?; + Ok(t) +} + +fn axis(s: &mut Session, p: &Value, def: AxisDef) -> Result { + add_feature(s, p, FeatureKind::ConstructionAxis { def }) +} + +fn point(s: &mut Session, p: &Value, def: PointDef) -> Result { + add_feature(s, p, FeatureKind::ConstructionPoint { def }) +} + +fn plane(s: &mut Session, p: &Value, plane: PlaneRef) -> Result { + add_feature(s, p, FeatureKind::ConstructionPlane { plane }) +} + +fn axis_face(s: &mut Session, p: &Value) -> Result { + let face = geo(s, p.get("face"), "construct.axis.through_cylinder", "face")?; + axis(s, p, AxisDef::ThroughFace { face }) +} + +fn axis_normal(s: &mut Session, p: &Value) -> Result { + let cmd = "construct.axis.normal_to_face"; + let face = geo(s, p.get("face"), cmd, "face")?; + let point = geo(s, p.get("point"), cmd, "point")?; + axis(s, p, AxisDef::NormalToFace { face, point }) +} + +fn axis_planes(s: &mut Session, p: &Value) -> Result { + let cmd = "construct.axis.two_planes"; + let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?); + axis(s, p, AxisDef::TwoPlanes { a, b }) +} + +fn axis_points(s: &mut Session, p: &Value) -> Result { + let cmd = "construct.axis.two_points"; + let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?); + axis(s, p, AxisDef::TwoPoints { a, b }) +} + +fn axis_edge(s: &mut Session, p: &Value) -> Result { + let edge = geo(s, p.get("edge"), "construct.axis.edge", "edge")?; + axis(s, p, AxisDef::Edge { edge }) +} + +fn point_at(s: &mut Session, p: &Value) -> Result { + let at = geo(s, p.get("point"), "construct.point.vertex", "point")?; + point(s, p, PointDef::At { point: at }) +} + +fn point_edges(s: &mut Session, p: &Value) -> Result { + let cmd = "construct.point.two_edges"; + let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?); + point(s, p, PointDef::TwoEdges { a, b }) +} + +fn point_planes(s: &mut Session, p: &Value) -> Result { + let cmd = "construct.point.three_planes"; + let (a, b, c) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?, geo(s, p.get("c"), cmd, "c")?); + point(s, p, PointDef::ThreePlanes { a, b, c }) +} + +fn point_center(s: &mut Session, p: &Value) -> Result { + let of = geo(s, p.get("of"), "construct.point.center", "of")?; + point(s, p, PointDef::Center { of }) +} + +fn point_edge_plane(s: &mut Session, p: &Value) -> Result { + let cmd = "construct.point.edge_plane"; + let (edge, pl) = (geo(s, p.get("edge"), cmd, "edge")?, geo(s, p.get("plane"), cmd, "plane")?); + point(s, p, PointDef::EdgeAndPlane { edge, plane: pl }) +} + +fn point_along(s: &mut Session, p: &Value) -> Result { + let cmd = "construct.point.along_path"; + let path = geo(s, p.get("path"), cmd, "path")?; + let t = t_param(s, p, cmd)?; + point(s, p, PointDef::AlongPath { path, t }) +} + +fn plane_mid(s: &mut Session, p: &Value) -> Result { + let cmd = "construct.plane.midplane"; + let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?); + plane(s, p, PlaneRef::Midplane { a: Box::new(a), b: Box::new(b) }) +} + +fn plane_edges(s: &mut Session, p: &Value) -> Result { + let cmd = "construct.plane.two_edges"; + let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?); + plane(s, p, PlaneRef::TwoEdges { a: Box::new(a), b: Box::new(b) }) +} + +fn plane_points(s: &mut Session, p: &Value) -> Result { + let cmd = "construct.plane.three_points"; + let (a, b, c) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?, geo(s, p.get("c"), cmd, "c")?); + plane(s, p, PlaneRef::ThreePoints { a: Box::new(a), b: Box::new(b), c: Box::new(c) }) +} + +fn plane_tangent(s: &mut Session, p: &Value) -> Result { + let cmd = "construct.plane.tangent"; + let face = geo(s, p.get("face"), cmd, "face")?; + let at = match (p.get("at").and_then(vec3), &face) { + (Some(a), _) => a, + (None, GeoRef::Face { at, .. }) => *at, + _ => return Err(bad(cmd, "`at` is required: where the plane touches the face")), + }; + plane(s, p, PlaneRef::Tangent { face: Box::new(face), at }) +} + +fn plane_along(s: &mut Session, p: &Value) -> Result { + let cmd = "construct.plane.along_path"; + let path = geo(s, p.get("path"), cmd, "path")?; + let t = t_param(s, p, cmd)?; + plane(s, p, PlaneRef::AlongPath { path: Box::new(path), t }) +} diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 655b28f2..d21e1d1e 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -797,7 +797,7 @@ fn mirror(s: &mut Session, p: &Value) -> Result { /// A plane reference from a parameter: an origin plane, a construction plane name, or an /// explicit plane `{origin, x_dir, y_dir}` / `{origin, normal}`. -pub fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result { +pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result { use solvecraft_doc::PlaneRef; match v { Some(Value::String(n)) if solvecraft_geom::Plane::named(n).is_some() => Ok(PlaneRef::Origin { name: n.to_ascii_uppercase() }), @@ -1080,7 +1080,7 @@ fn draft(s: &mut Session, p: &Value) -> Result { check_expr(s, &angle, Kind::Angle, cmd, "angle")?; let neutral = plane_param(s, p.get("neutral"), cmd)?; let (vals, _) = s.doc.param_values(); - let pl = s.doc.resolve_plane(&vals, &neutral, 0)?; + let pl = s.doc.resolve_plane_in(&vals, &neutral, 0, Some(&s.model.state()))?; let pull = p.get("pull").and_then(vec3).unwrap_or_else(|| pl.normal()); add_feature(s, p, FeatureKind::Draft { faces, angle, neutral, pull, body: str_(p, "body").map(str::to_string) }) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1dcc59df..3a8d77ff 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -5,6 +5,7 @@ mod browser; mod clipboard; mod component; +mod construct; mod delete; mod edit; mod face; @@ -145,6 +146,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(sketch_project::COMMANDS.iter()); v.extend(features::COMMANDS.iter()); v.extend(features_more::COMMANDS.iter()); + v.extend(construct::COMMANDS.iter()); v.extend(sheet::COMMANDS.iter()); v.extend(plastic::COMMANDS.iter()); v.extend(component::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 5467800d..11ad1a31 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -367,7 +367,7 @@ fn create_sketch(s: &mut Session, p: &Value) -> Result { let plane = plane_ref(s, p, cmd)?; // Validate the plane resolves. let (vals, _) = s.doc.param_values(); - s.doc.resolve_plane(&vals, &plane, 0)?; + s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?; let name = str_(p, "name"); let mut kind = FeatureKind::Sketch { plane, sketch: Sketch::new() }; super::component::to_active_frame(s, &mut kind); diff --git a/crates/engine/src/cmd/sketch_canvas.rs b/crates/engine/src/cmd/sketch_canvas.rs index c3111aa8..5bb84a89 100644 --- a/crates/engine/src/cmd/sketch_canvas.rs +++ b/crates/engine/src/cmd/sketch_canvas.rs @@ -48,7 +48,7 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result { let (format, pixels) = image_info(&bytes).ok_or_else(|| bad(cmd, "only PNG and JPEG images can be inserted"))?; let plane = super::sketch::plane_ref(s, p, cmd)?; let (vals, _) = s.doc.param_values(); - let pl = s.doc.resolve_plane(&vals, &plane, 0)?; + let pl = s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?; // A decal sits where the face was picked. let at = p.get("at").or_else(|| p.get("plane").and_then(|v| v.get("face"))).and_then(crate::params::vec3).map(|q| pl.to_local(q)); let center = p.get("center").and_then(vec2).or(at).unwrap_or(Vec2::ZERO); diff --git a/crates/engine/src/construct_tests.rs b/crates/engine/src/construct_tests.rs new file mode 100644 index 00000000..19621845 --- /dev/null +++ b/crates/engine/src/construct_tests.rs @@ -0,0 +1,259 @@ +//! Construction axes, points and planes (SOLID ▸ CONSTRUCT), one test per type, against a +//! 40 × 30 × 20 box at the origin, a cylinder, a sphere and a torus. + +use serde_json::{Value, json}; +use solvecraft_geom::{Plane, Vec3}; + +use crate::Session; +use crate::view::{construction_axes, construction_planes, construction_points}; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn part() -> Session { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Box"})); + run(&mut s, "solid.cylinder", json!({"base": [60, 0, 0], "radius": 5, "height": 10, "body_name": "Cyl"})); + run(&mut s, "solid.sphere", json!({"center": [0, 0, 60], "radius": 5, "body_name": "Ball"})); + run(&mut s, "solid.torus", json!({"center": [0, 60, 0], "major": 10, "minor": 2, "body_name": "Ring"})); + s +} + +fn near(a: Vec3, b: Vec3) -> bool { + a.dist(b) < 1e-3 +} + +fn parallel(a: Vec3, b: Vec3) -> bool { + a.normalized().zip(b.normalized()).is_some_and(|(a, b)| a.cross(b).len() < 1e-6) +} + +fn axis(s: &Session, name: &str) -> (Vec3, Vec3) { + construction_axes(s).into_iter().find(|a| a.1 == name).map(|a| (a.2, a.3)).unwrap_or_else(|| panic!("no axis {name}")) +} + +fn point(s: &Session, name: &str) -> Vec3 { + construction_points(s).into_iter().find(|p| p.1 == name).map(|p| p.2).unwrap_or_else(|| panic!("no point {name}")) +} + +fn plane(s: &Session, name: &str) -> Plane { + construction_planes(s).into_iter().find(|p| p.1 == name).map(|p| p.2).unwrap_or_else(|| panic!("no plane {name}")) +} + +/// The line through `o` along `d` contains `p`. +fn on_line(o: Vec3, d: Vec3, p: Vec3) -> bool { + let d = d.normalized().unwrap_or(Vec3::Z); + (p - o - d * (p - o).dot(d)).len() < 1e-3 +} + +fn on_plane(pl: &Plane, p: Vec3) -> bool { + (p - pl.origin).dot(pl.normal()).abs() < 1e-3 +} + +#[test] +fn axis_through_cylinder_cone_or_torus() { + let mut s = part(); + run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [65, 0, 5]}, "name": "A"})); + let (o, d) = axis(&s, "A"); + assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(60.0, 0.0, 0.0)), "{o:?} {d:?}"); + run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [12, 60, 0]}, "name": "T"})); + let (o, d) = axis(&s, "T"); + assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(0.0, 60.0, 0.0)), "{o:?} {d:?}"); + // A flat face has no axis. + assert!(s.execute("construct.axis.through_cylinder", &json!({"face": {"face": [20, 15, 20]}})).is_err()); +} + +#[test] +fn axis_perpendicular_to_a_face_at_a_point() { + let mut s = part(); + run(&mut s, "construct.axis.normal_to_face", json!({"face": {"face": [10, 10, 20]}, "point": {"point": [10, 10, 25]}, "name": "N"})); + let (o, d) = axis(&s, "N"); + assert!(near(o, Vec3::new(10.0, 10.0, 20.0)) && parallel(d, Vec3::Z), "{o:?} {d:?}"); + // On the cylinder: radial through the point. + run(&mut s, "construct.axis.normal_to_face", json!({"face": {"face": [65, 0, 5]}, "point": {"point": [70, 0, 5]}, "name": "R"})); + let (o, d) = axis(&s, "R"); + assert!(near(o, Vec3::new(65.0, 0.0, 5.0)) && parallel(d, Vec3::X), "{o:?} {d:?}"); +} + +#[test] +fn axis_through_two_planes() { + let mut s = part(); + run(&mut s, "construct.axis.two_planes", json!({"a": {"plane": "XZ"}, "b": {"face": [40, 15, 10]}, "name": "L"})); + let (o, d) = axis(&s, "L"); + assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(40.0, 0.0, 0.0)), "{o:?} {d:?}"); + assert!(s.execute("construct.axis.two_planes", &json!({"a": "XY", "b": {"face": [20, 15, 20]}})).is_err(), "parallel"); +} + +#[test] +fn axis_through_two_points() { + let mut s = part(); + run(&mut s, "construct.axis.two_points", json!({"a": {"vertex": [0.5, 0.5, 0.5]}, "b": {"vertex": [39, 29, 19]}, "name": "D"})); + let (o, d) = axis(&s, "D"); + assert!(on_line(o, d, Vec3::ZERO) && parallel(d, Vec3::new(40.0, 30.0, 20.0)), "{o:?} {d:?}"); +} + +#[test] +fn axis_through_an_edge() { + let mut s = part(); + run(&mut s, "construct.axis.edge", json!({"edge": {"edge": [20, 0, 0]}, "name": "E"})); + let (o, d) = axis(&s, "E"); + assert!(parallel(d, Vec3::X) && on_line(o, d, Vec3::ZERO), "{o:?} {d:?}"); + // A circular edge is not straight. + assert!(s.execute("construct.axis.edge", &json!({"edge": {"edge": [65, 0, 10]}})).is_err()); +} + +#[test] +fn point_at_a_vertex() { + let mut s = part(); + run(&mut s, "construct.point.vertex", json!({"point": {"vertex": [39, 29, 19]}, "name": "P"})); + assert!(near(point(&s, "P"), Vec3::new(40.0, 30.0, 20.0))); +} + +#[test] +fn point_through_two_edges() { + let mut s = part(); + run(&mut s, "construct.point.two_edges", json!({"a": {"edge": [20, 0, 0]}, "b": {"edge": [40, 15, 0]}, "name": "P"})); + assert!(near(point(&s, "P"), Vec3::new(40.0, 0.0, 0.0)), "{:?}", point(&s, "P")); +} + +#[test] +fn point_through_three_planes() { + let mut s = part(); + run( + &mut s, + "construct.point.three_planes", + json!({"a": {"face": [40, 15, 10]}, "b": {"face": [20, 30, 10]}, "c": {"face": [20, 15, 20]}, "name": "P"}), + ); + assert!(near(point(&s, "P"), Vec3::new(40.0, 30.0, 20.0)), "{:?}", point(&s, "P")); +} + +#[test] +fn point_at_the_centre_of_a_circle_sphere_or_torus() { + let mut s = part(); + run(&mut s, "construct.point.center", json!({"of": {"edge": [65, 0, 10]}, "name": "C"})); + assert!(near(point(&s, "C"), Vec3::new(60.0, 0.0, 10.0)), "{:?}", point(&s, "C")); + run(&mut s, "construct.point.center", json!({"of": {"face": [5, 0, 60]}, "name": "S"})); + assert!(near(point(&s, "S"), Vec3::new(0.0, 0.0, 60.0)), "{:?}", point(&s, "S")); + run(&mut s, "construct.point.center", json!({"of": {"face": [12, 60, 0]}, "name": "T"})); + assert!(near(point(&s, "T"), Vec3::new(0.0, 60.0, 0.0)), "{:?}", point(&s, "T")); +} + +#[test] +fn point_at_edge_and_plane() { + let mut s = part(); + run( + &mut s, + "construct.point.edge_plane", + json!({"edge": {"edge": [20, 0, 0]}, "plane": {"plane": {"origin": [12, 0, 0], "normal": [1, 0, 0]}}, "name": "P"}), + ); + assert!(near(point(&s, "P"), Vec3::new(12.0, 0.0, 0.0)), "{:?}", point(&s, "P")); +} + +#[test] +fn point_along_a_path() { + let mut s = part(); + run(&mut s, "construct.point.along_path", json!({"path": {"edge": [20, 0, 0]}, "t": 0.25, "name": "P"})); + let p = point(&s, "P"); + assert!(near(p, Vec3::new(10.0, 0.0, 0.0)) || near(p, Vec3::new(30.0, 0.0, 0.0)), "{p:?}"); +} + +#[test] +fn midplane_between_parallel_and_meeting_faces() { + let mut s = part(); + run(&mut s, "construct.plane.midplane", json!({"a": "XY", "b": {"face": [20, 15, 20]}, "name": "M"})); + let m = plane(&s, "M"); + assert!(parallel(m.normal(), Vec3::Z) && on_plane(&m, Vec3::new(5.0, 5.0, 10.0)), "{m:?}"); + // Two sides meeting at the corner (40, 30): the diagonal plane through it. + run(&mut s, "construct.plane.midplane", json!({"a": {"face": [40, 15, 10]}, "b": {"face": [20, 30, 10]}, "name": "Q"})); + let q = plane(&s, "Q"); + assert!(on_plane(&q, Vec3::new(40.0, 30.0, 0.0)) && on_plane(&q, Vec3::new(41.0, 31.0, 5.0)), "{q:?}"); +} + +#[test] +fn plane_through_two_edges() { + let mut s = part(); + run(&mut s, "construct.plane.two_edges", json!({"a": {"edge": [20, 0, 0]}, "b": {"edge": [20, 30, 20]}, "name": "E"})); + let e = plane(&s, "E"); + // The front-bottom and back-top edges: the slanted plane through both. + assert!(on_plane(&e, Vec3::new(5.0, 0.0, 0.0)) && on_plane(&e, Vec3::new(5.0, 30.0, 20.0)), "{e:?}"); + assert!(s.execute("construct.plane.two_edges", &json!({"a": {"edge": [20, 0, 0]}, "b": {"edge": [40, 15, 20]}})).is_err(), "skew"); +} + +#[test] +fn plane_through_three_points() { + let mut s = part(); + run( + &mut s, + "construct.plane.three_points", + json!({"a": {"vertex": [0, 0, 20]}, "b": {"vertex": [40, 0, 20]}, "c": {"vertex": [40, 30, 0]}, "name": "T"}), + ); + let t = plane(&s, "T"); + assert!(on_plane(&t, Vec3::new(0.0, 0.0, 20.0)) && on_plane(&t, Vec3::new(40.0, 30.0, 0.0)) && on_plane(&t, Vec3::new(0.0, 30.0, 0.0)), "{t:?}"); +} + +#[test] +fn plane_tangent_to_a_cylinder() { + let mut s = part(); + run(&mut s, "construct.plane.tangent", json!({"face": {"face": [65, 0, 5]}, "name": "T"})); + let t = plane(&s, "T"); + assert!(parallel(t.normal(), Vec3::X) && on_plane(&t, Vec3::new(65.0, 3.0, 9.0)), "{t:?}"); + // And to the sphere, where the pick lies over it. + run(&mut s, "construct.plane.tangent", json!({"face": {"face": [0, 0, 65]}, "name": "U"})); + let u = plane(&s, "U"); + assert!(parallel(u.normal(), Vec3::Z) && on_plane(&u, Vec3::new(3.0, 3.0, 65.0)), "{u:?}"); +} + +#[test] +fn plane_along_a_path() { + let mut s = part(); + run(&mut s, "construct.plane.along_path", json!({"path": {"edge": [20, 0, 0]}, "t": 0.5, "name": "P"})); + let p = plane(&s, "P"); + assert!(parallel(p.normal(), Vec3::X) && on_plane(&p, Vec3::new(20.0, 7.0, 3.0)), "{p:?}"); + // A sketch can be placed on it. + run(&mut s, "sketch.create", json!({"plane": "P", "name": "OnPath"})); +} + +/// Built from model geometry, they follow when the model changes: the box gets taller. +#[test] +fn construction_follows_the_model() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "h", "expression": "20 mm"})); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": "h", "body_name": "Box"})); + run( + &mut s, + "construct.point.three_planes", + json!({"a": {"face": [40, 15, 10]}, "b": {"face": [20, 30, 10]}, "c": {"face": [20, 15, 20]}, "name": "Corner"}), + ); + run(&mut s, "construct.axis.edge", json!({"edge": {"edge": [20, 30, 20]}, "name": "TopBack"})); + run(&mut s, "construct.plane.midplane", json!({"a": "XY", "b": {"face": [20, 15, 20]}, "name": "Mid"})); + run(&mut s, "parameters.change", json!({"name": "h", "expression": "50 mm"})); + assert!(near(point(&s, "Corner"), Vec3::new(40.0, 30.0, 50.0)), "{:?}", point(&s, "Corner")); + let (o, d) = axis(&s, "TopBack"); + assert!(parallel(d, Vec3::X) && on_line(o, d, Vec3::new(0.0, 30.0, 50.0)), "{o:?} {d:?}"); + assert!(on_plane(&plane(&s, "Mid"), Vec3::new(1.0, 1.0, 25.0))); + // Construction from construction: an axis through two construction points. + run(&mut s, "construct.point.vertex", json!({"point": {"vertex": [0, 0, 0]}, "name": "Origin"})); + run(&mut s, "construct.axis.two_points", json!({"a": {"construct_point": "Origin"}, "b": {"construct_point": "Corner"}, "name": "Diag"})); + let (o, d) = axis(&s, "Diag"); + assert!(on_line(o, d, Vec3::new(40.0, 30.0, 50.0)) && on_line(o, d, Vec3::ZERO)); +} + +/// In a component moved 50 mm along X, picks given in the world land on its geometry, and the +/// axis and point move with the occurrence. +#[test] +fn construction_lives_in_the_component_frame() { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Part"})); + run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "body_name": "Pin"})); + run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [50, 0, 0]})); + run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [55, 0, 5]}, "name": "PinAxis"})); + run(&mut s, "construct.point.center", json!({"of": {"edge": [55, 0, 10]}, "name": "PinTop"})); + let (o, d) = axis(&s, "PinAxis"); + assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(50.0, 0.0, 0.0)), "{o:?} {d:?}"); + assert!(near(point(&s, "PinTop"), Vec3::new(50.0, 0.0, 10.0)), "{:?}", point(&s, "PinTop")); + run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 20, 0]})); + assert!(near(point(&s, "PinTop"), Vec3::new(50.0, 20.0, 10.0)), "{:?}", point(&s, "PinTop")); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 94675c69..67c2f536 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -425,6 +425,8 @@ impl Session { } } +#[cfg(test)] +mod construct_tests; #[cfg(test)] mod delete_face_tests; #[cfg(test)] diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 780269d3..d9485512 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -181,30 +181,58 @@ pub fn home_camera(s: &Session) -> Camera { c } -/// Construction planes of the timeline: (feature id, name, plane). +/// Construction planes of the timeline, as evaluated: (feature id, name, plane). pub fn construction_planes(s: &Session) -> Vec<(u64, String, Plane)> { - use solvecraft_doc::FeatureKind; - let (vals, _) = s.doc.param_values(); - s.doc - .features - .iter() - .filter_map(|f| match &f.kind { - FeatureKind::ConstructionPlane { plane } if !f.suppressed => s.doc.resolve_plane(&vals, plane, 0).ok().map(|p| { - // Shown where its component is placed. - let m = s.doc.component_transform(f.component); - let p = if solvecraft_doc::is_identity(&m) { - p - } else { - Plane::new( - solvecraft_doc::apply_point(&m, p.origin), - solvecraft_doc::apply_vector(&m, p.x), - solvecraft_doc::apply_vector(&m, p.y), - ) - .unwrap_or(p) - }; - (f.id, f.name.clone(), p) - }), + constructs(s) + .into_iter() + .filter_map(|(id, name, g)| match g { + solvecraft_doc::construct::ConstructGeom::Plane(p) => Some((id, name, p)), _ => None, }) .collect() } + +/// Construction axes, as evaluated: (feature id, name, origin, unit direction). +pub fn construction_axes(s: &Session) -> Vec<(u64, String, Vec3, Vec3)> { + constructs(s) + .into_iter() + .filter_map(|(id, name, g)| match g { + solvecraft_doc::construct::ConstructGeom::Axis { origin, dir } => Some((id, name, origin, dir)), + _ => None, + }) + .collect() +} + +/// Construction points, as evaluated: (feature id, name, position). +pub fn construction_points(s: &Session) -> Vec<(u64, String, Vec3)> { + constructs(s) + .into_iter() + .filter_map(|(id, name, g)| match g { + solvecraft_doc::construct::ConstructGeom::Point(p) => Some((id, name, p)), + _ => None, + }) + .collect() +} + +/// Construction geometry of unsuppressed features, shown where its component is placed. +fn constructs(s: &Session) -> Vec<(u64, String, solvecraft_doc::construct::ConstructGeom)> { + use solvecraft_doc::construct::ConstructGeom; + let st = s.model.state(); + st.construct + .iter() + .filter_map(|c| { + let f = s.doc.feature(c.feature).filter(|f| !f.suppressed)?; + let m = s.doc.component_transform(f.component); + if solvecraft_doc::is_identity(&m) { + return Some((c.feature, c.name.clone(), c.geom.clone())); + } + let (pt, v) = (|p: Vec3| solvecraft_doc::apply_point(&m, p), |d: Vec3| solvecraft_doc::apply_vector(&m, d)); + let g = match &c.geom { + ConstructGeom::Plane(p) => ConstructGeom::Plane(Plane::new(pt(p.origin), v(p.x), v(p.y)).unwrap_or(*p)), + ConstructGeom::Axis { origin, dir } => ConstructGeom::Axis { origin: pt(*origin), dir: v(*dir) }, + ConstructGeom::Point(p) => ConstructGeom::Point(pt(*p)), + }; + Some((c.feature, c.name.clone(), g)) + }) + .collect() +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index df2a8a5c..4a837f05 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -39,6 +39,7 @@ mod splitface; mod step; mod step_in; mod step_out; +mod surface_kind; mod surfaces; mod sweep_path; #[cfg(test)] @@ -65,6 +66,7 @@ pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map}; pub use step::step_export; pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_orientation_errors, step_validate}; pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products}; +pub use surface_kind::{FaceSurface, face_surface}; pub use surfaces::{copy_faces, extend, patch_edges, patch_region, stitch, thicken, trim}; pub use sweep_path::sweep_path; pub use topo::{CylinderFace, cylinder_face_at}; diff --git a/crates/kernel/src/offset.rs b/crates/kernel/src/offset.rs index b0b371e5..c4c79c00 100644 --- a/crates/kernel/src/offset.rs +++ b/crates/kernel/src/offset.rs @@ -14,7 +14,7 @@ use crate::body::{Body, Solid, from_p3, p3}; use crate::{KernelError, Result, guard}; #[derive(Clone, Copy, Debug)] -enum Surf { +pub(crate) enum Surf { /// Outward normal and offset: n·x = d. Plane { n: Vec3, d: f64 }, /// Axis point and direction, radius; `convex` when the outward normal points away from @@ -211,7 +211,7 @@ fn samples(f: &mt::Face) -> Vec<(Vec3, Vec3)> { } /// What surface a face lies on (planes and cylinders only). -fn surf_of(f: &mt::Face, tol: f64) -> Option { +pub(crate) fn surf_of(f: &mt::Face, tol: f64) -> Option { if let mt::Surface::Plane(pl) = f.oriented_surface() { let n = pl.normal(); let n = Vec3::new(n.x, n.y, n.z).normalized()?; diff --git a/crates/kernel/src/surface_kind.rs b/crates/kernel/src/surface_kind.rs new file mode 100644 index 00000000..b28aa994 --- /dev/null +++ b/crates/kernel/src/surface_kind.rs @@ -0,0 +1,52 @@ +//! What surface a face lies on (plane, cylinder, cone, sphere, torus), for construction +//! geometry: axes through round faces, centres of spheres and tori. + +use solvecraft_geom::Vec3; + +use crate::Body; +use crate::offset::{Surf, surf_of}; + +/// A face's analytic surface. Axes are unit vectors. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum FaceSurface { + /// Outward unit normal and a point on the plane. + Plane { + normal: Vec3, + point: Vec3, + }, + Cylinder { + origin: Vec3, + axis: Vec3, + radius: f64, + }, + /// Apex, axis pointing into the cone, half-angle (radians). + Cone { + apex: Vec3, + axis: Vec3, + half_angle: f64, + }, + Sphere { + center: Vec3, + radius: f64, + }, + /// Centre of the ring, its axis, the ring (major) and tube (minor) radii. + Torus { + center: Vec3, + axis: Vec3, + major: f64, + minor: f64, + }, +} + +/// The surface of face `face` (in face order), if it is one of the analytic kinds. +pub fn face_surface(b: &Body, face: usize) -> Option { + let f = b.solid.face_iter().nth(face)?; + let tol = (b.size() * 1e-7).max(1e-9) * 100.0; + Some(match surf_of(f, tol)? { + Surf::Plane { n, d } => FaceSurface::Plane { normal: n, point: n * d }, + Surf::Cylinder { o, a, r, .. } => FaceSurface::Cylinder { origin: o, axis: a, radius: r }, + Surf::Cone { v, a, half, .. } => FaceSurface::Cone { apex: v, axis: a, half_angle: half }, + Surf::Sphere { c, r, .. } => FaceSurface::Sphere { center: c, radius: r }, + Surf::Torus { o, a, big, h, r, .. } => FaceSurface::Torus { center: o + a * h, axis: a, major: big, minor: r }, + }) +}