diff --git a/README.md b/README.md index 2ed7066a..e72cbe63 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,9 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). - **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles, arcs, polygons, slots, ellipses, fit/control splines, conics and text; construction geometry, projection, trimming, fillets and chamfers, including corners at the origin. + New face attachments retain supported construction identities and chosen occurrences; + missing sources expose an explicit plane repair workflow. See [face attachments](docs/face-attachments.md). + Explicitly closed fit splines and closed associative offsets use periodic C2 interpolation. - **Constraints and dimensions** solved by our own solver (damped Gauss–Newton with degree-of-freedom analysis): coincident, horizontal/vertical, parallel, perpendicular, tangent, equal, concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical, @@ -36,6 +39,8 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). shell, draft, holes, threads, patterns, mirror, split, combine, move and construction geometry. The timeline rebuilds incrementally; features can be edited, suppressed, renamed, reordered and deleted. Supported geometry and current limitations are described in [ROADMAP.md](ROADMAP.md). + New revolves preserve symmetric and two-sided angular placement across reversed axes; + older designs retain their saved convention when reopened or edited. - **Parameters and configurations** with unit-aware expressions (`2 * width + 5 mm`, `angle / 2`), ordered rules, feature suppression and per-row body materials/appearances. - **Assemblies** with nested components and occurrences, joints, motion studies, contact sets, diff --git a/ROADMAP.md b/ROADMAP.md index 208d9f77..d71f1706 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,7 +46,7 @@ headline counts in-scope tabs only and lists the deferred ones separately. | # | Milestone | Status | Hours left | |---|---|---|---| | M0 | Vertical slice | done | — | -| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, periodic closed spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 | +| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 | | M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 | | M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 | | M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 | @@ -59,7 +59,7 @@ headline counts in-scope tabs only and lists the deferred ones separately. | M11 | 2D drawings | deferred | — | | M12 | Release: installers, signing, docs, performance | planned | 16 | | M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 | -| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/10"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | +| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/11"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | ## Known limitations @@ -100,6 +100,11 @@ headline counts in-scope tabs only and lists the deferred ones separately. - Persistent naming (plan/adr/0002) names faces from their history and edges by their faces; references resolve by name first and warn when the entity split or vanished. Kernel-reported face history (exact through coincident surfaces) is not wired in yet. + New planar sketch attachments use exact box, extrusion cap/side and immutable imported-face + source certificates with checked target and author occurrence paths. A missing or changed + source fails explicitly; Redefine Sketch Plane previews reattachment or a fixed plane while + retaining current geometry and dimension identities. Unsupported modified faces need an + explicit fixed plane. Legacy attachments retain their original resolver behavior. - Modelled threads are cut as a faceted body (curved faces within 1/200 of the pitch); Extrude and Revolve To Object on a curved face cut the tool back to the target's surface. - Explicit sketch breaks preserve exact ellipses, fit/control splines (including high degrees) @@ -110,7 +115,12 @@ headline counts in-scope tabs only and lists the deferred ones separately. circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance certificate and refine within bounded node limits while retaining existing IDs. Singular, self-intersecting and ambiguous curves refuse atomically; mixed junctions and nonsmooth - closed natural-spline seams remain limitations. + closed natural-spline seams remain limitations. New explicitly closed fit splines and closed + offset outputs use periodic C2 interpolation, supporting repeated certified offsets; older + natural splines are preserved. Clicking the starting fit point deliberately closes a new loop. + Projected analytic boundaries follow nested loop identities through resizing, including + stationary holes and rounded corners. An inset that consumes a corner arc still reports + a collapse error; changing the associated output topology remains unfinished. - Extrude and Revolve editors retain original direction, extent expressions, explicit targets, axes, From/To inputs and All-profile intent. No-op edits preserve downstream dimension names and selected occurrence frames; cancel and successful edits preserve one-step undo history. @@ -118,7 +128,9 @@ headline counts in-scope tabs only and lists the deferred ones separately. use the sketch's actual target, author or uniquely placed third-component frame. Revolve rotators follow the actual signed axis and support symmetric and two-sided extents; dragging a To Object extent converts its derived value to an explicit angle. Legacy reversed-axis - symmetric geometry is preserved; correcting that saved behavior needs versioned semantics. + symmetric geometry is preserved through format 11's explicit legacy convention. New revolves + use the sketch-normal direction for each selected profile, including To Object and curved + target trimming, independently of the selected axis sign. - Cylindrical emboss/deboss wraps bounded orthogonal line profiles, including holes, concave outlines and nested islands, onto complete exterior cylinders or coaxial sleeves with exact radial walls. New features infer a unique eligible cylinder when the face field is empty; diff --git a/crates/doc/src/anchor_frame.rs b/crates/doc/src/anchor_frame.rs new file mode 100644 index 00000000..713a9e76 --- /dev/null +++ b/crates/doc/src/anchor_frame.rs @@ -0,0 +1,21 @@ +//! Presentation/projection state for an exact selected planar source placement. +use crate::{DocError, Document, FaceAnchor, ModelBody, ModelState, Result}; +use std::borrow::Cow; + +pub fn state_in_face_anchor_frame<'a>(doc: &Document, state: &'a ModelState, anchor: &FaceAnchor, author: u64) -> Result> { + let frame = anchor.author_frame(doc, author)?; + let raw = state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("anchored body `{}`", anchor.target.body)))?; + let relative = anchor.source_to_author(doc, author, raw)?; + let mut output = crate::state_in_frame_at(doc, state, author, frame).into_owned(); + let shape = if crate::is_identity(&relative) { raw.body.clone() } else { solvecraft_kernel::transform_matrix(&raw.body, relative)? }; + let mut body = ModelBody::new(raw.name.clone(), shape, raw.feature); + body.component = raw.component; + body.transferred = raw.transferred; + body.source_name = raw.source_name.clone(); + body.source_transform = crate::mat_mul(&relative, &raw.source_transform); + body.imported = raw.imported.clone(); + body.names = crate::naming::NameCell::known(crate::naming::face_names(raw)); + let slot = output.bodies.iter_mut().find(|body| body.name == raw.name).ok_or_else(|| DocError::Unknown("anchored body frame".into()))?; + *slot = body; + Ok(Cow::Owned(output)) +} diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index d0b89bfd..e60c2c7c 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -146,10 +146,10 @@ fn remap_geo( names: &BTreeMap, bodies: &BTreeMap, depth: usize, -) { +) -> crate::Result<()> { use crate::construct::GeoRef; if depth > 64 { - return; + return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into())); } match g { GeoRef::SketchPoint { sketch, .. } | GeoRef::SketchCurve { sketch, .. } => { @@ -159,25 +159,40 @@ fn remap_geo( } GeoRef::Face { name: Some(n), .. } | GeoRef::Edge { name: Some(n), .. } => *n = remap_topology(n, ids, bodies), GeoRef::ConstructPoint { name } | GeoRef::Axis { name } => mapped_name(name, names), - GeoRef::Plane { plane } => remap_plane(plane, ids, names, bodies, depth + 1), + GeoRef::Plane { plane } => remap_plane(plane, ids, names, bodies, depth + 1)?, _ => {} } + Ok(()) } -fn remap_plane(p: &mut PlaneRef, ids: &BTreeMap, names: &BTreeMap, bodies: &BTreeMap, depth: usize) { +fn remap_plane( + p: &mut PlaneRef, + ids: &BTreeMap, + names: &BTreeMap, + bodies: &BTreeMap, + depth: usize, +) -> crate::Result<()> { if depth > 64 { - return; + return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into())); } match p { - PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => remap_plane(base, ids, names, bodies, depth + 1), + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => remap_plane(base, ids, names, bodies, depth + 1)?, PlaneRef::Construction { name } => mapped_name(name, names), - PlaneRef::Face { name: Some(n), .. } => *n = remap_topology(n, ids, bodies), + PlaneRef::Face { name, anchor, .. } => { + if let Some(name) = name { + *name = remap_topology(name, ids, bodies); + } + if let Some(anchor) = anchor { + anchor.remap_target(ids, bodies)?; + } + } _ => { for r in p.refs_mut() { - remap_geo(r, ids, names, bodies, depth + 1); + remap_geo(r, ids, names, bodies, depth + 1)?; } } } + Ok(()) } fn remap_link( @@ -348,7 +363,7 @@ impl crate::Feature { } match &mut self.kind { K::Sketch { plane, sketch } => { - remap_plane(plane, ids, names, bodies, 0); + remap_plane(plane, ids, names, bodies, 0)?; for c in &mut sketch.constraints { if let Some(n) = &mut c.param { mapped_name(n, parameters); @@ -365,17 +380,17 @@ impl crate::Feature { | K::Split { plane, .. } | K::SplitFace { plane, .. } | K::Mirror { plane, .. } - | K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, names, bodies, 0), - K::Draft { neutral, .. } => remap_plane(neutral, ids, names, bodies, 0), - K::ReplaceFace { target, .. } => remap_plane(target, ids, names, bodies, 0), + | K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, names, bodies, 0)?, + K::Draft { neutral, .. } => remap_plane(neutral, ids, names, bodies, 0)?, + K::ReplaceFace { target, .. } => remap_plane(target, ids, names, bodies, 0)?, K::ConstructionAxis { def } => { for r in def.refs_mut() { - remap_geo(r, ids, names, bodies, 0); + remap_geo(r, ids, names, bodies, 0)?; } } K::ConstructionPoint { def } => { for r in def.refs_mut() { - remap_geo(r, ids, names, bodies, 0); + remap_geo(r, ids, names, bodies, 0)?; } } _ => {} diff --git a/crates/doc/src/config_certificates.rs b/crates/doc/src/config_certificates.rs index 4b51cadf..6c6b7396 100644 --- a/crates/doc/src/config_certificates.rs +++ b/crates/doc/src/config_certificates.rs @@ -3,16 +3,162 @@ use crate::{DocError, Document, FeatureKind, ModelBody, Result, SolvedSketch}; use serde::{Deserialize, Serialize}; use solvecraft_geom::Seg2; +fn valid_signature(outer: &[String], holes: &[Vec]) -> bool { + let valid = |ids: &[String]| { + !ids.is_empty() && ids.len() <= 2000 && ids.iter().all(|id| !id.is_empty() && id.len() <= 128) && ids.windows(2).all(|pair| pair[0] < pair[1]) + }; + valid(outer) + && holes.len() <= 128 + && holes.iter().all(|ids| valid(ids)) + && outer.len() + holes.iter().map(Vec::len).sum::() <= 2000 + && holes.windows(2).all(|pair| pair[0] < pair[1]) + && holes.iter().flatten().all(|id| outer.binary_search(id).is_err()) + && holes.iter().flatten().collect::>().len() == holes.iter().map(Vec::len).sum::() +} +fn valid_signature_json(outer: Option<&serde_json::Value>, holes: Option<&serde_json::Value>) -> bool { + let valid = |value: &serde_json::Value| { + value.as_array().is_some_and(|ids| { + !ids.is_empty() + && ids.len() <= 2000 + && ids.iter().all(|id| id.as_str().is_some_and(|id| !id.is_empty() && id.len() <= 128)) + && ids.windows(2).all(|pair| pair[0].as_str() < pair[1].as_str()) + }) + }; + let Some(outer) = outer.filter(|value| valid(value)).and_then(serde_json::Value::as_array) else { + return false; + }; + let Some(holes) = holes.and_then(serde_json::Value::as_array).filter(|holes| holes.len() <= 128) else { + return false; + }; + if !holes.iter().all(valid) { + return false; + } + let total = outer.len() + holes.iter().filter_map(serde_json::Value::as_array).map(Vec::len).sum::(); + let ids: Vec<_> = outer.iter().filter_map(serde_json::Value::as_str).collect(); + total <= 2000 + && holes + .iter() + .filter_map(serde_json::Value::as_array) + .flatten() + .filter_map(serde_json::Value::as_str) + .collect::>() + .len() + == total - outer.len() + && holes + .iter() + .filter_map(serde_json::Value::as_array) + .flatten() + .filter_map(serde_json::Value::as_str) + .all(|id| ids.binary_search(&id).is_err()) + && holes.windows(2).all(|pair| { + let a: Vec<_> = pair[0].as_array().into_iter().flatten().filter_map(serde_json::Value::as_str).collect(); + let b: Vec<_> = pair[1].as_array().into_iter().flatten().filter_map(serde_json::Value::as_str).collect(); + a < b + }) +} +pub(crate) fn cap_key(outer: &[String], holes: &[Vec]) -> String { + use sha2::{Digest, Sha256}; + let mut hash = Sha256::new(); + for ids in std::iter::once(outer).chain(holes.iter().map(Vec::as_slice)) { + hash.update((ids.len() as u64).to_le_bytes()); + for id in ids { + hash.update((id.len() as u64).to_le_bytes()); + hash.update(id.as_bytes()); + } + } + format!("{:x}", hash.finalize()) +} +pub(crate) fn exact_region_signature(sketch: &SolvedSketch, region: &solvecraft_geom::Region2) -> Option { + if region.outer.segs.len() + region.holes.iter().map(|hole| hole.segs.len()).sum::() > 4096 || region.holes.len() > 128 { + return None; + } + let signature = |outline: &solvecraft_geom::Loop2| { + let mut ids = outline.segs.iter().map(|segment| exact_sketch_curve(sketch, segment)).collect::>>()?; + ids.sort(); + ids.dedup(); + Some(ids) + }; + let outer = signature(®ion.outer)?; + let mut holes = region.holes.iter().map(signature).collect::>>()?; + holes.sort(); + valid_signature(&outer, &holes).then_some((outer, holes)) +} + +/// Charge all source-curve comparisons before matching; over-budget tools stay usable but uncertified. +type CapBindings = Vec>>; +pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solvecraft_kernel::Body], budget: usize) -> Option { + use solvecraft_kernel::FaceSource; + if sketch.sketch.curves.len() > 2000 { + return None; + } + let mut faces = 0usize; + let mut comparisons = 0usize; + for body in bodies { + for source in body.face_sources().into_iter().flatten() { + faces = faces.checked_add(1)?; + if faces > 50_000 { + return None; + } + let segments = match source { + Some(FaceSource::ExtrudeSide { .. }) => 1, + Some(FaceSource::ExtrudeCap { region, .. }) => { + region.outer.segs.len().checked_add(region.holes.iter().map(|hole| hole.segs.len()).sum::())? + } + _ => 0, + }; + comparisons = comparisons.checked_add(segments.checked_mul(sketch.sketch.curves.len())?)?; + if comparisons > budget { + return None; + } + } + } + let mut counts = std::collections::BTreeMap::new(); + let mut bindings = Vec::with_capacity(bodies.len()); + for body in bodies { + let mut cached: Option<(&solvecraft_geom::Region2, Option)> = None; + let mut values = Vec::new(); + for source in body.face_sources().into_iter().flatten() { + let signature = if let Some(FaceSource::ExtrudeCap { region, high, .. }) = source { + let signature = match &cached { + Some((previous, signature)) if *previous == region => signature.clone(), + _ => exact_region_signature(sketch, region), + }; + cached = Some((region, signature.clone())); + if let Some((outer, holes)) = &signature { + *counts.entry((*high, outer.clone(), holes.clone())).or_insert(0usize) += 1; + } + signature + } else { + None + }; + values.push(signature); + } + bindings.push(values); + } + for (body, values) in bodies.iter().zip(&mut bindings) { + for (source, value) in body.face_sources().into_iter().flatten().zip(values) { + if let (Some(FaceSource::ExtrudeCap { high, .. }), Some((outer, holes))) = (source, value.as_ref()) + && counts.get(&(*high, outer.clone(), holes.clone())) != Some(&1) + { + *value = None; + } + } + } + Some(bindings) +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum FaceCertificate { + Box { axis: u8, high: bool }, + ExtrudeCap { sketch: u64, high: bool, outer: Vec, holes: Vec> }, ExtrudeSide { sketch: u64, curve: String }, Imported { payload_sha256: String, item: u64, face: u64 }, } impl FaceCertificate { pub fn remap_features(&mut self, features: &std::collections::BTreeMap) { - if let Self::ExtrudeSide { sketch, .. } = self + if let Self::ExtrudeSide { sketch, .. } | Self::ExtrudeCap { sketch, .. } = self && let Some(mapped) = features.get(sketch) { *sketch = *mapped; @@ -20,6 +166,8 @@ impl FaceCertificate { } pub fn validate(&self) -> Result<()> { let valid = match self { + Self::Box { axis, .. } => *axis <= 2, + Self::ExtrudeCap { sketch, outer, holes, .. } => *sketch != 0 && valid_signature(outer, holes), Self::ExtrudeSide { sketch, curve } => *sketch != 0 && !curve.is_empty() && curve.len() <= 128, Self::Imported { payload_sha256, item, face } => { *item != 0 @@ -39,6 +187,19 @@ pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()> let fields = value.as_object().ok_or_else(|| DocError::Invalid("face certificate must be an object".into()))?; let nonzero = |field: &str| fields.get(field).and_then(serde_json::Value::as_u64).is_some_and(|id| id != 0); let valid = match fields.get("kind").and_then(serde_json::Value::as_str) { + Some("box") => { + fields.len() == 3 + && fields.keys().all(|key| ["kind", "axis", "high"].contains(&key.as_str())) + && fields.get("axis").and_then(serde_json::Value::as_u64).is_some_and(|axis| axis <= 2) + && fields.get("high").and_then(serde_json::Value::as_bool).is_some() + } + Some("extrude_cap") => { + fields.len() == 5 + && fields.keys().all(|key| ["kind", "sketch", "high", "outer", "holes"].contains(&key.as_str())) + && nonzero("sketch") + && fields.get("high").and_then(serde_json::Value::as_bool).is_some() + && valid_signature_json(fields.get("outer"), fields.get("holes")) + } Some("extrude_side") => { fields.len() == 3 && fields.keys().all(|key| ["kind", "sketch", "curve"].contains(&key.as_str())) @@ -143,7 +304,9 @@ impl Document { .ok_or_else(|| DocError::Unknown("face source name missing".into()))?; let origin = match body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) { Some(solvecraft_kernel::FaceSource::Imported { producer: Some(producer), .. }) - | Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. }) => *producer, + | Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. }) + | Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. }) + | Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), .. }) => *producer, _ => name .strip_prefix('F') .and_then(|name| name.split_once(':')) @@ -178,17 +341,36 @@ impl Document { return Ok(Some(certificate)); } let certificate = match source { - solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch, curve } => { + solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch, curve, .. } => { if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Extrude { .. })) { return Err(DocError::Invalid("side source producer missing".into())); } FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() } } - _ => return Err(DocError::Invalid("face source does not match producer".into())), + _ => return Ok(None), }; certificate.validate()?; Ok(Some(certificate)) } + pub fn strong_face_target(&self, body: &ModelBody, index: usize, point: solvecraft_geom::Vec3) -> Result { + let mut target = self.configuration_face_target(body, index, point)?; + match body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) { + Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), axis, high, .. }) => { + target.origin_feature = *producer; + let axis_name = ["x", "y", "z"].get(*axis as usize).ok_or_else(|| DocError::Invalid("invalid box face axis".into()))?; + target.name = format!("F{producer}:{}{axis_name}", if *high { "+" } else { "-" }); + target.certificate = Some(FaceCertificate::Box { axis: *axis, high: *high }); + } + Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, .. }) => { + target.origin_feature = *producer; + target.name = format!("F{producer}:cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes)); + target.certificate = Some(FaceCertificate::ExtrudeCap { sketch: *sketch, high: *high, outer: outer.clone(), holes: holes.clone() }); + } + _ => {} + } + self.certify_config_face(&target)?; + Ok(target) + } } pub(crate) fn payload_digest(payload: &str) -> String { diff --git a/crates/doc/src/config_styles.rs b/crates/doc/src/config_styles.rs index f9b4d949..18d053f8 100644 --- a/crates/doc/src/config_styles.rs +++ b/crates/doc/src/config_styles.rs @@ -109,6 +109,13 @@ impl Document { let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?; if let Some(certificate) = &target.certificate { let valid = match (certificate, &feature.kind) { + (super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => { + ["x", "y", "z"].get(*axis as usize).is_some_and(|axis| role == format!("{}{axis}", if *high { "+" } else { "-" })) + } + (super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => { + sketch == producer_sketch && extent.taper.is_none() + && role == format!("cap:{}:{}", if *high { "high" } else { "low" }, super::cap_key(outer, holes)) + } (super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => { sketch == producer_sketch && extent.taper.is_none() && role == format!("side:{curve}") && self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. })))) @@ -149,9 +156,49 @@ impl Document { if body.body.is_mesh() || body.definition_name() != target.body { return Err(DocError::Invalid("configured face definition does not match".into())); } + if matches!(target.certificate, Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::ExtrudeCap { .. })) { + let indices: Vec<_> = body + .body + .face_sources() + .into_iter() + .flatten() + .enumerate() + .filter_map(|(index, source)| { + let matched = match (&target.certificate, source) { + ( + Some(super::FaceCertificate::Box { axis, high }), + Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), axis: current_axis, high: current_high, .. }), + ) => *producer == target.origin_feature && axis == current_axis && high == current_high, + ( + Some(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }), + Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { + producer, + sketch: current_sketch, + high: current_high, + outer: current_outer, + holes: current_holes, + .. + }), + ) => { + *producer == target.origin_feature + && sketch == current_sketch + && high == current_high + && outer == current_outer + && holes == current_holes + } + _ => false, + }; + matched.then_some(index) + }) + .collect(); + if indices.len() != 1 { + return Err(DocError::Unknown("attached planar face certificate is missing or ambiguous".into())); + } + return Ok(indices); + } if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate { let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| { - matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve }) if *producer == target.origin_feature && current_sketch == sketch && current_curve == curve).then_some(index) + matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve, .. }) if *producer == target.origin_feature && current_sketch == sketch && current_curve == curve).then_some(index) }).collect(); if indices.is_empty() { return Err(DocError::Unknown("constructed side certificate no longer exists".into())); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 0f16df14..6fb8c4c0 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -56,15 +56,18 @@ pub enum PlaneRef { AlongPath { path: Box, t: String }, /// Through a straight edge (or line, axis) and square to a plane (or planar face). Perpendicular { plane: Box, line: Box }, - /// 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 - /// persistent name (found first; see `naming`). + /// A planar body face. Certified attachments resolve only their exact source and report + /// an error when it is lost; `plane` then supplies a repair guide. Legacy references without + /// an anchor try the persistent `name`, then the nearest parallel face at `at`, and retain + /// the picked `plane` as their historical fallback. Face { plane: Plane, at: Vec3, #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, + /// Exact attachment identity for newly certified faces; absent on legacy designs. + #[serde(default, skip_serializing_if = "Option::is_none")] + anchor: Option>, }, } @@ -116,6 +119,16 @@ pub enum Direction { Symmetric, } +/// Angular starts in older designs used the raw axis sign. New revolves measure starts +/// along the same sketch-normal sweep direction as the kernel. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RevolveConvention { + #[default] + LegacyAxisStart, + SketchNormal, +} + /// Extent of an extrude. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Extent { @@ -282,6 +295,9 @@ pub enum FeatureKind { /// Symmetric: `angle` split evenly either side of the profile. #[serde(default, skip_serializing_if = "std::ops::Not::not")] symmetric: bool, + /// Missing fields retain the angular placement of existing designs. + #[serde(default)] + convention: RevolveConvention, /// To Object: turn until the face, vertex or point here. #[serde(default, skip_serializing_if = "Option::is_none")] to: Option, diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 693fd349..b44ede63 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -529,7 +529,10 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 { return hash.finish(); } let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 }; - if doc.occurrences.is_empty() || (!matches!(plane, PlaneRef::Face { .. }) && sketch.links.is_empty()) { + let anchor = crate::face_anchor::plane_face_anchor(plane); + if (doc.occurrences.is_empty() && (anchor.is_none() || doc.body_offsets.is_empty())) + || (!matches!(plane, PlaneRef::Face { .. }) && anchor.is_none() && sketch.links.is_empty()) + { return 0; } let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -1494,17 +1497,53 @@ pub fn revolve_extent( (angle, angle2): (f64, Option), symmetric: bool, to: Option, +) -> Result { + revolve_extent_with_convention(st, plane, regions, (origin, axis), (angle, angle2), symmetric, to, crate::RevolveConvention::LegacyAxisStart) +} + +#[allow(clippy::too_many_arguments)] +pub fn revolve_extent_with_convention( + st: &ModelState, + plane: &Plane, + regions: &[Region2], + (origin, axis): (Vec3, Vec3), + (angle, angle2): (f64, Option), + symmetric: bool, + to: Option, + convention: crate::RevolveConvention, ) -> Result { let (start, sweep) = match (to, angle2, symmetric) { (Some(point), _, _) => { let points: Vec = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect(); - let centre = plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64)); + let centre = if convention == crate::RevolveConvention::SketchNormal { + regions.first().map(|r| plane.to_world(r.centroid())).ok_or_else(|| DocError::Invalid("no profile selected".into()))? + } else { + plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64)) + }; + let projection_axis = if convention == crate::RevolveConvention::SketchNormal { + axis.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))? + } else { + axis + }; let flat = |point: Vec3| { let v = point - origin; - v - axis * v.dot(axis) + v - projection_axis * v.dot(projection_axis) }; let (u, v) = (flat(centre), flat(point)); - let sweep = (-axis).dot(u.cross(v)).atan2(u.dot(v)); + let angle_axis = if convention == crate::RevolveConvention::SketchNormal { + if v.len() < 1e-9 { + return Err(DocError::Invalid("the revolve target lies on the axis; pick a point away from the axis".into())); + } + kernel::canonical_revolve_axis( + plane, + regions.first().ok_or_else(|| DocError::Invalid("no profile selected".into()))?, + plane.to_local(origin), + Vec2::new(projection_axis.dot(plane.x), projection_axis.dot(plane.y)), + )? + } else { + -axis + }; + let sweep = angle_axis.dot(u.cross(v)).atan2(u.dot(v)); (0.0, if sweep <= 1e-9 { sweep + std::f64::consts::TAU } else { sweep }) } (None, Some(other), _) => (-other, angle + other), @@ -1519,8 +1558,22 @@ pub fn revolve_extent( let unit_axis = axis.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?; let radial_centre = regions.first().map(|region| plane.to_world(region.centroid())).unwrap_or(centre); let radial = radial_centre - origin - unit_axis * (radial_centre - origin).dot(unit_axis); - let rotation_axis = if unit_axis.cross(radial).dot(plane.normal()) < 0.0 { -unit_axis } else { unit_axis }; - let display_start = start * -unit_axis.dot(rotation_axis); + let rotation_axis = match (convention, regions.first()) { + (crate::RevolveConvention::SketchNormal, Some(region)) => { + kernel::canonical_revolve_axis(plane, region, plane.to_local(origin), Vec2::new(unit_axis.dot(plane.x), unit_axis.dot(plane.y)))? + } + _ => { + if unit_axis.cross(radial).dot(plane.normal()) < 0.0 { + -unit_axis + } else { + unit_axis + } + } + }; + let display_start = match convention { + crate::RevolveConvention::LegacyAxisStart => start * -unit_axis.dot(rotation_axis), + crate::RevolveConvention::SketchNormal => start, + }; Ok(RevolveExtent { start, sweep, rotation_axis, display_start, curved_to: to.is_some_and(|point| curved_target_any(st, point).is_some()) }) } @@ -1629,25 +1682,33 @@ fn feature_tools(doc: &Document, vals: &BTreeMap, f: &Feature, st tools = trim_to_body(tools, &tb.body, |q: Vec3| ((q - ss.plane.origin).dot(n) - start).abs())?; } if *operation == Operation::NewBody && extent.taper.is_none() { + let cap_bindings = crate::config::extrude_cap_bindings(&ss, &tools, 10_000_000); tools = tools .into_iter() - .map(|body| { + .enumerate() + .map(|(body_index, body)| { let curves: Vec<_> = body .face_sources() .into_iter() .flatten() .map(|source| match source { - Some(kernel::FaceSource::ExtrudeSide { segment }) => crate::config::exact_sketch_curve(&ss, segment), + Some(kernel::FaceSource::ExtrudeSide { segment, .. }) if cap_bindings.is_some() => { + crate::config::exact_sketch_curve(&ss, segment) + } _ => None, }) .collect(); - body.with_extrude_certificates(f.id, *sketch, &curves).map_err(DocError::from) + let caps = + cap_bindings.as_ref().and_then(|bindings| bindings.get(body_index)).cloned().unwrap_or_else(|| vec![None; curves.len()]); + body.with_extrude_certificates(f.id, *sketch, &curves)? + .with_extrude_cap_certificates(f.id, *sketch, &caps) + .map_err(DocError::from) }) .collect::>>()?; } Ok(tools) } - FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, to, .. } => { + FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, convention, to, .. } => { let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone(); let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); let (o, d) = revolve_axis(&ss, axis)?; @@ -1656,26 +1717,66 @@ fn feature_tools(doc: &Document, vals: &BTreeMap, f: &Feature, st } else { (val(vals, angle, Kind::Angle)?, angle2.as_ref().map(|a| val(vals, a, Kind::Angle)).transpose()?) }; - let extent = revolve_extent(st, &ss.plane, ®ions, (ss.plane.to_world(o), ss.plane.dir_to_world(d)), angles, *symmetric, *to)?; + let extent = revolve_extent_with_convention( + st, + &ss.plane, + ®ions, + (ss.plane.to_world(o), ss.plane.dir_to_world(d)), + angles, + *symmetric, + *to, + *convention, + )?; let (start, ang) = (extent.start, extent.sweep); + let region_extents = regions + .iter() + .map(|r| { + if *convention == crate::RevolveConvention::SketchNormal && to.is_some() { + revolve_extent_with_convention( + st, + &ss.plane, + std::slice::from_ref(r), + (ss.plane.to_world(o), ss.plane.dir_to_world(d)), + angles, + *symmetric, + *to, + *convention, + ) + } else { + Ok(extent) + } + }) + .collect::>>()?; let (operation, targets) = feature_op(f); let axis_w = ss.plane.dir_to_world(d); let origin_w = ss.plane.to_world(o); - let probe = |p: Vec2| { - let q = ss.plane.to_world(p); - (1..4) - .map(|k| { - let a = ang * k as f64 / 4.0; - let v = q - origin_w; - origin_w + v * a.cos() + axis_w.cross(v) * a.sin() + axis_w * (axis_w.dot(v) * (1.0 - a.cos())) - }) - .collect::>() - }; - let regions: Vec = regions.iter().map(|r| grow_profile_for_coplanar(st, r, operation, &targets, &probe)).collect(); + let regions: Vec = regions + .iter() + .zip(®ion_extents) + .map(|(r, region_extent)| { + let rotation_axis = match convention { + crate::RevolveConvention::LegacyAxisStart => axis_w, + crate::RevolveConvention::SketchNormal => kernel::canonical_revolve_axis(&ss.plane, r, o, d)?, + }; + let probe = |p: Vec2| { + let q = ss.plane.to_world(p); + (1..4) + .map(|k| { + let a = region_extent.sweep * k as f64 / 4.0 + + if *convention == crate::RevolveConvention::SketchNormal { region_extent.start } else { 0.0 }; + let v = q - origin_w; + origin_w + v * a.cos() + rotation_axis.cross(v) * a.sin() + rotation_axis * (rotation_axis.dot(v) * (1.0 - a.cos())) + }) + .collect::>() + }; + Ok(grow_profile_for_coplanar(st, r, operation, &targets, &probe)) + }) + .collect::>>()?; // To a curved face: turn past the whole target body, then cut back to its surface // (the piece that starts at the profile). let curved = to.and_then(|p| curved_target_any(st, p)); - if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) { + let curved_body = curved.and_then(|bi| st.bodies.get(bi)); + let build_curved = |regions: &[Region2], angle_axis: Vec3, tb: &ModelBody| -> Result> { let pts: Vec = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect(); let c = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, q| a + *q) * (1.0 / pts.len().max(1) as f64)); let flat = |q: Vec3| { @@ -1685,15 +1786,36 @@ fn feature_tools(doc: &Document, vals: &BTreeMap, f: &Feature, st let u0 = flat(c); let angle_of = |q: Vec3| { let v = flat(q); - let a = (axis_w * -1.0).dot(u0.cross(v)).atan2(u0.dot(v)); + let a = angle_axis.dot(u0.cross(v)).atan2(u0.dot(v)); if a < 0.0 { a + std::f64::consts::TAU } else { a } }; let far = tb.mesh().positions.iter().map(|q| angle_of(*q)).fold(0.0, f64::max); let sweep = (far + 0.05).min(std::f64::consts::TAU - 1e-3); - let bodies = kernel::revolve(&ss.plane, ®ions, o, d, sweep)?; + let bodies = kernel::revolve(&ss.plane, regions, o, d, sweep)?; let n = ss.plane.normal(); let tol_side = |q: Vec3| if flat(q).dot(u0) > 0.0 { ((q - ss.plane.origin).dot(n)).abs() } else { f64::MAX }; - return trim_to_body(bodies, &tb.body, tol_side); + trim_to_body(bodies, &tb.body, tol_side) + }; + if *convention == crate::RevolveConvention::LegacyAxisStart + && let Some(tb) = curved_body + { + return build_curved(®ions, -axis_w, tb); + } + if *convention == crate::RevolveConvention::SketchNormal { + let mut out = Vec::new(); + for (region, region_extent) in regions.iter().zip(®ion_extents) { + let rotation_axis = kernel::canonical_revolve_axis(&ss.plane, region, o, d)?; + if let Some(tb) = curved_body { + out.extend(build_curved(std::slice::from_ref(region), rotation_axis, tb)?); + continue; + } + let bodies = kernel::revolve(&ss.plane, std::slice::from_ref(region), o, d, region_extent.sweep)?; + let m = crate::rigid(Vec3::ZERO, origin_w, rotation_axis, region_extent.start); + for body in bodies { + out.push(if region_extent.start.abs() < 1e-12 { body } else { kernel::transform_matrix(&body, m)? }); + } + } + return Ok(out); } let bodies = kernel::revolve(&ss.plane, ®ions, o, d, ang)?; if start.abs() < 1e-12 { @@ -2565,13 +2687,21 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::Align { .. } | FeatureKind::Remove { .. } => more::eval(doc, vals, f, st), FeatureKind::Sketch { plane, sketch } => { - let fst = state_in_frame(doc, st, f.component); - let (plane, face_warning) = match plane { - PlaneRef::Face { plane: picked, at, name } => face_plane(&fst, picked, *at, name.as_deref()), - other => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None), + let anchor = crate::face_anchor::plane_face_anchor(plane); + let fst = match anchor { + Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?, + None => state_in_frame(doc, st, f.component), + }; + let (plane, face_warning) = match (anchor, plane) { + (Some(_), plane) => (doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?, None), + (None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref()), + (None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None), }; let mut sk = sketch.clone(); - let mut warns = crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk); + let mut warns = match anchor { + Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor), + None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk), + }; drop(fst); sk.refresh_offsets()?; if let Some(w) = face_warning { @@ -2590,6 +2720,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: if !warns.is_empty() { *warning = Some(warns.join("; ")); } + sk.validate_periodic_splines()?; let profiles = find_profiles(&sk); st.sketches.retain(|s| s.feature != f.id); st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles }); @@ -2953,6 +3084,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?); let corner = point(vals, corner)?; let b = kernel::box_solid(corner, corner + s)?; + let b = if *operation == Operation::NewBody { b.with_box_certificate(f.id)? } else { b }; apply_op(st, f, vec![b], *operation, &[]) } FeatureKind::Cylinder { base, axis, radius, height, operation } => { diff --git a/crates/doc/src/face_anchor.rs b/crates/doc/src/face_anchor.rs new file mode 100644 index 00000000..6dde99d2 --- /dev/null +++ b/crates/doc/src/face_anchor.rs @@ -0,0 +1,561 @@ +//! Exact ordinary face attachments. Missing identities never invoke a proximity fallback. +use crate::config::ConfigFaceTarget; +use crate::{DocError, Document, EmbossFace, FeatureKind, Model, ModelBody, ModelState, PlaneRef, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use solvecraft_geom::Plane; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FaceAnchor { + pub target: ConfigFaceTarget, + pub component: u64, + pub path: Vec, + pub author_path: Vec, + /// Origin and x direction in the exact canonical face plane's coordinates. + pub coordinates: [f64; 2], + pub x_axis: [f64; 2], +} + +impl FaceAnchor { + pub fn remap_target( + &mut self, + features: &std::collections::BTreeMap, + bodies: &std::collections::BTreeMap, + ) -> Result<()> { + if bodies.contains_key(&self.target.body) && !features.contains_key(&self.target.origin_feature) { + return Err(DocError::Invalid("cannot clone an attached face without its certified producer".into())); + } + self.target.name = crate::remap_topology(&self.target.name, features, bodies); + if let Some(body) = bodies.get(&self.target.body) { + self.target.body = body.clone(); + } + if let Some(feature) = features.get(&self.target.origin_feature) { + self.target.origin_feature = *feature; + } + if let Some(certificate) = &mut self.target.certificate { + certificate.remap_features(features); + } + self.validate() + } + pub fn validate(&self) -> Result<()> { + self.target.validate()?; + if self.target.certificate.is_none() + || self.path.len() > 64 + || self.author_path.len() > 64 + || self.path.iter().chain(&self.author_path).any(|id| *id == 0) + || (self.component == 0 && !self.path.is_empty()) + || (self.component != 0 && self.path.is_empty()) + || self.coordinates.iter().any(|value| !value.is_finite() || value.abs() > 1e9) + || self.x_axis.iter().any(|value| !value.is_finite()) + || (self.x_axis[0].hypot(self.x_axis[1]) - 1.).abs() > 1e-6 + { + return Err(DocError::Invalid("invalid exact face attachment".into())); + } + Ok(()) + } + pub fn author_frame(&self, doc: &Document, author: u64) -> Result { + if author != 0 && self.author_path.is_empty() { + return Err(DocError::Invalid( + "attached sketch requires an explicit author occurrence path; activate a placement or choose a fixed plane".into(), + )); + } + EmbossFace::component_frame(doc, author, &self.author_path) + } + pub fn source_to_author(&self, doc: &Document, author: u64, body: &ModelBody) -> Result { + self.validate()?; + if doc.model_body_component(body) != self.component { + return Err(DocError::Invalid("attached face's component ownership changed; reattach explicitly".into())); + } + let inverse = crate::mat_inverse(&self.author_frame(doc, author)?) + .ok_or_else(|| DocError::Invalid("attached sketch author frame is invalid".into()))?; + let source = + EmbossFace { body: self.target.body.clone(), at: self.target.point, path: self.path.clone(), author_path: self.author_path.clone() }; + Ok(crate::mat_mul(&inverse, &source.body_frame(doc, body)?)) + } +} + +pub fn plane_face_anchor(plane: &PlaneRef) -> Option<&FaceAnchor> { + match plane { + PlaneRef::Face { anchor, .. } => anchor.as_deref(), + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane_face_anchor(base), + _ => None, + } +} + +fn has_face_reference(plane: &PlaneRef) -> bool { + match plane { + PlaneRef::Face { .. } => true, + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => has_face_reference(base), + _ => false, + } +} + +impl Document { + pub fn resolve_attached_plane( + &self, + vals: &std::collections::BTreeMap, + plane: &PlaneRef, + author: u64, + state: &ModelState, + framed: &ModelState, + ) -> Result { + fn replace(doc: &Document, plane: &mut PlaneRef, author: u64, state: &ModelState, depth: usize) -> Result<()> { + if depth > 64 { + return Err(DocError::Invalid("attached plane nesting exceeds budget".into())); + } + match plane { + PlaneRef::Face { anchor: Some(anchor), .. } => *plane = PlaneRef::Custom { plane: doc.resolve_face_anchor(anchor, author, state)? }, + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => replace(doc, base, author, state, depth + 1)?, + _ => {} + } + Ok(()) + } + let mut resolved = plane.clone(); + replace(self, &mut resolved, author, state, 0)?; + self.resolve_plane_in(vals, &resolved, 0, Some(framed)) + } +} + +/// All status planes are world-space. A lost plane is a repair guide only. +#[derive(Clone, Debug)] +pub enum FaceAttachmentStatus { + Fixed { plane: Option }, + Legacy { plane: Option }, + Healthy { plane: Plane, target: ConfigFaceTarget, path: Vec }, + Lost { message: String, repair_plane: Option }, +} + +fn map_plane(matrix: &crate::Mat, plane: &Plane) -> Result { + Plane::new(crate::apply_point(matrix, plane.origin), crate::apply_vector(matrix, plane.x), crate::apply_vector(matrix, plane.y)) + .ok_or_else(|| DocError::Invalid("attached plane transform is degenerate".into())) +} + +impl Document { + /// Capture a known selected face in the definition model, without another nearest pick. + pub fn capture_face_anchor( + &self, + body: &ModelBody, + index: usize, + picked: &Plane, + target_path: &[u64], + author: u64, + author_path: &[u64], + ) -> Result { + let source = body + .body + .face_plane(index) + .ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into()))?; + let target = self.strong_face_target(body, index, source.origin)?; + if target.certificate.is_none() { + return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into())); + } + self.configuration_face_indices(body, &target)?; + let mut anchor = FaceAnchor { + target, + component: self.model_body_component(body), + path: target_path.to_vec(), + author_path: author_path.to_vec(), + coordinates: [0., 0.], + x_axis: [1., 0.], + }; + let canonical = map_plane(&anchor.source_to_author(self, author, body)?, &source)?; + if picked.normal().dot(canonical.normal()) < 1. - 1e-7 || (picked.origin - canonical.origin).dot(canonical.normal()).abs() > 1e-7 { + return Err(DocError::Invalid("picked plane does not match its exact source face".into())); + } + let delta = picked.origin - canonical.origin; + anchor.coordinates = [delta.dot(canonical.x), delta.dot(canonical.y)]; + anchor.x_axis = [picked.x.dot(canonical.x), picked.x.dot(canonical.y)]; + anchor.validate()?; + Ok(anchor) + } + + pub fn resolve_face_anchor(&self, anchor: &FaceAnchor, author: u64, state: &ModelState) -> Result { + anchor.validate()?; + let body = + state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("attached body `{}` no longer exists", anchor.target.body)))?; + let indices = self.configuration_face_indices(body, &anchor.target)?; + let first = indices.first().copied().ok_or_else(|| DocError::Unknown("attached face no longer exists".into()))?; + let plane = body.body.face_plane(first).ok_or_else(|| DocError::Invalid("attached face no longer has an exact planar source".into()))?; + for index in indices.iter().skip(1) { + let other = body.body.face_plane(*index).ok_or_else(|| DocError::Invalid("attached face pieces are not exactly planar".into()))?; + if plane.normal().dot(other.normal()) < 1. - 1e-8 || (other.origin - plane.origin).dot(plane.normal()).abs() > 1e-7 { + return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into())); + } + } + let canonical = map_plane(&anchor.source_to_author(self, author, body)?, &plane)?; + let origin = canonical.origin + canonical.x * anchor.coordinates[0] + canonical.y * anchor.coordinates[1]; + let x = canonical.x * anchor.x_axis[0] + canonical.y * anchor.x_axis[1]; + Plane::new(origin, x, canonical.normal().cross(x)).ok_or_else(|| DocError::Invalid("attached sketch basis is degenerate".into())) + } + + pub fn sketch_face_attachment(&self, feature: u64, model: &Model) -> FaceAttachmentStatus { + let Some(feature) = self.feature(feature) else { + return FaceAttachmentStatus::Lost { message: "sketch no longer exists".into(), repair_plane: None }; + }; + let FeatureKind::Sketch { plane, .. } = &feature.kind else { + return FaceAttachmentStatus::Fixed { plane: None }; + }; + let Some(anchor) = plane_face_anchor(plane) else { + let path = self.first_path(feature.component); + let unique = path.iter().all(|id| { + self.occurrences + .iter() + .find(|o| o.id == *id) + .is_some_and(|selected| self.occurrences.iter().filter(|o| o.component == selected.component).count() == 1) + }); + let world = unique.then(|| EmbossFace::component_frame(self, feature.component, &path).ok()).flatten(); + let current = model.state().sketch(feature.id).map(|sketch| sketch.plane).or_else(|| { + let (vals, _) = self.param_values(); + self.resolve_plane(&vals, plane, 0).ok() + }); + let current = world.and_then(|world| current.and_then(|plane| map_plane(&world, &plane).ok())); + return if has_face_reference(plane) { + FaceAttachmentStatus::Legacy { plane: current } + } else { + FaceAttachmentStatus::Fixed { plane: current } + }; + }; + let (vals, _) = self.param_values(); + let picked = self.resolve_plane(&vals, plane, 0).ok(); + let world = anchor.author_frame(self, feature.component); + let repair_plane = world.as_ref().ok().and_then(|matrix| picked.as_ref().and_then(|picked| map_plane(matrix, picked).ok())); + let state = model.state_before(feature.id); + match crate::state_in_face_anchor_frame(self, &state, anchor, feature.component) + .and_then(|framed| self.resolve_attached_plane(&vals, plane, feature.component, &state, &framed)) + .and_then(|plane| map_plane(&world?, &plane)) + { + Ok(plane) => FaceAttachmentStatus::Healthy { plane, target: anchor.target.clone(), path: anchor.path.clone() }, + Err(error) => FaceAttachmentStatus::Lost { message: format!("lost face attachment on `{}`: {error}", anchor.target.body), repair_plane }, + } + } +} + +pub fn validate_face_anchor_json(value: &Value) -> Result<()> { + let fields = value.as_object().ok_or_else(|| DocError::Invalid("face anchor must be an object".into()))?; + if fields.len() != 6 || fields.keys().any(|key| !["target", "component", "path", "author_path", "coordinates", "x_axis"].contains(&key.as_str())) + { + return Err(DocError::Invalid("unknown or missing face anchor fields".into())); + } + let target = fields.get("target").ok_or_else(|| DocError::Invalid("face anchor target missing".into()))?; + crate::config::validate_face_target_json(target)?; + if target.get("certificate").is_none_or(Value::is_null) { + return Err(DocError::Invalid("face anchor needs a source certificate".into())); + } + let component = + fields.get("component").and_then(Value::as_u64).ok_or_else(|| DocError::Invalid("face anchor component must be an integer".into()))?; + for key in ["path", "author_path"] { + if fields.get(key).and_then(Value::as_array).is_none_or(|path| { + path.len() > 64 + || path.iter().any(|id| id.as_u64().is_none_or(|id| id == 0)) + || (key == "path" && component == 0 && !path.is_empty()) + || (key == "path" && component != 0 && path.is_empty()) + }) { + return Err(DocError::Invalid("face anchor path is invalid or oversized".into())); + } + } + for key in ["coordinates", "x_axis"] { + let values = fields + .get(key) + .and_then(Value::as_array) + .filter(|values| values.len() == 2) + .ok_or_else(|| DocError::Invalid("face anchor vectors require exactly two values".into()))?; + if values.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9)) { + return Err(DocError::Invalid("face anchor vector is invalid".into())); + } + if key == "x_axis" && (values[0].as_f64().unwrap_or(0.).hypot(values[1].as_f64().unwrap_or(0.)) - 1.).abs() > 1e-6 { + return Err(DocError::Invalid("face anchor basis must be normalized".into())); + } + } + Ok(()) +} + +/// Walk reference-bearing JSON before typed decoding, excluding bulk geometry payloads. +pub fn validate_face_anchors_json(value: &Value) -> Result<()> { + let mut stack = vec![(value, 0usize)]; + let mut visited = 0usize; + while let Some((value, depth)) = stack.pop() { + visited += 1; + if visited > 2_000_000 || depth > 128 { + return Err(DocError::Invalid("face anchor structure exceeds work limits".into())); + } + match value { + Value::Object(fields) => { + if fields.get("type").and_then(Value::as_str) == Some("face") + && let Some(anchor) = fields.get("anchor") + && !anchor.is_null() + { + validate_face_anchor_json(anchor)?; + } + for (key, child) in fields { + if !["sketch", "meshes", "step", "configs", "appearances", "materials"].contains(&key.as_str()) + && matches!(child, Value::Array(_) | Value::Object(_)) + { + stack.push((child, depth + 1)); + } + } + } + Value::Array(values) => { + for child in values { + if matches!(child, Value::Array(_) | Value::Object(_)) { + stack.push((child, depth + 1)); + } + } + } + _ => {} + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use solvecraft_geom::Vec3; + fn fixture() -> (Document, ModelBody, usize, Plane) { + let mut doc = Document::default(); + let kind: FeatureKind = + serde_json::from_value(serde_json::json!({"type":"box","corner":[0,0,0],"length":"10","width":"10","height":"10"})).unwrap(); + let id = doc.add_feature(kind, Some("Base")).unwrap(); + let body = ModelBody::new( + "Base".into(), + solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(id).unwrap(), + id, + ); + let index = body + .body + .face_sources() + .unwrap() + .iter() + .position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::Box { axis: 2, high: true, .. }))) + .unwrap(); + let canonical = body.body.face_plane(index).unwrap(); + let picked = Plane::new(canonical.origin + canonical.x * 2. + canonical.y * 3., canonical.y, -canonical.x).unwrap(); + (doc, body, index, picked) + } + #[test] + fn exact_anchor_preserves_picked_basis_and_follows_height_without_parallel_rescue() { + let (doc, body, index, picked) = fixture(); + let anchor = doc.capture_face_anchor(&body, index, &picked, &[], 0, &[]).unwrap(); + let anchor: FaceAnchor = serde_json::from_str(&serde_json::to_string(&anchor).unwrap()).unwrap(); + assert!(anchor.author_frame(&doc, 1).is_err(), "strong author attachment cannot infer a first placement"); + let mut missing_source = anchor.clone(); + missing_source.component = 1; + assert!(missing_source.validate().is_err(), "strong source attachment needs an explicit path"); + assert!(validate_face_anchor_json(&serde_json::to_value(missing_source).unwrap()).is_err()); + let mut state = ModelState::default(); + state.bodies.push(body); + let initial = doc.resolve_face_anchor(&anchor, 0, &state).unwrap(); + assert!((initial.origin - picked.origin).len() < 1e-9); + assert!((initial.x - picked.x).len() < 1e-9); + state.bodies[0] = ModelBody::new( + "Base".into(), + solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 20.)) + .unwrap() + .with_box_certificate(anchor.target.origin_feature) + .unwrap(), + anchor.target.origin_feature, + ); + let changed = doc.resolve_face_anchor(&anchor, 0, &state).unwrap(); + assert!((changed.origin.z - 20.).abs() < 1e-9); + assert!((changed.origin.x - initial.origin.x).abs() < 1e-9); + state.bodies[0] = ModelBody::new( + "Control".into(), + solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 30.)) + .unwrap() + .with_box_certificate(anchor.target.origin_feature) + .unwrap(), + anchor.target.origin_feature, + ); + assert!(doc.resolve_face_anchor(&anchor, 0, &state).is_err()); + state.bodies[0].name = "Base".into(); + state.bodies[0].body = solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 30.)) + .unwrap() + .with_box_certificate(anchor.target.origin_feature + 1) + .unwrap(); + assert!(doc.resolve_face_anchor(&anchor, 0, &state).is_err(), "reused name cannot replace the producer"); + } + #[test] + fn nested_anchor_offset_tracks_source_and_raw_budgets_precede_decode() { + let (doc, body, index, picked) = fixture(); + let anchor = doc.capture_face_anchor(&body, index, &picked, &[], 0, &[]).unwrap(); + let plane = PlaneRef::Offset { + base: Box::new(PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor.clone())) }), + distance: "2".into(), + }; + let mut state = ModelState::default(); + state.bodies.push(body); + let (vals, _) = doc.param_values(); + let result = doc.resolve_attached_plane(&vals, &plane, 0, &state, &state).unwrap(); + assert!((result.origin.z - 12.).abs() < 1e-9); + state.bodies.clear(); + assert!(doc.resolve_attached_plane(&vals, &plane, 0, &state, &state).is_err()); + let mut raw = serde_json::to_value(anchor).unwrap(); + assert!(validate_face_anchor_json(&raw).is_ok()); + raw["path"] = serde_json::json!([1]); + assert!(validate_face_anchor_json(&raw).is_err()); + raw["path"] = serde_json::json!([]); + raw["target"]["name"] = Value::String("x".repeat(1_000_000)); + assert!(validate_face_anchor_json(&raw).is_err()); + } + #[test] + fn extrude_cap_profile_reordering_never_retargets_an_ordinal_body() { + use solvecraft_geom::Vec2; + let mut sketch = solvecraft_sketch::Sketch::new(); + for offset in [0., 30.] { + let points: Vec<_> = [(0., 0.), (10., 0.), (10., 10.), (0., 10.)] + .iter() + .map(|(x, y)| sketch.add_point(Vec2::new(x + offset, *y), None).unwrap()) + .collect(); + for edge in 0..4 { + sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap(); + } + } + let mut doc = Document::default(); + let source = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap(); + let extent: crate::Extent = serde_json::from_value(serde_json::json!({"distance":"10"})).unwrap(); + let producer = doc + .add_feature( + FeatureKind::Extrude { + sketch: source, + profiles: crate::ProfileSel::Indices { indices: vec![0, 1] }, + extent, + operation: crate::Operation::NewBody, + targets: vec![], + }, + None, + ) + .unwrap(); + let mut model = Model::new(); + model.evaluate(&doc); + let state = model.state(); + assert_eq!(state.bodies.len(), 2); + let solved = state.sketch(source).unwrap(); + let regions = crate::select_profiles(solved, &crate::ProfileSel::All).unwrap(); + let raw = solvecraft_kernel::extrude(&solved.plane, ®ions, 0., 10.).unwrap(); + assert!( + crate::config::extrude_cap_bindings(solved, &raw, 0).is_none(), + "exhausted certification work must leave the complete producer uncertified" + ); + let bindings = crate::config::extrude_cap_bindings(solved, &raw, 10_000_000).unwrap(); + assert_eq!(bindings.iter().flatten().flatten().count(), 4, "two profiles each retain both exact caps within budget"); + let mut duplicate = raw.clone(); + duplicate.extend(raw); + let ambiguous = crate::config::extrude_cap_bindings(solved, &duplicate, 10_000_000).unwrap(); + assert!(ambiguous.iter().flatten().all(Option::is_none), "duplicated profile identities cannot stamp ambiguous caps"); + let body = &state.bodies[0]; + let index = body + .body + .face_sources() + .unwrap() + .iter() + .position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { high: true, .. }))) + .unwrap(); + let picked = body.body.face_plane(index).unwrap(); + let anchor = doc.capture_face_anchor(body, index, &picked, &[], 0, &[]).unwrap(); + let bounds = body.mesh().bounds(); + let FeatureKind::Extrude { profiles, .. } = &mut doc.feature_mut(producer).unwrap().kind else { + panic!("fixture must retain its extrude feature") + }; + *profiles = crate::ProfileSel::Indices { indices: vec![1, 0] }; + model.evaluate(&doc); + let changed = model.state(); + assert_eq!(changed.bodies.len(), 2); + if doc.resolve_face_anchor(&anchor, 0, &changed).is_ok() { + let current = changed.body(&anchor.target.body).unwrap().mesh().bounds(); + assert!((current.min.x - bounds.min.x).abs() < 1e-9, "a same-role cap of the other profile must never replace the source"); + } + } + #[test] + fn wrapped_legacy_face_is_reported_as_legacy_without_inferred_upgrade() { + let (mut doc, _, _, picked) = fixture(); + let id = doc + .add_feature( + FeatureKind::Sketch { + plane: PlaneRef::Offset { + base: Box::new(PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: None }), + distance: "2".into(), + }, + sketch: solvecraft_sketch::Sketch::new(), + }, + None, + ) + .unwrap(); + let mut model = Model::new(); + model.evaluate(&doc); + let FaceAttachmentStatus::Legacy { plane: Some(plane) } = doc.sketch_face_attachment(id, &model) else { + panic!("wrapped legacy attachment must retain its classification and fixed-plane escape"); + }; + assert!((plane.origin.z - 12.).abs() < 1e-9); + let FeatureKind::Sketch { plane, .. } = &doc.feature(id).unwrap().kind else { panic!("fixture must retain its sketch feature") }; + assert!(plane_face_anchor(plane).is_none()); + } + #[test] + fn warm_root_body_offset_edit_matches_cold_attached_sketch_and_downstream_bounds() { + use solvecraft_geom::Vec2; + let (mut doc, _, _, _) = fixture(); + let mut warm = Model::new(); + warm.evaluate(&doc); + let source = warm.state(); + let body = &source.bodies[0]; + let index = body + .body + .face_sources() + .unwrap() + .iter() + .position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::Box { axis: 2, high: true, .. }))) + .unwrap(); + let picked = body.body.face_plane(index).unwrap(); + let anchor = doc.capture_face_anchor(body, index, &picked, &[], 0, &[]).unwrap(); + let source_name = body.name.clone(); + let mut sketch = solvecraft_sketch::Sketch::new(); + let points: Vec<_> = + [(2., 2.), (4., 2.), (4., 4.), (2., 4.)].iter().map(|(x, y)| sketch.add_point(Vec2::new(*x, *y), None).unwrap()).collect(); + for edge in 0..4 { + sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap(); + } + let sk = doc + .add_feature( + FeatureKind::Sketch { + plane: PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor)) }, + sketch, + }, + None, + ) + .unwrap(); + let extent = serde_json::from_value(serde_json::json!({"distance":"2"})).unwrap(); + let boss = doc + .add_feature( + FeatureKind::Extrude { sketch: sk, profiles: crate::ProfileSel::All, extent, operation: crate::Operation::NewBody, targets: vec![] }, + Some("Boss"), + ) + .unwrap(); + warm.evaluate(&doc); + let initial = warm.state(); + let before = initial.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds(); + let mut offset = crate::IDENTITY; + offset[3][0] = 5.; + offset[3][2] = 3.; + doc.body_offsets.insert(source_name, offset); + warm.evaluate(&doc); + let mut cold = Model::new(); + cold.evaluate(&doc); + let warm_state = warm.state(); + let cold_state = cold.state(); + let after = warm_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds(); + let rebuilt = cold_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds(); + assert!( + (after.min - before.min - Vec3::new(5., 0., 3.)).len() < 1e-7, + "before={before:?}, warm={after:?}, cold={rebuilt:?}, warm_plane={:?}, cold_plane={:?}", + warm_state.sketch(sk).map(|sk| sk.plane), + cold_state.sketch(sk).map(|sk| sk.plane) + ); + assert!((after.min - rebuilt.min).len() < 1e-7); + assert!((after.max - rebuilt.max).len() < 1e-7); + let FaceAttachmentStatus::Healthy { plane, .. } = doc.sketch_face_attachment(sk, &warm) else { + panic!("offset source must remain attached"); + }; + assert!((plane.origin - warm_state.sketch(sk).unwrap().plane.origin).len() < 1e-9); + assert!((warm_state.sketch(sk).unwrap().plane.origin - cold_state.sketch(sk).unwrap().plane.origin).len() < 1e-9); + } +} diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 6e9bf80b..e60ec16f 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -31,13 +31,15 @@ //! - 10: timeline body transfers, explicit 3D wire coordinate spaces and certified face //! bindings. Legacy ownership maps remain unchanged. Retained wire associations determine //! their space; saved coordinates are never rewritten during migration. +//! - 11: durable sketch face anchors, explicitly periodic fit splines and versioned revolve +//! conventions. Absent markers retain legacy attachment and interpolation behavior. use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 10; +pub const FORMAT: u32 = 11; pub fn format_string(n: u32) -> String { format!("solvecraft/{n}") @@ -50,7 +52,7 @@ pub fn version_of(s: &str) -> Option { /// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`. type JsonStep = fn(&mut Value) -> Result<()>; -const JSON_STEPS: [JsonStep; 9] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())]; +const JSON_STEPS: [JsonStep; 10] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())]; /// Old XZ references retain their exact frame, including nested offset and angled planes. /// New origin references use +Y; freezing the old frame avoids changing sketch coordinates, @@ -143,6 +145,8 @@ pub fn read(s: &str) -> Result { check_numbers(&v, 0)?; crate::config::validate_rules_json(&v)?; crate::config::validate_styles_json(&v)?; + crate::face_anchor::validate_face_anchors_json(&v)?; + validate_versioned_geometry(&v)?; validate_emboss_targets(&v)?; validate_freeform_offsets(&v)?; let legacy_wires = validate_wires(&v, version)?; @@ -184,6 +188,7 @@ pub fn read(s: &str) -> Result { if let crate::FeatureKind::Sketch { sketch, .. } = &feature.kind { sketch.validate_freeform_offsets().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?; sketch.validate_wires().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?; + sketch.validate_periodic_splines().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?; } if let crate::FeatureKind::BodyTransfer { from, to, .. } = &feature.kind && [from, to].into_iter().any(|id| !component_ids.contains(id)) @@ -351,6 +356,58 @@ fn validate_emboss_targets(value: &Value) -> Result<()> { Ok(()) } +/// Bound explicitly versioned geometry before allocating typed spline point arrays. +fn validate_versioned_geometry(value: &Value) -> Result<()> { + let invalid = || DocError::Invalid("damaged design: invalid periodic spline or revolve convention".into()); + let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) }; + for feature in features { + if feature.get("type").and_then(Value::as_str) == Some("revolve") + && let Some(convention) = feature.get("convention") + && !matches!(convention.as_str(), Some("legacy_axis_start" | "sketch_normal")) + { + return Err(invalid()); + } + if feature.get("type").and_then(Value::as_str) != Some("sketch") { + continue; + } + let Some(sketch) = feature.get("sketch") else { continue }; + if let Some(links) = sketch.get("links").and_then(Value::as_array) { + for link in links { + if link.get("auto_face").is_some_and(|marker| !marker.is_boolean()) { + return Err(invalid()); + } + } + } + let Some(curves) = sketch.get("curves").and_then(Value::as_array) else { continue }; + let points = sketch.get("points").and_then(Value::as_array); + let mut work = 0usize; + for curve in curves { + if curve.get("type").and_then(Value::as_str) != Some("spline") { + continue; + } + let Some(marker) = curve.get("periodic") else { continue }; + let periodic = marker.as_bool().ok_or_else(invalid)?; + if !periodic { + continue; + } + let indices = curve.get("pts").and_then(Value::as_array).ok_or_else(invalid)?; + work = work.saturating_add(indices.len()); + if !(4..=solvecraft_sketch::MAX_SPLINE_POINTS).contains(&indices.len()) + || work > 1_000_000 + || curve.get("control").is_some_and(|control| control.as_bool() != Some(false)) + || curve.get("degree").is_some_and(|degree| degree.as_u64() != Some(3)) + { + return Err(invalid()); + } + let count = points.ok_or_else(invalid)?.len(); + if indices.iter().any(|index| index.as_u64().is_none_or(|index| index >= count as u64)) { + return Err(invalid()); + } + } + } + Ok(()) +} + fn validate_freeform_offsets(value: &Value) -> Result<()> { let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) }; for feature in features { @@ -363,6 +420,27 @@ fn validate_freeform_offsets(value: &Value) -> Result<()> { if records.len() > 128 { return Err(invalid()); } + let periodic_outputs: std::collections::BTreeSet<_> = records + .iter() + .filter(|record| record.get("certificate").and_then(Value::as_u64) == Some(2)) + .filter_map(|record| record.get("output").and_then(Value::as_str)) + .collect(); + let mut periodic_curves = std::collections::BTreeMap::new(); + if !periodic_outputs.is_empty() + && let Some(curves) = feature.get("sketch").and_then(|sketch| sketch.get("curves")).and_then(Value::as_array) + { + if curves.len() > 1_000_000 { + return Err(invalid()); + } + for curve in curves { + if let Some(id) = curve.get("id").and_then(Value::as_str) + && periodic_outputs.contains(id) + && periodic_curves.insert(id, curve).is_some() + { + return Err(invalid()); + } + } + } let mut outputs = std::collections::BTreeSet::new(); for record in records { let object = record.as_object().ok_or_else(invalid)?; @@ -396,12 +474,22 @@ fn validate_freeform_offsets(value: &Value) -> Result<()> { || !(1e-9..=MAX_REAL).contains(&distance.abs()) || !tolerance.is_finite() || !(tolerance > 0.0 && tolerance <= 0.002) - || record.get("certificate").and_then(Value::as_u64) != Some(1) + || !matches!(record.get("certificate").and_then(Value::as_u64), Some(1 | 2)) || record.get("forward").and_then(Value::as_bool).is_none() || record.get("closed").and_then(Value::as_bool).is_none() { return Err(invalid()); } + if record.get("certificate").and_then(Value::as_u64) == Some(2) { + let output_curve = periodic_curves.get(output); + if record.get("closed").and_then(Value::as_bool) != Some(true) + || output_curve.is_none_or(|curve| { + curve.get("type").and_then(Value::as_str) != Some("spline") || curve.get("periodic").and_then(Value::as_bool) != Some(true) + }) + { + return Err(invalid()); + } + } } let mut resolved = std::collections::BTreeSet::new(); for record in records { @@ -673,6 +761,129 @@ mod tests { assert!(error.contains("damaged design"), "{error}"); } + #[test] + fn malformed_face_anchors_are_refused_before_typed_decoding() { + let anchor = serde_json::json!({ + "target":{"body":"Base", "origin_feature":1, "name":"Base:+z", "point":[0,0,10], + "certificate":{"kind":"box", "axis":2, "high":true}}, + "component":0, "path":[], "author_path":[], "coordinates":[0,0], "x_axis":[1,0] + }); + let make = |anchor: Value| { + serde_json::json!({"features":[{"type":"sketch", "plane":{ + "type":"face", "anchor":anchor + }}]}) + }; + assert!(crate::face_anchor::validate_face_anchors_json(&make(anchor.clone())).is_ok()); + let mut nested = serde_json::json!({"features":[{"type":"sketch", "plane":{ + "type":"offset", "base":{"type":"at_angle", "base":{"type":"face", "anchor":anchor.clone()}} + }}]}); + assert!(crate::face_anchor::validate_face_anchors_json(&nested).is_ok()); + nested["features"][0]["plane"]["base"]["base"]["anchor"]["coordinates"] = serde_json::json!([0, 0, 0]); + assert!(crate::face_anchor::validate_face_anchors_json(&nested).is_err()); + let mut malformed = Vec::new(); + for (key, damaged) in [ + ("component", serde_json::json!(-1)), + ("path", serde_json::json!([0])), + ("author_path", serde_json::json!((1..=65).collect::>())), + ("coordinates", serde_json::json!([1_000_000_001_u64, 0])), + ("coordinates", serde_json::json!([0])), + ("x_axis", serde_json::json!([0, 0])), + ("unknown", serde_json::json!(true)), + ] { + let mut candidate = anchor.clone(); + candidate[key] = damaged; + malformed.push(candidate); + } + let mut missing_certificate = anchor.clone(); + missing_certificate["target"].as_object_mut().unwrap().remove("certificate"); + malformed.push(missing_certificate); + let mut invalid_role = anchor; + invalid_role["target"]["certificate"]["axis"] = Value::from(3); + malformed.push(invalid_role); + for candidate in malformed { + let value = make(candidate); + assert!(crate::face_anchor::validate_face_anchors_json(&value).is_err()); + let error = read(&value.to_string()).unwrap_err().to_string(); + assert!(!error.contains("design file:"), "must refuse before typed decode: {error}"); + } + } + + #[test] + fn periodic_markers_are_bounded_before_typed_decoding() { + let curve = serde_json::json!({"id":"closed", "type":"spline", "pts":[0,1,2,3], "periodic":true}); + let make = |curve: Value| { + serde_json::json!({"features":[{"type":"sketch", "sketch":{ + "points":[{}, {}, {}, {}], "curves":[curve] + }}]}) + }; + assert!(validate_versioned_geometry(&make(curve.clone())).is_ok()); + for (key, malformed) in [ + ("periodic", serde_json::json!("true")), + ("control", serde_json::json!(true)), + ("degree", serde_json::json!(2)), + ("pts", serde_json::json!([0, 1, 2])), + ("pts", serde_json::json!([0, 1, 2, u64::MAX])), + ("pts", serde_json::json!(vec![0; solvecraft_sketch::MAX_SPLINE_POINTS + 1])), + ] { + let mut damaged = curve.clone(); + damaged[key] = malformed; + assert!(validate_versioned_geometry(&make(damaged)).is_err(), "{key}"); + } + let mut legacy = curve; + legacy.as_object_mut().unwrap().remove("periodic"); + legacy["control"] = Value::Bool(true); + assert!(validate_versioned_geometry(&make(legacy)).is_ok()); + for convention in [serde_json::json!(null), serde_json::json!(false), serde_json::json!("unknown")] { + assert!(validate_versioned_geometry(&serde_json::json!({"features":[{"type":"revolve", "convention":convention}]})).is_err()); + } + } + + #[test] + fn periodic_offset_certificate_requires_closed_periodic_output() { + let record = serde_json::json!({"source":"ellipse", "output":"offset", "forward":true, + "distance":2.0, "divisions":8, "pieces":4, "closed":true, + "tolerance":0.002, "certificate":2}); + let mut value = serde_json::json!({"features":[{"type":"sketch", "sketch":{ + "freeform_offsets":[record], "curves":[{"id":"offset", "type":"spline", "periodic":true}] + }}]}); + assert!(validate_freeform_offsets(&value).is_ok()); + let duplicate = value["features"][0]["sketch"]["curves"][0].clone(); + value["features"][0]["sketch"]["curves"].as_array_mut().unwrap().push(duplicate); + assert!(validate_freeform_offsets(&value).is_err(), "periodic output ID must be unique"); + value["features"][0]["sketch"]["curves"].as_array_mut().unwrap().pop(); + value["features"][0]["sketch"]["curves"][0]["periodic"] = Value::Bool(false); + assert!(validate_freeform_offsets(&value).is_err()); + value["features"][0]["sketch"]["curves"][0]["periodic"] = Value::Bool(true); + value["features"][0]["sketch"]["freeform_offsets"][0]["closed"] = Value::Bool(false); + assert!(validate_freeform_offsets(&value).is_err()); + value["features"][0]["sketch"]["freeform_offsets"][0]["certificate"] = Value::from(1); + assert!(validate_freeform_offsets(&value).is_ok(), "legacy certificate semantics remain valid"); + } + + #[test] + fn periodic_file_validation_refuses_damaged_closed_geometry() { + use solvecraft_geom::Vec2; + use solvecraft_sketch::{CurveKind, Sketch}; + let mut sketch = Sketch::new(); + let mut pts = Vec::new(); + for point in [Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 10.0), Vec2::ZERO] { + pts.push(sketch.add_point(point, None).unwrap()); + } + let first = pts[0]; + let neighbor = pts[1]; + let closing = pts[3]; + sketch.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true }, None).unwrap(); + let mut document = Document::new("periodic file validation"); + document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap(); + assert_eq!(read(&document.to_json()).unwrap(), document); + let mut value: Value = serde_json::from_str(&document.to_json()).unwrap(); + value["features"][0]["sketch"]["points"][closing]["pos"] = serde_json::json!([1, 0]); + assert!(read(&value.to_string()).unwrap_err().to_string().contains("periodic")); + value["features"][0]["sketch"]["points"][closing]["pos"] = value["features"][0]["sketch"]["points"][first]["pos"].clone(); + value["features"][0]["sketch"]["points"][neighbor]["pos"] = value["features"][0]["sketch"]["points"][first]["pos"].clone(); + assert!(read(&value.to_string()).unwrap_err().to_string().contains("periodic")); + } + #[test] fn freeform_offset_metadata_is_bounded_before_typed_decoding() { let valid = serde_json::json!({"source":"ellipse", "output":"offset", "forward":true, diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 4e8d6557..02e849a1 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -18,6 +18,8 @@ pub mod construct; mod document; mod eval; pub mod expr; +pub mod face_anchor; +pub use face_anchor::{FaceAnchor, FaceAttachmentStatus}; pub mod format; pub mod joints; pub mod motion; @@ -33,7 +35,8 @@ pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_iden pub use document::*; pub use eval::{ EdgeRef, FeatureResult, Model, ModelBody, ModelState, RevolveExtent, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, object_height, - parse_metric_thread, pattern_matrices, revolve_extent, select_profiles, state_in_frame, state_in_frame_at, through_all_distance, world_state, + parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, state_in_frame, state_in_frame_at, + through_all_distance, world_state, }; pub use params::{ParamRow, ParamUser, cycles}; pub use solvecraft_kernel as kernel; @@ -55,5 +58,10 @@ pub enum DocError { pub type Result = std::result::Result; +#[cfg(test)] +mod named_projection_tests; #[cfg(test)] mod tests; + +mod anchor_frame; +pub use anchor_frame::state_in_face_anchor_frame; diff --git a/crates/doc/src/named_projection_tests.rs b/crates/doc/src/named_projection_tests.rs new file mode 100644 index 00000000..201f30b8 --- /dev/null +++ b/crates/doc/src/named_projection_tests.rs @@ -0,0 +1,50 @@ +use crate::{Document, ModelBody, ModelState}; +use solvecraft_geom::{Plane, Vec3}; +use solvecraft_sketch::{LinkKind, LinkSource, Sketch}; +use std::collections::BTreeMap; +use std::sync::Arc; + +fn named_box(name: &str, width: f64, height: f64) -> ModelBody { + let body = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(width, width, height)).unwrap(); + let mut names = vec!["unrelated".to_string(); body.faces(0.01).unwrap().len()]; + let top = body.faces(0.01).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.99)).unwrap().index; + names[top] = "shared:top".into(); + let mut result = ModelBody::new(name.into(), body, 1); + result.names = crate::naming::NameCell::known(Arc::new(names)); + result +} + +#[test] +fn named_manual_projection_keeps_its_body_and_face_at_shared_boundaries() { + let doc = Document::default(); + let mut initial = ModelState::default(); + initial.bodies.extend([named_box("Source", 20., 10.), named_box("Control", 5., 10.)]); + let plane = Plane::XY.offset(20.); + let source = LinkSource::Face { body: "Source".into(), at: Vec3::new(1., 1., 10.) }; + let resolved = crate::project::resolve(&doc, &BTreeMap::new(), &initial, &plane, LinkKind::Project, &source).unwrap(); + let mut sketch = Sketch::default(); + let link = sketch.add_link(LinkKind::Project, source, &resolved.geom).unwrap(); + sketch.links[0].face_name = Some("shared:top".into()); + sketch.links[0].source = LinkSource::Face { body: "Source".into(), at: Vec3::new(0., 0., 10.) }; + let mut current = ModelState::default(); + current.bodies.extend([named_box("Source", 20., 20.), named_box("Control", 5., 10.)]); + assert!(crate::project::refresh_links(&doc, &BTreeMap::new(), ¤t, &plane, &mut sketch).is_empty()); + assert!(!sketch.links[0].lost); + assert!(sketch.points.iter().any(|point| point.pos.x > 19.)); + assert!(sketch.points.iter().any(|point| point.pos.y > 19.)); + assert!(matches!(&sketch.links[0].source,LinkSource::Face{body,at} if body=="Source" && (at.z-20.).abs()<1e-6)); + // The closest named point can sit on the wall/top seam: retain its actual + // face index rather than re-picking the adjacent vertical wall. + let index = crate::naming::face_on_body(current.body("Source").unwrap(), "shared:top", Vec3::new(0., 0., 10.)).unwrap().0; + assert!(current.body("Source").unwrap().body.faces(0.01).unwrap()[index].plane_normal.unwrap().z > 0.99); + let saved = sketch.clone(); + let mut missing = ModelState::default(); + missing.bodies.push(named_box("Control", 5., 10.)); + let warnings = crate::project::refresh_links(&doc, &BTreeMap::new(), &missing, &plane, &mut sketch); + assert!(sketch.links[0].lost); + assert!(!warnings.is_empty()); + assert_eq!(sketch.curves, saved.curves); + assert_eq!(sketch.points, saved.points); + assert_eq!(sketch.links[0].id, link); + assert!(matches!(&sketch.links[0].source,LinkSource::Face{body,..} if body=="Source")); +} diff --git a/crates/doc/src/naming.rs b/crates/doc/src/naming.rs index d2c92816..133958bf 100644 --- a/crates/doc/src/naming.rs +++ b/crates/doc/src/naming.rs @@ -655,6 +655,21 @@ pub fn face_names_at(st: &ModelState, pts: &[Vec3]) -> Vec { .collect() } +/// Capture a face name from the recorded projection body only. +pub fn face_name_at_body(body: &ModelBody, point: Vec3) -> Option { + if body.body.is_mesh() || !point.is_finite() { + return None; + } + let mesh = body.mesh(); + let count = face_count(body); + if mesh.triangles.len() > 2_000_000 || count > 50_000 { + return None; + } + let tolerance = (body.body.size() * 1e-3).max(1e-3); + let index = FaceIndex::new(&mesh, count).face_at(point, tolerance)?; + face_names(body).get(index).filter(|name| !name.is_empty()).cloned() +} + /// The point on the face with this name nearest `p` (a split face: its nearest piece, with /// `true` for "it was split"); `None` when no face has the name. pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool)> { @@ -679,3 +694,31 @@ pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool } best.map(|(_, q, split)| (q, split)) } + +/// Resolve a legacy named face only on its recorded source body. Return the +/// selected face index as well as the closest point, which may lie on a shared +/// edge and therefore must not be used to pick the face a second time. +pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec3, bool)> { + if body.body.is_mesh() { + return None; + } + let names = face_names(body); + let mesh = body.mesh(); + let stem = strip_piece(name); + let mut best: Option<(f64, usize, Vec3, bool)> = None; + for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) { + let index = usize::try_from(*face).ok()?; + let Some(candidate) = names.get(index) else { continue }; + let exact = candidate == name; + if !exact && strip_piece(candidate) != stem { + continue; + } + let Some([a, b, c]) = mesh.tri(triangle) else { continue }; + let q = closest(p, a, b, c); + let distance = q.dist(p) - if exact { 1e-9 } else { 0. }; + if best.is_none_or(|(old, _, _, _)| distance < old) { + best = Some((distance, index, q, !exact)); + } + } + best.map(|(_, index, q, split)| (index, q, split)) +} diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index 677d72b1..9d994b74 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -32,6 +32,9 @@ pub struct Resolved { pub source: LinkSource, /// The reference was not exactly where it was and was re-found on the nearest geometry. pub moved: bool, + /// Exact face selected during this resolution, before its closest point is + /// projected onto an edge shared with another face. Computed metadata only. + pub face_index: Option, } /// Resolutions of body-based references, keyed by the reference, the sketch plane and the @@ -94,6 +97,7 @@ fn resolve_uncached( ) -> Result { let lost = |what: &str| DocError::Invalid(format!("{what} no longer exists")); let mut moved = false; + let mut face_index = None; let mut source = src.clone(); let mut wires: Vec> = Vec::new(); // Included curves that leave the sketch plane stay 3D. @@ -125,6 +129,7 @@ fn resolve_uncached( LinkSource::Face { body, at } => { let (bi, mesh) = body_mesh(st, body, *at)?; let (f, q, d) = nearest_face(&mesh, *at).ok_or_else(|| lost("the face"))?; + face_index = Some(usize::try_from(f).map_err(|_| DocError::Invalid("face index exceeds platform limit".into()))?); let size = mesh.bounds().diagonal().max(1e-9); if d > size * 0.25 { return Err(lost("the face")); @@ -267,19 +272,130 @@ fn resolve_uncached( if geom.is_empty() && wires.is_empty() { return Err(DocError::Invalid(format!("the {} projects to nothing on this sketch plane", src.describe()))); } - Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved }) + Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved, face_index }) } /// Re-resolve every link of a sketch (in place). Returns warnings for lost, moved or rebuilt /// links; lost links keep their last geometry. +fn lost(reference: &str) -> DocError { + DocError::Unknown(format!("{reference} no longer exists")) +} + +fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str, index: usize, at: Vec3) -> Result { + let target = st.body(body).ok_or_else(|| lost("named projection body"))?; + let mesh = target.mesh(); + if mesh.triangles.len() > 2_000_000 || mesh.edges.len() > 2_000_000 { + return Err(DocError::Invalid("named face projection work limit exceeded".into())); + } + let face_index = Some(index); + let index = u32::try_from(index).map_err(|_| DocError::Invalid("named projection face index exceeds limit".into()))?; + let geom = if kind == LinkKind::Intersect { + section(plane, &mesh, Some(index)) + } else { + let mut geom = Vec::new(); + for edge in order::face_edges(&mesh, index, plane)? { + if mesh.seams.get(edge).copied().unwrap_or(false) { + continue; + } + if let Some(points) = mesh.edges.get(edge) { + geom.extend(fit_world(plane, points)); + } + } + merge_split_circles(geom) + }; + if geom.is_empty() { + return Err(DocError::Invalid("named face projects to nothing on this sketch plane".into())); + } + Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index }) +} + +pub fn resolve_anchored_face(doc: &Document, st: &ModelState, plane: &Plane, kind: LinkKind, anchor: &crate::FaceAnchor) -> Result { + let body = st.body(&anchor.target.body).ok_or_else(|| lost("anchored projection body"))?; + let indices = doc.configuration_face_indices(body, &anchor.target)?; + resolve_face_indices(st, plane, kind, &body.name, &indices) +} + +/// Project explicitly selected current face indices, without claiming persistent provenance. +/// Callers retaining a link must supply their own replay identity policy. +pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body_name: &str, indices: &[usize]) -> Result { + let body = st.body(body_name).ok_or_else(|| lost("projection body"))?; + let mesh = body.mesh(); + if mesh.triangles.len() > 2_000_000 || indices.len() > 10_000 { + return Err(DocError::Invalid("anchored projection work limit exceeded".into())); + } + let work = mesh.triangles.len().saturating_add(mesh.edges.len()).saturating_mul(indices.len()); + if work > 10_000_000 { + return Err(DocError::Invalid("anchored projection aggregate work limit exceeded".into())); + } + let selected: std::collections::BTreeSet<_> = indices.iter().copied().collect(); + let mut geometry = Vec::new(); + for index in indices { + let index = u32::try_from(*index).map_err(|_| DocError::Invalid("anchored face index exceeds format bounds".into()))?; + if kind == LinkKind::Intersect { + geometry.extend(section(plane, &mesh, Some(index))); + } else { + for edge in order::face_edges(&mesh, index, plane)? { + if mesh.seams.get(edge).copied().unwrap_or(false) { + continue; + } + if let Some(points) = mesh.edges.get(edge) { + geometry.extend(fit_world(plane, points)); + } + } + } + } + let point = mesh + .triangles + .iter() + .zip(&mesh.tri_face) + .find_map(|(triangle, face)| selected.contains(&(*face as usize)).then(|| mesh.tri(triangle)).flatten().map(|[a, b, c]| (a + b + c) / 3.0)) + .ok_or_else(|| lost("anchored projection face"))?; + if geometry.is_empty() { + return Err(lost("anchored projection boundaries")); + } + Ok(Resolved { + geom: merge_split_circles(geometry), + wires: Vec::new(), + source: LinkSource::Face { body: body.name.clone(), at: point }, + moved: false, + face_index: if indices.len() == 1 { indices.first().copied() } else { None }, + }) +} + +pub fn refresh_face_anchor_links( + doc: &Document, + vals: &BTreeMap, + st: &ModelState, + plane: &Plane, + sk: &mut Sketch, + anchor: &crate::FaceAnchor, +) -> Vec { + refresh_links_in(doc, vals, st, plane, sk, Some(anchor)) +} + pub fn refresh_links(doc: &Document, vals: &BTreeMap, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Vec { + refresh_links_in(doc, vals, st, plane, sk, None) +} + +fn refresh_links_in( + doc: &Document, + vals: &BTreeMap, + st: &ModelState, + plane: &Plane, + sk: &mut Sketch, + anchor: Option<&crate::FaceAnchor>, +) -> Vec { let mut warn = Vec::new(); let links = sk.links.clone(); for l in &links { let mut source = l.source.clone(); - if let (Some(name), LinkSource::Face { at, .. }) = (&l.face_name, &mut source) { - if let Some((p, split)) = crate::naming::point_on_face(st, name, *at) { + let mut named_face = None; + if !l.auto_face + && let (Some(name), LinkSource::Face { body, at }) = (&l.face_name, &mut source) + { + if let Some((index, p, split)) = st.body(body).and_then(|target| crate::naming::face_on_body(target, name, *at)) { *at = p; + named_face = Some((body.clone(), index, p)); if split { warn.push(format!("projected face `{name}` was split; using the nearest piece")); } @@ -289,7 +405,19 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap, st: &ModelS continue; } } - match resolve(doc, vals, st, plane, l.kind, &source) { + let resolved = if l.auto_face { + anchor + .filter(|anchor| { + l.face_name.as_ref() == Some(&anchor.target.name) && matches!(&l.source,LinkSource::Face {body,..} if *body==anchor.target.body) + }) + .ok_or_else(|| lost("exact auto-projected attachment")) + .and_then(|anchor| resolve_anchored_face(doc, st, plane, l.kind, anchor)) + } else if let Some((body, index, at)) = named_face { + resolve_named_face(st, plane, l.kind, &body, index, at) + } else { + resolve(doc, vals, st, plane, l.kind, &source) + }; + match resolved { Ok(r) => { sk.set_link_source(&l.id, r.source); let _ = sk.set_link_wires(&l.id, &r.wires); @@ -442,9 +570,9 @@ fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec (Some(c), Some(m)) => vec![LinkGeom::Ellipse { c: map(c), major: map(m), minor: *r }], _ => Vec::new(), }, - Some(CurveKind::Spline { pts, control, degree }) if parallel => { + Some(CurveKind::Spline { pts, control, degree, periodic }) if parallel => { let p: Option> = pts.iter().map(|q| sk.point(*q).map(map)).collect(); - p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree }]).unwrap_or_default() + p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree, periodic: *periodic }]).unwrap_or_default() } Some(CurveKind::Conic { a, b, apex, rho }) if parallel => match (sk.point(*a), sk.point(*apex), sk.point(*b)) { (Some(a), Some(x), Some(b)) => vec![LinkGeom::Conic { a: map(a), apex: map(x), b: map(b), rho: *rho }], @@ -560,7 +688,7 @@ fn as_spline(pts: &[Vec2], tol: f64) -> Option { <= tol }); if ok { - return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3 }); + return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3, periodic: false }); } } None diff --git a/crates/doc/src/project3d.rs b/crates/doc/src/project3d.rs index 4a5b24ff..df1bb10b 100644 --- a/crates/doc/src/project3d.rs +++ b/crates/doc/src/project3d.rs @@ -456,7 +456,7 @@ fn link_polyline(g: &solvecraft_sketch::LinkGeom) -> Vec { LinkGeom::Ellipse { c, major, minor } => { (0..=96).map(|i| solvecraft_sketch::ellipse_point(c, major, minor, i as f64 / 96.0 * std::f64::consts::TAU)).collect() } - LinkGeom::Spline { ref pts, control, degree } => solvecraft_sketch::spline_polyline(pts, control, degree), + LinkGeom::Spline { ref pts, control, degree, periodic } => solvecraft_sketch::spline_polyline_mode(pts, control, degree, periodic), LinkGeom::Point(_) => Vec::new(), } } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 6e318aec..3ea65a68 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -157,6 +157,7 @@ fn errors_are_reported_not_fatal() { targets: vec![], angle2: None, symmetric: false, + convention: Default::default(), to: None, }, None, diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 36a4f6ec..acb1f4a9 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -16,7 +16,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("body.rename", "Rename Body", rename_body).params("body: its name, name: the new name (features that use the body follow)"), CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch) .icon("plane") - .params("sketch: id or name; plane: XY|XZ|YZ | construction plane name | {face: [x,y,z]} | {origin, normal} (the sketch keeps its geometry)"), + .params("sketch: id or name; plane: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"), CommandSpec::new("browser.group", "New Group", group) .icon("folder") .params("folder: bodies|sketches|construction; component?: id (default 0); name?; items?: [keys] (they leave other groups)"), @@ -461,6 +461,19 @@ fn rename_body(s: &mut Session, p: &Value) -> Result { // Later features refer to bodies by name. let mut failed = None; for f in d.features.iter_mut().filter(|f| f.id != fid) { + if let FeatureKind::Sketch { plane, sketch } = &mut f.kind + && let Some(anchor) = super::sketch::face::anchor_mut(plane) + && anchor.target.body == old + { + let ids = std::collections::BTreeMap::from([(anchor.target.origin_feature, anchor.target.origin_feature)]); + let bodies = std::collections::BTreeMap::from([(old.clone(), name.to_string())]); + anchor.remap_target(&ids, &bodies)?; + for link in &mut sketch.links { + if link.auto_face && matches!(&link.source,solvecraft_sketch::LinkSource::Face {body,..} if *body==old) { + link.face_name = Some(anchor.target.name.clone()); + } + } + } let Ok(mut v) = serde_json::to_value(&f.kind) else { continue }; if replace_str(&mut v, &old, name, 0) { match serde_json::from_value(v) { @@ -519,13 +532,89 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result { if p.get("plane").is_none() { return Err(bad(cmd, "`plane` is required")); } - let plane = super::sketch::plane_ref(s, p, cmd)?; - let (vals, _) = s.doc.param_values(); - 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) { + let feature = s.doc.feature(id).ok_or_else(|| bad(cmd, "sketch disappeared"))?; + let component = feature.component; + let index = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "sketch disappeared"))?; + let author_path = super::sketch::author_path_for_rebind(s, feature, p, cmd)?; + let marker = s.doc.marker; + let mut baseline = s.scratch(); + baseline.doc_mut().marker = Some(index + 1); + baseline.refresh(); + let visible_geometry = baseline.model.state().sketch(id).map(|resolved| resolved.sketch.clone()); + let mut trial = s.scratch(); + trial.active_component = component; + trial.active_occurrence_path = (!author_path.is_empty()).then_some(author_path); + // A new attachment may only depend on geometry preceding this sketch. + trial.doc_mut().marker = Some(index); + trial.refresh(); + let old_anchor = trial + .doc + .feature(id) + .and_then(|feature| match &feature.kind { + FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane), + _ => None, + }) + .cloned(); + let plane = super::sketch::plane_ref(&trial, p, cmd)?; + if super::sketch::face::is_face(p) { + super::sketch::face::capture_positions(&mut trial, &plane)?; + } + if let Some(anchor) = old_anchor + && super::sketch::face::anchor(&plane) + .is_none_or(|next| next.target != anchor.target || next.component != anchor.component || next.path != anchor.path) + { + let sketch = trial.doc_mut().sketch_mut(id)?; + if let Some(mut visible) = visible_geometry { + let constraints: std::collections::BTreeMap<_, _> = + sketch.constraints.iter().map(|constraint| (constraint.id.clone(), constraint.clone())).collect(); + for constraint in &mut visible.constraints { + if let Some(original) = constraints.get(&constraint.id) { + if std::mem::discriminant(&constraint.kind) != std::mem::discriminant(&original.kind) { + return Err(bad(cmd, "a dimension role changed during border refresh; repair it explicitly before rebinding")); + } + // Keep evaluated entity indices after linked topology rebuilds. + constraint.param = original.param.clone(); + constraint.driven = original.driven; + constraint.text = original.text; + if let Some(value) = original.kind.value() { + constraint.kind.set_value(value); + } + } + } + *sketch = visible; + } + let affected: Vec<_> = sketch + .links + .iter() + .filter(|link| { + link.auto_face + && link.face_name.as_ref() == Some(&anchor.target.name) + && matches!(&link.source,solvecraft_sketch::LinkSource::Face {body,..} if *body==anchor.target.body) + }) + .map(|link| link.id.clone()) + .collect(); + for link in affected { + sketch.break_link(&link)?; + } + } + if let Some(FeatureKind::Sketch { plane: slot, .. }) = trial.doc_mut().feature_mut(id).map(|feature| &mut feature.kind) { *slot = plane; } - Ok(json!({ "sketch": id })) + trial.doc_mut().marker = Some(index + 1); + trial.refresh(); + if let Some(error) = trial.model.result(id).and_then(|result| result.error.as_ref()) { + return Err(bad(cmd, error.clone())); + } + trial.doc_mut().marker = marker; + trial.refresh(); + s.doc = trial.doc; + s.pending_moves = trial.pending_moves; + + let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind { + FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() { "exact_face" } else { "fixed" }), + _ => None, + }); + Ok(json!({ "sketch": id,"attachment":attachment })) } #[cfg(test)] diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 026bf806..c26baefd 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -62,11 +62,68 @@ fn paste(s: &mut Session, p: &Value) -> Result { let feats = s.clipboard.clone(); let comp = s.active_component; let mut sketches: BTreeMap = BTreeMap::new(); + let mut bodies: BTreeMap = BTreeMap::new(); + let mut used: std::collections::BTreeSet = s.model.state().bodies.iter().map(|body| body.name.clone()).collect(); + let mut notices = Vec::new(); + let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(comp)); + solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), comp, &author_path)?; let mut names: BTreeMap = BTreeMap::new(); let mut made = Vec::new(); for f in feats { let mut kind = f.kind.clone(); kind.remap_refs(&sketches, &names); + if let FeatureKind::Sketch { plane, .. } = &mut kind + && let Some(anchor) = super::sketch::face::anchor_mut(plane) + { + let internal = bodies.contains_key(&anchor.target.body); + if !internal && translate.is_some_and(|translate| translate.len() > 1e-12) { + return Err(bad(cmd, "translated paste retains an external exact face attachment; explicitly convert it to a fixed plane first")); + } + if !internal { + notices.push(format!("pasted sketch retains external exact face attachment to {}", anchor.target.body)); + } + anchor.remap_target(&sketches, &bodies)?; + if internal { + anchor.component = comp; + anchor.path = author_path.clone(); + } + anchor.author_path = author_path.clone(); + let target = anchor.target.clone(); + if let FeatureKind::Sketch { sketch, .. } = &mut kind { + for link in &mut sketch.links { + if link.auto_face { + if let solvecraft_sketch::LinkSource::Face { body, .. } = &mut link.source + && let Some(mapped) = bodies.get(body) + { + *body = mapped.clone(); + } + link.face_name = Some(target.name.clone()); + } + } + } + } + let outputs = if f.body_names.is_empty() { + s.model + .result(f.id) + .map(|result| result.state.bodies.iter().filter(|body| body.feature == f.id).map(|body| body.name.clone()).collect::>()) + .unwrap_or_default() + } else { + f.body_names.clone() + }; + let copied_names: Vec<_> = outputs + .iter() + .map(|original| { + let base = format!("{original} (copy)"); + let mut name = base.clone(); + let mut suffix = 2usize; + while !used.insert(name.clone()) { + name = format!("{base} {suffix}"); + suffix += 1; + } + bodies.insert(original.clone(), name.clone()); + name + }) + .collect(); let doc = s.doc_mut(); if let FeatureKind::Sketch { sketch, .. } = &mut kind { // Each dimension of the copy gets its own parameter, with the same expression. @@ -90,12 +147,10 @@ fn paste(s: &mut Session, p: &Value) -> Result { let id = doc.add_feature(kind, None)?; if let Some(nf) = doc.feature_mut(id) { nf.component = comp; - nf.body_names = f.body_names.iter().map(|b| format!("{b} (copy)")).collect(); + nf.body_names = copied_names; } let new_name = doc.feature(id).map(|x| x.name.clone()).unwrap_or_default(); - if matches!(f.kind, FeatureKind::Sketch { .. }) { - sketches.insert(f.id, id); - } + sketches.insert(f.id, id); names.insert(f.name.clone(), new_name.clone()); made.push(json!({"id": id, "name": new_name, "from": f.name})); } @@ -105,7 +160,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { .filter_map(|m| m["id"].as_u64()) .filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|e| json!({"feature": id, "error": e}))) .collect(); - Ok(json!({"pasted": made, "errors": errors})) + Ok(json!({"pasted": made, "errors": errors, "notices":notices})) } #[cfg(test)] diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 79616264..16bda80f 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -12,7 +12,7 @@ use crate::params::{bad, bool_, str_, string_list, vec3}; use crate::{Result, Session}; #[path = "component_properties.rs"] -mod properties; +pub(super) mod properties; #[path = "component_transfer.rs"] mod transfer; diff --git a/crates/engine/src/cmd/component_clone.rs b/crates/engine/src/cmd/component_clone.rs index 08f3065c..ad3ed31e 100644 --- a/crates/engine/src/cmd/component_clone.rs +++ b/crates/engine/src/cmd/component_clone.rs @@ -346,6 +346,16 @@ pub(super) fn paste( { face.author_path = cloned_path(&source, &face.author_path, component, &clone_path, &occurrences)?; } + if let (FeatureKind::Sketch { plane, .. }, FeatureKind::Sketch { plane: old_plane, .. }) = (&mut f.kind, &old.kind) + && let (Some(anchor), Some(original)) = (super::sketch::face::anchor_mut(plane), super::sketch::face::anchor(old_plane)) + { + if bodies.contains_key(&original.target.body) { + anchor.component = + *components.get(&original.component).ok_or_else(|| bad(CMD, "anchored source component is outside copied graph"))?; + anchor.path = cloned_path(&source, &original.path, component, &clone_path, &occurrences)?; + } + anchor.author_path = cloned_path(&source, &original.author_path, component, &clone_path, &occurrences)?; + } if let Some(slot) = scratch.doc_mut().feature_mut(f.id) { *slot = f; } @@ -473,6 +483,15 @@ pub(super) fn paste( } } } + if let FeatureKind::Sketch { plane, .. } = &feature.kind + && let Some(anchor) = super::sketch::face::anchor(plane) + && !bodies.contains_key(&anchor.target.body) + { + notices.push(format!( + "{} retains external exact face attachment to {} (producer {})", + feature.name, anchor.target.body, anchor.target.origin_feature + )); + } for id in feature.kind.input_sketches() { if !features.contains_key(&id) { notices.push(format!("{} retains an external sketch reference to {id}", feature.name)); diff --git a/crates/engine/src/cmd/component_properties.rs b/crates/engine/src/cmd/component_properties.rs index 195d565e..3d70262a 100644 --- a/crates/engine/src/cmd/component_properties.rs +++ b/crates/engine/src/cmd/component_properties.rs @@ -26,11 +26,15 @@ fn placed_doc(s: &Session) -> Document { /// A property total must cover the entire assembly. The display traversal intentionally /// truncates hostile designs, so use an explicit error instead of accepting its prefix. -pub(super) fn placements(doc: &Document, cmd: &str) -> Result, Mat)>> { +pub(in crate::cmd) fn placements(doc: &Document, cmd: &str) -> Result, Mat)>> { let mut out = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)]; let mut stack = vec![(0, Vec::::new(), solvecraft_doc::IDENTITY)]; + let mut children: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for occurrence in &doc.occurrences { + children.entry(occurrence.parent).or_default().push(occurrence); + } while let Some((component, path, transform)) = stack.pop() { - for occurrence in doc.occurrences.iter().filter(|o| o.parent == component) { + for occurrence in children.get(&component).into_iter().flatten() { if path.len() >= 64 || out.len() >= 100_000 { return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)")); } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e19dc52b..45ed6950 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -352,6 +352,12 @@ fn redefine(s: &mut Session, p: &Value) -> Result { let mut params = p.get("params").cloned().unwrap_or_else(|| json!({})); let idx = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?; let old = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?; + if let FeatureKind::Revolve { convention, .. } = &old.kind + && cid == "solid.revolve" + && let Some(parameters) = params.as_object_mut() + { + parameters.entry("convention").or_insert_with(|| json!(convention)); + } if !old.participants.is_empty() && let Some(parameters) = params.as_object_mut() { diff --git a/crates/engine/src/cmd/face.rs b/crates/engine/src/cmd/face.rs index 8a13da8f..ca66bb49 100644 --- a/crates/engine/src/cmd/face.rs +++ b/crates/engine/src/cmd/face.rs @@ -3,6 +3,7 @@ //! edges become lines, circular edges circles or arcs, anything else a chain of lines. use serde_json::{Value, json}; +use solvecraft_doc::{EmbossFace, apply_point, mat_inverse, mat_mul}; use solvecraft_geom::{Mesh, Vec2, Vec3}; use crate::params::bad; @@ -19,7 +20,7 @@ fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 { /// The outward normal of the planar body face at `p`. pub(super) fn face_normal(s: &Session, p: Vec3) -> Option { - let (m, f, d) = face_at(s, p)?; + let (m, f, d, _) = face_at(s, p)?; let tol = (m.bounds().diagonal() * 1e-3).max(1e-3); if d > tol * 10.0 { return None; @@ -34,7 +35,7 @@ pub(super) fn face_normal(s: &Session, p: Vec3) -> Option { /// Is the body face at `p` planar (`None`: no face there)? pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option { - let (m, f, d) = face_at(s, p)?; + let (m, f, d, _) = face_at(s, p)?; let tol = (m.bounds().diagonal() * 1e-3).max(1e-3); if d > tol * 10.0 { return None; @@ -52,9 +53,10 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option { } /// The body face under a point: (mesh, face index, distance). -fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc, u32, f64)> { +fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc, u32, f64, String)> { let st = s.model.state(); - let mut best: Option<(std::sync::Arc, u32, f64)> = None; + let mut best: Option<(std::sync::Arc, u32, f64, String)> = None; + let mut ambiguous = false; for b in &st.bodies { let m = b.mesh(); let mut near: Option<(u32, f64)> = None; @@ -65,13 +67,16 @@ fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc, u32, f64)> { near = Some((*f, d)); } } - if let Some((f, d)) = near - && best.as_ref().is_none_or(|x| d < x.2) - { - best = Some((m.clone(), f, d)); + if let Some((f, d)) = near { + if best.as_ref().is_none_or(|x| d < x.2 - 1e-8) { + best = Some((m.clone(), f, d, b.name.clone())); + ambiguous = false; + } else if best.as_ref().is_some_and(|x| (d - x.2).abs() <= 1e-8) { + ambiguous = true; + } } } - best + if ambiguous { None } else { best } } /// Circle through three points. @@ -93,7 +98,7 @@ fn xy(v: Vec2) -> Value { /// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns /// the sketch id and a point inside the face (sketch coordinates). The sketch is left active. pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<(u64, Vec2)> { - let (m, f, d) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?; + let (m, f, d, body) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?; let tol = (m.bounds().diagonal() * 1e-3).max(1e-3); if d > tol * 10.0 { return Err(bad(cmd, "no body face at `face`")); @@ -118,14 +123,45 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result< let spec = crate::find_command(id).ok_or_else(|| bad(cmd, id))?; (spec.run)(s, &p) }; - let r = run(s, "sketch.create", json!({"plane": {"face": [inside.x, inside.y, inside.z]}, "project_edges": true}))?; + // Command frame normalization already made `point` definition-local. Capture takes + // world coordinates; convert exactly once using the picked body's actual occurrence. + let state = s.model.state(); + let doc = s.placed_document(); + let picked = state.body(&body).ok_or_else(|| bad(cmd, "picked body disappeared"))?; + let component = doc.model_body_component(picked); + let path = if component == s.active_component { + s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(component)) + } else { + // The command mapper discarded the original placement when making this pick + // definition-local. A repeated cross-owner source cannot be re-found safely. + let placements = super::component::properties::placements(&doc, cmd)?; + if placements.iter().filter(|(owner, _, _)| *owner == component).count() != 1 { + return Err(bad(cmd, "activate the picked occurrence before generating a face profile from a repeated component")); + } + doc.first_path(component) + }; + let placement = EmbossFace::component_frame(&doc, component, &path)?; + let placement = doc.model_body_offset(picked).map(|offset| mat_mul(&placement, offset)).unwrap_or(placement); + let world_inside = apply_point(&placement, inside); + let world_body = doc.world_body_name(&state, &body, component, &path); + let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component)); + let author = EmbossFace::component_frame(&doc, s.active_component, &author_path)?; + let author_inverse = mat_inverse(&author).ok_or_else(|| bad(cmd, "invalid face profile author frame"))?; + let author_inside = apply_point(&author_inverse, world_inside); + drop(doc); + let r = super::sketch::create_face_profile( + s, + &json!({"plane": {"face": [world_inside.x, world_inside.y, world_inside.z], "body": world_body, "index": f, "path":path}, "project_edges": true}), + &body, + f as usize, + )?; let sid = r.get("sketch").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?; s.refresh(); let plane = s.model.state().sketch(sid).map(|ss| ss.plane).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?; // The face's edges came in as linked projections: they follow the face when earlier // features change, so nothing is copied. if r.get("projected").is_some_and(|x| !x.is_null()) { - return Ok((sid, plane.to_local(inside))); + return Ok((sid, plane.to_local(author_inside))); } for e in &edges { let pts: Vec = e.iter().map(|p| plane.to_local(*p)).collect(); @@ -163,5 +199,9 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result< } }; } - Ok((sid, plane.to_local(inside))) + Ok((sid, plane.to_local(author_inside))) } + +#[cfg(test)] +#[path = "face_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/face_tests.rs b/crates/engine/src/cmd/face_tests.rs new file mode 100644 index 00000000..ed46d6c4 --- /dev/null +++ b/crates/engine/src/cmd/face_tests.rs @@ -0,0 +1,187 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +use crate::Session; +use serde_json::{Value, json}; +use solvecraft_doc::{Document, FeatureKind, PlaneRef}; +use solvecraft_geom::Vec3; +use std::f64::consts::PI; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}")) +} +fn check(s: &mut Session, want: f64, hole: bool, height: f64) { + s.refresh(); + assert!(s.model.results.iter().all(|result| result.error.is_none())); + let state = s.model.state(); + let got: f64 = state.bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).sum(); + // Legacy linked outlines fit tessellated curves; enforce the existing 1e-4 material envelope. + assert!((got - want).abs() < want * 1e-4, "{got} vs {want}"); + assert!(state.bodies.iter().any(|b| b.body.contains(Vec3::new(5.0, 5.0, height - 1.0)))); + assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(5.0, 5.0, height + 1.0)))); + if hole { + assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(20.0, 15.0, height - 1.0)))); + } +} +#[test] +fn generated_face_profiles_keep_certified_and_internal_legacy_links_live_after_edit_and_reopen() { + for hole in [false, true] { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base"})); + if hole { + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.circle.center", json!({"center":[20,15],"radius":5})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"through_all":true,"operation":"cut"})); + } + let result = run(&mut s, "solid.extrude", json!({"face":[5,5,20],"distance":10,"operation":"new","name":"Pad"})); + let pad = s.doc.feature(result["feature"].as_u64().unwrap()).unwrap(); + let FeatureKind::Extrude { sketch, .. } = pad.kind else { panic!("extrude") }; + let generated = s.doc.feature(sketch).unwrap(); + let FeatureKind::Sketch { plane, sketch: profile } = &generated.kind else { panic!("sketch") }; + assert!(matches!(plane,PlaneRef::Face{anchor,..} if anchor.is_some()!=hole)); + assert!(!profile.links.is_empty()); + assert!(profile.links.iter().all(|link| link.auto_face != hole && !link.lost)); + let area = 1200.0 - if hole { 25.0 * PI } else { 0.0 }; + check(&mut s, area * 30.0, hole, 30.0); + run(&mut s, "timeline.edit", json!({"feature":"Base","set":{"height":"25","length":"50"}})); + let area = 1500.0 - if hole { 25.0 * PI } else { 0.0 }; + check(&mut s, area * 35.0, hole, 35.0); + let edited = s.model.state(); + assert!(edited.bodies.iter().any(|body| body.body.contains(Vec3::new(49.0, 5.0, 34.0)))); + assert!(!edited.bodies.iter().any(|body| body.body.contains(Vec3::new(51.0, 5.0, 34.0)))); + assert!(edited.sketch(sketch).unwrap().sketch.links.iter().all(|link| !link.lost)); + let solved = edited.sketch(sketch).unwrap().plane; + assert!((solved.origin.z - 25.0).abs() < 1e-6); + let before = s.model.state(); + let before_volume: f64 = before.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum(); + let before_bounds: Vec<_> = before.bodies.iter().map(|body| (body.name.clone(), body.mesh().bounds())).collect(); + let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap()); + check(&mut reopened, area * 35.0, hole, 35.0); + let after = reopened.model.state(); + let after_volume: f64 = after.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum(); + assert!((before_volume - after_volume).abs() < 1e-6); + for (name, bounds) in before_bounds { + let cold = after.body(&name).unwrap().mesh().bounds(); + assert!(bounds.min.dist(cold.min) < 1e-9 && bounds.max.dist(cold.max) < 1e-9); + } + } +} +#[test] +fn internal_capture_does_not_bypass_missing_ambiguous_or_invalid_path_picks() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"A"})); + run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"B"})); + let before = s.doc.to_json(); + assert!(s.execute("solid.extrude", &json!({"face":[5,5,20],"distance":10,"operation":"new"})).is_err()); + assert_eq!(s.doc.to_json(), before); + for pick in [json!({"face":[5,5,20]}), json!({"face":[500,5,20]}), json!({"face":[5,5,20],"body":"A","index":0,"path":[999999]})] { + assert!(super::super::sketch::face::capture_internal(&s, &pick, "internal").is_err(), "{pick}"); + } +} + +#[test] +fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() { + for hole in [false, true] { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Arm"})); + let cid = component["component"].as_u64().unwrap(); + let occ = component["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base","body_name":"Stock"})); + if hole { + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.circle.center", json!({"center":[20,15],"radius":5})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"through_all":true,"operation":"cut"})); + } + run(&mut s, "component.activate", json!({"component":0})); + run(&mut s, "occurrence.move", json!({"occurrence":occ,"translate":[30,20,10],"axis":[1,0,0],"angle":"90 deg","capture":true})); + run(&mut s, "component.activate", json!({"component":cid,"occurrence":occ})); + run(&mut s, "solid.extrude", json!({"face":[35,0,15],"distance":10,"operation":"join"})); + let area = 1200.0 - if hole { 25.0 * PI } else { 0.0 }; + check(&mut s, area * 30.0, hole, 30.0); + run(&mut s, "solid.press_pull", json!({"face":[35,-10,15],"distance":5})); + check(&mut s, area * 35.0, hole, 35.0); + let height = if hole { + // Curved coincident Cut is an existing kernel limit; it must be atomic. + let before = s.doc.to_json(); + let error = s.execute("solid.press_pull", &json!({"face":[35,-15,15],"distance":-5})).unwrap_err(); + assert!(error.to_string().contains("coincident faces on curved bodies"), "{error}"); + assert_eq!(s.doc.to_json(), before); + check(&mut s, area * 35.0, hole, 35.0); + 35.0 + } else { + run(&mut s, "solid.press_pull", json!({"face":[35,-15,15],"distance":-5})); + check(&mut s, area * 30.0, hole, 30.0); + 30.0 + }; + let world = s.world_state(); + let occupied = Vec3::new(35.0, 21.0 - height, 15.0); + let absent = Vec3::new(35.0, 19.0 - height, 15.0); + assert!(world.bodies.iter().any(|body| body.body.contains(occupied))); + assert!(!world.bodies.iter().any(|body| body.body.contains(absent))); + if hole { + assert!(!world.bodies.iter().any(|body| body.body.contains(Vec3::new(50.0, 21.0 - height, 25.0)))); + } + let before_volume: f64 = world.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum(); + let before_bounds: Vec<_> = world.bodies.iter().map(|body| (body.name.clone(), body.mesh().bounds())).collect(); + let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap()); + let after = reopened.world_state(); + assert!(after.bodies.iter().any(|body| body.body.contains(occupied))); + let v: f64 = after.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum(); + assert!((v - area * height).abs() < area * height * 1e-4); + assert!((v - before_volume).abs() < 1e-6); + for (name, bounds) in before_bounds { + let cold = after.body(&name).unwrap().mesh().bounds(); + assert!(bounds.min.dist(cold.min) < 1e-9 && bounds.max.dist(cold.max) < 1e-9); + } + } +} + +#[test] +fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Source"})); + let occurrence = component["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Stock"})); + run(&mut s, "solid.press_pull", json!({"face":[5,5,20],"distance":5})); + run(&mut s, "component.activate", json!({"component":0})); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":false})); + let state = s.model.state(); + let index = state + .body("Stock") + .unwrap() + .body + .faces(1e-4) + .unwrap() + .iter() + .find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 25.0).abs() < 1e-6) + .unwrap() + .index; + let before = s.doc.to_json(); + let error = + super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,25],"body":"Stock","index":index,"path":[occurrence]}), "internal") + .unwrap_err(); + assert!(error.to_string().contains("capture the source occurrence position"), "{error}"); + assert_eq!(s.doc.to_json(), before); +} + +#[test] +fn repeated_source_face_profile_requires_and_uses_the_exact_active_occurrence() { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Repeated"})); + let cid = component["component"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"})); + run(&mut s, "component.activate", json!({"component":0})); + let copied = run(&mut s, "occurrence.copy", json!({"component":cid,"translate":[50,0,0]})); + let occurrence = copied["occurrence"].as_u64().unwrap(); + let before = s.doc.to_json(); + let error = s.execute("solid.extrude", &json!({"face":[55,5,10],"distance":5,"operation":"new"})).unwrap_err(); + assert!(error.to_string().contains("activate the picked occurrence"), "{error}"); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "component.activate", json!({"component":cid,"occurrence":occurrence})); + run(&mut s, "solid.extrude", json!({"face":[55,5,10],"distance":5,"operation":"new"})); + let state = s.world_state(); + for x in [5.0, 55.0] { + assert!(state.bodies.iter().any(|body| body.body.contains(Vec3::new(x, 5.0, 14.0)))); + assert!(!state.bodies.iter().any(|body| body.body.contains(Vec3::new(x, 5.0, 16.0)))); + } +} diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 9a15b4f5..755fec72 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -17,7 +17,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.revolve", "Revolve", revolve) .at("SOLID", "CREATE") .icon("revolve") - .params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"), + .params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"), CommandSpec::new("solid.sweep", "Sweep", sweep) .at("SOLID", "CREATE") .icon("sweep") @@ -482,6 +482,10 @@ fn revolve(s: &mut Session, p: &Value) -> Result { }; let symmetric = matches!(str_(p, "direction").map(str::to_ascii_lowercase).as_deref(), Some("symmetric")) || bool_(p, "symmetric").unwrap_or(false); + let convention = match p.get("convention") { + None => solvecraft_doc::RevolveConvention::SketchNormal, + Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`convention` must be sketch_normal or legacy_axis_start"))?, + }; let kind = FeatureKind::Revolve { sketch, profiles, @@ -491,6 +495,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result { targets: string_list(p, "targets"), angle2, symmetric, + convention, to, }; add_feature(s, p, kind) @@ -1342,3 +1347,7 @@ fn offset_face(s: &mut Session, p: &Value) -> Result { #[cfg(test)] #[path = "patterns_tests.rs"] mod patterns_tests; + +#[cfg(test)] +#[path = "revolve_convention_tests.rs"] +mod revolve_convention_tests; diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index ca4250b4..c83ae954 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -119,7 +119,7 @@ fn sketch_json(s: &Session, id: u64) -> Option { CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), CurveKind::Ellipse { c: cc, m, r } => json!({"id": c.id, "type": "ellipse", "center": pid(cc), "major": pid(m), "center_at": sk.point(cc), "major_at": sk.point(m), "minor_radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), - CurveKind::Spline { ref pts, control, degree } => json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::>(), "control": control, "degree": degree, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), + CurveKind::Spline { ref pts, control, degree, periodic } => json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::>(), "control": control, "degree": degree, "periodic": periodic, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), CurveKind::Conic { a, b, apex, rho } => json!({"id": c.id, "type": "conic", "start": pid(a), "end": pid(b), "apex": pid(apex), "start_at": sk.point(a), "end_at": sk.point(b), "apex_at": sk.point(apex), "rho": rho, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), }; if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f8e92073..8eec64ee 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -29,6 +29,7 @@ mod parameters; mod plastic; mod sheet; mod sketch; +pub(crate) use sketch::author_path_for_rebind; mod sketch_3d; mod sketch_canvas; mod sketch_constraints; diff --git a/crates/engine/src/cmd/revolve_convention_tests.rs b/crates/engine/src/cmd/revolve_convention_tests.rs new file mode 100644 index 00000000..55cf400c --- /dev/null +++ b/crates/engine/src/cmd/revolve_convention_tests.rs @@ -0,0 +1,309 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use crate::Session; +use serde_json::{Value, json}; +use solvecraft_doc::{Document, FeatureKind, RevolveConvention}; +use solvecraft_geom::Vec3; +use std::f64::consts::{FRAC_PI_2, PI, SQRT_2}; + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn profile(s: &mut Session, opposed: bool) { + run(s, "sketch.create", json!({"name":"Profile","plane":"XY"})); + run(s, "sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[4,10]})); + if opposed { + run(s, "sketch.rectangle.two_point", json!({"p0":[-4,0],"p1":[-2,10]})); + } + run(s, "sketch.finish", json!({})); +} + +fn turn(s: &mut Session, sign: f64, settings: Value) { + let mut p = json!({"sketch":"Profile","axis":{"origin":[0,0,0],"dir":[0,sign,0]},"angle":"90 deg","direction":"symmetric","operation":"new","name":"Turn","body_name":"Turned"}); + p.as_object_mut().unwrap().extend(settings.as_object().unwrap().clone()); + run(s, "solid.revolve", p); +} + +fn check_centered(s: &Session, opposed: bool) { + let state = s.model.state(); + let expected = 30.0 * PI * if opposed { 2.0 } else { 1.0 }; + let volume: f64 = state.bodies.iter().map(|mb| solvecraft_kernel::measure(&mb.body).unwrap().volume).sum(); + assert!((volume - expected).abs() < 0.03, "{volume} vs {expected}"); + for sign in if opposed { vec![1.0, -1.0] } else { vec![1.0] } { + let body = &state.bodies.iter().find(|mb| mb.body.contains(Vec3::new(sign * 3.0, 5.0, 0.0))).unwrap().body; + assert!(!body.contains(Vec3::new(0.0, 5.0, 3.0))); + assert!(body.contains(Vec3::new(sign * 3.0 * (PI / 6.0).cos(), 5.0, 1.5))); + assert!(body.contains(Vec3::new(sign * 3.0 * (PI / 6.0).cos(), 5.0, -1.5))); + let bounds = body.tessellate(1e-4).unwrap().bounds(); + assert!((bounds.min.z + 2.0 * SQRT_2).abs() < 1e-6, "{bounds:?}"); + assert!((bounds.max.z - 2.0 * SQRT_2).abs() < 1e-6, "{bounds:?}"); + let inner_x = if sign > 0.0 { bounds.min.x } else { -bounds.max.x }; + assert!((inner_x - SQRT_2).abs() < 1e-6, "{bounds:?}"); + let centroid = solvecraft_kernel::measure(body).unwrap().centroid; + let radial = (2.0 / 3.0) * (64.0 - 8.0) / (16.0 - 4.0); + let want_x = radial * (PI / 4.0).sin() / (PI / 4.0); + assert!((centroid.x - sign * want_x).abs() < 0.005, "{centroid:?}"); + assert!((centroid.y - 5.0).abs() < 1e-6); + assert!(centroid.z.abs() < 1e-6); + } +} + +#[test] +fn new_symmetric_revolves_center_material_for_both_axis_signs_and_negative_angles() { + for sign in [-1.0, 1.0] { + for angle in ["90 deg", "-90 deg"] { + let mut s = Session::default(); + profile(&mut s, false); + turn(&mut s, sign, json!({"angle":angle})); + assert!(matches!(s.doc.find_feature("Turn").unwrap().kind, FeatureKind::Revolve { convention: RevolveConvention::SketchNormal, .. })); + check_centered(&s, false); + } + } +} + +#[test] +fn opposed_profiles_use_independent_sweep_axes() { + for sign in [-1.0, 1.0] { + let mut s = Session::default(); + profile(&mut s, true); + turn(&mut s, sign, json!({})); + check_centered(&s, true); + } +} + +#[test] +fn two_sided_revolves_use_the_canonical_negative_and_positive_limits() { + for sign in [-1.0, 1.0] { + let mut s = Session::default(); + profile(&mut s, false); + turn(&mut s, sign, json!({"direction":"positive","angle":"60 deg","angle2":"30 deg"})); + let state = s.model.state(); + let body = &state.bodies[0].body; + let bounds = body.tessellate(1e-4).unwrap().bounds(); + assert!((bounds.min.x - 1.0).abs() < 1e-6, "{bounds:?}"); + assert!((bounds.max.x - 4.0).abs() < 1e-4); + assert!((bounds.min.z + 2.0).abs() < 1e-6); + assert!((bounds.max.z - 2.0 * 3.0f64.sqrt()).abs() < 1e-6); + assert!(body.contains(Vec3::new(3.0, 5.0, 0.0))); + assert!(!body.contains(Vec3::new(0.0, 5.0, 3.0))); + assert!((solvecraft_kernel::measure(body).unwrap().volume - 30.0 * PI).abs() < 0.03); + } +} + +#[test] +fn legacy_missing_convention_keeps_geometry_through_edit_redefine_undo_and_reopen() { + let mut s = Session::default(); + profile(&mut s, false); + turn(&mut s, -1.0, json!({"convention":"legacy_axis_start"})); + let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap(); + saved["format"] = json!("solvecraft/10"); + for f in saved["features"].as_array_mut().unwrap() { + f.as_object_mut().unwrap().remove("convention"); + } + let mut s = Session::new(Document::from_json(&saved.to_string()).unwrap()); + let original = s.doc.find_feature("Turn").unwrap().kind.clone(); + let check = |s: &Session| { + assert_eq!(s.doc.find_feature("Turn").unwrap().kind, original); + let state = s.model.state(); + let body = &state.bodies[0].body; + assert!(body.contains(Vec3::new(0.0, 5.0, 3.0))); + assert!(!body.contains(Vec3::new(3.0, 5.0, 0.0))); + let bounds = body.tessellate(1e-4).unwrap().bounds(); + assert!((bounds.min.x + 2.0 * SQRT_2).abs() < 1e-6); + assert!((bounds.max.x - 2.0 * SQRT_2).abs() < 1e-6); + assert!((bounds.min.z - SQRT_2).abs() < 1e-6); + }; + check(&s); + let angle_input = s.doc.find_feature("Turn").unwrap().param_names[0].clone(); + run(&mut s, "parameters.change", json!({"name":angle_input,"expression":"60 deg"})); + let changed = s.model.state(); + assert!(changed.bodies[0].body.contains(Vec3::new(1.5, 5.0, 3.0 * 3.0f64.sqrt() / 2.0))); + assert!(!changed.bodies[0].body.contains(Vec3::new(3.0, 5.0, 0.0))); + run(&mut s, "edit.undo", json!({})); + check(&s); + run(&mut s, "timeline.edit", json!({"feature":"Turn","set":{"angle":"90 deg"}})); + check(&s); + run( + &mut s, + "timeline.redefine", + json!({"feature":"Turn","command":"solid.revolve","params":{"sketch":"Profile","axis":{"origin":[0,0,0],"dir":[0,-1,0]},"angle":"90 deg","direction":"symmetric","body_name":"Turned"}}), + ); + check(&s); + run(&mut s, "timeline.edit", json!({"feature":"Turn","set":{"convention":"sketch_normal"}})); + check_centered(&s, false); + run(&mut s, "edit.undo", json!({})); + check(&s); + let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap()); + check(&reopened); + // A newly authored feature in this migrated document uses current semantics. + turn(&mut s, -1.0, json!({"name":"NewTurn","body_name":"NewTurned"})); + assert!(matches!(s.doc.find_feature("NewTurn").unwrap().kind, FeatureKind::Revolve { convention: RevolveConvention::SketchNormal, .. })); +} + +#[test] +fn extent_handle_angles_match_new_and_legacy_material() { + let mut s = Session::default(); + profile(&mut s, false); + let state = s.model.state(); + let ss = &state.sketches[0]; + let regions = vec![ss.profiles[0].region.clone()]; + for convention in [RevolveConvention::SketchNormal, RevolveConvention::LegacyAxisStart] { + let extent = solvecraft_doc::revolve_extent_with_convention( + &state, + &ss.plane, + ®ions, + (Vec3::ZERO, -Vec3::Y), + (FRAC_PI_2, None), + true, + None, + convention, + ) + .unwrap(); + assert_eq!(extent.rotation_axis, -Vec3::Y); + assert!((extent.display_start - if convention == RevolveConvention::SketchNormal { -PI / 4.0 } else { PI / 4.0 }).abs() < 1e-12); + assert!((extent.sweep - FRAC_PI_2).abs() < 1e-12); + } +} + +#[test] +fn symmetric_revolve_preserves_a_translated_rotated_sketch_frame() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"name":"Profile","plane":{"origin":[10,20,30],"x_dir":[0,0,1],"y_dir":[1,0,0]}})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[4,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.revolve", json!({"sketch":"Profile","axis":{"origin":[10,20,30],"dir":[-1,0,0]},"angle":"90 deg","direction":"symmetric"})); + let state = s.model.state(); + let body = &state.bodies[0].body; + let bounds = body.tessellate(1e-4).unwrap().bounds(); + assert!(bounds.min.dist(Vec3::new(10.0, 20.0 - 2.0 * SQRT_2, 30.0 + SQRT_2)) < 1e-6, "{bounds:?}"); + assert!(bounds.max.dist(Vec3::new(20.0, 20.0 + 2.0 * SQRT_2, 34.0)) < 1e-4, "{bounds:?}"); + assert!(body.contains(Vec3::new(15.0, 20.0, 33.0))); + assert!(!body.contains(Vec3::new(15.0, 23.0, 30.0))); + assert!((solvecraft_kernel::measure(body).unwrap().volume - 30.0 * PI).abs() < 0.03); +} + +#[test] +fn copied_revolves_keep_their_convention_and_angular_material() { + for convention in ["sketch_normal", "legacy_axis_start"] { + let mut s = Session::default(); + profile(&mut s, false); + turn(&mut s, -1.0, json!({"convention":convention})); + run(&mut s, "timeline.copy", json!({"features":["Profile","Turn"]})); + run(&mut s, "timeline.paste", json!({"translate":[0,20,0]})); + let state = s.model.state(); + let probe = if convention == "sketch_normal" { Vec3::new(3.0, 25.0, 0.0) } else { Vec3::new(0.0, 25.0, 3.0) }; + assert!(state.bodies.iter().any(|mb| mb.body.contains(probe))); + let modes: Vec<_> = s + .doc + .features + .iter() + .filter_map(|f| if let FeatureKind::Revolve { convention, .. } = f.kind { Some(convention) } else { None }) + .collect(); + assert_eq!(modes.len(), 2); + assert_eq!(modes[0], modes[1]); + let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.state().bodies.iter().any(|mb| mb.body.contains(probe))); + } +} + +#[test] +fn centered_coplanar_cut_uses_the_actual_angular_interval() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"corner":[2,0,-4],"length":2,"width":10,"height":8,"body_name":"Stock"})); + profile(&mut s, false); + turn(&mut s, -1.0, json!({"operation":"cut","targets":["Stock"]})); + let state = s.model.state(); + // Removed sector clipped by x>=2: 10∫[-π/4,π/4](16−4sec²θ)/2 dθ. + // The sector crosses the whole stock at z=0, leaving two disconnected solids. + let expected = 200.0 - 40.0 * PI; + let volumes: Vec<_> = state.bodies.iter().map(|mb| solvecraft_kernel::measure(&mb.body).unwrap().volume).collect(); + let total: f64 = volumes.iter().sum(); + assert!((total - expected).abs() < 0.05, "{volumes:?}: total{total}, expected{expected}"); + assert_eq!(state.bodies.len(), 2); + assert!(volumes.iter().all(|v| (*v - expected / 2.0).abs() < 0.025)); + let contains = |p| state.bodies.iter().any(|mb| mb.body.contains(p)); + assert!(!contains(Vec3::new(3.0, 5.0, 0.0))); + assert!(contains(Vec3::new(3.0, 5.0, 3.5))); + assert!(contains(Vec3::new(3.0, 5.0, -3.5))); +} + +#[test] +fn one_sided_and_full_revolves_keep_the_existing_material() { + for angle in ["90 deg", "360 deg"] { + for sign in [-1.0, 1.0] { + for convention in ["legacy_axis_start", "sketch_normal"] { + let mut s = Session::default(); + profile(&mut s, false); + turn(&mut s, sign, json!({"angle":angle,"direction":"positive","convention":convention})); + let state = s.model.state(); + let body = &state.bodies[0].body; + assert!(body.contains(Vec3::new(3.0 / SQRT_2, 5.0, 3.0 / SQRT_2))); + assert_eq!(body.contains(Vec3::new(-3.0 / SQRT_2, 5.0, -3.0 / SQRT_2)), angle == "360 deg"); + let expected = if angle == "360 deg" { 120.0 * PI } else { 30.0 * PI }; + assert!((solvecraft_kernel::measure(body).unwrap().volume - expected).abs() < 0.08); + } + } + } +} + +#[test] +fn point_targets_use_each_profiles_canonical_axis_for_both_axis_signs() { + for sign in [-1.0, 1.0] { + for opposed in [false, true] { + let mut s = Session::default(); + profile(&mut s, opposed); + turn(&mut s, sign, json!({"to":[0,5,3]})); + let state = s.model.state(); + let expected = 30.0 * PI * if opposed { 2.0 } else { 1.0 }; + let volume: f64 = state.bodies.iter().map(|mb| solvecraft_kernel::measure(&mb.body).unwrap().volume).sum(); + assert!((volume - expected).abs() < 0.05, "{volume}"); + assert!(state.bodies.iter().any(|mb| mb.body.contains(Vec3::new(3.0 / SQRT_2, 5.0, 3.0 / SQRT_2)))); + assert!(!state.bodies.iter().any(|mb| mb.body.contains(Vec3::new(3.0 / SQRT_2, 5.0, -3.0 / SQRT_2)))); + assert_eq!(state.bodies.iter().any(|mb| mb.body.contains(Vec3::new(-3.0 / SQRT_2, 5.0, 3.0 / SQRT_2))), opposed); + assert!(!state.bodies.iter().any(|mb| mb.body.contains(Vec3::new(-3.0 / SQRT_2, 5.0, -3.0 / SQRT_2)))); + } + } +} + +#[test] +fn an_axis_target_is_an_actionable_atomic_error_for_new_convention() { + let mut s = Session::default(); + profile(&mut s, false); + let before = s.doc.to_json(); + let e = s.execute("solid.revolve", &json!({"sketch":"Profile","axis":"Y","to":[0,5,0]})).unwrap_err().to_string(); + assert!(e.contains("target lies on the axis"), "{e}"); + assert_eq!(s.doc.to_json(), before); +} + +#[test] +fn curved_targets_keep_only_the_material_before_the_post_for_both_axis_signs() { + for sign in [-1.0, 1.0] { + for opposed in [false, true] { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"base":[0,14,-5],"radius":4,"height":15,"body_name":"Post"})); + run(&mut s, "sketch.create", json!({"name":"Profile","plane":"XZ"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[10,0],"p1":[15,5]})); + if opposed { + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-15,0],"p1":[-10,5]})); + } + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.revolve", json!({"sketch":"Profile","axis":{"origin":[0,0,0],"dir":[0,0,sign]},"to":[4,14,2],"body_name":"Arc"})); + let state = s.model.state(); + let post = &state.body("Post").unwrap().body; + let arcs: Vec<_> = state.bodies.iter().filter(|mb| mb.name != "Post").collect(); + assert_eq!(arcs.len(), if opposed { 2 } else { 1 }); + for side in if opposed { vec![1.0, -1.0] } else { vec![1.0] } { + let probe = Vec3::new(side * 12.0 * (PI / 6.0).cos(), 6.0, -2.5); + let arc = &arcs.iter().find(|mb| mb.body.contains(probe)).unwrap().body; + assert!(!arc.contains(Vec3::new(-side * 12.0 * (PI / 6.0).cos(), 6.0, -2.5))); + assert!(!arc.contains(Vec3::new(side * 12.0 * (PI / 6.0).cos(), -6.0, -2.5))); + let full = PI * (225.0 - 100.0) * 5.0; + let angle = solvecraft_kernel::measure(arc).unwrap().volume / full * 360.0; + assert!(angle > 60.0 && angle < 90.0, "{angle}"); + let overlap = solvecraft_kernel::boolean(arc, post, solvecraft_kernel::BoolOp::Intersect).unwrap(); + assert!(overlap.is_none_or(|b| solvecraft_kernel::measure(&b).unwrap().volume < 1e-3)); + } + } + } +} diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 04e05866..a3921e3b 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -11,6 +11,8 @@ use crate::{EngineError, Result, Session}; #[path = "sketch_dimensioned.rs"] mod dimensioned; +#[path = "sketch_face.rs"] +pub(super) mod face; use dimensioned::draw as draw_dimensioned; /// The `connect` parameter every drawing command takes (see `connect_points`). @@ -24,8 +26,8 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("sketch.create", "Create Sketch", create_sketch) .at("SOLID", "CREATE") .icon("sketch") - .params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [x,y,z]} (planar face at point), offset?: expr, name?"), - CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"), + .params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source, fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"), + CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).noundo().icon("sketch").params("sketch: id or name"), CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(), CommandSpec::new("sketch.line", "Line", draw_line) .at("SKETCH", "CREATE") @@ -232,6 +234,7 @@ pub(super) fn edit( if strict && !rep.ok() { return Err(EngineError::Other(format!("that would over-constrain the sketch (conflicts with {})", rep.failing.join(", ")))); } + sk.validate_periodic_splines()?; *doc.sketch_mut(id)? = sk; *s.doc_mut() = doc; Ok((out, json!({"solved": rep.ok(), "dof": rep.dof}))) @@ -357,36 +360,56 @@ pub(super) fn result(sk_out: (Vec, Vec), info: Value) -> Value { // --------------------------------------------------------------------------------------------- // Sketch lifecycle +pub(crate) fn author_path_for_rebind(s: &Session, feature: &solvecraft_doc::Feature, p: &Value, cmd: &str) -> Result> { + let mut saved = None; + if let FeatureKind::Sketch { plane, .. } = &feature.kind { + let mut plane = plane; + loop { + match plane { + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base, + PlaneRef::Face { anchor: Some(anchor), .. } => { + saved = Some(anchor.author_path.clone()); + break; + } + _ => break, + } + } + } + let path = if let Some(value) = p.get("author_path") { + value + .as_array() + .filter(|path| path.len() <= 64) + .ok_or_else(|| bad(cmd, "author_path must contain at most64 ids"))? + .iter() + .map(|id| id.as_u64().filter(|id| *id != 0).ok_or_else(|| bad(cmd, "invalid author_path id"))) + .collect::>>()? + } else if let Some(path) = saved { + path + } else if feature.component == s.active_component && s.active_occurrence_path.is_some() { + s.active_occurrence_path.clone().unwrap_or_default() + } else { + let placements = super::component::properties::placements(&s.placed_document(), cmd)?; + let paths: Vec<_> = placements.into_iter().filter(|(component, _, _)| *component == feature.component).map(|(_, path, _)| path).collect(); + if paths.len() != 1 { + return Err(bad(cmd, "sketch author placement is ambiguous; provide exact author_path or activate the intended occurrence")); + } + paths.into_iter().next().ok_or_else(|| bad(cmd, "sketch author placement is missing"))? + }; + if feature.component != 0 && path.is_empty() { + return Err(bad(cmd, "a component sketch needs an explicit complete author_path")); + } + solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), feature.component, &path)?; + Ok(path) +} + pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result { let base = match p.get("plane") { None => PlaneRef::Origin { name: "XY".into() }, // An origin plane, or a construction plane by name or id. Some(v @ (Value::String(_) | Value::Number(_))) => super::features::plane_param(s, Some(v), cmd)?, Some(v @ Value::Object(o)) => { - if let Some(fp) = o.get("face").and_then(vec3) { - // A planar face of a body at this point, as the active component sees the model - // (another component's face where it is placed). - let model = s.model.state(); - let placed = s.placed_document(); - let st = solvecraft_doc::state_in_frame_at(&placed, &model, s.active_component, s.active_component_frame()); - let mut found = None; - let mut face_name = None; - for b in &st.bodies { - let tol = (b.body.size() * 1e-3).max(1e-3); - for f in b.body.faces(tol).unwrap_or_default() { - if let Some(n) = f.plane_normal - && (fp - f.centroid).dot(n).abs() < tol * 10.0 - && b.body.tessellate(tol).map(|m| face_contains(&m, f.index, fp, tol * 10.0)).unwrap_or(false) - { - // Sketch origin: the model origin projected onto the face plane. - let d = (f.centroid + n * (fp - f.centroid).dot(n)).dot(n); - found = Plane::from_normal(n * d, n); - face_name = solvecraft_doc::naming::face_names(b).get(f.index).cloned(); - } - } - } - let pl = found.ok_or_else(|| bad(cmd, "no planar face at that point"))?; - PlaneRef::Face { plane: pl, at: fp, name: face_name } + if o.contains_key("face") { + face::capture(s, v, cmd)? } else { let origin = v.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); let x = v.get("x_dir").and_then(vec3).ok_or_else(|| bad(cmd, "plane needs x_dir"))?; @@ -403,7 +426,7 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result { } /// Does triangle set of face `fi` contain point `p` (within `tol` of the face plane)? -fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> bool { +pub(super) fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> bool { m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == fi).any(|(t, _)| { let Some([a, b, c]) = m.tri(t) else { return false }; let n = (b - a).cross(c - a); @@ -423,13 +446,28 @@ fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> boo fn create_sketch(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.create"; - let plane = plane_ref(s, p, cmd)?; + create_sketch_with_plane(s, p, plane_ref(s, p, cmd)?, None) +} + +/// Used only by face Extrude/Revolve/PressPull, preserving pre-certificate linked profiles. +pub(super) fn create_face_profile(s: &mut Session, p: &Value, body: &str, index: usize) -> Result { + let value = p.get("plane").ok_or_else(|| bad("face profile", "missing picked face plane"))?; + let plane = face::capture_internal(s, value, "face profile")?; + create_sketch_with_plane(s, p, plane, Some((body, index))) +} + +fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneRef, internal_body: Option<(&str, usize)>) -> Result { + if face::is_face(p) { + face::capture_positions(s, &plane)?; + } // Validate the plane resolves. let (vals, _) = s.doc.param_values(); 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); + if !face::is_face(p) { + super::component::to_active_frame(s, &mut kind); + } let id = s.doc_mut().add_feature(kind, name)?; let comp = s.active_component; if let Some(f) = s.doc_mut().feature_mut(id) { @@ -444,8 +482,23 @@ fn create_sketch(s: &mut Session, p: &Value) -> Result { && bool_(p, "project_edges").unwrap_or(s.auto_project) { link = super::sketch_project::auto_project_face(s, id, at)?; + if link.is_none() + && let Some((body, index)) = internal_body + { + link = Some(super::sketch_project::project_internal_face(s, id, body, index)?); + } } - Ok(json!({"sketch": id, "name": name, "projected": link})) + let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind { + FeatureKind::Sketch { plane, .. } => Some(if face::anchor(plane).is_some() { + "exact_face" + } else if internal_body.is_some() { + "internal_legacy_face" + } else { + "fixed" + }), + _ => None, + }); + Ok(json!({"sketch": id, "name": name, "projected": link,"attachment":attachment})) } fn edit_sketch(s: &mut Session, p: &Value) -> Result { @@ -458,7 +511,17 @@ fn edit_sketch(s: &mut Session, p: &Value) -> Result { if !matches!(f.kind, FeatureKind::Sketch { .. }) { return Err(bad("sketch.edit", format!("`{key}` is not a sketch"))); } - let (id, name) = (f.id, f.name.clone()); + let (id, name, component) = (f.id, f.name.clone(), f.component); + let authored_path = match &f.kind { + FeatureKind::Sketch { plane, .. } => face::anchor(plane).map(|anchor| anchor.author_path.clone()), + _ => None, + }; + if let Some(path) = authored_path { + solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), component, &path) + .map_err(|error| bad("sketch.edit", format!("{error}; explicitly rebind the author placement before editing")))?; + s.active_component = component; + s.active_occurrence_path = (!path.is_empty()).then_some(path); + } s.active_sketch = Some(id); s.revision += 1; Ok(json!({"sketch": id, "name": name})) @@ -1759,7 +1822,7 @@ fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2) { } k => { // Lines, splines and conics slide with the cursor. - for q in k.point_ids() { + for q in k.point_ids().into_iter().collect::>() { if let Some(at) = sk.point(q) { set(sk, q, at + d); } diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index b5247c45..6b8948d2 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -54,7 +54,7 @@ pub static COMMANDS: &[CommandSpec] = .at("SKETCH", "CREATE") .icon("spline") .enabled(in_sketch) - .params("points: [[x,y] or point refs…] (2…500): the spline passes through them"), + .params("points: [[x,y] or point refs…] (2…500), periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples"), CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3) .at("SKETCH", "CREATE") .icon("spline_cv") @@ -167,7 +167,7 @@ fn blend(s: &mut Session, p: &Value) -> Result { let first_new = sk.points.len(); let c1 = sk.add_point(qa + ta * (l / 3.0), None)?; let c2 = sk.add_point(qb + tb * (l / 3.0), None)?; - let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3 }, None)?; + let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false }, None)?; let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?]; solve_new_only(sk, first_new); Ok((ids_of(sk, &[sp]), cons)) @@ -252,10 +252,24 @@ fn point_list(sk: &mut Sketch, p: &Value, cmd: &str) -> Result> { } fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> Result { + let periodic = match p.get("periodic") { + None => false, + Some(Value::Bool(v)) => *v, + _ => return Err(bad(cmd, "periodic must be boolean")), + }; + if periodic && (control || degree != 3) { + return Err(bad(cmd, "periodic mode requires a cubic fit spline")); + } let p = &super::sketch_project::snap_points(s, p, cmd)?; let (out, info) = edit(s, p, cmd, false, |sk, _| { - let pts = point_list(sk, p, cmd)?; - let c = sk.add_curve(CurveKind::Spline { pts, control, degree }, None)?; + let mut pts = point_list(sk, p, cmd)?; + if periodic + && sk.point(*pts.first().ok_or_else(|| bad(cmd, "missing first point"))?) + != sk.point(*pts.last().ok_or_else(|| bad(cmd, "missing last point"))?) + { + pts.push(*pts.first().ok_or_else(|| bad(cmd, "missing first point"))?); + } + let c = sk.add_curve(CurveKind::Spline { pts, control, degree, periodic }, None)?; mark_construction(sk, &[c], p); Ok((ids_of(sk, &[c]), Vec::new())) })?; @@ -344,12 +358,12 @@ fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option { None } } - CurveKind::Spline { ref pts, control, .. } => { + CurveKind::Spline { ref pts, control, periodic, .. } => { let q: Vec = pts.iter().filter_map(|i| sk.point(*i)).collect(); if pts.last() == Some(&pi) { - solvecraft_sketch::spline_end_tangent(&q, control, true) + solvecraft_sketch::spline_end_tangent_mode(&q, control, periodic, true) } else if pts.first() == Some(&pi) { - solvecraft_sketch::spline_end_tangent(&q, control, false).map(|t| -t) + solvecraft_sketch::spline_end_tangent_mode(&q, control, periodic, false).map(|t| -t) } else { None } diff --git a/crates/engine/src/cmd/sketch_face.rs b/crates/engine/src/cmd/sketch_face.rs new file mode 100644 index 00000000..b71e0283 --- /dev/null +++ b/crates/engine/src/cmd/sketch_face.rs @@ -0,0 +1,215 @@ +//! Exact planar face capture for sketch attachment. World labels are lookup keys only. +use crate::params::{bad, vec3}; +use crate::{Result, Session}; +use serde_json::Value; +use solvecraft_doc::{EmbossFace, PlaneRef, apply_point, apply_vector, mat_inverse, mat_mul}; +use solvecraft_geom::Plane; + +pub(in crate::cmd) fn is_face(p: &Value) -> bool { + p.get("plane").and_then(|plane| plane.get("face")).is_some() +} + +pub(in crate::cmd) fn capture(s: &Session, value: &Value, cmd: &str) -> Result { + capture_impl(s, value, cmd, false) +} + +/// Internal face-profile compatibility only; never exposed as a command parameter. +pub(in crate::cmd) fn capture_internal(s: &Session, value: &Value, cmd: &str) -> Result { + capture_impl(s, value, cmd, true) +} + +fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result { + let point = value + .get("face") + .and_then(vec3) + .filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()) + .ok_or_else(|| bad(cmd, "face point must be three finite world coordinates"))?; + let fixed = match value.get("fixed") { + None => false, + Some(Value::Bool(fixed)) => *fixed, + _ => return Err(bad(cmd, "fixed must be a boolean")), + }; + let state = s.model.state(); + let doc = s.placed_document(); + let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component)); + let author_frame = EmbossFace::component_frame(&doc, s.active_component, &author_path)?; + let author_inverse = mat_inverse(&author_frame).ok_or_else(|| bad(cmd, "invalid sketch author frame"))?; + let requested_body = match value.get("body") { + None => None, + Some(Value::String(body)) if !body.is_empty() => Some(body.as_str()), + _ => return Err(bad(cmd, "body must be the picked world body name")), + }; + let requested_index = match value.get("index") { + None => None, + Some(value) => { + Some(value.as_u64().and_then(|index| usize::try_from(index).ok()).ok_or_else(|| bad(cmd, "index must be a nonnegative face index"))?) + } + }; + if requested_body.is_some() != requested_index.is_some() { + return Err(bad(cmd, "an exact face pick requires both body and index")); + } + let requested_path = match value.get("path") { + None => None, + Some(value) => Some( + value + .as_array() + .filter(|path| path.len() <= 64) + .ok_or_else(|| bad(cmd, "path must contain at most64 occurrence ids"))? + .iter() + .map(|id| id.as_u64().filter(|id| *id != 0).ok_or_else(|| bad(cmd, "path ids must be nonzero integers"))) + .collect::>>()?, + ), + }; + let selected_path = requested_path + .clone() + .or_else(|| requested_body.and_then(|name| state.body(name)).map(|body| doc.first_path(doc.model_body_component(body)))); + let placements = if let Some(path) = selected_path { + let component = path + .last() + .and_then(|id| doc.occurrences.iter().find(|occurrence| occurrence.id == *id)) + .map(|occurrence| occurrence.component) + .unwrap_or(0); + let frame = EmbossFace::component_frame(&doc, component, &path)?; + vec![(component, path, frame)] + } else { + crate::cmd::component::properties::placements(&doc, cmd)? + }; + let mut by_component: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for body in &state.bodies { + by_component.entry(doc.model_body_component(body)).or_default().push(body); + } + let mut chosen = None; + let mut work = state.bodies.len().saturating_add(doc.occurrences.len()); + for (component, path, placement) in placements { + let Some(bodies) = by_component.get(&component) else { + continue; + }; + for body in bodies { + if let Some(source) = requested_body.and_then(|name| state.body(name)) + && source.name != body.name + { + continue; + } + work = work.saturating_add(1); + if let Some(requested) = requested_body { + work = work.saturating_add(doc.occurrences.len()).saturating_add(state.bodies.len()).saturating_add(path.len()); + if work > 2_000_000 { + return Err(bad(cmd, "face capture work limit exceeded; supply exact path")); + } + if requested != doc.world_body_name(&state, &body.name, component, &path) { + continue; + } + } + let frame = doc.model_body_offset(body).map(|offset| mat_mul(&placement, offset)).unwrap_or(placement); + let inverse = mat_inverse(&frame).ok_or_else(|| bad(cmd, "invalid selected face frame"))?; + let local_point = apply_point(&inverse, point); + let tolerance = (body.body.size() * 1e-4).max(1e-5); + let faces = body.body.faces(tolerance)?; + let mesh = body.body.tessellate(tolerance)?; + work = work.saturating_add(mesh.triangles.len().saturating_mul(if requested_index.is_some() { 1 } else { faces.len() })); + if work > 2_000_000 { + return Err(bad(cmd, "face capture work limit exceeded; use an exact body and face index")); + } + for face in faces { + if requested_index.is_some_and(|index| index != face.index) { + continue; + } + let Some(exact_plane) = (if fixed { + body.body.planar_face(face.index).or_else(|| body.body.face_plane(face.index)) + } else { + body.body.face_plane(face.index).or_else(|| body.body.planar_face(face.index)) + }) else { + continue; + }; + let normal = exact_plane.normal(); + if (local_point - face.centroid).dot(normal).abs() > tolerance * 10.0 + || !super::face_contains(&mesh, face.index, local_point, tolerance * 10.0) + { + continue; + } + if chosen.is_some() { + return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index")); + } + let relative = mat_mul(&author_inverse, &frame); + let normal = apply_vector(&relative, normal).normalized().ok_or_else(|| bad(cmd, "invalid planar face normal"))?; + let centroid = apply_point(&relative, face.centroid); + let picked = Plane::from_normal(normal * centroid.dot(normal), normal).ok_or_else(|| bad(cmd, "invalid selected planar face"))?; + chosen = Some((body, face.index, path.clone(), picked)); + } + } + } + let (body, index, path, plane) = + chosen.ok_or_else(|| bad(cmd, "the selected planar face no longer exists; choose another face or an explicit fixed plane"))?; + if fixed { + return Ok(PlaneRef::Custom { plane }); + } + let at = apply_point(&author_inverse, point); + let name = solvecraft_doc::naming::face_names(body).get(index).cloned(); + let anchor = match doc.capture_face_anchor(body, index, &plane, &path, s.active_component, &author_path) { + Ok(anchor) => Some(Box::new(anchor)), + Err(solvecraft_doc::DocError::Invalid(message)) + if internal + && matches!( + message.as_str(), + "this face has no exact planar source; choose a fixed plane instead" + | "this face has no supported construction certificate; choose a fixed plane instead" + ) => + { + if path.iter().any(|id| s.pending_moves.contains_key(id) && !author_path.contains(id)) { + return Err(bad(cmd, "capture the source occurrence position before generating a legacy face profile across components")); + } + None + } + Err(error) => return Err(bad(cmd, format!("{error}; choose a supported exact face or explicitly use fixed:true"))), + }; + Ok(PlaneRef::Face { plane, at, name, anchor }) +} + +/// The sole durable face attachment beneath an offset/angle wrapper. +pub(in crate::cmd) fn anchor(mut plane: &PlaneRef) -> Option<&solvecraft_doc::FaceAnchor> { + loop { + match plane { + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base, + PlaneRef::Face { anchor, .. } => return anchor.as_deref(), + _ => return None, + } + } +} + +pub(in crate::cmd) fn anchor_mut(mut plane: &mut PlaneRef) -> Option<&mut solvecraft_doc::FaceAnchor> { + loop { + match plane { + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base, + PlaneRef::Face { anchor, .. } => return anchor.as_deref_mut(), + _ => return None, + } + } +} + +pub(in crate::cmd) fn capture_positions(s: &mut Session, plane: &PlaneRef) -> Result<()> { + let mut involved = std::collections::BTreeSet::new(); + if let Some(anchor) = anchor(plane) { + involved.extend(anchor.path.iter().copied()); + involved.extend(anchor.author_path.iter().copied()); + } else { + involved.extend(s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component))); + } + let changes: Vec<_> = involved.into_iter().filter_map(|id| s.pending_moves.get(&id).copied().map(|matrix| (id, matrix))).collect(); + for (id, matrix) in &changes { + let occurrence = s + .doc_mut() + .occurrences + .iter_mut() + .find(|occurrence| occurrence.id == *id) + .ok_or_else(|| bad("sketch attachment", "pending author/source path disappeared"))?; + occurrence.transform = *matrix; + } + for (id, _) in changes { + s.pending_moves.remove(&id); + } + Ok(()) +} + +#[cfg(test)] +#[path = "sketch_face_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/sketch_face_tests.rs b/crates/engine/src/cmd/sketch_face_tests.rs new file mode 100644 index 00000000..32ed9218 --- /dev/null +++ b/crates/engine/src/cmd/sketch_face_tests.rs @@ -0,0 +1,401 @@ +use crate::Session; +use serde_json::{Value, json}; +use solvecraft_geom::Vec3; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}")) +} +fn top_pick(s: &Session, body: &str) -> Value { + let world = s.world_state(); + let body = world.body(body).unwrap(); + let face = body + .body + .faces(0.01) + .unwrap() + .into_iter() + .filter(|face| face.plane_normal.is_some_and(|normal| normal.dot(Vec3::Z) > 0.999)) + .max_by(|a, b| a.centroid.z.total_cmp(&b.centroid.z)) + .unwrap(); + json!({"body":body.name,"index":face.index,"face":[face.centroid.x,face.centroid.y,face.centroid.z]}) +} +fn draw(s: &mut Session) { + run(s, "sketch.rectangle.two_point", json!({"p0":[1,1],"p1":[3,3]})); + run(s, "sketch.dimension", json!({"entities":["l1"],"value":2})); + run(s, "sketch.finish", json!({})); +} +#[test] +fn exact_selected_base_follows_height_and_never_retargets_control() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"})); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":30,"body_name":"Control","name":"ControlFeature"})); + let pick = top_pick(&s, "Base"); + let sketch = run(&mut s, "sketch.create", json!({"name":"Attached","plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap(); + draw(&mut s); + run(&mut s, "solid.extrude", json!({"sketch":sketch,"distance":2,"operation":"new","body_name":"Badge"})); + let height = s.doc.find_feature("BaseFeature").unwrap().param_names[2].clone(); + run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"})); + assert!((s.model.state().sketch(sketch).unwrap().plane.origin.z - 20.).abs() < 1e-6); + assert!((s.world_state().body("Badge").unwrap().mesh().bounds().max.z - 22.).abs() < 1e-6); + run(&mut s, "timeline.delete", json!({"features":["BaseFeature"]})); + assert!(s.model.result(sketch).unwrap().error.is_some()); + assert!(s.model.state().body("Badge").is_none()); + assert!((s.world_state().body("Control").unwrap().mesh().bounds().max.z - 30.).abs() < 1e-6); + let before = s.doc.to_json(); + let reopened = Session::new(solvecraft_doc::Document::from_json(&before).unwrap()); + assert!(reopened.model.result(sketch).unwrap().error.is_some()); +} +#[test] +fn explicit_rebind_preserves_geometry_dimensions_context_and_one_undo() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"})); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":30,"body_name":"Control"})); + let pick = top_pick(&s, "Base"); + let id = run(&mut s, "sketch.create", json!({"plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap(); + draw(&mut s); + run(&mut s, "timeline.delete", json!({"features":["BaseFeature"]})); + let geometry = s.doc.sketch(id).unwrap().clone(); + let before = s.doc.to_json(); + let context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch); + let pick = top_pick(&s, "Control"); + let commands = vec![("sketch.redefine".into(), json!({"sketch":id,"plane":pick}))]; + let preview = s.preview_visible_world(&commands).unwrap(); + assert_eq!(s.doc.to_json(), before); + assert!(preview.after.sketch(id).is_some()); + run(&mut s, "sketch.redefine", commands[0].1.clone()); + assert_eq!(s.doc.sketch(id).unwrap(), &geometry); + assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), context); + assert!((s.model.state().sketch(id).unwrap().plane.origin.z - 30.).abs() < 1e-6); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + assert!(s.model.result(id).unwrap().error.is_some()); + run(&mut s, "edit.redo", json!({})); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!((reopened.model.state().sketch(id).unwrap().plane.origin.z - 30.).abs() < 1e-6); +} +#[test] +fn unsupported_face_has_explicit_fixed_alternative_and_no_live_projection() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"})); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[4,4,10],"operation":"join"})); + let mut pick = top_pick(&s, "Base"); + let before = s.doc.to_json(); + let error = s.execute("sketch.create", &json!({"plane":pick})).unwrap_err(); + assert!(error.to_string().contains("fixed"), "{error}"); + assert_eq!(s.doc.to_json(), before); + pick["fixed"] = json!(true); + let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap(); + assert!(matches!(s.doc.feature(id).unwrap().kind, solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Custom { .. }, .. })); + assert!(s.doc.sketch(id).unwrap().links.is_empty()); +} +#[test] +fn malformed_exact_face_and_path_leave_every_session_field_unchanged() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"})); + let pick = top_pick(&s, "Base"); + for invalid in [ + json!({"body":"Missing","index":pick["index"],"face":pick["face"]}), + json!({"body":"Base","index":-1,"face":pick["face"]}), + json!({"body":"Base","index":pick["index"],"face":pick["face"],"path":[999]}), + json!({"body":"Base","face":pick["face"]}), + ] { + let before = s.doc.to_json(); + let pending = s.pending_moves.clone(); + let context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch); + assert!(s.execute("sketch.create", &json!({"plane":invalid})).is_err()); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.pending_moves, pending); + assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), context); + } +} + +#[test] +fn default_certified_borders_follow_width_and_height_with_stable_entities() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":8,"height":10,"body_name":"Base","name":"BaseFeature"})); + let pick = top_pick(&s, "Base"); + let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap(); + assert!(s.doc.sketch(id).unwrap().links.iter().any(|link| link.auto_face)); + let old_ids: Vec<_> = s.doc.sketch(id).unwrap().curves.iter().map(|curve| curve.id.clone()).collect(); + run(&mut s, "sketch.finish", json!({})); + let dimensions = s.doc.find_feature("BaseFeature").unwrap().param_names.clone(); + run(&mut s, "parameters.change", json!({"name":dimensions[0],"expression":"20 mm"})); + run(&mut s, "parameters.change", json!({"name":dimensions[2],"expression":"15 mm"})); + let resolved = s.model.state(); + let sketch = resolved.sketch(id).unwrap(); + assert!((sketch.plane.origin.z - 15.).abs() < 1e-6); + assert!(sketch.sketch.links.iter().all(|link| !link.lost)); + assert_eq!(sketch.sketch.curves.iter().map(|curve| curve.id.clone()).collect::>(), old_ids); + let max_x = sketch.sketch.points.iter().map(|point| point.pos.x).fold(f64::NEG_INFINITY, f64::max); + let min_x = sketch.sketch.points.iter().map(|point| point.pos.x).fold(f64::INFINITY, f64::min); + let max_y = sketch.sketch.points.iter().map(|point| point.pos.y).fold(f64::NEG_INFINITY, f64::max); + let min_y = sketch.sketch.points.iter().map(|point| point.pos.y).fold(f64::INFINITY, f64::min); + assert!(((max_x - min_x) * (max_y - min_y) - 160.).abs() < 1e-5); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.state().sketch(id).unwrap().sketch.links.iter().all(|link| !link.lost)); +} + +#[test] +fn nested_offset_retains_exact_attachment_during_height_change() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"})); + let pick = top_pick(&s, "Base"); + let id = run(&mut s, "sketch.create", json!({"plane":pick,"offset":"3 mm","project_edges":false}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.finish", json!({})); + let height = s.doc.find_feature("BaseFeature").unwrap().param_names[2].clone(); + run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"})); + assert!((s.model.state().sketch(id).unwrap().plane.origin.z - 23.).abs() < 1e-6); +} + +#[test] +fn exact_nested_source_and_cross_owner_pending_paths_capture_only_involved_positions() { + let mut s = Session::default(); + let parent = run(&mut s, "component.create", json!({"name":"Parent","parent":0})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":parent["occurrence"],"translate":[40,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":parent["component"],"occurrence":second,"path":[second]})); + let child = run(&mut s, "component.create", json!({"name":"Child"})); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"})); + let child_occ = child["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[10,0,0],"capture":false})); + run(&mut s, "occurrence.move", json!({"occurrence":child_occ,"translate":[0,20,0],"capture":false})); + let unrelated = run(&mut s, "component.create", json!({"name":"Unrelated","parent":0})); + run(&mut s, "occurrence.move", json!({"occurrence":unrelated["occurrence"],"translate":[300,0,0],"capture":false})); + let author = run(&mut s, "component.create", json!({"name":"Author","parent":0})); + run(&mut s, "occurrence.move", json!({"occurrence":author["occurrence"],"translate":[100,0,0],"angle":"90 deg","capture":false})); + let world = s.world_state(); + let key = s + .placed_document() + .body_frames_with_source(&s.model.state()) + .into_iter() + .find(|(_, source, _, path, _)| source == "Base" && *path == vec![second, child_occ]) + .unwrap() + .0; + assert!(world.body(&key).is_some()); + let mut pick = top_pick(&s, &key); + pick["path"] = json!([second, child_occ]); + let before = s.doc.to_json(); + let pending = s.pending_moves.clone(); + let id = run(&mut s, "sketch.create", json!({"plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap(); + let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &s.doc.feature(id).unwrap().kind else { panic!("not sketch") }; + let anchor = super::anchor(plane).unwrap(); + assert_eq!(anchor.path, vec![second, child_occ]); + assert_eq!(anchor.author_path, vec![author["occurrence"].as_u64().unwrap()]); + assert_eq!(s.pending_moves.len(), 1); + assert!(s.pending_moves.contains_key(&unrelated["occurrence"].as_u64().unwrap())); + let saved = s.doc.to_json(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.pending_moves, pending); + run(&mut s, "edit.redo", json!({})); + let mut reopened = Session::new(solvecraft_doc::Document::from_json(&saved).unwrap()); + assert!(reopened.model.result(id).unwrap().error.is_none()); + run(&mut reopened, "component.activate", json!({"component":0})); + let original = reopened.doc.to_json(); + let undo = reopened.undo.len(); + let dirty = reopened.is_dirty(); + run(&mut reopened, "sketch.edit", json!({"sketch":id})); + assert_eq!(reopened.doc.to_json(), original); + assert_eq!(reopened.undo.len(), undo); + assert_eq!(reopened.is_dirty(), dirty); + assert_eq!(reopened.active_component, author["component"].as_u64().unwrap()); + assert_eq!(reopened.active_occurrence_path, Some(vec![author["occurrence"].as_u64().unwrap()])); +} + +#[test] +fn fixed_rebind_freezes_current_resized_borders_and_only_detaches_auto_links() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":8,"height":10,"body_name":"Base","name":"BaseFeature"})); + let pick = top_pick(&s, "Base"); + let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.project", json!({"refs":[{"origin":true}]})); + run(&mut s, "sketch.finish", json!({})); + let length = s.doc.find_feature("BaseFeature").unwrap().param_names[0].clone(); + run(&mut s, "parameters.change", json!({"name":length,"expression":"20 mm"})); + let visible = s.model.state().sketch(id).unwrap().sketch.clone(); + let auto: Vec<_> = visible.links.iter().filter(|link| link.auto_face).map(|link| link.id.clone()).collect(); + let manual: Vec<_> = visible.links.iter().filter(|link| !link.auto_face).map(|link| link.id.clone()).collect(); + let before = s.doc.to_json(); + let mut pick = top_pick(&s, "Base"); + pick["fixed"] = json!(true); + run(&mut s, "sketch.redefine", json!({"sketch":id,"plane":pick})); + let fixed = s.doc.sketch(id).unwrap(); + assert!(auto.iter().all(|id| fixed.link(id).is_none())); + assert!(manual.iter().all(|id| fixed.link(id).is_some())); + assert_eq!( + fixed.curves.iter().map(|curve| curve.id.clone()).collect::>(), + visible.curves.iter().map(|curve| curve.id.clone()).collect::>() + ); + assert_eq!(fixed.points.iter().map(|point| point.pos).collect::>(), visible.points.iter().map(|point| point.pos).collect::>()); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.result(id).unwrap().error.is_none()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); +} + +#[test] +fn strict_anchor_timeline_copy_remaps_producer_and_refuses_external_translation_atomically() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"})); + let pick = top_pick(&s, "Base"); + let id = run(&mut s, "sketch.create", json!({"name":"Attached","plane":pick}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "timeline.copy", json!({"features":["Attached"]})); + let before = s.doc.to_json(); + let pending = s.pending_moves.clone(); + let error = s.execute("timeline.paste", &json!({"translate":[30,0,0]})).unwrap_err(); + assert!(error.to_string().contains("external exact face"), "{error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.pending_moves, pending); + run(&mut s, "timeline.copy", json!({"features":["BaseFeature","Attached"]})); + let pasted = run(&mut s, "timeline.paste", json!({"translate":[30,0,0]})); + assert!(pasted["errors"].as_array().unwrap().is_empty(), "{pasted}"); + let new_id = pasted["pasted"][1]["id"].as_u64().unwrap(); + let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &s.doc.feature(new_id).unwrap().kind else { panic!("not sketch") }; + let anchor = super::anchor(plane).unwrap(); + assert_ne!(anchor.target.origin_feature, s.doc.find_feature("BaseFeature").unwrap().id); + assert_ne!(anchor.target.body, "Base"); + assert!(s.model.state().sketch(new_id).unwrap().sketch.links.iter().all(|link| !link.lost)); + assert!(s.model.state().sketch(id).unwrap().sketch.links.iter().all(|link| !link.lost)); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.result(new_id).unwrap().error.is_none()); +} + +#[test] +fn strict_anchor_deep_clone_is_independent_and_rename_preserves_certified_projection() { + let mut s = Session::default(); + let part = run(&mut s, "component.create", json!({"name":"Part","parent":0})); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"})); + let pick = top_pick(&s, "Base"); + let id = run(&mut s, "sketch.create", json!({"name":"Attached","plane":pick}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.finish", json!({})); + let height = s.doc.find_feature("BaseFeature").unwrap().param_names[2].clone(); + let pasted = run(&mut s, "component.paste_new", json!({"component":part["component"],"translate":[30,0,0]})); + let owner = pasted["component"].as_u64().unwrap(); + let new_feature = s + .doc + .features + .iter() + .find(|feature| feature.component == owner && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. })) + .unwrap(); + let new_id = new_feature.id; + let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &new_feature.kind else { panic!("not sketch") }; + let anchor = super::anchor(plane).unwrap(); + let body = anchor.target.body.clone(); + assert_eq!(anchor.component, owner); + run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"})); + assert!((s.model.state().sketch(id).unwrap().plane.origin.z - 20.).abs() < 1e-6); + assert!((s.model.state().sketch(new_id).unwrap().plane.origin.z - 10.).abs() < 1e-6); + run(&mut s, "body.rename", json!({"body":body,"name":"Independent"})); + assert!(s.model.result(new_id).unwrap().error.is_none()); + assert!(s.model.state().sketch(new_id).unwrap().sketch.links.iter().all(|link| !link.lost)); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.result(new_id).unwrap().error.is_none()); +} + +#[test] +fn legacy_or_fixed_rebind_needs_explicit_author_path_for_repeated_owner() { + let mut s = Session::default(); + let part = run(&mut s, "component.create", json!({"name":"Part","parent":0})); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"})); + let mut pick = top_pick(&s, "Base"); + pick["fixed"] = json!(true); + let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.finish", json!({})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":part["occurrence"],"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":0})); + let pick = top_pick(&s, "Base"); + let before = s.doc.to_json(); + let error = s.execute("sketch.redefine", &json!({"sketch":id,"plane":pick})).unwrap_err(); + assert!(error.to_string().contains("ambiguous"), "{error}"); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "sketch.redefine", json!({"sketch":id,"plane":pick,"author_path":[second]})); + let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &s.doc.feature(id).unwrap().kind else { panic!("not sketch") }; + assert_eq!(super::anchor(plane).unwrap().author_path, vec![second]); + assert_eq!(s.active_component, 0); +} + +#[test] +fn replacement_producer_with_same_body_name_cannot_heal_lost_anchor() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"Original"})); + let pick = top_pick(&s, "Base"); + let id = run(&mut s, "sketch.create", json!({"plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "timeline.suppress", json!({"feature":"Original","suppressed":true})); + run(&mut s, "timeline.rollback", json!({"position":1})); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":30,"body_name":"Base","name":"Replacement"})); + run(&mut s, "timeline.rollback", json!({})); + assert!(s.model.state().body("Base").is_some()); + assert!(s.model.result(id).unwrap().error.is_some()); + let before = s.doc.to_json(); + let pick = top_pick(&s, "Base"); + run(&mut s, "sketch.redefine", json!({"sketch":id,"plane":pick})); + assert!((s.model.state().sketch(id).unwrap().plane.origin.z - 30.).abs() < 1e-6); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + assert!(s.model.result(id).unwrap().error.is_some()); +} + +#[test] +fn freezing_rebuilt_arc_borders_keeps_authored_dimension_entity_indices() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name":"turn","expression":"270 deg","unit":"deg"})); + let profile = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap(); + let arc = run( + &mut s, + "sketch.draw.dimensioned", + json!({"command":"sketch.arc.center_point","params":{"center":[0,0],"start":[10,0],"sweep":270},"dimensions":{"radius":10,"sweep":"turn"}}), + )["curves"][0] + .as_str() + .unwrap() + .to_owned(); + run(&mut s, "sketch.line", json!({"points":[format!("{arc}.end"),format!("{arc}.start")],"infer":false})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch":profile,"distance":10,"operation":"new","body_name":"Sector"})); + let pick = top_pick(&s, "Sector"); + let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap(); + let circle = run(&mut s, "sketch.circle.center", json!({"center":[20,20],"radius":2}))["curves"][0].as_str().unwrap().to_owned(); + run(&mut s, "sketch.dimension", json!({"entities":[circle],"type":"radius","value":"2 mm"})); + run(&mut s, "sketch.finish", json!({})); + let before_count = s.model.state().sketch(id).unwrap().sketch.curves.len(); + run(&mut s, "parameters.change", json!({"name":"turn","expression":"300 deg"})); + assert!(s.model.result(id).unwrap().error.is_none()); + let visible = s.model.state().sketch(id).unwrap().sketch.clone(); + assert_ne!(visible.curves.len(), before_count, "fixture must rebuild projected topology"); + let mut pick = top_pick(&s, "Sector"); + pick["fixed"] = json!(true); + let before = s.doc.to_json(); + run(&mut s, "sketch.redefine", json!({"sketch":id,"plane":pick})); + let frozen = s.doc.sketch(id).unwrap(); + let circle_index = frozen.curve_index(&circle).unwrap(); + let dimension = frozen + .constraints + .iter() + .find(|constraint| matches!(constraint.kind, solvecraft_sketch::ConstraintKind::Radius { c, .. } if c == circle_index)) + .unwrap(); + assert!((solvecraft_sketch::measure_dimension(frozen, &dimension.kind).unwrap() - 2.).abs() < 1e-6); + assert_eq!(frozen.curves.iter().map(|curve| &curve.id).collect::>(), visible.curves.iter().map(|curve| &curve.id).collect::>()); + assert!(s.model.result(id).unwrap().error.is_none()); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.result(id).unwrap().error.is_none()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); +} + +#[test] +fn angled_attachment_tracks_source_plane_and_reopens() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"})); + let pick = top_pick(&s, "Base"); + let id = run(&mut s, "sketch.create", json!({"plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.finish", json!({})); + let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &mut s.doc_mut().feature_mut(id).unwrap().kind else { panic!("not sketch") }; + *plane = solvecraft_doc::PlaneRef::AtAngle { base: Box::new(plane.clone()), axis_origin: Vec3::ZERO, axis_dir: Vec3::X, angle: "90 deg".into() }; + s.refresh(); + let height = s.doc.find_feature("BaseFeature").unwrap().param_names[2].clone(); + run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"})); + let placed = s.model.state().sketch(id).unwrap().plane; + assert!((placed.origin.y + 20.).abs() < 1e-6); + assert!(placed.origin.z.abs() < 1e-6); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert_eq!(reopened.model.state().sketch(id).unwrap().plane, placed); +} diff --git a/crates/engine/src/cmd/sketch_freeform.rs b/crates/engine/src/cmd/sketch_freeform.rs index f1e884d0..699d672e 100644 --- a/crates/engine/src/cmd/sketch_freeform.rs +++ b/crates/engine/src/cmd/sketch_freeform.rs @@ -156,7 +156,7 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option, end: Option< Seg2::Line { .. } => CurveKind::Line { a: prev, b: to }, Seg2::Cubic { p1, p2, .. } => { let (c1, c2) = (sk.add_point(p1, None)?, sk.add_point(p2, None)?); - CurveKind::Spline { pts: vec![prev, c1, c2, to], control: true, degree: 3 } + CurveKind::Spline { pts: vec![prev, c1, c2, to], control: true, degree: 3, periodic: false } } Seg2::Conic { apex, w, .. } => { let x = sk.add_point(apex, None)?; @@ -169,7 +169,7 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option, end: Option< pts.push(sk.add_point(*q, None)?); } pts.push(to); - CurveKind::Spline { pts, control: true, degree: n } + CurveKind::Spline { pts, control: true, degree: n, periodic: false } } Seg2::Arc { .. } => return Err(bad("trim", "unexpected arc piece")), }; diff --git a/crates/engine/src/cmd/sketch_import.rs b/crates/engine/src/cmd/sketch_import.rs index 381391db..801683bf 100644 --- a/crates/engine/src/cmd/sketch_import.rs +++ b/crates/engine/src/cmd/sketch_import.rs @@ -89,7 +89,7 @@ fn add_geometry(sk: &mut Sketch, geom: &[Geom2], f: &dyn Fn(Vec2) -> Vec2, k: f6 idx.push(i); } } - (idx.len() >= 2).then_some(CurveKind::Spline { pts: idx, control: *control, degree: *degree }) + (idx.len() >= 2).then_some(CurveKind::Spline { pts: idx, control: *control, degree: *degree, periodic: false }) } Geom2::Conic { a, apex, b, rho } => { let (a, x, b) = (pt(sk, *a)?, pt(sk, *apex)?, pt(sk, *b)?); diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 372f3c8f..282f2def 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -970,7 +970,7 @@ fn offset(s: &mut Session, p: &Value) -> Result { idx.push(pb); idx.dedup(); if idx.len() >= 2 { - made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3 }, None)?); + made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3, periodic: false }, None)?); } } Piece::Line(..) => { @@ -1002,14 +1002,26 @@ fn offset(s: &mut Session, p: &Value) -> Result { fn break_offset(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.offset.break"; - let (out, info) = edit(s, p, cmd, true, |sk, _| { + let (out, info) = edit(s, p, cmd, true, |sk, doc| { let output = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must name an offset output"))?; let record = sk .freeform_offsets .iter() .position(|record| record.output == output) .ok_or_else(|| bad(cmd, "that curve has no free-form offset association"))?; + // A parameter edit may have changed the evaluated source without + // rewriting the stored fit points. Break must freeze the displayed, + // current dimensioned geometry, rather than its stale stored predecessor. + let (values, _) = doc.param_values(); + doc.apply_dimension_values(&values, sk)?; + if !solvecraft_sketch::solve(sk).ok() { + return Err(bad(cmd, "cannot break an offset with conflicting source dimensions")); + } sk.refresh_offsets()?; + if !solvecraft_sketch::solve(sk).ok() { + return Err(bad(cmd, "cannot break an offset with conflicting derived dimensions")); + } + sk.validate_periodic_splines()?; let ci = sk.curve_index(output).ok_or_else(|| bad(cmd, "missing offset output"))?; let indices = sk.curves[ci].kind.point_ids(); sk.freeform_offsets.remove(record); diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index b1940a9c..df31c181 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -690,6 +690,119 @@ fn certified_offset_delete_output_undo_reopen_and_refuse_dependent_source() { run(&mut s, "sketch.delete", json!({"entities":[node]})); assert!(sketch(&s).freeform_offsets.is_empty()); assert!(sketch(&s).curve_index(&id).is_none()); + assert_eq!(sketch(&s).points.len(), 3, "all private derived nodes must be removed"); +} + +#[test] +fn periodic_closed_offset_chain_break_drag_reopen_and_material_volume() { + let mut s = new_sketch(); + run(&mut s, "sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})); + run(&mut s, "sketch.dimension", json!({"entities":["e1"],"type":"major","value":10})); + let source = sketch(&s).points.clone(); + let a = run(&mut s, "sketch.offset", json!({"curves":["e1"],"distance":-1,"chain":false}))["curves"][0].as_str().unwrap().to_string(); + let before_second = s.doc.to_json(); + let b = run(&mut s, "sketch.offset", json!({"curves":[a],"distance":-0.5,"chain":false}))["curves"][0].as_str().unwrap().to_string(); + assert_eq!(&sketch(&s).points[..source.len()], source.as_slice()); + let check = |session: &Session| { + let sk = sketch(session); + assert_eq!(sk.freeform_offsets.iter().map(|r| r.certificate).collect::>(), [2, 2]); + assert_eq!(solvecraft_sketch::find_profiles(&sk).len(), 3); + for id in [&a, &b] { + assert!(matches!(sk.curves[sk.curve_index(id).unwrap()].kind, CurveKind::Spline { periodic: true, .. })); + } + }; + check(&s); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before_second); + run(&mut s, "edit.redo", json!({})); + check(&s); + run(&mut s, "parameters.change", json!({"name":"d1","expression":12})); + check(&s); + let mut reopened = + Session { doc: solvecraft_doc::format::read(&s.doc.to_json()).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() }; + reopened.refresh(); + check(&reopened); + let attached = reopened.doc.to_json(); + let exact_before = sketch(&reopened).exact_segs(sketch(&reopened).curve_index(&b).unwrap()).unwrap(); + run(&mut reopened, "sketch.offset.break", json!({"curve":b})); + let sk = sketch(&reopened); + assert_eq!(sk.exact_segs(sk.curve_index(&b).unwrap()).unwrap(), exact_before); + let points = sk.curves[sk.curve_index(&b).unwrap()].kind.point_ids(); + let first = sk.points[points[0]].id.clone(); + let from = sk.points[points[0]].pos; + run(&mut reopened, "sketch.drag", json!({"entity":first,"from":from,"to":[from.x+0.2,from.y+0.1]})); + let moved = sketch(&reopened); + moved.validate_periodic_splines().unwrap(); + let indices = moved.curves[moved.curve_index(&b).unwrap()].kind.point_ids(); + assert_eq!(moved.point(indices[0]), moved.point(*indices.last().unwrap())); + assert!(moved.point(indices[0]).unwrap().dist(from) > 0.1); + assert_eq!(solvecraft_doc::format::read(&reopened.doc.to_json()).unwrap().to_json(), reopened.doc.to_json()); + run(&mut reopened, "edit.undo", json!({})); + run(&mut reopened, "edit.undo", json!({})); + assert_eq!(reopened.doc.to_json(), attached); + // An independent ring area multiplied by height probes actual profile material. + let regions = solvecraft_sketch::find_profiles(&sketch(&s)); + let selected = regions.iter().enumerate().min_by(|(_, x), (_, y)| x.area.total_cmp(&y.area)).unwrap(); + let n = 4096; + let h = std::f64::consts::TAU / n as f64; + let perimeter = (0..=n) + .map(|i| { + let t = i as f64 * h; + (144.0 * t.sin().powi(2) + 25.0 * t.cos().powi(2)).sqrt() + * if i == 0 || i == n { + 1.0 + } else if i % 2 == 0 { + 2.0 + } else { + 4.0 + } + }) + .sum::() + * h + / 3.0; + let analytic_ring = 0.5 * perimeter + 1.25 * std::f64::consts::PI; + assert!((selected.1.area - analytic_ring).abs() < 0.03); + let expected = analytic_ring * 3.0; + let sketch_id = s.active_sketch.unwrap(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch":sketch_id,"profiles":[selected.0],"distance":3,"operation":"new"})); + let state = s.world_state(); + let volume = solvecraft_kernel::measure(&state.bodies.last().unwrap().body).unwrap().volume; + assert!((volume - expected).abs() < 0.05, "{volume} vs {expected}"); +} + +#[test] +fn periodic_fit_creator_keeps_legacy_and_refuses_hostile_intent_atomically() { + let mut s = new_sketch(); + let p = json!({"points":[[0,0],[4,0],[3,2],[-1,1]],"periodic":true}); + let id = run(&mut s, "sketch.spline.fit_point", p.clone())["curves"][0].as_str().unwrap().to_string(); + let sk = sketch(&s); + let i = sk.curve_index(&id).unwrap(); + assert!(matches!(sk.curves[i].kind, CurveKind::Spline { periodic: true, .. })); + sk.validate_periodic_splines().unwrap(); + assert_eq!(solvecraft_sketch::find_profiles(&sk).len(), 1); + let before = s.doc.to_json(); + let history = s.undo.len(); + for (cmd, p) in [ + ("sketch.spline.control_point", p), + ("sketch.spline.fit_point", json!({"points":[[0,0],[1,0],[0,0],[1,0]],"periodic":true})), + ("sketch.spline.fit_point", json!({"points":[[0,0],[1,0],[1,1]],"periodic":"yes"})), + ] { + assert!(s.execute(cmd, &p).is_err()); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), history); + } + let indices = sk.curves[i].kind.point_ids(); + let point = sk.points[indices[1]].id.clone(); + let target = sk.points[indices[0]].pos; + let redo = s.redo.len(); + assert!(s.execute("sketch.drag", &json!({"entity":point,"from":sk.points[indices[1]].pos,"to":target})).is_err()); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), history); + assert_eq!(s.redo.len(), redo); + let legacy = + run(&mut s, "sketch.spline.fit_point", json!({"points":[[0,0],[4,0],[3,2],[-1,1],[0,0]]}))["curves"][0].as_str().unwrap().to_string(); + assert!(matches!(sketch(&s).curves[sketch(&s).curve_index(&legacy).unwrap()].kind, CurveKind::Spline { periodic: false, .. })); } #[test] diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index 11fe99ce..05daef5f 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -152,8 +152,21 @@ pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) -> let (vals, _) = doc.param_values(); let st = s.model.state_before(id); let comp = doc.feature(id).map(|f| f.component).unwrap_or(0); - let st = solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp)); - solvecraft_doc::project::refresh_links(&doc, &vals, &st, &plane, sk) + let attachment = doc.feature(id).and_then(|feature| match &feature.kind { + solvecraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane), + _ => None, + }); + let st = match attachment { + Some(anchor) => solvecraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp), + None => Ok(solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))), + }; + let Ok(st) = st else { + return vec!["lost face attachment; reattach explicitly".into()]; + }; + match attachment { + Some(anchor) => solvecraft_doc::project::refresh_face_anchor_links(&doc, &vals, &st, &plane, sk, anchor), + None => solvecraft_doc::project::refresh_links(&doc, &vals, &st, &plane, sk), + } } /// Add a link for `src` to sketch `id` in `doc` (not stored in the session). Returns the link id. @@ -164,10 +177,24 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki let st = s.model.state_before(id); // The model as the sketch's component sees it. let comp = doc.feature(id).map(|f| f.component).unwrap_or(0); - let st = solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp)); + let attachment = doc.feature(id).and_then(|feature| match &feature.kind { + solvecraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane), + _ => None, + }); + let st = match attachment { + Some(anchor) => solvecraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp), + None => Ok(solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))), + }; + let st = st?; let r = resolve(&doc, &vals, &st, &plane, kind, &src)?; - let name = if let LinkSource::Face { at, .. } = &r.source { - solvecraft_doc::naming::face_names_at(&st, &[*at]).into_iter().next().filter(|n| !n.is_empty()) + let name = if let LinkSource::Face { body, at } = &r.source { + st.body(body).and_then(|body| { + if let Some(index) = r.face_index { + solvecraft_doc::naming::face_names(body).get(index).filter(|name| !name.is_empty()).cloned() + } else { + solvecraft_doc::naming::face_name_at_body(body, *at) + } + }) } else { None }; @@ -455,17 +482,53 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result Result> { +/// Private compatibility projection for generated face profiles. Named legacy links follow +/// edits, but cannot provide the construction certificate required by public attachments. +pub(super) fn project_internal_face(s: &mut Session, id: u64, body: &str, index: usize) -> Result { let mut doc = (*s.doc).clone(); + let plane = sketch_plane(&doc, id)?; + let raw = s.model.state_before(id); + let component = doc.feature(id).map(|feature| feature.component).unwrap_or(0); + let state = solvecraft_doc::state_in_frame_at(&doc, &raw, component, owner_frame(s, &doc, component)); + let resolved = solvecraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?; + let face_name = state.body(body).and_then(|body| solvecraft_doc::naming::face_names(body).get(index).cloned()); let mut sk = doc.sketch(id)?.clone(); - let l = match add_link(s, &doc, id, &mut sk, LinkKind::Project, LinkSource::Face { body: String::new(), at }) { - Ok(l) => l, - Err(_) => return Ok(None), - }; + let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?; + if let Some(entry) = sk.links.iter_mut().find(|entry| entry.id == link) { + entry.face_name = face_name; + } *doc.sketch_mut(id)? = sk; *s.doc_mut() = doc; - Ok(Some(l)) + Ok(link) +} + +/// Project the edges of the face a new sketch was created on (Fusion's default). +pub(super) fn auto_project_face(s: &mut Session, id: u64, _at: Vec3) -> Result> { + let mut doc = (*s.doc).clone(); + let Some(anchor) = doc + .feature(id) + .and_then(|feature| match &feature.kind { + solvecraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane), + _ => None, + }) + .cloned() + else { + return Ok(None); + }; + let raw = s.model.state_before(id); + let author = doc.feature(id).map(|feature| feature.component).unwrap_or(0); + let state = solvecraft_doc::state_in_face_anchor_frame(&doc, &raw, &anchor, author)?; + let plane = doc.resolve_face_anchor(&anchor, author, &raw)?; + let resolved = solvecraft_doc::project::resolve_anchored_face(&doc, &state, &plane, LinkKind::Project, &anchor)?; + let mut sk = doc.sketch(id)?.clone(); + let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?; + if let Some(link) = sk.links.iter_mut().find(|entry| entry.id == link) { + link.face_name = Some(anchor.target.name.clone()); + link.auto_face = true; + } + *doc.sketch_mut(id)? = sk; + *s.doc_mut() = doc; + Ok(Some(link)) } #[cfg(test)] diff --git a/crates/engine/src/cmd/sketch_project_tests.rs b/crates/engine/src/cmd/sketch_project_tests.rs index bf9e699a..6dbb6eff 100644 --- a/crates/engine/src/cmd/sketch_project_tests.rs +++ b/crates/engine/src/cmd/sketch_project_tests.rs @@ -13,6 +13,68 @@ fn ids(v: &Value) -> Vec { v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() } +#[test] +fn manual_projection_captures_the_requested_coincident_body_producer() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Control","name":"ControlFeature"})); + run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"body_name":"Target","name":"TargetFeature"})); + let producer = s.doc.find_feature("TargetFeature").unwrap().id; + let id = run(&mut s, "sketch.create", json!({"plane":"XY","offset":10}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.project", json!({"refs":[{"body":"Target","face":[1,1,10]}]})); + run(&mut s, "sketch.finish", json!({})); + let initial_state = s.model.state(); + let initial = &initial_state.sketch(id).unwrap().sketch.links[0]; + assert_eq!(initial.face_name, Some(format!("F{producer}:+z"))); + assert!(!initial.lost); + let height = s.doc.find_feature("TargetFeature").unwrap().param_names[2].clone(); + run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"})); + let verify = |session: &Session| { + let state = session.model.state(); + let sk = &state.sketch(id).unwrap().sketch; + assert!(!sk.links[0].lost); + assert_eq!(sk.links[0].face_name, Some(format!("F{producer}:+z"))); + assert!(matches!(&sk.links[0].source,solvecraft_sketch::LinkSource::Face{body,at} if body=="Target" && (at.z-20.).abs()<1e-6)); + assert!(sk.points.iter().any(|point| point.pos.x > 19.)); + assert!(sk.points.iter().any(|point| point.pos.y > 19.)); + assert!((session.world_state().body("Control").unwrap().mesh().bounds().max.z - 10.).abs() < 1e-6); + }; + verify(&s); + verify(&Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap())); +} + +#[test] +fn initial_projection_retains_selected_face_when_closest_point_lands_on_shared_edge() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"body_name":"Target"})); + let state = s.model.state(); + let body = state.body("Target").unwrap(); + let source = solvecraft_sketch::LinkSource::Face { body: "Target".into(), at: solvecraft_geom::Vec3::new(-0.1, 5., 10.5) }; + let resolved = solvecraft_doc::project::resolve( + &s.doc, + &std::collections::BTreeMap::new(), + &state, + &solvecraft_geom::Plane::XY, + solvecraft_sketch::LinkKind::Project, + &source, + ) + .unwrap(); + let index = resolved.face_index.unwrap(); + let expected = solvecraft_doc::naming::face_names(body)[index].clone(); + let solvecraft_sketch::LinkSource::Face { at, .. } = resolved.source else { panic!("not face") }; + assert!(at.x.abs() < 1e-6 && (at.z - 10.).abs() < 1e-6, "closestpoint must be on shared top/wall edge: {at:?}"); + let plane = body.body.face_plane(index).unwrap(); + let id = run(&mut s, "sketch.create", json!({"plane":{"origin":plane.origin,"x_dir":plane.x,"y_dir":plane.y}}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.project", json!({"refs":[{"body":"Target","face":[-0.1,5.,10.5]}]})); + run(&mut s, "sketch.finish", json!({})); + let current = s.model.state(); + let sk = ¤t.sketch(id).unwrap().sketch; + assert_eq!(sk.links[0].face_name, Some(expected)); + assert!(!sk.links[0].lost); + assert_eq!(sk.curves.len(), 4); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert_eq!(reopened.model.state().sketch(id).unwrap().sketch.curves, sk.curves); +} + /// A 40 x 30 x 20 box from a sketch whose width is the user parameter `width`. fn box_part(s: &mut Session) { run(s, "parameters.change", json!({"name": "width", "expression": "40 mm"})); diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs index 8a882e41..1698f65f 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -369,12 +369,25 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option { out["params"] = inner; return Some(out); } + // Exact face capture retains the original world pick and occurrence identity. + if matches!(id, "sketch.create" | "sketch.redefine") && params.get("plane").and_then(|plane| plane.get("face")).is_some() { + return None; + } let keys = table(id); if keys.is_empty() || !placed(s) { return None; } let obj = params.as_object()?; - let mut m = Mapper { s, f: frames(s), first: None, changed: false, here: false }; + let mut command_frames = frames(s); + if id == "sketch.redefine" { + let key = params.get("sketch").and_then(|value| value.as_str().map(str::to_string).or_else(|| value.as_u64().map(|id| id.to_string())))?; + let feature = s.doc.find_feature(&key)?; + let path = crate::cmd::author_path_for_rebind(s, feature, params, id).ok()?; + let frame = solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), feature.component, &path).ok()?; + command_frames.active = frame; + command_frames.sketch = frame; + } + let mut m = Mapper { s, f: command_frames, first: None, changed: false, here: false }; let body = obj.get("body").and_then(Value::as_str).map(str::to_string); let mut out: Map = obj.clone(); // Picks first: their frame turns the directions. diff --git a/crates/engine/src/frames_tests.rs b/crates/engine/src/frames_tests.rs index 220ab277..f1eec120 100644 --- a/crates/engine/src/frames_tests.rs +++ b/crates/engine/src/frames_tests.rs @@ -75,8 +75,9 @@ fn world_picks_map_into_the_component() { // The edge along X at the top front: world (65, 0, 10). run(&mut s, "solid.fillet", json!({"edges": [[65, 0, 10]], "radius": 2})); assert!(volume(&mut s) < v0 - 1.0, "the fillet cut material"); - // A sketch on the moved top face sits at world z = 10 over the moved block. - run(&mut s, "sketch.create", json!({"plane": {"face": [70, 10, 10]}, "name": "Pad"})); + // The modified face has no certified lineage; explicitly freeze its picked plane. + // Its world pick still maps into the moved component exactly once. + run(&mut s, "sketch.create", json!({"plane": {"face": [70, 10, 10],"fixed":true}, "name": "Pad"})); run(&mut s, "sketch.circle.center", json!({"center": [20, 10], "radius": 2, "id": "PadCircle"})); let sketch = run(&mut s, "sketch.inspect", json!({"sketch": "Pad"})); let center = sketch["curves"].as_array().unwrap().iter().find(|c| c["id"] == "PadCircle").unwrap()["center"].as_str().unwrap().to_string(); diff --git a/crates/engine/src/pattern_suppress_tests.rs b/crates/engine/src/pattern_suppress_tests.rs index f52dd25f..ee5ba758 100644 --- a/crates/engine/src/pattern_suppress_tests.rs +++ b/crates/engine/src/pattern_suppress_tests.rs @@ -33,3 +33,79 @@ fn suppressing_a_patterned_feature_removes_its_copies() { run(&mut s, "timeline.suppress", json!({"feature": "Standoff", "suppressed": false})); assert!((volume(&s, "Case") - full).abs() < 1e-6 * full); } + +#[test] +fn enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs() { + let mut s = Session::default(); + let script: Value = serde_json::from_str(include_str!("../../../examples/acceptance/sensor_enclosure.json")).unwrap(); + s.run_script(&script).unwrap(); + for (name, expression) in [("wall", "2.5 mm"), ("height", "30 mm"), ("board_d", "50 mm")] { + run(&mut s, "parameters.change", json!({"name":name,"expression":expression})); + } + assert!(s.model.results.iter().all(|result| result.error.is_none())); + for (name, z) in [("PCB standoff", 2.5), ("Lid boss", 2.5), ("Lid lip", 30.), ("Lid screw", 34.)] { + let feature = + s.doc.features.iter().find(|feature| feature.name == name && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap(); + assert!((s.model.state().sketch(feature.id).unwrap().plane.origin.z - z).abs() < 1e-6); + } + let usb = + s.doc.features.iter().find(|feature| feature.name == "USB" && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap(); + assert!((s.model.state().sketch(usb.id).unwrap().plane.origin.y + 35.5).abs() < 1e-6); + let assert_material = |session: &Session| { + let state = session.world_state(); + let case = state.body("Case").unwrap().mesh(); + let lid = state.body("Lid").unwrap().mesh(); + assert!(case.contains(solvecraft_geom::Vec3::new(-24.5, -21.5, 7.))); + assert!(!case.contains(solvecraft_geom::Vec3::new(-26.5, -21.5, 7.))); + assert!(!case.contains(solvecraft_geom::Vec3::new(-10., -35.25, 11.35))); + assert!(case.contains(solvecraft_geom::Vec3::new(-10., -35.25, 15.))); + assert!( + lid.contains(solvecraft_geom::Vec3::new(0., 32.2, 29.)), + "lid bounds {:?}; lip {:?}; probes {:?}", + lid.bounds(), + session + .model + .state() + .sketch( + session + .doc + .features + .iter() + .find(|feature| feature.name == "Lid lip" && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. })) + .unwrap() + .id + ) + .unwrap() + .sketch + .links, + (20..35).map(|y| (y, lid.contains(solvecraft_geom::Vec3::new(0., f64::from(y) + 0.2, 29.)))).collect::>() + ); + assert!(!lid.contains(solvecraft_geom::Vec3::new(0., 32.9, 29.))); + assert!(!lid.contains(solvecraft_geom::Vec3::new(-33.5, -28.5, 32.))); + assert!(lid.contains(solvecraft_geom::Vec3::new(-31.5, -28.5, 32.))); + }; + assert_material(&s); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.results.iter().all(|result| result.error.is_none())); + assert_material(&reopened); +} + +#[test] +fn enclosure_inward_offset_collapse_reports_an_error_instead_of_a_stale_lip() { + let mut s = Session::default(); + let script: Value = serde_json::from_str(include_str!("../../../examples/acceptance/sensor_enclosure.json")).unwrap(); + s.run_script(&script).unwrap(); + run(&mut s, "parameters.change", json!({"name":"wall","expression":"3 mm"})); + // R4 outer corners leave R1 inner corners. The requested 1.4 mm inward + // offset needs a topology transition, which this offset operation refuses. + let lip = s.doc.features.iter().find(|f| f.name == "Lid lip" && matches!(f.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap(); + let result = s.model.result(lip.id).unwrap(); + assert!(result.error.as_deref().is_some_and(|error| error.contains("offset collapses an arc")), "{result:?}"); + assert!(result.warning.as_deref().is_none_or(|warning| !warning.contains("identity is ambiguous")), "{result:?}"); + let state = s.model.state(); + assert!(state.sketch(lip.id).is_none()); + assert!(!state.body("Lid").unwrap().mesh().contains(solvecraft_geom::Vec3::new(0., 27.2, 24.))); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.result(lip.id).unwrap().error.as_deref().is_some_and(|error| error.contains("offset collapses an arc"))); + assert!(reopened.model.state().sketch(lip.id).is_none()); +} diff --git a/crates/engine/src/profile_refs_tests.rs b/crates/engine/src/profile_refs_tests.rs index 00d7e751..4354cbbf 100644 --- a/crates/engine/src/profile_refs_tests.rs +++ b/crates/engine/src/profile_refs_tests.rs @@ -30,7 +30,8 @@ fn enclosure() -> Session { {"command": "solid.extrude", "params": {"distance": "height", "body_name": "Enclosure"}}, {"command": "solid.fillet", "params": {"edges": [[40, 30, 15], [-40, 30, 15], [-40, -30, 15], [40, -30, 15]], "radius": 6}}, {"command": "solid.shell", "params": {"faces": [[0, 0, 30]], "thickness": "wall"}}, - {"command": "sketch.create", "params": {"plane": {"face": [40, 10, 20]}, "name": "USB port"}}, + {"command": "sketch.create", "params": {"plane": "YZ", "offset":"width / 2", "name": "USB port"}}, + {"command": "sketch.project", "params": {"refs":[{"face":[40,10,20],"body":"Enclosure"}]}}, {"command": "sketch.rectangle.center", "params": {"center": [0, 12], "corner": [6, 15.5]}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"distance": "-wall", "operation": "cut", "profiles": [1]}} @@ -58,6 +59,18 @@ fn a_port_picked_by_index_stays_a_cut_after_a_width_change() { assert!(after[0].1 > before[0].1 + 1000.0, "{before:?} {after:?}"); let id = s.doc.features.last().unwrap().id; assert!(s.model.result(id).is_some_and(|r| r.error.is_none()), "{:?}", s.model.result(id)); + let port = s.doc.find_feature("USB port").unwrap().id; + assert!((s.model.state().sketch(port).unwrap().plane.origin.x - 50.).abs() < 1e-6); + let world = s.world_state(); + let mesh = world.body("Enclosure").unwrap().mesh(); + assert!(!mesh.contains(solvecraft_geom::Vec3::new(49., 0., 12.))); + assert!(mesh.contains(solvecraft_geom::Vec3::new(49., 0., 20.))); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.result(id).unwrap().error.is_none()); + let world = reopened.world_state(); + let mesh = world.body("Enclosure").unwrap().mesh(); + assert!(!mesh.contains(solvecraft_geom::Vec3::new(49., 0., 12.))); + assert!(mesh.contains(solvecraft_geom::Vec3::new(49., 0., 20.))); } /// A pick the sketch no longer has is an error on the feature, never a different profile. diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index caa4f621..dc50b6b2 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -21,14 +21,14 @@ pub fn script() -> Value { {"command": "sketch.circle.center", "params": {"center": [68, 25], "diameter": 8}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"distance": "thickness", "operation": "cut", "name": "Holes"}}, - {"command": "sketch.create", "params": {"plane": {"face": [40, 25, 8]}, "name": "Boss"}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": "thickness", "name": "Boss"}}, {"command": "sketch.circle.center", "params": {"center": [40, 25], "diameter": 24}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"distance": 14, "operation": "join", "name": "Boss"}}, - {"command": "sketch.create", "params": {"plane": {"face": [40, 37, 22]}, "name": "Bore"}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": "thickness + 14 mm", "name": "Bore"}}, {"command": "sketch.circle.center", "params": {"center": [40, 25], "diameter": 12}}, {"command": "sketch.finish", "params": {}}, - {"command": "solid.extrude", "params": {"distance": 22, "direction": "negative", "operation": "cut", "name": "Bore"}}, + {"command": "solid.extrude", "params": {"distance": "thickness + 14 mm", "direction": "negative", "operation": "cut", "name": "Bore"}}, ]}) } @@ -139,6 +139,44 @@ fn vents() -> Value { #[cfg(test)] mod tests { + #[test] + fn plate_boss_and_bore_planes_follow_thickness_without_weak_face_references() { + let mut session = crate::Session::default(); + session.run_script(&super::script()).unwrap(); + let boss = session + .doc + .features + .iter() + .find(|feature| feature.name == "Boss" && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. })) + .unwrap() + .id; + let bore = session + .doc + .features + .iter() + .find(|feature| feature.name == "Bore" && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. })) + .unwrap() + .id; + session.execute("parameters.change", &serde_json::json!({"name":"thickness","expression":"12 mm"})).unwrap(); + assert!(session.model.results.iter().all(|result| result.error.is_none())); + assert!((session.model.state().sketch(boss).unwrap().plane.origin.z - 12.).abs() < 1e-6); + assert!((session.model.state().sketch(bore).unwrap().plane.origin.z - 26.).abs() < 1e-6); + let world = session.world_state(); + let plate = world.body("Plate").unwrap().mesh(); + assert!((plate.bounds().max.z - 26.).abs() < 1e-6); + assert!(plate.contains(solvecraft_geom::Vec3::new(5., 25., 10.))); + assert!(plate.contains(solvecraft_geom::Vec3::new(40., 35., 24.))); + assert!(!plate.contains(solvecraft_geom::Vec3::new(40., 25., 24.))); + assert!(!plate.contains(solvecraft_geom::Vec3::new(40., 25., 2.))); + assert!(plate.contains(solvecraft_geom::Vec3::new(40., 35., 2.))); + let reopened = crate::Session::new(solvecraft_doc::Document::from_json(&session.doc.to_json()).unwrap()); + assert!((reopened.model.state().sketch(bore).unwrap().plane.origin.z - 26.).abs() < 1e-6); + let reopened_world = reopened.world_state(); + let reopened_plate = reopened_world.body("Plate").unwrap().mesh(); + assert!(!reopened_plate.contains(solvecraft_geom::Vec3::new(40., 25., 2.))); + assert!(reopened_plate.contains(solvecraft_geom::Vec3::new(40., 35., 2.))); + } + /// Every built-in sample builds without a failing feature or joint. #[test] fn samples_build_cleanly() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9e2529ac..b16559dc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -28,7 +28,19 @@ fn join_extrude_from_a_chamfered_top_face() { run(&mut s, "solid.extrude", json!({"distance": 20})); run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": 2})); let base = volume(&mut s); - run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20]}})); + let before = s.doc.to_json(); + let active = s.active_sketch; + assert!(s.execute("sketch.create", &json!({"plane":{"face":[0,0,20]}})).is_err(), "modified faces require an explicit fixed plane"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.active_sketch, active); + assert!(rel(volume(&mut s), base) < 1e-12); + let id = run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20],"fixed":true}}))["sketch"].as_u64().unwrap(); + let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Custom { plane }, sketch } = &s.doc.feature(id).unwrap().kind else { + panic!("explicit fixed face must become a custom plane"); + }; + assert!((plane.origin.z - 20.).abs() < 1e-9); + assert!(sketch.links.is_empty(), "fresh fixed plane does not create live auto-projections"); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":8})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"})); assert_eq!(s.model.state().bodies.len(), 1); @@ -482,7 +494,12 @@ fn step_open_then_model_on_it() { run(&mut s, "solid.hole", json!({"position": at(28.0, 15.0, 20.0), "diameter": 6})); want -= PI * 9.0 * 20.0; assert!(rel(volume(&mut s), want) < 5e-4, "{} vs {want}", volume(&mut s)); - run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0)}})); + // Modified imported faces no longer have an immutable construction certificate. + // The public attachment refuses them; the user can explicitly choose a fixed plane. + let before_fixed = s.doc.to_json(); + assert!(s.execute("sketch.create", &json!({"plane":{"face":at(10.0,15.0,20.0)}})).is_err()); + assert_eq!(s.doc.to_json(), before_fixed); + run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0), "fixed": true}})); let c = [x0 + 10.0, y0 + 15.0]; // Sketch coordinates on the face are relative to the model origin projected onto it. run(&mut s, "sketch.rectangle.two_point", json!({"p0": [c[0] - 4.0, c[1] - 4.0], "p1": [c[0] + 4.0, c[1] + 4.0]})); @@ -1587,20 +1604,71 @@ mod naming { run(&mut s, "solid.extrude", json!({"sketch": "Top", "distance": 5, "operation": "join", "name": "Peg"})); let top = |s: &mut Session| run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0); assert!((top(&mut s) - 15.0).abs() < 1e-6); - // Upstream, a tall boss over the picked point: the nearest upward face is now its top, - // but the sketch keeps to the block's top face by name. - run(&mut s, "timeline.roll_to", json!({"feature": "Block"})); - run(&mut s, "sketch.create", json!({"plane": "XY", "name": "B"})); - run(&mut s, "sketch.rectangle.two_point", json!({"p0": [8, -5], "p1": [45, 35]})); - run(&mut s, "sketch.finish", json!({})); - run(&mut s, "solid.extrude", json!({"sketch": "B", "distance": 20, "operation": "join", "name": "Boss"})); - run(&mut s, "timeline.roll_to", json!({})); - // (Its projected face outline changed, which it says; its plane did not.) - assert!(!warning(&s, "Top").unwrap_or_default().contains("no longer exists")); - // The peg starts on the block's top (z = 10), inside the boss: the part stays 20 tall. + let attached = |s: &Session| s.model.state().sketch(s.doc.find_feature("Top").unwrap().id).map(|sketch| sketch.plane.origin.z); + assert_eq!(attached(&s), Some(10.)); + run(&mut s, "timeline.edit", json!({"feature":"Block","set":{"extent":{"distance":"12"}}})); + assert_eq!(attached(&s), Some(12.)); + assert!((top(&mut s) - 17.).abs() < 1e-6); + run(&mut s, "edit.undo", json!({})); + assert_eq!(attached(&s), Some(10.)); + assert!((top(&mut s) - 15.).abs() < 1e-6); + let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + let mut model = solvecraft_doc::Model::new(); + model.evaluate(&reopened); + assert_eq!(model.state().sketch(reopened.find_feature("Top").unwrap().id).unwrap().plane.origin.z, 10.); + assert!((model.state().bodies[0].mesh().bounds().max.z - 15.).abs() < 1e-6); + // A historical format-10 attachment uses its saved generic face name and no strong anchor. + let mut old = (*s.doc).clone(); + let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Face { anchor, name, .. }, sketch } = + &mut old.features.iter_mut().find(|f| f.name == "Top").unwrap().kind + else { + panic!("fixture must contain a face sketch"); + }; + *anchor = None; + for link in &mut sketch.links { + if link.auto_face { + link.auto_face = false; + link.face_name = name.clone(); + } + } + old.format = "solvecraft/10".into(); + let mut legacy = Session::new(solvecraft_doc::Document::from_json(&old.to_json()).unwrap()); + assert_eq!(attached(&legacy), Some(10.)); + assert!((top(&mut legacy) - 15.).abs() < 1e-6); + // The upstream join consumes the certified body; new attachments cannot follow its substitute roof. + for target in [&mut s, &mut legacy] { + run(target, "timeline.roll_to", json!({"feature": "Block"})); + run(target, "sketch.create", json!({"plane": "XY", "name": "B"})); + run(target, "sketch.rectangle.two_point", json!({"p0": [8, -5], "p1": [45, 35]})); + run(target, "sketch.finish", json!({})); + run(target, "solid.extrude", json!({"sketch": "B", "distance": 20, "operation": "join", "name": "Boss"})); + run(target, "timeline.roll_to", json!({})); + } + assert_eq!(attached(&legacy), Some(10.), "historical weak naming retains its saved geometry behavior"); + assert!((top(&mut legacy) - 20.).abs() < 1e-6); + let legacy_reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.doc.to_json()).unwrap()); + assert_eq!(attached(&legacy_reopened), Some(10.)); + assert!((legacy_reopened.model.state().bodies[0].mesh().bounds().max.z - 20.).abs() < 1e-6); + let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Face { anchor, .. }, .. } = + &legacy_reopened.doc.find_feature("Top").unwrap().kind + else { + panic!("saved historical face reference must remain present"); + }; + assert!(anchor.is_none(), "opening old face references must not infer a strict attachment"); + let failed = |s: &Session, name: &str| s.model.results.iter().find(|r| r.id == s.doc.find_feature(name).unwrap().id).unwrap().error.is_some(); + assert!(failed(&s, "Top")); + assert!(failed(&s, "Peg"), "lost sketch cannot supply downstream geometry"); + assert_eq!(attached(&s), None); assert!((top(&mut s) - 20.0).abs() < 1e-6, "{}", top(&mut s)); - let sk = s.model.state().sketch(s.doc.find_feature("Top").map(|f| f.id).unwrap_or(0)).map(|x| x.plane.origin.z); - assert_eq!(sk.map(|z| (z * 1e6).round() / 1e6), Some(10.0)); + run(&mut s, "timeline.suppress", json!({"feature":"Boss","suppressed":true})); + assert!(!failed(&s, "Top")); + assert!(!failed(&s, "Peg")); + assert_eq!(attached(&s), Some(10.)); + assert!((top(&mut s) - 15.).abs() < 1e-6); + run(&mut s, "edit.undo", json!({})); + assert!(failed(&s, "Top")); + assert!(failed(&s, "Peg")); + assert_eq!(attached(&s), None); } #[test] diff --git a/crates/engine/src/tests/component_frames.rs b/crates/engine/src/tests/component_frames.rs index 081e5a95..b6ce2d53 100644 --- a/crates/engine/src/tests/component_frames.rs +++ b/crates/engine/src/tests/component_frames.rs @@ -24,8 +24,9 @@ fn moved_component_picks_and_frames() { // Root active: a fillet picked at the moved edge in the world. run(&mut s, "solid.fillet", json!({"edges": [[25, 54, 4]], "radius": 1})); assert!(rel(vol(&mut s, "ArmBody"), 160.0 - (1.0 - PI / 4.0) * 10.0) < 1e-4, "{}", vol(&mut s, "ArmBody")); - // A sketch on the moved body's top face: its points sit on that face in the world. - let sk = run(&mut s, "sketch.create", json!({"plane": {"face": [25, 52, 4]}})); + // An explicit fixed plane on the modified body's top keeps the world pick; + // unsupported modifier lineage cannot silently create a weak attachment. + let sk = run(&mut s, "sketch.create", json!({"plane": {"face": [25, 52, 4],"fixed":true}})); run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 1})); run(&mut s, "sketch.finish", json!({})); let si = run(&mut s, "sketch.inspect", json!({"sketch": sk["sketch"]})); diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index cbe92ed2..44998c36 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -33,11 +33,27 @@ pub struct Body { #[derive(Clone, Debug, PartialEq)] pub enum FaceSource { - ExtrudeSide { segment: solvecraft_geom::Seg2 }, - CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String }, + ExtrudeSide { segment: solvecraft_geom::Seg2, plane: Option }, + CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String, plane: Option }, + ExtrudeCap { high: bool, region: solvecraft_geom::Region2, plane: solvecraft_geom::Plane }, + CertifiedExtrudeCap { producer: u64, sketch: u64, high: bool, outer: Vec, holes: Vec>, plane: solvecraft_geom::Plane }, + Box { producer: Option, axis: u8, high: bool, plane: solvecraft_geom::Plane }, Imported { item: u64, face: u64, payload_sha256: Option, producer: Option }, } +pub type CapSignature = (Vec, Vec>); +type FaceSources = std::sync::Arc>>; + +impl FaceSource { + fn plane_mut(&mut self) -> Option<&mut solvecraft_geom::Plane> { + match self { + Self::ExtrudeSide { plane, .. } | Self::CertifiedExtrudeSide { plane, .. } => plane.as_mut(), + Self::ExtrudeCap { plane, .. } | Self::CertifiedExtrudeCap { plane, .. } | Self::Box { plane, .. } => Some(plane), + Self::Imported { .. } => None, + } + } +} + /// How a body looks beyond its colour, for export: its opacity and its faces' own colours. #[derive(Clone, Debug, PartialEq)] pub struct Paint { @@ -342,6 +358,131 @@ impl Body { self.face_sources.as_deref().map(Vec::as_slice) } + /// An exact construction/import plane; never inferred from tessellation. + pub fn face_plane(&self, index: usize) -> Option { + if index >= 50_000 { + return None; + } + let source = self.face_sources()?.get(index)?.as_ref()?; + match source { + FaceSource::ExtrudeSide { plane, .. } | FaceSource::CertifiedExtrudeSide { plane, .. } => *plane, + FaceSource::ExtrudeCap { plane, .. } | FaceSource::CertifiedExtrudeCap { plane, .. } | FaceSource::Box { plane, .. } => Some(*plane), + FaceSource::Imported { .. } => self.planar_face(index), + } + } + + /// Exact current planar surface for an explicitly fixed snapshot, without source lineage. + pub fn planar_face(&self, index: usize) -> Option { + if index >= 50_000 { + return None; + } + guard("exact planar face", || { + let plane = self.solid.face_iter().nth(index).and_then(|face| match face.oriented_surface() { + mt::Surface::Plane(plane) => { + let u = plane.u_axis(); + let v = plane.v_axis(); + solvecraft_geom::Plane::new(from_p3(plane.origin()), Vec3::new(u.x, u.y, u.z), Vec3::new(v.x, v.y, v.z)) + } + _ => None, + }); + Ok(plane) + }) + .ok() + .flatten() + } + + pub fn with_box_certificate(mut self, producer: u64) -> Result { + if producer == 0 { + return Err(KernelError::Invalid("invalid box producer".into())); + } + if let Some(sources) = &self.face_sources { + let mut sources = sources.as_ref().clone(); + for source in sources.iter_mut().flatten() { + if let FaceSource::Box { producer: id, .. } = source { + *id = Some(producer); + } + } + self.face_sources = Some(std::sync::Arc::new(sources)); + } + Ok(self) + } + + /// Cap signatures are in face order; unknown or ambiguous source curves stay uncertified. + pub fn with_extrude_cap_certificates(mut self, producer: u64, sketch: u64, signatures: &[Option]) -> Result { + if producer == 0 || sketch == 0 { + return Err(KernelError::Invalid("invalid cap producer".into())); + } + if let Some(sources) = &self.face_sources { + if sources.len() != signatures.len() { + return Err(KernelError::Invalid("cap certificate count mismatch".into())); + } + let mut sources = sources.as_ref().clone(); + for (source, signature) in sources.iter_mut().zip(signatures) { + if let Some(FaceSource::ExtrudeCap { high, plane, .. }) = source { + let Some((outer, holes)) = signature else { + *source = None; + continue; + }; + if outer.len() > 4096 || holes.len() > 4096 || holes.iter().any(|hole| hole.len() > 4096) { + return Err(KernelError::Invalid("oversized cap curve signature".into())); + } + let count = outer.len() + holes.iter().map(Vec::len).sum::(); + if outer.is_empty() + || count > 4096 + || holes.len() > 4096 + || outer.iter().chain(holes.iter().flatten()).map(String::len).sum::() > 65_536 + || outer.iter().chain(holes.iter().flatten()).any(|id| id.is_empty() || id.len() > 128) + { + return Err(KernelError::Invalid("invalid cap curve signature".into())); + } + let mut outer = outer.clone(); + outer.sort(); + outer.dedup(); + let mut holes = holes.clone(); + for hole in &mut holes { + if hole.is_empty() { + return Err(KernelError::Invalid("empty cap hole signature".into())); + } + hole.sort(); + hole.dedup(); + } + holes.sort(); + if holes.windows(2).any(|pair| pair[0] == pair[1]) || holes.iter().flatten().any(|id| outer.binary_search(id).is_ok()) { + return Err(KernelError::Invalid("ambiguous cap hole signature".into())); + } + *source = Some(FaceSource::CertifiedExtrudeCap { producer, sketch, high: *high, outer, holes, plane: *plane }); + } + } + self.face_sources = Some(std::sync::Arc::new(sources)); + } + Ok(self) + } + + pub(crate) fn transformed_sources(&self, m: [[f64; 4]; 4]) -> Result> { + let Some(sources) = &self.face_sources else { return Ok(None) }; + let vector = |p: Vec3| { + Vec3::new( + m[0][0] * p.x + m[1][0] * p.y + m[2][0] * p.z, + m[0][1] * p.x + m[1][1] * p.y + m[2][1] * p.z, + m[0][2] * p.x + m[1][2] * p.y + m[2][2] * p.z, + ) + }; + let translation = Vec3::new(m[3][0], m[3][1], m[3][2]); + let reflected = vector(Vec3::X).dot(vector(Vec3::Y).cross(vector(Vec3::Z))) < 0.; + let mut sources = sources.as_ref().clone(); + for source in sources.iter_mut().flatten() { + if let Some(plane) = source.plane_mut() { + *plane = solvecraft_geom::Plane::new( + vector(plane.origin) + translation, + vector(plane.x), + vector(plane.y) * if reflected { -1. } else { 1. }, + ) + .ok_or_else(|| KernelError::Invalid("degenerate transformed face plane".into()))?; + } + } + Ok(Some(std::sync::Arc::new(sources))) + } + /// Bind constructed edges to uniquely proven source curves without changing face names. pub fn with_extrude_certificates(mut self, producer: u64, sketch: u64, curves: &[Option]) -> Result { if producer == 0 || sketch == 0 || curves.iter().flatten().any(|curve| curve.is_empty() || curve.len() > 128) { @@ -355,8 +496,8 @@ impl Body { .iter() .zip(curves) .map(|(source, curve)| match (source, curve) { - (Some(FaceSource::ExtrudeSide { .. }), Some(curve)) => { - Some(FaceSource::CertifiedExtrudeSide { producer, sketch, curve: curve.clone() }) + (Some(FaceSource::ExtrudeSide { plane, .. }), Some(curve)) => { + Some(FaceSource::CertifiedExtrudeSide { producer, sketch, curve: curve.clone(), plane: *plane }) } (Some(FaceSource::ExtrudeSide { .. }), None) => None, _ => source.clone(), diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 03899b3d..cda62fb7 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -90,6 +90,15 @@ fn region_ok(r: &Region2) -> Result<()> { /// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`). pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result> { + if !plane.origin.is_finite() + || !plane.x.is_finite() + || !plane.y.is_finite() + || (plane.x.len2() - 1.).abs() > 1e-8 + || (plane.y.len2() - 1.).abs() > 1e-8 + || plane.x.dot(plane.y).abs() > 1e-8 + { + return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into())); + } let (start, end) = (finite(start, "start")?, finite(end, "end")?); let (lo, hi) = if end >= start { (start, end) } else { (end, start) }; if hi - lo < 1e-6 { @@ -112,6 +121,31 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu } let f = guard("extrude profile", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))?; let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?; + let upper = guard("extrude cap identities", || { + let inputs: std::collections::HashSet<_> = sources.iter().map(|(edge, _)| edge.id()).collect(); + let mut visits = 0usize; + let mut candidate = None; + for (index, generated) in solid.face_iter().enumerate() { + if index >= 50_000 { + return Ok(None); + } + let mut side = false; + for edge in generated.absolute_boundaries().iter().flatten() { + visits += 1; + if visits > 200_000 { + return Ok(None); + } + side |= inputs.contains(&edge.id()); + } + if generated.id() != f.id() && !side { + if candidate.is_some() { + return Ok(None); + } + candidate = Some(generated.id()); + } + } + Ok(candidate) + })?; let body = Body::new(solid)?; // tsweep retains each actual input boundary edge on its generated side. // Match topology identity, never face order, surface samples or centroids. @@ -120,13 +154,35 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu let mut result = Vec::new(); for generated in body.solid.face_iter() { let matching: Vec<_> = generated.absolute_boundaries().iter().flatten().filter_map(|edge| source_edges.get(&edge.id())).collect(); - let source = if generated.id() == f.id() { - None - } else if let [segment] = matching.as_slice() { - Some(crate::FaceSource::ExtrudeSide { segment: **segment }) - } else { - None - }; + let source = + if generated.id() == f.id() { + upper.and_then(|_| { + (r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::() <= 4096).then(|| { + crate::FaceSource::ExtrudeCap { + high: false, + region: r.clone(), + plane: Plane { origin: base.origin, x: plane.x, y: -plane.y }, + } + }) + }) + } else if Some(generated.id()) == upper { + (r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::() <= 4096) + .then(|| crate::FaceSource::ExtrudeCap { high: true, region: r.clone(), plane: plane.offset(hi) }) + } else if let [segment] = matching.as_slice() { + let basis = match **segment { + Seg2::Line { a, b } => { + let d = b - a; + d.normalized().and_then(|direction| { + let foot = a - direction * a.dot(direction); + Plane::new(plane.to_world(foot), plane.dir_to_world(direction), n) + }) + } + _ => None, + }; + Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis }) + } else { + None + }; result.push(source); } Ok(result) @@ -136,8 +192,25 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu Ok(out) } -/// Revolve each region about the axis through sketch point `axis_origin` with sketch direction -/// `axis_dir` by `angle` radians (|angle| ≥ 2π = full revolution). +/// Choose the per-profile axis sign whose positive rotation follows the sketch normal. +pub fn canonical_revolve_axis(plane: &Plane, region: &Region2, axis_origin: Vec2, axis_dir: Vec2) -> Result { + if !axis_origin.is_finite() || !axis_dir.is_finite() || !plane.origin.is_finite() || !plane.x.is_finite() || !plane.y.is_finite() { + return Err(KernelError::Invalid("non-finite revolve frame".into())); + } + if (plane.x.len2() - 1.).abs() > 1e-8 || (plane.y.len2() - 1.).abs() > 1e-8 || plane.x.dot(plane.y).abs() > 1e-8 { + return Err(KernelError::Invalid("revolve frame is not orthonormal".into())); + } + region_ok(region)?; + let direction = axis_dir.normalized().ok_or_else(|| KernelError::Invalid("revolve axis has no direction".into()))?; + let axis = plane.dir_to_world(direction).normalized().ok_or_else(|| KernelError::Invalid("revolve frame has no axis".into()))?; + let origin = plane.to_world(axis_origin); + let center = plane.to_world(region.centroid()); + let radial = center - origin - axis * (center - origin).dot(axis); + Ok(if axis.cross(radial).dot(plane.normal()) < 0. { -axis } else { axis }) +} + +/// Revolve each region about the sketch axis by `angle` radians. +/// This legacy API preserves its historical sweep sign behavior. pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec2, angle: f64) -> Result> { let angle = finite(angle, "angle")?; if angle.abs() < 1e-6 { @@ -264,7 +337,36 @@ pub fn box_solid(a: Vec3, b: Vec3) -> Result { holes: vec![], }; let mut v = extrude(&Plane::XY, &[r], lo.z, hi.z)?; - v.pop().ok_or_else(|| KernelError::Failed("box".into())) + let body = v.pop().ok_or_else(|| KernelError::Failed("box".into()))?; + let sources = body.face_sources().ok_or_else(|| KernelError::Failed("box construction source".into()))?; + let mut roles = std::collections::HashSet::new(); + let mut certified = Vec::new(); + for source in sources { + let role = match source { + Some(crate::FaceSource::ExtrudeCap { high, .. }) => Some((2u8, *high)), + Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.x == b.x && a.x == lo.x => Some((0, false)), + Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.x == b.x && a.x == hi.x => Some((0, true)), + Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.y == b.y && a.y == lo.y => Some((1, false)), + Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.y == b.y && a.y == hi.y => Some((1, true)), + _ => None, + } + .ok_or_else(|| KernelError::Failed("box role correspondence".into()))?; + if !roles.insert(role) { + return Err(KernelError::Failed("duplicate box role".into())); + } + let (axis, high) = role; + let sign = if high { 1. } else { -1. }; + let plane = match axis { + 0 => Plane { origin: Vec3::X * if high { hi.x } else { lo.x }, x: Vec3::Y, y: Vec3::Z * sign }, + 1 => Plane { origin: Vec3::Y * if high { hi.y } else { lo.y }, x: Vec3::X, y: -Vec3::Z * sign }, + _ => Plane { origin: Vec3::Z * if high { hi.z } else { lo.z }, x: Vec3::X, y: Vec3::Y * sign }, + }; + certified.push(Some(crate::FaceSource::Box { producer: None, axis, high, plane })); + } + if roles.len() != 6 { + return Err(KernelError::Failed("box role count".into())); + } + Ok(body.with_face_sources(Some(std::sync::Arc::new(certified)))) } /// Where primitives start their circles (radians from the frame's x axis). diff --git a/crates/kernel/src/face_source_tests.rs b/crates/kernel/src/face_source_tests.rs index 8968b2b5..927750b6 100644 --- a/crates/kernel/src/face_source_tests.rs +++ b/crates/kernel/src/face_source_tests.rs @@ -1,6 +1,219 @@ use crate::{FaceSource, extrude, transform_matrix}; use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; +fn assert_exact_planes(body: &crate::Body) { + let mesh = body.tessellate(0.001).unwrap(); + for (index, _) in body.face_sources().unwrap().iter().enumerate() { + let Some(plane) = body.face_plane(index) else { continue }; + let mut count = 0; + for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) { + if *face as usize != index { + continue; + } + for point in mesh.tri(triangle).unwrap() { + assert!((point - plane.origin).dot(plane.normal()).abs() < 1e-7); + count += 1; + } + } + assert!(count > 0); + let normal = body.faces(0.001).unwrap()[index].plane_normal.unwrap(); + assert!(normal.dot(plane.normal()) > 0.999999); + } +} + +#[test] +fn box_certificates_are_six_construction_roles_with_dimension_stable_axes() { + for (a, b) in [ + (Vec3::new(2., 3., 4.), Vec3::new(12., 23., 34.)), + (Vec3::new(12., 23., 34.), Vec3::new(2., 3., 4.)), + (Vec3::new(2., -8., 4.), Vec3::new(22., 43., 54.)), + ] { + let body = crate::box_solid(a, b).unwrap().with_box_certificate(42).unwrap(); + let mut roles = std::collections::HashSet::new(); + for (index, source) in body.face_sources().unwrap().iter().enumerate() { + let Some(FaceSource::Box { producer, axis, high, plane }) = source else { panic!("uncertified box face") }; + assert_eq!(*producer, Some(42)); + assert!(roles.insert((*axis, *high))); + let unit = [Vec3::X, Vec3::Y, Vec3::Z][*axis as usize]; + let bound = if *high { a.max(b) } else { a.min(b) }; + assert_eq!(plane.origin, unit * bound.dot(unit)); + assert!(plane.normal().dist(unit * if *high { 1. } else { -1. }) < 1e-12); + assert_eq!(body.face_plane(index), Some(*plane)); + } + assert_eq!(roles.len(), 6); + assert_exact_planes(&body); + } +} + +#[test] +fn cap_roles_follow_exact_offset_planes_and_canonical_profile_memberships() { + let outer = Loop2::polygon(&[Vec2::new(-10., -10.), Vec2::new(10., -10.), Vec2::new(10., 10.), Vec2::new(-10., 10.)]); + let h1 = Loop2::circle(Vec2::new(-4., 0.), 1.); + let h2 = Loop2::circle(Vec2::new(4., 0.), 2.); + let plane = Plane::new(Vec3::new(30., 40., 50.), Vec3::Y, Vec3::Z).unwrap(); + for (start, end) in [(-9., -2.), (7., -3.), (2., 11.)] { + for holes in [vec![h1.clone(), h2.clone()], vec![h2.clone().reversed(), h1.clone().reversed()]] { + let region = Region2 { outer: outer.clone().reversed(), holes }; + let raw = extrude(&plane, std::slice::from_ref(®ion), start, end).unwrap().remove(0); + let signatures: Vec<_> = raw + .face_sources() + .unwrap() + .iter() + .map(|source| match source { + Some(FaceSource::ExtrudeCap { .. }) => { + Some((vec!["l4".into(), "l1".into(), "l3".into(), "l2".into()], vec![vec!["c2".into()], vec!["c1".into()]])) + } + _ => None, + }) + .collect(); + let body = raw.with_extrude_cap_certificates(42, 11, &signatures).unwrap(); + let mut caps = std::collections::HashSet::new(); + for source in body.face_sources().unwrap().iter().flatten() { + if let FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, plane: cap } = source { + assert_eq!((*producer, *sketch), (42, 11)); + assert!(caps.insert(*high)); + assert_eq!(outer, &["l1", "l2", "l3", "l4"]); + assert_eq!(holes, &[vec!["c1"], vec!["c2"]]); + assert!(cap.origin.dist(plane.origin + plane.normal() * if *high { start.max(end) } else { start.min(end) }) < 1e-10); + assert!(cap.normal().dot(plane.normal()) * if *high { 1. } else { -1. } > 0.999999); + } + } + assert_eq!(caps.len(), 2); + assert_exact_planes(&body); + let expected = (400. - 5. * std::f64::consts::PI) * (end - start).abs(); + assert!((body.tessellate(1e-4).unwrap().measure().volume - expected).abs() < 0.03); + } + } +} + +#[test] +fn construction_planes_follow_reflected_and_sheared_affine_images() { + let body = crate::box_solid(Vec3::new(2., 3., 4.), Vec3::new(12., 23., 34.)).unwrap().with_box_certificate(42).unwrap(); + for m in [ + [[0., 2., 0., 0.], [-1., 0., 0., 0.], [0., 0., 3., 0.], [25., 30., 40., 1.]], + [[-1., 0., 0., 0.], [0.4, 2., 0., 0.], [0., 0.3, 1., 0.], [8., 9., 10., 1.]], + ] { + let placed = transform_matrix(&body, m).unwrap(); + for (original, transformed) in body.face_sources().unwrap().iter().zip(placed.face_sources().unwrap()) { + let ( + Some(FaceSource::Box { producer: a, axis: aa, high: ah, plane: ap }), + Some(FaceSource::Box { producer: b, axis: ba, high: bh, plane: bp }), + ) = (original, transformed) + else { + panic!("box identity changed") + }; + assert_eq!((a, aa, ah), (b, ba, bh)); + let p = ap.origin; + let expected = Vec3::new( + m[0][0] * p.x + m[1][0] * p.y + m[2][0] * p.z + m[3][0], + m[0][1] * p.x + m[1][1] * p.y + m[2][1] * p.z + m[3][1], + m[0][2] * p.x + m[1][2] * p.y + m[2][2] * p.z + m[3][2], + ); + assert!(bp.origin.dist(expected) < 1e-10); + } + assert_exact_planes(&placed); + } +} + +#[test] +fn material_changes_discard_producer_certificates() { + let base = crate::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(42).unwrap(); + let tool = crate::box_solid(Vec3::new(5., -1., -1.), Vec3::new(11., 11., 11.)).unwrap(); + let cut = crate::boolean(&base, &tool, crate::BoolOp::Cut).unwrap().unwrap(); + assert!((cut.tessellate(1e-4).unwrap().measure().volume - 500.).abs() < 1e-8); + assert!(cut.face_sources().into_iter().flatten().flatten().all(|source| matches!( + source, + FaceSource::ExtrudeSide { .. } + | FaceSource::ExtrudeCap { .. } + | FaceSource::Box { producer: None, .. } + | FaceSource::Imported { producer: None, .. } + ))); +} + +#[test] +fn canonical_revolve_axis_is_per_profile_and_independent_of_input_axis_sign() { + for x in [-4., 4.] { + let region = Region2 { + outer: Loop2::polygon(&[Vec2::new(x - 1., 0.), Vec2::new(x + 1., 0.), Vec2::new(x + 1., 2.), Vec2::new(x - 1., 2.)]), + holes: vec![], + }; + let axis = crate::canonical_revolve_axis(&Plane::XY, ®ion, Vec2::default(), Vec2::Y).unwrap(); + assert_eq!(axis, crate::canonical_revolve_axis(&Plane::XY, ®ion, Vec2::default(), -Vec2::Y).unwrap()); + assert!(axis.cross(Plane::XY.to_world(region.centroid())).dot(Vec3::Z) > 0.); + assert_eq!(axis, Vec3::Y * if x < 0. { 1. } else { -1. }); + } +} + +#[test] +fn linear_side_frame_stays_fixed_when_endpoints_and_depth_change() { + for (lo, hi, start, end) in [(3., 23., 0., 7.), (-9., 43., -3., 19.)] { + let region = + Region2 { outer: Loop2::polygon(&[Vec2::new(2., lo), Vec2::new(12., lo), Vec2::new(12., hi), Vec2::new(2., hi)]), holes: vec![] }; + let body = extrude(&Plane::XY, &[region], start, end).unwrap().remove(0); + let plane = body + .face_sources() + .unwrap() + .iter() + .enumerate() + .find_map(|(index, source)| match source { + Some(FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.x == 2. && b.x == 2. => body.face_plane(index), + _ => None, + }) + .unwrap(); + assert_eq!(plane.origin, Vec3::X * 2.); + assert_eq!(plane.x, -Vec3::Y); + assert_eq!(plane.normal(), -Vec3::X); + } +} + +#[test] +fn reflected_hole_walls_and_low_caps_keep_exact_outward_planes() { + let outer = Loop2::polygon(&[Vec2::new(-10., -10.), Vec2::new(10., -10.), Vec2::new(10., 10.), Vec2::new(-10., 10.)]); + let hole = Loop2::polygon(&[Vec2::new(-3., -2.), Vec2::new(3., -2.), Vec2::new(3., 2.), Vec2::new(-3., 2.)]); + let body = extrude(&Plane::XY, &[Region2 { outer, holes: vec![hole] }], -4., 7.).unwrap().remove(0); + let reflected = transform_matrix(&body, [[-1., 0., 0., 0.], [0., 2., 0., 0.], [0.2, 0., 1., 0.], [30., 40., 50., 1.]]).unwrap(); + assert_exact_planes(&reflected); + assert_eq!( + reflected.face_sources().unwrap().iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeSide { plane: Some(_), .. }))).count(), + 8 + ); + assert_eq!( + reflected.face_sources().unwrap().iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeCap { high: false, .. }))).count(), + 1 + ); +} + +#[test] +fn side_basis_and_attached_coordinates_stay_continuous_through_vertical() { + let mut previous = None; + for epsilon in [1e-6, -1e-6] { + let region = + Region2 { outer: Loop2::polygon(&[Vec2::ZERO, Vec2::new(epsilon, 10.), Vec2::new(-5., 10.), Vec2::new(-5., 0.)]), holes: vec![] }; + let body = extrude(&Plane::XY, &[region], 0., 5.).unwrap().remove(0); + let plane = body + .face_sources() + .unwrap() + .iter() + .enumerate() + .find_map(|(index, source)| match source { + Some(FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.dist(Vec2::ZERO) < 1e-12 && b.y == 10. && b.x == epsilon => { + body.face_plane(index) + } + _ => None, + }) + .unwrap(); + assert!(plane.x.dot(Vec3::Y) > 0.999999); + assert_eq!(plane.y, Vec3::Z); + let attached = plane.to_world(Vec2::new(3., 2.)); + assert!(attached.dist(Vec3::new(epsilon * 0.3, 3., 2.)) < 1e-10); + if let Some(old) = previous { + assert!(attached.dist(old) < 1e-6); + } + previous = Some(attached); + assert_exact_planes(&body); + } +} + #[test] fn constructed_annulus_sides_follow_exact_source_edges() { let ring = Region2 { outer: Loop2::circle(Vec2::default(), 10.), holes: vec![Loop2::circle(Vec2::default(), 9.).reversed()] }; @@ -13,7 +226,7 @@ fn constructed_annulus_sides_follow_exact_source_edges() { let mut inner = 0; let mut outer = 0; for (index, source) in sources.iter().enumerate() { - let Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { radius, .. } }) = source else { continue }; + let Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { radius, .. }, .. }) = source else { continue }; if (*radius - 10.).abs() < 1e-12 { outer += 1; } else { @@ -33,7 +246,7 @@ fn constructed_annulus_sides_follow_exact_source_edges() { assert!(vertices > 0); } assert_eq!((outer, inner), (2, 2), "full circles split into two actual edges each"); - assert_eq!(sources.iter().filter(|source| source.is_none()).count(), 2); + assert_eq!(sources.iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeCap { .. }))).count(), 2); } } @@ -43,7 +256,16 @@ fn constructed_side_identity_survives_affine_placement_without_recognition() { let bodies = extrude(&Plane::named("XY").unwrap(), &[region], 0., 7.).unwrap(); let source = &bodies[0]; let placed = transform_matrix(source, [[0., 1., 0., 0.], [-1., 0., 0., 0.], [0., 0., 1., 0.], [25., 30., 40., 1.]]).unwrap(); - assert_eq!(placed.face_sources(), source.face_sources()); + for (original, placed) in source.face_sources().unwrap().iter().zip(placed.face_sources().unwrap()) { + match (original, placed) { + (Some(FaceSource::ExtrudeSide { segment: a, .. }), Some(FaceSource::ExtrudeSide { segment: b, .. })) => assert_eq!(a, b), + (Some(FaceSource::ExtrudeCap { high: a, region: ar, .. }), Some(FaceSource::ExtrudeCap { high: b, region: br, .. })) => { + assert_eq!(a, b); + assert_eq!(ar, br) + } + _ => panic!("source role changed"), + } + } assert!((placed.size() - source.size()).abs() < 1e-8); assert!(placed.faces(0.001).unwrap().iter().all(|face| face.centroid.dist(Vec3::new(25., 30., 43.5)) < 11.)); } @@ -58,16 +280,16 @@ fn constructed_large_arc_split_and_reversal_preserve_one_source_segment() { let arcs: Vec<_> = sources .iter() .filter_map(|source| match source { - Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { radius, sweep, .. } }) => Some((radius, sweep)), + Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { radius, sweep, .. }, .. }) => Some((radius, sweep)), _ => None, }) .collect(); assert_eq!(arcs.len(), 2, "large input arc explicitly creates two actual boundary edges"); assert!(arcs.iter().all(|(radius, sweep)| (**radius - 10.).abs() < 1e-12 && (sweep.abs() - std::f64::consts::PI * 5. / 3.).abs() < 1e-12)); - assert_eq!(sources.iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeSide { segment: Seg2::Line { .. } }))).count(), 1); + assert_eq!(sources.iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeSide { segment: Seg2::Line { .. }, .. }))).count(), 1); let mesh = body.tessellate(0.001).unwrap(); for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) { - if matches!(sources[*face as usize], Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { .. } })) { + if matches!(sources[*face as usize], Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { .. }, .. })) { assert!(mesh.tri(triangle).unwrap().iter().all(|point| (point.x.hypot(point.y) - 10.).abs() < 1e-7)); } } diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 00a48f02..5c6d28a9 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -54,8 +54,8 @@ mod topo; pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet}; pub use blend_corner::fillet_radii; pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points}; -pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint}; -pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus}; +pub use body::{Body, CapSignature, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint}; +pub use build::{PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus}; pub use delete_face::delete_faces; pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss}; pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh}; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index a3125ae7..5cc5904d 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -531,8 +531,9 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result { m[3][3], ); // An affine placement retains face order and appearance metadata. + let sources = body.transformed_sources(m)?; Body::new(placed_topology(&body.solid, mat)?) - .map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(body.face_sources.clone())) + .map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources)) }) } diff --git a/crates/sketch/src/curves.rs b/crates/sketch/src/curves.rs index debfbadc..595c4958 100644 --- a/crates/sketch/src/curves.rs +++ b/crates/sketch/src/curves.rs @@ -168,6 +168,64 @@ pub fn fit_beziers(p: &[Vec2]) -> Vec<[Vec2; 4]> { fit_spans(p).into_iter().map(|(p0, p1, t0, t1)| [p0, p0 + t0 / 3.0, p1 - t1 / 3.0, p1]).collect() } +/// Fit pieces in the explicitly selected interpolation mode. +pub fn fit_beziers_mode(p: &[Vec2], periodic: bool) -> crate::model::Result> { + if periodic { crate::periodic_beziers(p) } else { Ok(fit_beziers(p)) } +} +pub fn spline_point_mode(p: &[Vec2], control: bool, degree: u8, periodic: bool, t: f64) -> Vec2 { + if !periodic { + return spline_point(p, control, degree, t); + } + let Ok(pieces) = crate::periodic_beziers(p) else { + return p.first().copied().unwrap_or_default(); + }; + let lengths: Vec = p.windows(2).map(|p| p[0].dist(p[1])).collect(); + let total = lengths.iter().sum::(); + let mut remaining = t.clamp(0.0, 1.0) * total; + let mut i = 0; + while i + 1 < pieces.len() && remaining > lengths[i] { + remaining -= lengths[i]; + i += 1; + } + let Some([a, b, c, d]) = pieces.get(i).copied() else { + return Vec2::ZERO; + }; + let t = (remaining / lengths[i]).clamp(0.0, 1.0); + a * (1.0 - t).powi(3) + b * (3.0 * t * (1.0 - t).powi(2)) + c * (3.0 * t * t * (1.0 - t)) + d * t.powi(3) +} +pub fn spline_polyline_mode(p: &[Vec2], control: bool, degree: u8, periodic: bool) -> Vec { + if !periodic { + return spline_polyline(p, control, degree); + } + let Ok(pieces) = crate::periodic_beziers(p) else { + return Vec::new(); + }; + let mut out = Vec::with_capacity(pieces.len() * SPAN_SAMPLES + 1); + for [a, b, c, d] in pieces { + for i in 0..SPAN_SAMPLES { + let t = i as f64 / SPAN_SAMPLES as f64; + out.push(a * (1.0 - t).powi(3) + b * (3.0 * t * (1.0 - t).powi(2)) + c * (3.0 * t * t * (1.0 - t)) + d * t.powi(3)); + } + } + if let Some(first) = p.first() { + out.push(*first); + } + out +} +pub fn spline_end_tangent_mode(p: &[Vec2], control: bool, periodic: bool, at_end: bool) -> Option { + if !periodic { + return spline_end_tangent(p, control, at_end); + } + let pieces = crate::periodic_beziers(p).ok()?; + if at_end { + let c = pieces.last()?; + (c[3] - c[2]).normalized() + } else { + let c = pieces.first()?; + (c[1] - c[0]).normalized() + } +} + /// The Bézier pieces (each `degree + 1` points) of the clamped uniform B-spline over `ctrl` /// (Boehm knot insertion until every interior knot has multiplicity `degree`). pub fn bspline_beziers(ctrl: &[Vec2], degree: usize) -> Vec> { diff --git a/crates/sketch/src/freeform_offset.rs b/crates/sketch/src/freeform_offset.rs index 9f5cc434..fee33f0a 100644 --- a/crates/sketch/src/freeform_offset.rs +++ b/crates/sketch/src/freeform_offset.rs @@ -1,14 +1,19 @@ //! Persistent free-form offsets with a whole-span normal-distance error certificate. //! The certificate bounds the second derivative of the exact normal offset and of -//! the actual natural cubic interpolant. The endpoint interpolation theorem then +//! the actual cubic interpolant (natural for legacy output, periodic for new closed output). The endpoint interpolation theorem then //! bounds their distance everywhere, rather than just at selected samples. -use crate::{CurveKind, Sketch, SketchError, fit_beziers}; +use crate::{CurveKind, Sketch, SketchError, fit_beziers_mode}; use serde::{Deserialize, Serialize}; use solvecraft_geom::{Seg2, Vec2}; pub const OFFSET_TOLERANCE: f64 = 0.002; const MAX_PIECES: usize = 256; const MAX_ASSOCIATIONS: usize = 128; +const MAX_OFFSET_WORK: usize = 1_048_576; +struct Fit { + tolerance: f64, + periodic: bool, +} type Result = std::result::Result; fn bad(message: &str) -> SketchError { SketchError::Invalid(message.into()) @@ -285,7 +290,11 @@ fn verify_simple(points: &[Vec2], cubics: &[[Vec2; 4]], closed: bool) -> Result< Ok(()) } -fn geometry(sk: &Sketch, source: usize, forward: bool, d: f64, divisions: usize, tolerance: f64) -> Result<(Vec, usize, bool)> { +fn geometry(sk: &Sketch, source: usize, forward: bool, d: f64, divisions: usize, fit: Fit, work: &mut usize) -> Result<(Vec, usize, bool)> { + if *work == 0 { + return Err(bad("offset transaction certificate work limit exceeded")); + } + let Fit { tolerance, periodic } = fit; let mut pieces = sk.exact_segs(source).ok_or_else(|| bad("offset source changed shape"))?; if pieces.is_empty() || pieces.len() > MAX_PIECES @@ -333,11 +342,12 @@ fn geometry(sk: &Sketch, source: usize, forward: bool, d: f64, divisions: usize, *last = first; } } - let cubics = fit_beziers(&points); - let mut budget = 65_536; - for (h, c) in restricted.iter().zip(&cubics) { - certify(h, c, d, tolerance, 0, &mut budget)?; - } + let cubics = fit_beziers_mode(&points, periodic && closed)?; + let allowance = (*work).min(65_536); + let mut budget = allowance; + let result = restricted.iter().zip(&cubics).try_for_each(|(h, c)| certify(h, c, d, tolerance, 0, &mut budget)); + *work -= allowance - budget; + result?; verify_simple(&points, &cubics, closed)?; Ok((points, pieces.len(), closed)) } @@ -377,7 +387,7 @@ impl Sketch { || record.output.is_empty() || record.output.len() > 512 || record.source == record.output - || record.certificate != 1 + || !matches!(record.certificate, 1 | 2) || record.pieces == 0 || record.pieces > MAX_PIECES || record.divisions == 0 @@ -400,8 +410,10 @@ impl Sketch { } let output = self.curve_index(&record.output).ok_or_else(|| bad("offset output vanished"))?; match &self.curves[output].kind { - CurveKind::Spline { pts, control: false, degree: 3 } - if pts.len() == record.pieces * record.divisions + 1 + CurveKind::Spline { pts, control: false, degree: 3, periodic } + if *periodic == (record.certificate == 2) + && (record.certificate != 2 || record.closed) + && pts.len() == record.pieces * record.divisions + 1 && pts.iter().all(|i| *i < self.points.len()) && pts.iter().copied().collect::>().len() == pts.len() && pts @@ -420,11 +432,12 @@ impl Sketch { let source_pieces = self.exact_segs(source).ok_or_else(|| bad("offset source changed shape"))?.len(); let mut result = None; let mut failure = None; + let mut work = MAX_OFFSET_WORK; for divisions in [1, 2, 4, 8, 16, 32, 64, 128, 256] { if source_pieces.saturating_mul(divisions) >= crate::MAX_SPLINE_POINTS { break; } - match geometry(self, source, forward, distance, divisions, OFFSET_TOLERANCE) { + match geometry(self, source, forward, distance, divisions, Fit { tolerance: OFFSET_TOLERANCE, periodic: true }, &mut work) { Ok(g) => { result = Some((divisions, g)); break; @@ -442,7 +455,7 @@ impl Sketch { p.fixed = true; } } - let output = candidate.add_curve(CurveKind::Spline { pts: indices, control: false, degree: 3 }, None)?; + let output = candidate.add_curve(CurveKind::Spline { pts: indices, control: false, degree: 3, periodic: closed }, None)?; candidate.freeform_offsets.push(FreeformOffset { source: source_id, output: candidate.curves[output].id.clone(), @@ -452,7 +465,7 @@ impl Sketch { pieces, closed, tolerance: OFFSET_TOLERANCE, - certificate: 1, + certificate: if closed { 2 } else { 1 }, }); *self = candidate; Ok(output) @@ -462,8 +475,9 @@ impl Sketch { self.validate_freeform_offsets()?; let mut candidate = self.clone(); let mut seen = std::collections::BTreeSet::new(); + let mut work = MAX_OFFSET_WORK; for (record_index, record) in self.freeform_offsets.clone().into_iter().enumerate() { - if record.certificate != 1 + if !matches!(record.certificate, 1 | 2) || record.source == record.output || !seen.insert(record.output.clone()) || candidate.freeform_offsets.iter().any(|later| later.output == record.source && !seen.contains(&later.output)) @@ -486,7 +500,15 @@ impl Sketch { divisions = (record.divisions / ratio).max(1); } let (points, pieces, closed) = loop { - match geometry(&candidate, source, record.forward, record.distance, divisions, record.tolerance) { + match geometry( + &candidate, + source, + record.forward, + record.distance, + divisions, + Fit { tolerance: record.tolerance, periodic: record.certificate == 2 }, + &mut work, + ) { Ok(geometry) => break geometry, Err(error) => { if divisions >= 256 || source_pieces.saturating_mul(divisions.saturating_mul(2)) >= crate::MAX_SPLINE_POINTS { @@ -500,7 +522,13 @@ impl Sketch { return Err(bad("offset source topology changed")); } let old_indices = match &candidate.curves[output].kind { - CurveKind::Spline { pts, control: false, degree: 3 } if pts.len() == record.pieces * record.divisions + 1 => pts.clone(), + CurveKind::Spline { pts, control: false, degree: 3, periodic } + if *periodic == (record.certificate == 2) + && (record.certificate != 2 || record.closed) + && pts.len() == record.pieces * record.divisions + 1 => + { + pts.clone() + } _ => return Err(bad("offset output was edited; break its association first")), }; // Refinement never reuses or removes an old node ID. Existing dyadic @@ -526,7 +554,7 @@ impl Sketch { p.pos = pos; p.fixed = true; } - candidate.curves[output].kind = CurveKind::Spline { pts: indices, control: false, degree: 3 }; + candidate.curves[output].kind = CurveKind::Spline { pts: indices, control: false, degree: 3, periodic: record.certificate == 2 }; candidate.freeform_offsets[record_index].divisions = divisions; candidate.freeform_offsets[record_index].pieces = pieces; } @@ -689,9 +717,124 @@ mod tests { fn certified_offset_closed_chain_refuses_natural_spline_seam_atomically() { let (mut sk, source) = ellipse(); let first = sk.add_freeform_offset(source, true, -1.0).unwrap(); + // Explicitly reconstruct the old saved semantics, not the new periodic output. + if let CurveKind::Spline { periodic, .. } = &mut sk.curves[first].kind { + *periodic = false; + } + sk.freeform_offsets[0].certificate = 1; + sk.refresh_offsets().unwrap(); let before = sk.clone(); let error = sk.add_freeform_offset(first, true, -0.5).unwrap_err(); assert!(error.to_string().contains("nonsmooth seam"), "{error}"); assert_eq!(sk, before); } + + #[test] + fn certified_offset_periodic_closed_chain_has_c2_seams_and_stable_roles() { + let (mut sk, source) = ellipse(); + let source_points = sk.curves[source].kind.point_ids(); + let unchanged = source_points.iter().map(|i| sk.points[*i].clone()).collect::>(); + let a = sk.add_freeform_offset(source, true, -1.0).unwrap(); + let b = sk.add_freeform_offset(a, true, -0.5).unwrap(); + assert_eq!(source_points.iter().map(|i| sk.points[*i].clone()).collect::>(), unchanged); + let old = sk.freeform_offsets.clone(); + let ids = [a, b].map(|i| sk.curves[i].kind.point_ids().iter().map(|p| sk.points[*p].id.clone()).collect::>()); + let verify = |sk: &Sketch| { + for output in [a, b] { + let record = sk.freeform_offsets.iter().find(|r| r.output == sk.curves[output].id).unwrap(); + assert_eq!(record.certificate, 2); + let points = sk.curves[output].kind.point_ids().iter().map(|p| sk.point(*p).unwrap()).collect::>(); + let cubics = crate::periodic_beziers(&points).unwrap(); + let first = cubics[0]; + let last = *cubics.last().unwrap(); + let (h0, h1) = (points[0].dist(points[1]), points[points.len() - 2].dist(points[points.len() - 1])); + assert_eq!(first[0], last[3]); + assert!(((first[1] - first[0]) / h0 - (last[3] - last[2]) / h1).len() < 1e-10); + assert!(((first[2] - first[1] * 2.0 + first[0]) / (h0 * h0) - (last[3] - last[2] * 2.0 + last[1]) / (h1 * h1)).len() < 1e-9); + let source = sk.curve_index(&record.source).unwrap(); + for (i, piece) in sk.exact_segs(source).unwrap().iter().enumerate() { + for j in 0..record.divisions { + for t in [0.0, 0.13, 0.41, 0.79, 1.0] { + let exact = normal_point(piece, (j as f64 + t) / record.divisions as f64, record.distance).unwrap(); + let got = sk.exact_segs(output).unwrap()[i * record.divisions + j].point_at(t); + assert!(got.dist(exact) <= OFFSET_TOLERANCE); + if source == 0 { + // Independent ellipse implicit-gradient normal, rather + // than using the offset implementation as its own oracle. + let base = piece.point_at((j as f64 + t) / record.divisions as f64); + let major = sk.point(source_points[1]).unwrap().x; + let normal = Vec2::new(base.x / (major * major), base.y / 25.0).normalized().unwrap(); + assert!(got.dist(base + normal) <= OFFSET_TOLERANCE); + } + } + } + } + } + }; + verify(&sk); + // Steiner's formula is independent of fitted output: both offsets expand + // the original ellipse by 1.5mm, within the accumulated two certificates. + let n = 4096; + let h = std::f64::consts::TAU / n as f64; + let perimeter = (0..=n) + .map(|i| { + let t = i as f64 * h; + (100.0 * t.sin().powi(2) + 25.0 * t.cos().powi(2)).sqrt() + * if i == 0 || i == n { + 1.0 + } else if i % 2 == 0 { + 2.0 + } else { + 4.0 + } + }) + .sum::() + * h + / 3.0; + let expected = 50.0 * std::f64::consts::PI + 1.5 * perimeter + 2.25 * std::f64::consts::PI; + let area = sk.exact_segs(b).unwrap().iter().map(Seg2::area_term).sum::(); + assert!((area - expected).abs() < 0.03, "{area} vs {expected}"); + let profiles = crate::find_profiles(&sk); + assert_eq!(profiles.len(), 3); + if let CurveKind::Ellipse { m, .. } = sk.curves[source].kind { + sk.points[m].pos.x = 12.0; + } + sk.refresh_offsets().unwrap(); + verify(&sk); + for (j, i) in [a, b].into_iter().enumerate() { + let now = &sk.freeform_offsets[j]; + let factor = now.pieces * now.divisions / (old[j].pieces * old[j].divisions); + assert_eq!(sk.curves[i].kind.point_ids().iter().step_by(factor).map(|p| sk.points[*p].id.clone()).collect::>(), ids[j]); + } + let encoded = serde_json::to_string(&sk).unwrap(); + let mut reopened: Sketch = serde_json::from_str(&encoded).unwrap(); + reopened.validate_periodic_splines().unwrap(); + reopened.refresh_offsets().unwrap(); + assert_eq!(sk, reopened); + } + + #[test] + fn certified_offset_periodic_circle_and_reverse_have_analytic_normals() { + for forward in [true, false] { + let mut sk = Sketch::new(); + let center = sk.add_point(Vec2::ZERO, None).unwrap(); + let major = sk.add_point(Vec2::new(5.0, 0.0), None).unwrap(); + let source = sk.add_curve(CurveKind::Ellipse { c: center, m: major, r: 5.0 }, None).unwrap(); + let distance = if forward { -1.0 } else { 1.0 }; + let a = sk.add_freeform_offset(source, forward, distance).unwrap(); + let b = sk.add_freeform_offset(a, true, distance * 0.5).unwrap(); + for (output, radius) in [(a, 6.0), (b, 6.5)] { + let pieces = sk.exact_segs(output).unwrap(); + for curve in &pieces { + for t in [0.0, 0.13, 0.41, 0.79, 1.0] { + assert!((curve.point_at(t).len() - radius).abs() <= 2.0 * OFFSET_TOLERANCE); + } + } + let area = pieces.iter().map(Seg2::area_term).sum::().abs(); + assert!((area - std::f64::consts::PI * radius * radius).abs() < 0.03); + } + sk.refresh_offsets().unwrap(); + sk.validate_periodic_splines().unwrap(); + } + } } diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 486faa93..e48cccdd 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -15,6 +15,7 @@ mod linalg; mod link; mod model; mod offset; +mod periodic; mod profiles; mod shape; mod solver; @@ -22,13 +23,15 @@ mod text; mod wire; pub use curves::{ - MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, spline_end_tangent, spline_point, spline_polyline, + MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, fit_beziers_mode, spline_end_tangent, + spline_end_tangent_mode, spline_point, spline_point_mode, spline_polyline, spline_polyline_mode, }; pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text}; pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE}; pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES}; pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView}; pub use offset::OffsetChain; +pub use periodic::periodic_beziers; pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions}; pub use shape::{Shape, intersections}; pub use solver::{Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_holding}; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index 088b7b54..274c9a0b 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -13,6 +13,9 @@ use crate::model::{Curve, CurveKind, Result, Sketch, SketchError}; /// Points of one link closer than this are merged into one point. const MERGE_TOL: f64 = 1e-7; + +#[path = "link_loops.rs"] +mod loops; /// Most entities a single link may produce. pub const MAX_LINK_ENTITIES: usize = 20_000; @@ -107,6 +110,9 @@ pub struct Link { /// Persistent model face identity, resolved by the document before geometry refresh. #[serde(default, skip_serializing_if = "Option::is_none")] pub face_name: Option, + /// Automatically projected borders owned by a durable sketch face attachment. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub auto_face: bool, /// The reference could not be found on the last recompute; the geometry is the last good one. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub lost: bool, @@ -142,6 +148,7 @@ pub enum LinkGeom { pts: Vec, control: bool, degree: u8, + periodic: bool, }, } @@ -157,8 +164,11 @@ impl LinkGeom { LinkGeom::Ellipse { c, major, minor } => { ok(c) && ok(major) && c.dist(*major) > 1e-9 && minor.is_finite() && *minor > 1e-9 && *minor < 1e9 } - LinkGeom::Spline { pts, degree, .. } => { - (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len()) && pts.iter().all(ok) && (1..=7).contains(degree) + LinkGeom::Spline { pts, control, degree, periodic } => { + (!*periodic || (!*control && *degree == 3 && crate::periodic_beziers(pts).is_ok())) + && (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len()) + && pts.iter().all(ok) + && (1..=7).contains(degree) } } } @@ -181,7 +191,7 @@ enum LCurve { Ellipse(usize, usize, f64), /// Start, end, first interior point and interior count (interior points are consecutive /// and never shared), control flag, degree. - Spline(usize, usize, usize, usize, bool, u8), + Spline(usize, usize, usize, usize, bool, u8, bool), } fn layout(geom: &[LinkGeom]) -> Result { @@ -234,14 +244,14 @@ fn layout(geom: &[LinkGeom]) -> Result { l.curves.push(LCurve::Ellipse(c, m, minor)); } } - LinkGeom::Spline { pts, control, degree } => { + LinkGeom::Spline { pts, control, degree, periodic } => { let (Some(first), Some(last)) = (pts.first(), pts.last()) else { continue }; let s0 = pt(&mut l.pts, *first); let first_in = l.pts.len(); let inner = pts.get(1..pts.len() - 1).unwrap_or_default(); l.pts.extend(inner.iter().copied()); let s1 = pt(&mut l.pts, *last); - l.curves.push(LCurve::Spline(s0, s1, first_in, inner.len(), control, degree)); + l.curves.push(LCurve::Spline(s0, s1, first_in, inner.len(), control, degree, periodic)); } } } @@ -293,7 +303,7 @@ fn same_geometry(a: &[(LCurve, Vec)], b: &[(LCurve, Vec)]) -> bool { (LCurve::Arc(..), LCurve::Arc(..)) => all(), (LCurve::Conic(.., ra), LCurve::Conic(.., rb)) => all() && close(*ra, *rb), (LCurve::Ellipse(.., ra), LCurve::Ellipse(.., rb)) => all() && close(*ra, *rb), - (LCurve::Spline(.., ca, da), LCurve::Spline(.., cb, db)) => ca == cb && da == db && all(), + (LCurve::Spline(.., ca, da, pa), LCurve::Spline(.., cb, db, pb)) => ca == cb && da == db && pa == pb && all(), _ => false, } }; @@ -420,7 +430,7 @@ impl Sketch { return Err(SketchError::TooLarge); } let id = self.fresh("j"); - self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false }); + self.links.push(Link { id: id.clone(), kind, source, face_name: None, auto_face: false, lost: false }); self.build_link(&id, &lay)?; Ok(id) } @@ -449,13 +459,13 @@ impl Sketch { LCurve::Arc(c, a, b) => (CurveKind::Arc { c: point(self, c)?, a: point(self, a)?, b: point(self, b)? }, "a"), LCurve::Conic(a, b, x, rho) => (CurveKind::Conic { a: point(self, a)?, b: point(self, b)?, apex: point(self, x)?, rho }, "k"), LCurve::Ellipse(c, m, r) => (CurveKind::Ellipse { c: point(self, c)?, m: point(self, m)?, r }, "e"), - LCurve::Spline(a, b, f, n, control, degree) => { + LCurve::Spline(a, b, f, n, control, degree, periodic) => { let mut pts = vec![point(self, a)?]; for i in f..f + n { pts.push(point(self, i)?); } pts.push(point(self, b)?); - (CurveKind::Spline { pts, control, degree }, "s") + (CurveKind::Spline { pts, control, degree, periodic }, "s") } }; let cid = self.fresh(prefix); @@ -484,6 +494,16 @@ impl Sketch { return Err(SketchError::Unknown(id.into())); } let lay = layout(geom)?; + if self.link(id).is_some_and(|l| matches!(l.source, LinkSource::Face { .. })) + && lay.lone.is_empty() + && lay.curves.len() <= 512 + && lay.pts.len() <= 512 + && lay.curves.iter().all(|c| matches!(c, LCurve::Line(..) | LCurve::Arc(..) | LCurve::Circle(..))) + && !loops::valid(&lay.curves, &lay.pts) + { + self.set_link_lost(id, true); + return Err(SketchError::Invalid("projected face boundaries cross, touch or have ambiguous topology; last geometry retained".into())); + } let curves = self.link_curves(id); let points = self.link_points(id); // Current layout of the link, in the same terms. @@ -496,7 +516,7 @@ impl Sketch { Some(CurveKind::Arc { c, a, b }) => Some(LCurve::Arc(local(*c)?, local(*a)?, local(*b)?)), Some(CurveKind::Conic { a, b, apex, rho }) => Some(LCurve::Conic(local(*a)?, local(*b)?, local(*apex)?, *rho)), Some(CurveKind::Ellipse { c, m, r }) => Some(LCurve::Ellipse(local(*c)?, local(*m)?, *r)), - Some(CurveKind::Spline { pts, control, degree }) => { + Some(CurveKind::Spline { pts, control, degree, periodic }) => { let (a, b) = (local(*pts.first()?)?, local(*pts.last()?)?); let inner: Vec = pts.get(1..pts.len().saturating_sub(1)).unwrap_or_default().iter().map(|q| local(*q)).collect::>()?; @@ -505,7 +525,7 @@ impl Sketch { if inner.iter().enumerate().any(|(k, x)| *x != f + k) { return None; } - Some(LCurve::Spline(a, b, f, inner.len(), *control, *degree)) + Some(LCurve::Spline(a, b, f, inner.len(), *control, *degree, *periodic)) } _ => None, }) @@ -561,7 +581,7 @@ impl Sketch { LCurve::Arc(c, a, b) => LCurve::Arc(renum(c), renum(a), renum(b)), LCurve::Conic(a, b, x, r) => LCurve::Conic(renum(a), renum(b), renum(x), r), LCurve::Ellipse(c, m, r) => LCurve::Ellipse(renum(c), renum(m), r), - LCurve::Spline(a, b, f, n, ct, d) => LCurve::Spline(renum(a), renum(b), renum(f), n, ct, d), + LCurve::Spline(a, b, f, n, ct, d, periodic) => LCurve::Spline(renum(a), renum(b), renum(f), n, ct, d, periodic), }) .collect(); let same = match &cur { @@ -576,8 +596,8 @@ impl Sketch { (LCurve::Arc(a0, a1, a2), LCurve::Arc(b0, b1, b2)) => a0 == b0 && a1 == b1 && a2 == b2, (LCurve::Conic(a0, a1, a2, _), LCurve::Conic(b0, b1, b2, _)) => a0 == b0 && a1 == b1 && a2 == b2, (LCurve::Ellipse(a0, a1, _), LCurve::Ellipse(b0, b1, _)) => a0 == b0 && a1 == b1, - (LCurve::Spline(a0, a1, a2, a3, a4, a5), LCurve::Spline(b0, b1, b2, b3, b4, b5)) => { - a0 == b0 && a1 == b1 && (a2 == b2 || *a3 == 0) && a3 == b3 && a4 == b4 && a5 == b5 + (LCurve::Spline(a0, a1, a2, a3, a4, a5, a6), LCurve::Spline(b0, b1, b2, b3, b4, b5, b6)) => { + a0 == b0 && a1 == b1 && (a2 == b2 || *a3 == 0) && a3 == b3 && a4 == b4 && a5 == b5 && a6 == b6 } _ => false, } @@ -625,12 +645,33 @@ impl Sketch { let hi = ps.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y))); ps.iter().map(|p| Vec2::new((p.x - lo.x) / (hi.x - lo.x).max(1e-9), (p.y - lo.y) / (hi.y - lo.y).max(1e-9))).collect() }; - let (a, b) = (normalized(&old), normalized(&lay.pts)); + let loop_roles_required = self.link(id).is_some_and(|l| matches!(l.source, LinkSource::Face { .. })) + && cur.iter().chain(&lay.curves).all(|c| matches!(c, LCurve::Line(..) | LCurve::Arc(..) | LCurve::Circle(..))) + && lay.lone.is_empty(); + let loop_roles = loop_roles_required.then(|| loops::roles(cur, &old, &lay.curves, &lay.pts)).flatten(); + if loop_roles_required && loop_roles.is_none() { + self.set_link_lost(id, true); + return Err(SketchError::Invalid("projected face boundary identity is ambiguous; last geometry retained".into())); + } + let (a, b) = match &loop_roles { + Some(roles) => (roles.old_normal.clone(), roles.fresh_normal.clone()), + None => (normalized(&old), normalized(&lay.pts)), + }; let (old_roles, fresh_roles) = (point_roles(cur, old.len()), point_roles(&lay.curves, lay.pts.len())); let mut mapping = Vec::new(); for (old_i, p) in a.iter().enumerate() { - let mut candidates: Vec<(usize, f64)> = - b.iter().enumerate().filter(|(i, _)| old_roles.get(old_i) == fresh_roles.get(*i)).map(|(i, q)| (i, p.dist(*q))).collect(); + let mut candidates: Vec<(usize, f64)> = b + .iter() + .enumerate() + .filter(|(i, _)| { + old_roles.get(old_i) == fresh_roles.get(*i) + && loop_roles.as_ref().is_none_or(|roles| { + roles.old.get(old_i) == roles.fresh.get(*i) + && roles.old_lines.get(old_i).zip(roles.fresh_lines.get(*i)).is_some_and(|(a, b)| loops::same_lines(a, b)) + }) + }) + .map(|(i, q)| (i, p.dist(*q))) + .collect(); candidates.sort_by(|x, y| x.1.total_cmp(&y.1)); if let Some((i, d)) = candidates.first() && *d < 0.1 diff --git a/crates/sketch/src/link_identity_tests.rs b/crates/sketch/src/link_identity_tests.rs index d9c202b1..bae16d88 100644 --- a/crates/sketch/src/link_identity_tests.rs +++ b/crates/sketch/src/link_identity_tests.rs @@ -156,3 +156,178 @@ fn resized_dense_rim_matches_arc_centres_by_role_before_proximity() { } assert_eq!(ids, arcs.iter().map(|i| sk.curves[*i].id.clone()).collect::>()); } + +#[test] +fn nested_face_resize_matches_each_loop_before_nearby_outer_vertices() { + let rectangle = |x: f64, y: f64| { + let p = [Vec2::new(-x, -y), Vec2::new(x, -y), Vec2::new(x, y), Vec2::new(-x, y)]; + (0..4).map(|i| LinkGeom::Line(p[i], p[(i + 1) % 4])).collect::>() + }; + let mut sk = Sketch::new(); + let old: Vec<_> = rectangle(38.0, 28.0).into_iter().chain(rectangle(40.0, 30.0)).collect(); + let link = sk.add_link(LinkKind::Project, face(), &old).unwrap(); + let ids: Vec<_> = sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect(); + let points = sk.link_points(&link); + let before: Vec<_> = points.iter().map(|i| sk.points[*i].pos).collect(); + let fresh: Vec<_> = rectangle(41.0, 36.0).into_iter().chain(rectangle(38.0, 33.0)).collect(); + assert!(!sk.update_link(&link, &fresh).unwrap()); + assert!(!sk.link(&link).unwrap().lost); + for (i, p) in points.iter().zip(before) { + let expected = if p.x.abs() == 38.0 { Vec2::new(p.x, p.y.signum() * 33.0) } else { Vec2::new(p.x.signum() * 41.0, p.y.signum() * 36.0) }; + assert_eq!(sk.points[*i].pos, expected); + } + assert_eq!(ids, sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect::>()); +} + +#[test] +fn growing_face_retains_stationary_hole_arcs_and_shared_centre() { + let geometry = |width: f64| { + let p = [Vec2::ZERO, Vec2::new(width, 0.0), Vec2::new(width, 30.0), Vec2::new(0.0, 30.0)]; + let mut out: Vec<_> = (0..4).map(|i| LinkGeom::Line(p[i], p[(i + 1) % 4])).collect(); + let c = Vec2::new(20.0, 15.0); + out.extend([ + LinkGeom::Arc { c, a: c + Vec2::X * 5.0, b: c - Vec2::X * 5.0 }, + LinkGeom::Arc { c, a: c - Vec2::X * 5.0, b: c + Vec2::X * 5.0 }, + ]); + out + }; + let mut sk = Sketch::new(); + let link = sk.add_link(LinkKind::Project, face(), &geometry(40.0)).unwrap(); + let saved = sk.clone(); + assert!(!sk.update_link(&link, &geometry(50.0)).unwrap()); + assert!(!sk.link(&link).unwrap().lost); + for i in sk.link_curves(&link) { + assert_eq!(sk.curves[i].id, saved.curves[i].id); + if matches!(sk.curves[i].kind, CurveKind::Arc { .. }) { + assert_eq!(sk.curves[i], saved.curves[i]); + for p in sk.curves[i].kind.point_ids() { + assert_eq!(sk.points[p], saved.points[p]); + } + } + } +} + +#[test] +fn touching_or_crossed_face_boundaries_refuse_without_retargeting_ids() { + for x in [9.0, 11.0] { + let mut sk = Sketch::new(); + let link = sk.add_link(LinkKind::Project, face(), &[LinkGeom::Circle(Vec2::ZERO, 10.0), LinkGeom::Circle(Vec2::ZERO, 1.0)]).unwrap(); + let saved = sk.clone(); + assert!(sk.update_link(&link, &[LinkGeom::Circle(Vec2::ZERO, 10.0), LinkGeom::Circle(Vec2::new(x, 0.0), 1.0)]).is_err()); + assert_eq!(sk.points, saved.points); + assert_eq!(sk.curves, saved.curves); + assert!(sk.link(&link).unwrap().lost); + } +} + +#[test] +fn resized_rounded_rim_retains_inner_outer_roles_with_shared_arc_centres() { + let rounded = |x: f64, y: f64, r: f64| { + let centres = [Vec2::new(x - r, -y + r), Vec2::new(x - r, y - r), Vec2::new(-x + r, y - r), Vec2::new(-x + r, -y + r)]; + let mut out = Vec::new(); + for i in 0..4 { + let start = -std::f64::consts::FRAC_PI_2 + i as f64 * std::f64::consts::FRAC_PI_2; + let previous = centres[(i + 3) % 4] + Vec2::from_angle(start) * r; + let a = centres[i] + Vec2::from_angle(start) * r; + let b = centres[i] + Vec2::from_angle(start + std::f64::consts::FRAC_PI_2) * r; + out.push(LinkGeom::Line(previous, a)); + out.push(LinkGeom::Arc { c: centres[i], a, b }); + } + out + }; + let mut sk = Sketch::new(); + let old: Vec<_> = rounded(38.0, 28.0, 2.0).into_iter().chain(rounded(40.0, 30.0, 4.0)).collect(); + let link = sk.add_link(LinkKind::Project, face(), &old).unwrap(); + let curves = sk.link_curves(&link); + let ids: Vec<_> = curves.iter().map(|i| sk.curves[*i].id.clone()).collect(); + let fresh: Vec<_> = rounded(41.0, 36.0, 4.0).into_iter().chain(rounded(38.0, 33.0, 1.0)).collect(); + assert!(!sk.update_link(&link, &fresh).unwrap()); + assert!(!sk.link(&link).unwrap().lost); + assert_eq!(ids, sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect::>()); + for (old, fresh) in curves.iter().zip(rounded(38.0, 33.0, 1.0).into_iter().chain(rounded(41.0, 36.0, 4.0))) { + match (&sk.curves[*old].kind, fresh) { + (CurveKind::Line { a, b }, LinkGeom::Line(p, q)) => { + assert!(sk.points[*a].pos.dist(p) < 1e-10); + assert!(sk.points[*b].pos.dist(q) < 1e-10); + } + (CurveKind::Arc { c, a, b }, LinkGeom::Arc { c: p, a: q, b: r }) => { + assert!(sk.points[*c].pos.dist(p) < 1e-10); + assert!(sk.points[*a].pos.dist(q) < 1e-10); + assert!(sk.points[*b].pos.dist(r) < 1e-10); + } + _ => panic!(), + } + } +} + +#[test] +fn far_translated_small_hole_crossing_long_edge_is_not_certified_separate() { + let mut sk = Sketch::new(); + let p = [Vec2::new(-1e8, 1.0), Vec2::new(1e8, 1.0), Vec2::new(1e8, 10.0), Vec2::new(-1e8, 10.0)]; + let mut old: Vec<_> = (0..4).map(|i| LinkGeom::Line(p[i], p[(i + 1) % 4])).collect(); + old.push(LinkGeom::Circle(Vec2::new(1e6, 5.0), 2.0)); + let link = sk.add_link(LinkKind::Project, face(), &old).unwrap(); + let saved = sk.clone(); + let mut fresh: Vec<_> = (0..4).map(|i| LinkGeom::Line(p[i], p[(i + 1) % 4])).collect(); + fresh.push(LinkGeom::Circle(Vec2::new(1e6, 0.0), 2.0)); + assert!(sk.update_link(&link, &fresh).is_err()); + assert_eq!(sk.points, saved.points); + assert_eq!(sk.curves, saved.curves); + assert!(sk.link(&link).unwrap().lost); +} + +#[test] +fn equal_nested_sibling_candidates_remain_ambiguous_after_loop_classification() { + let mut sk = Sketch::new(); + let link = sk + .add_link( + LinkKind::Project, + face(), + &[LinkGeom::Circle(Vec2::ZERO, 10.0), LinkGeom::Circle(Vec2::new(-2.0, 0.0), 1.0), LinkGeom::Circle(Vec2::new(2.0, 0.0), 1.0)], + ) + .unwrap(); + let saved = sk.clone(); + assert!( + sk.update_link( + &link, + &[LinkGeom::Circle(Vec2::ZERO, 10.0), LinkGeom::Circle(Vec2::new(0.0, -2.0), 1.0), LinkGeom::Circle(Vec2::new(0.0, 2.0), 1.0)] + ) + .is_err() + ); + assert_eq!(sk.points, saved.points); + assert_eq!(sk.curves, saved.curves); + assert!(sk.link(&link).unwrap().lost); +} + +#[test] +fn tiny_circle_crossing_huge_round_boundary_is_not_certified_separate() { + let mut sk = Sketch::new(); + let direction = Vec2::new(std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2); + let link = sk.add_link(LinkKind::Project, face(), &[LinkGeom::Circle(Vec2::ZERO, 1e8), LinkGeom::Circle(direction * (1e8 + 10.0), 2.0)]).unwrap(); + let saved = sk.clone(); + assert!(sk.update_link(&link, &[LinkGeom::Circle(Vec2::ZERO, 1e8), LinkGeom::Circle(direction * (1e8 + 1.0), 2.0)]).is_err()); + assert_eq!(sk.points, saved.points); + assert_eq!(sk.curves, saved.curves); + assert!(sk.link(&link).unwrap().lost); +} + +#[test] +fn huge_loop_does_not_make_nearby_bowtie_edges_topologically_adjacent() { + let mut sk = Sketch::new(); + let link = sk.add_link(LinkKind::Project, face(), &square(10.0)).unwrap(); + let saved = sk.clone(); + let p = [ + Vec2::ZERO, + Vec2::new(0.04, 0.04), + Vec2::new(0.0, 0.04), + Vec2::new(0.04, 0.0), + Vec2::new(9e8, 0.0), + Vec2::new(9e8, 10.0), + Vec2::new(0.0, 10.0), + ]; + let crossed: Vec<_> = (0..p.len()).map(|i| LinkGeom::Line(p[i], p[(i + 1) % p.len()])).collect(); + assert!(sk.update_link(&link, &crossed).is_err()); + assert_eq!(sk.points, saved.points); + assert_eq!(sk.curves, saved.curves); + assert!(sk.link(&link).unwrap().lost); +} diff --git a/crates/sketch/src/link_loops.rs b/crates/sketch/src/link_loops.rs new file mode 100644 index 00000000..72f557cf --- /dev/null +++ b/crates/sketch/src/link_loops.rs @@ -0,0 +1,343 @@ +//! Conservative analytic boundary roles for associative face identity. +//! Endpoint walks exclude support centres. Crossings/touches refuse before nesting. +use super::{LCurve, curve_points}; +use crate::{Shape, intersections}; +use solvecraft_geom::Vec2; +use std::f64::consts::TAU; + +struct Boundary { + curves: Vec, + shapes: Vec, + lo: Vec2, + hi: Vec2, + depth: usize, + signature: [usize; 3], +} +pub(super) struct Roles { + pub old: Vec>, + pub fresh: Vec>, + pub old_normal: Vec, + pub fresh_normal: Vec, + pub old_lines: Vec>, + pub fresh_lines: Vec>, +} + +pub(super) fn same_lines(a: &[Vec2], b: &[Vec2]) -> bool { + a.len() == b.len() && a.iter().all(|x| b.iter().any(|y| x.dot(*y).abs() > 0.98)) +} +fn on(s: &Shape, p: Vec2, tol: f64) -> bool { + match *s { + Shape::Line { a, b } => { + let d = b - a; + let t = (p - a).dot(d) / d.len2().max(1e-30); + t >= -tol / d.len().max(tol) && t <= 1.0 + tol / d.len().max(tol) && (a + d * t).dist(p) <= tol + } + Shape::Round { c, r, start, sweep } => { + (p.dist(c) - r).abs() <= tol && (sweep >= TAU - 1e-12 || ((p - c).angle() - start).rem_euclid(TAU) <= sweep + tol / r) + } + } +} +fn endpoints(s: &Shape) -> [Vec2; 2] { + let (_, end) = s.range(); + [s.point(0.0), s.point(end)] +} +fn stable_intersections(a: &Shape, b: &Shape, tol: f64) -> Vec { + match (*a, *b) { + (Shape::Line { a, b }, Shape::Round { c, r, .. }) | (Shape::Round { c, r, .. }, Shape::Line { a, b }) => { + let Some(direction) = (b - a).normalized() else { return Vec::new() }; + // Work in the circle's local orthonormal frame. The world-coordinate + // quadratic discriminant can lose every significant bit on long lines. + let foot = a + direction * (c - a).dot(direction); + let distance = foot.dist(c); + if distance > r + tol { + return Vec::new(); + } + let half = ((r - distance).max(0.0) * (r + distance)).sqrt(); + vec![foot - direction * half, foot + direction * half] + } + (Shape::Round { c: ac, r: ar, .. }, Shape::Round { c: bc, r: br, .. }) => { + let ((small, r), (large, radius)) = if ar <= br { ((ac, ar), (bc, br)) } else { ((bc, br), (ac, ar)) }; + let delta = large - small; + let distance = delta.len(); + if distance <= tol || distance > radius + r + tol || distance < radius - r - tol { + return Vec::new(); + } + let direction = delta / distance; + // Compute from the smaller circle; factoring d^2-R^2 avoids the + // cancellation of R^2-a^2 for a small hole crossing a huge circle. + let along = ((distance - radius) * (distance + radius) + r * r) / (2.0 * distance); + let height = ((r - along).max(0.0) * (r + along).max(0.0)).sqrt(); + let foot = small + direction * along; + vec![foot - direction.perp() * height, foot + direction.perp() * height] + } + _ => intersections(a, b), + } +} +fn clear(a: &Shape, b: &Shape, shared: &[Vec2], tol: f64) -> bool { + let allowed = |p: Vec2| { + shared.iter().any(|q| { + let joint_tol = 1e-7 + 128.0 * f64::EPSILON * (1.0 + p.len().max(q.len())); + q.dist(p) <= joint_tol + }) + }; + // Parallel coincident lines/circles have no isolated intersections. + // Interior overlap is nevertheless ambiguous, including duplicate boundaries. + for s in [a, b] { + let other = if std::ptr::eq(s, a) { b } else { a }; + let (_, end) = s.range(); + for t in [0.0, 0.5 * end, end] { + let p = s.point(t); + if on(other, p, tol) && !allowed(p) { + return false; + } + } + } + stable_intersections(a, b, tol).into_iter().all(|p| !on(a, p, tol) || !on(b, p, tol) || allowed(p)) +} +fn contains(boundary: &Boundary, p: Vec2, tol: f64) -> Option { + let mut crossings = 0; + for shape in &boundary.shapes { + // Avoid ray/vertex coincidences rather than guessing a half-open arc rule. + if endpoints(shape).iter().any(|q| (q.y - p.y).abs() <= tol) { + return None; + } + match *shape { + Shape::Line { a, b } => { + if (a.y > p.y) != (b.y > p.y) { + let x = a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y); + if (x - p.x).abs() <= tol { + return None; + } + crossings += usize::from(x > p.x); + } + } + Shape::Round { c, r, start, sweep } => { + let y = (p.y - c.y) / r; + if (y.abs() - 1.0).abs() <= tol / r { + return None; + } + if y.abs() < 1.0 { + let angle = y.asin(); + for theta in [angle, std::f64::consts::PI - angle] { + let off = (theta - start).rem_euclid(TAU); + if off < sweep { + let x = c.x + r * theta.cos(); + if (x - p.x).abs() <= tol { + return None; + } + crossings += usize::from(x > p.x); + } + } + } + } + } + } + Some(crossings % 2 == 1) +} +fn boundaries(curves: &[LCurve], pts: &[Vec2]) -> Option> { + let get = |i| pts.get(i).copied(); + let mut shapes = Vec::new(); + let mut ends = Vec::new(); + let mut incidence = vec![Vec::new(); pts.len()]; + for (i, curve) in curves.iter().enumerate() { + let (shape, ep) = match *curve { + LCurve::Line(a, b) => (Shape::Line { a: get(a)?, b: get(b)? }, Some((a, b))), + LCurve::Arc(c, a, b) => { + let (c, a_pos, b_pos) = (get(c)?, get(a)?, get(b)?); + let sweep = ((b_pos - c).angle() - (a_pos - c).angle()).rem_euclid(TAU); + (Shape::Round { c, r: c.dist(a_pos), start: (a_pos - c).angle(), sweep }, Some((a, b))) + } + LCurve::Circle(c, r) => (Shape::Round { c: get(c)?, r, start: 0.0, sweep: TAU }, None), + // Existing free-form identity matching remains conservative and unchanged. + _ => return None, + }; + if let Some((a, b)) = ep { + incidence.get_mut(a)?.push(i); + incidence.get_mut(b)?.push(i); + } + shapes.push(shape); + ends.push(ep); + } + if incidence.iter().any(|v| !v.is_empty() && v.len() != 2) { + return None; + } + let mut visited = vec![false; curves.len()]; + let mut out = Vec::new(); + for first in 0..curves.len() { + if *visited.get(first)? { + continue; + } + let mut members = Vec::new(); + let mut pending = vec![first]; + while let Some(i) = pending.pop() { + if *visited.get(i)? { + continue; + } + *visited.get_mut(i)? = true; + members.push(i); + if let Some((a, b)) = *ends.get(i)? { + pending.extend(incidence.get(a)?); + pending.extend(incidence.get(b)?); + } + } + let mut lo = Vec2::new(f64::INFINITY, f64::INFINITY); + let mut hi = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY); + let mut signature = [0; 3]; + let mut bound_shapes = Vec::new(); + for i in &members { + let shape = *shapes.get(*i)?; + let mut candidates = endpoints(&shape).to_vec(); + if let Shape::Round { c, r, .. } = shape { + for angle in [0.0, std::f64::consts::FRAC_PI_2, std::f64::consts::PI, 3.0 * std::f64::consts::FRAC_PI_2] { + let p = c + Vec2::from_angle(angle) * r; + if on(&shape, p, 1e-7) { + candidates.push(p); + } + } + } + for p in candidates { + lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y)); + hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y)); + } + signature[match curves.get(*i)? { + LCurve::Line(..) => 0, + LCurve::Arc(..) => 1, + _ => 2, + }] += 1; + bound_shapes.push(shape); + } + out.push(Boundary { curves: members, shapes: bound_shapes, lo, hi, depth: 0, signature }); + } + let scale = out.iter().map(|b| (b.hi - b.lo).len()).fold(1.0, f64::max); + let tol = (scale * 1e-10).max(1e-7); + let mut work = 0usize; + for i in 0..out.len() { + for j in i..out.len() { + for (a_i, a) in out.get(i)?.shapes.iter().enumerate() { + for (b_i, b) in out.get(j)?.shapes.iter().enumerate() { + if i == j && a_i >= b_i { + continue; + } + work += 1; + if work > 262_144 { + return None; + } + let mut shared = Vec::new(); + if i == j { + let ai = *out.get(i)?.curves.get(a_i)?; + let bi = *out.get(j)?.curves.get(b_i)?; + if let (Some((a0, a1)), Some((b0, b1))) = (*ends.get(ai)?, *ends.get(bi)?) { + for point in [a0, a1] { + if point == b0 || point == b1 { + shared.push(get(point)?); + } + } + } + } + if !clear(a, b, &shared, tol) { + return None; + } + } + } + } + } + for i in 0..out.len() { + let mut depth = 0; + for j in 0..out.len() { + if i == j { + continue; + } + let shape = out.get(i)?.shapes.first()?; + let (_, end) = shape.range(); + let inside = [0.317, 0.613, 0.823].into_iter().find_map(|t| contains(out.get(j)?, shape.point(end * t), tol))?; + depth += usize::from(inside); + } + out.get_mut(i)?.depth = depth; + } + Some(out) +} + +pub(super) fn valid(curves: &[LCurve], points: &[Vec2]) -> bool { + boundaries(curves, points).is_some() +} +fn normalize(p: Vec2, lo: Vec2, hi: Vec2) -> Vec2 { + Vec2::new((p.x - lo.x) / (hi.x - lo.x).max(1e-9), (p.y - lo.y) / (hi.y - lo.y).max(1e-9)) +} +pub(super) fn roles(old_curves: &[LCurve], a: &[Vec2], fresh_curves: &[LCurve], b: &[Vec2]) -> Option { + let (previous, incoming) = (boundaries(old_curves, a)?, boundaries(fresh_curves, b)?); + if previous.len() != incoming.len() { + return None; + } + let bounds = |loops: &[Boundary]| { + loops.iter().fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), x| { + (Vec2::new(lo.x.min(x.lo.x), lo.y.min(x.lo.y)), Vec2::new(hi.x.max(x.hi.x), hi.y.max(x.hi.y))) + }) + }; + let ((alo, ahi), (blo, bhi)) = (bounds(&previous), bounds(&incoming)); + let mut mapping = Vec::new(); + for x in &previous { + let mut candidates: Vec<_> = incoming + .iter() + .enumerate() + .filter(|(_, y)| x.depth == y.depth && x.signature == y.signature) + .map(|(i, y)| (i, normalize((x.lo + x.hi) * 0.5, alo, ahi).dist(normalize((y.lo + y.hi) * 0.5, blo, bhi)))) + .collect(); + candidates.sort_by(|x, y| x.1.total_cmp(&y.1)); + let (i, d) = *candidates.first()?; + if mapping.contains(&i) || (candidates.len() > 1 && (d >= 0.1 || candidates.get(1).is_some_and(|(_, second)| d >= second * 0.5))) { + return None; + } + let y = incoming.get(i)?; + // Preserve the conservative bound on relative boundary changes. This does + // not mistake a stationary hole's coordinates for the outer loop's scaling. + let old_size = normalize(x.hi, alo, ahi) - normalize(x.lo, alo, ahi); + let new_size = normalize(y.hi, blo, bhi) - normalize(y.lo, blo, bhi); + if old_size.dist(new_size) >= 0.1 { + return None; + } + mapping.push(i); + } + let membership = |loops: &[Boundary], curves: &[LCurve], pts: &[Vec2], remap: Option<&[usize]>| -> Option<(Vec>, Vec)> { + let mut roles = vec![Vec::new(); pts.len()]; + let mut coordinates = vec![Vec2::ZERO; pts.len()]; + for (i, loop_) in loops.iter().enumerate() { + let target = remap.and_then(|m| m.get(i)).copied().unwrap_or(i); + for curve in &loop_.curves { + for p in curve_points(curves.get(*curve)?) { + if !roles.get(p)?.contains(&target) { + roles.get_mut(p)?.push(target); + *coordinates.get_mut(p)? += normalize(*pts.get(p)?, loop_.lo, loop_.hi); + } + } + } + } + for (role, coordinate) in roles.iter_mut().zip(&mut coordinates) { + if role.is_empty() { + return None; + } + role.sort_unstable(); + *coordinate = *coordinate / role.len() as f64; + } + Some((roles, coordinates)) + }; + let ((old, old_normal), (fresh, fresh_normal)) = + (membership(&previous, old_curves, a, Some(&mapping))?, membership(&incoming, fresh_curves, b, None)?); + let line_roles = |loops: &[Boundary], curves: &[LCurve], pts: &[Vec2]| -> Option>> { + let mut roles = vec![Vec::new(); pts.len()]; + for loop_ in loops { + let size = loop_.hi - loop_.lo; + for i in &loop_.curves { + if let LCurve::Line(a, b) = *curves.get(*i)? { + let d = *pts.get(b)? - *pts.get(a)?; + let direction = Vec2::new(d.x / size.x.max(1e-9), d.y / size.y.max(1e-9)).normalized()?; + roles.get_mut(a)?.push(direction); + roles.get_mut(b)?.push(direction); + } + } + } + Some(roles) + }; + let old_lines = line_roles(&previous, old_curves, a)?; + let fresh_lines = line_roles(&incoming, fresh_curves, b)?; + Some(Roles { old, fresh, old_normal, fresh_normal, old_lines, fresh_lines }) +} diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 9b0c3f27..35d80e35 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -53,6 +53,9 @@ pub enum CurveKind { control: bool, #[serde(default = "three")] degree: u8, + /// Explicit cyclic C2 fit interpolation; absent retains natural endpoints. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + periodic: bool, }, /// Conic from `a` to `b` shaped by the `apex` point and `rho` in (0, 1) (0.5 parabola, /// less elliptical, more hyperbolic). @@ -111,6 +114,7 @@ impl CurveKind { pub fn ends(&self) -> Option<(usize, usize)> { match self { CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. } | CurveKind::Conic { a, b, .. } => Some((*a, *b)), + CurveKind::Spline { periodic: true, .. } => None, CurveKind::Spline { pts, .. } => Some((*pts.first()?, *pts.last()?)), CurveKind::Circle { .. } | CurveKind::Ellipse { .. } => None, } @@ -962,11 +966,13 @@ impl Sketch { ) } CurveKind::Conic { a, b, apex, rho } => Some(vec![Seg2::Conic { a: pt(a)?, apex: pt(apex)?, b: pt(b)?, w: rho / (1.0 - rho).max(1e-9) }]), - CurveKind::Spline { pts, control: false, .. } => { + CurveKind::Spline { pts, control: false, periodic, .. } => { let p: Vec = pts.iter().map(pt).collect::>()?; - Some(crate::curves::fit_beziers(&p).into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect()) + Some( + crate::curves::fit_beziers_mode(&p, *periodic).ok()?.into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect(), + ) } - CurveKind::Spline { pts, control: true, degree } if (*degree as usize) <= solvecraft_geom::MAX_BEZIER_DEGREE => { + CurveKind::Spline { pts, control: true, degree, .. } if (*degree as usize) <= solvecraft_geom::MAX_BEZIER_DEGREE => { let p: Vec = pts.iter().map(pt).collect::>()?; let k = (*degree as usize).min(p.len().saturating_sub(1)).max(1); Some(crate::curves::bspline_beziers(&p, k).iter().filter_map(|c| solvecraft_geom::bezier(c)).collect()) @@ -985,9 +991,9 @@ impl Sketch { (Some(c), Some(m)) => crate::curves::ellipse_polyline(c, m, *r), _ => Vec::new(), }, - CurveKind::Spline { pts, control, degree } => { + CurveKind::Spline { pts, control, degree, periodic } => { let p: Option> = pts.iter().map(pt).collect(); - p.map(|p| crate::curves::spline_polyline(&p, *control, *degree)).unwrap_or_default() + p.map(|p| crate::curves::spline_polyline_mode(&p, *control, *degree, *periodic)).unwrap_or_default() } CurveKind::Conic { a, b, apex, rho } => match (pt(a), pt(apex), pt(b)) { (Some(a), Some(x), Some(b)) => crate::curves::conic_polyline(a, x, b, *rho), @@ -1023,10 +1029,20 @@ impl Sketch { } "e" } - CurveKind::Spline { pts, degree, .. } => { + CurveKind::Spline { pts, control, degree, periodic } => { if pts.len() < 2 || pts.len() > crate::curves::MAX_SPLINE_POINTS || !(1..=7).contains(degree) { return Err(SketchError::Invalid("a spline needs 2 to 500 points and degree 1 to 7".into())); } + if *periodic { + if *control || *degree != 3 { + return Err(SketchError::Invalid("periodic mode requires a cubic fit spline".into())); + } + let p = pts + .iter() + .map(|i| self.point(*i).ok_or_else(|| SketchError::Invalid("missing periodic point".into()))) + .collect::>>()?; + crate::periodic_beziers(&p)?; + } "s" } CurveKind::Conic { rho, .. } => { @@ -1092,7 +1108,34 @@ impl Sketch { let gone = |p: usize| p != 0 && which.contains(&p); let dead_curves: Vec = self.curves.iter().enumerate().filter(|(_, c)| c.kind.point_ids().into_iter().any(gone)).map(|(i, _)| i).collect(); + let private = dead_curves + .iter() + .filter_map(|i| self.curves.get(*i)) + .filter(|c| self.freeform_offsets.iter().any(|r| r.output == c.id)) + .flat_map(|c| c.kind.point_ids()) + .collect::>(); self.drop_curves(&dead_curves); + let mut removed = which.iter().copied().filter(|i| *i != 0).collect::>(); + // Keep any node still named by a surviving curve or constraint. + let referenced = self.curves.iter().flat_map(|c| c.kind.point_ids()).collect::>(); + let constraint_points = std::cell::RefCell::new(std::collections::BTreeSet::new()); + for constraint in &self.constraints { + let mut kind = constraint.kind.clone(); + kind.remap( + &|p| { + constraint_points.borrow_mut().insert(p); + Some(p) + }, + &Some, + ); + } + let constraint_points = constraint_points.into_inner(); + for index in private { + if index != 0 && !referenced.contains(&index) && !constraint_points.contains(&index) { + removed.insert(index); + } + } + let gone = |p: usize| removed.contains(&p); let mut pmap = vec![None; self.points.len()]; let mut n = 0; for (i, slot) in pmap.iter_mut().enumerate() { diff --git a/crates/sketch/src/periodic.rs b/crates/sketch/src/periodic.rs new file mode 100644 index 00000000..b9e408e6 --- /dev/null +++ b/crates/sketch/src/periodic.rs @@ -0,0 +1,204 @@ +//! Explicit cyclic C2 fit splines. Missing periodic markers retain natural interpolation. +use crate::{MAX_SPLINE_POINTS, Sketch, SketchError}; +use solvecraft_geom::Vec2; + +type Result = std::result::Result; +fn bad() -> SketchError { + SketchError::Invalid("periodic fit spline needs 3 distinct cyclic samples, an exact closing point, and regular bounded chords".into()) +} + +pub(crate) fn validate_periodic_points(points: &[Vec2]) -> Result<()> { + if points.len() < 4 + || points.len() > MAX_SPLINE_POINTS + || points.first() != points.last() + || points.iter().any(|p| !p.is_finite() || p.x.abs() >= 1e9 || p.y.abs() >= 1e9) + || points.windows(2).any(|p| { + let h = p[0].dist(p[1]); + !h.is_finite() || h <= 1e-9 + }) + { + return Err(bad()); + } + let mut distinct = Vec::new(); + for p in points { + if !distinct.contains(p) { + distinct.push(*p); + } + if distinct.len() == 3 { + return Ok(()); + } + } + Err(bad()) +} +pub fn periodic_beziers(points: &[Vec2]) -> Result> { + validate_periodic_points(points)?; + let n = points.len() - 1; + let h: Vec = points.windows(2).map(|p| p[0].dist(p[1])).collect(); + let mut diagonal = Vec::with_capacity(n); + let mut rhs = Vec::with_capacity(n); + for i in 0..n { + let previous = (i + n - 1) % n; + diagonal.push(2.0 * (h[previous] + h[i])); + rhs.push(((points[i + 1] - points[i]) / h[i] - (points[i] - points[previous]) / h[previous]) * 6.0); + } + // Remove the two corner entries with a rank-one correction. The underlying + // cyclic matrix is strictly diagonally dominant; all pivots are still checked. + let corner = h[n - 1]; + let gamma = -diagonal[0]; + diagonal[0] -= gamma; + diagonal[n - 1] -= corner * corner / gamma; + let solve = |mut rhs: Vec| -> Result> { + let mut diagonal = diagonal.clone(); + for i in 1..n { + if !diagonal[i - 1].is_finite() || diagonal[i - 1].abs() < 1e-12 * (h[i - 1] + h[(i + n - 2) % n]) { + return Err(bad()); + } + let factor = h[i - 1] / diagonal[i - 1]; + diagonal[i] -= factor * h[i - 1]; + rhs[i] = rhs[i] - rhs[i - 1] * factor; + } + for i in (0..n).rev() { + if !diagonal[i].is_finite() || diagonal[i].abs() < 1e-12 * (h[i] + h[(i + n - 1) % n]) { + return Err(bad()); + } + let next = rhs.get(i + 1).copied().unwrap_or_default() * h[i]; + rhs[i] = (rhs[i] - next) / diagonal[i]; + if !rhs[i].is_finite() { + return Err(bad()); + } + } + Ok(rhs) + }; + let mut second = solve(rhs)?; + let mut correction_rhs = vec![Vec2::ZERO; n]; + correction_rhs[0].x = gamma; + correction_rhs[n - 1].x = corner; + let correction = solve(correction_rhs)?; + let denominator = 1.0 + correction[0].x + corner * correction[n - 1].x / gamma; + if !denominator.is_finite() || denominator.abs() <= 1e-12 { + return Err(bad()); + } + let factor = (second[0] + second[n - 1] * (corner / gamma)) / denominator; + for (value, correction) in second.iter_mut().zip(&correction) { + *value -= factor * correction.x; + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let next = (i + 1) % n; + let chord = points[i + 1] - points[i]; + let t0 = chord - (second[i] * 2.0 + second[next]) * (h[i] * h[i] / 6.0); + let t1 = chord + (second[i] + second[next] * 2.0) * (h[i] * h[i] / 6.0); + let cubic = [points[i], points[i] + t0 / 3.0, points[i + 1] - t1 / 3.0, points[i + 1]]; + if cubic.iter().any(|p| !p.is_finite() || p.x.abs() >= 1e9 || p.y.abs() >= 1e9) { + return Err(bad()); + } + out.push(cubic); + } + Ok(out) +} + +impl Sketch { + pub fn validate_periodic_splines(&self) -> Result<()> { + let mut work = 0usize; + for curve in &self.curves { + if let crate::CurveKind::Spline { pts, control, degree, periodic: true } = &curve.kind { + work = work.saturating_add(pts.len()); + if *control || *degree != 3 || work > 1_000_000 || pts.len() > MAX_SPLINE_POINTS { + return Err(bad()); + } + let points = pts.iter().map(|i| self.point(*i).ok_or_else(bad)).collect::>>()?; + validate_periodic_points(&points)?; + } + } + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + #[test] + fn periodic_c2_unequal_chords_and_legacy_natural_are_distinct() { + let p = [Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(3.0, 2.0), Vec2::new(-1.0, 1.0), Vec2::new(0.0, 0.0)]; + let curves = periodic_beziers(&p).unwrap(); + for i in 0..curves.len() { + let j = (i + 1) % curves.len(); + let (a, b) = (curves[i], curves[j]); + let (ha, hb) = (p[i].dist(p[i + 1]), p[j].dist(p[j + 1])); + assert_eq!(a[3], b[0]); + assert!(((a[3] - a[2]) * (3.0 / ha) - (b[1] - b[0]) * (3.0 / hb)).len() < 1e-12); + assert!(((a[3] - a[2] * 2.0 + a[1]) * (6.0 / (ha * ha)) - (b[2] - b[1] * 2.0 + b[0]) * (6.0 / (hb * hb))).len() < 1e-12); + } + let legacy = crate::fit_beziers(&p); + assert!( + (legacy[0][1] - legacy[0][0]).normalized().unwrap().dist((legacy.last().unwrap()[3] - legacy.last().unwrap()[2]).normalized().unwrap()) + > 0.01 + ); + let global = |t| crate::spline_point_mode(&p, false, 3, true, t); + let h = 1e-5; + assert!(((global(h) - global(0.0)) / h - (global(1.0) - global(1.0 - h)) / h).len() < 0.002); + } + #[test] + fn periodic_rejects_two_samples_and_hostile_geometry() { + let a = Vec2::ZERO; + let b = Vec2::X; + assert!(periodic_beziers(&[a, b, a, b, a]).is_err()); + assert!(periodic_beziers(&[a, b, Vec2::new(0.0, 1.0), b]).is_err()); + assert!(periodic_beziers(&[a, b, Vec2::new(f64::INFINITY, 1.0), a]).is_err()); + assert!(periodic_beziers(&vec![a; MAX_SPLINE_POINTS + 1]).is_err()); + } + + #[test] + fn periodic_solver_closes_shared_roles_and_refuses_immutable_or_collapsed_nodes() { + let mut sk = Sketch::new(); + let first = sk.add_point(Vec2::ZERO, None).unwrap(); + let a = sk.add_point(Vec2::new(4.0, 0.0), None).unwrap(); + let b = sk.add_point(Vec2::new(3.0, 2.0), None).unwrap(); + let seam = sk.add_point(Vec2::ZERO, None).unwrap(); + let c = sk.add_point(Vec2::new(-4.0, 0.0), None).unwrap(); + let d = sk.add_point(Vec2::new(-3.0, -2.0), None).unwrap(); + let last = sk.add_point(Vec2::ZERO, None).unwrap(); + sk.add_curve(crate::CurveKind::Spline { pts: vec![first, a, b, seam], control: false, degree: 3, periodic: true }, None).unwrap(); + sk.add_curve(crate::CurveKind::Spline { pts: vec![seam, c, d, last], control: false, degree: 3, periodic: true }, None).unwrap(); + sk.points[first].pos = Vec2::new(0.2, 0.1); + assert!(crate::solve_holding(&mut sk, &crate::Hold { points: vec![first], radii: vec![] }).ok()); + assert_eq!(sk.point(first), sk.point(seam)); + assert_eq!(sk.point(first), sk.point(last)); + sk.validate_periodic_splines().unwrap(); + let mut fixed = sk.clone(); + fixed.points[first].fixed = true; + fixed.points[seam].fixed = true; + fixed.points[seam].pos.x += 1e-8; + let before = fixed.clone(); + assert!(!crate::solve(&mut fixed).ok()); + assert_eq!(fixed, before); + let mut collapsed = sk; + collapsed.points[a].pos = collapsed.points[first].pos; + let before = collapsed.clone(); + assert!(!crate::solve_holding(&mut collapsed, &crate::Hold { points: vec![first, a], radii: vec![] }).ok()); + assert_eq!(collapsed, before); + } + + #[test] + fn periodic_link_projection_keeps_mode_and_break_geometry() { + let p = vec![Vec2::ZERO, Vec2::new(4.0, 0.0), Vec2::new(3.0, 2.0), Vec2::new(-1.0, 1.0), Vec2::ZERO]; + let mut sk = Sketch::new(); + let source = crate::LinkSource::SketchCurve { sketch: 99, curve: "source".into() }; + let geometry = crate::LinkGeom::Spline { pts: p.clone(), control: false, degree: 3, periodic: true }; + let id = sk.add_link(crate::LinkKind::Project, source, &[geometry]).unwrap(); + let curve = sk.link_curves(&id)[0]; + assert!(matches!(sk.curves[curve].kind, crate::CurveKind::Spline { periodic: true, .. })); + let before = sk.exact_segs(curve).unwrap(); + sk.break_link(&id).unwrap(); + assert_eq!(sk.exact_segs(curve).unwrap(), before); + sk.validate_periodic_splines().unwrap(); + let mut singular = Sketch::new(); + let points = + [Vec2::ZERO, Vec2::X, Vec2::ZERO, Vec2::new(0.0, 1.0), Vec2::ZERO].into_iter().map(|p| singular.add_point(p, None).unwrap()).collect(); + let curve = singular.add_curve(crate::CurveKind::Spline { pts: points, control: false, degree: 3, periodic: true }, None).unwrap(); + let before = singular.clone(); + assert!(singular.add_freeform_offset(curve, true, 0.1).is_err()); + assert_eq!(singular, before); + } +} diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index 1a480b70..96e9c722 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -169,9 +169,9 @@ impl State<'_> { }; crate::curves::ellipse_polyline(self.p(*ci), self.p(*m), r) } - Some(CurveKind::Spline { pts, control, degree }) => { - let p: Vec = pts.iter().map(|q| self.p(*q)).collect(); - crate::curves::spline_polyline(&p, *control, *degree) + Some(CurveKind::Spline { pts, control, degree, periodic }) => { + let p = self.spline_points(pts, *periodic); + crate::curves::spline_polyline_mode(&p, *control, *degree, *periodic) } Some(CurveKind::Conic { a, b, apex, rho }) => crate::curves::conic_polyline(self.p(*a), self.p(*apex), self.p(*b), *rho), _ => Vec::new(), @@ -179,6 +179,16 @@ impl State<'_> { } /// Point-on-curve constraints use the exact shape. A polyline residual would move an /// exact crossing onto its chord, changing geometry merely by splitting it. + fn spline_points(&self, pts: &[usize], periodic: bool) -> Vec { + let mut points: Vec = pts.iter().map(|i| self.p(*i)).collect(); + if periodic + && let Some(first) = points.first().copied() + && let Some(last) = points.last_mut() + { + *last = first; + } + points + } fn freeform_dist(&self, c: usize, q: Vec2) -> f64 { let segs: Vec = match self.sk.curves.get(c).map(|c| &c.kind) { Some(CurveKind::Ellipse { c: ci, m, .. }) => { @@ -194,8 +204,8 @@ impl State<'_> { let y = delta.cross(u) / (a * r); return (x.hypot(y) - 1.0) * a.min(r); } - Some(CurveKind::Spline { pts, control, degree }) => { - let p: Vec = pts.iter().map(|i| self.p(*i)).collect(); + Some(CurveKind::Spline { pts, control, degree, periodic }) => { + let p = self.spline_points(pts, *periodic); if p.len() < 2 { return q.dist(p.first().copied().unwrap_or_default()); } @@ -203,7 +213,11 @@ impl State<'_> { let k = (*degree as usize).min(solvecraft_geom::MAX_BEZIER_DEGREE).min(p.len().saturating_sub(1)).max(1); crate::curves::bspline_beziers(&p, k).iter().filter_map(|span| solvecraft_geom::bezier(span)).collect() } else { - crate::curves::fit_beziers(&p).into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect() + crate::curves::fit_beziers_mode(&p, *periodic) + .unwrap_or_default() + .into_iter() + .map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }) + .collect() } } Some(CurveKind::Conic { a, b, apex, rho }) => { @@ -269,10 +283,10 @@ impl State<'_> { let (cc, r) = self.round(c); (cc - self.p(p)) / (r * r).max(1e-24) } - CurveKind::Spline { pts, control, degree } => { - let q: Vec = pts.iter().map(|i| self.p(*i)).collect(); + CurveKind::Spline { pts, control, degree, periodic } => { + let q = self.spline_points(pts, *periodic); let at_end = pts.last() == Some(&p) && pts.first() != Some(&p); - crate::curves::end_curvature(&|t| crate::curves::spline_point(&q, *control, *degree, t), at_end) + crate::curves::end_curvature(&|t| crate::curves::spline_point_mode(&q, *control, *degree, *periodic, t), at_end) } CurveKind::Conic { a, b, apex, rho } => { let (pa, pb, px) = (self.p(*a), self.p(*b), self.p(*apex)); @@ -285,9 +299,9 @@ impl State<'_> { match self.sk.curves.get(c).map(|c| &c.kind)? { CurveKind::Line { a, b } => (self.p(*b) - self.p(*a)).normalized(), CurveKind::Circle { .. } | CurveKind::Arc { .. } => (self.p(p) - self.round(c).0).perp().normalized(), - CurveKind::Spline { pts, control, .. } => { - let q: Vec = pts.iter().map(|i| self.p(*i)).collect(); - crate::curves::spline_end_tangent(&q, *control, pts.last() == Some(&p) && pts.first() != Some(&p)) + CurveKind::Spline { pts, control, periodic, .. } => { + let q = self.spline_points(pts, *periodic); + crate::curves::spline_end_tangent_mode(&q, *control, *periodic, pts.last() == Some(&p) && pts.first() != Some(&p)) } CurveKind::Conic { a, b, apex, .. } => { if p == *a { @@ -540,6 +554,7 @@ pub fn measure_dimension(sk: &Sketch, k: &ConstraintKind) -> Option { /// One residual block: implicit arc radius equality, a user constraint, or a tangency at a known /// touch point (better conditioned than the distance form when an end point is on the curve). enum Block<'a> { + Closure { a: usize, b: usize }, Arc { c: usize, a: usize, b: usize }, User(usize, &'a ConstraintKind), TangentAt(usize, &'a ConstraintKind, usize), @@ -547,6 +562,7 @@ enum Block<'a> { fn copy_block<'a>(b: &Block<'a>) -> Block<'a> { match b { + Block::Closure { a, b } => Block::Closure { a: *a, b: *b }, Block::Arc { c, a, b } => Block::Arc { c: *c, a: *a, b: *b }, Block::User(i, k) => Block::User(*i, k), Block::TangentAt(i, k, p) => Block::TangentAt(*i, k, *p), @@ -555,6 +571,10 @@ fn copy_block<'a>(b: &Block<'a>) -> Block<'a> { fn block_residuals(s: &State, b: &Block, out: &mut Vec) { match b { + Block::Closure { a, b } => { + let d = s.p(*a) - s.p(*b); + out.extend([d.x, d.y]); + } Block::Arc { c, a, b } => { let cc = s.p(*c); out.push(cc.dist(s.p(*b)) - cc.dist(s.p(*a))); @@ -625,6 +645,10 @@ fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> Vec { }; use ConstraintKind::*; match b { + Block::Closure { a, b } => { + pt(&mut v, *a); + pt(&mut v, *b); + } Block::Arc { c, a, b } => { pt(&mut v, *c); pt(&mut v, *a); @@ -947,6 +971,14 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { let mut blocks: Vec<(Block, Vec)> = Vec::new(); for c in &sk.curves { + if let CurveKind::Spline { pts, periodic: true, .. } = &c.kind + && let (Some(a), Some(b)) = (pts.first(), pts.last()) + && a != b + { + let bl = Block::Closure { a: *a, b: *b }; + let vars = block_vars(sk, &lay, &bl); + blocks.push((bl, vars)); + } if let CurveKind::Arc { c, a, b } = c.kind { let bl = Block::Arc { c, a, b }; let vars = block_vars(sk, &lay, &bl); @@ -1067,6 +1099,63 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { let (r, _, owner) = system(sk, &lay, &consts, &mut x, false); check(&r, &owner, &consts, &mut failing); } + // Weld the whole connected closure graph, preserving every immutable node. + // Validate the actual prospective geometry before accepting a numerical solve. + let has_periodic = sk.curves.iter().any(|c| matches!(c.kind, CurveKind::Spline { periodic: true, .. })); + let periodic_positions = if has_periodic { + let state = State { sk, lay: &lay, x: &x }; + let mut positions = (0..sk.points.len()).map(|i| state.p(i)).collect::>(); + let mut parent = (0..positions.len()).collect::>(); + for curve in &sk.curves { + if let CurveKind::Spline { pts, periodic: true, .. } = &curve.kind + && let (Some(a), Some(b)) = (pts.first(), pts.last()) + && *a < parent.len() + && *b < parent.len() + { + let (a, b) = (find(&mut parent, *a), find(&mut parent, *b)); + if let Some(slot) = parent.get_mut(b) { + *slot = a; + } + } + } + let mut representatives = std::collections::BTreeMap::new(); + let mut valid = true; + for i in 0..positions.len() { + let root = find(&mut parent, i); + if let Some(previous) = representatives.get(&root).copied() { + let fixed = |p| lay.pvar.get(p).is_some_and(Option::is_none); + if fixed(i) && fixed(previous) && positions[i] != positions[previous] { + valid = false; + } + if fixed(i) && !fixed(previous) { + representatives.insert(root, i); + } + } else { + representatives.insert(root, i); + } + } + for (i, position) in positions.iter_mut().enumerate() { + if let Some(index) = representatives.get(&find(&mut parent, i)) { + *position = state.p(*index); + } + } + for curve in &sk.curves { + if let CurveKind::Spline { pts, control, degree, periodic: true } = &curve.kind { + let points = pts.iter().map(|i| positions.get(*i).copied()).collect::>>(); + if *control || *degree != 3 || points.as_ref().is_none_or(|p| crate::periodic::validate_periodic_points(p).is_err()) { + valid = false; + } + } + } + if !valid { + max_residual = max_residual.max(1.0); + None + } else { + Some(positions) + } + } else { + None + }; let status = if max_residual < ACCEPT { SolveStatus::Solved } else { SolveStatus::Failed }; let use_x = if status == SolveStatus::Solved { &x } else { &x0 }; // Write back. @@ -1080,6 +1169,13 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { *r = use_x.get(v).copied().unwrap_or(*r).abs().max(1e-9); } } + if status == SolveStatus::Solved + && let Some(positions) = periodic_positions + { + for (point, position) in sk.points.iter_mut().zip(positions) { + point.pos = position; + } + } if status == SolveStatus::Failed { for (ci, a_before, b_before, reversed) in orientation_before.into_iter().rev() { if let Some(curve) = sk.curves.get_mut(ci) diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index a7a2ca51..4953dce6 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -424,7 +424,7 @@ fn exact_profiles_for_high_degree_and_cut_free_form_curves() { .iter() .map(|p| sk.add_point(*p, None).unwrap()) .collect(); - sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: true, degree: 5 }, None).unwrap(); + sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: true, degree: 5, periodic: false }, None).unwrap(); sk.add_line_pts(ids[5], ids[0], None).unwrap(); let pr = find_profiles(&sk); assert_eq!(pr.len(), 1); @@ -433,7 +433,7 @@ fn exact_profiles_for_high_degree_and_cut_free_form_curves() { // A fit spline arch over a base line, cut by a vertical line at x = 10. let mut sk = Sketch::default(); let ids: Vec = [v(0.0, 0.0), v(6.0, 9.0), v(14.0, 9.0), v(20.0, 0.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect(); - sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: false, degree: 3 }, None).unwrap(); + sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: false, degree: 3, periodic: false }, None).unwrap(); sk.add_line_pts(ids[3], ids[0], None).unwrap(); let (a, b) = (sk.add_point(v(10.0, -5.0), None).unwrap(), sk.add_point(v(10.0, 20.0), None).unwrap()); sk.add_line_pts(a, b, None).unwrap(); diff --git a/crates/sketch/src/wire.rs b/crates/sketch/src/wire.rs index 94a5f925..178588b2 100644 --- a/crates/sketch/src/wire.rs +++ b/crates/sketch/src/wire.rs @@ -196,7 +196,7 @@ impl Sketch { } let id = if geom.is_empty() { let id = self.fresh("j"); - self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false }); + self.links.push(Link { id: id.clone(), kind, source, face_name: None, auto_face: false, lost: false }); id } else { self.add_link(kind, source, geom)? diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 9ef7bd98..e07bf0b2 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -50,6 +50,7 @@ pub struct TreeState { pub occurrence_move: Option, /// The Redefine Sketch Plane panel (sketch id). pub redefine: Option, + pub redefine_draft: Option, /// Components picked in the tree (they have no viewport selection). pub picked_components: Vec, /// Canvases picked in the tree. @@ -112,6 +113,7 @@ pub fn set_sketch_visible(app: &mut SolveApp, id: u64, on: bool) { pub fn redefine_sketch(app: &mut SolveApp, id: u64) { app.tree.redefine = Some(id); + app.tree.redefine_draft = Some(crate::sketch_attachment_ui::Draft::new(app, id)); } fn find_group(app: &SolveApp, id: u64) -> Option<&solvecraft_engine::doc::BrowserGroup> { @@ -523,7 +525,8 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { occurrence_panel(app, ui); capture_prompt(app, ui.ctx()); canvas_panel(app, ui.ctx()); - redefine_panel(app, ui.ctx()); + let repair_anchor = app.viewport.rect.map(|rect| rect.right_top() + vec2(-16.0, 16.0)).unwrap_or_else(|| ui.ctx().content_rect().right_top()); + crate::sketch_attachment_ui::panel(app, ui.ctx(), repair_anchor); } fn component_name(app: &SolveApp, id: u64) -> String { @@ -1141,14 +1144,22 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec { .filter(|f| f.component == comp && matches!(f.kind, FeatureKind::Sketch { .. })) .map(|f| { let active = app.session.active_sketch == Some(f.id); + let lost = app.session.model.result(f.id).is_some_and(|result| result.error.is_some()) + && matches!(doc.sketch_face_attachment(f.id, &app.session.model), solvecraft_engine::doc::FaceAttachmentStatus::Lost { .. }); Entry { key: f.id.to_string(), row_key: format!("s:{}", f.id), - label: if active { format!("{} (editing)", f.name) } else { f.name.clone() }, + label: if lost { + format!("{} (attachment lost)", f.name) + } else if active { + format!("{} (editing)", f.name) + } else { + f.name.clone() + }, icon: "sketch", visible: sketch_visible(app, f.id), selected: active || sel.iter().any(|x| matches!(x, Sel::Feature { id } if *id == f.id)), - color: active.then_some(t.sketch_accent), + color: if lost { Some(t.error) } else { active.then_some(t.sketch_accent) }, locked: false, sel: Some(Sel::Feature { id: f.id }), target: Some(Target::Sketch { id: f.id }), @@ -1805,46 +1816,3 @@ fn occurrence_origin(app: &SolveApp, occurrence: u64) -> Option = vec!["XY".into(), "XZ".into(), "YZ".into()]; - planes.extend(app.session.doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })).map(|f| f.name.clone())); - let face = app.session.selection.iter().find_map(|s| if let Sel::Face { point, .. } = s { Some(*point) } else { None }); - let mut open = true; - let mut pick: Option = None; - crate::frame::window(ctx, format!("Redefine Sketch Plane: {name}"), crate::frame::Width::Normal) - .id(egui::Id::new("sc_redefine")) - .pivot(egui::Align2::LEFT_TOP) - .default_pos(panel_pos(app)) - .open(&mut open) - .resizable(false) - .collapsible(false) - .show(ctx, |ui| { - ui.label(RichText::new("Move the sketch to").color(Tokens::get().text_dim)); - ui.horizontal_wrapped(|ui| { - for p in &planes { - if ui.button(p).clicked() { - pick = Some(json!(p)); - } - } - }); - ui.add_space(4.0); - let b = ui.add_enabled(face.is_some(), egui::Button::new("Selected face")); - if b.clicked() - && let Some(f) = face - { - pick = Some(json!({ "face": [f.x, f.y, f.z] })); - } - b.on_disabled_hover_text("Select a planar face in the viewport first"); - }); - if let Some(plane) = pick { - if app.run("sketch.redefine", json!({ "sketch": id, "plane": plane })).is_ok() { - app.tree.redefine = None; - } - } else if !open { - app.tree.redefine = None; - } -} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f82ae28e..023632b8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -494,7 +494,10 @@ fn draw_revolve( if radius > 1e-9 { let e1 = (base - centre) / radius; let e2 = extent.rotation_axis.cross(e1); - let handed = -direction.dot(extent.rotation_axis); + let handed = match revolve_convention(d) { + solvecraft_engine::doc::RevolveConvention::LegacyAxisStart => -direction.dot(extent.rotation_axis), + solvecraft_engine::doc::RevolveConvention::SketchNormal => 1.0, + }; let at = |angle: f64| centre + (e1 * angle.cos() + e2 * angle.sin()) * radius; let points: Vec<_> = (0..=48).filter_map(|i| proj.to_screen(at(extent.display_start + extent.sweep * i as f64 / 48.0))).collect(); painter.add(egui::Shape::line(points, Stroke::new(2.0, Tokens::get().manipulator))); @@ -608,6 +611,13 @@ fn draw_revolve( }); } +fn revolve_convention(d: &Dialog) -> solvecraft_engine::doc::RevolveConvention { + d.extra + .get("convention") + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .unwrap_or(solvecraft_engine::doc::RevolveConvention::SketchNormal) +} + fn revolve_display_extent(app: &SolveApp, d: &Dialog, axis: (Vec3, Vec3)) -> Option { let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()? else { return None }; let state = app.session.world_state(); @@ -639,7 +649,7 @@ fn revolve_display_extent(app: &SolveApp, d: &Dialog, axis: (Vec3, Vec3)) -> Opt let other = d.extra.get("angle2").map(|value| app.session.doc.eval(value.as_str().unwrap_or_default(), ValueKind::Angle)).transpose().ok()?; (angle, other) }; - solvecraft_engine::doc::revolve_extent( + solvecraft_engine::doc::revolve_extent_with_convention( &state, &plane, ®ions, @@ -647,6 +657,7 @@ fn revolve_display_extent(app: &SolveApp, d: &Dialog, axis: (Vec3, Vec3)) -> Opt angles, d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric"), to, + revolve_convention(d), ) .ok() } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a31fd6ad..1e0500f4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1330,7 +1330,30 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { placement_row(&mut d, ui); input_rows(&mut d, ui); match &mut d.kind { - Kind::Sketch => {} + Kind::Sketch => { + if d.error.is_some() + && let Some(selection @ Sel::Face { .. }) = d.inputs.first().and_then(|i| i.items.first()) + { + row_label(ui, "Attachment"); + ui.label("This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane."); + ui.end_row(); + row_label(ui, ""); + if ui.button("Use fixed plane").clicked() + && let Some(mut plane) = crate::sketch_attachment_ui::face_plane(app, selection) + { + plane["fixed"] = json!(true); + match app.run("sketch.create", json!({"plane":plane})) { + Ok(_) => { + app.pre_sketch_cam = Some(app.cam); + look_at_sketch(app); + cancel = true; + } + Err(error) => d.error = Some(error), + } + } + ui.end_row(); + } + } Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, taper_present, to_offset, start, all } => { row_label(ui, "Start"); let old = if object_on(&d.inputs, FROM_OBJECT) { 2 } else { usize::from(!start.is_empty()) }; @@ -1931,10 +1954,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } // A plane picked for a new sketch starts it right away. if matches!(d.kind, Kind::Sketch) + && d.error.is_none() && let Some(sel) = d.inputs.first().and_then(|i| i.items.first()).cloned() { - start_sketch(app, &sel); - cancel = true; + match start_sketch(app, &sel) { + Ok(()) => cancel = true, + Err(error) => d.error = Some(error), + } } if ok { match run_dialog(app, &d) { @@ -2154,25 +2180,25 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne } /// Start a sketch on a picked plane or planar face and turn the view to face it. -pub fn start_sketch(app: &mut SolveApp, sel: &Sel) { +pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> { let plane = match sel { Sel::Plane { name } => json!(name), - Sel::Face { point, .. } => json!({"face": pt(*point)}), - _ => return, + Sel::Face { .. } => crate::sketch_attachment_ui::face_plane(app, sel).ok_or("Selected face placement no longer exists")?, + _ => return Err("Select an origin plane, construction plane or planar body face".into()), }; let before = app.cam; - if app.run("sketch.create", json!({ "plane": plane })).is_ok() { - app.pre_sketch_cam = Some(before); - look_at_sketch(app); - // On a face: look at the picked point, not the plane's origin (a corner of the face), - // which left the face filling the view off-centre like a big square. - if let Sel::Face { point, .. } = sel - && let Some(mut to) = app.cam_anim.as_ref().map(|a| a.to) - { - to.target = *point; - app.animate_to(to); - } + app.run("sketch.create", json!({ "plane": plane }))?; + app.pre_sketch_cam = Some(before); + look_at_sketch(app); + // On a face: look at the picked point, not the plane's origin (a corner of the face), + // which left the face filling the view off-centre like a big square. + if let Sel::Face { point, .. } = sel + && let Some(mut to) = app.cam_anim.as_ref().map(|a| a.to) + { + to.target = *point; + app.animate_to(to); } + Ok(()) } /// Animate the camera to look straight at the active sketch plane. @@ -3060,7 +3086,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets, angle2, symmetric, convention, to } => { let mut d = start("solid.revolve")?; let frame = feature_frame(s, f.component); let stored_axis = match axis { @@ -3071,6 +3097,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option None, }; d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation), axis: stored_axis }; + d.extra.insert("convention".into(), json!(convention)); if let Some(a2) = angle2 { d.extra.insert("angle2".into(), json!(a2)); } diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 1ee625c9..1e97d42b 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -82,6 +82,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot { app.preview = crate::preview::PreviewState::default(); app.viewport.hover = None; app.menu.props = None; + crate::sketch_attachment_ui::clear(app); let out_rev = app.session.revision; let out = DocSlot { session: std::mem::replace(&mut app.session, slot.session), @@ -142,6 +143,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) { crate::dialogs::cancel(app); app.tool = None; app.menu.props = None; + crate::sketch_attachment_ui::clear(app); app.session = session; app.session.invalidate_view(rev); app.cam = Camera::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b0a9d62b..11577212 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -75,6 +75,9 @@ pub mod shortcut_box; pub mod sketch3d; #[cfg(test)] mod sketch3d_frame_ui_tests; +pub(crate) mod sketch_attachment_ui; +#[cfg(test)] +mod sketch_attachment_ui_tests; pub mod sketch_dims; #[cfg(test)] mod sketch_edit_tests; @@ -414,6 +417,7 @@ impl SolveApp { self.session.echo(format!("{} done", command_label(id))); if matches!(id, "file.new" | "doc.open" | "doc.recover") { self.menu.props = None; + sketch_attachment_ui::clear(self); // The old document is already replaced: discard its editor without // applying cancellation hooks (such as restoring its timeline marker). self.dialog = None; @@ -574,6 +578,13 @@ impl SolveApp { /// puts them back. Editing the sketch that is already open does nothing; editing another one /// finishes the current sketch first, so edit sessions never nest. pub fn edit_sketch(&mut self, id: u64) { + if let solvecraft_engine::doc::FaceAttachmentStatus::Lost { message, .. } = + self.session.placed_document().sketch_face_attachment(id, &self.session.model) + { + browser::redefine_sketch(self, id); + self.set_status(message, true); + return; + } if self.session.active_sketch == Some(id) { return; } diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index 4ffddc11..97a01398 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -132,6 +132,7 @@ impl PreviewState { pub fn update(app: &mut SolveApp, ctx: &egui::Context) { let cmds = match app.dialog.as_ref().filter(|d| d.previews()).map(|d| crate::dialogs::preview_commands(app, d)) { Some(Ok(c)) if !c.is_empty() => c, + _ if crate::sketch_attachment_ui::commands(app).is_some() => crate::sketch_attachment_ui::commands(app).unwrap_or_default(), // A parameter being edited in Change Parameters. _ if app.params.as_ref().and_then(|p| p.preview_commands()).is_some() => { app.params.as_ref().and_then(|p| p.preview_commands()).unwrap_or_default() @@ -185,6 +186,15 @@ fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, V /// show their result in place of the bodies they change. When the feature fails (a boolean the /// kernel can't do yet), the tool body is still shown, with the error. pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job { + if cmds.iter().any(|(command, _)| command == "sketch.redefine") { + let preview = s.preview_visible_world(cmds).map_err(|error| error.to_string())?; + return std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut built = build(&preview.before, &preview.after, colors); + built.bounds = state_bounds(&preview.after); + built + })) + .map_err(|_| "the preview failed".to_string()); + } // Joints and drives move occurrences: shown on the placed model. if let Some(job) = crate::dialogs_assembly::world_preview(s, cmds, colors) { return job; diff --git a/crates/ui-egui/src/revolve_edit_tests.rs b/crates/ui-egui/src/revolve_edit_tests.rs index 6d1c355a..be452ca7 100644 --- a/crates/ui-egui/src/revolve_edit_tests.rs +++ b/crates/ui-egui/src/revolve_edit_tests.rs @@ -51,15 +51,28 @@ fn to_object_noop_preserves_cut_targets_and_untargeted_body() { } let mut h = make_params(h, json!("Y"), json!({"to":[0,2,12],"angle":"45 deg","operation":"cut","targets":["A"]})); let state = h.app.session.world_state(); - let before_a = solvecraft_engine::doc::kernel::measure(&state.body("A").unwrap().body).unwrap().volume; - assert!((before_a - (2000.0 - 490.873852)).abs() < 0.2); + // A quarter annulus separates the stock into inner and outer solids. Both are + // results of cutting A; checking only the first named solid loses material. + let cut_volume = |state: &solvecraft_engine::doc::ModelState| -> f64 { + state.bodies.iter().filter(|b| b.name != "B").map(|b| solvecraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum() + }; + let before_a = cut_volume(&state); + let removed = 0.5 * (15.0f64.powi(2) - 10.0f64.powi(2)) * 5.0 * std::f64::consts::FRAC_PI_2; + assert!((before_a - (2000.0 - removed)).abs() < 0.2, "cut results {:?}: {before_a}", state.bodies.iter().map(|b| &b.name).collect::>()); + let in_cut_stock = |p| state.bodies.iter().filter(|b| b.name != "B").any(|b| b.body.contains(p)); + assert!(in_cut_stock(solvecraft_engine::geom::Vec3::new(2.0, 2.0, 2.0))); + assert!(in_cut_stock(solvecraft_engine::geom::Vec3::new(18.0, 2.0, 18.0))); + assert!(!in_cut_stock(solvecraft_engine::geom::Vec3::new(9.0, 2.0, 9.0))); + assert!(state.body("B").unwrap().body.contains(solvecraft_engine::geom::Vec3::new(9.0, 2.0, 9.0))); + let before_bounds: Vec<_> = state.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect(); let undo = h.app.session.undo.len(); open(&mut h); h.step(&json!({"widget":"OK"})).unwrap(); assert!(h.app.dialog.is_none()); let state = h.app.session.world_state(); assert!((solvecraft_engine::doc::kernel::measure(&state.body("B").unwrap().body).unwrap().volume - 2000.0).abs() < 1e-7); - assert!((solvecraft_engine::doc::kernel::measure(&state.body("A").unwrap().body).unwrap().volume - before_a).abs() < 1e-7); + assert!((cut_volume(&state) - before_a).abs() < 1e-7); + assert_eq!(state.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect::>(), before_bounds); assert_eq!(h.app.session.undo.len(), undo + 1); let FeatureKind::Revolve { targets, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") }; assert_eq!(targets, &["A"]); diff --git a/crates/ui-egui/src/revolve_manipulator_tests.rs b/crates/ui-egui/src/revolve_manipulator_tests.rs index c32cd20a..d62d4f07 100644 --- a/crates/ui-egui/src/revolve_manipulator_tests.rs +++ b/crates/ui-egui/src/revolve_manipulator_tests.rs @@ -251,7 +251,21 @@ fn edge_on_ring_drag_uses_nearest_projected_branch() { #[test] fn legacy_reversed_axis_symmetric_interval_is_shown_and_preserved_exactly() { - let mut h = fixture(Harness::new(), json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]},"direction":"symmetric","angle":"120 deg"})); + let mut h = fixture( + Harness::new(), + json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]},"direction":"symmetric","angle":"120 deg","convention":"legacy_axis_start"}), + ); + // Exercise an actual older design: no convention marker was stored by version 10. + let mut saved: Value = serde_json::from_str(&h.app.session.doc.to_json()).unwrap(); + saved["format"] = json!("solvecraft/10"); + for feature in saved["features"].as_array_mut().unwrap() { + feature.as_object_mut().unwrap().remove("convention"); + } + let path = h.dir.join("legacy-revolve.solvecraft"); + std::fs::write(&path, serde_json::to_vec(&saved).unwrap()).unwrap(); + h.app.run("doc.open", json!({"path":path})).unwrap(); + let FeatureKind::Revolve { convention, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") }; + assert_eq!(*convention, solvecraft_engine::doc::RevolveConvention::LegacyAxisStart); let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(); let before = h.app.session.world_state().body("Main").unwrap().mesh().bounds(); assert!(before.min.x < -14.9 && before.max.x < 7.6 && before.min.z > -1e-6 && before.max.z > 14.9); @@ -270,3 +284,83 @@ fn legacy_reversed_axis_symmetric_interval_is_shown_and_preserved_exactly() { assert!(after.min.z > 7.0 && after.max.z > 14.9); assert!((volume(&h) - expected_volume(90.0)).abs() < 0.1); } + +#[test] +fn canonical_symmetric_ring_preview_drag_and_reopen_match_material() { + for axis in [json!("Y"), json!({"origin":[0,0,0],"dir":[0,-2,0]})] { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY","name":"Profile"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[4,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app + .run("solid.revolve", json!({"sketch":"Profile","axis":axis,"angle":"90 deg","direction":"symmetric","name":"Turn","body_name":"Main"})) + .unwrap(); + let before = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(); + let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone(); + open(&mut h); + let at = |degrees: f64| { + let a = degrees.to_radians(); + json!([3.0 * a.cos(), 5.0, 3.0 * a.sin()]) + }; + handle_at(&mut h, "revolve", at(45.0)); + handle_at(&mut h, "revolve2", at(-45.0)); + let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + let preview = h.app.session.preview_world(&commands).unwrap(); + let body = &preview.after.body("Main").unwrap().body; + assert!(body.contains(solvecraft_engine::geom::Vec3::new(3.0, 5.0, 0.0))); + assert!(!body.contains(solvecraft_engine::geom::Vec3::new(0.0, 5.0, 3.0))); + let bounds = preview.after.body("Main").unwrap().mesh().bounds(); + assert!((bounds.min.x - 2.0f64.sqrt()).abs() < 0.02 && (bounds.max.x - 4.0).abs() < 0.02, "{bounds:?}"); + assert!((bounds.min.z + 2.0 * 2.0f64.sqrt()).abs() < 0.02 && (bounds.max.z - 2.0 * 2.0f64.sqrt()).abs() < 0.02); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), before); + open(&mut h); + h.step(&json!({"drag":[{"handle":"revolve2"},{"world":at(-30.0)}]})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(angle(&h), "60 deg"); + assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names); + assert!((volume(&h) - 60.0 * std::f64::consts::FRAC_PI_3).abs() < 0.03); + let path = h.dir.join("canonical-revolve.solvecraft"); + h.app.run("file.save", json!({"path":path})).unwrap(); + h.app.run("doc.open", json!({"path":path})).unwrap(); + open(&mut h); + handle_at(&mut h, "revolve", at(30.0)); + handle_at(&mut h, "revolve2", at(-30.0)); + let FeatureKind::Revolve { convention, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") }; + assert_eq!(*convention, solvecraft_engine::doc::RevolveConvention::SketchNormal); + h.step(&json!({"widget":"Cancel"})).unwrap(); + } +} + +#[test] +fn reversed_axis_to_object_canvas_sweep_and_preview_follow_the_quarter_target() { + let mut h = fixture(Harness::new(), json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]},"to":[0,2,12],"angle":"45 deg"})); + let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(); + let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone(); + let before = h.app.session.world_state().body("Main").unwrap().mesh().bounds(); + let p = 12.5 / 2.0f64.sqrt(); + open(&mut h); + assert!(h.widget("Sweep 90.0°").is_some()); + assert!(crate::scenario::handle_at("revolve").is_none()); + assert!(crate::scenario::handle_at("revolve2").is_none()); + let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + let preview = h.app.session.preview_world(&commands).unwrap(); + let result = preview.after.body("Main").unwrap(); + assert_eq!(result.mesh().bounds(), before); + assert!(result.body.contains(solvecraft_engine::geom::Vec3::new(p, 2.5, p))); + for (x, z) in [(-p, p), (-p, -p), (p, -p)] { + assert!(!result.body.contains(solvecraft_engine::geom::Vec3::new(x, 2.5, z))); + } + assert!((kernel::measure(&result.body).unwrap().volume - expected_volume(90.0)).abs() < 0.03); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original); + assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names); + let path = h.dir.join("reversed-to-object.solvecraft"); + h.app.run("file.save", json!({"path":path})).unwrap(); + h.app.run("doc.open", json!({"path":path})).unwrap(); + open(&mut h); + assert!(h.widget("Sweep 90.0°").is_some()); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), before); +} diff --git a/crates/ui-egui/src/sketch_attachment_ui.rs b/crates/ui-egui/src/sketch_attachment_ui.rs new file mode 100644 index 00000000..f06e2482 --- /dev/null +++ b/crates/ui-egui/src/sketch_attachment_ui.rs @@ -0,0 +1,269 @@ +//! Reviewable sketch-plane rebinding. Saved lost planes are repair guides, never model geometry. + +use egui::{Pos2, RichText}; +use serde_json::{Value, json}; +use solvecraft_engine::Sel; +use solvecraft_engine::doc::face_anchor::plane_face_anchor; +use solvecraft_engine::doc::{EmbossFace, FaceAttachmentStatus, FeatureKind, mat_inverse}; +use solvecraft_engine::geom::{Plane, Vec3}; + +use crate::SolveApp; +use crate::theme::Tokens; + +#[derive(Clone, Debug)] +pub struct Draft { + pub sketch: u64, + pub candidate: Option, + pub label: String, + pub error: Option, + pub author_path: Option>, +} + +impl Draft { + pub fn new(app: &SolveApp, sketch: u64) -> Self { + let author_path = app.session.doc.feature(sketch).and_then(|feature| match &feature.kind { + FeatureKind::Sketch { plane, .. } if plane_face_anchor(plane).is_some() => { + plane_face_anchor(plane).map(|anchor| anchor.author_path.clone()) + } + _ if feature.component == app.session.active_component => app.session.active_occurrence_path.clone(), + _ => None, + }); + Self { sketch, candidate: None, label: String::new(), error: None, author_path } + } + + fn choose(&mut self, plane: Value, label: String) { + self.candidate = Some(plane); + self.label = label; + self.error = None; + } + + pub fn commands(&self) -> Option> { + let mut params = json!({"sketch":self.sketch,"plane":self.candidate.as_ref()?}); + if let Some(path) = &self.author_path { + params["author_path"] = json!(path); + } + Some(vec![("sketch.redefine".into(), params)]) + } + + pub fn choose_saved_fixed(&mut self, app: &SolveApp, world: Plane) -> Result<(), String> { + let doc = app.session.placed_document(); + let owner = doc.feature(self.sketch).ok_or("Sketch no longer exists")?.component; + let path = self.author_path.clone().unwrap_or_else(|| doc.first_path(owner)); + if self.author_path.is_none() + && path.iter().any(|id| { + doc.occurrences + .iter() + .find(|o| o.id == *id) + .is_none_or(|selected| doc.occurrences.iter().filter(|o| o.component == selected.component).count() != 1) + }) + { + return Err("Select an exact author placement before converting this plane".into()); + } + let frame = EmbossFace::component_frame(&doc, owner, &path).map_err(|error| error.to_string())?; + mat_inverse(&frame).ok_or("Sketch author frame is invalid")?; + self.author_path = Some(path); + // Public commands take world geometry. Session maps Custom planes into + // this exact author placement once, including redefine's saved path. + self.choose(fixed_plane(world), "Fixed plane: independent of the source body".into()); + Ok(()) + } +} + +pub fn clear(app: &mut SolveApp) { + app.tree.redefine = None; + app.tree.redefine_draft = None; +} + +pub fn commands(app: &SolveApp) -> Option> { + let draft = app.tree.redefine_draft.as_ref()?; + (app.tree.redefine == Some(draft.sketch)).then(|| draft.commands()).flatten() +} + +fn attachment(app: &SolveApp, sketch: u64) -> FaceAttachmentStatus { + app.session.placed_document().sketch_face_attachment(sketch, &app.session.model) +} + +fn fixed_plane(plane: Plane) -> Value { + json!({"origin":plane.origin,"x_dir":plane.x,"y_dir":plane.y}) +} + +pub fn face_plane(app: &SolveApp, selection: &Sel) -> Option { + match selection { + Sel::Face { body, index, point } => { + let doc = app.session.placed_document(); + let (_, _, _, path, _) = doc.body_frames_with_source(&app.session.model.state()).into_iter().find(|(world, _, _, _, _)| world == body)?; + Some(json!({"face":point,"body":body,"index":index,"path":path})) + } + _ => None, + } +} + +fn face_label(app: &SolveApp, selection: &Sel) -> String { + let Sel::Face { body, .. } = selection else { return String::new() }; + let placed = app.session.placed_document(); + if let Some((_, source, _, path, _)) = + placed.body_frames_with_source(&app.session.model.state()).into_iter().find(|(world, _, _, _, _)| world == body) + { + format!("Face on {source} / {}", path_label(app, &path)) + } else { + format!("Face on {body}") + } +} + +fn path_label(app: &SolveApp, path: &[u64]) -> String { + if path.is_empty() { + return "Root".into(); + } + (1..=path.len()) + .map(|end| app.session.occurrence_placement(&path[..end]).map(|placement| placement.name).unwrap_or_else(|_| "Missing occurrence".into())) + .collect::>() + .join(" / ") +} + +pub fn panel(app: &mut SolveApp, ctx: &egui::Context, at: Pos2) { + let Some(sketch) = app.tree.redefine else { return }; + if app.session.doc.feature(sketch).is_none() { + clear(app); + return; + } + let mut draft = app.tree.redefine_draft.take().filter(|draft| draft.sketch == sketch).unwrap_or_else(|| Draft::new(app, sketch)); + let status = attachment(app, sketch); + let name = app.session.doc.feature(sketch).map(|feature| feature.name.as_str()).unwrap_or("Sketch"); + let selected = app.session.selection.iter().find(|selection| matches!(selection, Sel::Face { .. })).cloned(); + let mut open = true; + let mut apply = false; + let mut cancel = false; + let t = Tokens::get(); + crate::frame::docked( + format!("Redefine Sketch Plane: {name}"), + egui::Id::new("sc_redefine"), + at, + app.viewport.rect.map(|rect| rect.bottom()).unwrap_or_else(|| ctx.content_rect().bottom()) - at.y - 8.0, + crate::frame::Width::Normal, + ) + .open(&mut open) + .scroll([false, true]) + .show(ctx, |ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); + ui.strong(format!("Redefine Sketch Plane: {name}")); + ui.separator(); + let saved_plane = match &status { + FaceAttachmentStatus::Healthy { plane, target, path } => { + ui.label(format!("Attached to {} / {}", target.body, path_label(app, path))); + Some(*plane) + } + FaceAttachmentStatus::Lost { message, repair_plane } => { + ui.label(RichText::new("Face attachment lost").color(t.error)); + ui.label(message); + if repair_plane.is_some() { + ui.label("The dashed sketch is a saved plane guide. Select a new face to repair the attachment."); + } else { + ui.label("The saved author occurrence is missing. Select a valid author placement before rebinding."); + } + *repair_plane + } + FaceAttachmentStatus::Legacy { plane } => { + ui.label("Legacy face attachment. Rebinding to a certified face creates an exact attachment."); + *plane + } + FaceAttachmentStatus::Fixed { plane } => { + ui.label("Sketch plane has no body-face attachment."); + *plane + } + }; + ui.separator(); + if matches!(&status, FaceAttachmentStatus::Lost { repair_plane: None, .. }) { + let author = app.session.doc.feature(sketch).map(|feature| feature.component); + let available = author == Some(app.session.active_component); + if ui.add_enabled(available, egui::Button::new("Use active author placement")).clicked() { + draft.author_path = Some(app.session.active_occurrence_path.clone().unwrap_or_default()); + draft.error = None; + } + if available && let Some(path) = &draft.author_path { + ui.label(format!("Author placement: {}", path_label(app, path))); + } + } + ui.label("Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates."); + ui.horizontal_wrapped(|ui| { + for plane in ["XY", "XZ", "YZ"] { + if ui.button(format!("Use {plane} plane")).clicked() { + draft.choose(json!(plane), plane.into()); + } + } + }); + for feature in &app.session.doc.features { + if matches!(feature.kind, FeatureKind::ConstructionPlane { .. }) && ui.button(&feature.name).clicked() { + draft.choose(json!(feature.name), feature.name.clone()); + } + } + if ui.add_enabled(selected.is_some(), egui::Button::new("Reattach selected face")).clicked() + && let Some(selection @ Sel::Face { .. }) = &selected + && let Some(plane) = face_plane(app, selection) + { + let label = face_label(app, selection); + draft.choose(plane, label); + } + if ui.add_enabled(saved_plane.is_some(), egui::Button::new("Use saved fixed plane")).clicked() + && let Some(plane) = saved_plane + && let Err(error) = draft.choose_saved_fixed(app, plane) + { + draft.error = Some(error); + } + if ui.add_enabled(selected.is_some(), egui::Button::new("Use selected face as fixed plane")).clicked() + && let Some(selection) = &selected + && let Some(mut plane) = face_plane(app, selection) + { + plane["fixed"] = json!(true); + draft.choose(plane, "Fixed plane: independent of the selected body".into()); + } + if draft.candidate.is_some() { + ui.separator(); + ui.label(format!("Preview: {}", draft.label)); + } + if let Some(error) = draft.error.as_ref().or(app.preview.error.as_ref()) { + ui.label(RichText::new(error).color(t.error)); + } + ui.horizontal(|ui| { + if ui.add_enabled(draft.candidate.is_some() && app.preview.error.is_none(), egui::Button::new("Apply")).clicked() { + apply = true; + } + if ui.button("Cancel").clicked() { + cancel = true; + } + }); + }); + if ctx.input(|input| input.key_pressed(egui::Key::Escape)) { + cancel = true; + app.esc_handled = true; + } + if apply && let Some(commands) = draft.commands() { + for (command, params) in commands { + match app.run(&command, params) { + Ok(_) => cancel = true, + Err(error) => draft.error = Some(error), + } + } + } + if cancel || !open { + clear(app); + } else { + app.tree.redefine_draft = Some(draft); + } +} + +/// Only a lost reference's explicit saved author frame supplies a guide. It is +/// absent from ModelState, selection, snapping and all downstream evaluation. +pub fn lost_guide(app: &SolveApp) -> Option<(Plane, &solvecraft_engine::sketch::Sketch)> { + let sketch = app.tree.redefine?; + let FaceAttachmentStatus::Lost { repair_plane: Some(plane), .. } = attachment(app, sketch) else { return None }; + let FeatureKind::Sketch { sketch, .. } = &app.session.doc.feature(sketch)?.kind else { return None }; + Some((plane, sketch)) +} + +pub fn guide_lines(app: &SolveApp) -> Vec> { + let Some((plane, sketch)) = lost_guide(app) else { return Vec::new() }; + (0..sketch.curves.len()) + .flat_map(|index| sketch.segs(index)) + .map(|segment| segment.polyline(0.05).into_iter().map(|point| plane.to_world(point)).collect()) + .collect() +} diff --git a/crates/ui-egui/src/sketch_attachment_ui_tests.rs b/crates/ui-egui/src/sketch_attachment_ui_tests.rs new file mode 100644 index 00000000..88cbe936 --- /dev/null +++ b/crates/ui-egui/src/sketch_attachment_ui_tests.rs @@ -0,0 +1,324 @@ +use crate::scenario::Harness; +use serde_json::{Value, json}; +use solvecraft_engine::doc::{FaceAttachmentStatus, FeatureKind, PlaneRef}; + +fn picked_sketch() -> (Harness, u64) { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.session.auto_project = false; + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"BaseBox","name":"Original"})).unwrap(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[0,0,20],"body_name":"ControlBox","name":"Control"})).unwrap(); + h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":false})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.call("ui.view", json!({"view":"fit"})); + h.settle(); + h.step(&json!({"start":"sketch.create"})).unwrap(); + h.step(&json!({"click":{"world":[3,4,10]}})).unwrap(); + h.settle(); + let sketch = h.app.session.active_sketch.expect("actual picked face creates the sketch"); + let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &h.app.session.doc.feature(sketch).unwrap().kind else { + panic!("picked face must create a certified attachment"); + }; + assert_eq!(anchor.target.body, "BaseBox"); + assert!(anchor.path.is_empty()); + h.app.run("parameters.add", json!({"name":"badge_diameter","expression":"2 mm"})).unwrap(); + h.step(&json!({"start":"sketch.circle.center"})).unwrap(); + h.step(&json!({"click":{"world":[5,5,10]}})).unwrap(); + h.step(&json!({"move":{"world":[7,5,10]}})).unwrap(); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"badge_diameter"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + h.app.finish_sketch(); + h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":true})).unwrap(); + assert!(!h.app.session.doc.sketch(sketch).unwrap().constraints.is_empty()); + (h, sketch) +} + +fn raw_sketch(h: &Harness, id: u64) -> Value { + serde_json::to_value(h.app.session.doc.sketch(id).unwrap()).unwrap() +} + +#[test] +fn actual_picked_face_and_noop_edit_preserve_exact_anchor_and_dimensions() { + let (mut h, sketch) = picked_sketch(); + let before = serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap(); + h.app.edit_sketch(sketch); + h.settle(); + h.app.finish_sketch(); + assert_eq!(serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap(), before); + let path = h.dir.join("picked-face.solvecraft"); + h.app.run("file.save", json!({"path":path})).unwrap(); + h.app.run("doc.open", json!({"path":path})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap(), before); + assert!(matches!(h.app.session.doc.sketch_face_attachment(sketch, &h.app.session.model), FaceAttachmentStatus::Healthy { .. })); +} + +#[test] +fn lost_attachment_ghost_rebind_preview_cancel_apply_and_undo_preserve_sketch() { + let (mut h, sketch) = picked_sketch(); + h.app.run("solid.extrude", json!({"sketch":sketch,"distance":2,"operation":"new_body","body_name":"Boss"})).unwrap(); + h.app.run("timeline.suppress", json!({"feature":"Original","suppressed":true})).unwrap(); + assert!(h.app.session.model.result(sketch).unwrap().error.is_some()); + assert!(h.app.session.world_state().body("Boss").is_none(), "lost attachment cannot produce a Boss on ControlBox"); + assert!(h.app.session.world_state().sketch(sketch).is_none(), "saved repair guides are not solved sketch geometry"); + let raw = raw_sketch(&h, sketch); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + let redo = h.app.session.redo.len(); + h.app.edit_sketch(sketch); + h.frames(4); + assert_eq!(h.app.tree.redefine, Some(sketch)); + assert!(h.widget("Face attachment lost").is_some()); + let guides = crate::sketch_attachment_ui::guide_lines(&h.app); + assert!(!guides.is_empty()); + assert!(guides.iter().flatten().all(|point| (point.z - 10.0).abs() < 1e-8)); + h.call("ui.view", json!({"view":"top"})); + h.call("ui.view", json!({"view":"fit"})); + h.settle(); + h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":false})).unwrap(); + h.app.session.selection.clear(); + h.frames(4); + h.step(&json!({"click":{"world":[5,5,10]}})).unwrap(); + assert!(h.app.session.selection.is_empty(), "saved lost-plane guides must not be selectable geometry"); + assert!(h.app.session.active_sketch.is_none()); + h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":true})).unwrap(); + h.frames(4); + h.step(&json!({"click":{"world":[3,4,30]}})).unwrap(); + assert!(matches!(h.app.session.selection.first(), Some(solvecraft_engine::Sel::Face { body, .. }) if body=="ControlBox")); + h.frames(2); + h.step(&json!({"widget":"Reattach selected face"})).unwrap(); + let commands = crate::sketch_attachment_ui::commands(&h.app) + .unwrap_or_else(|| panic!("repair draft {:?}, selection {:?}", h.app.tree.redefine_draft, h.app.session.selection)); + assert_eq!(commands[0].1["plane"]["body"], "ControlBox"); + assert!(commands[0].1["plane"]["index"].as_u64().is_some()); + let preview = h.app.session.preview_visible_world(&commands).unwrap(); + let boss = preview.after.body("Boss").unwrap().mesh().bounds(); + assert!((boss.min.z - 30.0).abs() < 1e-7 && (boss.max.z - 32.0).abs() < 1e-7); + assert_eq!(h.app.session.doc.to_json(), before, "preview is scratch only"); + h.step(&json!({"widget":"Cancel"})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.session.redo.len(), redo); + assert!(crate::sketch_attachment_ui::guide_lines(&h.app).is_empty()); + + crate::browser::redefine_sketch(&mut h.app, sketch); + h.frames(4); + h.step(&json!({"widget":"Reattach selected face"})).unwrap(); + h.frames(8); + h.step(&json!({"widget":"Apply"})).unwrap(); + assert!(h.app.tree.redefine.is_none(), "{:?}", h.app.tree.redefine_draft); + assert_eq!(raw_sketch(&h, sketch), raw, "curves, dimensions and expressions remain exact"); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(h.app.session.model.result(sketch).unwrap().error.is_none()); + let boss = h.app.session.world_state().body("Boss").unwrap().mesh().bounds(); + assert!((boss.min.z - 30.0).abs() < 1e-7 && (boss.max.z - 32.0).abs() < 1e-7); + let repaired = h.app.session.doc.to_json(); + let path = h.dir.join("rebound-face.solvecraft"); + h.app.run("file.save", json!({"path":path})).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), before); + h.app.run("doc.open", json!({"path":path})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), repaired); + assert_eq!(raw_sketch(&h, sketch), raw); + assert!(h.app.session.model.result(sketch).unwrap().error.is_none()); +} + +#[test] +fn attachment_drafts_leave_on_document_replacement_but_survive_failed_open() { + let (mut h, sketch) = picked_sketch(); + crate::browser::redefine_sketch(&mut h.app, sketch); + h.frames(2); + h.step(&json!({"widget":"Use XZ plane"})).unwrap(); + assert!(crate::sketch_attachment_ui::commands(&h.app).is_some()); + assert!(h.app.run("doc.open", json!({"path":h.dir.join("missing.solvecraft")})).is_err()); + assert!(crate::sketch_attachment_ui::commands(&h.app).is_some()); + h.app.run("file.new", json!({})).unwrap(); + assert!(h.app.tree.redefine.is_none()); + assert!(h.app.tree.redefine_draft.is_none()); +} + +#[test] +fn unsupported_modified_face_offers_explicit_fixed_plane_without_live_links() { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Modified"})).unwrap(); + h.app.run("solid.fillet", json!({"edges":[[0,0,5]],"radius":1})).unwrap(); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.call("ui.view", json!({"view":"top"})); + h.call("ui.view", json!({"view":"fit"})); + h.settle(); + h.step(&json!({"start":"sketch.create"})).unwrap(); + h.step(&json!({"click":{"world":[5,5,10]}})).unwrap(); + assert!(h.app.session.active_sketch.is_none()); + assert!(h.app.dialog.as_ref().unwrap().error.is_some()); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.undo.len(), undo); + h.frames(4); + assert!(h.widget("Use fixed plane").is_some()); + h.step(&json!({"widget":"Use fixed plane"})).unwrap(); + let sketch = h.app.session.active_sketch.expect("explicit fixed choice succeeds for the actual planar surface"); + let FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch: geometry } = &h.app.session.doc.feature(sketch).unwrap().kind else { + panic!("fixed choice must never create a durable-looking Face reference"); + }; + assert!((plane.origin.z - 10.0).abs() < 1e-7); + assert!(geometry.links.is_empty(), "a fixed snapshot cannot carry live source projections"); + assert_eq!(h.app.session.undo.len(), undo + 1); +} + +fn occurrence_click(h: &mut Harness, path: &[u64], control: &str) { + h.step(&json!({"click":{"occurrence":path,"control":control}})).unwrap(); +} + +#[test] +fn actual_nested_second_placement_face_pick_keeps_target_and_author_paths() { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.session.auto_project = false; + let parent = h.app.run("component.create", json!({"name":"Parent"})).unwrap(); + let parent_component = parent["component"].as_u64().unwrap(); + let first_parent = parent["occurrence"].as_u64().unwrap(); + let child = h.app.run("component.create", json!({"name":"Child","parent":parent_component,"parent_path":[first_parent]})).unwrap(); + let child_component = child["component"].as_u64().unwrap(); + let child_occurrence = child["occurrence"].as_u64().unwrap(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"NestedBase"})).unwrap(); + h.app.run("component.activate", json!({"component":0})).unwrap(); + let second_parent = + h.app.run("occurrence.copy", json!({"occurrence":first_parent,"translate":[40,0,0]})).unwrap()["occurrence"].as_u64().unwrap(); + h.frames(4); + let path = vec![second_parent, child_occurrence]; + if !crate::browser::drawn_occurrences().iter().any(|(drawn, _, _, _)| drawn == &path) { + occurrence_click(&mut h, &[second_parent], "fold"); + } + occurrence_click(&mut h, &path, "activate"); + assert_eq!(h.app.session.active_component, child_component); + assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&path)); + h.call("ui.view", json!({"view":"top"})); + h.call("ui.view", json!({"view":"fit"})); + h.settle(); + h.step(&json!({"start":"sketch.create"})).unwrap(); + h.step(&json!({"click":{"world":[43,4,10]}})).unwrap(); + let sketch = h.app.session.active_sketch.expect("actual second nested face pick"); + let kind = h.app.session.doc.feature(sketch).unwrap().kind.clone(); + let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &kind else { + panic!("strict face attachment"); + }; + assert_eq!(anchor.target.body, "NestedBase"); + assert_eq!(anchor.path, path); + assert_eq!(anchor.author_path, path); + assert_eq!(anchor.component, child_component); + let FaceAttachmentStatus::Healthy { plane, .. } = h.app.session.placed_document().sketch_face_attachment(sketch, &h.app.session.model) else { + panic!("selected saved placement resolves"); + }; + assert!((plane.origin.x - 40.0).abs() < 1e-7 && (plane.origin.z - 10.0).abs() < 1e-7); + h.app.run("sketch.rectangle.two_point", json!({"p0":[1,1],"p1":[3,3]})).unwrap(); + h.app.run("sketch.dimension", json!({"entities":["l1"],"value":2})).unwrap(); + h.app.finish_sketch(); + let kind = h.app.session.doc.feature(sketch).unwrap().kind.clone(); + h.app.run("component.activate", json!({"component":0})).unwrap(); + h.app.edit_sketch(sketch); + assert_eq!(h.app.session.active_component, child_component); + assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&path)); + h.app.finish_sketch(); + assert_eq!(h.app.session.doc.feature(sketch).unwrap().kind, kind); + let file = h.dir.join("nested-face.solvecraft"); + h.app.run("file.save", json!({"path":file})).unwrap(); + h.app.run("doc.open", json!({"path":file})).unwrap(); + assert_eq!(h.app.session.doc.feature(sketch).unwrap().kind, kind); + h.app.run("selection.delete", json!({"occurrences":[second_parent]})).unwrap(); + assert!(h.app.session.model.result(sketch).unwrap().error.is_some()); + let FaceAttachmentStatus::Lost { repair_plane, .. } = h.app.session.placed_document().sketch_face_attachment(sketch, &h.app.session.model) else { + panic!("deleted second placement must not substitute first"); + }; + assert!(repair_plane.is_none(), "a missing saved author placement cannot be drawn at its sibling"); + h.app.edit_sketch(sketch); + h.frames(4); + assert!(crate::sketch_attachment_ui::guide_lines(&h.app).is_empty()); + assert_eq!(h.app.tree.redefine, Some(sketch)); +} + +#[test] +fn nested_anchor_saved_fixed_conversion_preserves_rotated_second_author_plane() { + use solvecraft_engine::doc::{EmbossFace, apply_point, apply_vector}; + use solvecraft_engine::geom::Plane; + use solvecraft_engine::geom::Vec3; + let mut h = Harness::new(); + h.app.session.auto_project = false; + let created = h.app.run("component.create", json!({"name":"Author"})).unwrap(); + let component = created["component"].as_u64().unwrap(); + let first = created["occurrence"].as_u64().unwrap(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"})).unwrap(); + let second = h.app.run("occurrence.copy", json!({"occurrence":first,"translate":[40,0,0]})).unwrap()["occurrence"].as_u64().unwrap(); + h.app.run("occurrence.move", json!({"occurrence":second,"axis":[0,1,0],"angle":"90 deg","capture":true})).unwrap(); + h.app.run("component.activate", json!({"component":component,"path":[second],"occurrence":second})).unwrap(); + let doc = h.app.session.placed_document(); + let body_name = doc.body_frames_with_source(&h.app.session.model.state()).into_iter().find(|(_, _, _, path, _)| path == &[second]).unwrap().0; + let world = h.app.session.world_state(); + let face = world + .body(&body_name) + .unwrap() + .body + .faces(0.01) + .unwrap() + .into_iter() + .find(|face| world.body(&body_name).unwrap().body.face_plane(face.index).is_some_and(|plane| plane.normal().dot(Vec3::X) > 0.999)) + .unwrap(); + let id = h + .app + .run("sketch.create", json!({"plane":{"body":body_name,"index":face.index,"face":face.centroid,"path":[second]},"project_edges":false})) + .unwrap()["sketch"] + .as_u64() + .unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[1,1],"p1":[3,3]})).unwrap(); + h.app.finish_sketch(); + // Exercise a durable anchor under both public plane wrappers, as imported + // documents can contain these without an interactive wrapper editor. + let mut document = (*h.app.session.doc).clone(); + let FeatureKind::Sketch { plane, .. } = &mut document.feature_mut(id).unwrap().kind else { + panic!("sketch"); + }; + *plane = PlaneRef::AtAngle { + base: Box::new(PlaneRef::Offset { base: Box::new(plane.clone()), distance: "2 mm".into() }), + axis_origin: Vec3::ZERO, + axis_dir: Vec3::X, + angle: "15 deg".into(), + }; + h.app.session = solvecraft_engine::Session::new(document); + let raw = h.app.session.doc.sketch(id).unwrap().clone(); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + let FaceAttachmentStatus::Healthy { plane: expected, .. } = h.app.session.placed_document().sketch_face_attachment(id, &h.app.session.model) + else { + panic!("wrapped anchor must resolve"); + }; + crate::browser::redefine_sketch(&mut h.app, id); + h.frames(4); + assert_eq!(h.app.tree.redefine_draft.as_ref().unwrap().author_path, Some(vec![second])); + h.step(&json!({"widget":"Use saved fixed plane"})).unwrap(); + let commands = crate::sketch_attachment_ui::commands(&h.app).unwrap(); + let preview = h.app.session.preview_visible_world(&commands).unwrap(); + // The shared sketch's primary world entry belongs to the untransformed + // first placement. Verify that local plane at the specifically chosen + // second placement, rather than treating the primary entry as that path. + let frame = EmbossFace::component_frame(&h.app.session.placed_document(), component, &[second]).unwrap(); + let selected_plane = + |local: Plane| Plane::new(apply_point(&frame, local.origin), apply_vector(&frame, local.x), apply_vector(&frame, local.y)).unwrap(); + let shown = selected_plane(preview.after.sketch(id).unwrap().plane); + assert!( + shown.origin.dist(expected.origin) < 1e-7, + "expected {expected:?}, shown {shown:?}, primary {:?}, commands {commands:?}", + preview.after.sketch(id).unwrap().plane + ); + assert!(shown.x.dist(expected.x) < 1e-7 && shown.y.dist(expected.y) < 1e-7); + assert_eq!(h.app.session.doc.to_json(), before); + h.frames(6); + h.step(&json!({"widget":"Apply"})).unwrap(); + assert!(h.app.tree.redefine.is_none()); + assert_eq!(h.app.session.doc.sketch(id).unwrap(), &raw); + assert_eq!(h.app.session.undo.len(), undo + 1); + let shown = selected_plane(h.app.session.model.state().sketch(id).unwrap().plane); + assert!(shown.origin.dist(expected.origin) < 1e-7); + assert!(shown.x.dist(expected.x) < 1e-7 && shown.y.dist(expected.y) < 1e-7); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), before); +} diff --git a/crates/ui-egui/src/sketch_corner_ui_tests.rs b/crates/ui-egui/src/sketch_corner_ui_tests.rs index 397cc102..419001ce 100644 --- a/crates/ui-egui/src/sketch_corner_ui_tests.rs +++ b/crates/ui-egui/src/sketch_corner_ui_tests.rs @@ -1,6 +1,66 @@ use super::*; use solvecraft_engine::sketch::CurveKind; +#[test] +fn clicked_periodic_fit_closes_at_start_and_right_click_keeps_natural() { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.settle(); + h.step(&json!({"start":"sketch.spline.fit_point"})).unwrap(); + for point in [[2, 2, 0], [8, 2, 0], [9, 7, 0], [1, 6, 0]] { + h.step(&json!({"click":{"world":point}})).unwrap(); + } + let sid = h.app.session.active_sketch.unwrap(); + assert!(h.app.session.model.state().sketch(sid).unwrap().sketch.curves.is_empty()); + h.step(&json!({"move":{"world":[2,2,0]}})).unwrap(); + assert_eq!(h.app.tool.as_ref().unwrap().hover.as_ref().unwrap().1.as_deref(), Some("Close smooth loop")); + h.step(&json!({"click":{"world":[2,2,0]}})).unwrap(); + let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone(); + assert_eq!(sk.curves.len(), 1, "{:?}", h.app.status); + assert!(matches!(sk.curves[0].kind, CurveKind::Spline { periodic: true, .. })); + sk.validate_periodic_splines().unwrap(); + assert_eq!(solvecraft_engine::sketch::find_profiles(&sk).len(), 1); + assert!(h.app.tool.as_ref().unwrap().pts.is_empty()); + for point in [[15, 2, 0], [20, 2, 0], [20, 6, 0]] { + h.step(&json!({"click":{"world":point}})).unwrap(); + } + h.step(&json!({"click":{"world":[20,6,0]},"button":"right"})).unwrap(); + let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone(); + assert_eq!(sk.curves.len(), 2); + assert!(matches!(sk.curves[1].kind, CurveKind::Spline { periodic: false, .. })); +} + +#[test] +fn clicked_ellipse_offset_then_closed_offset_preserves_exact_distance_and_undo() { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})).unwrap(); + h.settle(); + let sid = h.app.session.active_sketch.unwrap(); + h.step(&json!({"start":"sketch.offset"})).unwrap(); + // Major tip is also a source handle: Offset must choose the actual curve. + h.step(&json!({"click":{"world":[10,0,0]}})).unwrap(); + assert_eq!(h.app.tool.as_ref().unwrap().picks, vec!["e1"]); + h.step(&json!({"click":{"world":[11,0,0]}})).unwrap(); + let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone(); + assert_eq!(sk.freeform_offsets.len(), 1, "{:?}", h.app.status); + assert!((sk.freeform_offsets[0].distance + 1.0).abs() < 1e-4); + let first = sk.freeform_offsets[0].output.clone(); + let saved = h.app.session.doc.to_json(); + h.step(&json!({"click":{"world":[11,0,0]}})).unwrap(); + assert_eq!(h.app.tool.as_ref().unwrap().picks, vec![first]); + h.step(&json!({"click":{"world":[11.5,0,0]}})).unwrap(); + let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone(); + assert_eq!(sk.freeform_offsets.len(), 2, "{:?}", h.app.status); + assert!((sk.freeform_offsets[1].distance + 0.5).abs() < 1e-4); + assert!(sk.freeform_offsets.iter().all(|r| r.certificate == 2)); + assert_eq!(solvecraft_engine::sketch::find_profiles(&sk).len(), 3); + let last = sk.curve_index(&sk.freeform_offsets[1].output).unwrap(); + assert!(sk.exact_segs(last).unwrap()[0].point_at(0.0).dist(Vec2::new(11.5, 0.0)) < 1e-4); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), saved); +} + #[test] fn clicked_extend_endpoint_reaches_exact_ellipse_and_preserves_support() { let mut h = crate::scenario::Harness::new(); diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index a40a8070..c54c857c 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -403,7 +403,7 @@ pub fn hint(id: &str) -> Option { "sketch.slot.center_point" => "Slot: click the centre, an arc centre, then the width", "sketch.slot.arc_three_point" => "Arc slot: click start, a point on the arc, the end, then the width", "sketch.slot.arc_center" => "Arc slot: click the centre, the start, the end, then the width", - "sketch.spline.fit_point" => "Spline: click fit points; right-click to finish", + "sketch.spline.fit_point" => "Spline: click fit points; click the start to close smoothly, or right-click to finish", "sketch.spline.control_point" | "sketch.spline.control_point_5" => "Control point spline: click control points; right-click to finish", "sketch.text" => "Text: click where the text starts", "sketch.blend_curve" => "Blend curve: click the ends of two curves", @@ -546,6 +546,29 @@ fn round_nice(x: f64) -> f64 { } /// A click with an extra tool. +pub fn periodic_start_snap(app: &SolveApp, tool: &Tool, proj: &Proj, pos: Pos2) -> Option { + if tool.cmd != "sketch.spline.fit_point" || tool.pts.len() < 3 || crate::drag_snap::suppressed() { + return None; + } + let first = tool.pts.first()?.0; + let mut distinct = Vec::new(); + for p in &tool.pts { + if !distinct.contains(&p.0) { + distinct.push(p.0); + } + if distinct.len() == 3 { + break; + } + } + if distinct.len() < 3 { + return None; + } + let state = app.session.world_state(); + let plane = &state.sketch(app.session.active_sketch?)?.plane; + let screen = proj.to_screen(plane.to_world(first))?; + (screen.distance(pos) <= 8.0).then_some(first) +} + pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { let Some(m) = mode(tool.cmd) else { return }; let hits = pick(app, proj, pos); @@ -658,6 +681,16 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { } } Mode::Points(n) => { + if periodic_start_snap(app, tool, proj, pos).is_some() { + // Only this deliberate click selects periodic interpolation. A + // right-click finish and all previously saved splines stay natural. + let params = json!({"points":tool.pts.iter().map(arg).collect::>(),"periodic":true}); + if app.run(cmd, params).is_ok() { + tool.pts.clear(); + crate::sketch_dims::reset(tool); + } + return; + } let Some(p) = at else { return }; tool.pts.push(p); if n > 0 && tool.pts.len() >= n { @@ -673,21 +706,30 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { return; } if cmd == "sketch.offset" && !tool.picks.is_empty() { - if let Some((p, _)) = at { + // Dense derived fit nodes must not snap a nearby side click back + // onto the source, reducing a deliberate offset to zero. + let state = app.session.world_state(); + let side = active_sketch(app).and_then(|sid| state.sketch(sid)).and_then(|ss| { + let (o, d) = proj.ray(pos); + ss.plane.intersect_ray(o, d).map(|p| ss.plane.to_local(p)) + }); + if let Some(p) = side { offset_side(app, tool, p); } return; } - let Some((id, is_point)) = sketch_entity(app, &hits) else { return }; if cmd == "sketch.offset" { - // First the curve, then the side point. - if tool.picks.is_empty() && !is_point { + if let Some(sid) = active_sketch(app) + && let Some(id) = hits.iter().find_map(|hit| match hit { + Hit::SketchCurve { sketch, id, .. } if *sketch == sid => Some(id.clone()), + _ => None, + }) + { tool.picks.push(id); - } else if let Some((p, _)) = at { - offset_side(app, tool, p); } return; } + let Some((id, _)) = sketch_entity(app, &hits) else { return }; if cmd == "sketch.centerline" { let _ = app.run(cmd, json!({"curves": [id]})); return; @@ -890,8 +932,14 @@ fn offset_side(app: &mut SolveApp, tool: &mut Tool, p: Vec2) -> bool { let st = app.session.world_state(); let d = active_sketch(app) .and_then(|s| st.sketch(s)) - .and_then(|ss| ss.sketch.curve_index(&tool.picks[0]).and_then(|c| ss.sketch.shape(c))) - .map(|sh| sh.dist(p)) + .and_then(|ss| { + let curve = ss.sketch.curve_index(&tool.picks[0])?; + if let Some(shape) = ss.sketch.shape(curve) { + Some(shape.dist(p)) + } else { + ss.sketch.exact_segs(curve)?.iter().map(|s| s.point_at(s.closest_param(p)).dist(p)).min_by(f64::total_cmp) + } + }) .unwrap_or(0.0); if d > 1e-6 { let _ = app.run("sketch.offset", json!({"curves": [tool.picks[0]], "distance": d, "side": xy(p)})); diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 64180911..adbefac1 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -112,7 +112,9 @@ fn xy(p: &(Vec2, Option)) -> Value { pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) { crate::sketch3d::on_hover(app, proj, pos); - let h = if let Some(tool) = app.tool.as_ref().filter(|t| t.cmd == "sketch.fillet" || t.cmd.starts_with("sketch.chamfer.")) { + let h = if let Some(point) = app.tool.as_ref().and_then(|tool| crate::sketch_tools::periodic_start_snap(app, tool, proj, pos)) { + Some((point, Some("Close smooth loop".into()))) + } else if let Some(tool) = app.tool.as_ref().filter(|t| t.cmd == "sketch.fillet" || t.cmd.starts_with("sketch.chamfer.")) { crate::sketch_tools::corner_point(app, proj, pos, tool.cmd).map(|(at, id)| (at, Some(id))) } else { sketch_point_at(app, proj, pos) diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 15b579f8..dfb11e18 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -225,6 +225,7 @@ fn scene_key(app: &SolveApp) -> u64 { app.session.active_component.hash(&mut h); app.session.active_occurrence_path.hash(&mut h); app.preview.replaced.hash(&mut h); + app.tree.redefine.hash(&mut h); crate::theme::is_dark().hash(&mut h); app.session.active_sketch.hash(&mut h); let (minor, _) = grid_step(app.cam.half_height()); @@ -374,6 +375,12 @@ fn build_scene(app: &SolveApp) -> GpuScene { } } } + let guide_unit = grid_step(app.cam.half_height()).0; + for points in crate::sketch_attachment_ui::guide_lines(app) { + for (a, b) in dashes(&points, &[guide_unit * 0.25, guide_unit * 0.15]) { + sc.line(a.to_f32(), b.to_f32(), c4(tk.warning), 1.5, true); + } + } sc } @@ -594,6 +601,9 @@ pub fn model_bounds(app: &SolveApp) -> solvecraft_engine::geom::Aabb3 { } } } + for point in crate::sketch_attachment_ui::guide_lines(app).into_iter().flatten() { + b.add(point); + } b } @@ -1795,6 +1805,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::drag_snap::show(app, &painter, &proj); hover_highlight(app, &painter, &proj); points_2d(app, &painter, &proj); + if let Some((plane, sketch)) = crate::sketch_attachment_ui::lost_guide(app) + && let Some((low, high)) = sketch.bounds() + && let Some(at) = proj.to_screen(plane.to_world(solvecraft_engine::geom::Vec2::new((low.x + high.x) * 0.5, high.y))) + { + painter.text(at - vec2(0.0, 12.0), Align2::CENTER_BOTTOM, "Saved sketch plane — attachment lost", FontId::proportional(12.0), t.warning); + } if let Some(bx) = app.viewport.boxsel { let col = if bx.crossing { t.box_crossing } else { t.box_window }; painter.rect_filled(bx.rect, 0.0, col.gamma_multiply(0.12)); diff --git a/crates/ui-egui/tests/scenarios/face_wins_over_origin_plane.json b/crates/ui-egui/tests/scenarios/face_wins_over_origin_plane.json index 18f2f4fd..6d8df411 100644 --- a/crates/ui-egui/tests/scenarios/face_wins_over_origin_plane.json +++ b/crates/ui-egui/tests/scenarios/face_wins_over_origin_plane.json @@ -60,6 +60,18 @@ ] } }, + { + "note": "The shelled floor is picked rather than the origin plane. Its uncertified mesh face requires an explicit fixed-plane choice." + }, + { + "expect": { + "sketching": false, + "dialog": "this face has no exact planar source" + } + }, + { + "widget": "Use fixed plane" + }, { "expect": { "sketching": true @@ -78,4 +90,4 @@ } } } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s22_sheet_metal_fold.json b/crates/ui-egui/tests/scenarios/s22_sheet_metal_fold.json index 4990a63c..cf87dada 100644 --- a/crates/ui-egui/tests/scenarios/s22_sheet_metal_fold.json +++ b/crates/ui-egui/tests/scenarios/s22_sheet_metal_fold.json @@ -80,6 +80,28 @@ ] } }, + { + "note": "The sheet flange face is uncertified; choose the displayed fixed-plane option explicitly before drawing the fold line." + }, + { + "expect": { + "sketching": false, + "dialog": "this face has no exact planar source" + } + }, + { + "widget": "Use fixed plane" + }, + { + "expect": { + "sketching": true, + "query": { + "command": "sketch.inspect", + "path": ["plane", "origin", 2], + "value": 2.5 + } + } + }, { "start": "sketch.line" }, @@ -149,4 +171,4 @@ "file": "Untitled-Body1.dxf" } } -] \ No newline at end of file +] diff --git a/docs/book/src/guide/features.md b/docs/book/src/guide/features.md index f27211fe..9aeff456 100644 --- a/docs/book/src/guide/features.md +++ b/docs/book/src/guide/features.md @@ -26,13 +26,23 @@ one undo step, including downstream parameter changes. Editing a placed componen edited feature before its downstream history. Cylindrical Emboss profile guides follow the sketch's chosen occurrence frame. +New symmetric revolves split the entered angle evenly around the sketch profile, regardless +of which way the selected axis points. Two Sides measures the second angle backward and the +first angle forward along the sketch-normal sweep direction. Profiles on opposite sides of +an axis each use their own sweep direction. Designs saved before format 11 keep their original +angular placement when opened, edited or copied; creating another revolve uses the new behavior. +To Object follows that same direction for each selected profile, including curved target faces. +Pick a target away from the axis so its angular position is defined. + For cylindrical **Emboss**, select a supported exterior cylinder face and a tangent sketch profile. **Deboss** cuts radially into the wall. Orthogonal line profiles, including holes and concave outlines, wrap with exact radial walls; the profile must stay clear of seams and ends. Complete cylinders and coaxial sleeves are supported. Capture pending component positions before applying. The feature stores the selected target and author occurrence paths, so edits and reopening retain the chosen instances. Other curved surfaces and free-form profiles remain -unsupported; omitting the face retains the planar emboss behavior. +unsupported. A new Emboss without an explicit face infers a unique eligible supported cylinder; +select the face when the target is ambiguous. Older saved features without a face retain their +planar placement. Fillets and chamfers handle straight edges between planar faces, whole smooth loops, runs of neighbouring edges with mitred or spherical corners, and closed chains of curved edges (a rolling @@ -44,3 +54,7 @@ be found, the feature warns instead of silently picking another face. Press **Delete** on anything selected (bodies, faces, sketches, features, planes, components, canvases). It runs as one undoable step and lists the features that would go with it or fail. + +Face Extrude, face Revolve, and Press Pull retain linked generated profiles. When a modified planar face has no construction certificate, these internal profiles use the legacy named-face attachment and projection behavior so upstream edits still update the plane and outline. This compatibility path is private to those modeling tools: newly attached user sketches require a certified face or an explicitly fixed plane. Legacy internal attachments can follow the nearest matching face after topology changes; inspect their result after major upstream edits. Activate the intended occurrence before generating a face profile from a repeated component; capture a separately moved source position before creating a legacy profile across components. + +A negative Press Pull across a planar face containing a round through-hole can still fail when the Boolean cutter has coincident curved faces. This operation reports an error and preserves the design atomically; positive face extrusion and Press Pull retain the hole and remain linked. diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 569c1556..de24f0d4 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -25,9 +25,20 @@ error bound of 0.002 mm. Singular tangents, curvature cusps, possible self-inter excessive subdivisions are refused. Offset these curves individually: mixed chain junctions currently require separate geometry. `sketch.offset.break` releases an offset for independent editing, and undo restores its association. Older independently sampled offsets remain independent. -Smooth open offsets can be offset again, including bounded refinement after a source edit. -A closed offset uses a natural cubic spline whose seam can have different end tangents; -offsetting that result again is refused when the seam has no unique normal. +Smooth open and newly created smooth closed offsets can be offset again, including bounded +refinement after a source edit. New closed outputs use a periodic cubic fit with continuous +tangent and curvature at the seam. Break Offset Association preserves that interpolation, +and its closing point remains joined when dragged. Deleting an output also removes its +unused private fit points; deleting a source with a retained dependent is refused. + +For explicit closed fit splines, `sketch.spline.fit_point` accepts `periodic: true` and at +least three distinct cyclic samples. It closes the loop automatically. Existing calls and +older saved closed splines retain natural interpolation; older associative closed offsets +also retain their saved natural fit. A legacy seam without a unique normal still refuses +another offset. Periodic intent is stored on each curve and survives save, reopen and Break. +While drawing a fit spline, click its starting point after three distinct samples to close +it smoothly. Right-click finishes an ordinary natural fit. Offset accepts both ellipse +handles and dense fitted curves; the second click sets the side and distance from the curve. Enable **3D Sketch** in the Sketch Palette to draw lines, fit splines and points off the plane. New 3D geometry follows its sketch's component placement, including the selected occurrence diff --git a/docs/configurations.md b/docs/configurations.md index 4b125657..5125fdd3 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -146,6 +146,6 @@ value lists support up to 128 expressions. These bounds are checked before saved decoded. Driven appearance/material rules and repeated fixed-point evaluation are outside the current rule scope. -Designs saved with source-certified face styles use file format 10. Older designs upgrade on opening without +Newly saved designs use file format 11, including optional durable face attachments. Older designs upgrade on opening without changing their existing configuration values. Older SolveCraft versions must be updated to open newly saved designs. diff --git a/docs/face-attachments.md b/docs/face-attachments.md new file mode 100644 index 00000000..d96f60a6 --- /dev/null +++ b/docs/face-attachments.md @@ -0,0 +1,66 @@ +# Face-attached sketches + +New face-attached sketches store the selected source body, its creating feature, an exact +face certificate, and the source and author occurrence paths. The sketch's picked origin +and orientation are stored in the source face's canonical plane. Editing the source height +or moving its placement therefore updates the sketch plane while preserving its local +geometry and dimensions. + +Supported planar sources are exact Box faces, untapered New Body Extrude caps and straight +sketch sides, and planar faces of immutable STEP imports. Extrude caps also identify their +outer boundary and holes by source curve IDs. Reordering profiles cannot substitute another +profile's cap. Curved surfaces cannot host a planar sketch through this workflow. +Edited surfaces without retained construction provenance, ambiguous sources, and unsupported +generators require an explicit fixed plane instead. A nearby or parallel face is never used +to repair a new attachment automatically. + +## Create and repair + +Select a supported planar face and create a sketch. The desktop selection supplies the exact +body and face index, including its occurrence path. Command callers can use +`sketch.create` with `plane: {face: [x,y,z], body: "world body key", index: face_index}`; +the point is in world coordinates. A point-only face request must find a unique supported +source. An unsupported or ambiguous request fails with an explanation instead of creating +an attachment with weaker identity. + +When a source is suppressed, deleted, replaced, or loses its certificate, the sketch reports +a lost attachment. Its saved plane is only a visible repair guide: it does not produce a +healthy solved sketch or let downstream features build at a substitute face. + +Use **Redefine Sketch Plane** to select a new supported face explicitly. The draft can be +reviewed before applying it; cancellation leaves the existing attachment intact. Reattachment +keeps existing sketch geometry, IDs, and dimensions. When the selected source changes, its +previous automatically projected boundary becomes ordinary geometry on the new plane; +it is not replaced by a newly projected boundary. Rebinding to the same certified source +retains its live boundary. Manually created projection links retain their own sources. + +Choose an explicit fixed plane to detach the sketch. This preserves its current local +geometry and dimensions and converts only its automatically projected face boundary to +ordinary geometry. Manual projection links remain attached to their own sources. A fresh +fixed-plane sketch has no live face auto-projection. + +## Occurrences, pending positions, and files + +The selected source occurrence and the sketch's author occurrence are separate identities. +Repeated instances therefore cannot silently switch an attachment to the first placement. +A missing author path also requires explicit repair. Reattachment and creation capture +pending positions only on the involved source and author paths. Failed commands restore +those pending positions; undo restores the previous attachment and placement state. + +File format 11 saves these optional attachments. Existing designs with face references +without an attachment retain their legacy geometry and lookup behavior when opened; they +are not silently upgraded. Redefining such a sketch onto a supported selected face creates +the stronger attachment. Unsupported legacy geometry can instead be retained on an explicit +fixed plane. Imported attachments also bind the exact STEP payload digest, item entity, and +face entity: replacing the payload cannot reuse an old attachment merely because a face +index or shape looks the same. + +## Profiles generated by face modelling commands + +Extrude, Revolve, and Press Pull can generate an internal sketch from a selected planar face. Supported +sources use the same certified attachment and live boundary as an ordinary attached sketch. +For modified faces without a construction certificate, these commands retain their historical +named-face and linked-boundary behaviour through a private compatibility path. Initial capture +uses the selected body and face index. These helper sketches do not have the stronger saved +identity guarantee of a certified attachment. This compatibility path does not change the +requirements for creating an ordinary face-attached sketch. diff --git a/docs/occurrence-workflows.md b/docs/occurrence-workflows.md index ebfbbf23..9152ad9a 100644 --- a/docs/occurrence-workflows.md +++ b/docs/occurrence-workflows.md @@ -51,3 +51,11 @@ Create Components from Bodies and Move Bodies to Component now add ownership-tra Transfers preserve the body's world pose through rotated or translated parents. Pending positions on the involved source and destination paths are captured atomically with the transfer so save/reopen retains that pose; unrelated pending positions remain pending. Undo and checkpoint restoration recover the original pending positions as well as the feature history; redo restores the captured transfer. The operation requires one source and one destination placement. Shared instance definitions with several placements are rejected because a single ownership change cannot preserve several different world poses. A component that supplies transfer history outside its subtree cannot be deleted until that dependent history is removed. Older files without transfer features keep their saved assignment and offset behavior. No transfer timestamp is inferred from their final ownership map. Paste New freezes externally authored geometry after its successful entry transfer and remaps internal transfers, then replays later modifiers once. All transfer commands validate and evaluate on a scratch document before adoption. + +New face-attached sketches capture the selected body's producer, certified face and exact occurrence path, together with the sketch's authoring placement. A supported face follows source edits; a missing or incompatible source reports a lost attachment rather than choosing another nearby face. Opening Edit restores the saved authoring occurrence as view context without creating an undo entry. Older saved point-only face references retain their legacy behavior. + +`sketch.create` accepts `plane:{face:[x,y,z],body:"world body name",index:faceIndex,path:[occurrenceIDs]}`. Coordinates and custom planes are world inputs. The optional path must match the selected body. Point-only face picks must resolve uniquely to a supported exact attachment. For an unsupported planar face, explicitly add `fixed:true` to keep its current plane; this creates no live boundary projection. Curved faces cannot become planar attachments through this option. + +Use `sketch.redefine` with the sketch ID and a new plane to repair or convert an attachment. Its preview is disposable; Apply records one undo step. An optional `author_path` explicitly repairs a missing author placement. Rebinding a repeated owner from another active component requires that path. Changing the target or converting to a fixed plane detaches only the old automatic face borders, retaining their currently evaluated geometry and entity IDs; unrelated projection links remain live. Pending positions on the involved target and author paths are captured with the edit, while unrelated pending positions remain pending. + +Paste New remaps internal certified targets and authoring paths to the copied definitions. Timeline paste reports retained external face targets and refuses translating such attachments until an explicit fixed-plane conversion establishes independent geometry. Rename preserves certified body identity; another producer using the same body name cannot repair a lost attachment. diff --git a/examples/acceptance/sensor_enclosure.json b/examples/acceptance/sensor_enclosure.json index 12cef4ed..755dcf06 100644 --- a/examples/acceptance/sensor_enclosure.json +++ b/examples/acceptance/sensor_enclosure.json @@ -169,14 +169,9 @@ { "command": "sketch.create", "params": { - "plane": { - "face": [ - 5, - -7, - 2 - ] - }, - "name": "PCB standoff" + "plane": "XY", + "name": "PCB standoff", + "offset": "wall" } }, { @@ -283,13 +278,24 @@ "command": "sketch.create", "params": { "plane": { - "face": [ + "origin": [ 0, - -30, - 12 + 0, + 0 + ], + "x_dir": [ + 1, + 0, + 0 + ], + "y_dir": [ + 0, + 0, + 1 ] }, - "name": "USB" + "name": "USB", + "offset": "board_d / 2 + gap + wall" } }, { @@ -309,7 +315,7 @@ "command": "sketch.dimension", "params": { "entities": [ - "l5" + "l1" ], "value": "9 mm" } @@ -318,7 +324,7 @@ "command": "sketch.dimension", "params": { "entities": [ - "l6" + "l2" ], "value": "3.5 mm" } @@ -328,7 +334,7 @@ "params": { "entities": [ "origin", - "p9" + "p5" ], "type": "horizontal", "value": "usb_offset" @@ -339,7 +345,7 @@ "params": { "entities": [ "origin", - "p9" + "p5" ], "type": "vertical", "value": "wall + standoff_h + 1.6 mm + 1.75 mm" @@ -354,10 +360,10 @@ "params": { "profiles": [ [ - "l5", - "l6", - "l7", - "l8" + "l1", + "l2", + "l3", + "l4" ] ], "distance": "-(wall + 1 mm)", @@ -368,14 +374,9 @@ { "command": "sketch.create", "params": { - "plane": { - "face": [ - 5, - -7, - 2 - ] - }, - "name": "Lid boss" + "plane": "XY", + "name": "Lid boss", + "offset": "wall" } }, { @@ -591,13 +592,39 @@ "command": "sketch.create", "params": { "plane": { - "face": [ + "origin": [ 0, 0, - 25 + 0 + ], + "x_dir": [ + 1, + 0, + 0 + ], + "y_dir": [ + 0, + -1, + 0 ] }, - "name": "Lid lip" + "name": "Lid lip", + "offset": "-height" + } + }, + { + "command": "sketch.project", + "params": { + "refs": [ + { + "face": [ + 0, + 0, + 25 + ], + "body": "Lid" + } + ] } }, { @@ -672,14 +699,9 @@ { "command": "sketch.create", "params": { - "plane": { - "face": [ - 5, - -7, - 29 - ] - }, - "name": "Lid screw" + "plane": "XY", + "name": "Lid screw", + "offset": "height + lid_h" } }, {