Construction axes, points and planes from model geometry (SOLID ▸ CONSTRUCT): axes through a cylinder/cone/torus, perpendicular to a face at a point, through two planes, two points or an edge; points at a vertex, through two edges, three planes, at the centre of a circle/sphere/torus, at an edge and a plane, along a path; midplane, planes through two edges or three points, tangent to a face, along a path. References (doc::construct::GeoRef) keep picked faces and edges by persistent name and resolve on each evaluation (they follow the model); results live in ModelState.construct, in the component's frame; view::construction_axes/points/planes for the viewport; kernel::face_surface for a face's analytic surface

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-09 12:47:56 +00:00
1 parent 68271137a5
commit 846bb112e2
21 files changed
+1400 -46

No files matched your search

+15
View File
@@ -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 {
+643
View File
@@ -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<String>,
},
/// 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<String>,
},
/// 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<String, Value>,
pub st: &'a ModelState,
}
fn bad(m: impl Into<String>) -> DocError {
DocError::Invalid(m.into())
}
fn bodies(st: &ModelState) -> impl Iterator<Item = &ModelBody> {
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<String>) -> 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<String>) -> Result<Vec<Vec3>> {
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<Vec3>)> = 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<Vec3> {
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<Vec<Vec3>> {
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<Vec3> {
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<Plane> {
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<Vec3> {
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<Vec<Vec3>> {
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<Vec3> {
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<Vec3> {
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<Vec3> {
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<Plane> {
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()
}
}
+68 -6
View File
@@ -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<PlaneRef>, 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<GeoRef>, b: Box<GeoRef> },
/// Through two straight edges (lines) that lie in one plane.
TwoEdges { a: Box<GeoRef>, b: Box<GeoRef> },
/// Through three points.
ThreePoints { a: Box<GeoRef>, b: Box<GeoRef>, c: Box<GeoRef> },
/// Tangent to a face (cylinder, cone, sphere, torus) where the point `at` lies over it.
Tangent { face: Box<GeoRef>, at: Vec3 },
/// Square to an edge or sketch curve, a fraction `t` (expression, 0…1) of the way along it.
AlongPath { path: Box<GeoRef>, 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<FaceAt>,
},
/// 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<String, expr::Value>, p: &PlaneRef, depth: usize) -> Result<Plane> {
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<String, expr::Value>,
p: &PlaneRef,
depth: usize,
st: Option<&crate::ModelState>,
) -> Result<Plane> {
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)
}
}
}
+27 -8
View File
@@ -239,6 +239,8 @@ pub struct ModelState {
pub body_counter: usize,
/// Sheet metal bodies (by body name) and their flat patterns.
pub sheets: Vec<crate::sheet::SheetBody>,
/// Construction planes, axes and points as evaluated (by feature).
pub construct: Vec<crate::construct::Construct>,
/// Evaluation-cache keys of the sketches (by sketch feature id).
sketch_keys: BTreeMap<u64, u64>,
}
@@ -410,7 +412,9 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_ba
/// metal and plastic rules (and the parameters they use), components and body placement.
fn context_digest(doc: &Document, vals: &BTreeMap<String, Value>) -> 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<String, Value>, 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<String, Value>, 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<String, Value>, 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<String, Value>, 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<String, Value>, 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<String, Value>, 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)]);
}
+1 -1
View File
@@ -45,7 +45,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, 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())) };
+1 -1
View File
@@ -67,7 +67,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, 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);
+1
View File
@@ -10,6 +10,7 @@ mod assembly;
pub mod canvas;
mod clipboard;
pub mod config;
pub mod construct;
mod document;
mod eval;
pub mod expr;
+18
View File
@@ -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 {
+1 -1
View File
@@ -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()
}
};
+1 -1
View File
@@ -358,7 +358,7 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
}
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;
}
+251
View File
@@ -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<GeoRef> {
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::<GeoRef>(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<String> {
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<Value> {
add_feature(s, p, FeatureKind::ConstructionAxis { def })
}
fn point(s: &mut Session, p: &Value, def: PointDef) -> Result<Value> {
add_feature(s, p, FeatureKind::ConstructionPoint { def })
}
fn plane(s: &mut Session, p: &Value, plane: PlaneRef) -> Result<Value> {
add_feature(s, p, FeatureKind::ConstructionPlane { plane })
}
fn axis_face(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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 })
}
+2 -2
View File
@@ -797,7 +797,7 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
/// 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<solvecraft_doc::PlaneRef> {
pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solvecraft_doc::PlaneRef> {
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<Value> {
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) })
}
+2
View File
@@ -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());
+1 -1
View File
@@ -367,7 +367,7 @@ fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
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);
+1 -1
View File
@@ -48,7 +48,7 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
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);
+259
View File
@@ -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"));
}
+2
View File
@@ -425,6 +425,8 @@ impl Session {
}
}
#[cfg(test)]
mod construct_tests;
#[cfg(test)]
mod delete_face_tests;
#[cfg(test)]
+50 -22
View File
@@ -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()
}
+2
View File
@@ -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};
+2 -2
View File
@@ -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<Surf> {
pub(crate) fn surf_of(f: &mt::Face, tol: f64) -> Option<Surf> {
if let mt::Surface::Plane(pl) = f.oriented_surface() {
let n = pl.normal();
let n = Vec3::new(n.x, n.y, n.z).normalized()?;
+52
View File
@@ -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<FaceSurface> {
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 },
})
}