diff --git a/README.md b/README.md index 6694fd22..2d25820d 100644 --- a/README.md +++ b/README.md @@ -23,21 +23,25 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). ## What works -- **Sketches** on the origin planes, offset planes or planar faces: lines, rectangles (2-point, - 3-point, centre), circles (centre, 2-point, 3-point), arcs (3-point, centre), polygons, slots - and points; construction geometry. +- **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. - **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, radius, diameter and angle dimensions driven by parameters. Over-constraining edits are rejected; fully constrained geometry turns black. - **Profiles** found automatically, including regions split by lines and holes inside outlines. -- **Features** on a parametric timeline: extrude (one side, flipped, symmetric, two-sided, start - offset; new body, join, cut, intersect), revolve, fillet and chamfer (straight convex edges), - box, cylinder, sphere, torus, combine, move. The timeline rebuilds incrementally from the first - changed feature; it can be rolled back, and features can be suppressed, renamed, edited and - deleted. -- **Parameters** with unit-aware expressions (`2 * width + 5 mm`, `angle / 2`). +- **Features** on a parametric timeline: extrude, revolve, sweep, loft, fillet/chamfer, + 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). +- **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, + interference and material-aware mass properties. +- **Sheet metal** with flanges, hems, fold/unfold/refold, material cutouts, flat patterns, + exact line/arc/circle DXF outlines and bend tables; plastic enclosure features and rules. - **Files**: designs as JSON (`.solvecraft`), export to STEP (AP242 with body names, colours and components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori), IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ; diff --git a/ROADMAP.md b/ROADMAP.md index a54854aa..5b1565d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,20 +46,20 @@ 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. Remaining workflow repairs: exact free-form Extend intersections, associative free-form Offset, and consistent occurrence-frame 3D wire authoring | 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. Remaining workflow repairs: mixed offset junctions, periodic closed spline seams, and consistent occurrence-frame 3D wire authoring | 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 | | M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 | | M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | -| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, exact (unfaceted) modelled threads | in progress | 12 | +| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: general curved-face emboss, automatic cylinder target inference, exact (unfaceted) modelled threads | in progress | 12 | | M8 | Surface workspace | deferred | — | | M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | | M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 | | 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. Left: component/face style columns | 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/8"`, 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 | +| 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. Left: certified sketch-side/imported face lineage and 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/9"`, 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 @@ -106,6 +106,23 @@ headline counts in-scope tabs only and lists the deferred ones separately. and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent touch (no clean crossing) enter that profile as polylines. Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth). +- Extend intersects exact ellipses and rational/high-degree splines without moving the line or + 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. +- 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. + Placed feature edits preview through the edited timeline entry, and cylindrical Emboss guides + use the sketch's actual target, author or uniquely placed third-component frame. + Revolve rotator direction and To Object value interaction remain follow-on usability repairs. +- 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. Select the face explicitly and capture pending positions first. Saved target + and author occurrence paths retain the chosen placements; missing/split faces refuse rather + than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces + remain unsupported. STEP round-trip and independent radial/volume checks cover this subset. - Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not: filleted boxes, bosses, domes, countersinks); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two sections ruled. @@ -127,17 +144,25 @@ headline counts in-scope tabs only and lists the deferred ones separately. checked occurrence paths. Active nested occurrences author planar sketches and solids in their selected frame. Pending moves compose through parents; dialog cancellation restores its selected move. Source materials and body/face appearances follow transformed instances. - Shared nested definitions remain shared, and legacy 3D wire authoring and deep Paste New - need further work. See [occurrence workflows](docs/occurrence-workflows.md). + Shared nested definitions remain shared. Deep Paste New copies reachable definition graphs, + independent feature/input histories, styles and internal motion records, with bounded geometry + checks and explicit external-link notices. Paste New selects the copy while preserving the active + authoring context. Imported periodic STEP seams survive placement and reexport with shared + topology and appearances intact. Creating a component from a body can change its historical + implicit Join membership; preserving that material through ownership transfers remains open. + Legacy 3D wire authoring needs further work. See [occurrence workflows](docs/occurrence-workflows.md). - Configuration rules: ordered typed conditions can drive parameters and suppressions or restrict values. Preview explains effective values without modifying the document; applying rules validates rows atomically. Length expressions retain their authoring units across document-unit changes. The UI supports structured All/Any/Not conditions and rule ordering. - Component and face style columns remain open. See [configurations](docs/configurations.md). + Component styles and certified semantic face styles are available; unsupported lineage refuses + atomically, and stale certified face paint cannot jump to another surface. See [configurations](docs/configurations.md). - Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material pieces survive unfold/refold as one logical sheet group, including split flanged sheets. Circular cuts crossing bends remain unsupported; free-form bases use bounded sampling. + Partial-depth Cut and Intersect still lack retained machining metadata; later sheet rebuilds + can restore removed material. Their continuity repair remains open. - Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 89fcfce7..7fefc281 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -45,6 +45,13 @@ pub struct FaceLook { /// The face's persistent name (found first). #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, + /// Strict semantic assignments never transfer to a nearby replacement face. + #[serde(default, skip_serializing_if = "is_false")] + pub strict_name: bool, +} + +fn is_false(value: &bool) -> bool { + !*value } /// The look a body was imported with, carried through later features (which build new kernel @@ -134,6 +141,14 @@ impl Document { pub fn model_body_look(&self, body: &crate::ModelBody) -> Option { self.body_look(body.definition_name(), body.feature) } + /// Effective body opacity, including paint carried from an imported body. + pub fn model_body_opacity(&self, body: &crate::ModelBody) -> f64 { + self.model_body_look(body) + .map(|look| look.opacity) + .or_else(|| body.body.paint().map(|paint| paint.opacity as f64)) + .or_else(|| body.imported.as_ref().and_then(|paint| paint.opacity.map(f64::from))) + .unwrap_or(1.0) + } /// A body's look (without face appearances): its own, else its component's, else its /// material's. pub fn body_look(&self, name: &str, feature: u64) -> Option { @@ -173,6 +188,24 @@ impl Document { }; for f in self.appearances.faces.iter().filter(|f| f.body == b.definition_name()) { let (p, look) = (crate::apply_point(&b.source_transform, f.point), f.look.clone()); + if f.strict_name { + let Some(name) = f.name.as_ref() else { continue }; + let Some(origin_feature) = name.strip_prefix('F').and_then(|name| name.split_once(':')).and_then(|(id, _)| id.parse().ok()) else { + continue; + }; + let target = crate::config::ConfigFaceTarget { + body: f.body.clone(), + origin_feature, + name: crate::naming::strip_piece(name).into(), + point: f.point, + }; + let Ok(indices) = self.configuration_face_indices(b, &target) else { continue }; + for index in indices { + out.retain(|(old, _)| *old != index); + out.push((index, look.clone())); + } + continue; + } // By name (every piece of a split face), else by the point. if let Some(n) = &f.name { let stem = crate::naming::strip_piece(n); @@ -185,7 +218,9 @@ impl Document { continue; } } - if let Some(i) = face_index_at(b, p) { + if !f.strict_name + && let Some(i) = face_index_at(b, p) + { out.retain(|(j, _)| *j != i); out.push((i, look)); } diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index 81272631..add9db0a 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -49,7 +49,10 @@ impl FeatureKind { FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Emboss { sketch, .. } + | FeatureKind::SheetBase { sketch, .. } + | FeatureKind::SheetContour { sketch, .. } | FeatureKind::Rib { sketch, .. } => sk(sketch), + FeatureKind::Patch { sketch: Some(s), .. } => sk(s), FeatureKind::Sweep { sketch, path_sketch, .. } => { sk(sketch); sk(path_sketch); @@ -74,3 +77,371 @@ impl FeatureKind { } } } + +/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels. +pub fn remap_topology(name: &str, ids: &BTreeMap, bodies: &BTreeMap) -> String { + // Imported names belong to the complete canonical body prefix, even when that prefix + // happens to contain text resembling a feature token. + if let Some((body, suffix)) = name.rsplit_once(":face:") + && let Some(new) = bodies.get(body) + { + return format!("{new}:face:{suffix}"); + } + let mut out = String::new(); + let mut at = 0; + while at < name.len() { + let rest = &name[at..]; + if rest.starts_with('F') && (at == 0 || matches!(name.as_bytes().get(at - 1), Some(b'@') | Some(b':') | Some(b'|'))) { + let end = rest.as_bytes().iter().skip(1).take_while(|b| b.is_ascii_digit()).count() + 1; + if end > 1 + && matches!(rest.as_bytes().get(end), Some(b':') | Some(b'.')) + && let Ok(id) = rest[1..end].parse::() + && let Some(new) = ids.get(&id) + { + out.push_str(&format!("F{new}")); + at += end; + continue; + } + } + let Some(ch) = rest.chars().next() else { break }; + out.push(ch); + at += ch.len_utf8(); + } + // Import faces have a canonical body prefix rather than a feature token. + if let Some((body, suffix)) = out.rsplit_once(":face:") + && let Some(new) = bodies.get(body) + { + return format!("{new}:face:{suffix}"); + } + out +} + +fn mapped_name(n: &mut String, names: &BTreeMap) { + if let Some(new) = names.get(n) { + *n = new.clone(); + } +} + +/// Simultaneous generated-parameter substitution: map values may themselves be old keys. +pub fn clone_expression(source: &str, parameters: &BTreeMap) -> String { + let mut result = source.to_string(); + let mut temporary = Vec::new(); + for (index, (old, new)) in parameters.iter().enumerate() { + let mut token = format!("__clone_reference_{index}"); + while source.contains(&token) || parameters.values().any(|n| n == &token) { + token.push('_'); + } + result = crate::expr::rename_reference(&result, old, &token); + temporary.push((token, new)); + } + for (token, new) in temporary { + result = crate::expr::rename_reference(&result, &token, new); + } + result +} + +fn remap_geo( + g: &mut crate::construct::GeoRef, + ids: &BTreeMap, + names: &BTreeMap, + bodies: &BTreeMap, + depth: usize, +) { + use crate::construct::GeoRef; + if depth > 64 { + return; + } + match g { + GeoRef::SketchPoint { sketch, .. } | GeoRef::SketchCurve { sketch, .. } => { + if let Some(new) = ids.get(sketch) { + *sketch = *new; + } + } + 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), + _ => {} + } +} + +fn remap_plane(p: &mut PlaneRef, ids: &BTreeMap, names: &BTreeMap, bodies: &BTreeMap, depth: usize) { + if depth > 64 { + return; + } + match p { + 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), + _ => { + for r in p.refs_mut() { + remap_geo(r, ids, names, bodies, depth + 1); + } + } + } +} + +fn remap_link( + source: &mut solvecraft_sketch::LinkSource, + ids: &BTreeMap, + names: &BTreeMap, + bodies: &BTreeMap, + depth: usize, +) { + use solvecraft_sketch::LinkSource; + if depth > 64 { + return; + } + match source { + LinkSource::Edge { body, .. } + | LinkSource::Face { body, .. } + | LinkSource::Body { body } + | LinkSource::Vertex { body, .. } + | LinkSource::Iso { body, .. } + | LinkSource::Spun { body, .. } => mapped_name(body, bodies), + LinkSource::SketchCurve { sketch, .. } | LinkSource::SketchPoint { sketch, .. } => { + if let Some(new) = ids.get(sketch) { + *sketch = *new; + } + } + LinkSource::OnSurface { sketch, body, .. } => { + if let Some(new) = ids.get(sketch) { + *sketch = *new; + } + mapped_name(body, bodies); + } + LinkSource::Intersection { a, b } => { + remap_link(a, ids, names, bodies, depth + 1); + remap_link(b, ids, names, bodies, depth + 1); + } + LinkSource::Plane { name } => mapped_name(name, names), + _ => {} + } +} + +impl crate::Feature { + /// Clone identities through typed reference fields. Sketch-local entity IDs stay local. + pub fn remap_clone( + &mut self, + ids: &BTreeMap, + names: &BTreeMap, + bodies: &BTreeMap, + parameters: &BTreeMap, + ) -> crate::Result<()> { + use FeatureKind as K; + match &self.kind { + K::Sketch { plane, sketch } => { + checked_plane(plane, 0)?; + for link in &sketch.links { + checked_link(&link.source, 0)?; + } + } + K::ConstructionPlane { plane } + | K::Split { plane, .. } + | K::SplitFace { plane, .. } + | K::Mirror { plane, .. } + | K::SurfaceTrim { plane, .. } => checked_plane(plane, 0)?, + K::Draft { neutral, .. } => checked_plane(neutral, 0)?, + K::ReplaceFace { target, .. } => checked_plane(target, 0)?, + K::ConstructionAxis { def } => { + for r in def.refs() { + checked_geo(r, 0)?; + } + } + K::ConstructionPoint { def } => { + for r in def.refs() { + checked_geo(r, 0)?; + } + } + _ => {} + } + self.kind.remap_refs(ids, names); + for n in &mut self.body_names { + mapped_name(n, bodies); + } + for n in &mut self.participants { + mapped_name(n, bodies); + } + for n in &mut self.param_names { + mapped_name(n, parameters); + } + for n in self.face_names.iter_mut().chain(&mut self.edge_names) { + *n = remap_topology(n, ids, bodies); + } + for (_, e, _) in self.kind.inputs_mut() { + *e = clone_expression(e, parameters); + } + for e in self.kind.point_expr_mut().into_iter().flatten() { + *e = clone_expression(e, parameters); + } + match &mut self.kind { + K::Emboss { face: Some(face), .. } => mapped_name(&mut face.body, bodies), + K::Split { tool: Some(tool), .. } | K::SplitFace { tool: Some(tool), .. } | K::SurfaceTrim { tool: Some(tool), .. } => { + mapped_name(&mut tool.body, bodies) + } + _ => {} + } + match &mut self.kind { + K::Extrude { targets, .. } + | K::Revolve { targets, .. } + | K::Loft { targets, .. } + | K::Sweep { targets, .. } + | K::Pipe { targets, .. } + | K::Emboss { targets, .. } => { + for n in targets { + mapped_name(n, bodies); + } + } + K::Combine { target, tools, .. } => { + mapped_name(target, bodies); + for n in tools { + mapped_name(n, bodies); + } + } + K::Pattern { bodies: list, .. } + | K::Mirror { bodies: list, .. } + | K::Scale { bodies: list, .. } + | K::BoundingSolid { bodies: list, .. } + | K::Align { bodies: list, .. } + | K::Remove { bodies: list } + | K::Stitch { bodies: list, .. } + | K::Move { bodies: list, .. } => { + for n in list { + mapped_name(n, bodies); + } + } + K::Thicken { bodies: list, targets, .. } => { + for n in list.iter_mut().chain(targets) { + mapped_name(n, bodies); + } + } + K::BoundaryFill { tools, .. } => { + for n in tools { + mapped_name(n, bodies); + } + } + K::Fillet { body, .. } + | K::Chamfer { body, .. } + | K::Shell { body, .. } + | K::Draft { body, .. } + | K::SplitFace { body, .. } + | K::OffsetFace { body, .. } + | K::DeleteFace { body, .. } + | K::ReplaceFace { body, .. } + | K::SheetFlange { body, .. } + | K::SheetHem { body, .. } + | K::SheetFold { body, .. } + | K::SheetUnfold { body, .. } + | K::Boss { body, .. } + | K::Lip { body, .. } + | K::SnapFit { body, .. } + | K::Rest { body, .. } + | K::Patch { body, .. } => { + if let Some(n) = body { + mapped_name(n, bodies); + } + } + K::Split { body, .. } | K::SheetConvert { body, .. } | K::SurfaceExtend { body, .. } | K::SurfaceTrim { body, .. } => { + mapped_name(body, bodies) + } + _ => {} + } + match &mut self.kind { + K::Sketch { plane, sketch } => { + remap_plane(plane, ids, names, bodies, 0); + for c in &mut sketch.constraints { + if let Some(n) = &mut c.param { + mapped_name(n, parameters); + } + } + for link in &mut sketch.links { + remap_link(&mut link.source, ids, names, bodies, 0); + if let Some(n) = &mut link.face_name { + *n = remap_topology(n, ids, bodies); + } + } + } + K::ConstructionPlane { plane } + | 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::ConstructionAxis { def } => { + for r in def.refs_mut() { + remap_geo(r, ids, names, bodies, 0); + } + } + K::ConstructionPoint { def } => { + for r in def.refs_mut() { + remap_geo(r, ids, names, bodies, 0); + } + } + _ => {} + } + Ok(()) + } +} + +fn checked_geo(g: &crate::construct::GeoRef, depth: usize) -> crate::Result<()> { + if depth > 64 { + return Err(crate::DocError::Invalid("copy reference nesting exceeds 64".into())); + } + if let crate::construct::GeoRef::Plane { plane } = g { + checked_plane(plane, depth + 1)?; + } + Ok(()) +} +fn checked_plane(p: &PlaneRef, depth: usize) -> crate::Result<()> { + if depth > 64 { + return Err(crate::DocError::Invalid("copy reference nesting exceeds 64".into())); + } + match p { + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => checked_plane(base, depth + 1)?, + PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => { + checked_geo(a, depth + 1)?; + checked_geo(b, depth + 1)?; + } + PlaneRef::Perpendicular { plane, line } => { + checked_geo(plane, depth + 1)?; + checked_geo(line, depth + 1)?; + } + PlaneRef::ThreePoints { a, b, c } => { + checked_geo(a, depth + 1)?; + checked_geo(b, depth + 1)?; + checked_geo(c, depth + 1)?; + } + PlaneRef::Tangent { face, .. } => checked_geo(face, depth + 1)?, + PlaneRef::AlongPath { path, .. } => checked_geo(path, depth + 1)?, + _ => {} + } + Ok(()) +} +fn checked_link(source: &solvecraft_sketch::LinkSource, depth: usize) -> crate::Result<()> { + if depth > 64 { + return Err(crate::DocError::Invalid("copy link nesting exceeds 64".into())); + } + if let solvecraft_sketch::LinkSource::Intersection { a, b } = source { + checked_link(a, depth + 1)?; + checked_link(b, depth + 1)?; + } + Ok(()) +} + +#[cfg(test)] +mod clone_tests { + use super::*; + #[test] + fn imported_names_and_topology_tokens_have_separate_identity() { + let ids = BTreeMap::from([(1, 2), (10, 20)]); + let bodies = BTreeMap::from([("F1:Housing".into(), "F1:Housing (copy)".into())]); + assert_eq!(remap_topology("F1:Housing:face:0", &ids, &bodies), "F1:Housing (copy):face:0"); + assert_eq!(remap_topology("F1:+x|F10:end@F1.2", &ids, &bodies), "F2:+x|F20:end@F2.2"); + assert_eq!(remap_topology("UserF1:label", &ids, &bodies), "UserF1:label"); + } + #[test] + fn parameter_remap_is_simultaneous_and_token_scoped() { + let map = BTreeMap::from([("d1".into(), "d2".into()), ("d2".into(), "d3".into())]); + assert_eq!(clone_expression("d1 + d2 + d10 + 2 mm", &map), "d2 + d3 + d10 + 2 mm"); + } +} diff --git a/crates/doc/src/config.rs b/crates/doc/src/config.rs index 97fa631c..e0e2bc47 100644 --- a/crates/doc/src/config.rs +++ b/crates/doc/src/config.rs @@ -9,6 +9,12 @@ use crate::{DocError, Document, Result}; #[path = "config_rules.rs"] mod rules; pub use rules::*; +#[path = "config_styles.rs"] +mod styles; +pub use styles::*; +#[path = "config_clone.rs"] +mod clone; +pub use clone::*; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -21,6 +27,10 @@ pub enum Column { Material(String), /// A body's appearance (library name, serialized Look, or empty to clear). Appearance(String), + /// Shared component definition's direct appearance. + ComponentAppearance(u64), + /// A certified semantic face lineage in a shared definition. + FaceAppearance(ConfigFaceTarget), } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -132,6 +142,12 @@ impl Document { Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()), Column::Material(body) => Some(self.materials.get(body).cloned().unwrap_or_else(|| "Default".into())), Column::Appearance(body) => Some(self.appearances.bodies.get(body).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default()), + Column::ComponentAppearance(id) => { + Some(self.appearances.components.get(id).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default()) + } + Column::FaceAppearance(target) => { + Some(self.config_face_look(target).and_then(|look| serde_json::to_string(&look).ok()).unwrap_or_default()) + } } } @@ -142,6 +158,8 @@ impl Document { Column::Suppress(id) => format!("suppress:{}", self.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| id.to_string())), Column::Material(body) => format!("material:{body}"), Column::Appearance(body) => format!("appearance:{body}"), + Column::ComponentAppearance(id) => format!("component_appearance:{id}"), + Column::FaceAppearance(target) => format!("face_appearance:{}", target.key()), } } @@ -194,6 +212,32 @@ impl Document { next.appearances.bodies.remove(body); } }, + Column::ComponentAppearance(id) => { + if *id == 0 || !next.components.iter().any(|component| component.id == *id) { + return Err(DocError::Unknown(format!("component {id} is gone"))); + } + match appearance_cell(v)? { + Some(look) => { + next.appearances.components.insert(*id, look); + } + None => { + next.appearances.components.remove(id); + } + } + } + Column::FaceAppearance(target) => { + next.certify_config_face(target)?; + next.appearances.faces.retain(|face| !target.matches(face)); + if let Some(look) = appearance_cell(v)? { + next.appearances.faces.push(crate::appearance::FaceLook { + body: target.body.clone(), + point: target.point, + look, + name: Some(target.name.clone()), + strict_name: true, + }); + } + } } } next.configs.active = Some(row.to_string()); @@ -228,6 +272,8 @@ impl Document { } Column::Material(body) => material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()), Column::Appearance(body) => appearance_cell(v).is_ok_and(|look| self.appearances.bodies.get(body) == look.as_ref()), + Column::ComponentAppearance(id) => appearance_cell(v).is_ok_and(|look| self.appearances.components.get(id) == look.as_ref()), + Column::FaceAppearance(target) => appearance_cell(v).is_ok_and(|look| self.config_face_look(target) == look), }) } } diff --git a/crates/doc/src/config_clone.rs b/crates/doc/src/config_clone.rs new file mode 100644 index 00000000..7ccb8eba --- /dev/null +++ b/crates/doc/src/config_clone.rs @@ -0,0 +1,213 @@ +//! Configuration graph remapping for independent component copies. +use super::{Column, ConfigTable}; +use crate::{DocError, Result}; +use std::collections::BTreeMap; + +#[derive(Clone, Debug, Default)] +pub struct ConfigCloneMaps { + pub features: BTreeMap, + pub components: BTreeMap, + pub bodies: BTreeMap, + pub parameters: BTreeMap, +} + +/// Simultaneous token-reference substitution, including maps whose values are other keys. +pub(super) fn remap_expression(source: &str, parameters: &BTreeMap) -> String { + let refs = crate::expr::references(source); + let mut result = source.to_string(); + let mut replacements = Vec::new(); + for (index, reference) in refs.iter().enumerate() { + if let Some(mapped) = parameters.get(reference) { + let mut temporary = format!("_config_clone_{index}"); + while refs.contains(&temporary) || parameters.contains_key(&temporary) || parameters.values().any(|value| value == &temporary) { + temporary.push('_'); + } + result = crate::expr::rename_reference(&result, reference, &temporary); + replacements.push((temporary, mapped)); + } + } + for (temporary, mapped) in replacements { + result = crate::expr::rename_reference(&result, &temporary, mapped); + } + result +} + +impl ConfigTable { + /// Append mapped columns/cells/rules atomically; source targets and active row remain intact. + pub fn clone_targets(&mut self, maps: &ConfigCloneMaps) -> Result<()> { + if maps.features.values().chain(maps.components.values()).any(|id| *id == 0) + || maps.parameters.values().any(|name| crate::Document::param_name_problem(name).is_some()) + || maps.bodies.values().any(|name| name.is_empty() || name.len() > 128) + { + return Err(DocError::Invalid("invalid configuration clone identity mapping".into())); + } + if self.columns.len() > super::MAX_COLUMNS + || self.rows.len() > super::MAX_ROWS + || self.rules.len() > super::MAX_RULES + || self.rows.iter().any(|row| row.cells.len() != self.columns.len()) + { + return Err(DocError::Invalid("cannot clone an incomplete configuration table".into())); + } + let mut next = self.clone(); + for (index, column) in self.columns.iter().enumerate() { + if let Column::FaceAppearance(target) = column + && maps.bodies.contains_key(&target.body) + && !maps.features.contains_key(&target.origin_feature) + { + return Err(DocError::Invalid(format!( + "cannot copy configured face `{}`: imported foreign body has no certified cloned lineage", + target.name + ))); + } + let mapped = match column { + Column::Param(name) => maps.parameters.get(name).cloned().map(Column::Param), + Column::Suppress(id) => maps.features.get(id).copied().map(Column::Suppress), + Column::Material(name) => maps.bodies.get(name).cloned().map(Column::Material), + Column::Appearance(name) => maps.bodies.get(name).cloned().map(Column::Appearance), + Column::ComponentAppearance(id) => maps.components.get(id).copied().map(Column::ComponentAppearance), + Column::FaceAppearance(target) => { + maps.bodies.get(&target.body).zip(maps.features.get(&target.origin_feature)).map(|(body, origin)| { + let mut target = target.clone(); + target.body = body.clone(); + target.name = target + .name + .strip_prefix(&format!("F{}:", target.origin_feature)) + .map(|role| format!("F{origin}:{role}")) + .unwrap_or_else(|| target.name.clone()); + target.origin_feature = *origin; + Column::FaceAppearance(target) + }) + } + }; + if let Some(mapped) = mapped { + if mapped == *column { + continue; + } + if next.columns.iter().any(|column| { + *column == mapped + || matches!((column,&mapped),(Column::FaceAppearance(a),Column::FaceAppearance(b)) if a.body == b.body && a.name == b.name) + }) { + return Err(DocError::Invalid("configuration copy target collides with an existing column".into())); + } + if next.columns.len() >= super::MAX_COLUMNS { + return Err(DocError::Invalid("configuration copy exceeds column limit".into())); + } + next.columns.push(mapped); + for (source, destination) in self.rows.iter().zip(&mut next.rows) { + let value = source.cells.get(index).cloned().unwrap_or_default(); + let value = if matches!(column, Column::Param(_)) { remap_expression(&value, &maps.parameters) } else { value }; + if value.len() > 4096 { + return Err(DocError::Invalid("configuration copied value exceeds length limit".into())); + } + destination.cells.push(value); + } + } + } + for rule in &self.rules { + if let Some(mut copied) = rule.clone_for_targets(maps) { + if next.rules.len() >= super::MAX_RULES { + return Err(DocError::Invalid("configuration copy exceeds rule limit".into())); + } + let stem: String = rule.id.chars().take(40).collect(); + copied.id = (1..=super::MAX_RULES + 1) + .map(|i| format!("{stem}_copy{i}")) + .find(|id| !next.rules.iter().any(|rule| rule.id == *id)) + .ok_or_else(|| DocError::Invalid("no configuration rule ID available".into()))?; + copied.name = format!("{} (copy)", rule.name.chars().take(120).collect::()); + next.rules.push(copied); + } + } + super::validate_rules_json(&serde_json::json!({"configs":{"rules":next.rules}}))?; + *self = next; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ConfigFaceTarget, ConfigRow, ConfigRule, RuleCondition, RuleEffect}; + #[test] + fn clone_preserves_source_variations_and_remaps_only_typed_targets_and_tokens() { + let mut table = ConfigTable { + columns: vec![ + Column::Param("d1".into()), + Column::Param("d2".into()), + Column::Appearance("Old".into()), + Column::ComponentAppearance(2), + Column::FaceAppearance(ConfigFaceTarget { + body: "Old".into(), + origin_feature: 3, + name: "F3:+z".into(), + point: solvecraft_geom::Vec3::new(1., 1., 2.), + }), + ], + rows: vec![ConfigRow { + name: "Default".into(), + cells: vec!["d1 * 2 + d2".into(), "4 mm".into(), "d1".into(), String::new(), String::new()], + }], + active: Some("Default".into()), + rules: vec![ConfigRule { + id: "Rule1".into(), + name: "Rule1".into(), + enabled: true, + length_unit: Some("mm".into()), + condition: RuleCondition::Suppression { feature: 3, value: false }, + effect: RuleEffect::DriveParameter { parameter: "d1".into(), expression: "d2 * 2 + d1".into() }, + }], + }; + let original = table.clone(); + let maps = ConfigCloneMaps { + features: BTreeMap::from([(3, 7)]), + components: BTreeMap::from([(2, 8)]), + bodies: BTreeMap::from([("Old".into(), "New".into())]), + parameters: BTreeMap::from([("d1".into(), "copy_d1".into()), ("d2".into(), "copy_d2".into())]), + }; + table.clone_targets(&maps).unwrap(); + assert_eq!(&table.columns[..5], &original.columns); + assert_eq!(&table.rows[0].cells[..5], &original.rows[0].cells); + assert_eq!(table.columns[5], Column::Param("copy_d1".into())); + assert_eq!(table.rows[0].cells[5], "copy_d1 * 2 + copy_d2"); + assert_eq!(table.rows[0].cells[7], "d1"); // appearance strings are never parameter expressions + assert_eq!(table.columns[8], Column::ComponentAppearance(8)); + let Column::FaceAppearance(face) = &table.columns[9] else { panic!() }; + assert_eq!(face.name, "F7:+z"); + assert_eq!(face.body, "New"); + assert_eq!(table.rules[0], original.rules[0]); + assert!( + matches!(&table.rules[1].effect,RuleEffect::DriveParameter{parameter,expression} if parameter=="copy_d1" && expression=="copy_d2 * 2 + copy_d1") + ); + assert!(matches!(table.rules[1].condition, RuleCondition::Suppression { feature: 7, .. })); + assert_eq!(table.active, original.active); + let chained = BTreeMap::from([("d1".into(), "d2".into()), ("d2".into(), "d3".into())]); + assert_eq!(remap_expression("d1 * 2 + d2", &chained), "d2 * 2 + d3"); + let collision = ConfigCloneMaps { parameters: chained, ..Default::default() }; + let mut original_copy = original.clone(); + assert!(original_copy.clone_targets(&collision).is_err()); + assert_eq!(original_copy, original); + } + #[test] + fn foreign_strict_lineage_failure_is_atomic() { + let mut table = ConfigTable { + columns: vec![ + Column::Param("width".into()), + Column::FaceAppearance(ConfigFaceTarget { + body: "Foreign".into(), + origin_feature: 3, + name: "F3:+z".into(), + point: solvecraft_geom::Vec3::ZERO, + }), + ], + rows: vec![ConfigRow { name: "Default".into(), cells: vec!["2 mm".into(), String::new()] }], + ..Default::default() + }; + let original = table.clone(); + let maps = ConfigCloneMaps { + bodies: BTreeMap::from([("Foreign".into(), "Copied".into())]), + parameters: BTreeMap::from([("width".into(), "copied_width".into())]), + ..Default::default() + }; + assert!(table.clone_targets(&maps).unwrap_err().to_string().contains("certified")); + assert_eq!(table, original); + } +} diff --git a/crates/doc/src/config_rules.rs b/crates/doc/src/config_rules.rs index f186fbf4..ebd01a3a 100644 --- a/crates/doc/src/config_rules.rs +++ b/crates/doc/src/config_rules.rs @@ -377,6 +377,44 @@ impl ConfigRule { } }); } + pub(super) fn clone_for_targets(&self, maps: &super::ConfigCloneMaps) -> Option { + let mapped = match &self.effect { + RuleEffect::DriveParameter { parameter, .. } + | RuleEffect::ParameterRange { parameter, .. } + | RuleEffect::AllowParameterValues { parameter, .. } => maps.parameters.contains_key(parameter), + RuleEffect::DriveSuppression { feature, .. } => maps.features.contains_key(feature), + }; + if !mapped { + return None; + } + let mut copy = self.clone(); + copy.visit_parameters(&mut |text, expression| { + *text = if expression { + super::clone::remap_expression(text, &maps.parameters) + } else { + maps.parameters.get(text).cloned().unwrap_or_else(|| text.clone()) + }; + }); + fn remap(condition: &mut RuleCondition, maps: &super::ConfigCloneMaps) { + match condition { + RuleCondition::Suppression { feature, .. } => { + *feature = maps.features.get(feature).copied().unwrap_or(*feature); + } + RuleCondition::All { conditions } | RuleCondition::Any { conditions } => { + for condition in conditions { + remap(condition, maps); + } + } + RuleCondition::Not { condition } => remap(condition, maps), + _ => {} + } + } + remap(&mut copy.condition, maps); + if let RuleEffect::DriveSuppression { feature, .. } = &mut copy.effect { + *feature = maps.features.get(feature).copied().unwrap_or(*feature); + } + Some(copy) + } fn visit_parameters(&mut self, visit: &mut impl FnMut(&mut String, bool)) { fn walk(c: &mut RuleCondition, visit: &mut impl FnMut(&mut String, bool)) { match c { diff --git a/crates/doc/src/config_styles.rs b/crates/doc/src/config_styles.rs new file mode 100644 index 00000000..577e5c38 --- /dev/null +++ b/crates/doc/src/config_styles.rs @@ -0,0 +1,191 @@ +//! Stable configuration style targets. Display indices and placement names are never saved. +use super::Column; +use crate::{DocError, Document, FeatureKind, ModelState, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use solvecraft_geom::Vec3; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConfigFaceTarget { + pub body: String, + pub origin_feature: u64, + pub name: String, + /// Definition-frame point for diagnostics and rendering; never a resolver fallback. + pub point: Vec3, +} +impl ConfigFaceTarget { + pub fn key(&self) -> String { + // Length-prefix encoding keeps punctuation in body/name unambiguous. + format!("{}:{}{}", self.body.len(), self.body, self.name) + } + pub fn matches(&self, face: &crate::appearance::FaceLook) -> bool { + face.body == self.body && face.name.as_ref().is_some_and(|name| crate::naming::strip_piece(name) == self.name) + } + pub fn validate(&self) -> Result<()> { + if self.body.is_empty() + || self.body.len() > 128 + || self.name.is_empty() + || self.name.len() > 512 + || self.origin_feature == 0 + || [self.point.x, self.point.y, self.point.z].iter().any(|v| !v.is_finite() || v.abs() > 1e9) + { + return Err(DocError::Invalid("invalid configuration face target".into())); + } + Ok(()) + } +} + +/// Budget malformed style nodes before decoding their typed representation. +pub fn validate_styles_json(value: &Value) -> Result<()> { + if let Some(columns) = value.get("configs").and_then(|v| v.get("columns")).and_then(Value::as_array) { + if columns.len() > super::MAX_COLUMNS { + return Err(DocError::Invalid("too many configuration columns".into())); + } + for column in columns { + if let Some(target) = column.get("face_appearance") { + let fields = target.as_object().ok_or_else(|| DocError::Invalid("face target must be an object".into()))?; + if fields.len() != 4 + || fields.keys().any(|key| !["body", "origin_feature", "name", "point"].contains(&key.as_str())) + || fields.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128) + || fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512) + || fields.get("origin_feature").and_then(Value::as_u64).is_none_or(|id| id == 0) + || fields.get("point").and_then(Value::as_array).is_none_or(|point| { + point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9)) + }) + { + return Err(DocError::Invalid("invalid or oversized configuration face target".into())); + } + } + if let Some(id) = column.get("component_appearance") + && id.as_u64().is_none_or(|id| id == 0) + { + return Err(DocError::Invalid("configuration component ID must be nonzero".into())); + } + } + } + Ok(()) +} + +impl Document { + /// Certify named generator lineage; generic index-derived naming is unsupported. + pub fn certify_config_face(&self, target: &ConfigFaceTarget) -> Result<()> { + target.validate()?; + let prefix = format!("F{}:", target.origin_feature); + let role = target.name.strip_prefix(&prefix).ok_or_else(|| DocError::Invalid("face lineage does not match its feature".into()))?; + if crate::naming::strip_piece(&target.name) != target.name { + return Err(DocError::Invalid("configure a face lineage, not a numbered split piece".into())); + } + let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?; + let valid = match &feature.kind { + FeatureKind::Box { .. } => ["+x", "-x", "+y", "-y", "+z", "-z"].contains(&role), + FeatureKind::Cylinder { .. } => ["top", "bottom", "side"].contains(&role), + // Curved side lineage currently uses centroid/nearest-curve heuristics; + // certify caps only until the naming layer supplies proven curve provenance. + FeatureKind::Extrude { extent, .. } => { + extent.from.is_none() + && extent.start_offset.as_deref().is_none_or(|value| { + crate::expr::is_literal(value) + && crate::expr::eval_with(value, &|name| Err(DocError::Unknown(name.into()))) + .ok() + .and_then(|value| value.to_kind(crate::expr::Kind::Length).ok()) + .is_some_and(|value| value.abs() < 1e-12) + }) + && extent.distance2.is_none() + && ["start", "top", "bottom"].contains(&role) + } + FeatureKind::Revolve { angle2, symmetric, .. } => angle2.is_none() && !symmetric && ["start", "end"].contains(&role), + _ => false, + }; + if !valid { + return Err(DocError::Invalid("this face has no supported stable semantic lineage".into())); + } + Ok(()) + } + /// Verify the current named pieces describe one surface, not ambiguous generated roles. + pub fn configuration_face_indices(&self, body: &crate::ModelBody, target: &ConfigFaceTarget) -> Result> { + self.certify_config_face(target)?; + if body.body.is_mesh() || body.definition_name() != target.body { + return Err(DocError::Invalid("configured face definition does not match".into())); + } + let names = crate::naming::face_names(body); + let indices: Vec = + names.iter().enumerate().filter(|(_, name)| crate::naming::strip_piece(name) == target.name).map(|(index, _)| index).collect(); + if indices.is_empty() { + return Err(DocError::Unknown(format!("configured face `{}` on `{}` no longer exists", target.name, target.body))); + } + if target.name.ends_with(":side") { + if indices.len() != 1 { + return Err(DocError::Invalid("split curved face lineage is not yet certified".into())); + } + return Ok(indices); + } + // Caps and box faces must share an oriented plane. This rejects generic cap-role + // collisions while permitting the coplanar pieces of a split supported face. + let mesh = body.mesh(); + let mut plane: Option<(Vec3, Vec3)> = None; + for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) { + if indices.binary_search(&(*face as usize)).is_err() { + continue; + } + let Some([a, b, c]) = mesh.tri(triangle) else { continue }; + let Some(normal) = (b - a).cross(c - a).normalized() else { continue }; + if let Some((point, base)) = plane { + let magnitude = [point.x, point.y, point.z, a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z] + .iter() + .fold(body.body.size(), |scale, value| scale.max(value.abs())); + let tolerance = (magnitude * f64::EPSILON * 128.0).max(1e-7); + if normal.dot(base) < 1.0 - 1e-6 || [a, b, c].iter().any(|vertex| (*vertex - point).dot(base).abs() > tolerance) { + return Err(DocError::Invalid("face lineage names more than one oriented surface".into())); + } + } else { + plane = Some((a, normal)); + } + } + if plane.is_none() { + return Err(DocError::Invalid("configured face has no valid surface".into())); + } + Ok(indices) + } + pub fn config_face_look(&self, target: &ConfigFaceTarget) -> Option { + self.appearances.faces.iter().rev().find(|face| target.matches(face)).map(|face| face.look.clone()) + } + /// Validate against the final rebuilt definition state. Missing names never pick nearby faces. + pub fn configuration_style_status(&self, state: &ModelState) -> Result> { + let mut result = Vec::new(); + for column in &self.configs.columns { + match column { + Column::ComponentAppearance(id) => { + let component = + self.components.iter().find(|c| c.id == *id).ok_or_else(|| DocError::Unknown(format!("component {id} is gone")))?; + result.push(serde_json::json!({"key":self.config_key(column),"target":component.name,"shared":true,"dormant":false})); + } + Column::FaceAppearance(target) => { + self.certify_config_face(target)?; + let dormant = match state.body(&target.body) { + None if self.feature(target.origin_feature).is_some_and(|feature| feature.suppressed) => true, + None => return Err(DocError::Unknown(format!("configured face body `{}` is gone", target.body))), + Some(body) => { + self.configuration_face_indices(body, target)?; + false + } + }; + result.push(serde_json::json!({"key":self.config_key(column),"target":format!("{} / {}",target.body,target.name),"shared":true,"dormant":dormant})); + } + _ => {} + } + } + Ok(result) + } + /// Maintain columns and row cell alignment when component definitions are deleted. + pub fn remove_configuration_components(&mut self, ids: &[u64]) { + let keep: Vec = self.configs.columns.iter().map(|c| !matches!(c,Column::ComponentAppearance(id) if ids.contains(id))).collect(); + let mut mask = keep.iter(); + self.configs.columns.retain(|_| mask.next().copied().unwrap_or(false)); + for row in &mut self.configs.rows { + let mut mask = keep.iter(); + row.cells.retain(|_| mask.next().copied().unwrap_or(false)); + } + self.appearances.components.retain(|id, _| !ids.contains(id)); + } +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index f9085052..3ed7742a 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -161,6 +161,85 @@ pub enum AxisRef { Line { origin: Vec3, dir: Vec3 }, } +/// Persistent cylindrical Emboss selection, authored in the feature component frame. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EmbossFace { + pub body: String, + pub at: Vec3, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub path: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub author_path: Vec, +} + +impl EmbossFace { + /// The selected sketch placement, shared by evaluation and interactive guides. + pub fn sketch_frame(&self, doc: &Document, author: u64, sketch_owner: u64, body_feature: u64) -> Result { + let owner = doc.body_component(&self.body, body_feature); + if sketch_owner == owner { + return Self::component_frame(doc, owner, &self.path); + } + if sketch_owner == author { + return Self::component_frame(doc, author, &self.author_path); + } + let path = doc.first_path(sketch_owner); + let mut component = sketch_owner; + for _ in 0..64 { + if component == 0 { + break; + } + let mut occurrences = doc.occurrences.iter().filter(|o| o.component == component); + let occurrence = occurrences.next().ok_or_else(|| DocError::Invalid("sketch placement no longer exists".into()))?; + if occurrences.next().is_some() { + return Err(DocError::Invalid("sketch placement is ambiguous".into())); + } + component = occurrence.parent; + } + if component != 0 { + return Err(DocError::Invalid("sketch placement exceeds depth limit".into())); + } + Self::component_frame(doc, sketch_owner, &path) + } + + /// Resolve this exact placement without expanding every assembly instance. + pub fn body_frame(&self, doc: &Document, feature: u64) -> Result { + let owner = doc.body_component(&self.body, feature); + let mut frame = Self::component_frame(doc, owner, &self.path)?; + if let Some(offset) = doc.body_offsets.get(&self.body) { + frame = crate::mat_mul(&frame, offset); + } + Ok(frame) + } + + /// Resolve one saved component path without expanding other occurrences. + pub fn component_frame(doc: &Document, owner: u64, path: &[u64]) -> Result { + if path.len() > 64 || doc.occurrences.len() > 100_000 { + return Err(DocError::Invalid("selected cylinder path exceeds work limits".into())); + } + let mut frame = crate::IDENTITY; + let mut component = 0; + let mut seen = std::collections::BTreeSet::new(); + for id in path { + if *id == 0 || !seen.insert(*id) { + return Err(DocError::Invalid("invalid selected cylinder path".into())); + } + let occurrence = doc + .occurrences + .iter() + .find(|o| o.id == *id && o.parent == component) + .ok_or_else(|| DocError::Invalid("selected cylinder placement no longer exists".into()))?; + frame = crate::mat_mul(&frame, &occurrence.transform); + component = occurrence.component; + } + if path.is_empty() { + frame = doc.component_transform(owner); + } else if component != owner { + return Err(DocError::Invalid("selected cylinder path belongs to another component".into())); + } + Ok(frame) + } +} + /// Feature definitions. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -437,13 +516,15 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Vec::is_empty")] targets: Vec, }, - /// Raise (emboss) or sink (deboss) sketch profiles on the planar face they lie on. + /// Raise or sink profiles on a planar face, or wrap them on a selected cylindrical wall. Emboss { sketch: u64, profiles: ProfileSel, depth: String, #[serde(default, skip_serializing_if = "std::ops::Not::not")] deboss: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + face: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] targets: Vec, }, @@ -1220,6 +1301,7 @@ impl FeatureKind { FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(), FeatureKind::Revolve { to: Some(p), .. } => vec![p], FeatureKind::Thread { face, .. } => vec![face], + FeatureKind::Emboss { face: Some(face), .. } => vec![&mut face.at], FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position], _ => Vec::new(), } @@ -1255,6 +1337,9 @@ impl FeatureKind { if let Some(d) = &extent.taper { v.push(d); } + if let Some(d) = &extent.to_offset { + v.push(d); + } } FeatureKind::Revolve { angle, angle2, .. } => { v.push(angle); @@ -1595,17 +1680,12 @@ impl Document { let mut st = state.clone(); for b in &mut st.bodies { let c = self.body_color(b); - let look = self.model_body_look(b); let faces: Vec = self .face_colors(b) .into_iter() .map(|(face, l)| solvecraft_kernel::FacePaint { face, color: l.rgb(), opacity: l.opacity as f32 }) .collect(); - let opacity = look - .map(|l| l.opacity as f32) - .or_else(|| b.body.paint().map(|p| p.opacity)) - .or_else(|| b.imported.as_ref().and_then(|i| i.opacity)) - .unwrap_or(1.0); + let opacity = self.model_body_opacity(b) as f32; let paint = solvecraft_kernel::Paint { opacity, faces }; if c != b.body.color() || b.body.paint() != Some(&paint) { b.body = b.body.clone().with_color(c).with_paint(Some(paint)); @@ -1752,9 +1832,13 @@ impl Document { .map(|c| match c { crate::config::Column::Suppress(id) => !gone.contains(id), crate::config::Column::Param(name) => !gone_params.contains(name), + crate::config::Column::FaceAppearance(target) => !gone.contains(&target.origin_feature), _ => true, }) .collect(); + self.appearances + .faces + .retain(|face| !face.strict_name || !face.name.as_ref().is_some_and(|name| gone.iter().any(|id| name.starts_with(&format!("F{id}:"))))); if keep.contains(&false) { let mut k = keep.iter(); self.configs.columns.retain(|_| k.next().copied().unwrap_or(true)); diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index bfd25e24..626b7229 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -632,7 +632,10 @@ impl Model { let mut warning = None; // Face references by name: move each face point onto its named face. let renamed; - let f = if f.face_names.iter().any(|n| !n.is_empty()) { + // Cylindrical emboss resolves its named face in the selected body's frame + // and certifies an analytic anchor; mesh projection here would undo that. + let explicit_emboss = matches!(&f.kind, FeatureKind::Emboss { face: Some(_), .. }); + let f = if !explicit_emboss && f.face_names.iter().any(|n| !n.is_empty()) { let mut g = f.clone(); let mut notes = Vec::new(); for (p, n) in g.kind.face_points_mut().into_iter().zip(&f.face_names) { @@ -785,6 +788,11 @@ fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String { } } +fn copy_body_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> String { + let base = f.body_names.get(k).filter(|name| !name.trim().is_empty()).map(String::as_str).unwrap_or(source); + unique_body_name(state, base) +} + /// Add tool bodies to the model according to the operation. fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation, targets: &[String]) -> Result<()> { // Participants: the feature's own targets, else its explicit participants, else the bodies @@ -876,7 +884,7 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation } /// Distance that reaches through every target body from the sketch plane (either side). -fn through_all_distance(st: &ModelState, plane: &Plane, targets: &[String]) -> Result { +pub fn through_all_distance(st: &ModelState, plane: &Plane, targets: &[String]) -> Result { let mut far: f64 = 0.0; let mut any = false; for b in st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)) { @@ -1415,11 +1423,12 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - if extent.distance2.is_some() || extent.start_offset.is_some() || extent.direction == crate::Direction::Symmetric { return Err(DocError::Invalid("not supported yet: taper with two-sided, symmetric or offset extents".into())); } - let sign = if extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 }; + let sign = if extent.to.is_none() && extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 }; let (len, sign) = if d < 0.0 { (-d, -sign) } else { (d, sign) }; + let start_plane = ss.plane.offset(from); return Ok(regions .iter() - .map(|r| kernel::extrude_tapered(&ss.plane, r, len, sign, taper)) + .map(|r| kernel::extrude_tapered(&start_plane, r, len, sign, taper)) .collect::, _>>()?); } let off = from @@ -2092,7 +2101,14 @@ fn replay( } } // New bodies from a pattern join their source when they touch it (as one feature would). - apply_op(st, f, copies, op, &targets)?; + if f.body_names.is_empty() { + apply_op(st, f, copies, op, &targets)?; + } else { + let start = st.bodies.iter().filter(|b| b.feature == f.id).count(); + let mut named = f.clone(); + named.body_names = f.body_names.iter().skip(start).cloned().collect(); + apply_op(st, &named, copies, op, &targets)?; + } } Ok(()) } @@ -2105,7 +2121,7 @@ mod surface; /// Height above `plane` (along its normal) of the object at `p` as seen from `centre`: the face /// there if it is planar and parallel (its plane), else where a ray from `centre` along the /// normal meets the body there, else the point itself. -fn object_height(st: &ModelState, plane: &Plane, centre: Vec3, p: Vec3) -> f64 { +pub fn object_height(st: &ModelState, plane: &Plane, centre: Vec3, p: Vec3) -> f64 { let n = plane.normal(); let h = |q: Vec3| (q - plane.origin).dot(n); for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) { @@ -2367,7 +2383,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } doc.apply_dimension_values(vals, &mut sk)?; let mut report = solve(&mut sk); - if !sk.offsets.is_empty() { + if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() { // Source dimensions may have moved curves during the solve. sk.refresh_offsets()?; report = solve(&mut sk); @@ -2626,12 +2642,14 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: return replay(doc, vals, f, st, features, &mats, warning); } // Bodies: each copy is a new body named after its source. + let mut output = 0; for n in bodies { let mb = st.body(n).cloned().ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?; for m in &mats { let copy = kernel::transform_matrix(&mb.body, *m)?; - let name = unique_body_name(st, &mb.name); + let name = copy_body_name(st, f, output, &mb.name); st.bodies.push(ModelBody::new(name, copy, f.id)); + output += 1; } } Ok(()) @@ -2642,6 +2660,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: return replay(doc, vals, f, st, features, &[mirror_matrix(&pl)], warning); } let m = mirror_matrix(&pl); + let mut output = 0; for n in bodies { let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?; let Some(mb) = st.bodies.get(i).cloned() else { continue }; @@ -2655,13 +2674,15 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: *slot = ModelBody::new(mb.name.clone(), p, mb.feature); } } else { - let name = unique_body_name(st, &mb.name); + let name = copy_body_name(st, f, output, &mb.name); st.bodies.push(ModelBody::new(name, p, f.id)); + output += 1; } } } else { - let name = unique_body_name(st, &mb.name); + let name = copy_body_name(st, f, output, &mb.name); st.bodies.push(ModelBody::new(name, mirrored, f.id)); + output += 1; } } Ok(()) @@ -2857,8 +2878,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: Ok(()) } FeatureKind::MeshImport { meshes, .. } => { - for (name, b) in mesh_bodies(meshes)? { - let name = unique_body_name(st, &name); + for (k, (name, b)) in mesh_bodies(meshes)?.into_iter().enumerate() { + let name = copy_body_name(st, f, k, &name); st.bodies.push(ModelBody::new(name, b, f.id)); } Ok(()) diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 4c7a0ec8..3c8e5f73 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -2,18 +2,65 @@ //! kernel's extrude, boolean, offset and transform operations). use super::*; +use crate::EmbossFace; use crate::apply_point; use solvecraft_geom::{Loop2, Seg2}; pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { match &f.kind { - FeatureKind::Emboss { sketch, profiles, depth, deboss, targets } => { + FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face } => { let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?; let regions = select_profiles(ss, profiles)?; let d = val(vals, depth, Kind::Length)?; if !(d > 0.0) { return Err(DocError::Invalid("the emboss depth must be positive".into())); } + if let Some(face) = face { + if !targets.is_empty() && (targets.len() != 1 || targets.first() != Some(&face.body)) { + return Err(DocError::Invalid("cylinder emboss must keep its selected target body".into())); + } + let i = + st.bodies.iter().position(|b| b.name == face.body).ok_or_else(|| DocError::Unknown(format!("emboss body `{}`", face.body)))?; + let mb = st.bodies.get(i).cloned().ok_or_else(|| DocError::Invalid("emboss body".into()))?; + let body_frame = face.body_frame(doc, mb.feature)?; + let inverse = crate::mat_inverse(&body_frame).ok_or_else(|| DocError::Invalid("invalid cylinder body frame".into()))?; + let sketch_owner = doc.feature(ss.feature).map(|f| f.component).unwrap_or(0); + let author_frame = EmbossFace::component_frame(doc, f.component, &face.author_path)?; + let sketch_frame = face.sketch_frame(doc, f.component, sketch_owner, mb.feature)?; + let sketch_transform = crate::mat_mul(&inverse, &sketch_frame); + let plane = Plane::new( + crate::apply_point(&sketch_transform, ss.plane.origin), + crate::apply_vector(&sketch_transform, ss.plane.x), + crate::apply_vector(&sketch_transform, ss.plane.y), + ) + .ok_or_else(|| DocError::Invalid("invalid cylinder sketch frame".into()))?; + let mut at = crate::apply_point(&crate::mat_mul(&inverse, &author_frame), face.at); + let name = f + .face_names + .first() + .filter(|name| !name.is_empty()) + .ok_or_else(|| DocError::Invalid("selected cylinder face has no persistent identity".into()))?; + { + let mut scoped = ModelState::default(); + scoped.bodies.push(mb.clone()); + let (picked, split) = crate::naming::point_on_face(&scoped, name, at) + .ok_or_else(|| DocError::Invalid("selected cylinder face no longer exists".into()))?; + if split { + return Err(DocError::Invalid("selected cylinder face was split".into())); + } + let (index, _, _) = crate::project::nearest_face(&mb.mesh(), picked) + .ok_or_else(|| DocError::Invalid("selected cylinder face cannot be resolved".into()))?; + if crate::naming::face_names(&mb).get(index as usize) != Some(name) { + return Err(DocError::Invalid("selected cylinder face is ambiguous".into())); + } + at = kernel::cylinder_emboss_anchor(&mb.body, index as usize, picked)?; + } + let result = kernel::emboss_cylinder_at(&mb.body, at, &plane, ®ions, if *deboss { -d } else { d })?; + if let Some(slot) = st.bodies.get_mut(i) { + *slot = ModelBody::new(mb.name, result, mb.feature); + } + return Ok(()); + } let (a, b) = if *deboss { (-d, 0.0) } else { (0.0, d) }; let tools = kernel::extrude(&ss.plane, ®ions, a, b)?; if st.bodies.is_empty() { diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 2ee77cfe..9017fb7e 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -25,13 +25,16 @@ //! instead of encountering an unknown column variant. //! - 8: ordered configuration rules and persistent signed line-angle, arc-sweep and slot //! dimensions. Version 7 geometry and table values retain their meaning without changes. +//! - 9: configured component/semantic-face styles, associative free-form offsets and +//! explicit cylindrical emboss targets. Existing version 8 geometry and rules are +//! preserved; new fields use defaults rather than guessing a target or an association. use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 8; +pub const FORMAT: u32 = 9; pub fn format_string(n: u32) -> String { format!("solvecraft/{n}") @@ -44,7 +47,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; 7] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(())]; +const JSON_STEPS: [JsonStep; 8] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| 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, @@ -136,6 +139,9 @@ pub fn read(s: &str) -> Result { } check_numbers(&v, 0)?; crate::config::validate_rules_json(&v)?; + crate::config::validate_styles_json(&v)?; + validate_emboss_targets(&v)?; + validate_freeform_offsets(&v)?; for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) { step(&mut v)?; } @@ -151,12 +157,125 @@ pub fn read(s: &str) -> Result { return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name))); } } + for feature in &d.features { + if let crate::FeatureKind::Sketch { sketch, .. } = &feature.kind { + sketch.validate_freeform_offsets().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?; + } + } // Fill in what older designs (and hand-edited files) may lack; a no-op otherwise. normalise(&mut d)?; d.format = format_string(FORMAT); Ok(d) } +/// Validate new explicit targets before typed decoding. Older planar features have none. +fn validate_emboss_targets(value: &Value) -> Result<()> { + let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) }; + if features.len() > crate::document::MAX_FEATURES { + return Err(DocError::Invalid("document too large".into())); + } + for feature in features { + if feature.get("type").and_then(Value::as_str) != Some("emboss") { + continue; + } + let Some(target) = feature.get("face").filter(|target| !target.is_null()) else { continue }; + let invalid = || DocError::Invalid("damaged design: an emboss face needs a bounded body name and three finite coordinates".into()); + let object = target.as_object().ok_or_else(invalid)?; + if object.keys().any(|key| !matches!(key.as_str(), "body" | "at" | "path" | "author_path")) { + return Err(invalid()); + } + let body = object.get("body").and_then(Value::as_str).ok_or_else(invalid)?; + let point = object.get("at").and_then(Value::as_array).ok_or_else(invalid)?; + if body.trim().is_empty() + || body.len() > 512 + || body.chars().any(char::is_control) + || point.len() != 3 + || point.iter().any(|coordinate| coordinate.as_f64().is_none_or(|number| !number.is_finite() || number.abs() > MAX_REAL)) + { + return Err(invalid()); + } + for path in ["path", "author_path"].into_iter().filter_map(|key| object.get(key)) { + let path = path.as_array().ok_or_else(invalid)?; + if path.len() > 64 { + return Err(invalid()); + } + let mut ids = std::collections::BTreeSet::new(); + for id in path { + let id = id.as_u64().filter(|id| *id != 0).ok_or_else(invalid)?; + if !ids.insert(id) { + 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 { + if feature.get("type").and_then(Value::as_str) != Some("sketch") { + continue; + } + let Some(records) = feature.get("sketch").and_then(|sketch| sketch.get("freeform_offsets")) else { continue }; + let invalid = || DocError::Invalid("damaged design: invalid free-form offset metadata".into()); + let records = records.as_array().ok_or_else(invalid)?; + if records.len() > 128 { + return Err(invalid()); + } + let mut outputs = std::collections::BTreeSet::new(); + for record in records { + let object = record.as_object().ok_or_else(invalid)?; + if object.len() != 9 + || object.keys().any(|key| { + !matches!( + key.as_str(), + "source" | "output" | "forward" | "distance" | "divisions" | "pieces" | "closed" | "tolerance" | "certificate" + ) + }) + { + return Err(invalid()); + } + let source = record.get("source").and_then(Value::as_str).ok_or_else(invalid)?; + let output = record.get("output").and_then(Value::as_str).ok_or_else(invalid)?; + if [source, output].iter().any(|id| id.is_empty() || id.len() > 512 || id.chars().any(char::is_control)) + || source == output + || !outputs.insert(output) + { + return Err(invalid()); + } + let divisions = record.get("divisions").and_then(Value::as_u64).ok_or_else(invalid)?; + let pieces = record.get("pieces").and_then(Value::as_u64).ok_or_else(invalid)?; + let distance = record.get("distance").and_then(Value::as_f64).ok_or_else(invalid)?; + let tolerance = record.get("tolerance").and_then(Value::as_f64).ok_or_else(invalid)?; + if !(1..=256).contains(&divisions) + || !divisions.is_power_of_two() + || !(1..=256).contains(&pieces) + || pieces * divisions >= solvecraft_sketch::MAX_SPLINE_POINTS as u64 + || !distance.is_finite() + || !(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) + || record.get("forward").and_then(Value::as_bool).is_none() + || record.get("closed").and_then(Value::as_bool).is_none() + { + return Err(invalid()); + } + } + let mut resolved = std::collections::BTreeSet::new(); + for record in records { + let source = record.get("source").and_then(Value::as_str).ok_or_else(invalid)?; + let output = record.get("output").and_then(Value::as_str).ok_or_else(invalid)?; + if outputs.contains(source) && !resolved.contains(source) { + return Err(invalid()); + } + resolved.insert(output); + } + } + Ok(()) +} + /// Largest coordinate or size a design may hold (mm): a kilometre-scale part is already far /// outside what the kernel's tolerances serve. const MAX_REAL: f64 = 1e9; @@ -216,6 +335,173 @@ fn normalise(d: &mut Document) -> Result<()> { mod tests { use super::*; + #[test] + fn version_eight_to_object_offset_keeps_existing_model_parameter_names() { + use crate::{Direction, Extent, FeatureKind, Operation, PlaneRef, ProfileSel}; + let mut document = Document::new("legacy named extent"); + document.set_param("offset", "2", Some("mm"), None).unwrap(); + let sketch = document + .add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: solvecraft_sketch::Sketch::new() }, None) + .unwrap(); + document + .add_feature( + FeatureKind::Extrude { + sketch, + profiles: ProfileSel::All, + extent: Extent { + distance: "10".into(), + direction: Direction::Negative, + distance2: None, + start_offset: None, + through_all: false, + taper: Some("0 deg".into()), + to: Some(solvecraft_geom::Vec3::new(0.0, 0.0, 10.0)), + to_offset: Some("offset + 1 mm".into()), + from: Some(solvecraft_geom::Vec3::new(0.0, 0.0, 5.0)), + }, + operation: Operation::NewBody, + targets: vec![], + }, + None, + ) + .unwrap(); + document + .add_feature( + FeatureKind::Box { + corner: ["0".into(), "0".into(), "0".into()], + length: "d1".into(), + width: "4".into(), + height: "6".into(), + operation: Operation::NewBody, + }, + None, + ) + .unwrap(); + let mut legacy: Value = serde_json::from_str(&document.to_json()).unwrap(); + legacy["format"] = Value::from("solvecraft/8"); + // Version 8 exposed distance and taper, but had no model slot for To Offset. + legacy["features"][1]["param_names"] = serde_json::json!(["d1", "d2"]); + legacy["features"][2]["param_names"] = serde_json::json!(["d3", "d4", "d5"]); + let old_kind = legacy["features"][1].clone(); + let upgraded = read(&legacy.to_string()).unwrap(); + assert_eq!(&upgraded.features[1].param_names[..2], ["d1", "d2"]); + assert_eq!(upgraded.features[2].param_names, ["d3", "d4", "d5"]); + assert_eq!(upgraded.features[1].param_names.len(), 3); + assert_eq!(upgraded.features[1].param_names[2], "d6"); + let saved: Value = serde_json::from_str(&upgraded.to_json()).unwrap(); + for field in ["extent", "sketch", "operation", "profiles"] { + assert_eq!(saved["features"][1][field], old_kind[field]); + } + let (values, errors) = upgraded.param_values(); + assert!(errors.is_empty(), "{errors:?}"); + assert_eq!(values.get("d1").unwrap().v, 10.0); + assert_eq!(values.get("d3").unwrap().v, 10.0); + assert_eq!(values.get("d6").unwrap().v, 3.0); + assert_eq!(read(&upgraded.to_json()).unwrap(), upgraded); + } + + #[test] + fn saved_offset_cannot_alias_and_overwrite_its_source_geometry() { + use solvecraft_geom::Vec2; + use solvecraft_sketch::{CurveKind, Sketch}; + let mut sketch = Sketch::new(); + let center = sketch.add_point(Vec2::ZERO, None).unwrap(); + let major = sketch.add_point(Vec2::new(10.0, 0.0), None).unwrap(); + let source = sketch.add_curve(CurveKind::Ellipse { c: center, m: major, r: 5.0 }, None).unwrap(); + let output = sketch.add_freeform_offset(source, true, -1.0).unwrap(); + assert!(sketch.validate_freeform_offsets().is_ok()); + if let CurveKind::Spline { pts, .. } = &mut sketch.curves[output].kind { + pts[0] = center; + } else { + panic!("offset must produce a fit spline"); + } + let mut document = Document::new("aliased offset geometry"); + document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap(); + let error = read(&document.to_json()).unwrap_err().to_string(); + assert!(error.contains("damaged design"), "{error}"); + } + + #[test] + fn structurally_valid_offset_records_must_reference_real_sketch_geometry() { + let mut document = Document::new("missing offset geometry"); + document + .add_feature( + crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch: solvecraft_sketch::Sketch::new() }, + None, + ) + .unwrap(); + let mut value: Value = serde_json::from_str(&document.to_json()).unwrap(); + value["features"][0]["sketch"]["freeform_offsets"] = serde_json::json!([{ + "source":"ellipse", "output":"offset", "forward":true, "distance":2.0, + "divisions":8, "pieces":4, "closed":true, "tolerance":0.002, "certificate":1 + }]); + assert!(validate_freeform_offsets(&value).is_ok()); + let error = read(&value.to_string()).unwrap_err().to_string(); + assert!(error.contains("damaged design"), "{error}"); + } + + #[test] + fn freeform_offset_metadata_is_bounded_before_typed_decoding() { + let valid = serde_json::json!({"source":"ellipse", "output":"offset", "forward":true, + "distance":2.0, "divisions":8, "pieces":4, "closed":true, + "tolerance":0.002, "certificate":1}); + let mut malformed = Vec::new(); + for (key, value) in [ + ("divisions", serde_json::json!(u64::MAX)), + ("pieces", serde_json::json!(u64::MAX)), + ("divisions", serde_json::json!(3)), + ("distance", serde_json::json!(0)), + ("tolerance", serde_json::json!(0.003)), + ("certificate", serde_json::json!(2)), + ("output", serde_json::json!("x".repeat(513))), + ("source", serde_json::json!("offset")), + ("unknown", serde_json::json!(true)), + ] { + let mut record = valid.clone(); + record[key] = value; + malformed.push(serde_json::json!([record])); + } + malformed.push(serde_json::json!(vec![valid.clone(); 129])); + malformed.push(serde_json::json!([valid.clone(), valid.clone()])); + let mut cycle = valid.clone(); + cycle["source"] = serde_json::json!("offset"); + cycle["output"] = serde_json::json!("ellipse"); + malformed.push(serde_json::json!([valid, cycle])); + for records in malformed { + let mut value: Value = serde_json::from_str(&Document::new("hostile offset").to_json()).unwrap(); + value["features"] = serde_json::json!([{"type":"sketch", "sketch":{"freeform_offsets":records}}]); + let error = read(&value.to_string()).unwrap_err().to_string(); + assert!(error.contains("free-form offset metadata"), "{error}"); + value["format"] = Value::from(format_string(FORMAT + 1)); + assert!(read(&value.to_string()).unwrap_err().to_string().contains("newer SolveCraft")); + } + } + + #[test] + fn explicit_emboss_target_bounds_precede_typed_decoding() { + for target in [ + serde_json::json!({"body":"", "at":[0,0,0]}), + serde_json::json!({"body":"Body", "at":[0,0]}), + serde_json::json!({"body":"Body", "at":[0,"invalid",0]}), + serde_json::json!({"body":"Body", "at":[0,0,0], "unknown":true}), + serde_json::json!({"body":"x".repeat(513), "at":[0,0,0]}), + serde_json::json!({"body":"Body", "at":[0,0,0], "path":"bad"}), + serde_json::json!({"body":"Body", "at":[0,0,0], "path":[0]}), + serde_json::json!({"body":"Body", "at":[0,0,0], "path":[1,1]}), + serde_json::json!({"body":"Body", "at":[0,0,0], "path":(1..=65).collect::>()}), + serde_json::json!({"body":"Body", "at":[0,0,0], "author_path":[1,1]}), + serde_json::json!({"body":"Body", "at":[0,0,0], "author_path":(1..=65).collect::>()}), + ] { + let mut value: Value = serde_json::from_str(&Document::new("hostile target").to_json()).unwrap(); + value["features"] = serde_json::json!([{"type":"emboss", "face":target}]); + let error = read(&value.to_string()).unwrap_err().to_string(); + assert!(error.contains("emboss face"), "{error}"); + value["format"] = Value::from(format_string(FORMAT + 1)); + let error = read(&value.to_string()).unwrap_err().to_string(); + assert!(error.contains("newer SolveCraft"), "{error}"); + } + } + #[test] fn version_six_configuration_rows_upgrade_without_changing_values_or_features() { use crate::config::{Column, ConfigRow}; @@ -269,7 +555,7 @@ mod tests { let roundtrip = read(&value.to_string()).unwrap(); assert_eq!(roundtrip, d); assert_eq!(serde_json::to_value(&roundtrip.configs).unwrap(), original_table); - assert_eq!(roundtrip.format, "solvecraft/8"); + assert_eq!(roundtrip.format, format_string(FORMAT)); assert!(roundtrip.configuration_matches()); value["format"] = Value::from(format_string(FORMAT + 1)); value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]); @@ -322,7 +608,7 @@ mod tests { sketch.add_constraint(ConstraintKind::SlotOverallLength { a, b, value: 10.0 }, Some(overall)).unwrap(); d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap(); let mut saved: Value = serde_json::from_str(&d.to_json()).unwrap(); - assert_eq!(saved["format"], "solvecraft/8"); + assert_eq!(saved["format"], format_string(FORMAT)); assert_eq!(read(&saved.to_string()).unwrap(), d); saved["format"] = Value::from(format_string(FORMAT + 1)); saved["features"][0]["sketch"]["constraints"][0]["type"] = Value::from("future_dimension"); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index be6802b2..248942da 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -9,6 +9,7 @@ pub mod appearance; mod assembly; pub mod canvas; mod clipboard; +pub use clipboard::{clone_expression, remap_topology}; pub mod config; pub mod construct; mod document; @@ -28,8 +29,8 @@ pub mod sheet; pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse, mat_mul, rigid}; pub use document::*; pub use eval::{ - EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices, - state_in_frame, state_in_frame_at, world_state, + EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, object_height, parse_metric_thread, + pattern_matrices, state_in_frame, state_in_frame_at, through_all_distance, world_state, }; pub use params::{ParamRow, ParamUser, cycles}; pub use solvecraft_kernel as kernel; diff --git a/crates/doc/src/naming.rs b/crates/doc/src/naming.rs index a60c1478..d2c92816 100644 --- a/crates/doc/src/naming.rs +++ b/crates/doc/src/naming.rs @@ -195,11 +195,22 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec { || xb.max.z < bb.min.z - tol || xb.min.z > bb.max.z + tol) }; + // Independently created solids own their faces even when another body + // occupies the same coordinates. Boolean operations still inherit inputs. + let independent_solid = matches!( + o.feature.kind, + FeatureKind::Box { operation: crate::Operation::NewBody, .. } + | FeatureKind::Cylinder { operation: crate::Operation::NewBody, .. } + | FeatureKind::Sphere { operation: crate::Operation::NewBody, .. } + | FeatureKind::Torus { operation: crate::Operation::NewBody, .. } + | FeatureKind::Extrude { operation: crate::Operation::NewBody, .. } + | FeatureKind::Revolve { operation: crate::Operation::NewBody, .. } + ); let inputs: Vec<(&ModelBody, FaceIndex, Arc>)> = o .before .bodies .iter() - .filter(|x| !x.body.is_mesh() && overlaps(x)) + .filter(|x| !independent_solid && !x.body.is_mesh() && overlaps(x)) .take(64) .map(|x| { let n = face_count(x); diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index e7ce630d..fde2cebe 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -65,6 +65,9 @@ impl FeatureKind { if let Some(d) = &mut extent.taper { v.push(("Taper angle", d, A)); } + if let Some(d) = &mut extent.to_offset { + v.push(("To offset", d, L)); + } } FeatureKind::Revolve { angle, angle2, .. } => { v.push(("Angle", angle, A)); diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 008dc4ac..13da3a96 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -486,6 +486,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result { solvecraft_doc::config::Column::Material(body) | solvecraft_doc::config::Column::Appearance(body) if *body == old => { *body = name.to_string(); } + solvecraft_doc::config::Column::FaceAppearance(target) if target.body == old => { + target.body = name.to_string(); + } _ => {} } } diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index f7455944..cbeaabd7 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -41,7 +41,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).params("sketches: [ids or names], component"), CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence) .params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]. Copies the selected effective transform, including pending moves"), - CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: copied with its own features into a new, independent component; occurrence?, path?: selected source placement; translate?: [x,y,z]"), + CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"), CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence) .params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position). Nested occurrence definitions remain shared"), CommandSpec::new("occurrence.ground", "Ground", ground).params("occurrence; grounded?: bool (default toggles)"), @@ -82,7 +82,7 @@ fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) Ok(v) } -fn valid_position(m: &Mat) -> bool { +pub(super) fn valid_position(m: &Mat) -> bool { let column = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]); let (x, y, z) = (column(0), column(1), column(2)); m.iter().flatten().all(|x| x.is_finite()) @@ -229,6 +229,7 @@ fn delete(s: &mut Session, p: &Value) -> Result { let _ = d.delete_feature(*f); } d.components.retain(|c| !gone.contains(&c.id)); + d.remove_configuration_components(&gone); d.occurrences.retain(|o| !gone.contains(&o.component) && !gone.contains(&o.parent)); let moved_out: Vec = d.body_components.iter().filter(|(_, c)| gone.contains(c)).map(|(b, _)| b.clone()).collect(); d.body_components.retain(|_, c| !gone.contains(c)); @@ -351,65 +352,7 @@ fn paste_new(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "copy transform exceeds supported numeric range")); } let parent = occ.as_ref().map(|o| o.parent).unwrap_or(src.parent); - let new_id = s.doc_mut().add_component(Some(&format!("{} (copy)", src.name)), parent)?; - // Copy the component's features with fresh ids and names; references between them follow. - let feats: Vec = s.doc.features.iter().filter(|f| f.component == comp).cloned().collect(); - let mut idmap: std::collections::HashMap = Default::default(); - let mut namemap: std::collections::HashMap = Default::default(); - let mut new_ids = Vec::new(); - for f in &feats { - let nid = s.doc_mut().add_feature(f.kind.clone(), Some(&format!("{} (copy)", f.name)))?; - idmap.insert(f.id, nid); - let nname = s.doc.feature(nid).map(|x| x.name.clone()).unwrap_or_default(); - namemap.insert(f.name.clone(), nname); - new_ids.push(nid); - } - for nid in &new_ids { - let Some(f) = s.doc.feature(*nid).cloned() else { continue }; - let mut v = serde_json::to_value(&f.kind).map_err(|e| bad(cmd, e.to_string()))?; - remap(&mut v, &idmap, &namemap, 0); - let kind: FeatureKind = serde_json::from_value(v).map_err(|e| bad(cmd, e.to_string()))?; - if let Some(slot) = s.doc_mut().feature_mut(*nid) { - slot.kind = kind; - slot.component = new_id; - } - } - if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.component == new_id) { - o.transform = transform; - } - Ok(json!({"component": new_id, "features": new_ids})) -} - -/// Point copied features at their copied sketches and features. -fn remap(v: &mut Value, ids: &std::collections::HashMap, names: &std::collections::HashMap, depth: usize) { - if depth > 32 { - return; - } - match v { - Value::Object(o) => { - for (k, x) in o.iter_mut() { - match (k.as_str(), &*x) { - ("sketch" | "path_sketch", Value::Number(n)) => { - if let Some(m) = n.as_u64().and_then(|i| ids.get(&i)) { - *x = json!(m); - } - } - ("features", Value::Array(_)) => { - if let Value::Array(a) = x { - for e in a.iter_mut() { - if let Some(m) = e.as_str().and_then(|n| names.get(n)) { - *e = json!(m); - } - } - } - } - _ => remap(x, ids, names, depth + 1), - } - } - } - Value::Array(a) => a.iter_mut().for_each(|x| remap(x, ids, names, depth + 1)), - _ => {} - } + super::component_clone::paste(s, comp, occ, transform, parent, p) } pub(super) fn occurrence_path(s: &Session, p: &Value, id: u64, cmd: &str) -> Result> { diff --git a/crates/engine/src/cmd/component_clone.rs b/crates/engine/src/cmd/component_clone.rs new file mode 100644 index 00000000..5acf8d5d --- /dev/null +++ b/crates/engine/src/cmd/component_clone.rs @@ -0,0 +1,579 @@ +//! Independent copies of a component definition graph, assembled and evaluated atomically. +use std::collections::{BTreeMap, BTreeSet}; + +use crate::params::bad; +use crate::{Result, Sel, Session}; +use serde_json::{Value, json}; +use solvecraft_doc::{Document, Feature, FeatureKind, Mat, Occurrence}; + +const CMD: &str = "component.paste_new"; +const MAX_WORK: usize = 10_000; + +fn cloned_path(source: &Document, path: &[u64], root: u64, destination: &[u64], occurrences: &BTreeMap) -> Result> { + let start = path + .iter() + .position(|id| source.occurrences.iter().any(|o| o.id == *id && o.component == root)) + .ok_or_else(|| bad(CMD, "copied Emboss placement is outside the copied component tree"))?; + let mut mapped = destination.to_vec(); + for id in path.iter().skip(start + 1) { + mapped.push(*occurrences.get(id).ok_or_else(|| bad(CMD, "copied Emboss path has an external descendant"))?); + } + if mapped.len() > 64 { + return Err(bad(CMD, "copied Emboss path exceeds placement depth limit")); + } + Ok(mapped) +} + +fn unique(base: &str, used: &mut BTreeSet) -> String { + let stem = format!("{base} (copy)"); + let mut name = stem.clone(); + let mut n = 2; + while used.contains(&name) { + name = format!("{stem} {n}"); + n += 1; + } + used.insert(name.clone()); + name +} + +fn graph(doc: &Document, root: u64) -> Result> { + let definitions: BTreeSet<_> = doc.components.iter().map(|c| c.id).collect(); + let mut children: BTreeMap> = BTreeMap::new(); + for occurrence in &doc.occurrences { + children.entry(occurrence.parent).or_default().push(occurrence.component); + } + let mut output = Vec::new(); + let mut seen = BTreeSet::new(); + let mut stack = vec![(root, Vec::::new())]; + let mut work = 0; + while let Some((id, mut ancestors)) = stack.pop() { + work += 1; + if work > MAX_WORK || ancestors.len() >= 64 { + return Err(bad(CMD, "component graph exceeds copy work limit")); + } + if ancestors.contains(&id) { + return Err(bad(CMD, "component graph contains a cycle")); + } + if !definitions.contains(&id) { + return Err(bad(CMD, "component graph has a missing definition")); + } + if !seen.insert(id) { + continue; + } + output.push(id); + ancestors.push(id); + if let Some(children) = children.get(&id) { + if work.saturating_add(stack.len()).saturating_add(children.len()) > MAX_WORK { + return Err(bad(CMD, "component graph exceeds copy work limit")); + } + for child in children.iter().rev() { + stack.push((*child, ancestors.clone())); + } + } + } + // A shared node may be reached by a longer path after its first DFS visit. Check the + // longest path over the complete DAG, rather than trusting the first placement depth. + let mut indegree: BTreeMap = output.iter().map(|id| (*id, 0)).collect(); + for parent in &output { + if let Some(edges) = children.get(parent) { + for child in edges { + if let Some(degree) = indegree.get_mut(child) { + *degree += 1; + } + } + } + } + let mut ready = std::collections::VecDeque::from([root]); + let mut depth = BTreeMap::from([(root, 1usize)]); + let mut sorted = Vec::new(); + while let Some(parent) = ready.pop_front() { + sorted.push(parent); + let parent_depth = *depth.get(&parent).unwrap_or(&1); + if let Some(edges) = children.get(&parent) { + for child in edges { + let next = parent_depth + 1; + if next > 64 { + return Err(bad(CMD, "component graph exceeds copy depth limit")); + } + depth.entry(*child).and_modify(|d| *d = (*d).max(next)).or_insert(next); + if let Some(degree) = indegree.get_mut(child) { + *degree = degree.saturating_sub(1); + if *degree == 0 { + ready.push_back(*child); + } + } + } + } + } + if sorted.len() != output.len() { + return Err(bad(CMD, "component graph contains a cycle")); + } + Ok(sorted) +} + +pub(super) fn paste( + s: &mut Session, + component: u64, + source_occurrence: Option, + transform: Mat, + parent: u64, + params: &Value, +) -> Result { + let source = s.doc.clone(); + let source_parameters: BTreeMap<_, _> = source.params.iter().map(|p| (p.name.as_str(), p)).collect(); + let generated_parameters: BTreeSet<_> = source + .params + .iter() + .filter(|p| p.model) + .map(|p| p.name.as_str()) + .chain(source.features.iter().flat_map(|f| f.param_names.iter().map(String::as_str))) + .collect(); + let definitions = graph(&source, component)?; + let definition_records: BTreeMap<_, _> = source.components.iter().map(|c| (c.id, c)).collect(); + let selected: BTreeSet<_> = definitions.iter().copied().collect(); + if source.marker.is_some_and(|m| m < source.features.len()) { + return Err(bad(CMD, "move the timeline marker to the end before Paste New")); + } + let feats: Vec = source.features.iter().filter(|f| selected.contains(&f.component)).cloned().collect(); + if feats.len() + source.features.len() > MAX_WORK { + return Err(bad(CMD, "too many features to copy")); + } + for f in &feats { + if s.model.result(f.id).is_some_and(|r| r.error.is_some()) { + return Err(bad(CMD, format!("source feature {} has an evaluation error", f.name))); + } + } + let state = s.model.state(); + if state.bodies.len() > MAX_WORK { + return Err(bad(CMD, "body set exceeds copy work limit")); + } + if source.next_id.checked_add(100_000).is_none() { + return Err(bad(CMD, "copy id allocation exceeds supported range")); + } + let mut scratch = s.scratch(); + let mut components = BTreeMap::new(); + let mut occurrences = BTreeMap::new(); + let mut features = BTreeMap::new(); + let mut names = BTreeMap::new(); + let mut bodies = BTreeMap::new(); + let mut parameters = BTreeMap::new(); + let mut used: BTreeSet = source + .features + .iter() + .map(|f| f.name.clone()) + .chain(source.components.iter().map(|c| c.name.clone())) + .chain(state.bodies.iter().map(|b| b.name.clone())) + .collect(); + let mut new_ids = Vec::new(); + let mut frozen = Vec::new(); + // Allocate definitions, discarding their automatic placeholder occurrence except root. + for old in &definitions { + let c = definition_records.get(old).ok_or_else(|| bad(CMD, "missing definition"))?; + let mapped_parent = if *old == component { + parent + } else { + components.get(&c.parent).copied().unwrap_or(*components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?) + }; + let name = unique(&c.name, &mut used); + let new = scratch.doc_mut().add_component(Some(&name), mapped_parent)?; + components.insert(*old, new); + if *old != component { + scratch.doc_mut().occurrences.retain(|o| o.component != new); + } + } + let root = *components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?; + let root_occurrence = scratch.doc.occurrence_of(root).map(|o| o.id).ok_or_else(|| bad(CMD, "missing cloned placement"))?; + let mut clone_path = if let Some(o) = &source_occurrence { + let mut path = super::component::occurrence_path(s, params, o.id, CMD)?; + path.pop(); + path + } else { + source.first_path(parent) + }; + clone_path.push(root_occurrence); + if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == root_occurrence) { + o.transform = transform; + o.grounded = source_occurrence.as_ref().is_some_and(|o| o.grounded); + } + if let Some(o) = &source_occurrence { + occurrences.insert(o.id, root_occurrence); + } + for old in source.occurrences.iter().filter(|o| selected.contains(&o.parent)) { + let child = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing child definition"))?; + let owner = *components.get(&old.parent).ok_or_else(|| bad(CMD, "missing parent definition"))?; + let pose = s.pending_moves.get(&old.id).copied().unwrap_or(old.transform); + if !super::component::valid_position(&pose) { + return Err(bad(CMD, "invalid internal pending occurrence transform")); + } + let id = scratch.doc_mut().add_occurrence(child, owner, pose)?; + if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == id) { + o.grounded = old.grounded; + } + occurrences.insert(old.id, id); + } + // Collect historical outputs too: a later Combine may have consumed a named tool body. + let mut outputs: BTreeMap> = BTreeMap::new(); + for f in &feats { + if let Some(result) = s.model.result(f.id) { + let created: Vec<_> = result.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| b.name.clone()).collect(); + for name in &created { + bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used)); + } + outputs.insert(f.id, created); + } + for name in &f.body_names { + if !name.is_empty() { + bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used)); + } + } + if bodies.len() > MAX_WORK { + return Err(bad(CMD, "historical body set exceeds copy work limit")); + } + } + // Body assignment may put externally authored geometry into this graph. Freeze it explicitly. + let owned_features: BTreeSet<_> = feats.iter().map(|f| f.id).collect(); + for body in state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature)) && !owned_features.contains(&b.feature)) + { + if body.body.is_mesh() { + return Err(bad(CMD, "Paste New of a reassigned mesh body is not supported yet")); + } + let comp = source.body_component(&body.name, body.feature); + let offset = source.body_offsets.get(&body.name).copied().unwrap_or(solvecraft_doc::IDENTITY); + let before = feats.iter().find_map(|f| { + let before = s.model.state_before(f.id); + let prior = before.body(&body.name)?; + let after = s.model.result(f.id)?.state.body(&body.name); + if after.is_none_or(|after| !prior.body.same(&after.body)) { Some(before) } else { None } + }); + let base = before.as_ref().and_then(|st| st.body(&body.name)).unwrap_or(body); + let geometry = solvecraft_kernel::transform_matrix(&base.body, offset)?; + let step = solvecraft_kernel::step_export(&[&geometry], "SolveCraft independent reassigned body")?; + let new_name = unique(&body.name, &mut used); + bodies.insert(body.name.clone(), new_name.clone()); + let feature_name = unique(&format!("{} Base", body.name), &mut used); + let id = scratch.doc_mut().add_feature( + FeatureKind::Import { file: format!("{} independent base", body.name), step, components: Vec::new() }, + Some(&feature_name), + )?; + if let Some(f) = scratch.doc_mut().feature_mut(id) { + f.component = *components.get(&comp).ok_or_else(|| bad(CMD, "missing assigned component"))?; + f.body_names = vec![new_name]; + } + new_ids.push(id); + frozen.push(body.name.clone()); + } + // Allocate all history IDs before rewriting any forward reference. + for f in &feats { + let name = unique(&f.name, &mut used); + let id = scratch.doc_mut().add_feature(f.kind.clone(), Some(&name))?; + if let Some(new) = scratch.doc.feature(id) { + for (old, pn) in f.param_names.iter().zip(&new.param_names) { + parameters.insert(old.clone(), pn.clone()); + } + } + features.insert(f.id, id); + names.insert(f.name.clone(), name); + new_ids.push(id); + } + // Constraint dimensions own independent generated parameters; public user parameters stay shared. + for f in &feats { + if let FeatureKind::Sketch { sketch, .. } = &f.kind { + for c in &sketch.constraints { + if let Some(name) = &c.param + && !parameters.contains_key(name) + && let Some(p) = source_parameters.get(name.as_str()) + && p.model + { + if scratch.doc.params.len() >= solvecraft_doc::MAX_PARAMS { + return Err(bad(CMD, "too many copied parameters")); + } + let new = scratch.doc_mut().new_model_param(&p.expr, &p.unit); + parameters.insert(name.clone(), new); + } + } + } + } + for (old, new) in ¶meters { + if let Some(comment) = source.param_comments.get(old) { + scratch.doc_mut().param_comments.insert(new.clone(), comment.clone()); + } + if let Some(p) = source_parameters.get(old.as_str()) + && p.model + { + let expr = solvecraft_doc::clone_expression(&p.expr, ¶meters); + if let Some(p) = scratch.doc_mut().params.iter_mut().find(|p| p.name == *new) { + p.expr = expr; + p.comment = source_parameters.get(old.as_str()).map(|p| p.comment.clone()).unwrap_or_default(); + } + } + } + for old in &feats { + let mut f = old.clone(); + f.id = *features.get(&old.id).ok_or_else(|| bad(CMD, "missing feature mapping"))?; + f.name = names.get(&old.name).cloned().ok_or_else(|| bad(CMD, "missing feature name"))?; + f.component = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing component mapping"))?; + if f.body_names.is_empty() { + f.body_names = outputs.get(&old.id).cloned().unwrap_or_default(); + } + f.remap_clone(&features, &names, &bodies, ¶meters)?; + if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind + && !face.path.is_empty() + && let FeatureKind::Emboss { face: Some(old_face), .. } = &old.kind + && bodies.contains_key(&old_face.body) + { + face.path = cloned_path(&source, &old_face.path, component, &clone_path, &occurrences)?; + } + if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind + && !face.author_path.is_empty() + { + face.author_path = cloned_path(&source, &face.author_path, component, &clone_path, &occurrences)?; + } + if let Some(slot) = scratch.doc_mut().feature_mut(f.id) { + *slot = f; + } + } + for (old, new) in &bodies { + if let Some(mat) = source.materials.get(old) { + scratch.doc_mut().materials.insert(new.clone(), mat.clone()); + } + if let Some(look) = source.appearances.bodies.get(old) { + scratch.doc_mut().appearances.bodies.insert(new.clone(), look.clone()); + } + if !frozen.contains(old) { + if let Some(comp) = source.body_components.get(old).and_then(|c| components.get(c)) { + scratch.doc_mut().body_components.insert(new.clone(), *comp); + } + if let Some(offset) = source.body_offsets.get(old) { + scratch.doc_mut().body_offsets.insert(new.clone(), *offset); + } + } + } + for (old, new) in &components { + if let Some(part) = source.parts.get(old) { + scratch.doc_mut().parts.insert(*new, part.clone()); + } + if let Some(look) = source.appearances.components.get(old) { + scratch.doc_mut().appearances.components.insert(*new, look.clone()); + } + } + // A creator's history can belong here while its final body was reassigned outside. + // Retain the independent history, but reproduce the current ownership at its end. + let moved_out: Vec<_> = state + .bodies + .iter() + .filter(|b| owned_features.contains(&b.feature) && !selected.contains(&source.body_component(&b.name, b.feature))) + .filter_map(|b| bodies.get(&b.name).cloned()) + .collect(); + if !moved_out.is_empty() { + let name = unique("Assigned-out bodies", &mut used); + let id = scratch.doc_mut().add_feature(FeatureKind::Remove { bodies: moved_out }, Some(&name))?; + if let Some(f) = scratch.doc_mut().feature_mut(id) { + f.component = root; + } + new_ids.push(id); + } + for face in &source.appearances.faces { + if let Some(new) = bodies.get(&face.body) { + let mut face = face.clone(); + if frozen.contains(&face.body) { + if let Some(name) = &face.name { + face.point = solvecraft_doc::naming::point_on_face(&state, name, face.point) + .map(|(point, _)| point) + .ok_or_else(|| bad(CMD, "source frozen face appearance lost its identity"))?; + } + face.point = solvecraft_doc::apply_point(source.body_offsets.get(&face.body).unwrap_or(&solvecraft_doc::IDENTITY), face.point); + face.name = None; + } else if let Some(name) = &mut face.name { + *name = solvecraft_doc::remap_topology(name, &features, &bodies); + } + face.body = new.clone(); + scratch.doc_mut().appearances.faces.push(face); + } + } + // Typed configuration and assembly helpers preserve original targets. + scratch.doc_mut().configs.clone_targets(&solvecraft_doc::config::ConfigCloneMaps { + features: features.clone(), + components: components.clone(), + bodies: bodies.clone(), + parameters: parameters.clone(), + })?; + let mut notices = super::component_clone_assembly::clone_assembly(scratch.doc_mut(), &source, &occurrences, &features, &bodies)?; + let mut reference_work = 0usize; + for feature in &feats { + if let FeatureKind::Emboss { face: Some(face), .. } = &feature.kind + && !face.path.is_empty() + && !bodies.contains_key(&face.body) + { + notices.push(format!("{} retains external selected occurrence path {:?}", feature.name, face.path)); + } + if let FeatureKind::Sketch { sketch, .. } = &feature.kind { + use solvecraft_sketch::LinkSource as L; + let mut pending: Vec<_> = sketch.links.iter().map(|l| &l.source).collect(); + let mut work = 0; + while let Some(link) = pending.pop() { + work += 1; + reference_work += 1; + if work > MAX_WORK || reference_work > 100_000 { + return Err(bad(CMD, "linked references exceed copy work limit")); + } + match link { + L::Edge { body, .. } + | L::Face { body, .. } + | L::Body { body } + | L::Vertex { body, .. } + | L::Iso { body, .. } + | L::Spun { body, .. } => { + if !bodies.contains_key(body) { + notices.push(format!("{} retains external linked body {body}", feature.name)); + } + } + L::SketchCurve { sketch, .. } | L::SketchPoint { sketch, .. } => { + if !features.contains_key(sketch) { + notices.push(format!("{} retains external linked sketch {sketch}", feature.name)); + } + } + L::OnSurface { sketch, body, .. } => { + if !features.contains_key(sketch) || !bodies.contains_key(body) { + notices.push(format!("{} retains external surface link", feature.name)); + } + } + L::Intersection { a, b } => { + pending.push(a); + pending.push(b); + } + L::Plane { name } if source.find_feature(name).is_some() && !names.contains_key(name) => { + notices.push(format!("{} retains external linked plane {name}", feature.name)); + } + _ => {} + } + } + } + for id in feature.kind.input_sketches() { + if !features.contains_key(&id) { + notices.push(format!("{} retains an external sketch reference to {id}", feature.name)); + } + } + if let FeatureKind::Pattern { features: references, .. } | FeatureKind::Mirror { features: references, .. } = &feature.kind { + for name in references { + if !names.contains_key(name) { + notices.push(format!("{} retains external feature reference {name}", feature.name)); + } + } + } + for expression in feature.kind.expressions() { + for name in solvecraft_doc::expr::references(expression) { + reference_work += 1; + if reference_work > 100_000 { + return Err(bad(CMD, "expression references exceed copy work limit")); + } + if !parameters.contains_key(&name) && generated_parameters.contains(name.as_str()) { + notices.push(format!("{} retains external model parameter {name}", feature.name)); + } + } + } + } + notices.sort(); + notices.dedup(); + scratch.refresh(); + let original_errors: BTreeMap<_, _> = s.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect(); + let refreshed_errors: BTreeMap<_, _> = scratch.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect(); + if source.features.iter().any(|feature| original_errors.get(&feature.id) != refreshed_errors.get(&feature.id)) { + return Err(bad(CMD, "copy would change an original feature's error state")); + } + // Base imports acquire new face identities. Resolve copied face overrides against the + // verified new geometry once, rather than weakening strict persistent-name semantics. + let resolved = scratch.model.state(); + let mut face_updates = Vec::new(); + for (index, face) in scratch.doc.appearances.faces.iter().enumerate() { + if bodies.iter().any(|(old, new)| frozen.contains(old) && *new == face.body) { + let body = resolved.body(&face.body).ok_or_else(|| bad(CMD, "missing frozen face body"))?; + let face_index = + solvecraft_doc::appearance::face_index_at(body, face.point).ok_or_else(|| bad(CMD, "cannot reproduce frozen face appearance"))?; + let name = solvecraft_doc::naming::face_names(body).get(face_index).cloned().ok_or_else(|| bad(CMD, "missing frozen face identity"))?; + face_updates.push((index, name)); + } + } + for (index, name) in face_updates { + if let Some(face) = scratch.doc_mut().appearances.faces.get_mut(index) { + face.name = Some(name); + } + } + for id in &new_ids { + if let Some(error) = scratch.model.result(*id).and_then(|r| r.error.as_ref()) { + return Err(bad(CMD, format!("copied feature failed: {error}"))); + } + } + let copied = scratch.model.state(); + let copied_bodies: BTreeMap<_, _> = copied.bodies.iter().map(|body| (body.name.as_str(), body)).collect(); + // Adding placements can invalidate cached source evaluation. Compare clone effects + // against that same evaluation's source prefix, not older shared B-rep pointers. + if scratch.doc.features.get(..source.features.len()) != Some(source.features.as_slice()) + || scratch.doc.params.get(..source.params.len()) != Some(source.params.as_slice()) + { + return Err(bad(CMD, "copy would change original feature history or parameters")); + } + let original_state = new_ids.first().map(|id| scratch.model.state_before(*id)).unwrap_or_else(|| copied.clone()); + let all_columns = std::mem::take(&mut scratch.doc_mut().configs.columns); + scratch.doc_mut().configs.columns = all_columns.iter().skip(source.configs.columns.len()).cloned().collect(); + let style_validation = scratch.doc.configuration_style_status(&copied); + scratch.doc_mut().configs.columns = all_columns; + style_validation?; + let mut proof_work = 0usize; + let expected_count = state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature))).count(); + let copied_components: BTreeSet<_> = components.values().copied().collect(); + let actual_count = copied.bodies.iter().filter(|b| copied_components.contains(&scratch.doc.body_component(&b.name, b.feature))).count(); + if actual_count != expected_count { + return Err(bad(CMD, "copy produced an incomplete or unexpected set of bodies")); + } + for original in &original_state.bodies { + let Some(after) = copied_bodies.get(original.name.as_str()) else { + return Err(bad(CMD, "copy would remove a source body outside its independent history")); + }; + if !original.body.same(&after.body) { + return Err(bad(CMD, "copy would modify a source body; external destructive dependencies are not supported")); + } + } + for body in state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature))) { + let new = bodies.get(&body.name).ok_or_else(|| bad(CMD, format!("body {} has no clone identity", body.name)))?; + let cloned = copied_bodies.get(new.as_str()).ok_or_else(|| bad(CMD, format!("copy did not reproduce body {}", body.name)))?; + let a = solvecraft_kernel::transform_matrix(&body.body, *source.body_offsets.get(&body.name).unwrap_or(&solvecraft_doc::IDENTITY))?; + let b = solvecraft_kernel::transform_matrix(&cloned.body, *scratch.doc.body_offsets.get(new).unwrap_or(&solvecraft_doc::IDENTITY))?; + let ma = solvecraft_kernel::measure(&a)?; + let mb = solvecraft_kernel::measure(&b)?; + let tolerance = 1e-5 * (1.0 + (ma.bbox.max - ma.bbox.min).len()) + 64.0 * f64::EPSILON * (ma.bbox.min.len() + ma.bbox.max.len()); + let mut material_matches = true; + let mesh_a = a.display_mesh((a.size() * 1e-3).max(1e-3))?; + let mesh_b = b.display_mesh((b.size() * 1e-3).max(1e-3))?; + proof_work = proof_work.saturating_add(mesh_a.triangles.len().saturating_add(mesh_b.triangles.len()).saturating_mul(27)); + if proof_work > 50_000_000 { + return Err(bad(CMD, "geometry verification exceeds copy work limit")); + } + for x in [0.2, 0.5, 0.8] { + for y in [0.2, 0.5, 0.8] { + for z in [0.2, 0.5, 0.8] { + let d = ma.bbox.max - ma.bbox.min; + let p = ma.bbox.min + solvecraft_geom::Vec3::new(d.x * x, d.y * y, d.z * z); + material_matches &= mesh_a.contains(p) == mesh_b.contains(p); + } + } + } + if (ma.volume - mb.volume).abs() > 1e-5 * (1.0 + ma.volume.abs()) + || (ma.area - mb.area).abs() > 1e-5 * (1.0 + ma.area.abs()) + || ma.centroid.dist(mb.centroid) > tolerance + || ma.bbox.min.dist(mb.bbox.min) > tolerance + || ma.bbox.max.dist(mb.bbox.max) > tolerance + || !material_matches + { + return Err(bad(CMD, format!("copy geometry differs for body {}", body.name))); + } + } + scratch.selection = vec![Sel::Occurrence { path: clone_path }]; + s.accept_computed(scratch); + Ok( + json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices}), + ) +} + +#[cfg(test)] +#[path = "component_clone_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/component_clone_assembly.rs b/crates/engine/src/cmd/component_clone_assembly.rs new file mode 100644 index 00000000..22a186fc --- /dev/null +++ b/crates/engine/src/cmd/component_clone_assembly.rs @@ -0,0 +1,360 @@ +//! Typed remapping of assembly relationships for an independent component-tree copy. + +use std::collections::{BTreeMap, BTreeSet}; + +use solvecraft_doc::Document; +use solvecraft_doc::joints::{Assembly, JointOrigin, Snap}; + +use crate::{EngineError, Result}; + +const MAX_RECORDS: usize = 10_000; +const MAX_REFERENCES: usize = 100_000; +const MAX_TEXT: usize = 1_048_576; +const MAX_NAME: usize = 8192; + +fn invalid(message: &str) -> EngineError { + EngineError::Other(format!("Paste New assembly: {message}")) +} + +fn add_references(count: &mut usize, more: usize) -> Result<()> { + *count = count.saturating_add(more); + if *count > MAX_REFERENCES { + return Err(invalid("assembly references exceed the processing budget")); + } + Ok(()) +} + +fn finite_origin(origin: &JointOrigin) -> Result<()> { + let finite = match &origin.snap { + Snap::Frame { origin, z, x } => origin.is_finite() && z.is_finite() && x.is_none_or(|x| x.is_finite()), + Snap::FaceCenter { pick, .. } | Snap::CircleCenter { pick, .. } => pick.is_finite(), + Snap::Point { point, z } => point.is_finite() && z.is_none_or(|z| z.is_finite()), + }; + if !finite { + return Err(invalid("joint origin contains non-finite geometry")); + } + Ok(()) +} + +fn budget(assembly: &Assembly) -> Result<()> { + let counts = [ + assembly.origins.len(), + assembly.joints.len(), + assembly.links.len(), + assembly.contact_sets.len(), + assembly.studies.len(), + assembly.exploded.len(), + ]; + if counts.iter().any(|n| *n > MAX_RECORDS) { + return Err(invalid("too many assembly records")); + } + let mut references = counts.iter().fold(0usize, |n, c| n.saturating_add(*c)); + let mut bytes = 0usize; + let mut text = |s: &str| -> Result<()> { + bytes = bytes.saturating_add(s.len()); + if s.len() > MAX_NAME || bytes > MAX_TEXT { + return Err(invalid("assembly names exceed the processing budget")); + } + Ok(()) + }; + for origin in &assembly.origins { + finite_origin(&origin.origin)?; + text(&origin.name)?; + if let Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } = &origin.origin.snap { + text(face)?; + } + } + for joint in &assembly.joints { + text(&joint.name)?; + add_references(&mut references, joint.values.len().saturating_add(joint.limits.len()))?; + if !joint.values.iter().all(|v| v.is_finite()) + || !joint.limits.iter().flatten().all(|(lo, hi)| lo.is_finite() && hi.is_finite() && lo <= hi) + || !joint.offset.is_finite() + || !joint.angle.is_finite() + { + return Err(invalid("joint contains invalid motion values")); + } + for origin in [&joint.a, &joint.b] { + finite_origin(origin)?; + if let Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } = &origin.snap { + text(face)?; + } + } + } + for contact in &assembly.contact_sets { + text(&contact.name)?; + add_references(&mut references, contact.occurrences.len())?; + } + for study in &assembly.studies { + text(&study.name)?; + add_references(&mut references, study.keys.len())?; + for key in &study.keys { + add_references(&mut references, key.points.len())?; + if !key.points.iter().all(|(step, value)| step.is_finite() && value.is_finite()) { + return Err(invalid("motion study contains non-finite keys")); + } + } + } + for view in &assembly.exploded { + text(&view.name)?; + add_references(&mut references, view.moves.len())?; + if !view.moves.iter().all(|(_, p)| p.is_finite()) { + return Err(invalid("exploded view contains non-finite moves")); + } + } + if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite()) { + return Err(invalid("motion link contains non-finite values")); + } + if references > MAX_REFERENCES { + return Err(invalid("assembly references exceed the processing budget")); + } + Ok(()) +} + +fn unique_name(base: &str, used: &mut BTreeSet) -> Result { + if !used.contains(base) { + used.insert(base.to_string()); + return Ok(base.to_string()); + } + for index in 1..=MAX_RECORDS { + let name = if index == 1 { format!("{base} (copy)") } else { format!("{base} (copy {index})") }; + if name.len() > MAX_NAME { + return Err(invalid("copied assembly name is too long")); + } + if used.insert(name.clone()) { + return Ok(name); + } + } + Err(invalid("could not allocate a unique assembly name")) +} + +fn next_id(id: &mut u64) -> Result { + *id = id.checked_add(1).ok_or_else(|| invalid("assembly identifier space is exhausted"))?; + Ok(*id) +} + +fn unique_ids(ids: impl Iterator) -> Result { + let mut seen = BTreeSet::new(); + let mut largest = 0; + for id in ids { + if id == 0 || !seen.insert(id) { + return Err(invalid("assembly identifiers must be nonzero and unique")); + } + largest = largest.max(id); + } + Ok(largest) +} + +fn validate_map(map: &BTreeMap) -> Result<()> { + if map.len() > MAX_RECORDS || map.values().collect::>().len() != map.len() { + return Err(invalid("copy maps exceed their budget or contain duplicate destinations")); + } + Ok(()) +} + +/// Remap certified naming tokens, rather than replacing digit substrings (F1 must not +/// alter F10). Body-based legacy names are remapped only through an explicit identity map. +fn face_name(name: &str, features: &BTreeMap, bodies: &BTreeMap) -> Option { + let body = name.match_indices(':').filter_map(|(end, _)| bodies.get_key_value(name.get(..end)?)).next_back(); + let mut out = String::new(); + let mut last = 0usize; + let mut mapped = false; + if let Some((old, new)) = body { + out.push_str(new); + last = old.len(); + mapped = true; + } + let bytes = name.as_bytes(); + let mut i = last; + while i < bytes.len() { + let boundary = i == 0 + || bytes.get(i.wrapping_sub(1)).is_some_and(|c| matches!(*c, b'@' | b'|')) + || name.get(..i).is_some_and(|prefix| prefix.ends_with(":blend:") || prefix.ends_with(":inner:")); + if bytes.get(i) == Some(&b'F') && boundary { + let mut end = i + 1; + while bytes.get(end).is_some_and(u8::is_ascii_digit) { + end += 1; + } + if end > i + 1 && bytes.get(end).is_some_and(|c| matches!(*c, b':' | b'.')) { + let id: u64 = name.get(i + 1..end)?.parse().ok()?; + let replacement = features.get(&id)?; + out.push_str(name.get(last..i)?); + out.push_str(&format!("F{replacement}")); + last = end; + mapped |= i == 0; + i = end; + continue; + } + } + i += 1; + } + if !mapped { + return None; + } + out.push_str(name.get(last..)?); + Some(out) +} + +fn origin( + origin: &JointOrigin, + occurrences: &BTreeMap, + features: &BTreeMap, + bodies: &BTreeMap, +) -> Option { + let mut out = origin.clone(); + out.occurrence = *occurrences.get(&origin.occurrence)?; + match &mut out.snap { + Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } => *face = face_name(face, features, bodies)?, + _ => {} + } + Some(out) +} + +fn report(messages: &mut Vec, kind: &str, name: &str) { + messages.push(format!("Skipped {kind} `{name}`: references outside the copied component tree or geometry without a copied identity.")); +} + +/// Called with a scratch document after geometry cloning. Only wholly internal records +/// are copied; cross-boundary relationships are reported and omitted without truncation. +/// The assembly is swapped in only after every map, allocation and aggregate check passes. +pub(super) fn clone_assembly( + doc: &mut Document, + source: &Document, + occurrences: &BTreeMap, + features: &BTreeMap, + bodies: &BTreeMap, +) -> Result> { + budget(&source.assembly)?; + budget(&doc.assembly)?; + validate_map(occurrences)?; + validate_map(features)?; + validate_map(bodies)?; + if doc.occurrences.len() > MAX_REFERENCES { + return Err(invalid("too many destination occurrences")); + } + let mut map_bytes = 0usize; + for (old, new) in bodies { + map_bytes = map_bytes.saturating_add(old.len()).saturating_add(new.len()); + if old.is_empty() || new.is_empty() || old.len() > MAX_NAME || new.len() > MAX_NAME || map_bytes > MAX_TEXT { + return Err(invalid("invalid or excessive body identity map")); + } + } + let available: BTreeSet = doc.occurrences.iter().map(|o| o.id).chain(std::iter::once(0)).collect(); + if occurrences.values().any(|id| !available.contains(id)) { + return Err(invalid("copy occurrence map points to a missing occurrence")); + } + unique_ids(source.assembly.joints.iter().map(|j| j.id))?; + let mut joint_id = unique_ids(doc.assembly.joints.iter().map(|j| j.id))?; + let aux = |a: &Assembly| { + a.contact_sets.iter().map(|x| x.id).chain(a.studies.iter().map(|x| x.id)).chain(a.exploded.iter().map(|x| x.id)).collect::>() + }; + unique_ids(aux(&source.assembly).into_iter())?; + let mut auxiliary_id = unique_ids(aux(&doc.assembly).into_iter())?; + let mut a = doc.assembly.clone(); + let mut messages = Vec::new(); + let mut origin_names = a.origins.iter().map(|o| o.name.clone()).collect(); + for old in &source.assembly.origins { + if !occurrences.contains_key(&old.origin.occurrence) { + continue; + } + if let Some(new) = origin(&old.origin, occurrences, features, bodies) { + let mut item = old.clone(); + item.name = unique_name(&old.name, &mut origin_names)?; + item.origin = new; + a.origins.push(item); + } else { + report(&mut messages, "joint origin", &old.name); + } + } + let mut joint_names = a.joints.iter().map(|j| j.name.clone()).collect(); + let mut joints = BTreeMap::new(); + let mut touched_joints = BTreeSet::new(); + for old in &source.assembly.joints { + if !occurrences.contains_key(&old.a.occurrence) && !occurrences.contains_key(&old.b.occurrence) { + continue; + } + touched_joints.insert(old.id); + match (origin(&old.a, occurrences, features, bodies), origin(&old.b, occurrences, features, bodies)) { + (Some(aa), Some(bb)) => { + let mut item = old.clone(); + item.id = next_id(&mut joint_id)?; + item.name = unique_name(&old.name, &mut joint_names)?; + item.a = aa; + item.b = bb; + joints.insert(old.id, item.id); + a.joints.push(item); + } + _ => report(&mut messages, "joint", &old.name), + } + } + for old in &source.assembly.links { + if !touched_joints.contains(&old.a) && !touched_joints.contains(&old.b) { + continue; + } + match (joints.get(&old.a), joints.get(&old.b)) { + (Some(aa), Some(bb)) => { + let mut item = old.clone(); + item.a = *aa; + item.b = *bb; + a.links.push(item); + } + _ => report(&mut messages, "motion link", &format!("{} → {}", old.a, old.b)), + } + } + let mut contact_names = a.contact_sets.iter().map(|x| x.name.clone()).collect(); + for old in &source.assembly.contact_sets { + if !old.occurrences.iter().any(|o| occurrences.contains_key(o)) { + continue; + } + if let Some(ids) = old.occurrences.iter().map(|o| occurrences.get(o).copied()).collect::>>() { + let mut item = old.clone(); + item.id = next_id(&mut auxiliary_id)?; + item.name = unique_name(&old.name, &mut contact_names)?; + item.occurrences = ids; + a.contact_sets.push(item); + } else { + report(&mut messages, "contact set", &old.name); + } + } + let mut study_names = a.studies.iter().map(|x| x.name.clone()).collect(); + for old in &source.assembly.studies { + if !old.keys.iter().any(|k| touched_joints.contains(&k.joint)) { + continue; + } + if old.keys.iter().all(|k| joints.contains_key(&k.joint)) { + let mut item = old.clone(); + item.id = next_id(&mut auxiliary_id)?; + item.name = unique_name(&old.name, &mut study_names)?; + for key in &mut item.keys { + key.joint = *joints.get(&key.joint).ok_or_else(|| invalid("missing copied study joint"))?; + } + a.studies.push(item); + } else { + report(&mut messages, "motion study", &old.name); + } + } + let mut view_names = a.exploded.iter().map(|x| x.name.clone()).collect(); + for old in &source.assembly.exploded { + if !old.moves.iter().any(|(o, _)| occurrences.contains_key(o)) { + continue; + } + if old.moves.iter().all(|(o, _)| occurrences.contains_key(o)) { + let mut item = old.clone(); + item.id = next_id(&mut auxiliary_id)?; + item.name = unique_name(&old.name, &mut view_names)?; + for (o, _) in &mut item.moves { + *o = *occurrences.get(o).ok_or_else(|| invalid("missing copied exploded occurrence"))?; + } + a.exploded.push(item); + } else { + report(&mut messages, "exploded view", &old.name); + } + } + budget(&a)?; + doc.assembly = a; + Ok(messages) +} + +#[cfg(test)] +#[path = "component_clone_assembly_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/component_clone_assembly_tests.rs b/crates/engine/src/cmd/component_clone_assembly_tests.rs new file mode 100644 index 00000000..99a6e57d --- /dev/null +++ b/crates/engine/src/cmd/component_clone_assembly_tests.rs @@ -0,0 +1,193 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use super::*; +use solvecraft_doc::joints::{Joint, JointKind, MotionLink, NamedOrigin}; +use solvecraft_doc::motion::{ContactSet, ExplodedView, MotionStudy, StudyKey}; +use solvecraft_doc::{IDENTITY, Occurrence}; +use solvecraft_geom::Vec3; + +fn at(occurrence: u64) -> JointOrigin { + JointOrigin { occurrence, snap: Snap::Point { point: Vec3::ZERO, z: None } } +} + +fn joint(id: u64, a: u64, b: u64) -> Joint { + Joint { + id, + name: format!("Joint{id}"), + kind: JointKind::Revolute, + a: at(a), + b: at(b), + values: vec![0.25], + limits: vec![Some((-1.0, 1.0))], + flip: true, + offset: 2.0, + angle: 0.1, + suppressed: false, + } +} + +fn occurrence(id: u64) -> Occurrence { + Occurrence { id, component: id, parent: 0, name: format!("Occurrence{id}"), transform: IDENTITY, grounded: false } +} + +fn documents() -> (Document, Document) { + let mut source = Document { occurrences: [1, 2, 8, 9].map(occurrence).to_vec(), ..Document::default() }; + source.assembly.joints = vec![joint(1, 1, 2), joint(2, 1, 2), joint(3, 2, 9), joint(4, 8, 9)]; + source.assembly.origins = vec![ + NamedOrigin { + name: "Typed origin".into(), + origin: JointOrigin { occurrence: 1, snap: Snap::FaceCenter { pick: Vec3::new(1.0, 2.0, 3.0), face: Some("F1:start@F10.2".into()) } }, + }, + NamedOrigin { + name: "Foreign geometry".into(), + origin: JointOrigin { occurrence: 1, snap: Snap::CircleCenter { pick: Vec3::ZERO, face: Some("F99:end".into()) } }, + }, + NamedOrigin { name: "Outside".into(), origin: at(9) }, + ]; + source.assembly.links = + vec![MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3 }, MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0 }]; + source.assembly.contact_sets = vec![ + ContactSet { id: 1, name: "Internal contact".into(), occurrences: vec![1, 2], suppressed: true }, + ContactSet { id: 2, name: "Cross contact".into(), occurrences: vec![1, 9], suppressed: false }, + ]; + let key = |joint| StudyKey { joint, index: 0, points: vec![(0.0, 0.0), (10.0, 0.5)] }; + source.assembly.studies = vec![ + MotionStudy { id: 3, name: "Internal study".into(), steps: 10, keys: vec![key(1), key(2)] }, + MotionStudy { id: 4, name: "Cross study".into(), steps: 10, keys: vec![key(1), key(3)] }, + ]; + source.assembly.exploded = vec![ + ExplodedView { id: 5, name: "Internal explode".into(), moves: vec![(1, Vec3::X * 10.0), (2, Vec3::Y * 20.0)] }, + ExplodedView { id: 6, name: "Cross explode".into(), moves: vec![(1, Vec3::X), (9, Vec3::Y)] }, + ]; + let mut destination = source.clone(); + destination.occurrences.extend([101, 102].map(occurrence)); + (source, destination) +} + +#[test] +fn internal_relationships_receive_independent_ids_and_remapped_typed_references() { + let (source, mut destination) = documents(); + let report = + clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new()) + .unwrap(); + let a = &destination.assembly; + assert_eq!(a.joints.len(), 6); + assert_eq!((a.joints[4].id, a.joints[5].id), (5, 6)); + assert_eq!((a.joints[4].a.occurrence, a.joints[4].b.occurrence), (101, 102)); + assert_eq!(a.joints[4].values, source.assembly.joints[0].values); + assert_eq!(a.joints[4].limits, source.assembly.joints[0].limits); + assert_eq!(a.joints[4].name, "Joint1 (copy)"); + assert_eq!(a.origins.len(), 4); + assert!(matches!(&a.origins[3].origin.snap, Snap::FaceCenter { face: Some(face), .. } if face == "F11:start@F110.2")); + assert_eq!((a.links[2].a, a.links[2].b, a.links[2].ratio), (5, 6, -2.0)); + assert_eq!(a.contact_sets[2].id, 7); + assert_eq!(a.contact_sets[2].occurrences, vec![101, 102]); + assert!(a.contact_sets[2].suppressed); + assert_eq!(a.studies[2].id, 8); + assert_eq!(a.studies[2].keys.iter().map(|k| k.joint).collect::>(), vec![5, 6]); + assert_eq!(a.exploded[2].id, 9); + assert_eq!(a.exploded[2].moves, vec![(101, Vec3::X * 10.0), (102, Vec3::Y * 20.0)]); + assert_eq!(report.len(), 6); + for kind in ["joint origin", "joint `Joint3`", "motion link", "contact set", "motion study", "exploded view"] { + assert!(report.iter().any(|m| m.contains(kind)), "{kind}: {report:?}"); + } + assert!(!report.iter().any(|m| m.contains("Outside") || m.contains("Joint4"))); + assert_eq!(destination.assembly.joints[..4], source.assembly.joints); + destination.assembly.joints[4].values[0] = 0.75; + assert_eq!(source.assembly.joints[0].values[0], 0.25); +} + +#[test] +fn naming_remaps_exact_tokens_unicode_and_legacy_body_identity() { + let features = BTreeMap::from([(1, 21), (10, 30)]); + let bodies = BTreeMap::from([("Wheel: inner".into(), "Wheel copy: inner".into())]); + assert_eq!(face_name("F1:blend:F10:start@F1.3", &features, &bodies).as_deref(), Some("F21:blend:F30:start@F21.3")); + assert_eq!(face_name("F1:side:曲線F10", &features, &bodies).as_deref(), Some("F21:side:曲線F10")); + assert_eq!(face_name("F1:side:F100.7", &features, &bodies).as_deref(), Some("F21:side:F100.7")); + assert_eq!(face_name("Wheel: inner:face:7", &features, &bodies).as_deref(), Some("Wheel copy: inner:face:7")); + assert!(face_name("F100:start", &features, &bodies).is_none()); + assert!(face_name("F1:start@F99.1", &features, &bodies).is_none()); + assert!(face_name("Foreign:face:0", &features, &bodies).is_none()); + assert!(face_name("Foreign:face:0@F1.1", &features, &bodies).is_none()); + let bodies = BTreeMap::from([("F1:Housing".into(), "F1:Housing (copy)".into())]); + assert_eq!(face_name("F1:Housing:face:0@F10.1", &features, &bodies).as_deref(), Some("F1:Housing (copy):face:0@F30.1")); +} + +#[test] +fn malformed_maps_identifier_exhaustion_and_aggregate_budgets_are_atomic() { + let (source, destination) = documents(); + let mut cases = Vec::new(); + cases.push((source.clone(), destination.clone(), BTreeMap::from([(1, 101), (2, 101)]))); + cases.push((source.clone(), destination.clone(), BTreeMap::from([(1, 999)]))); + let mut overflow = destination.clone(); + overflow.assembly.joints[0].id = u64::MAX; + cases.push((source.clone(), overflow, BTreeMap::from([(1, 101), (2, 102)]))); + let mut oversized = source.clone(); + oversized.assembly.contact_sets[0].occurrences = vec![1; MAX_REFERENCES + 1]; + cases.push((oversized, destination.clone(), BTreeMap::from([(1, 101), (2, 102)]))); + let mut invalid = source.clone(); + invalid.assembly.links[0].ratio = f64::NAN; + cases.push((invalid, destination, BTreeMap::from([(1, 101), (2, 102)]))); + for (source, mut destination, occurrences) in cases { + let before = destination.assembly.clone(); + assert!(clone_assembly(&mut destination, &source, &occurrences, &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new()).is_err()); + assert_eq!(destination.assembly, before); + } +} + +#[test] +fn second_copy_has_unique_names_and_keeps_global_contact_switches_unchanged() { + let (source, mut destination) = documents(); + destination.assembly.contact_enabled = true; + destination.assembly.contact_all = true; + let features = BTreeMap::from([(1, 11), (10, 110)]); + clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &features, &BTreeMap::new()).unwrap(); + destination.occurrences.extend([201, 202].map(occurrence)); + clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 201), (2, 202)]), &features, &BTreeMap::new()).unwrap(); + assert_eq!(destination.assembly.joints[6].name, "Joint1 (copy 2)"); + assert!(destination.assembly.contact_enabled && destination.assembly.contact_all); + let names: BTreeSet<&str> = destination.assembly.origins.iter().map(|x| x.name.as_str()).collect(); + assert_eq!(names.len(), destination.assembly.origins.len()); +} + +#[test] +fn paste_new_internal_motion_study_drives_only_the_copied_assembly_and_reopens() { + use serde_json::{Value, json}; + fn run(s: &mut crate::Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}")) + } + let mut s = crate::Session::default(); + let parent = run(&mut s, "component.create", json!({"name":"Mechanism"}))["component"].as_u64().unwrap(); + let fixed = run(&mut s, "component.create", json!({"name":"Support"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"SupportBox"})); + run(&mut s, "occurrence.ground", json!({"occurrence":fixed,"grounded":true})); + run(&mut s, "component.activate", json!({"component":parent})); + let moving = run(&mut s, "component.create", json!({"name":"Carriage"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[0,0,5],"body_name":"CarriageBox"})); + run(&mut s, "joint.origin", json!({"name":"Support origin","occurrence":fixed,"point":[0,0,0]})); + let source_joint = run(&mut s, "joint.as_built", json!({"type":"slider","a":fixed,"b":moving,"name":"Rail"}))["joint"].as_u64().unwrap(); + run(&mut s, "contact.create", json!({"name":"Internal contact","occurrences":[fixed,moving]})); + run(&mut s, "motion.study", json!({"name":"Travel","steps":10,"keys":[{"joint":source_joint,"points":[[0,0],[10,6]]}]})); + run( + &mut s, + "explode.create", + json!({"name":"Exploded mechanism","moves":[{"occurrence":fixed,"translate":[10,0,0]},{"occurrence":moving,"translate":[0,10,0]}]}), + ); + let original = s.doc.occurrences.iter().find(|o| o.id == moving).unwrap().transform; + run(&mut s, "component.activate", json!({"component":"root"})); + let result = run(&mut s, "component.paste_new", json!({"component":parent,"translate":[20,0,0]})); + assert_eq!(result["notices"], json!([])); + assert_eq!(s.doc.assembly.joints.len(), 2); + assert_eq!(s.doc.assembly.origins.len(), 2); + assert_eq!(s.doc.assembly.contact_sets.len(), 2); + assert_eq!(s.doc.assembly.exploded.len(), 2); + let copied_joint = s.doc.assembly.joints.iter().find(|j| j.name == "Rail (copy)").unwrap().id; + let copied_moving = s.doc.assembly.joints.iter().find(|j| j.id == copied_joint).unwrap().b.occurrence; + assert_ne!(copied_moving, moving); + run(&mut s, "motion.play", json!({"study":"Travel (copy)","step":10})); + assert_eq!(s.doc.assembly.joints.iter().find(|j| j.id == source_joint).unwrap().values, vec![0.0]); + assert_eq!(s.doc.assembly.joints.iter().find(|j| j.id == copied_joint).unwrap().values, vec![6.0]); + assert_eq!(s.doc.occurrences.iter().find(|o| o.id == moving).unwrap().transform, original); + let reopened = Document::from_json(&s.doc.to_json()).unwrap(); + assert_eq!(reopened.assembly, s.doc.assembly); + assert!(reopened.assembly.studies.iter().any(|m| m.name == "Travel (copy)" && m.keys[0].joint == copied_joint)); +} diff --git a/crates/engine/src/cmd/component_clone_tests.rs b/crates/engine/src/cmd/component_clone_tests.rs new file mode 100644 index 00000000..1273710e --- /dev/null +++ b/crates/engine/src/cmd/component_clone_tests.rs @@ -0,0 +1,393 @@ +#![allow(clippy::unwrap_used)] +use crate::Session; +use serde_json::{Value, json}; +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap() +} +fn component(s: &mut Session, name: &str) -> u64 { + run(s, "component.create", json!({"name":name}))["component"].as_u64().unwrap() +} +fn volume(s: &mut Session, id: u64) -> f64 { + run(s, "component.properties", json!({"component":id}))["total"]["volume_mm3"].as_f64().unwrap() +} +fn box2(s: &mut Session) -> u64 { + run(s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Cube"}))["feature"].as_u64().unwrap() +} +fn copy(s: &mut Session, id: u64) -> Value { + run(s, "component.paste_new", json!({"component":id,"translate":[20,0,0]})) +} +fn near(a: f64, b: f64) { + assert!((a - b).abs() < 1e-6, "{a} != {b}"); +} + +#[test] +fn coincident_independent_primitives_keep_their_own_semantic_faces() { + let mut s = Session::default(); + let first = box2(&mut s); + let second = run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Other"}))["feature"].as_u64().unwrap(); + let state = s.model.state(); + let a = state.body("Cube").unwrap(); + let b = state.body("Other").unwrap(); + let a_names = solvecraft_doc::naming::face_names(a); + let b_names = solvecraft_doc::naming::face_names(b); + assert!(a_names.contains(&format!("F{first}:+z"))); + assert!(b_names.contains(&format!("F{second}:+z"))); + assert!(!b_names.contains(&format!("F{first}:+z"))); +} + +#[test] +fn nested_definition_dag_and_histories_are_independent() { + let mut s = Session::default(); + let parent = component(&mut s, "Assembly"); + let child = component(&mut s, "Child"); + let feature = box2(&mut s); + run(&mut s, "occurrence.copy", json!({"component":child})); + let copied = copy(&mut s, parent); + let new = copied["component"].as_u64().unwrap(); + assert_eq!(copied["components"], 2); + near(volume(&mut s, new), 16.0); + let kids: Vec<_> = s.doc.occurrences.iter().filter(|o| o.parent == new).collect(); + assert_eq!(kids.len(), 2); + assert_eq!(kids[0].component, kids[1].component); + assert_ne!(kids[0].component, child); + let parameter = s.doc.feature(feature).unwrap().param_names[0].clone(); + run(&mut s, "parameters.change", json!({"name":parameter,"expression":"3 mm"})); + near(volume(&mut s, parent), 24.0); + near(volume(&mut s, new), 16.0); + let saved = s.doc.to_json(); + let reopened = solvecraft_doc::Document::from_json(&saved).unwrap(); + assert_eq!(reopened.occurrences.iter().filter(|o| o.parent == new).count(), 2); +} + +#[test] +fn reassigned_body_is_an_independent_base_feature() { + let mut s = Session::default(); + let feature = box2(&mut s); + let part = component(&mut s, "Part"); + run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Cube"]})); + let copied = copy(&mut s, part); + let new = copied["component"].as_u64().unwrap(); + assert_eq!(copied["frozen_bodies"], json!(["Cube"])); + near(volume(&mut s, new), 8.0); + let parameter = s.doc.feature(feature).unwrap().param_names[0].clone(); + run(&mut s, "parameters.change", json!({"name":parameter,"expression":"3 mm"})); + near(volume(&mut s, part), 12.0); + near(volume(&mut s, new), 8.0); +} + +#[test] +fn clone_preserves_physical_material_and_independent_styles() { + let mut s = Session::default(); + let part = component(&mut s, "Part"); + box2(&mut s); + run(&mut s, "material.assign", json!({"bodies":["Cube"],"material":"Brass"})); + run(&mut s, "appearance.assign", json!({"components":[part.to_string()],"color":"#123456","opacity":0.4})); + run(&mut s, "appearance.assign", json!({"bodies":["Cube"],"color":"#abcdef"})); + run(&mut s, "appearance.assign", json!({"body":"Cube","faces":[[1,1,2]],"color":"#00ff00"})); + let copied = copy(&mut s, part); + let new = copied["component"].as_u64().unwrap(); + let props = run(&mut s, "component.properties", json!({"component":new})); + near(props["total"]["mass_g"].as_f64().unwrap(), 0.068); + let copied_body = s.model.state().bodies.iter().find(|b| b.component == new).unwrap().name.clone(); + assert_eq!(s.doc.appearances.bodies[&copied_body], s.doc.appearances.bodies["Cube"]); + let state = s.model.state(); + let body = state.body(&copied_body).unwrap(); + assert!(s.doc.face_colors(body).iter().any(|(_, look)| look.color == [0, 255, 0])); + run(&mut s, "appearance.assign", json!({"bodies":[copied_body],"color":"#ff0000"})); + assert_eq!(s.doc.appearances.bodies["Cube"].color, [0xab, 0xcd, 0xef]); + assert_eq!(s.doc.appearances.components[&new].opacity, 0.4); +} + +#[test] +fn reassigned_body_owned_move_replays_once_and_original_stays_unchanged() { + let mut s = Session::default(); + box2(&mut s); + let part = component(&mut s, "Part"); + run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Cube"]})); + run(&mut s, "solid.move", json!({"bodies":["Cube"],"translate":[10,0,0]})); + let before = run(&mut s, "component.properties", json!({"component":part})); + let copied = copy(&mut s, part); + let new = copied["component"].as_u64().unwrap(); + let after = run(&mut s, "component.properties", json!({"component":new})); + near(after["total"]["center_of_mass"][0].as_f64().unwrap(), 31.0); + assert_eq!(run(&mut s, "component.properties", json!({"component":part})), before); +} + +#[test] +fn copied_external_destructive_target_is_atomic() { + let mut s = Session::default(); + box2(&mut s); + let part = component(&mut s, "Part"); + run(&mut s, "solid.move", json!({"bodies":["Cube"],"translate":[10,0,0]})); + let saved = s.doc.to_json(); + let selection = s.selection.clone(); + let active = s.active_component; + let pending = s.pending_moves.clone(); + assert!(s.execute("component.paste_new", &json!({"component":part})).is_err()); + assert_eq!(s.doc.to_json(), saved); + assert_eq!(s.selection, selection); + assert_eq!(s.active_component, active); + assert_eq!(s.pending_moves, pending); +} + +#[test] +fn generated_feature_parameter_dependencies_are_independent() { + let mut s = Session::default(); + let part = component(&mut s, "Part"); + let source = box2(&mut s); + let source_names = s.doc.feature(source).unwrap().param_names.clone(); + run(&mut s, "parameters.change", json!({"name":source_names[1],"expression":format!("{} * 2",source_names[0])})); + let result = copy(&mut s, part); + let clone = result["component"].as_u64().unwrap(); + near(volume(&mut s, clone), 16.0); + let clone_names = s + .doc + .features + .iter() + .find(|f| f.component == clone && matches!(f.kind, solvecraft_doc::FeatureKind::Box { .. })) + .unwrap() + .param_names + .clone(); + assert_ne!(clone_names, source_names); + run(&mut s, "parameters.change", json!({"name":source_names[0],"expression":"3 mm"})); + near(volume(&mut s, part), 36.0); + near(volume(&mut s, clone), 16.0); + run(&mut s, "parameters.change", json!({"name":clone_names[0],"expression":"4 mm"})); + near(volume(&mut s, clone), 64.0); + near(volume(&mut s, part), 36.0); +} + +#[test] +fn selected_nested_pending_placement_survives_copy_and_undo() { + let mut s = Session::default(); + let parent = component(&mut s, "Parent"); + let parent_first = s.doc.occurrence_of(parent).unwrap().id; + let child = component(&mut s, "Child"); + let leaf = s.doc.occurrence_of(child).unwrap().id; + box2(&mut s); + let second = run(&mut s, "occurrence.copy", json!({"component":parent,"translate":[50,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":leaf,"capture":false,"translate":[5,0,0]})); + run(&mut s, "occurrence.move", json!({"occurrence":second,"capture":false,"translate":[2,0,0]})); + let pending = s.pending_moves.clone(); + let prior_context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch); + let copied = run(&mut s, "component.paste_new", json!({"component":child,"occurrence":leaf,"path":[second,leaf],"translate":[10,0,0]})); + let new_leaf = copied["occurrence"].as_u64().unwrap(); + assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), prior_context); + assert_eq!(s.selection, vec![crate::Sel::Occurrence { path: vec![second, new_leaf] }]); + assert_eq!(s.pending_moves, pending); + assert_eq!(s.occurrence_placement(&[parent_first, leaf]).unwrap().world[3][0], 5.0); + let props = run(&mut s, "occurrence.properties", json!({"occurrence":new_leaf,"path":[second,new_leaf]})); + near(props["total"]["center_of_mass"][0].as_f64().unwrap(), 68.0); + run(&mut s, "edit.undo", json!({})); + assert!(!s.doc.occurrences.iter().any(|o| o.id == new_leaf)); + assert_eq!(s.pending_moves, pending); +} + +#[test] +fn copied_sheet_base_targets_its_own_sketch_history() { + let mut s = Session::default(); + let part = component(&mut s, "Sheet"); + let sketch = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Base"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch":"Base"})); + let source_volume = volume(&mut s, part); + let copied = copy(&mut s, part); + let new = copied["component"].as_u64().unwrap(); + near(volume(&mut s, new), source_volume); + let clone_sketch = s.doc.features.iter().find(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap().id; + assert_ne!(clone_sketch, sketch); + let base = s.doc.features.iter().find(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::SheetBase { .. })).unwrap(); + assert_eq!(base.kind.input_sketches(), vec![clone_sketch]); + // Mutate only source geometry, then verify the copied base remains unchanged. + if let solvecraft_doc::FeatureKind::Sketch { sketch, .. } = &mut s.doc_mut().feature_mut(sketch).unwrap().kind { + for p in &mut sketch.points { + p.pos.x *= 2.0; + } + } + s.refresh(); + near(volume(&mut s, new), source_volume); +} + +#[test] +fn renamed_pattern_outputs_and_later_body_operands_follow_clone() { + let mut s = Session::default(); + let part = component(&mut s, "PatternPart"); + box2(&mut s); + run(&mut s, "solid.pattern.rectangular", json!({"bodies":["Cube"],"dir1":[1,0,0],"count1":3,"spacing1":5})); + let last = s.model.state().bodies.last().unwrap().name.clone(); + run(&mut s, "body.rename", json!({"body":last,"name":"Custom Output"})); + run(&mut s, "solid.move", json!({"bodies":["Custom Output"],"translate":[0,5,0]})); + let copied = copy(&mut s, part); + let new = copied["component"].as_u64().unwrap(); + near(volume(&mut s, new), 24.0); + let state = s.model.state(); + let target = state.bodies.iter().find(|b| b.name == "Custom Output (copy)").unwrap(); + near(solvecraft_kernel::measure(&target.body).unwrap().centroid.y, 6.0); + assert_eq!(s.doc.features.iter().filter(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::Move { .. })).count(), 1); +} + +#[test] +fn configuration_rows_follow_copied_parameters_material_and_strict_face_lineage() { + let mut s = Session::default(); + let part = component(&mut s, "Configured"); + let source = box2(&mut s); + let length = s.doc.feature(source).unwrap().param_names[0].clone(); + let target = solvecraft_doc::config::ConfigFaceTarget { + body: "Cube".into(), + origin_feature: source, + name: format!("F{source}:+z"), + point: solvecraft_geom::Vec3::new(1.0, 1.0, 2.0), + }; + run(&mut s, "config.configure", json!({"params":[length],"materials":["Cube"],"component_appearances":[part],"face_appearances":[target]})); + let face_key = s.doc.config_key(&solvecraft_doc::config::Column::FaceAppearance(target.clone())); + run( + &mut s, + "config.row", + json!({"name":"Large","values":{length.clone():"4 mm","material:Cube":"Brass",face_key:{"name":"Custom","color":[0,255,0],"opacity":1.0}}}), + ); + let result = copy(&mut s, part); + let new = result["component"].as_u64().unwrap(); + assert_eq!(s.doc.configs.columns.len(), 8); + run(&mut s, "config.activate", json!({"row":"Large"})); + near(volume(&mut s, part), 16.0); + near(volume(&mut s, new), 16.0); + let props = run(&mut s, "component.properties", json!({"component":new})); + near(props["total"]["mass_g"].as_f64().unwrap(), 0.136); + let state = s.model.state(); + let body = state.bodies.iter().find(|b| b.component == new).unwrap(); + assert!(s.doc.face_colors(body).iter().any(|(_, look)| look.color == [0, 255, 0])); + assert!(s.doc.configuration_style_status(&state).is_ok()); + assert!(s.doc.configuration_matches()); +} + +#[test] +fn configured_extrude_cap_copy_keeps_independent_semantic_lineage() { + let mut s = Session::default(); + let part = component(&mut s, "Extruded"); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[2,2]})); + run(&mut s, "sketch.finish", json!({})); + let feature = run(&mut s, "solid.extrude", json!({"distance":2}))["feature"].as_u64().unwrap(); + let body_name = s.model.state().bodies.last().unwrap().name.clone(); + let target = solvecraft_doc::config::ConfigFaceTarget { + body: body_name, + origin_feature: feature, + name: format!("F{feature}:top"), + point: solvecraft_geom::Vec3::new(1.0, 1.0, 2.0), + }; + run(&mut s, "config.configure", json!({"face_appearances":[target]})); + let key = s.doc.config_key(&solvecraft_doc::config::Column::FaceAppearance(target)); + run(&mut s, "config.row", json!({"name":"Painted","values":{key:{"name":"Custom","color":[0,255,0],"opacity":1.0}}})); + let copied = copy(&mut s, part)["component"].as_u64().unwrap(); + run(&mut s, "config.activate", json!({"row":"Painted"})); + let state = s.model.state(); + let body = state.bodies.iter().find(|b| b.component == copied).unwrap(); + assert!(solvecraft_doc::naming::face_names(body).contains(&format!("F{}:top", body.feature))); + assert!(s.doc.face_colors(body).iter().any(|(_, look)| look.color == [0, 255, 0])); + assert!(s.doc.configuration_style_status(&state).is_ok()); + let saved = s.doc.to_json(); + let reopened = solvecraft_doc::Document::from_json(&saved).unwrap(); + let mut model = solvecraft_doc::Model::default(); + model.evaluate(&reopened); + assert!(reopened.configuration_style_status(&model.state()).is_ok()); +} + +#[test] +fn selected_second_occurrence_emboss_copy_remaps_target_and_author_paths_and_reopens() { + let mut s = Session::default(); + let part = component(&mut s, "SleevePart"); + let cylinder = run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}))["feature"].as_u64().unwrap(); + let second = run(&mut s, "occurrence.copy", json!({"component":part,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":part,"occurrence":second,"path":[second]})); + run(&mut s, "sketch.create", json!({"name":"Badge","plane":{"origin":[80,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]}})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[80,10,7.5],"depth":1})); + let original_volume = volume(&mut s, part); + let copied = run(&mut s, "component.paste_new", json!({"component":part,"occurrence":second,"path":[second],"translate":[20,0,0]})); + let new = copied["component"].as_u64().unwrap(); + let placement = copied["occurrence"].as_u64().unwrap(); + let emboss = s.doc.features.iter().find(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::Emboss { .. })).unwrap(); + let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &emboss.kind else { panic!("missing selected face") }; + assert_eq!(face.body, "Sleeve (copy)"); + assert_eq!(face.path, vec![placement]); + assert_eq!(face.author_path, vec![placement]); + near(volume(&mut s, new), original_volume); + let clone_volume = volume(&mut s, new); + let height = s.doc.feature(cylinder).unwrap().param_names[1].clone(); + run(&mut s, "parameters.change", json!({"name":height,"expression":"30 mm"})); + near(volume(&mut s, new), clone_volume); + assert!(volume(&mut s, part) > original_volume); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert!(reopened.doc.features.iter().all(|f| reopened.model.result(f.id).is_some_and(|r| r.error.is_none()))); + near(volume(&mut reopened, new), clone_volume); +} + +#[test] +fn reassigned_sample_plate_paste_new_preserves_complex_frozen_geometry() { + let mut s = Session::default(); + s.run_script(&crate::sample::script()).unwrap(); + let body = s.model.state().bodies.first().unwrap().name.clone(); + let created = run(&mut s, "component.from_bodies", json!({"bodies":[body]})); + let part = created["components"][0]["component"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":part})); + let occurrence = s.doc.occurrence_of(part).unwrap().id; + run(&mut s, "occurrence.ground", json!({"occurrence":occurrence,"grounded":true})); + let original_volume = volume(&mut s, part); + let source_mesh = s.model.state().body(&body).unwrap().mesh(); + let boss_probe = solvecraft_geom::Vec3::new(40.0, 35.0, 12.0); + let source_boss = source_mesh.contains(boss_probe); + let copied = run(&mut s, "component.paste_new", json!({"component":part,"translate":[10,10,0]})); + assert_eq!(s.doc.components.len(), 2); + let new = copied["component"].as_u64().unwrap(); + // Imported curved faces may tessellate differently; keep the same geometric + // accuracy required by copy, and independently probe plate and hole material. + assert!((volume(&mut s, new) - original_volume).abs() < 1e-5 * original_volume); + near(volume(&mut s, part), original_volume); + let state = s.model.state(); + let copied_body = state.bodies.iter().find(|body| body.component == new).unwrap(); + let mesh = copied_body.mesh(); + for (point, expected) in + [([5.0, 25.0, 4.0], true), ([12.0, 25.0, 4.0], false), ([68.0, 25.0, 4.0], false), ([40.0, 25.0, 12.0], false), ([0.0, 0.0, 4.0], false)] + { + assert_eq!(mesh.contains(solvecraft_geom::Vec3::new(point[0], point[1], point[2])), expected, "{point:?}"); + } + assert_eq!(mesh.contains(boss_probe), source_boss); + assert_eq!(state.body(&body).unwrap().mesh().contains(boss_probe), source_boss); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert!((volume(&mut reopened, new) - original_volume).abs() < 1e-5 * original_volume); +} + +#[test] +fn generated_copy_parameters_cannot_heal_an_unrelated_missing_reference() { + let mut s = Session::default(); + let part = component(&mut s, "Valid"); + let valid = box2(&mut s); + run(&mut s, "component.activate", json!({"component":0})); + let kind = s.doc.feature(valid).unwrap().kind.clone(); + let broken = s.doc_mut().add_feature(kind, Some("Broken reference")).unwrap(); + let mut probe = (*s.doc).clone(); + let missing = probe.new_model_param("2 mm", "mm"); + let feature = s.doc_mut().feature_mut(broken).unwrap(); + feature.component = 0; + if let solvecraft_doc::FeatureKind::Box { width, .. } = &mut feature.kind { + *width = missing; + } + s.refresh(); + let original_error = s.model.result(broken).unwrap().error.clone(); + assert!(original_error.is_some()); + let saved = s.doc.to_json(); + let context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch); + let selection = s.selection.clone(); + let pending = s.pending_moves.clone(); + let error = s.execute("component.paste_new", &json!({"component":part})).unwrap_err(); + assert!(error.to_string().contains("original feature's error state"), "{error}"); + assert_eq!(s.doc.to_json(), saved); + assert_eq!(s.model.result(broken).unwrap().error, original_error); + assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), context); + assert_eq!(s.selection, selection); + assert_eq!(s.pending_moves, pending); +} diff --git a/crates/engine/src/cmd/config.rs b/crates/engine/src/cmd/config.rs index 993f1b9b..7e61db30 100644 --- a/crates/engine/src/cmd/config.rs +++ b/crates/engine/src/cmd/config.rs @@ -28,8 +28,66 @@ pub static COMMANDS: &[CommandSpec] = &[ ]; fn column_of(s: &Session, p: &Value, cmd: &str) -> Result { - if ["param", "feature", "material", "appearance"].iter().filter(|k| p.get(**k).is_some()).count() != 1 { - return Err(bad(cmd, "give exactly one of `param`, `feature`, `material`, or `appearance`")); + if ["param", "feature", "material", "appearance", "component_appearance", "face_appearance"].iter().filter(|k| p.get(**k).is_some()).count() != 1 + { + return Err(bad(cmd, "give exactly one configuration target")); + } + if let Some(value) = p.get("component_appearance") { + let key = value + .as_str() + .map(str::to_string) + .or_else(|| value.as_u64().map(|id| id.to_string())) + .ok_or_else(|| bad(cmd, "component name or ID required"))?; + if bool_(p, "delete") == Some(true) + && let Some(column) = s.doc.configs.columns.iter().find(|column| matches!(column,Column::ComponentAppearance(id) if id.to_string()==key)) + { + return Ok(column.clone()); + } + let id = s + .doc + .find_component(&key) + .or_else(|| s.doc.occurrences.iter().find(|occurrence| occurrence.name == key).map(|occurrence| occurrence.component)) + .filter(|id| *id != 0) + .ok_or_else(|| bad(cmd, "no component definition"))?; + return Ok(Column::ComponentAppearance(id)); + } + if let Some(value) = p.get("face_appearance") { + if bool_(p, "delete") == Some(true) { + let key = value.as_str().ok_or_else(|| bad(cmd, "face column key required for deletion"))?; + return s + .doc + .configs + .columns + .iter() + .find(|column| matches!(column, Column::FaceAppearance(_)) && s.doc.config_key(column) == key) + .cloned() + .ok_or_else(|| bad(cmd, "no face column")); + } + let body_name = value.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "face body required"))?; + let point = value.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "finite face point required"))?; + let world = s.world_state(); + let body = world.body(body_name).ok_or_else(|| bad(cmd, "no face body"))?; + if body.body.is_mesh() { + return Err(bad(cmd, "mesh faces have no supported semantic lineage")); + } + let index = solvecraft_doc::appearance::face_index_at(body, point).ok_or_else(|| bad(cmd, "no face at picked point"))?; + let names = solvecraft_doc::naming::face_names(body); + let name = names.get(index).map(|name| solvecraft_doc::naming::strip_piece(name).to_string()).ok_or_else(|| bad(cmd, "face name missing"))?; + let origin_feature = name + .strip_prefix('F') + .and_then(|name| name.split_once(':')) + .and_then(|(id, _)| id.parse::().ok()) + .ok_or_else(|| bad(cmd, "face lineage missing"))?; + let inverse = solvecraft_doc::mat_inverse(&body.source_transform).ok_or_else(|| bad(cmd, "invalid definition placement transform"))?; + let target = solvecraft_doc::config::ConfigFaceTarget { + body: body.definition_name().into(), + origin_feature, + name, + point: solvecraft_doc::apply_point(&inverse, point), + }; + s.doc.certify_config_face(&target).map_err(|error| bad(cmd, error.to_string()))?; + s.doc.configuration_face_indices(body, &target).map_err(|error| bad(cmd, error.to_string()))?; + return Ok(Column::FaceAppearance(target)); } for (key, appearance) in [("material", false), ("appearance", true)] { if let Some(body) = str_(p, key) { @@ -53,7 +111,9 @@ fn column_of(s: &Session, p: &Value, cmd: &str) -> Result { /// Add a column, filling existing rows with the design's current value. fn add_column(s: &mut Session, c: Column, cmd: &str) -> Result<()> { - if s.doc.configs.columns.contains(&c) { + if s.doc.configs.columns.iter().any(|existing| { + *existing == c || matches!((existing,&c),(Column::FaceAppearance(a),Column::FaceAppearance(b)) if a.body == b.body && a.name == b.name) + }) { return Ok(()); } if s.doc.configs.columns.len() >= MAX_COLUMNS { @@ -85,6 +145,18 @@ fn configure(s: &mut Session, p: &Value) -> Result { add_column(s, c, cmd)?; } } + for (plural, singular) in [("component_appearances", "component_appearance"), ("face_appearances", "face_appearance")] { + if let Some(values) = p.get(plural) { + let values = values + .as_array() + .filter(|values| values.len() <= MAX_COLUMNS) + .ok_or_else(|| bad(cmd, "configuration targets must be a bounded array"))?; + for value in values { + let c = column_of(s, &json!({singular:value}), cmd)?; + add_column(s, c, cmd)?; + } + } + } if s.doc.configs.rows.is_empty() { let cells = s.doc.configs.columns.iter().map(|c| s.doc.config_current(c).unwrap_or_default()).collect(); let t = &mut s.doc_mut().configs; @@ -102,7 +174,26 @@ fn show(s: &mut Session, _p: &Value) -> Result { .iter() .map(|r| json!({"name": r.name, "values": keys.iter().zip(&r.cells).map(|(k, v)| (k.clone(), json!(v))).collect::>()})) .collect(); - Ok(json!({"columns": keys, "rows": rows, "rules": t.rules, "active": t.active, "matches": s.doc.configuration_matches()})) + let targets: Vec = t + .columns + .iter() + .map(|column| { + let label = match column { + Column::ComponentAppearance(id) => { + format!("Component: {}", s.doc.components.iter().find(|c| c.id == *id).map(|c| c.name.clone()).unwrap_or_else(|| id.to_string())) + } + Column::FaceAppearance(target) => { + format!("Face: {} / {}", target.body, target.name.split_once(':').map(|(_, role)| role).unwrap_or(&target.name)) + } + _ => s.doc.config_key(column), + }; + json!({"key":s.doc.config_key(column),"label":label,"target":column}) + }) + .collect(); + let styles = s.doc.configuration_style_status(&s.model.state()); + Ok( + json!({"columns": keys, "targets":targets, "rows": rows, "rules": t.rules, "active": t.active, "matches": s.doc.configuration_matches() && styles.is_ok(), "style_status":styles.as_ref().ok(),"style_error":styles.err().map(|error|error.to_string())}), + ) } fn column(s: &mut Session, p: &Value) -> Result { @@ -154,7 +245,9 @@ fn row(s: &mut Session, p: &Value) -> Result { Value::String(t) => t.clone(), Value::Bool(b) => b.to_string(), Value::Number(n) => n.to_string(), - Value::Object(_) if k.starts_with("appearance:") => serde_json::to_string(v).map_err(|e| bad(cmd, e.to_string()))?, + Value::Object(_) if k.starts_with("appearance:") || k.starts_with("component_appearance:") || k.starts_with("face_appearance:") => { + serde_json::to_string(v).map_err(|e| bad(cmd, e.to_string()))? + } _ => return Err(bad(cmd, format!("value for `{k}` must be an expression or true/false"))), }; if text.len() > 4096 { @@ -170,7 +263,7 @@ fn row(s: &mut Session, p: &Value) -> Result { || match c { Column::Param(n) => n == k, Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == *k), - Column::Material(_) | Column::Appearance(_) => false, + Column::Material(_) | Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_) => false, } }) .ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?; @@ -178,7 +271,7 @@ fn row(s: &mut Session, p: &Value) -> Result { Some(Column::Material(_)) => { text = solvecraft_doc::config::material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into()); } - Some(Column::Appearance(_)) => { + Some(Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_)) => { text = match solvecraft_doc::config::appearance_cell(&text).map_err(|e| bad(cmd, e.to_string()))? { Some(look) => serde_json::to_string(&look).map_err(|e| bad(cmd, e.to_string()))?, None => String::new(), @@ -303,12 +396,14 @@ fn activate(s: &mut Session, p: &Value) -> Result { if !new_errors.is_empty() { return Err(bad(cmd, format!("configuration `{r}` would fail to rebuild: {}", new_errors.join("; ")))); } + let styles = trial.doc.configuration_style_status(&trial.model.state()).map_err(|error| bad(cmd, format!("configuration `{r}`: {error}")))?; s.doc = trial.doc; s.refresh(); let errors: Vec = s.model.results.iter().filter_map(|x| x.error.clone()).collect(); let mut out = show(s, p)?; out["errors"] = json!(errors); out["trace"] = json!(trace); + out["styles"] = json!(styles); Ok(out) } @@ -320,6 +415,11 @@ fn preview(s: &mut Session, p: &Value) -> Result { Ok(out) } +#[cfg(test)] +#[path = "config_style_tests.rs"] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod style_tests; + #[cfg(test)] mod tests { use serde_json::{Value, json}; diff --git a/crates/engine/src/cmd/config_style_tests.rs b/crates/engine/src/cmd/config_style_tests.rs new file mode 100644 index 00000000..2580ea1d --- /dev/null +++ b/crates/engine/src/cmd/config_style_tests.rs @@ -0,0 +1,225 @@ +use crate::Session; +use serde_json::{Value, json}; +use solvecraft_doc::config::{Column, ConfigFaceTarget}; +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}")) +} +fn block(s: &mut Session) -> String { + run(s, "solid.box", json!({"length":10,"width":10,"height":10})); + s.model.state().bodies[0].name.clone() +} +fn face_column(s: &mut Session, body: &str, point: Value) -> String { + run(s, "config.configure", json!({"face_appearances":[{"body":body,"point":point}]}))["columns"][0].as_str().unwrap().into() +} + +#[test] +fn component_appearance_shared_definition_inheritance_and_delete() { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Part"}))["component"].as_u64().unwrap(); + let body = block(&mut s); + run(&mut s, "material.assign", json!({"bodies":[body],"material":"Steel"})); + run(&mut s, "occurrence.copy", json!({"component":component,"translate":[30,0,0]})); + let table = run(&mut s, "config.configure", json!({"component_appearances":[component]})); + let key = table["columns"][0].as_str().unwrap().to_string(); + run(&mut s, "config.row", json!({"name":"Red","values":{key.clone():"ABS - Red"}})); + run(&mut s, "config.activate", json!({"row":"Red"})); + let world = s.world_state(); + assert_eq!(world.bodies.len(), 2); + assert!(world.bodies.iter().all(|body| s.doc.model_body_look(body).unwrap().name == "ABS - Red")); + run(&mut s, "appearance.assign", json!({"bodies":[body],"appearance":"ABS - Yellow"})); + assert!(s.world_state().bodies.iter().all(|body| s.doc.model_body_look(body).unwrap().name == "ABS - Yellow")); + run(&mut s, "config.activate", json!({"row":"Default"})); + assert!(!s.doc.appearances.components.contains_key(&component)); + assert_eq!(s.doc.appearances.bodies[&body].name, "ABS - Yellow"); + run(&mut s, "component.rename", json!({"component":component,"name":"Renamed"})); + assert_eq!(run(&mut s, "config.table", json!({}))["columns"][0], key); + run(&mut s, "appearance.assign", json!({"components":[component.to_string()],"appearance":"ABS - Blue"})); + run(&mut s, "component.delete", json!({"component":component})); + assert!(s.doc.configs.columns.is_empty()); + assert!(s.doc.configs.rows.iter().all(|row| row.cells.is_empty())); + assert!(!s.doc.appearances.components.contains_key(&component)); +} + +#[test] +fn strict_face_rows_resize_rename_preview_suppress_and_roundtrip() { + let mut s = Session::default(); + let body = block(&mut s); + let key = face_column(&mut s, &body, json!([5, 5, 10])); + let feature = s.doc.features[0].name.clone(); + let height = s.doc.features[0].param_names[2].clone(); + run(&mut s, "config.column", json!({"param":height})); + run(&mut s, "config.column", json!({"feature":feature})); + run(&mut s, "config.row", json!({"name":"Tall","values":{key:"ABS - Red",height:"20 mm"}})); + let before = s.doc.clone(); + let revision = s.revision; + let history = s.undo.len(); + let preview = run(&mut s, "config.preview", json!({"row":"Tall"})); + assert_eq!(s.doc, before); + assert_eq!(s.revision, revision); + assert_eq!(s.undo.len(), history); + assert_eq!(preview["styles"][0]["dormant"], false); + run(&mut s, "config.activate", json!({"row":"Tall"})); + let state = s.model.state(); + let mb = state.body(&body).unwrap(); + let colors = s.doc.face_colors(mb); + assert_eq!(colors.len(), 1); + let names = solvecraft_doc::naming::face_names(mb); + assert!(names[colors[0].0].ends_with(":+z")); + assert!(s.doc.appearances.faces[0].strict_name); + run(&mut s, "appearance.assign", json!({"faces":[{"body":body,"point":[5,5,20]}]})); + assert!(s.doc.appearances.faces.is_empty(), "clear resolves semantic name after configured resize"); + run(&mut s, "config.activate", json!({"row":"Tall"})); + run(&mut s, "body.rename", json!({"body":body,"name":"Housing"})); + let key = s.doc.config_key(&s.doc.configs.columns[0]); + let suppress = format!("suppress:{feature}"); + run(&mut s, "config.row", json!({"name":"Hidden","values":{key:"ABS - Blue",suppress:true}})); + let hidden = run(&mut s, "config.activate", json!({"row":"Hidden"})); + assert_eq!(hidden["styles"][0]["dormant"], true); + assert!(s.model.state().bodies.is_empty()); + let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap(); + assert_eq!(reopened, *s.doc); + run(&mut s, "config.activate", json!({"row":"Tall"})); + assert_eq!(s.doc.face_colors(s.model.state().body("Housing").unwrap())[0].1.name, "ABS - Red"); + run(&mut s, "config.activate", json!({"row":"Default"})); + assert!(s.doc.appearances.faces.is_empty()); + let before_delete = s.doc.clone(); + run(&mut s, "timeline.delete", json!({"feature":feature})); + assert!(s.doc.configs.columns.is_empty()); + assert!(s.doc.configs.rows.iter().all(|row| row.cells.is_empty())); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, before_delete); + run(&mut s, "config.activate", json!({"row":"Tall"})); + assert!(s.doc.appearances.faces[0].strict_name); +} + +#[test] +fn strict_face_split_pieces_share_lineage_without_index_targets() { + let mut s = Session::default(); + let body = block(&mut s); + let key = face_column(&mut s, &body, json!([5, 5, 10])); + run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Red"}})); + run(&mut s, "solid.split_face", json!({"body":body,"faces":[[5,5,10]],"plane":{"origin":[5,0,0],"normal":[1,0,0]}})); + run(&mut s, "config.activate", json!({"row":"Paint"})); + let state = s.model.state(); + let body = state.body(&body).unwrap(); + let colors = s.doc.face_colors(body); + let names = solvecraft_doc::naming::face_names(body); + assert!(colors.len() >= 2, "split should create multiple painted pieces: {names:?}"); + assert!(colors.iter().all(|(index, look)| solvecraft_doc::naming::strip_piece(&names[*index]).ends_with(":+z") && look.name == "ABS - Red")); +} + +#[test] +fn strict_face_missing_lineage_never_retargets_and_rolls_back() { + let mut s = Session::default(); + let body = block(&mut s); + let key = face_column(&mut s, &body, json!([5, 5, 10])); + run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Red"}})); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[30,0,0],"operation":"new"})); + let other = s.doc.features.last().unwrap().id; + let Column::FaceAppearance(target) = &mut s.doc_mut().configs.columns[0] else { panic!() }; + target.origin_feature = other; + target.name = format!("F{other}:+z"); + let before = s.doc.clone(); + let revision = s.revision; + let history = s.undo.len(); + assert!(s.execute("config.activate", &json!({"row":"Paint"})).is_err()); + assert!(s.execute("config.preview", &json!({"row":"Paint"})).is_err()); + assert_eq!(s.doc, before); + assert_eq!(s.revision, revision); + assert_eq!(s.undo.len(), history); + let look = solvecraft_doc::appearance::library("ABS - Red").unwrap(); + s.doc_mut().appearances.faces.push(solvecraft_doc::appearance::FaceLook { + body: body.clone(), + point: solvecraft_geom::Vec3::new(5., 5., 10.), + name: Some(format!("F{other}:+z")), + strict_name: true, + look, + }); + assert!(s.doc.face_colors(s.model.state().body(&body).unwrap()).is_empty()); + s.doc_mut().appearances.faces[0].strict_name = false; + assert_eq!(s.doc.face_colors(s.model.state().body(&body).unwrap()).len(), 1); +} + +#[test] +fn face_occurrence_pick_uses_definition_identity_and_coordinates() { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Part"}))["component"].as_u64().unwrap(); + let body = block(&mut s); + run(&mut s, "occurrence.copy", json!({"component":component,"translate":[30,0,0]})); + let world = s.world_state(); + let placed = world.bodies.iter().find(|body| body.name != body.definition_name()).unwrap(); + let placed_name = placed.name.clone(); + let point = solvecraft_doc::apply_point(&placed.source_transform, solvecraft_geom::Vec3::new(5., 5., 10.)); + let key = face_column(&mut s, &placed_name, json!(point)); + let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() }; + assert_eq!(target.body, body); + assert!(target.point.dist(solvecraft_geom::Vec3::new(5., 5., 10.)) < 1e-8); + run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Blue"}})); + run(&mut s, "config.activate", json!({"row":"Paint"})); + assert!(s.world_state().bodies.iter().all(|body| s.doc.face_colors(body).len() == 1)); +} + +#[test] +fn extrude_cap_certification_follows_actual_positive_negative_and_symmetric_names() { + for direction in ["positive", "negative", "symmetric"] { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"xy"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":10,"direction":direction,"body_name":"Pad"})); + let state = s.model.state(); + let body = state.body("Pad").unwrap(); + let faces = body.body.faces(0.01).unwrap(); + let names = solvecraft_doc::naming::face_names(body); + let mut caps = 0; + for face in faces { + let name = solvecraft_doc::naming::strip_piece(&names[face.index]); + let origin = name.strip_prefix('F').unwrap().split_once(':').unwrap().0.parse().unwrap(); + let target = ConfigFaceTarget { body: "Pad".into(), origin_feature: origin, name: name.into(), point: face.centroid }; + if name.ends_with(":start") || name.ends_with(":top") || name.ends_with(":bottom") { + s.doc.certify_config_face(&target).unwrap(); + s.doc.configuration_face_indices(body, &target).unwrap(); + caps += 1; + } else { + assert!(s.doc.certify_config_face(&target).is_err()); + } + } + assert_eq!(caps, 2, "{direction}"); + } +} + +#[test] +fn ordinary_extent_edits_never_transfer_strict_cap_paint_to_ambiguous_caps() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"xy"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Pad","name":"PadFeature","start_offset":"0 mm"})); + let key = face_column(&mut s, "Pad", json!([5, 5, 10])); + run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Red"}})); + run(&mut s, "config.activate", json!({"row":"Paint"})); + assert_eq!(s.doc.face_colors(s.model.state().body("Pad").unwrap()).len(), 1); + run(&mut s, "timeline.redefine", json!({"feature":"PadFeature","command":"solid.extrude","params":{"distance":10,"start_offset":5}})); + assert!(s.doc.face_colors(s.model.state().body("Pad").unwrap()).is_empty(), "ordinary offset edit must not paint both new top-named caps"); + let before = s.doc.clone(); + assert!(s.execute("config.activate", &json!({"row":"Paint"})).is_err()); + assert_eq!(s.doc, before); +} + +#[test] +fn hostile_and_uncertified_face_targets_are_rejected() { + let mut s = Session::default(); + let body = block(&mut s); + let id = s.doc.features[0].id; + let target = ConfigFaceTarget { body: body.clone(), origin_feature: id, name: format!("F{id}:face:0"), point: solvecraft_geom::Vec3::ZERO }; + assert!(s.doc.certify_config_face(&target).is_err()); + for value in [ + json!({"body":body,"origin_feature":id,"name":"x".repeat(1_000_000),"point":[0,0,0]}), + json!({"body":body,"origin_feature":id,"name":format!("F{id}:+z"),"point":[0,0,1e20]}), + json!({"body":body,"origin_feature":id,"name":format!("F{id}:+z"),"point":[0,0,0],"extra":[[]]}), + ] { + assert!(solvecraft_doc::config::validate_styles_json(&json!({"configs":{"columns":[{"face_appearance":value}]}})).is_err()); + } + assert!(s.execute("config.column", &json!({"component_appearance":0})).is_err()); + assert!(s.execute("config.column", &json!({"face_appearance":{"body":body,"point":[0,0,1e20]}})).is_err()); +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index d6a975a4..89497f14 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -355,6 +355,9 @@ fn redefine(s: &mut Session, p: &Value) -> Result { // selections, component authoring state or the original feature's definition. let mut trial = s.scratch(); trial.active_component = old.component; + if let FeatureKind::Emboss { face: Some(face), .. } = &old.kind { + trial.active_occurrence_path = (!face.author_path.is_empty()).then(|| face.author_path.clone()); + } trial.active_sketch = None; trial.selection = s.selection.clone(); trial.doc_mut().marker = Some(idx); @@ -362,7 +365,15 @@ fn redefine(s: &mut Session, p: &Value) -> Result { (spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?; let r = (spec.run)(&mut trial, ¶ms)?; let made = r.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?; - let new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?; + let mut new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?; + if let (FeatureKind::Emboss { face: Some(before), .. }, FeatureKind::Emboss { face: Some(after), .. }) = (&old.kind, &mut new.kind) + && before.body == after.body + && before.path == after.path + && before.author_path == after.author_path + && before.at.dist(after.at) < 1e-8 + { + *after = before.clone(); + } if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) { return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid))); } diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 13641d0f..88082a8f 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -321,7 +321,24 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu }; let face_names = { let pts = kind.face_points(); - if pts.is_empty() { Vec::new() } else { solvecraft_doc::naming::face_names_at(&s.model.state(), &pts) } + if let FeatureKind::Emboss { face: Some(face), .. } = &kind { + let state = s.model.state(); + let body = state.body(&face.body).ok_or_else(|| EngineError::Other("selected cylinder body no longer exists".into()))?; + let frame = face.body_frame(&s.doc, body.feature).map_err(|e| EngineError::Other(e.to_string()))?; + let inverse = solvecraft_doc::mat_inverse(&frame).ok_or_else(|| EngineError::Other("invalid cylinder placement".into()))?; + let at = solvecraft_doc::apply_point(&solvecraft_doc::mat_mul(&inverse, &s.active_component_frame()), face.at); + let mut scoped = solvecraft_doc::ModelState::default(); + scoped.bodies.push(body.clone()); + let names = solvecraft_doc::naming::face_names_at(&scoped, &[at]); + if names.len() != 1 || names.first().is_none_or(|name| name.is_empty()) { + return Err(EngineError::Other("selected cylinder face has no persistent identity".into())); + } + names + } else if pts.is_empty() { + Vec::new() + } else { + solvecraft_doc::naming::face_names_at(&s.model.state(), &pts) + } }; let participants = string_list(p, "participants"); if participants.len() > 10_000 || participants.iter().any(|b| b != "*" && s.model.state().body(b).is_none()) { diff --git a/crates/engine/src/cmd/features_more.rs b/crates/engine/src/cmd/features_more.rs index d98a6c23..c7ebedc6 100644 --- a/crates/engine/src/cmd/features_more.rs +++ b/crates/engine/src/cmd/features_more.rs @@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.emboss", "Emboss", emboss) .at("SOLID", "CREATE") .icon("emboss") - .params("sketch (on a planar face), profiles?; depth; mode?: emboss|deboss (default emboss); targets?"), + .params("sketch, profiles?; depth; mode?: emboss|deboss; targets?; face?: world point [x,y,z] selecting a planar or complete exterior cylindrical wall (required for wrapped orthogonal line profiles)"), CommandSpec::new("solid.replace_face", "Replace Face", replace_face) .at("SOLID", "MODIFY") .icon("offset_face") @@ -143,12 +143,97 @@ fn emboss(s: &mut Session, p: &Value) -> Result { Some("deboss") => true, Some(m) => return Err(bad(cmd, format!("unknown mode `{m}` (emboss or deboss)"))), }; - if let Some(fp) = p.get("face").and_then(vec3) - && super::face::face_normal(s, fp).is_none() - { - return Err(bad(cmd, "not supported yet: embossing onto a curved face")); - } - add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets: string_list(p, "targets") }) + let mut targets = string_list(p, "targets"); + let face = if let Some(value) = p.get("face") { + let local = vec3(value).ok_or_else(|| bad(cmd, "`face` must be a finite point [x,y,z]"))?; + let at = solvecraft_doc::apply_point(&s.active_component_frame(), local); + let state = s.world_state(); + let mut candidates = Vec::new(); + for body in &state.bodies { + let definition = body.definition_name(); + if !targets.is_empty() && !targets.iter().any(|n| n == definition || n == &body.name) { + continue; + } + let mesh = body.mesh(); + let closest = mesh + .triangles + .iter() + .zip(&mesh.tri_face) + .filter_map(|(tri, face)| { + let [a, b, c] = mesh.tri(tri)?; + let n = (b - a).cross(c - a).normalized()?; + let q = at - n * (at - a).dot(n); + let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-9); + inside.then_some(((at - a).dot(n).abs(), *face as usize)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)); + if let Some((distance, index)) = closest + && distance < (body.body.size() * 1e-3).max(1e-3) + { + candidates.push((body, index)); + } + } + if candidates.len() != 1 { + return Err(bad(cmd, "`face` must identify exactly one target body surface")); + } + let (selected, index) = candidates.first().copied().ok_or_else(|| bad(cmd, "no target face"))?; + let name = selected.definition_name(); + let body = &selected.body; + match solvecraft_kernel::face_surface(body, index) { + Some(solvecraft_kernel::FaceSurface::Plane { .. }) => { + targets = vec![name.to_owned()]; + None + } + Some(solvecraft_kernel::FaceSurface::Cylinder { .. }) => { + if targets.len() > 1 { + return Err(bad(cmd, "cylinder emboss needs one selected target body")); + } + // Viewport picks lie on tessellated chords. Keep their axial and + // angular position, and certify their distance before storing an + // exact surface anchor for later replay. + let at = solvecraft_kernel::cylinder_emboss_anchor(body, index, at).map_err(|e| bad(cmd, e.to_string()))?; + let original = s.model.state(); + let owner = s.doc.body_component(name, selected.feature); + let paths: Vec<_> = s + .doc + .placements() + .into_iter() + .filter(|(comp, path, _)| *comp == owner && s.doc.world_body_name(&original, name, owner, path) == selected.name) + .map(|(_, path, _)| path) + .collect(); + if paths.len() != 1 { + return Err(bad(cmd, "selected cylinder placement is ambiguous")); + } + let path = paths.into_iter().next().ok_or_else(|| bad(cmd, "selected cylinder placement"))?; + if path.len() > 64 { + return Err(bad(cmd, "selected cylinder placement is too deep")); + } + let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component)); + if author_path.len() > 64 { + return Err(bad(cmd, "author placement is too deep")); + } + let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?; + let sketch_path = if sketch_owner == owner { + path.clone() + } else if sketch_owner == s.active_component { + author_path.clone() + } else { + s.doc.first_path(sketch_owner) + }; + if path.iter().chain(&author_path).chain(&sketch_path).any(|id| s.pending_moves.contains_key(id)) { + return Err(bad(cmd, "capture pending source, author and profile positions with Capture Position before cylinder emboss")); + } + let inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid active frame"))?; + let at = solvecraft_doc::apply_point(&inverse, at); + targets = vec![name.to_owned()]; + Some(solvecraft_doc::EmbossFace { body: name.to_owned(), at, path, author_path }) + } + _ => return Err(bad(cmd, "only planar faces and complete exterior cylindrical walls support emboss")), + } + } else { + None + }; + add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face }) } fn face_points(p: &Value, k: &str, cmd: &str) -> Result> { diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 078464e1..c4b9bb4c 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -81,6 +81,268 @@ fn emboss_and_deboss_on_a_face() { assert!(s.execute("solid.emboss", &json!({"sketch": "Logo", "depth": 1, "mode": "sideways"})).is_err()); } +fn cylinder_badge() -> Session { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name":"badge_depth","expression":"1 mm"})); + run(&mut s, "solid.cylinder", json!({"radius":"10 mm","height":"20 mm","body_name":"Sleeve"})); + run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + s +} + +fn exact_cylinder_volume(s: &Session) -> f64 { + s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume +} + +#[test] +fn cylinder_emboss_persists_selection_and_replays_radial_depth() { + use solvecraft_geom::Vec3; + let mut s = cylinder_badge(); + let before = exact_cylinder_volume(&s); + run( + &mut s, + "solid.emboss", + json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":"badge_depth","targets":["Sleeve"],"name":"Raised Badge"}), + ); + assert!((exact_cylinder_volume(&s) - before - 21.0).abs() < 0.02); + let state = s.model.state(); + let body = &state.body("Sleeve").unwrap().body; + assert_eq!(body.shell_count(), 1); + let mesh = body.tessellate_with(0.001, true).unwrap(); + assert!(mesh.contains(Vec3::new(-10.5 * 0.15_f64.sin(), 10.5 * 0.15_f64.cos(), 7.5))); + let feature = s.doc.find_feature("Raised Badge").unwrap(); + assert!( + matches!(&feature.kind,solvecraft_doc::FeatureKind::Emboss{face:Some(face),..} if face.body=="Sleeve"&&face.at.dist(Vec3::new(0.,10.,7.5))<1e-10) + ); + let saved = s.doc.to_json(); + let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&saved).unwrap().into(), ..Default::default() }; + loaded.refresh(); + assert!(loaded.model.results.iter().all(|f| f.error.is_none()), "{:?}", loaded.model.results); + assert!((exact_cylinder_volume(&loaded) - before - 21.0).abs() < 0.02); + run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2 mm"})); + assert!(loaded.model.results.iter().all(|f| f.error.is_none()), "{:?}", loaded.model.results); + assert!((exact_cylinder_volume(&loaded) - before - 44.0).abs() < 0.02); +} + +#[test] +fn cylinder_deboss_is_connected_and_unsupported_profile_failure_is_atomic() { + let mut s = cylinder_badge(); + let before = exact_cylinder_volume(&s); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[0,10,7.5],"depth":1,"mode":"deboss","targets":["Sleeve"]})); + assert!((exact_cylinder_volume(&s) - before + 19.0).abs() < 0.02); + assert_eq!(s.model.state().body("Sleeve").unwrap().body.shell_count(), 1); + let mut bad = cylinder_badge(); + run(&mut bad, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Circle Badge"})); + run(&mut bad, "sketch.circle.center", json!({"center":[0,7.5],"radius":1})); + run(&mut bad, "sketch.finish", json!({})); + let saved = bad.doc.to_json(); + let v = exact_cylinder_volume(&bad); + assert!(bad.execute("solid.emboss", &json!({"sketch":"Circle Badge","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]})).is_err()); + assert_eq!(bad.doc.to_json(), saved); + assert_eq!(exact_cylinder_volume(&bad), v); +} + +#[test] +fn cylinder_small_chord_pick_is_saved_on_the_exact_surface() { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius":"0.1mm","height":"0.2mm","body_name":"Tiny"})); + run(&mut s, "sketch.create", json!({"plane":{"origin":[0,0.1,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-0.02,0.05],"p1":[0.02,0.1]})); + run(&mut s, "sketch.finish", json!({})); + let mesh = s.model.state().bodies.first().unwrap().mesh(); + let pick = mesh + .triangles + .iter() + .filter_map(|tri| mesh.tri(tri)) + .map(|[a, b, c]| (a + b + c) / 3.) + .find(|p| p.y > 0.08 && p.z > 0.04 && p.z < 0.16) + .unwrap(); + assert!(((pick.x * pick.x + pick.y * pick.y).sqrt() - 0.1).abs() > 1e-7); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[pick.x,pick.y,pick.z],"depth":"0.01mm","targets":["Tiny"]})); + let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else { + panic!("missing face"); + }; + assert!(((face.at.x * face.at.x + face.at.y * face.at.y).sqrt() - 0.1).abs() < 1e-12); + assert_eq!(s.model.state().bodies.first().unwrap().body.shell_count(), 1); +} + +#[test] +fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() { + for pick_offset in [0., 80.] { + let mut s = Session::default(); + let a = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap(); + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); + let source_feature = s.model.state().bodies.first().unwrap().feature; + let source_names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap()); + let before = exact_cylinder_volume(&s); + run(&mut s, "occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]})); + let second = run(&mut s, "occurrence.copy", json!({"component":a,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":"root"})); + let author = run(&mut s, "component.create", json!({"name":"Author"}))["component"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":"Author:1","axis":[1,0,0],"angle":"90 deg","translate":[-100,40,30]})); + let author_second = run(&mut s, "occurrence.copy", json!({"component":author,"translate":[0,100,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":author,"occurrence":author_second})); + run(&mut s, "parameters.add", json!({"name":"badge_depth","expression":"1mm"})); + run(&mut s, "sketch.create", json!({"plane":{"origin":[40.+pick_offset,0,0],"x_dir":[0,-1,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + let placements = serde_json::to_value(&s.doc.occurrences).unwrap(); + let target = s + .world_state() + .bodies + .iter() + .find(|b| b.body.tessellate_with(0.001, true).unwrap().contains(solvecraft_geom::Vec3::new(40.5 + pick_offset, 0., 7.5))) + .unwrap() + .name + .clone(); + run( + &mut s, + "solid.emboss", + json!({"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"badge_depth","targets":[target.clone()]}), + ); + let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else { + panic!("missing selection"); + }; + assert_eq!(face.path, if pick_offset == 0. { s.doc.first_path(a) } else { vec![second] }); + assert_eq!(face.author_path, vec![author_second]); + let state = s.model.state(); + let body = state.body("Sleeve").unwrap(); + assert_eq!(body.feature, source_feature); + assert_eq!(s.doc.body_component("Sleeve", body.feature), a); + assert_eq!(serde_json::to_value(&s.doc.occurrences).unwrap(), placements); + let selected = s.doc.features.last().unwrap().face_names.first().unwrap(); + assert!(!selected.is_empty() && source_names.contains(selected), "{selected:?} not in {source_names:?}"); + assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02); + let world = s.world_state(); + for x in [39.5, 119.5] { + assert!(world.bodies.iter().any(|b| b.body.tessellate_with(0.001, true).unwrap().contains(solvecraft_geom::Vec3::new(x, 0., 7.5)))); + } + let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() }; + loaded.refresh(); + assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results); + run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2mm"})); + assert!((exact_cylinder_volume(&loaded) - before - 44.).abs() < 0.02); + assert_eq!(loaded.doc.body_component("Sleeve", loaded.model.state().body("Sleeve").unwrap().feature), a); + assert_eq!(serde_json::to_value(&loaded.doc.occurrences).unwrap(), placements); + let feature = loaded.doc.features.last().unwrap().clone(); + run(&mut loaded, "component.activate", json!({"component":"root"})); + run( + &mut loaded, + "timeline.redefine", + json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"badge_depth","targets":[target.clone()]}}), + ); + assert_eq!(loaded.doc.feature(feature.id).unwrap().kind, feature.kind); + assert_eq!(loaded.doc.feature(feature.id).unwrap().face_names, feature.face_names); + run( + &mut loaded, + "timeline.redefine", + json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"3mm","targets":[target]}}), + ); + assert!((exact_cylinder_volume(&loaded) - before - 69.).abs() < 0.02); + let mut broken = loaded.doc.as_ref().clone(); + let id = broken.features.last().unwrap().id; + let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &mut broken.feature_mut(id).unwrap().kind else { + panic!("missing selection"); + }; + face.path = vec![u64::MAX]; + let mut invalid = Session { doc: broken.into(), ..Default::default() }; + invalid.refresh(); + assert!(invalid.model.results.iter().find(|r| r.id == id).unwrap().error.as_ref().unwrap().contains("placement")); + assert!((exact_cylinder_volume(&invalid) - before).abs() < 0.02); + } +} + +#[test] +fn cylinder_active_second_source_occurrence_authors_shared_geometry() { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap(); + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); + let before = exact_cylinder_volume(&s); + let second = run(&mut s, "occurrence.copy", json!({"component":component,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":component,"occurrence":second})); + run(&mut s, "sketch.create", json!({"plane":{"origin":[80,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[80,10,7.5],"depth":1})); + assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02); + let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else { + panic!("missing selection"); + }; + assert_eq!(face.path, vec![second]); + assert_eq!(face.author_path, vec![second]); + let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() }; + loaded.refresh(); + assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results); + assert!((exact_cylinder_volume(&loaded) - before - 21.).abs() < 0.02); +} + +#[test] +fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() { + for ancestor in [false, true] { + let mut s = Session::default(); + let parent = run(&mut s, "component.create", json!({"name":"Parent"}))["occurrence"].as_u64().unwrap(); + let leaf = run(&mut s, "component.create", json!({"name":"Source"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); + run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "occurrence.move", json!({"occurrence":if ancestor {parent}else{leaf},"translate":[5,0,0],"capture":false})); + let before = s.doc.to_json(); + let pending = s.pending_moves.clone(); + let volume = exact_cylinder_volume(&s); + let error = s.execute("solid.emboss", &json!({"sketch":"Badge","profiles":"all","face":[5,10,7.5],"depth":1})).unwrap_err().to_string(); + assert!(error.contains("Capture Position"), "{error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.pending_moves, pending); + assert_eq!(exact_cylinder_volume(&s), volume); + } +} + +#[test] +fn cylinder_pending_third_component_profile_requires_capture_atomically() { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); + let profile_occurrence = run(&mut s, "component.create", json!({"name":"ProfileOwner"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "component.activate", json!({"component":"root"})); + run(&mut s, "component.create", json!({"name":"Author"})); + run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[5,0,0],"capture":false})); + let before = s.doc.to_json(); + let pending = s.pending_moves.clone(); + let volume = exact_cylinder_volume(&s); + let error = s + .execute("solid.emboss", &json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]})) + .unwrap_err() + .to_string(); + assert!(error.contains("Capture Position"), "{error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.pending_moves, pending); + assert_eq!(exact_cylinder_volume(&s), volume); +} + +#[test] +fn cylinder_body_offset_keeps_exact_profile_and_anchor_frames() { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); + let before = exact_cylinder_volume(&s); + let holder = run(&mut s, "component.create", json!({"name":"Holder"}))["component"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":"Holder:1","translate":[0,50,0]})); + run(&mut s, "component.move_bodies", json!({"bodies":["Sleeve"],"component":holder})); + assert!(s.doc.body_offsets.contains_key("Sleeve")); + let offset = s.doc.body_offsets["Sleeve"]; + run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]})); + assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02); + assert_eq!(s.doc.body_offsets["Sleeve"], offset); + assert_eq!(s.doc.body_component("Sleeve", s.model.state().body("Sleeve").unwrap().feature), holder); + assert!(s.world_state().body("Sleeve").unwrap().body.tessellate_with(0.001, true).unwrap().contains(solvecraft_geom::Vec3::new(0., 10.5, 7.5))); +} + #[test] fn replace_face_align_remove() { let mut s = Session::default(); diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index dad2533e..23d46a18 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -401,7 +401,7 @@ fn appearance(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "too many face appearances")); } let name = st_names.iter().find(|(b, p, _)| b == body && p.dist(*pt) < 1e-12).and_then(|(_, _, n)| n.clone()); - a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name }); + a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name, strict_name: false }); } } for c in &comps { @@ -414,11 +414,27 @@ fn appearance(s: &mut Session, p: &Value) -> Result { if look.is_none() && !faces.is_empty() { let st = s.model.state(); let doc = s.doc_mut(); - let picked: Vec<(String, Option)> = - faces.iter().map(|(b, pt)| (b.clone(), st.body(b).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, *pt)))).collect(); + let picked: Vec<(String, Option, Option)> = faces + .iter() + .map(|(b, pt)| { + ( + b.clone(), + st.body(b).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, *pt)), + st_names.iter().find(|(name, p, _)| name == b && p.dist(*pt) < 1e-12).and_then(|(_, _, name)| name.clone()), + ) + }) + .collect(); doc.appearances.faces.retain(|f| { let idx = st.body(&f.body).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, f.point)); - !picked.iter().any(|(b, i)| *b == f.body && i.is_some() && *i == idx) + !picked.iter().any(|(b, i, name)| { + *b == f.body + && (f + .name + .as_ref() + .zip(name.as_ref()) + .is_some_and(|(a, b)| solvecraft_doc::naming::strip_piece(a) == solvecraft_doc::naming::strip_piece(b)) + || (!f.strict_name && i.is_some() && *i == idx)) + }) }); } Ok(json!({ diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3a8d77ff..f8e92073 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -5,6 +5,8 @@ mod browser; mod clipboard; mod component; +mod component_clone; +mod component_clone_assembly; mod construct; mod delete; mod edit; diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 35fe4a68..04e05866 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -223,7 +223,12 @@ pub(super) fn edit( let out = f(&mut sk, &mut doc)?; let (vals, _) = doc.param_values(); doc.apply_dimension_values(&vals, &mut sk)?; - let rep = solve(&mut sk); + sk.refresh_offsets()?; + let mut rep = solve(&mut sk); + if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() { + sk.refresh_offsets()?; + rep = solve(&mut sk); + } if strict && !rep.ok() { return Err(EngineError::Other(format!("that would over-constrain the sketch (conflicts with {})", rep.failing.join(", ")))); } diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 69575862..372f3c8f 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -12,6 +12,9 @@ use super::{CommandSpec, in_sketch}; use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec2_list}; use crate::{Result, Session}; +#[path = "sketch_support.rs"] +mod support; + pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("sketch.fillet", "Fillet", fillet) .at("SKETCH", "MODIFY") @@ -39,6 +42,9 @@ pub static COMMANDS: &[CommandSpec] = &[ .key("O") .enabled(in_sketch) .params("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)"), + CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset) + .enabled(in_sketch) + .params("curve: derived offset curve id (retains geometry and makes its points editable)"), CommandSpec::new("sketch.trim", "Trim", trim) .at("SKETCH", "MODIFY") .icon("trim") @@ -89,6 +95,9 @@ fn curve_arg(sk: &Sketch, p: &Value, cmd: &str) -> Result { if sk.is_linked_curve(c) { return Err(bad(cmd, format!("`{id}` is projected geometry: break its link first"))); } + if sk.freeform_offsets.iter().any(|offset| offset.output == id) { + return Err(bad(cmd, "break the offset association before editing its output")); + } Ok(c) } @@ -365,7 +374,12 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result { fn extend(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.extend"; let at = req_vec2(cmd, p, "at")?; - let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (out, info) = edit(s, p, cmd, true, |sk, doc| { + let (values, _) = doc.param_values(); + doc.apply_dimension_values(&values, sk)?; + if !solvecraft_sketch::solve(sk).ok() { + return Err(bad(cmd, "solve the existing sketch constraints before extending")); + } let ci = curve_arg(sk, p, cmd)?; let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?; let (pa, pb) = match sk.curves.get(ci).map(|c| c.kind.clone()) { @@ -386,41 +400,62 @@ fn extend(s: &mut Session, p: &Value) -> Result { }; let (_, t1) = sh.range(); let mut best: Option<(f64, Vec2, usize)> = None; + let mut ambiguous = false; + let mut budget = 1_000_000usize; + let mut segment_count = 0usize; for (oi, _) in sk.curves.iter().enumerate() { if oi == ci { continue; } - // Lines and circles exactly; free-form curves (splines, ellipses, conics) by the - // pieces of their polyline. - let pieces: Vec = match sk.shape(oi) { - Some(other) => vec![other], - None => sk.polyline(oi).windows(2).map(|w| Shape::Line { a: w[0], b: w[1] }).collect(), - }; - for q in pieces.iter().flat_map(|other| intersections(&unbounded, other).into_iter().filter(|q| on_bounded(other, *q).is_some())) { - let t = sh.param(q); - let gap = match sh { - Shape::Line { a, b } => { - let l = a.dist(b); - if at_b { (t - 1.0) * l } else { -t * l } - } - Shape::Round { r, .. } => { - if at_b { - (t - t1) * r - } else { - (TAU - t) * r + let pieces = sk.exact_segs(oi).unwrap_or_else(|| sk.segs(oi)); + segment_count += pieces.len(); + if segment_count > 16_384 { + return Err(bad(cmd, "too many exact curve pieces to extend safely")); + } + for piece in &pieces { + for q in support::hits(&unbounded, piece, &mut budget)? { + let t = sh.param(q); + let gap = match sh { + Shape::Line { a, b } => { + let l = a.dist(b); + if at_b { (t - 1.0) * l } else { -t * l } + } + Shape::Round { r, .. } => { + // A hit already inside the arc cannot lengthen either end. In + // particular, extending the start must not turn a quarter into an eighth. + if t <= t1 + TOL / r.max(1e-12) || t >= TAU - TOL / r.max(1e-12) { + continue; + } + if at_b { (t - t1) * r } else { (TAU - t) * r } + } + }; + if gap > TOL * 10.0 { + if best.is_none_or(|b| gap < b.0 - TOL) { + best = Some((gap, q, oi)); + ambiguous = false; + } else if best.is_some_and(|b| (gap - b.0).abs() <= TOL && (b.2 != oi || b.1.dist(q) > TOL)) { + ambiguous = true; } } - }; - if gap > TOL * 10.0 && best.is_none_or(|b| gap < b.0) { - best = Some((gap, q, oi)); } } } + if ambiguous { + return Err(bad(cmd, "multiple curves stop the extension at the same distance")); + } let (_, q, oi) = best.ok_or_else(|| bad(cmd, "nothing to extend to"))?; if let Some(pt) = sk.points.get_mut(end) { pt.pos = q; } pin(sk, end, oi)?; + let before = sk.points.iter().map(|p| p.pos).collect::>(); + let hold = solvecraft_sketch::Hold { points: (0..sk.points.len()).filter(|i| *i != end).collect(), radii: (0..sk.curves.len()).collect() }; + if !solvecraft_sketch::solve_holding(sk, &hold).ok() + || sk.point(end).is_none_or(|point| point.dist(q) > TOL) + || sk.points.iter().enumerate().any(|(i, point)| i != end && point.pos.dist(before[i]) > TOL) + { + return Err(bad(cmd, "the extension conflicts with constraints or cannot preserve its original support")); + } Ok(sk.points.get(end).map(|x| x.id.clone()).unwrap_or_default()) })?; Ok(json!({"moved": out, "sketch": info})) @@ -809,7 +844,14 @@ fn offset(s: &mut Session, p: &Value) -> Result { let d0 = num(p, "distance").filter(|d| d.abs() > 1e-9).ok_or_else(|| bad(cmd, "`distance` must be a non-zero number"))?; let side = p.get("side").and_then(vec2); let use_chain = bool_(p, "chain").unwrap_or(true); - let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (out, info) = edit(s, p, cmd, true, |sk, doc| { + let (values, _) = doc.param_values(); + doc.apply_dimension_values(&values, sk)?; + sk.refresh_offsets()?; + if !solvecraft_sketch::solve(sk).ok() { + return Err(bad(cmd, "solve existing constraints before offsetting")); + } + sk.refresh_offsets()?; let sel: Vec = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::>()?; let first = *sel.first().ok_or_else(|| bad(cmd, "no curves"))?; let mut made = Vec::new(); @@ -855,6 +897,17 @@ fn offset(s: &mut Session, p: &Value) -> Result { } None => d0, }; + if order.iter().any(|(i, _)| sk.shape(*i).is_none()) { + // A smooth free-form entity is offset from exact pieces. Junctions that + // would require trimming/intersecting independent normal offsets are + // refused rather than joined by a guessed average point. + if order.len() != 1 { + return Err(bad(cmd, "offset smooth free-form curves individually; mixed chain junctions are ambiguous")); + } + let (source, forward) = order[0]; + let output = sk.add_freeform_offset(source, forward, d)?; + return Ok((ids_of(sk, &[output]), cons)); + } let pieces: Vec = order.iter().map(|(c, f)| offset_piece(sk, *c, *f, d)).collect::>().ok_or_else(|| bad(cmd, "the offset collapses an arc"))?; let n = pieces.len(); @@ -947,6 +1000,29 @@ fn offset(s: &mut Session, p: &Value) -> Result { Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info})) } +fn break_offset(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.offset.break"; + let (out, info) = edit(s, p, cmd, true, |sk, _| { + 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"))?; + sk.refresh_offsets()?; + 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); + for i in indices { + if let Some(point) = sk.points.get_mut(i) { + point.fixed = false; + } + } + Ok(output.to_string()) + })?; + Ok(json!({"curve":out,"sketch":info})) +} + // --------------------------------------------------------------------------------------------- // Copies: mirror, patterns, move, scale diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index 5f13604d..b1940a9c 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -183,6 +183,58 @@ fn trim_circle_makes_an_arc_and_a_profile() { assert!(close(a[0], std::f64::consts::PI * 50.0), "{a:?}"); } +#[test] +fn exact_extend_ellipse_preserves_support_target_and_history() { + for (y, expected_x) in [(3.0, 12.0), (5.0, 20.0)] { + let mut s = new_sketch(); + let ellipse = ids(&run(&mut s, "sketch.ellipse", json!({"center":[20,0],"major":[30,0],"minor_radius":5}))["curves"])[0].clone(); + run(&mut s, "sketch.constraint.fix", json!({"entity":ellipse})); + let line = ids(&run(&mut s, "sketch.line", json!({"points":[[0,y],[5,y]],"connect":false,"infer":false}))["curves"])[0].clone(); + let before = s.doc.to_json(); + let target = sketch(&s).exact_segs(0).unwrap(); + run(&mut s, "sketch.extend", json!({"curve":line,"at":[5,y]})); + let sk = sketch(&s); + let end = sk.point(sk.resolve_point(&format!("{line}.end")).unwrap()).unwrap(); + assert!(end.dist(Vec2::new(expected_x, y)) < 1e-7, "exact ellipse contact {end:?}"); + assert_eq!(sk.exact_segs(0).unwrap(), target); + let persisted = s.doc.to_json(); + let reopened = solvecraft_doc::format::read(&persisted).unwrap(); + assert_eq!(reopened.to_json(), persisted); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.to_json(), persisted); + } +} + +#[test] +fn exact_extend_arc_rejects_interior_hit_atomically() { + let mut s = new_sketch(); + let arc = ids(&run(&mut s, "sketch.arc.center_point", json!({"center":[0,0],"start":[10,0],"end":[0,10],"connect":false}))["curves"])[0].clone(); + let x = 50.0_f64.sqrt(); + let cutter = ids(&run(&mut s, "sketch.line", json!({"points":[[x,5],[x,9]],"connect":false,"infer":false}))["curves"])[0].clone(); + run(&mut s, "sketch.constraint.fix", json!({"entity":cutter})); + let before = s.doc.to_json(); + let history = s.undo.len(); + assert!(s.execute("sketch.extend", &json!({"curve":arc,"at":[10,0]})).is_err()); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), history); +} + +#[test] +fn exact_extend_refuses_ambiguous_stop_without_history_change() { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points":[[0,0],[5,0]],"connect":false,"infer":false})); + run(&mut s, "sketch.line", json!({"points":[[10,-5],[10,5]],"connect":false,"infer":false})); + run(&mut s, "sketch.line", json!({"points":[[8,-2],[12,2]],"connect":false,"infer":false})); + let before = s.doc.to_json(); + let history = s.undo.len(); + let error = s.execute("sketch.extend", &json!({"curve":"l1","at":[5,0]})).unwrap_err(); + assert!(error.to_string().contains("multiple curves"), "{error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), history); +} + #[test] fn fillet_and_chamfers() { let mut s = new_sketch(); @@ -563,3 +615,101 @@ fn offset_free_form_curves() { let r = run(&mut s, "sketch.offset", json!({"curves": [e], "distance": 1, "side": [100, 20]})); assert_eq!(ids(&r["curves"]).len(), 1, "{r}"); } + +#[test] +fn certified_offset_follows_dimension_drag_reopen_break_and_undo() { + let mut s = new_sketch(); + let ellipse = ids(&run(&mut s, "sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5,"connect":false}))["curves"])[0].clone(); + run(&mut s, "sketch.dimension", json!({"entities":[ellipse],"type":"major","value":10})); + let before = s.doc.to_json(); + let out = run(&mut s, "sketch.offset", json!({"curves":[ellipse],"distance":-1,"chain":false})); + let output = out["curves"][0].as_str().unwrap().to_string(); + assert_eq!(sketch(&s).freeform_offsets.len(), 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "edit.redo", json!({})); + let result = run(&mut s, "parameters.change", json!({"name":"d1","expression":12})); + assert_eq!(result["errors"], json!([])); + let verify = |s: &Session| { + let sk = sketch(s); + let source = sk.curve_index(&ellipse).unwrap(); + let (c, m) = match sk.curves[source].kind { + CurveKind::Ellipse { c, m, .. } => (sk.point(c).unwrap(), sk.point(m).unwrap()), + _ => panic!("ellipse"), + }; + let derived = sk.exact_segs(sk.curve_index(&output).unwrap()).unwrap(); + let want = m + (m - c).normalized().unwrap(); + assert!(derived[0].point_at(0.0).dist(want) < 1e-7, "updated offset {want:?}"); + }; + verify(&s); + run(&mut s, "sketch.drag", json!({"entity":ellipse,"from":[0,0],"to":[4,3]})); + verify(&s); + let mut reopened = Session { + doc: std::sync::Arc::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap()), + active_sketch: s.active_sketch, + ..Session::default() + }; + reopened.refresh(); + verify(&reopened); + let attached = reopened.doc.to_json(); + run(&mut reopened, "sketch.offset.break", json!({"curve":output})); + let sk = sketch(&reopened); + assert!(sk.freeform_offsets.is_empty()); + assert!(sk.curves[sk.curve_index(&output).unwrap()].kind.point_ids().iter().all(|i| !sk.points[*i].fixed)); + run(&mut reopened, "edit.undo", json!({})); + assert_eq!(reopened.doc.to_json(), attached); +} + +#[test] +fn certified_offset_delete_output_undo_reopen_and_refuse_dependent_source() { + let mut s = new_sketch(); + run(&mut s, "sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})); + let out = run(&mut s, "sketch.offset", json!({"curves":["e1"],"distance":-1,"chain":false})); + let id = out["curves"][0].as_str().unwrap().to_string(); + let attached = s.doc.to_json(); + let history = s.undo.len(); + assert!(s.execute("sketch.delete", &json!({"entities":["e1"]})).is_err()); + assert_eq!(s.doc.to_json(), attached); + assert_eq!(s.undo.len(), history); + run(&mut s, "sketch.delete", json!({"entities":[id]})); + let sk = sketch(&s); + assert!(sk.freeform_offsets.is_empty()); + assert!(sk.curve_index(&id).is_none()); + assert!(sk.curve_index("e1").is_some()); + let deleted = s.doc.to_json(); + let reopened = solvecraft_doc::format::read(&deleted).unwrap(); + assert_eq!(reopened.to_json(), deleted); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), attached); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.to_json(), deleted); + // The central removal path also handles a deleted fit node, which removes its curve. + run(&mut s, "edit.undo", json!({})); + let sk = sketch(&s); + let node = sk.points[sk.curves[sk.curve_index(&id).unwrap()].kind.point_ids()[3]].id.clone(); + run(&mut s, "sketch.delete", json!({"entities":[node]})); + assert!(sketch(&s).freeform_offsets.is_empty()); + assert!(sketch(&s).curve_index(&id).is_none()); +} + +#[test] +fn analytic_offset_partial_delete_releases_survivors_preserves_source_and_undo() { + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,30]})); + let out = run(&mut s, "sketch.offset", json!({"curves":["l1"],"distance":-1})); + let outputs = ids(&out["curves"]); + assert_eq!(outputs.len(), 4); + let before = s.doc.to_json(); + run(&mut s, "sketch.delete", json!({"entities":[outputs[0]]})); + let sk = sketch(&s); + assert!(sk.offsets.is_empty()); + for id in outputs.iter().skip(1) { + let curve = &sk.curves[sk.curve_index(id).unwrap()]; + assert!(curve.kind.point_ids().iter().all(|i| !sk.points[*i].fixed)); + } + assert!(sk.curve_index("l1").is_some()); + let saved = s.doc.to_json(); + assert_eq!(solvecraft_doc::format::read(&saved).unwrap().to_json(), saved); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); +} diff --git a/crates/engine/src/cmd/sketch_support.rs b/crates/engine/src/cmd/sketch_support.rs new file mode 100644 index 00000000..28498d61 --- /dev/null +++ b/crates/engine/src/cmd/sketch_support.rs @@ -0,0 +1,214 @@ +//! Bounded roots on exact curve pieces against an infinite line or complete circle. +//! Rational pieces are intersected in homogeneous coordinates, without sampled chords. +use solvecraft_geom::{Seg2, Vec2}; +use solvecraft_sketch::{Shape, intersections}; + +use crate::{Result, params::bad}; + +fn choose(n: usize, k: usize) -> f64 { + (0..k.min(n - k)).fold(1.0, |value, i| value * (n - i) as f64 / (i + 1) as f64) +} +fn product(a: &[f64], b: &[f64]) -> Vec { + let (n, m) = (a.len() - 1, b.len() - 1); + (0..=n + m) + .map(|k| (k.saturating_sub(m)..=k.min(n)).map(|i| a[i] * b[k - i] * choose(n, i) * choose(m, k - i) / choose(n + m, k)).sum()) + .collect() +} +fn evaluate(c: &[f64], t: f64) -> f64 { + let mut values = [0.0; 15]; + for (slot, value) in values.iter_mut().zip(c) { + *slot = *value; + } + for n in (1..c.len()).rev() { + for i in 0..n { + values[i] = values[i] * (1.0 - t) + values[i + 1] * t; + } + } + values[0] +} + +/// Derivative roots partition a polynomial into monotone intervals. Testing its critical +/// points retains repeated/tangent roots that a sign-change-only search would miss. +fn roots(c: &[f64], budget: &mut usize) -> Result> { + if c.is_empty() || c.len() > 15 || c.iter().any(|v| !v.is_finite()) { + return Err(bad("sketch.extend", "invalid intersection polynomial")); + } + let scale = c.iter().map(|v| v.abs()).fold(0.0_f64, f64::max); + if scale < 1e-28 { + return Ok(Vec::new()); + } + let eps = scale * 2e-13; + if c.len() == 1 { + return Ok(Vec::new()); + } + let d: Vec = c.windows(2).map(|w| (w[1] - w[0]) * (c.len() - 1) as f64).collect(); + let mut knots = roots(&d, budget)?; + knots.extend([0.0, 1.0]); + knots.sort_by(f64::total_cmp); + knots.dedup_by(|a, b| (*a - *b).abs() < 1e-13); + let mut found: Vec = knots.iter().copied().filter(|t| evaluate(c, *t).abs() <= eps).collect(); + for pair in knots.windows(2) { + let (mut lo, mut hi) = (pair[0], pair[1]); + let (mut fl, fh) = (evaluate(c, lo), evaluate(c, hi)); + if fl.abs() <= eps || fh.abs() <= eps || fl.signum() == fh.signum() { + continue; + } + for _ in 0..64 { + if *budget == 0 { + return Err(bad("sketch.extend", "intersection work limit exceeded")); + } + *budget -= 1; + let mid = (lo + hi) * 0.5; + let f = evaluate(c, mid); + if hi - lo < 2e-14 { + break; + } + if f.signum() == fl.signum() { + lo = mid; + fl = f; + } else { + hi = mid; + } + } + found.push((lo + hi) * 0.5); + } + found.sort_by(f64::total_cmp); + found.dedup_by(|a, b| (*a - *b).abs() < 1e-10); + Ok(found) +} + +pub(super) fn hits(support: &Shape, seg: &Seg2, budget: &mut usize) -> Result> { + if let Seg2::Line { a, b } = *seg { + let other = Shape::Line { a, b }; + if let Shape::Line { a: pa, b: pb } = *support + && (pb - pa).normalized().is_some_and(|d| d.cross(a - pa).abs() < 1e-9 && d.cross(b - pa).abs() < 1e-9) + { + return Err(bad("sketch.extend", "the continuation overlaps another curve")); + } + return Ok(intersections(support, &other) + .into_iter() + .filter(|p| { + let d = b - a; + let t = (*p - a).dot(d) / d.len2().max(1e-30); + (-1e-10..=1.0 + 1e-10).contains(&t) + }) + .collect()); + } + if let Seg2::Arc { center, radius, start, sweep } = *seg { + let other = Shape::Round { c: center, r: radius, start, sweep }; + if let Shape::Round { c, r, .. } = *support + && c.dist(center) < 1e-9 + && (r - radius).abs() < 1e-9 + { + return Err(bad("sketch.extend", "the continuation overlaps another curve")); + } + return Ok(intersections(support, &other) + .into_iter() + .filter(|p| { + let t = (((*p - center).angle() - start) * sweep.signum()).rem_euclid(std::f64::consts::TAU); + t <= sweep.abs() + 1e-10 + }) + .collect()); + } + let (points, weights): (Vec, Vec) = match *seg { + Seg2::Cubic { p0, p1, p2, p3 } => (vec![p0, p1, p2, p3], vec![1.0; 4]), + Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => (vec![a, apex, b], vec![1.0, w, 1.0]), + Seg2::Bezier { n, p } if (1..=7).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]), + _ => return Err(bad("sketch.extend", "invalid exact curve piece")), + }; + if points.iter().any(|p| !p.is_finite()) { + return Err(bad("sketch.extend", "nonfinite intersection geometry")); + } + let weight_scale = weights.iter().copied().fold(1.0_f64, f64::max); + let weights: Vec = weights.iter().map(|w| w / weight_scale).collect(); + let origin = match *support { + Shape::Line { a, .. } => a, + Shape::Round { c, .. } => c, + }; + let scale = points.iter().map(|p| p.dist(origin)).fold(1.0_f64, f64::max); + let x: Vec = points.iter().zip(&weights).map(|(p, w)| (p.x - origin.x) * w / scale).collect(); + let y: Vec = points.iter().zip(&weights).map(|(p, w)| (p.y - origin.y) * w / scale).collect(); + let c = match *support { + Shape::Line { a, b } => { + let d = (b - a).normalized().ok_or_else(|| bad("sketch.extend", "zero-length support"))?; + x.iter().zip(&y).map(|(x, y)| d.x * y - d.y * x).collect::>() + } + Shape::Round { r, .. } => { + let (xx, yy, dd) = (product(&x, &x), product(&y, &y), product(&weights, &weights)); + xx.iter().zip(&yy).zip(&dd).map(|((x, y), d)| x + y - (r / scale).powi(2) * d).collect() + } + }; + if c.iter().all(|v| v.abs() < 1e-28) { + return Err(bad("sketch.extend", "the continuation overlaps another curve")); + } + let mut out = Vec::new(); + for t in roots(&c, budget)? { + let p = seg.point_at(t); + if !p.is_finite() || support.project(p).dist(p) > 1e-7 { + return Err(bad("sketch.extend", "the intersection could not be resolved accurately")); + } + if !out.iter().any(|q: &Vec2| q.dist(p) < 1e-7) { + out.push(p); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_extend_negative_arc_target() { + let support = Shape::Line { a: Vec2::new(-2.0, 0.5), b: Vec2::new(-1.5, 0.5) }; + let target = Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: std::f64::consts::PI, sweep: -std::f64::consts::FRAC_PI_2 }; + let found = hits(&support, &target, &mut 1000).unwrap(); + assert_eq!(found.len(), 1); + assert!(found[0].dist(Vec2::new(-0.75_f64.sqrt(), 0.5)) < 1e-10); + } + + #[test] + fn exact_extend_degree_seven_retains_tangent_and_crossing() { + // Degree elevation of quadratic y=(2t-1)^2 retains its double root at t=1/2. + let mut y = vec![1.0, -1.0, 1.0]; + while y.len() < 8 { + let n = y.len(); + let mut elevated = vec![y[0]]; + for i in 1..n { + let a = i as f64 / n as f64; + elevated.push(a * y[i - 1] + (1.0 - a) * y[i]); + } + elevated.push(y[n - 1]); + y = elevated; + } + let mut points = [Vec2::ZERO; 8]; + for (i, p) in points.iter_mut().enumerate() { + *p = Vec2::new(10.0 + i as f64 * 20.0 / 7.0, y[i]); + } + let target = Seg2::Bezier { n: 7, p: points }; + let support = Shape::Line { a: Vec2::ZERO, b: Vec2::X }; + let found = hits(&support, &target, &mut 10000).unwrap(); + assert_eq!(found.len(), 1); + assert!(found[0].dist(Vec2::new(20.0, 0.0)) < 1e-7); + let crossing = Shape::Line { a: Vec2::new(0.0, 0.25), b: Vec2::new(1.0, 0.25) }; + let found = hits(&crossing, &target, &mut 10000).unwrap(); + assert_eq!(found.len(), 2); + assert!(found[0].dist(Vec2::new(15.0, 0.25)) < 1e-7); + assert!(found[1].dist(Vec2::new(25.0, 0.25)) < 1e-7); + assert!(hits(&crossing, &target, &mut 0).is_err()); + let circle = Shape::Round { c: Vec2::new(20.0, -1.0), r: 1.0, start: 0.0, sweep: std::f64::consts::TAU }; + let found = hits(&circle, &target, &mut 10000).unwrap(); + assert_eq!(found.len(), 1, "degree fourteen tangent {found:?}"); + assert!(found[0].dist(Vec2::new(20.0, 0.0)) < 1e-7); + } + + #[test] + fn exact_extend_rational_quarter_hits_analytic_eight_six_point() { + let target = + Seg2::Conic { a: Vec2::new(10.0, 0.0), apex: Vec2::new(10.0, 10.0), b: Vec2::new(0.0, 10.0), w: std::f64::consts::FRAC_1_SQRT_2 }; + let support = Shape::Line { a: Vec2::new(0.0, 6.0), b: Vec2::new(1.0, 6.0) }; + let found = hits(&support, &target, &mut 10000).unwrap(); + assert_eq!(found.len(), 1); + assert!(found[0].dist(Vec2::new(8.0, 6.0)) < 1e-7); + } +} diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs index a0a565be..8a882e41 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -26,6 +26,8 @@ enum K { Make, /// A direction: turned with the command's frame. Dir, + /// An arbitrary axis object in the authoring frame: {origin, dir}. + Axis, /// A plane object (`{face}`, `{origin, normal}`, `{origin, x_dir, y_dir}`) or a plane name. Plane, /// A body name, or a list of them. @@ -43,7 +45,8 @@ use K::*; /// The parameters each command takes in world coordinates. fn table(id: &str) -> &'static [(&'static str, K)] { match id { - "solid.extrude" | "solid.revolve" => &[("face", Pick), ("targets", Body), ("participants", Body)], + "solid.extrude" => &[("face", Pick), ("from", Make), ("to", Make), ("targets", Body), ("participants", Body)], + "solid.revolve" => &[("face", Pick), ("axis", Axis), ("to", Make), ("targets", Body), ("participants", Body)], "solid.box" | "solid.sphere" => &[("corner", Make), ("center", Make)], "solid.cylinder" | "solid.coil" | "solid.torus" => &[("base", Make), ("center", Make), ("axis", Dir)], "solid.fillet" | "solid.chamfer" => &[("edges", Picks), ("body", Body)], @@ -51,6 +54,7 @@ fn table(id: &str) -> &'static [(&'static str, K)] { "solid.scale" => &[("origin", Make), ("bodies", Body)], "solid.offset_face" | "solid.shell" => &[("faces", Picks), ("body", Body)], "solid.thread" => &[("face", Pick)], + "solid.emboss" => &[("face", Make)], "solid.draft" => &[("faces", Picks), ("neutral", Plane), ("pull", Dir), ("body", Body)], "solid.press_pull" => &[("face", Pick), ("edges", Picks)], "solid.replace_face" => &[("faces", Picks), ("target_face", Pick), ("target", Plane), ("body", Body)], @@ -333,7 +337,34 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option { // component is active. Body-relative picks still use the picked body's frame. let mut context = s.scratch(); context.active_component = feature.component; - let inner = to_local(&context, cmd, params.get("params")?)?; + if let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &feature.kind { + context.active_occurrence_path = (!face.author_path.is_empty()).then(|| face.author_path.clone()); + } + let mut inner = to_local(&context, cmd, params.get("params")?)?; + // An untouched explicit axis is displayed in world coordinates by Edit + // Feature. Keep its exact saved values instead of an affine round trip. + if cmd == "solid.revolve" + && let solvecraft_doc::FeatureKind::Revolve { axis: solvecraft_doc::AxisRef::Line { origin, dir }, .. } = &feature.kind + { + let frame = context.active_component_frame(); + let displayed = serde_json::json!({"origin":pt(apply_point(&frame,*origin)),"dir":pt(apply_vector(&frame,*dir))}); + if params.get("params").and_then(|p| p.get("axis")) == Some(&displayed) { + inner["axis"] = serde_json::json!({"origin":pt(*origin),"dir":pt(*dir)}); + } + } + let points = match (&feature.kind, cmd) { + (solvecraft_doc::FeatureKind::Revolve { to, .. }, "solid.revolve") => [("to", *to), ("from", None)], + (solvecraft_doc::FeatureKind::Extrude { extent, .. }, "solid.extrude") => [("to", extent.to), ("from", extent.from)], + _ => [("to", None), ("from", None)], + }; + for (key, point) in points { + if let Some(point) = point { + let displayed = pt(apply_point(&context.active_component_frame(), point)); + if params.get("params").and_then(|p| p.get(key)) == Some(&displayed) { + inner[key] = pt(point); + } + } + } let mut out = params.clone(); out["params"] = inner; return Some(out); @@ -359,6 +390,24 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option { Picks => m.picks(v, body.as_deref()), Make => m.make(v), Dir => m.dir(v), + Axis => { + if let Some(axis) = v.as_object() { + let mut axis = axis.clone(); + if let Some(origin) = axis.get("origin").and_then(|o| m.make(o)) { + axis.insert("origin".into(), origin); + } + if let Some(dir) = axis.get("dir").and_then(vec3) + && !is_identity(&m.f.active) + && let Some(inv) = mat_inverse(&m.f.active) + { + axis.insert("dir".into(), pt(apply_vector(&inv, dir))); + m.changed = true; + } + Some(Value::Object(axis)) + } else { + None + } + } Plane => m.plane(v), Body => m.body(v), Here | HerePlane => { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c3acc13e..86d8a4b8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -513,10 +513,26 @@ impl Session { /// A `timeline.redefine` is shown even while the timeline is rolled back to the feature /// being edited. pub fn preview(&self, commands: &[(String, Value)]) -> Result { - self.scratch().preview_in_place(commands).map(|after| Preview { before: self.model.state(), after }) + self.scratch().preview_in_place(commands).map(|after| Preview { before: self.model.state(), after: after.model.state() }) } - fn preview_in_place(mut self, commands: &[(String, Value)]) -> Result> { + /// The same evaluated preview in occurrence coordinates, including uncaptured moves. + pub fn preview_world(&self, commands: &[(String, Value)]) -> Result { + self.scratch().preview_in_place(commands).map(|after| Preview { before: self.world_state(), after: after.world_state() }) + } + + /// World preview with each session's visibility applied, including new body placements. + pub fn preview_visible_world(&self, commands: &[(String, Value)]) -> Result { + let visible = |session: &Session| { + let world = session.world_state(); + let mut shown = (*world).clone(); + shown.bodies.retain(|body| session.body_visible(&body.name)); + Arc::new(shown) + }; + self.scratch().preview_in_place(commands).map(|after| Preview { before: visible(self), after: visible(&after) }) + } + + fn preview_in_place(mut self, commands: &[(String, Value)]) -> Result { if commands.is_empty() { return Err(EngineError::Other("nothing to preview".into())); } @@ -534,7 +550,7 @@ impl Session { } } } - Ok(self.model.state()) + Ok(self) } /// Measure one or two selections (see `inspect.measure` with `items`); errors come back as diff --git a/crates/kernel/src/emboss.rs b/crates/kernel/src/emboss.rs new file mode 100644 index 00000000..9d0c337f --- /dev/null +++ b/crates/kernel/src/emboss.rs @@ -0,0 +1,473 @@ +//! Exact radial embossing of orthogonal profiles on complete cylindrical walls. +use crate::body::{Body, Solid, from_p3, p3}; +use crate::offset::{Surf, surf_of}; +use crate::{KernelError, Result, guard}; +use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; +use truck_modeling::{self as mt, Transformed, builder}; + +fn fail(s: &str) -> KernelError { + KernelError::Invalid(format!("cylinder emboss: {s}")) +} +fn angle(p: Vec3, o: Vec3, axis: Vec3, radial: Vec3, tangent: Vec3) -> f64 { + let w = p - o; + let w = w - axis * w.dot(axis); + w.dot(tangent).atan2(w.dot(radial)) +} +fn unwrap(a: f64, around: f64) -> f64 { + around + (a - around + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI +} + +/// Certify a tessellated pick and project it onto the cylinder used by emboss. +pub fn cylinder_emboss_anchor(body: &Body, face: usize, at: Vec3) -> Result { + if !at.is_finite() || at.len() > 1e9 { + return Err(fail("invalid selected face point")); + } + guard("cylinder emboss anchor", || { + let f = body.solid.face_iter().nth(face).ok_or_else(|| fail("selected face"))?; + let tol = (body.size() * 1e-8).max(1e-8); + let Some(Surf::Cylinder { o, a, r, convex: true }) = surf_of(f, tol) else { + return Err(fail("selected face is not an exterior cylinder")); + }; + let w = at - o; + let radial = w - a * w.dot(a); + if (radial.len() - r).abs() > (body.size() * 1e-3).max(1e-3).min(r * 0.05) { + return Err(fail("selected point is too far from cylinder")); + } + Ok(o + a * w.dot(a) + radial.normalized().ok_or_else(|| fail("radial point"))? * r) + }) +} + +/// Wrap sketch arc length around an exterior cylinder, retaining exact radial depth. +/// Profiles must contain orthogonal lines and lie inside one uninterrupted wall face. +pub fn emboss_cylinder(body: &Body, plane: &Plane, regions: &[Region2], depth: f64) -> Result { + emboss_impl(body, None, plane, regions, depth) +} + +/// As [`emboss_cylinder`], requiring the saved selected point to identify that wall. +pub fn emboss_cylinder_at(body: &Body, at: Vec3, plane: &Plane, regions: &[Region2], depth: f64) -> Result { + if !at.is_finite() || at.len() > 1e9 { + return Err(fail("invalid selected face point")); + } + emboss_impl(body, Some(at), plane, regions, depth) +} + +fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Region2], depth: f64) -> Result { + body.require_brep("cylinder emboss")?; + if !depth.is_finite() || depth.abs() < 1e-6 || depth.abs() > 1e6 || regions.is_empty() || regions.len() > 64 { + return Err(fail("needs finite nonzero depth and 1..64 profiles")); + } + if !plane.origin.is_finite() + || !plane.x.is_finite() + || !plane.y.is_finite() + || (plane.x.len() - 1.0).abs() > 1e-8 + || (plane.y.len() - 1.0).abs() > 1e-8 + || plane.x.dot(plane.y).abs() > 1e-8 + { + return Err(fail("invalid sketch frame")); + } + guard("cylinder emboss", || { + if body.solid.boundaries().len() != 1 || body.solid.face_iter().take(17).count() > 16 || body.solid.edge_iter().take(257).count() > 256 { + return Err(fail("requires a complete cylinder or coaxial sleeve")); + } + let faces: Vec<_> = body.solid.face_iter().cloned().collect(); + let tol = (body.size() * 1e-8).max(1e-8); + let surfaces: Vec<_> = + faces.iter().map(|f| surf_of(f, tol).ok_or_else(|| fail("requires only cylindrical walls and planar endcaps"))).collect::>()?; + let mut chosen = None; + for (fi, s) in surfaces.iter().enumerate() { + let Surf::Cylinder { o, a, r, convex: true } = *s else { continue }; + let w = plane.origin - o; + let radial = w - a * w.dot(a); + if (radial.len() - r).abs() > tol * 10.0 || plane.normal().dot(radial / r) < 1.0 - 1e-8 { + continue; + } + let radial = radial.normalized().ok_or_else(|| fail("radial frame"))?; + let tangent = a.cross(radial); + let f = faces.get(fi).ok_or_else(|| fail("face"))?; + if f.boundaries().len() != 1 || f.edge_iter().count() != 4 { + continue; + } + let mut angular = None; + for e in f.edge_iter() { + if let Some(Some(mid)) = crate::offset::arc_mid(&e, tol * 10.0) { + let am = angle(mid, o, a, radial, tangent); + let aa = unwrap(angle(from_p3(e.front().point()), o, a, radial, tangent), am); + let ab = unwrap(angle(from_p3(e.back().point()), o, a, radial, tangent), am); + angular = Some((aa.min(ab), aa.max(ab))); + break; + } + } + let Some((alo, ahi)) = angular else { continue }; + if alo >= -tol / r || ahi <= tol / r { + continue; + } + if chosen.is_some() { + return Err(fail("ambiguous cylindrical face")); + } + chosen = Some((fi, o, a, r, radial, tangent, alo, ahi)); + } + let (fi, o, axis, r, radial, tangent, alo, ahi) = + chosen.ok_or_else(|| fail("sketch must be tangent to one complete exterior cylindrical wall, clear of its seam"))?; + let mut caps = Vec::new(); + let mut inner: f64 = 0.0; + for s in &surfaces { + match *s { + Surf::Plane { n, d } if n.dot(axis).abs() > 1.0 - 1e-8 => caps.push((d - n.dot(o)) / n.dot(axis)), + Surf::Cylinder { o: co, a: ca, r: cr, convex } + if ca.dot(axis).abs() > 1.0 - 1e-8 && (co - o - axis * (co - o).dot(axis)).len() < tol * 10.0 => + { + if convex && (cr - r).abs() > tol * 10.0 { + return Err(fail("interrupted or stepped cylinder")); + } + if !convex { + inner = inner.max(cr); + } + } + _ => return Err(fail("requires a complete cylinder or coaxial sleeve")), + } + } + if caps.len() != 2 { + return Err(fail("requires two planar endcaps")); + } + let lo = caps.iter().copied().fold(f64::INFINITY, f64::min); + let hi = caps.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let rr = r + depth; + if rr <= inner + tol * 10.0 { + return Err(fail("depth reaches the axis or inner wall")); + } + if let Some(at) = anchor { + let h = (at - o).dot(axis); + let v = at - o - axis * h; + let theta = angle(at, o, axis, radial, tangent); + if (v.len() - r).abs() > tol * 10.0 || theta <= alo + tol / r || theta >= ahi - tol / r || h <= lo + tol || h >= hi - tol { + return Err(fail("saved selection no longer identifies the sketch's cylindrical face")); + } + } + let z0 = (plane.origin - o).dot(axis); + let map2 = |p: Vec2| Vec2::new(plane.dir_to_world(p).dot(tangent), z0 + plane.dir_to_world(p).dot(axis)); + let mut count = 0usize; + let mut mapped = Vec::new(); + for reg in regions { + let mut loops = Vec::new(); + for lp in std::iter::once(®.outer).chain(®.holes) { + count = count.saturating_add(lp.segs.len()); + if count > 4096 || lp.segs.len() < 4 || !lp.is_closed(tol) { + return Err(fail("profile geometry budget or open loop")); + } + let mut segs = Vec::new(); + for s in &lp.segs { + let Seg2::Line { a, b } = *s else { + return Err(fail("only orthogonal line profiles are supported on cylinders")); + }; + if !a.is_finite() || !b.is_finite() || a.len() > 1e7 || b.len() > 1e7 { + return Err(fail("nonfinite or excessive profile coordinates")); + } + let (a, b) = (map2(a), map2(b)); + let d = b - a; + if d.len() < tol || (d.x.abs() > tol && d.y.abs() > tol) { + return Err(fail("profile edges must follow the cylinder axis or circumference")); + } + for p in [a, b] { + if p.x / r <= alo + tol / r || p.x / r >= ahi - tol / r || p.y <= lo + tol || p.y >= hi - tol { + return Err(fail("profile crosses a cylinder seam or endcap boundary")); + } + } + segs.push(Seg2::Line { a, b }); + } + loops.push(Loop2 { segs }); + } + let outer = loops.first().ok_or_else(|| fail("profile"))?.ccw(); + let holes = loops.iter().skip(1).map(|l| l.ccw().reversed()).collect(); + mapped.push(Region2 { outer, holes }); + } + validate_profiles(&mapped, tol)?; + let at = |p: Vec2, radius: f64| o + axis * p.y + (radial * (p.x / r).cos() + tangent * (p.x / r).sin()) * radius; + let selected = faces.get(fi).ok_or_else(|| fail("face"))?; + let mut base_patches = vec![selected.absolute_boundaries().clone()]; + let mut hole_patches = Vec::new(); + let mut footprints = Vec::new(); + let mut added = Vec::new(); + let k = rr / r; + let coord = |v: Vec3| [v.x, v.y, v.z]; + let av = coord(axis); + let ov = coord(o); + let mut m = [[0.0; 4]; 4]; + for (j, col) in m.iter_mut().take(3).enumerate() { + for (i, slot) in col.iter_mut().take(3).enumerate() { + *slot = if i == j { k } else { 0.0 } + (1.0 - k) * av.get(i).copied().unwrap_or(0.0) * av.get(j).copied().unwrap_or(0.0); + } + } + for (i, oi) in ov.iter().enumerate() { + let translation = + *oi - (0..3).map(|j| m.get(j).and_then(|row| row.get(i)).copied().unwrap_or(0.0) * ov.get(j).copied().unwrap_or(0.0)).sum::(); + if let Some(slot) = m.get_mut(3).and_then(|row| row.get_mut(i)) { + *slot = translation; + } + } + if let Some(slot) = m.get_mut(3).and_then(|row| row.get_mut(3)) { + *slot = 1.0; + } + let [c0, c1, c2, c3] = m; + let [a, b, c, d] = c0; + let [e, f, g, h] = c1; + let [i, j, l, n] = c2; + let [p, q, s, t] = c3; + let mat = mt::Matrix4::new(a, b, c, d, e, f, g, h, i, j, l, n, p, q, s, t); + let raised = selected.oriented_surface().transformed(mat); + for reg in &mapped { + let mut cap = Vec::new(); + for (li, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() { + let (base, top, walls) = wrapped_loop(lp, r, rr, &at)?; + if li == 0 { + footprints.push((lp, base.inverse())); + } else { + let index = base_patches.len(); + base_patches.push(vec![base.inverse()]); + hole_patches.push((lp, index)); + } + cap.push(top); + added.extend(walls); + } + added.push(mt::Face::try_new(cap, raised.clone()).map_err(|e| fail(&e.to_string()))?); + } + // An island inside a profile hole cuts that hole's original-radius patch, + // rather than adding another hole to the already-cut exterior patch. + for (lp, wire) in footprints { + let probe = lp.segs.first().ok_or_else(|| fail("empty profile"))?.start(); + let owner = hole_patches + .iter() + .filter(|(hole, _)| hole.contains(probe)) + .min_by(|(a, _), (b, _)| a.signed_area().abs().total_cmp(&b.signed_area().abs())) + .map(|(_, index)| *index) + .unwrap_or(0); + base_patches.get_mut(owner).ok_or_else(|| fail("profile ownership"))?.push(wire); + } + let mut all: Vec<_> = faces.iter().enumerate().filter(|(i, _)| *i != fi).map(|(_, f)| f.clone()).collect(); + for patch in base_patches { + all.push(mt::Face::try_new(patch, selected.oriented_surface()).map_err(|e| fail(&e.to_string()))?); + } + all.extend(added); + let solid = Solid::try_new(vec![all.into()]).map_err(|e| fail(&e.to_string()))?; + let result = Body::new(solid)?; + if !result.validity().is_empty() { + return Err(fail("rebuilt cylinder is not a closed manifold")); + } + Ok(result) + }) +} + +fn wrapped_loop(lp: &Loop2, r: f64, rr: f64, at: &impl Fn(Vec2, f64) -> Vec3) -> Result<(mt::Wire, mt::Wire, Vec)> { + let base: Vec<_> = lp.segs.iter().map(|s| builder::vertex(p3(at(s.start(), r)))).collect(); + let top: Vec<_> = lp.segs.iter().map(|s| builder::vertex(p3(at(s.start(), rr)))).collect(); + let radial: Vec<_> = base.iter().zip(&top).map(|(a, b)| builder::line(a, b)).collect(); + let mut be = Vec::new(); + let mut te = Vec::new(); + let mut walls = Vec::new(); + for (i, s) in lp.segs.iter().enumerate() { + let next = (i + 1) % lp.segs.len(); + let (Some(a), Some(b), Some(c), Some(d), Some(ra), Some(rb)) = + (base.get(i), base.get(next), top.get(i), top.get(next), radial.get(i), radial.get(next)) + else { + return Err(fail("loop topology")); + }; + let circular = (s.end().x - s.start().x).abs() > 1e-10; + let eb = if circular { builder::circle_arc(a, b, p3(at(s.mid(), r))) } else { builder::line(a, b) }; + let et = if circular { builder::circle_arc(c, d, p3(at(s.mid(), rr))) } else { builder::line(c, d) }; + let w: mt::Wire = vec![eb.clone(), rb.clone(), et.inverse(), ra.inverse()].into(); + walls.push(builder::try_attach_plane(&[w]).map_err(|e| fail(&e.to_string()))?); + be.push(eb); + te.push(et); + } + Ok((be.into(), te.into(), walls)) +} + +fn validate_profiles(regions: &[Region2], tol: f64) -> Result<()> { + let loops: Vec<_> = regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).collect(); + let edges: Vec<_> = loops.iter().enumerate().flat_map(|(l, lp)| lp.segs.iter().enumerate().map(move |(i, s)| (l, i, *s))).collect(); + for (k, (l, i, s)) in edges.iter().enumerate() { + for (ll, j, t) in edges.iter().skip(k + 1) { + let len = loops.get(*l).map(|lp| lp.segs.len()).unwrap_or(0); + if l == ll && ((*i + 1) % len == *j || (*j + 1) % len == *i) { + continue; + } + let (a, b, c, d) = (s.start(), s.end(), t.start(), t.end()); + let ab = b - a; + let cd = d - c; + let cross = ab.cross(cd); + let intersects = if cross.abs() > tol * tol { + let u = (c - a).cross(cd) / cross; + let v = (c - a).cross(ab) / cross; + (0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v) + } else { + false + }; + if intersects + || [a, b].iter().any(|p| t.point_at(t.closest_param(*p)).dist(*p) < tol) + || [c, d].iter().any(|p| s.point_at(s.closest_param(*p)).dist(*p) < tol) + { + return Err(fail("profile boundaries intersect or touch")); + } + } + } + for (i, r) in regions.iter().enumerate() { + if r.area() <= tol * tol { + return Err(fail("profile has no area")); + } + for (h, lp) in r.holes.iter().enumerate() { + if !r.outer.contains(lp.segs.first().ok_or_else(|| fail("empty hole"))?.start()) { + return Err(fail("hole outside profile")); + } + for other in r.holes.iter().skip(h + 1) { + if lp.contains(other.segs.first().ok_or_else(|| fail("empty hole"))?.start()) + || other.contains(lp.segs.first().ok_or_else(|| fail("empty hole"))?.start()) + { + return Err(fail("nested profile holes")); + } + } + } + for other in regions.iter().skip(i + 1) { + if r.contains(other.outer.segs.first().ok_or_else(|| fail("empty profile"))?.start()) + || other.contains(r.outer.segs.first().ok_or_else(|| fail("empty profile"))?.start()) + { + return Err(fail("overlapping profiles")); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn profile() -> Region2 { + Region2 { outer: Loop2::polygon(&[Vec2::new(-2., 5.), Vec2::new(2., 5.), Vec2::new(2., 10.), Vec2::new(-2., 10.)]), holes: vec![] } + } + fn plane() -> Plane { + Plane::new(Vec3::Y * 10., -Vec3::X, Vec3::Z).unwrap() + } + #[test] + fn wrapped_nested_island_uses_its_enclosing_hole_patch() { + let body = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap(); + let mut ring = profile(); + ring.holes.push(Loop2::polygon(&[Vec2::new(-1., 6.), Vec2::new(1., 6.), Vec2::new(1., 9.), Vec2::new(-1., 9.)])); + let island = + Region2 { outer: Loop2::polygon(&[Vec2::new(-0.5, 6.5), Vec2::new(0.5, 6.5), Vec2::new(0.5, 8.5), Vec2::new(-0.5, 8.5)]), holes: vec![] }; + let before = body.tessellate_with(1e-4, true).unwrap().measure().volume; + for depth in [1., -1.] { + for regions in [vec![ring.clone(), island.clone()], vec![island.clone(), ring.clone()]] { + let result = emboss_cylinder(&body, &plane(), ®ions, depth).unwrap(); + assert_eq!(result.shell_count(), 1); + assert!(result.validity().is_empty()); + let after = result.tessellate_with(1e-4, true).unwrap().measure().volume; + let expected = 16. * (depth + depth * depth / 20.); + assert!((after - before - expected).abs() < 0.02, "{} vs {expected}", after - before); + let mesh = result.tessellate_with(0.001, true).unwrap(); + for (s, raised) in [(0., true), (0.75, false), (1.5, true)] { + let radial = -Vec3::X * (s / 10.0_f64).sin() + Vec3::Y * (s / 10.0_f64).cos(); + let radius = if depth > 0. { 10.5 } else { 9.5 }; + assert_eq!(mesh.contains(radial * radius + Vec3::Z * 7.5), if depth > 0. { raised } else { !raised }); + } + } + } + } + + #[test] + fn exact_wrapped_rectangle_is_connected_and_has_radial_depth() { + let body = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap(); + for depth in [1., -1.] { + let result = emboss_cylinder(&body, &plane(), &[profile()], depth).unwrap(); + assert_eq!(result.shell_count(), 1); + assert!(result.validity().is_empty()); + let before = body.tessellate_with(1e-4, true).unwrap().measure().volume; + let after = result.tessellate_with(1e-4, true).unwrap().measure().volume; + let expected = if depth > 0. { 21. } else { -19. }; + assert!((after - before - expected).abs() < 0.02, "{} vs {expected}", after - before); + let mesh = result.tessellate_with(0.001, true).unwrap(); + for s in [-1.5, 0., 1.5] { + let radial = -Vec3::X * (s / 10.0_f64).sin() + Vec3::Y * (s / 10.0_f64).cos(); + assert_eq!(mesh.contains(radial * (if depth > 0. { 10.5 } else { 9.5 }) + Vec3::Z * 7.5), depth > 0.); + } + } + } + + #[test] + fn wrapped_holes_and_concave_profiles_preserve_material_on_rotated_axes() { + let origin = Vec3::new(123.0, -456.0, 789.0); + let axis = Vec3::X; + let radial = Vec3::Y; + let tangent = Vec3::Z; + let body = crate::cylinder(origin, axis, 10.0, 20.0).unwrap(); + let plane = Plane::new(origin + radial * 10.0, tangent, axis).unwrap(); + let mut holed = profile(); + holed.holes.push(Loop2::polygon(&[Vec2::new(-1., 6.), Vec2::new(1., 6.), Vec2::new(1., 9.), Vec2::new(-1., 9.)])); + let concave = Region2 { + outer: Loop2::polygon(&[ + Vec2::new(-2., 5.), + Vec2::new(2., 5.), + Vec2::new(2., 7.), + Vec2::new(0., 7.), + Vec2::new(0., 10.), + Vec2::new(-2., 10.), + ]), + holes: vec![], + }; + for profile in [holed, concave] { + for depth in [1., -1.] { + let result = emboss_cylinder_at(&body, origin + radial * 10.0 + axis * 7.5, &plane, std::slice::from_ref(&profile), depth).unwrap(); + assert_eq!(result.shell_count(), 1); + let before = body.tessellate_with(1e-4, true).unwrap().measure().volume; + let after = result.tessellate_with(1e-4, true).unwrap().measure().volume; + let want = profile.area().abs() * (depth + depth * depth / 20.0); + assert!((after - before - want).abs() < 0.02, "{} vs {want}", after - before); + let point = |s: f64, z: f64, r: f64| origin + axis * z + (radial * (s / 10.0).cos() + tangent * (s / 10.0).sin()) * r; + let mesh = result.tessellate_with(0.001, true).unwrap(); + for (s, z) in [(-1.5, 7.5), (1.5, 7.5), (0.5, 6.5)] { + let material = profile.contains(Vec2::new(s, z)); + assert_eq!(mesh.contains(point(s, z, if depth > 0. { 10.5 } else { 9.5 })), if depth > 0. { material } else { !material }); + } + let imported = crate::step_import(&crate::step_export(&[&result], "wrapped cylinder").unwrap()).unwrap(); + assert!(imported.warnings.is_empty(), "{:?}", imported.warnings); + assert_eq!(imported.bodies.len(), 1); + let round = imported.bodies.first().unwrap(); + assert!(round.body.validity().is_empty()); + let roundmesh = round.body.tessellate_with(0.001, true).unwrap(); + assert_eq!(roundmesh.contains(point(-1.5, 7.5, if depth > 0. { 10.5 } else { 9.5 })), depth > 0.); + let cut = crate::boolean( + &round.body, + &crate::box_solid(origin + Vec3::new(5., -20., -20.), origin + Vec3::new(10., 20., 20.)).unwrap(), + crate::BoolOp::Cut, + ) + .unwrap() + .unwrap(); + assert!(!cut.tessellate(0.01).unwrap().contains(point(-1.5, 7.5, 9.0))); + } + } + } + + #[test] + fn sleeve_depth_and_hostile_profiles_refuse_changes_atomically() { + let outer = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap(); + let inner = crate::cylinder(-Vec3::Z, Vec3::Z, 8., 22.).unwrap(); + let sleeve = crate::boolean(&outer, &inner, crate::BoolOp::Cut).unwrap().unwrap(); + let good = emboss_cylinder(&sleeve, &plane(), &[profile()], -1.).unwrap(); + assert_eq!(good.shell_count(), 1); + let before = crate::step_export(&[&sleeve], "unchanged").unwrap(); + let mut cap = profile(); + cap.outer = Loop2::polygon(&[Vec2::new(-2., 19.), Vec2::new(2., 19.), Vec2::new(2., 21.), Vec2::new(-2., 21.)]); + let circle = Region2 { outer: Loop2::circle(Vec2::new(0., 7.5), 1.), holes: vec![] }; + let mut crossing = profile(); + crossing.outer = Loop2::polygon(&[Vec2::new(-2., 5.), Vec2::new(2., 10.), Vec2::new(-2., 10.), Vec2::new(2., 5.)]); + for (profiles, d) in [ + (vec![profile()], -2.), + (vec![cap], 1.), + (vec![circle], 1.), + (vec![crossing], 1.), + (vec![profile()], f64::NAN), + (vec![profile(); 65], 1.), + ] { + assert!(emboss_cylinder(&sleeve, &plane(), &profiles, d).is_err()); + } + assert!(emboss_cylinder_at(&sleeve, -Vec3::Y * 10. + Vec3::Z * 7.5, &plane(), &[profile()], 1.).is_err()); + assert_eq!(crate::step_export(&[&sleeve], "unchanged").unwrap(), before); + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 38558a9a..a464e526 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -20,6 +20,7 @@ mod build; mod coplanar; mod curveblend; mod delete_face; +mod emboss; mod freeform; pub mod fuzz; mod heal; @@ -54,6 +55,7 @@ pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points}; pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint}; pub use build::{PathSeg, box_solid, 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}; pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh}; pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step}; pub use iges_out::iges_export_bodies; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 10a10d01..105e9f15 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use solvecraft_geom::Vec3; use truck_modeling as mt; -use crate::body::{Body, Solid, from_p3, p3, v3}; +use crate::body::{Body, Solid, from_p3}; use crate::{KernelError, Result, guard}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -530,16 +530,93 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result { m[0][0], m[0][1], m[0][2], m[0][3], m[1][0], m[1][1], m[1][2], m[1][3], m[2][0], m[2][1], m[2][2], m[2][3], m[3][0], m[3][1], m[3][2], m[3][3], ); - Body::new(mt::builder::transformed(&body.deep_copy(), mat)) + // An affine placement retains face order and appearance metadata. + Body::new(placed_topology(&body.solid, mat)?).map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned())) }) } +// A STEP periodic face may traverse a seam edge twice. Generic topology builders +// reject that wire while cloning, even though placement preserves its topology. +pub(crate) fn placed_topology(source: &Solid, matrix: mt::Matrix4) -> Result { + use mt::{Transform, Transformed}; + use std::collections::HashMap; + let mut vertices = HashMap::new(); + let mut edges = HashMap::new(); + let mut shells = Vec::new(); + let mut face_count = 0usize; + let mut edge_visits = 0usize; + for shell in source.boundaries() { + let mut faces = Vec::new(); + for face in shell { + face_count = face_count.saturating_add(1); + if face_count > 500_000 { + return Err(KernelError::Invalid("placement exceeds face work limit".into())); + } + let mut wires = Vec::new(); + for wire in face.absolute_boundaries() { + let mut mapped = Vec::new(); + for edge in wire { + edge_visits = edge_visits.saturating_add(1); + if edge_visits > 4_000_000 { + return Err(KernelError::Invalid("placement exceeds edge work limit".into())); + } + if let std::collections::hash_map::Entry::Vacant(entry) = edges.entry(edge.id()) { + for vertex in [edge.absolute_front(), edge.absolute_back()] { + if let std::collections::hash_map::Entry::Vacant(entry) = vertices.entry(vertex.id()) { + let point = matrix.transform_point(vertex.point()); + if !from_p3(point).is_finite() { + return Err(KernelError::Invalid("placement produced nonfinite geometry".into())); + } + entry.insert(mt::Vertex::new(point)); + } + } + let front = + vertices.get(&edge.absolute_front().id()).ok_or_else(|| KernelError::Failed("placement vertex missing".into()))?; + let back = vertices.get(&edge.absolute_back().id()).ok_or_else(|| KernelError::Failed("placement vertex missing".into()))?; + entry.insert(mt::Edge::new_unchecked(front, back, edge.curve().transformed(matrix))); + } + let copied = edges.get(&edge.id()).ok_or_else(|| KernelError::Failed("placement edge missing".into()))?; + mapped.push(if edge.orientation() { copied.clone() } else { copied.inverse() }); + } + wires.push(mt::Wire::from(mapped)); + } + // Wire connectivity, seam sharing and orientation are copied exactly; + // no new topology is introduced by this geometry-only operation. + let mut copied = mt::Face::new_unchecked(wires, face.surface().transformed(matrix)); + if !face.orientation() { + copied.invert(); + } + faces.push(copied); + } + shells.push(mt::Shell::from(faces)); + } + Ok(Solid::new_unchecked(shells)) +} + +#[cfg(test)] +#[test] +fn affine_placement_keeps_body_and_face_paint_attached() { + let paint = crate::Paint { opacity: 0.4, faces: vec![crate::FacePaint { face: 0, color: [0.8, 0.2, 0.1], opacity: 0.3 }] }; + let body = crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap().with_color(Some([0.2, 0.6, 0.8])).with_paint(Some(paint.clone())); + let source_faces = body.faces(0.01).unwrap(); + let transform = [[0.0, 2.0, 0.0, 0.0], [-1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.5, 0.0], [30.0, 40.0, 50.0, 1.0]]; + let placed = transform_matrix(&body, transform).unwrap(); + assert_eq!(placed.color(), body.color()); + assert_eq!(placed.paint(), Some(&paint)); + let faces = placed.faces(0.01).unwrap(); + for source in source_faces { + let target = faces.iter().find(|f| f.index == source.index).unwrap(); + let p = source.centroid; + assert!(target.centroid.dist(Vec3::new(30.0 - p.y, 40.0 + 2.0 * p.x, 50.0 + 0.5 * p.z)) < 1e-6); + } +} + /// Rigid transform: rotate about `axis` through `origin` by `angle` radians, then translate. pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: f64) -> Result { if !(translate.is_finite() && origin.is_finite() && angle.is_finite()) { return Err(KernelError::Invalid("non-finite transform".into())); } - if body.is_mesh() { + { // Same motion as a matrix: rotate about the line through `origin`, then translate. let ax = if angle.abs() > 1e-12 { axis.normalized().ok_or_else(|| KernelError::Invalid("rotation axis".into()))? } else { Vec3::Z }; let (c, s) = (angle.cos(), angle.sin()); @@ -556,19 +633,8 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: ); let tr = origin - ro + translate; let m = [[r[0][0], r[0][1], r[0][2], 0.0], [r[1][0], r[1][1], r[1][2], 0.0], [r[2][0], r[2][1], r[2][2], 0.0], [tr.x, tr.y, tr.z, 1.0]]; - return body.mesh_transformed(&m).ok_or_else(|| KernelError::Failed("mesh transform".into())); + transform_matrix(body, m) } - guard("transform", || { - let mut s = body.deep_copy(); - if angle.abs() > 1e-12 { - let ax = axis.normalized().ok_or_else(|| KernelError::Invalid("rotation axis".into()))?; - s = mt::builder::rotated(&s, p3(origin), v3(ax), mt::Rad(angle)); - } - if translate.len() > 0.0 { - s = mt::builder::translated(&s, v3(translate)); - } - Body::new(s) - }) } /// Split a body with a plane: the parts on the positive and the negative side of the plane @@ -604,3 +670,7 @@ pub(crate) fn half_space(body: &Body, plane: &solvecraft_geom::Plane, positive: let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?; boolean(body, &half, BoolOp::Intersect) } + +#[cfg(test)] +#[path = "placement_tests.rs"] +mod placement_tests; diff --git a/crates/kernel/src/placement_tests.rs b/crates/kernel/src/placement_tests.rs new file mode 100644 index 00000000..2a038bf0 --- /dev/null +++ b/crates/kernel/src/placement_tests.rs @@ -0,0 +1,95 @@ +use super::*; +use crate::body::{p3, v3}; +use mt::builder; + +fn assert_topology(source: &Body, placed: &Body, matrix: [[f64; 4]; 4]) { + use std::collections::HashMap; + let mut vertices = HashMap::new(); + let mut edges = HashMap::new(); + for (a, b) in source.solid.face_iter().zip(placed.solid.face_iter()) { + assert_eq!(a.orientation(), b.orientation()); + let aw = a.absolute_boundaries(); + let bw = b.absolute_boundaries(); + assert_eq!(aw.len(), bw.len()); + for (a, b) in aw.iter().zip(bw.iter()) { + assert_eq!(a.len(), b.len()); + for (a, b) in a.edge_iter().zip(b.edge_iter()) { + assert_eq!(a.orientation(), b.orientation()); + assert_ne!(a.id(), b.id()); + assert_eq!(*edges.entry(a.id()).or_insert(b.id()), b.id()); + for (a, b) in [(a.absolute_front(), b.absolute_front()), (a.absolute_back(), b.absolute_back())] { + assert_ne!(a.id(), b.id()); + assert_eq!(*vertices.entry(a.id()).or_insert(b.id()), b.id()); + let p = from_p3(a.point()); + let expected = Vec3::new( + matrix[0][0] * p.x + matrix[1][0] * p.y + matrix[2][0] * p.z + matrix[3][0], + matrix[0][1] * p.x + matrix[1][1] * p.y + matrix[2][1] * p.z + matrix[3][1], + matrix[0][2] * p.x + matrix[1][2] * p.y + matrix[2][2] * p.z + matrix[3][2], + ); + assert!(from_p3(b.point()).dist(expected) < 1e-9); + } + } + } + } + assert_eq!(edges.len(), source.solid.edge_iter().map(|e| e.id()).collect::>().len()); +} + +fn periodic_cylinder() -> Body { + let b0 = builder::vertex(p3(Vec3::new(10., 0., 0.))); + let b1 = builder::vertex(p3(Vec3::new(-10., 0., 0.))); + let t0 = builder::vertex(p3(Vec3::new(10., 0., 20.))); + let t1 = builder::vertex(p3(Vec3::new(-10., 0., 20.))); + let bottom: mt::Wire = + vec![builder::circle_arc(&b0, &b1, p3(Vec3::new(0., 10., 0.))), builder::circle_arc(&b1, &b0, p3(Vec3::new(0., -10., 0.)))].into(); + let top: mt::Wire = + vec![builder::circle_arc(&t0, &t1, p3(Vec3::new(0., 10., 20.))), builder::circle_arc(&t1, &t0, p3(Vec3::new(0., -10., 20.)))].into(); + let seam = builder::line(&b0, &t0); + let mut boundary: Vec<_> = bottom.edge_iter().cloned().collect(); + boundary.push(seam.clone()); + boundary.extend(top.inverse().edge_iter().cloned()); + boundary.push(seam.inverse()); + let wall = mt::Face::new_unchecked( + vec![boundary.into()], + mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution( + seam.inverse().oriented_curve(), + p3(Vec3::ZERO), + v3(Vec3::Z), + ))), + ); + let lower = builder::try_attach_plane(&[bottom.inverse()]).unwrap(); + let upper = builder::try_attach_plane(&[top]).unwrap(); + Body::new(Solid::new_unchecked(vec![vec![wall, lower, upper].into()])).unwrap() +} + +#[test] +fn periodic_step_seam_survives_identity_rotation_and_translation() { + let source = periodic_cylinder(); + let bytes = crate::step_export(&[&source], "placement regression").unwrap(); + let imported = crate::step_import(&bytes).unwrap(); + assert!(imported.warnings.is_empty(), "{:?}", imported.warnings); + let body = &imported.bodies.first().unwrap().body; + assert!(body.solid.face_iter().any(|f| f.absolute_boundaries().iter().any(|w| !w.is_simple()))); + let source_vertices: Vec<_> = body.solid.vertex_iter().map(|v| from_p3(v.point())).collect(); + let source_faces = body.solid.face_iter().count(); + let unchanged = crate::step_export(&[body], "source snapshot").unwrap(); + let before = body.tessellate_with(1e-3, true).unwrap().measure().volume; + assert!((before - 2000. * std::f64::consts::PI).abs() < 1.); + for matrix in [ + [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.]], + [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [10., 10., 0., 1.]], + [[0., 1., 0., 0.], [-1., 0., 0., 0.], [0., 0., 1., 0.], [10., 10., 30., 1.]], + ] { + let moved = transform_matrix(body, matrix).unwrap(); + assert_topology(body, &moved, matrix); + assert_eq!(moved.solid.face_iter().count(), source_faces); + assert_eq!(moved.solid.vertex_iter().count(), source_vertices.len()); + let after = moved.tessellate_with(1e-3, true).unwrap().measure().volume; + assert!((after - before).abs() < 1e-5, "{after} vs {before}"); + let roundtrip = crate::step_import(&crate::step_export(&[&moved], "placement regression").unwrap()).unwrap(); + assert!(roundtrip.warnings.is_empty(), "{:?}", roundtrip.warnings); + assert!((roundtrip.bodies.first().unwrap().body.tessellate_with(1e-3, true).unwrap().measure().volume - before).abs() < 1e-4); + } + let rigid = transform(body, Vec3::new(10., 10., 30.), Vec3::ZERO, Vec3::Z, std::f64::consts::FRAC_PI_2).unwrap(); + assert_topology(body, &rigid, [[0., 1., 0., 0.], [-1., 0., 0., 0.], [0., 0., 1., 0.], [10., 10., 30., 1.]]); + assert_eq!(crate::step_export(&[body], "source snapshot").unwrap(), unchanged); +} diff --git a/crates/kernel/src/step.rs b/crates/kernel/src/step.rs index 0ab0169c..b6656ec7 100644 --- a/crates/kernel/src/step.rs +++ b/crates/kernel/src/step.rs @@ -7,8 +7,8 @@ use crate::{KernelError, Result, guard}; /// Intersection-curve edges (from booleans) as cubic B-splines through the exact curve: STEP /// readers get plain 3D geometry that lies on both faces, and the file does not repeat the two /// surfaces for every such edge. -fn plain_curves(b: &Body) -> mt::Solid { - let s = b.deep_copy(); +fn plain_curves(b: &Body) -> Result { + let s = crate::ops::placed_topology(&b.solid, mt::Matrix4::from_scale(1.0))?; let size = b.size(); let mut seen = std::collections::HashSet::new(); for e in s.edge_iter() { @@ -21,7 +21,7 @@ fn plain_curves(b: &Body) -> mt::Solid { e.set_curve(mt::Curve::BSplineCurve(c)); } } - s + Ok(s) } /// A C¹ cubic B-spline through exact points of `c` spaced so that chords deviate at most `tol` @@ -71,7 +71,7 @@ pub(crate) fn truck_step(bodies: &[&Body], system: &str) -> Result { b.require_brep("STEP export")?; } guard("step export", || { - let cs: Vec<_> = bodies.iter().map(|b| plain_curves(b).compress()).collect(); + let cs: Vec<_> = bodies.iter().map(|b| plain_curves(b).map(|solid| solid.compress())).collect::>()?; let models: out::StepModels<_, _, _> = cs.iter().collect(); let header = out::StepHeaderDescriptor { organization_system: system.to_owned(), ..Default::default() }; Ok(out::CompleteStepDisplay::new(models, header).to_string()) diff --git a/crates/sketch/src/freeform_offset.rs b/crates/sketch/src/freeform_offset.rs new file mode 100644 index 00000000..9f5cc434 --- /dev/null +++ b/crates/sketch/src/freeform_offset.rs @@ -0,0 +1,697 @@ +//! 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 +//! bounds their distance everywhere, rather than just at selected samples. +use crate::{CurveKind, Sketch, SketchError, fit_beziers}; +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; +type Result = std::result::Result; +fn bad(message: &str) -> SketchError { + SketchError::Invalid(message.into()) +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FreeformOffset { + pub source: String, + pub output: String, + pub forward: bool, + pub distance: f64, + /// Uniform dyadic subdivision count per exact source piece; preserves point roles. + pub divisions: usize, + pub pieces: usize, + pub closed: bool, + pub tolerance: f64, + pub certificate: u8, +} + +#[derive(Clone, Copy)] +struct I { + lo: f64, + hi: f64, +} +impl I { + fn value(v: f64) -> Self { + Self { lo: v, hi: v } + } + fn hull(values: impl Iterator) -> Self { + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + for v in values { + lo = lo.min(v); + hi = hi.max(v); + } + let padding = 64.0 * f64::EPSILON * (lo.abs().max(hi.abs()) + 1.0); + Self { lo: lo - padding, hi: hi + padding } + } + fn add(self, b: Self) -> Self { + Self { lo: self.lo + b.lo, hi: self.hi + b.hi } + } + fn neg(self) -> Self { + Self { lo: -self.hi, hi: -self.lo } + } + fn sub(self, b: Self) -> Self { + self.add(b.neg()) + } + fn mul(self, b: Self) -> Self { + Self::hull([self.lo * b.lo, self.lo * b.hi, self.hi * b.lo, self.hi * b.hi].into_iter()) + } + fn scale(self, b: f64) -> Self { + self.mul(Self::value(b)) + } + fn div(self, b: Self) -> Result { + if b.lo <= 0.0 || !b.hi.is_finite() { + return Err(bad("uncertifiable rational offset denominator")); + } + Ok(self.mul(Self { lo: 1.0 / b.hi, hi: 1.0 / b.lo })) + } + fn abs_max(self) -> f64 { + self.lo.abs().max(self.hi.abs()) + } + fn abs_min(self) -> f64 { + if self.lo <= 0.0 && self.hi >= 0.0 { 0.0 } else { self.lo.abs().min(self.hi.abs()) } + } +} +#[derive(Clone, Copy)] +struct V { + x: I, + y: I, +} +impl V { + fn hull(values: &[Vec2]) -> Self { + Self { x: I::hull(values.iter().map(|v| v.x)), y: I::hull(values.iter().map(|v| v.y)) } + } + fn sub(self, b: Self) -> Self { + Self { x: self.x.sub(b.x), y: self.y.sub(b.y) } + } + fn mul(self, b: I) -> Self { + Self { x: self.x.mul(b), y: self.y.mul(b) } + } + fn div(self, b: I) -> Result { + Ok(Self { x: self.x.div(b)?, y: self.y.div(b)? }) + } + fn upper(self) -> f64 { + self.x.abs_max().hypot(self.y.abs_max()) + } + fn lower(self) -> f64 { + self.x.abs_min().hypot(self.y.abs_min()) + } + fn cross(self, b: Self) -> I { + self.x.mul(b.y).sub(self.y.mul(b.x)) + } +} + +/// Homogeneous controls retain the original parameter during restriction. Re-normalising +/// a conic with Seg2::split_at would change that parameter and invalidate the certificate. +#[derive(Clone)] +struct H { + n: Vec, + d: Vec, +} +impl H { + fn normal_end(&self, at_end: bool, distance: f64) -> Result { + let last = self.n.len() - 1; + let (i, j) = if at_end { (last, last - 1) } else { (0, 1) }; + let position = self.n[i] / self.d[i]; + let sign = if at_end { -1.0 } else { 1.0 }; + let dn = (self.n[j] - self.n[i]) * (last as f64 * sign); + let dd = (self.d[j] - self.d[i]) * (last as f64 * sign); + let tangent = ((dn - position * dd) / self.d[i]).normalized().ok_or_else(|| bad("offset source has a stationary tangent"))?; + Ok(position + tangent.perp() * distance) + } + fn from_seg(seg: &Seg2) -> Result { + let (points, weights) = match *seg { + Seg2::Line { a, b } => (vec![a, b], vec![1.0; 2]), + Seg2::Cubic { p0, p1, p2, p3 } => (vec![p0, p1, p2, p3], vec![1.0; 4]), + Seg2::Bezier { n, p } if (1..=7).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]), + Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => (vec![a, apex, b], vec![1.0, w, 1.0]), + _ => return Err(bad("unsupported exact free-form offset piece")), + }; + if points.iter().any(|p| !p.is_finite()) { + return Err(bad("nonfinite offset source")); + } + let scale = weights.iter().copied().fold(1.0_f64, f64::max); + let d = weights.iter().map(|w| w / scale).collect::>(); + Ok(Self { n: points.iter().zip(&d).map(|(p, w)| *p * *w).collect(), d }) + } + fn split(&self, t: f64) -> (Self, Self) { + let (mut n, mut d) = (self.n.clone(), self.d.clone()); + let (mut ln, mut rn, mut ld, mut rd) = (vec![n[0]], vec![n[n.len() - 1]], vec![d[0]], vec![d[d.len() - 1]]); + for width in (1..n.len()).rev() { + for i in 0..width { + n[i] = n[i].lerp(n[i + 1], t); + d[i] = d[i] * (1.0 - t) + d[i + 1] * t; + } + ln.push(n[0]); + rn.push(n[width - 1]); + ld.push(d[0]); + rd.push(d[width - 1]); + } + rn.reverse(); + rd.reverse(); + (Self { n: ln, d: ld }, Self { n: rn, d: rd }) + } + fn range(&self, a: f64, b: f64) -> Self { + let left = if b < 1.0 { self.split(b).0 } else { self.clone() }; + if a > 0.0 { left.split(a / b).1 } else { left } + } + fn derivatives(&self) -> Result<[V; 4]> { + let origin = self.n[0] / self.d[0]; + let mut n = self.n.iter().zip(&self.d).map(|(n, d)| *n - origin * *d).collect::>(); + let mut d = self.d.clone(); + let mut nh = Vec::new(); + let mut dh = Vec::new(); + for _ in 0..4 { + nh.push(V::hull(&n)); + dh.push(I::hull(d.iter().copied())); + let degree = n.len().saturating_sub(1) as f64; + n = if n.len() > 1 { n.windows(2).map(|p| (p[1] - p[0]) * degree).collect() } else { vec![Vec2::ZERO] }; + d = if d.len() > 1 { d.windows(2).map(|p| (p[1] - p[0]) * degree).collect() } else { vec![0.0] }; + } + let r = nh[0].div(dh[0])?; + let v = nh[1].sub(r.mul(dh[1])).div(dh[0])?; + let a = nh[2].sub(v.mul(dh[1].scale(2.0))).sub(r.mul(dh[2])).div(dh[0])?; + let j = nh[3].sub(a.mul(dh[1].scale(3.0))).sub(v.mul(dh[2].scale(3.0))).sub(r.mul(dh[3])).div(dh[0])?; + Ok([r, v, a, j]) + } + fn offset_second_bound(&self, distance: f64) -> Result { + let [_, v, a, j] = self.derivatives()?; + let speed = v.lower(); + if speed <= 1e-12 { + return Err(bad("offset source has an unresolved stationary tangent")); + } + let curvature = v.cross(a).div(I { lo: speed.powi(3), hi: v.upper().powi(3) })?; + if curvature.scale(distance).hi >= 1.0 - 1e-8 { + return Err(bad("offset crosses a curvature cusp")); + } + let bound = a.upper() + distance.abs() * (2.0 * j.upper() / speed + 5.0 * (a.upper() / speed).powi(2)); + if !bound.is_finite() { + return Err(bad("offset derivative bound overflow")); + } + Ok(bound) + } +} + +/// Subdivision tightens the proof, not the stored geometry. Endpoint discrepancies +/// are measured against the exact rational normal, then the second derivative +/// interpolation bound certifies the full intervening interval. +fn certify(h: &H, cubic: &[Vec2; 4], distance: f64, tolerance: f64, depth: u8, budget: &mut usize) -> Result<()> { + if *budget == 0 { + return Err(bad("offset certificate work limit exceeded")); + } + *budget -= 1; + let endpoint_error = cubic[0].dist(h.normal_end(false, distance)?).max(cubic[3].dist(h.normal_end(true, distance)?)); + if endpoint_error + 1e-10 > tolerance { + return Err(bad("offset spline exceeds its endpoint error certificate")); + } + let cubic_second = ((cubic[2] - cubic[1] * 2.0 + cubic[0]) * 6.0).len().max(((cubic[3] - cubic[2] * 2.0 + cubic[1]) * 6.0).len()); + if h.offset_second_bound(distance).is_ok_and(|second| endpoint_error + (second + cubic_second) / 8.0 * (1.0 + 1e-10) + 1e-10 <= tolerance) { + return Ok(()); + } + if depth >= 8 { + return Err(bad("offset spline exceeds its whole-span error or regularity certificate")); + } + let (left, right) = h.split(0.5); + let curve = Seg2::Cubic { p0: cubic[0], p1: cubic[1], p2: cubic[2], p3: cubic[3] }; + let (a, b) = curve.split_half(); + let controls = |s: Seg2| match s { + Seg2::Cubic { p0, p1, p2, p3 } => Ok([p0, p1, p2, p3]), + _ => Err(bad("invalid cubic certificate")), + }; + certify(&left, &controls(a)?, distance, tolerance, depth + 1, budget)?; + certify(&right, &controls(b)?, distance, tolerance, depth + 1, budget) +} + +fn normal_point(seg: &Seg2, t: f64, d: f64) -> Result { + let normal = seg.derivative(t).normalized().ok_or_else(|| bad("offset source has a stationary tangent"))?.perp(); + let p = seg.point_at(t) + normal * d; + if !p.is_finite() { + return Err(bad("nonfinite offset point")); + } + Ok(p) +} + +fn verify_simple(points: &[Vec2], cubics: &[[Vec2; 4]], closed: bool) -> Result<()> { + // Each cubic's derivative controls must lie in the forward half-plane of its + // chord. This proves local injectivity; separated convex hulls prove distant + // spans cannot intersect. Near or ambiguous contacts are refused atomically. + for c in cubics { + let chord = c[3] - c[0]; + if c.windows(2).any(|p| (p[1] - p[0]).dot(chord) <= 1e-18) { + return Err(bad("offset spline cannot be certified locally simple")); + } + } + // Adjacent spans need their own proof: individual monotonicity does not + // preclude them crossing each other away from their common endpoint. + let separates_joint = |a: &[Vec2; 4], b: &[Vec2; 4]| { + let joint = a[3]; + let direction = (a[3] - a[0]) + (b[3] - b[0]); + joint.dist(b[0]) < 1e-7 + && a[..3].iter().all(|p| (*p - joint).dot(direction) < -1e-18) + && b[1..].iter().all(|p| (*p - joint).dot(direction) > 1e-18) + }; + for pair in cubics.windows(2) { + if !separates_joint(&pair[0], &pair[1]) { + return Err(bad("offset junction cannot be certified simple")); + } + } + if closed && cubics.first().zip(cubics.last()).is_some_and(|(first, last)| !separates_joint(last, first)) { + return Err(bad("offset seam cannot be certified simple")); + } + for i in 0..cubics.len() { + for j in i + 2..cubics.len() { + if closed && i == 0 && j + 1 == cubics.len() { + continue; + } + let a = V::hull(&cubics[i]); + let b = V::hull(&cubics[j]); + if a.x.hi < b.x.lo || b.x.hi < a.x.lo || a.y.hi < b.y.lo || b.y.hi < a.y.lo { + continue; + } + // A separating axis from either chord also separates both convex hulls. + let separated = [points[i + 1] - points[i], points[j + 1] - points[j]].iter().any(|d| { + let n = d.perp(); + let a = I::hull(cubics[i].iter().map(|p| p.dot(n))); + let b = I::hull(cubics[j].iter().map(|p| p.dot(n))); + a.hi < b.lo || b.hi < a.lo + }); + if !separated { + return Err(bad("offset self-intersection or ambiguous close contact")); + } + } + } + Ok(()) +} + +fn geometry(sk: &Sketch, source: usize, forward: bool, d: f64, divisions: usize, tolerance: f64) -> Result<(Vec, usize, bool)> { + let mut pieces = sk.exact_segs(source).ok_or_else(|| bad("offset source changed shape"))?; + if pieces.is_empty() + || pieces.len() > MAX_PIECES + || divisions == 0 + || !divisions.is_power_of_two() + || divisions > 256 + || pieces.len().saturating_mul(divisions) >= crate::MAX_SPLINE_POINTS + || !d.is_finite() + || d.abs() < 1e-9 + || !tolerance.is_finite() + || tolerance <= 0.0 + || tolerance > OFFSET_TOLERANCE + { + return Err(bad("invalid or excessive certified offset")); + } + if !forward { + pieces.reverse(); + pieces = pieces.into_iter().map(|p| p.reversed()).collect(); + } + let mut points = Vec::new(); + let mut restricted = Vec::new(); + for piece in &pieces { + let homogeneous = H::from_seg(piece)?; + let first = normal_point(piece, 0.0, d)?; + if points.last().is_some_and(|p: &Vec2| p.dist(first) > 1e-7) { + return Err(bad("free-form offset has a nonsmooth source junction")); + } + if points.is_empty() { + points.push(first); + } + for i in 0..divisions { + let (a, b) = (i as f64 / divisions as f64, (i + 1) as f64 / divisions as f64); + restricted.push(homogeneous.range(a, b)); + let end = normal_point(piece, b, d)?; + points.push(end); + } + } + let closed = pieces.first().is_some_and(|a| pieces.last().is_some_and(|b| a.point_at(0.0).dist(b.point_at(1.0)) < 1e-7)); + if closed && points[0].dist(points[points.len() - 1]) > 1e-7 { + return Err(bad("free-form closed offset has a nonsmooth seam")); + } + if closed { + let first = points[0]; + if let Some(last) = points.last_mut() { + *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)?; + } + verify_simple(&points, &cubics, closed)?; + Ok((points, pieces.len(), closed)) +} + +impl Sketch { + /// Validate persisted association roles without solving or certifying geometry. + pub fn validate_freeform_offsets(&self) -> Result<()> { + if self.freeform_offsets.is_empty() { + return Ok(()); + } + if self.freeform_offsets.len() > MAX_ASSOCIATIONS { + return Err(bad("too many associative free-form offsets")); + } + if self.points.len() > 100_000 || self.curves.len() > 100_000 { + return Err(bad("offset sketch exceeds ownership validation limit")); + } + let mut owners = vec![0usize; self.points.len()]; + let mut references = 0usize; + for curve in &self.curves { + if matches!(&curve.kind, CurveKind::Spline {pts,..} if pts.len() > crate::MAX_SPLINE_POINTS) { + return Err(bad("offset sketch contains an excessive spline")); + } + for index in curve.kind.point_ids() { + references += 1; + if references > 1_000_000 { + return Err(bad("offset point ownership work limit exceeded")); + } + if let Some(count) = owners.get_mut(index) { + *count += 1; + } + } + } + let mut outputs = std::collections::BTreeSet::new(); + for record in &self.freeform_offsets { + if record.source.is_empty() + || record.source.len() > 512 + || record.output.is_empty() + || record.output.len() > 512 + || record.source == record.output + || record.certificate != 1 + || record.pieces == 0 + || record.pieces > MAX_PIECES + || record.divisions == 0 + || !record.divisions.is_power_of_two() + || record.divisions > 256 + || record.pieces.saturating_mul(record.divisions) >= crate::MAX_SPLINE_POINTS + || !record.distance.is_finite() + || record.distance.abs() < 1e-9 + || !record.tolerance.is_finite() + || record.tolerance <= 0.0 + || record.tolerance > OFFSET_TOLERANCE + || !outputs.insert(record.output.as_str()) + || self.freeform_offsets.iter().any(|other| other.output == record.source && !outputs.contains(other.output.as_str())) + { + return Err(bad("invalid cyclic or unbounded offset association")); + } + let source = self.curve_index(&record.source).ok_or_else(|| bad("offset source vanished"))?; + if !matches!(self.curves[source].kind, CurveKind::Ellipse { .. } | CurveKind::Conic { .. } | CurveKind::Spline { .. }) { + return Err(bad("offset source changed shape")); + } + 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 + && pts.iter().all(|i| *i < self.points.len()) + && pts.iter().copied().collect::>().len() == pts.len() + && pts + .iter() + .all(|i| self.points.get(*i).is_some_and(|point| point.fixed && point.link.is_none()) && owners.get(*i) == Some(&1)) => {} + _ => return Err(bad("invalid offset output point roles")), + } + } + Ok(()) + } + pub fn add_freeform_offset(&mut self, source: usize, forward: bool, distance: f64) -> Result { + if self.freeform_offsets.len() >= MAX_ASSOCIATIONS { + return Err(bad("too many associative free-form offsets")); + } + let source_id = self.curves.get(source).ok_or_else(|| bad("missing offset source"))?.id.clone(); + let source_pieces = self.exact_segs(source).ok_or_else(|| bad("offset source changed shape"))?.len(); + let mut result = None; + let mut failure = None; + 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) { + Ok(g) => { + result = Some((divisions, g)); + break; + } + Err(error) => { + failure = Some(error); + } + } + } + let (divisions, (points, pieces, closed)) = result.ok_or_else(|| failure.unwrap_or_else(|| bad("offset subdivision limit exceeded")))?; + let mut candidate = self.clone(); + let indices = points.iter().map(|p| candidate.add_point(*p, None)).collect::>>()?; + for i in &indices { + if let Some(p) = candidate.points.get_mut(*i) { + p.fixed = true; + } + } + let output = candidate.add_curve(CurveKind::Spline { pts: indices, control: false, degree: 3 }, None)?; + candidate.freeform_offsets.push(FreeformOffset { + source: source_id, + output: candidate.curves[output].id.clone(), + forward, + distance, + divisions, + pieces, + closed, + tolerance: OFFSET_TOLERANCE, + certificate: 1, + }); + *self = candidate; + Ok(output) + } + + pub(crate) fn refresh_freeform_offsets(&mut self) -> Result<()> { + self.validate_freeform_offsets()?; + let mut candidate = self.clone(); + let mut seen = std::collections::BTreeSet::new(); + for (record_index, record) in self.freeform_offsets.clone().into_iter().enumerate() { + if record.certificate != 1 + || record.source == record.output + || !seen.insert(record.output.clone()) + || candidate.freeform_offsets.iter().any(|later| later.output == record.source && !seen.contains(&later.output)) + { + return Err(bad("invalid cyclic offset association")); + } + let source = candidate.curve_index(&record.source).ok_or_else(|| bad("offset source vanished"))?; + let output = candidate.curve_index(&record.output).ok_or_else(|| bad("offset output vanished"))?; + let source_pieces = candidate.exact_segs(source).ok_or_else(|| bad("offset source changed shape"))?.len(); + let mut divisions = record.divisions; + if source_pieces != record.pieces { + let lineage = candidate.freeform_offsets.iter().any(|parent| parent.output == record.source); + if !lineage || source_pieces < record.pieces || source_pieces % record.pieces != 0 { + return Err(bad("offset source topology changed outside its refinement lineage")); + } + let ratio = source_pieces / record.pieces; + if !ratio.is_power_of_two() { + return Err(bad("offset source refinement has incompatible point roles")); + } + divisions = (record.divisions / ratio).max(1); + } + let (points, pieces, closed) = loop { + match geometry(&candidate, source, record.forward, record.distance, divisions, record.tolerance) { + Ok(geometry) => break geometry, + Err(error) => { + if divisions >= 256 || source_pieces.saturating_mul(divisions.saturating_mul(2)) >= crate::MAX_SPLINE_POINTS { + return Err(bad(&format!("offset refinement limit reached while certifying its chain: {error}"))); + } + divisions *= 2; + } + } + }; + if closed != record.closed { + 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(), + _ => return Err(bad("offset output was edited; break its association first")), + }; + // Refinement never reuses or removes an old node ID. Existing dyadic + // roles remain at the same normalized source parameters; new nodes + // append to the point table, so dimensions keep their point indices. + let old_spans = record.pieces * record.divisions; + let new_spans = pieces * divisions; + if new_spans < old_spans || new_spans % old_spans != 0 || !(new_spans / old_spans).is_power_of_two() { + return Err(bad("offset refinement would change existing normalized point roles")); + } + let factor = new_spans / old_spans; + let mut indices = Vec::with_capacity(points.len()); + for (role, pos) in points.iter().enumerate() { + let index = if role % factor == 0 { + *old_indices.get(role / factor).ok_or_else(|| bad("offset node role vanished"))? + } else { + candidate.add_point(*pos, None)? + }; + indices.push(index); + } + for (i, pos) in indices.iter().zip(points) { + let p = candidate.points.get_mut(*i).ok_or_else(|| bad("offset output point vanished"))?; + p.pos = pos; + p.fixed = true; + } + candidate.curves[output].kind = CurveKind::Spline { pts: indices, control: false, degree: 3 }; + candidate.freeform_offsets[record_index].divisions = divisions; + candidate.freeform_offsets[record_index].pieces = pieces; + } + *self = candidate; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ellipse() -> (Sketch, usize) { + let mut sketch = Sketch::new(); + let center = sketch.add_point(Vec2::ZERO, None).unwrap(); + let major = sketch.add_point(Vec2::new(10.0, 0.0), None).unwrap(); + let source = sketch.add_curve(CurveKind::Ellipse { c: center, m: major, r: 5.0 }, None).unwrap(); + (sketch, source) + } + + #[test] + fn certified_offset_ellipse_refreshes_exact_normals_and_identity() { + let (mut sk, source) = ellipse(); + let output = sk.add_freeform_offset(source, true, -1.0).unwrap(); + let record = sk.freeform_offsets[0].clone(); + let original_ids = sk.curves[output].kind.point_ids().iter().map(|i| sk.points[*i].id.clone()).collect::>(); + let verify = |sk: &Sketch| { + let record = &sk.freeform_offsets[0]; + let base = sk.exact_segs(source).unwrap(); + let curve = sk.exact_segs(output).unwrap(); + for (piece, source_piece) in base.iter().enumerate() { + for span in 0..record.divisions { + for t in [0.0, 0.13, 0.41, 0.79, 1.0] { + let want = normal_point(source_piece, (span as f64 + t) / record.divisions as f64, -1.0).unwrap(); + let got = curve[piece * record.divisions + span].point_at(t); + assert!(got.dist(want) <= OFFSET_TOLERANCE, "{got:?} vs {want:?}"); + } + } + } + }; + verify(&sk); + // Independent Steiner area check for the convex ellipse expanded by 1mm. + // Simpson integration evaluates the analytic ellipse speed, not offset samples. + let n = 4096usize; + let h = std::f64::consts::TAU / n as f64; + let speed = |t: f64| (100.0 * t.sin().powi(2) + 25.0 * t.cos().powi(2)).sqrt(); + let perimeter = (0..=n) + .map(|i| { + speed(i as f64 * h) + * if i == 0 || i == n { + 1.0 + } else if i % 2 == 0 { + 2.0 + } else { + 4.0 + } + }) + .sum::() + * h + / 3.0; + let expected_area = 50.0 * std::f64::consts::PI + perimeter + std::f64::consts::PI; + let actual_area = sk.exact_segs(output).unwrap().iter().map(Seg2::area_term).sum::(); + assert!((actual_area - expected_area).abs() < 0.15, "area {actual_area} vs {expected_area}"); + if let CurveKind::Ellipse { m, .. } = sk.curves[source].kind { + sk.points[m].pos = Vec2::new(12.0, 0.0); + } + sk.refresh_offsets().unwrap(); + assert!(sk.freeform_offsets[0].divisions >= record.divisions); + let factor = sk.freeform_offsets[0].divisions / record.divisions; + assert_eq!(sk.curves[output].kind.point_ids().iter().step_by(factor).map(|i| sk.points[*i].id.clone()).collect::>(), original_ids); + verify(&sk); + let first = sk.segs(output)[0].point_at(0.0); + assert!(first.dist(Vec2::new(13.0, 0.0)) < 1e-10); + let encoded = serde_json::to_string(&sk).unwrap(); + let mut reopened: Sketch = serde_json::from_str(&encoded).unwrap(); + reopened.refresh_offsets().unwrap(); + assert_eq!(reopened, sk); + } + + #[test] + fn certified_offset_refuses_cusp_and_hostile_records_atomically() { + let (mut sk, source) = ellipse(); + let before = sk.clone(); + // At (10,0), curvature radius is b²/a=2.5. An inward offset of 3 + // necessarily crosses the evolute and reverses its tangent orientation. + assert!(sk.add_freeform_offset(source, true, 3.0).is_err()); + assert_eq!(sk, before); + sk.add_freeform_offset(source, true, -1.0).unwrap(); + for divisions in [0, 3, usize::MAX] { + let mut candidate = sk.clone(); + candidate.freeform_offsets[0].divisions = divisions; + let before = candidate.clone(); + assert!(candidate.refresh_offsets().is_err()); + assert_eq!(candidate, before); + } + let mut cyclic = sk.clone(); + cyclic.freeform_offsets[0].source = cyclic.freeform_offsets[0].output.clone(); + let before = cyclic.clone(); + assert!(cyclic.refresh_offsets().is_err()); + assert_eq!(cyclic, before); + let source_points = sk.curves[source].kind.point_ids(); + let output = sk.curve_index(&sk.freeform_offsets[0].output).unwrap(); + let mut aliased = sk.clone(); + if let CurveKind::Spline { pts, .. } = &mut aliased.curves[output].kind { + pts[0] = source_points[0]; + } + let before = aliased.clone(); + assert!(aliased.validate_freeform_offsets().is_err()); + assert!(aliased.refresh_offsets().is_err()); + assert_eq!(aliased, before); + } + + #[test] + fn certified_offset_chain_preserves_roles_during_upstream_refinement() { + let mut sk = Sketch::new(); + let start = sk.add_point(Vec2::new(10.0, 0.0), None).unwrap(); + let apex = sk.add_point(Vec2::new(10.0, 5.0), None).unwrap(); + let end = sk.add_point(Vec2::new(0.0, 5.0), None).unwrap(); + let w = std::f64::consts::FRAC_1_SQRT_2; + let source = sk.add_curve(CurveKind::Conic { a: start, b: end, apex, rho: w / (1.0 + w) }, None).unwrap(); + let a = sk.add_freeform_offset(source, true, -1.0).unwrap(); + let b = sk.add_freeform_offset(a, true, -1.0).unwrap(); + let original = [a, b].map(|i| sk.curves[i].kind.point_ids().iter().map(|point| sk.points[*point].id.clone()).collect::>()); + let records = sk.freeform_offsets.clone(); + for major in [12.0, 16.0, 20.0, 24.0] { + sk.points[start].pos.x = major; + sk.points[apex].pos.x = major; + sk.refresh_offsets().unwrap(); + if sk.freeform_offsets[0].divisions > records[0].divisions { + break; + } + } + assert!(sk.freeform_offsets[0].divisions > records[0].divisions, "test must exercise real upstream refinement"); + for (record_index, output) in [a, b].into_iter().enumerate() { + let old = &records[record_index]; + let current = &sk.freeform_offsets[record_index]; + let factor = current.pieces * current.divisions / (old.pieces * old.divisions); + assert_eq!( + sk.curves[output].kind.point_ids().iter().step_by(factor).map(|i| sk.points[*i].id.clone()).collect::>(), + original[record_index] + ); + let source = sk.curve_index(¤t.source).unwrap(); + let source_pieces = sk.exact_segs(source).unwrap(); + let output_pieces = sk.exact_segs(output).unwrap(); + for (piece, source) in source_pieces.iter().enumerate() { + for span in 0..current.divisions { + let t = 0.371; + let want = normal_point(source, (span as f64 + t) / current.divisions as f64, current.distance).unwrap(); + assert!(output_pieces[piece * current.divisions + span].point_at(t).dist(want) < OFFSET_TOLERANCE); + } + } + } + let saved = serde_json::to_string(&sk).unwrap(); + let mut reopened: Sketch = serde_json::from_str(&saved).unwrap(); + reopened.refresh_offsets().unwrap(); + assert_eq!(reopened, sk); + } + + #[test] + 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(); + 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); + } +} diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 3079d6db..07c3ddcd 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -10,6 +10,7 @@ mod curves; mod dims; +mod freeform_offset; mod linalg; mod link; mod model; @@ -24,6 +25,7 @@ pub use curves::{ MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, spline_end_tangent, spline_point, spline_polyline, }; 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; diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 91981f70..9b0c3f27 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -492,6 +492,9 @@ pub struct Sketch { pub links: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub offsets: Vec, + /// New certified normal offsets; legacy sampled output splines remain independent. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub freeform_offsets: Vec, /// 3D curves (world coordinates) carried by the sketch. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub wires: Vec, @@ -521,6 +524,7 @@ impl Sketch { constraints: Vec::new(), links: Vec::new(), offsets: Vec::new(), + freeform_offsets: Vec::new(), wires: Vec::new(), counters: Default::default(), view: SketchView::default(), @@ -886,7 +890,7 @@ impl Sketch { /// Is curve `c` held in place: fixed or projected (linked). pub fn curve_locked(&self, c: usize) -> bool { - self.curves.get(c).is_some_and(|c| c.fixed || c.link.is_some()) + self.curves.get(c).is_some_and(|c| c.fixed || c.link.is_some() || self.freeform_offsets.iter().any(|offset| offset.output == c.id)) } /// Radius of a circle or arc. @@ -1060,6 +1064,7 @@ impl Sketch { /// Remove curves only (points stay), remapping constraints. fn drop_curves(&mut self, which: &[usize]) { + self.prune_offsets_for_removed_curves(which); let mut cmap = vec![None; self.curves.len()]; let mut n = 0; for (i, slot) in cmap.iter_mut().enumerate() { diff --git a/crates/sketch/src/offset.rs b/crates/sketch/src/offset.rs index b3f06455..cca8ec3e 100644 --- a/crates/sketch/src/offset.rs +++ b/crates/sketch/src/offset.rs @@ -12,7 +12,51 @@ pub struct OffsetChain { } impl Sketch { + /// Removing an output removes its relationship. A partial analytic chain + /// deletion releases the surviving outputs, while retained dependents keep + /// their source references so the command's refresh refuses broken sources. + pub(crate) fn prune_offsets_for_removed_curves(&mut self, which: &[usize]) { + let removed = which.iter().filter_map(|i| self.curves.get(*i).map(|curve| curve.id.clone())).collect::>(); + if removed.is_empty() { + return; + } + let released = self + .offsets + .iter() + .filter(|offset| offset.outputs.iter().any(|id| removed.contains(id))) + .flat_map(|offset| offset.outputs.iter().cloned()) + .chain(self.freeform_offsets.iter().filter(|offset| removed.contains(&offset.output)).map(|offset| offset.output.clone())) + .collect::>(); + self.offsets.retain(|offset| !offset.outputs.iter().any(|id| removed.contains(id))); + self.freeform_offsets.retain(|offset| !removed.contains(&offset.output)); + let points = self + .curves + .iter() + .filter(|curve| released.contains(&curve.id)) + .flat_map(|curve| curve.kind.point_ids()) + .collect::>(); + for index in points { + if index != 0 + && self.curves.iter().all(|curve| !curve.kind.uses(index) || released.contains(&curve.id)) + && let Some(point) = self.points.get_mut(index) + { + point.fixed = false; + } + } + } + pub fn refresh_offsets(&mut self) -> Result<(), SketchError> { + if self.offsets.is_empty() && self.freeform_offsets.is_empty() { + return Ok(()); + } + let mut candidate = self.clone(); + candidate.refresh_analytic_offsets()?; + candidate.refresh_freeform_offsets()?; + *self = candidate; + Ok(()) + } + + fn refresh_analytic_offsets(&mut self) -> Result<(), SketchError> { for offset in self.offsets.clone() { if offset.sources.len() != offset.outputs.len() || offset.sources.len() > 10_000 || !offset.distance.is_finite() { return Err(SketchError::Invalid("damaged offset chain".into())); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ee171eb3..a4ab9738 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -23,8 +23,26 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { let (at, normal) = match first { Sel::Profile { sketch, index } => { let ss = st.sketch(*sketch)?; + let plane = profile_plane(app, *sketch)?; let p = ss.profiles.get(*index)?; - (ss.plane.to_world(p.region.interior_point()), Some(ss.plane.normal())) + let centre = if matches!(d.kind, Kind::Extrude { .. }) { + let regions: Vec<_> = d + .inputs + .first()? + .items + .iter() + .filter_map(|item| { + if let Sel::Profile { sketch: id, index } = item { (*id == *sketch).then(|| ss.profiles.get(*index)).flatten() } else { None } + }) + .map(|p| p.region.clone()) + .collect(); + let regions = solvecraft_engine::sketch::merge_regions(®ions); + let points: Vec<_> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect(); + points.iter().fold(solvecraft_engine::geom::Vec2::default(), |sum, p| sum + *p) * (1.0 / points.len().max(1) as f64) + } else { + p.region.interior_point() + }; + (plane.to_world(centre), Some(plane.normal())) } Sel::Face { body, index, point } => (*point, crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| n)), Sel::Edge { body, index, point } => (*point, st.body(body).and_then(|b| edge_bisector(&b.mesh(), *index, *point))), @@ -43,13 +61,15 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { } _ => return None, }; - Some(match d.kind { - Kind::Extrude { .. } - | Kind::Fillet { .. } - | Kind::Section { .. } - | Kind::Hole { .. } - | Kind::OffsetPlane { .. } - | Kind::OffsetFaces { .. } => (at, normal), + Some(match &d.kind { + Kind::Extrude { start, .. } => { + let n = normal?; + let plane = solvecraft_engine::geom::Plane::from_normal(at, n)?; + let from = reference_height(app, d, "From Object", &plane, at).unwrap_or(0.0); + let offset = if start.trim().is_empty() { 0.0 } else { app.session.doc.eval(start, ValueKind::Length).ok()? }; + (at + n * (from + offset), Some(n)) + } + Kind::Fillet { .. } | Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal), // Shell thickness grows into the body. Kind::Shell { .. } => (at, normal.map(|n| -n)), Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)), @@ -59,6 +79,25 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { }) } +fn profile_plane(app: &SolveApp, sketch: u64) -> Option { + let component = app.session.doc.feature(sketch)?.component; + let state = app.session.model.state(); + let plane = state.sketch(sketch)?.plane; + let frame = crate::dialogs::feature_frame(&app.session, component); + solvecraft_engine::geom::Plane::new( + solvecraft_engine::doc::apply_point(&frame, plane.origin), + solvecraft_engine::doc::apply_vector(&frame, plane.x), + solvecraft_engine::doc::apply_vector(&frame, plane.y), + ) +} + +fn reference_height(app: &SolveApp, d: &Dialog, label: &str, plane: &solvecraft_engine::geom::Plane, centre: Vec3) -> Option { + let point = crate::dialogs::object_point(&d.inputs, label)?; + let p = point.as_array()?; + let p = Vec3::new(p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2)?.as_f64()?); + Some(solvecraft_engine::doc::object_height(&app.session.world_state(), plane, centre, p)) +} + /// The outward direction between the two faces at an edge (where a fillet's radius arrow /// points). fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option { @@ -155,11 +194,40 @@ fn offset_plane_quad(proj: &Proj, c: Vec3, n: Vec3, h: f64) -> Option> #[allow(clippy::too_many_arguments)] fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog, base: Vec3, bs: Pos2, dir: Option) { let t = Tokens::get(); - let sign = match d.kind { - Kind::Extrude { direction: 1, .. } => -1.0, + let to_span = dir.and_then(|n| { + let plane = solvecraft_engine::geom::Plane::from_normal(base, n)?; + reference_height(app, d, "To Object", &plane, base) + }); + let all_span = if matches!(d.kind, Kind::Extrude { all: true, .. }) { + dir.and_then(|n| { + let state = app.session.world_state(); + let plane = match d.inputs.first()?.items.first()? { + Sel::Profile { sketch, .. } => profile_plane(app, *sketch)?, + _ => solvecraft_engine::geom::Plane::from_normal(base, n)?, + }; + let targets: Vec = d + .extra + .get("targets") + .and_then(serde_json::Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()) + .unwrap_or_default(); + solvecraft_engine::doc::through_all_distance(&state, &plane, &targets).ok() + }) + } else { + None + }; + let sign = match (to_span, &d.kind) { + (Some(span), _) => { + if span < 0.0 { + -1.0 + } else { + 1.0 + } + } + (_, Kind::Extrude { direction: 1, .. }) => -1.0, _ => 1.0, }; - let symmetric = matches!(d.kind, Kind::Extrude { direction: 2, .. }); + let symmetric = to_span.is_none() && matches!(d.kind, Kind::Extrude { direction: 2, .. }); // Extrudes and moves go either way; radii and thicknesses stay positive. let signed = matches!(d.kind, Kind::Extrude { .. } | Kind::Move { .. } | Kind::Section { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. }); @@ -170,7 +238,7 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, if let (Kind::Extrude { direction: crate::dialogs::TWO_SIDES, distance2, .. }, Some(n)) = (&mut d.kind, dir) && let Some(l2) = app.session.doc.eval(distance2, ValueKind::Length).ok().filter(|v| v.is_finite()) { - drag_arrow(ui, painter, proj, base, bs, -n, l2, false, egui::Id::new("sc_manipulator2"), half_height, distance2); + drag_arrow(ui, painter, proj, base, bs, -n, l2, true, egui::Id::new("sc_manipulator2"), half_height, distance2); } // An offset plane shows where it will be. if let (Kind::OffsetPlane { offset }, Some(n)) = (&d.kind, dir) @@ -184,16 +252,21 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, let mut dir = dir; // Extrude taper: a handle beside the arrow tip; dragging it sideways tilts the walls. let cam_right = app.cam.basis().0; - if let (Kind::Extrude { direction: 0 | 1, distance, taper, .. }, Some(n)) = (&mut d.kind, dir) { + if let (Kind::Extrude { direction: 0 | 1, distance, taper, taper_present, to_offset, .. }, Some(n)) = (&mut d.kind, dir) { let n = n * sign; - if let Ok(l) = app.session.doc.eval(distance, ValueKind::Length) + let span = to_span.map(|span| span.abs() + app.session.doc.eval(to_offset, ValueKind::Length).unwrap_or(0.0)).or(all_span); + if let Ok(l) = span.map(Ok).unwrap_or_else(|| app.session.doc.eval(distance, ValueKind::Length)) && l.is_finite() && l.abs() > 1e-9 && let Some(e) = (cam_right - n * cam_right.dot(n)).normalized() { let px = 2.0 * half_height / f64::from(proj.rect.height().max(1.0)); let ang = app.session.doc.eval(taper, ValueKind::Angle).ok().filter(|v| v.is_finite()).unwrap_or(0.0); + let before = taper.clone(); taper_handle(ui, painter, proj, base - e * (40.0 * px), n * l, -e, ang, taper); + if *taper != before { + *taper_present = true; + } } } if let (Kind::Hole { depth, opts, .. }, Some(n)) = (&mut d.kind, dir) { @@ -207,7 +280,10 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, dir = None; } let Some((label, kind, value)) = d.primary() else { return }; - let len = app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite()); + let len = all_span.or_else(|| { + if to_span.is_some() && value.trim().is_empty() { Some(0.0) } else { app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite()) } + }); + let before = value.clone(); let mut box_at = bs + vec2(16.0, -30.0); if let (Some(axis), Some(angle)) = (axis, len.filter(|_| kind == ValueKind::Angle)) && let Some(at) = rotator(ui, painter, proj, base, axis, angle, value) @@ -216,7 +292,17 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, } if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) { let n = n * sign; - if let Some(ts) = drag_arrow(ui, painter, proj, base, bs, n, l, signed, egui::Id::new("sc_manipulator"), half_height, value) { + let baseline = to_span.map(f64::abs).unwrap_or(0.0); + let mut shown_value = format_len(l + baseline); + let original_shown = shown_value.clone(); + let dragged = if to_span.is_some() { &mut shown_value } else { &mut *value }; + if let Some(ts) = drag_arrow(ui, painter, proj, base, bs, n, l + baseline, signed, egui::Id::new("sc_manipulator"), half_height, dragged) { + if to_span.is_some() + && shown_value != original_shown + && let Ok(v) = app.session.doc.eval(&shown_value, ValueKind::Length) + { + *value = format_len(v - baseline); + } if symmetric && let Some(bt) = proj.to_screen(base - n * l) { arrow(painter, bs, bt, t.manipulator); } @@ -241,6 +327,11 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, }); }); }); + if *value != before + && let Kind::Extrude { all, .. } = &mut d.kind + { + *all = false; + } } /// A draggable arrow from `base` along `n` showing `l` (at least a short stub); dragging writes @@ -338,11 +429,39 @@ fn taper_handle(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, foot: V } /// The revolve axis (a point on it and its unit direction). -fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> { +pub(crate) fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> { if !matches!(d.kind, Kind::Revolve { .. }) { return None; } - crate::dialogs::axis_of(app, d.inputs.get(1)?.items.first()?) + if let Some(selected) = d.inputs.get(1)?.items.first() { + if let Sel::Axis { name } = selected + && matches!(name.as_str(), "X" | "Y" | "Z") + && let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()? + { + let component = app.session.doc.feature(*sketch)?.component; + let frame = crate::dialogs::feature_frame(&app.session, component); + let dir = match name.as_str() { + "X" => Vec3::X, + "Y" => Vec3::Y, + _ => Vec3::Z, + }; + return Some((solvecraft_engine::doc::apply_point(&frame, Vec3::ZERO), solvecraft_engine::doc::apply_vector(&frame, dir))); + } + if let Sel::Axis { name } = selected + && matches!(name.as_str(), "x" | "y") + && let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()? + { + let plane = profile_plane(app, *sketch)?; + return Some((plane.origin, if name == "x" { plane.x } else { plane.y })); + } + return crate::dialogs::axis_of(app, selected); + } + let Kind::Revolve { axis: Some(axis), .. } = &d.kind else { return None }; + let vector = |key: &str| -> Option { + let p = axis.get(key)?.as_array()?; + Some(Vec3::new(p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2)?.as_f64()?)) + }; + Some((vector("origin")?, vector("dir")?.normalized()?)) } /// The revolve angle rotator: an arc from the profile around the axis with a handle at its end diff --git a/crates/ui-egui/src/configuration_rules_ui.rs b/crates/ui-egui/src/configuration_rules_ui.rs index 4e75ff74..0fcffab3 100644 --- a/crates/ui-egui/src/configuration_rules_ui.rs +++ b/crates/ui-egui/src/configuration_rules_ui.rs @@ -210,6 +210,18 @@ pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option, if let Some(preview) = &editor.preview { ui.collapsing(format!("Preview: {}", preview["active"].as_str().unwrap_or("row")), |ui| { ui.label("Preview leaves the current design unchanged."); + if let Some(styles) = preview["styles"].as_array() { + for style in styles { + ui.add( + egui::Label::new(format!( + "{}: {} (shared definition)", + style["target"].as_str().unwrap_or("Appearance"), + if style["dormant"].as_bool() == Some(true) { "dormant" } else { "resolved" } + )) + .wrap(), + ); + } + } if let Some(trace) = preview["trace"].as_array() { for entry in trace { ui.label(format!( diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 5b12284b..4bfa024b 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -652,6 +652,9 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { if !curves.is_empty() { v.push(cmd(app, "sketch.construction", "Normal / Construction").with(json!({ "curves": curves }))); } + if curves.len() == 1 && ss.sketch.freeform_offsets.iter().any(|offset| offset.output == curves[0]) { + v.push(cmd(app, "sketch.offset.break", "Break Offset Association").with(json!({"curve":curves[0]}))); + } v.push(act("ui.fix", "Fix/Unfix", "c_fix").with(json!({ "entities": ents }))); v.push(act("ui.delete", "Delete", "delete").key("Del")); v.push(cmd(app, "sketch.dimension", "Sketch Dimension").with(json!({ "entities": ents })).on(ents.len() <= 2)); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 657047d1..7dd8afda 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -40,6 +40,10 @@ pub enum Kind { distance2: String, /// Taper angle (empty or zero: none). taper: String, + /// An explicitly authored zero taper is still a model parameter. + taper_present: bool, + /// Optional distance beyond the selected end object. + to_offset: String, /// Start offset from the profile plane (empty: start on the profile plane). start: String, /// Extent type All (through everything) instead of a distance. @@ -48,6 +52,8 @@ pub enum Kind { Revolve { angle: String, operation: usize, + /// An arbitrary stored line until the user deliberately picks a replacement. + axis: Option, }, Fillet { radius: String, @@ -262,6 +268,8 @@ pub struct Dialog { pub error: Option, /// Editing an existing feature (its id, and the timeline marker to restore afterwards). pub editing: Option<(u64, Option)>, + /// A user pick or clear makes the profile selector explicit, even if all are reselected. + pub profile_selection_changed: bool, /// Give the on-canvas value box the keyboard (and select its text) on the next frame. pub focus: bool, /// Folded to its header (the "−" glyph). @@ -288,7 +296,17 @@ fn fits(a: Accept, s: &Sel) -> bool { impl Dialog { pub(crate) fn new(kind: Kind, inputs: Vec) -> Dialog { - Dialog { kind, inputs, active: 0, error: None, editing: None, focus: true, collapsed: false, extra: serde_json::Map::new() } + Dialog { + kind, + inputs, + active: 0, + error: None, + editing: None, + profile_selection_changed: false, + focus: true, + collapsed: false, + extra: serde_json::Map::new(), + } } pub fn for_command(app: &SolveApp, id: &str) -> Option { @@ -304,13 +322,15 @@ impl Dialog { auto_op: true, distance2: "10 mm".into(), taper: "0 deg".into(), + taper_present: false, + to_offset: String::new(), start: String::new(), all: false, }, vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)], ), "solid.revolve" => Dialog::new( - Kind::Revolve { angle: "360 deg".into(), operation: 0 }, + Kind::Revolve { angle: "360 deg".into(), operation: 0, axis: None }, vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Axis", AXES, false)], ), "solid.fillet" => Dialog::new(fillet_kind("2 mm", false), vec![SelInput::new("Edges", EDGES | FACES, true)]), @@ -332,7 +352,10 @@ impl Dialog { Kind::Rib { web: id == "solid.web", thickness: "2 mm".into(), depth: String::new(), flip: false }, vec![SelInput::new("Curves", CURVES, true)], ), - "solid.emboss" => Dialog::new(Kind::Emboss { depth: "1 mm".into(), deboss: false }, vec![SelInput::new("Profiles", PROFILES, true)]), + "solid.emboss" => Dialog::new( + Kind::Emboss { depth: "1 mm".into(), deboss: false }, + vec![SelInput::new("Profiles", PROFILES, true), SelInput::new("Target Face (optional)", FACES, false)], + ), "solid.replace_face" => Dialog::new( Kind::ReplaceFace, vec![SelInput::new("Faces", PLANAR_FACES, true), SelInput::new("Target", PLANES | PLANAR_FACES, false)], @@ -567,6 +590,7 @@ impl Dialog { /// The main value (shown on the canvas too): label, kind and the expression. pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> { Some(match &mut self.kind { + Kind::Extrude { to_offset, .. } if to_object_on(&self.inputs) => ("To Offset", ValueKind::Length, to_offset), Kind::Extrude { distance, .. } => ("Distance", ValueKind::Length, distance), Kind::Revolve { angle, .. } => ("Angle", ValueKind::Angle, angle), Kind::Fillet { radius, chamfer, .. } => (if *chamfer { "Distance" } else { "Radius" }, ValueKind::Length, radius), @@ -633,6 +657,9 @@ impl Dialog { /// A pick in the viewport (already accepted by the active input). pub fn pick(&mut self, s: &Session, sel: Sel) { + if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) { + self.profile_selection_changed = true; + } let measure = matches!(self.kind, Kind::Measure { .. }); let sel = if measure { crate::viewport::measurement_selection(s, &sel) } else { sel }; let chain = matches!(self.kind, Kind::Fillet { chain: true, .. }); @@ -673,6 +700,9 @@ impl Dialog { /// Box selection result: replace (or add to) the active input. pub fn take_box(&mut self, s: &Session, sels: Vec, add: bool) { + if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) { + self.profile_selection_changed = true; + } let measure = matches!(self.kind, Kind::Measure { .. }); let Some(inp) = self.inputs.get_mut(self.active) else { return }; if !add { @@ -1055,6 +1085,9 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) { && let Some(inp) = d.inputs.get_mut(i) { inp.items.clear(); + if i == 0 { + d.profile_selection_changed = true; + } d.active = i; } } @@ -1284,17 +1317,22 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { input_rows(&mut d, ui); match &mut d.kind { Kind::Sketch => {} - Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, start, all } => { + Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, taper_present, to_offset, start, all } => { row_label(ui, "Start"); - let mut st = usize::from(!start.is_empty()); - combo(ui, "ex_start", &["Profile Plane", "Offset"], &mut st); - if st == 1 && start.is_empty() { - *start = "5 mm".into(); - } else if st == 0 { - start.clear(); + let old = if object_on(&d.inputs, FROM_OBJECT) { 2 } else { usize::from(!start.is_empty()) }; + let mut st = old; + combo(ui, "ex_start", &["Profile Plane", "Offset", "From Object"], &mut st); + if st != old { + set_object(&mut d.inputs, &mut d.active, FROM_OBJECT, st == 2); + if st == 1 && start.is_empty() { + *start = "5 mm".into(); + } + if st == 0 { + start.clear(); + } } ui.end_row(); - if !start.is_empty() { + if st != 0 { row_label(ui, "Offset"); enter |= field(ui, start); ui.end_row(); @@ -1304,8 +1342,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let order = [0usize, TWO_SIDES, 2]; let mut pos = order.iter().position(|o| o == direction).unwrap_or(0); combo(ui, "ex_dir", &["One Side", "Two Sides", "Symmetric"], &mut pos); - *direction = order.get(pos).copied().unwrap_or(0); + if pos != order.iter().position(|o| o == direction).unwrap_or(0) { + *direction = order.get(pos).copied().unwrap_or(0); + } ui.end_row(); + if *direction < 2 { + row_label(ui, "Reverse"); + let mut reverse = *direction == 1; + if ui.checkbox(&mut reverse, "Reverse Direction").changed() { + *direction = usize::from(reverse); + } + ui.end_row(); + } row_label(ui, "Extent Type"); let cur = if to_object_on(&d.inputs) { 1 } else { usize::from(*all) * 2 }; let mut ext = cur; @@ -1323,9 +1371,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.end_row(); } } + if ext == 1 { + row_label(ui, "To Offset"); + enter |= field(ui, to_offset); + ui.end_row(); + } if *direction < 2 { row_label(ui, "Taper Angle"); + let before = taper.clone(); enter |= field(ui, taper); + if *taper != before { + *taper_present = !taper.trim().is_empty(); + } ui.end_row(); } row_label(ui, "Operation"); @@ -1337,7 +1394,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } ui.end_row(); } - Kind::Revolve { angle, operation } => { + Kind::Revolve { angle, operation, .. } => { row_label(ui, "Type"); let cur = usize::from(to_object_on(&d.inputs)); let mut ty = cur; @@ -1541,6 +1598,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { combo(ui, "emb_mode", &["Emboss", "Deboss"], &mut i); *deboss = i == 1; ui.end_row(); + ui.label(""); + ui.add( + egui::Label::new("Pick a cylinder wall to wrap the profiles around it. Leave Target Face empty for planar embossing.").wrap(), + ); + ui.end_row(); } Kind::Align { flip } => { // Where on the picked face or edge to snap: as picked (a face turns to meet @@ -1873,7 +1935,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { app.dialog = Some(d); } else if let Some((_, marker)) = d.editing { // Editing done: put the timeline marker back where it was. - let _ = app.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({}))); + let _ = roll_marker(app, marker); } } @@ -1886,12 +1948,30 @@ pub fn cancel(app: &mut SolveApp) { if let Some(d) = app.dialog.take() && let Some((_, marker)) = d.editing { - let _ = app.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({}))); + let _ = roll_marker(app, marker); } } +/// Temporary rollback used by an editor is view state. Preserve both history stacks, +/// including redo and a history already at the undo limit. +pub(crate) fn roll_marker(app: &mut SolveApp, marker: Option) -> Result<(), String> { + let undo = std::mem::take(&mut app.session.undo); + let redo = std::mem::take(&mut app.session.redo); + let selection = std::mem::take(&mut app.session.selection); + let result = app.run("timeline.roll_to", marker.map(|m| json!({"position":m})).unwrap_or_else(|| json!({}))); + app.session.undo = undo; + app.session.redo = redo; + app.session.selection = selection; + result.map(|_| ()) +} + /// The pick input an extent of To Object adds (a face, vertex or point to go to). const TO_OBJECT: &str = "To Object"; +const FROM_OBJECT: &str = "From Object"; + +fn object_on(inputs: &[SelInput], label: &str) -> bool { + inputs.iter().any(|i| i.label == label) +} fn to_object_on(inputs: &[SelInput]) -> bool { inputs.iter().any(|i| i.label == TO_OBJECT) @@ -1899,18 +1979,26 @@ fn to_object_on(inputs: &[SelInput]) -> bool { /// Add or remove the To Object input; the active input moves to it when it is added. fn set_to_object(inputs: &mut Vec, active: &mut usize, on: bool) { - if on && !to_object_on(inputs) { - inputs.push(SelInput::new(TO_OBJECT, FACES | selection::VERTICES, false)); + set_object(inputs, active, TO_OBJECT, on); +} + +fn set_object(inputs: &mut Vec, active: &mut usize, label: &'static str, on: bool) { + if on && !object_on(inputs, label) { + inputs.push(SelInput::new(label, FACES | selection::VERTICES, false)); *active = inputs.len() - 1; - } else if !on && to_object_on(inputs) { - inputs.retain(|i| i.label != TO_OBJECT); + } else if !on && object_on(inputs, label) { + inputs.retain(|i| i.label != label); *active = (*active).min(inputs.len().saturating_sub(1)); } } /// The picked To Object place. fn to_object_point(inputs: &[SelInput]) -> Option { - inputs.iter().find(|i| i.label == TO_OBJECT)?.items.first().and_then(|x| match x { + object_point(inputs, TO_OBJECT) +} + +pub(crate) fn object_point(inputs: &[SelInput], label: &str) -> Option { + inputs.iter().find(|i| i.label == label)?.items.first().and_then(|x| match x { Sel::Face { point, .. } | Sel::Vertex { point, .. } | Sel::Edge { point, .. } => Some(pt(*point)), _ => None, }) @@ -1918,9 +2006,13 @@ fn to_object_point(inputs: &[SelInput]) -> Option { /// A To Object input filled from a stored place (editing). fn with_to_object(d: &mut Dialog, s: &Session, to: Option) { + with_object(d, s, TO_OBJECT, to); +} + +fn with_object(d: &mut Dialog, s: &Session, label: &'static str, to: Option) { if let Some(p) = to { - set_to_object(&mut d.inputs, &mut d.active, true); - if let Some(inp) = d.inputs.iter_mut().find(|i| i.label == TO_OBJECT) { + set_object(&mut d.inputs, &mut d.active, label, true); + if let Some(inp) = d.inputs.iter_mut().find(|i| i.label == label) { inp.items = vec![face_sel(s, p).unwrap_or(Sel::Vertex { body: String::new(), point: p })]; } } @@ -2086,17 +2178,58 @@ fn sels(d: &Dialog, i: usize) -> &[Sel] { } fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> { - for (cmd, params) in apply_commands(app, d)? { - app.run(&cmd, params)?; + let commands = apply_commands(app, d)?; + if let Some((_, marker)) = d.editing { + // Record the actual pre-edit timeline state in the command's undo snapshot. + roll_marker(app, marker)?; + } + for (cmd, params) in commands { + if let Err(error) = app.run(&cmd, params) { + if let Some((id, _)) = d.editing { + let position = app.session.doc.feature_index(id); + let _ = roll_marker(app, position); + } + return Err(error); + } } Ok(()) } +/// Placement used to edit a definition in the selected authoring occurrence. +pub(crate) fn feature_frame(s: &Session, component: u64) -> solvecraft_engine::doc::Mat { + if component == s.active_component { s.active_component_frame() } else { s.placed_document().component_transform(component) } +} + /// The commands OK runs (an edit rebuilds its feature in place); the live preview runs the same. pub fn apply_commands(app: &SolveApp, d: &Dialog) -> Result, String> { let cmds = dialog_commands(app, d)?; if let Some((id, _)) = d.editing { - let (cmd, params) = cmds.into_iter().next().ok_or("nothing to apply")?; + let (cmd, mut params) = cmds.into_iter().next().ok_or("nothing to apply")?; + if let Some(feature) = app.session.doc.feature(id) { + let original_all = match &feature.kind { + FeatureKind::Extrude { sketch, profiles: ProfileSel::All, .. } + | FeatureKind::Revolve { sketch, profiles: ProfileSel::All, .. } + | FeatureKind::Emboss { sketch, profiles: ProfileSel::All, .. } => Some(*sketch), + _ => None, + }; + if let Some(sketch) = original_all + && !d.profile_selection_changed + && let Some(ss) = app.session.world_state().sketch(sketch) + { + let mut selected: Vec = sels(d, 0) + .iter() + .filter_map(|item| match item { + Sel::Profile { sketch: id, index } if *id == sketch => Some(*index), + _ => None, + }) + .collect(); + selected.sort_unstable(); + selected.dedup(); + if selected == (0..ss.profiles.len()).collect::>() && sels(d, 0).len() == selected.len() { + params["profiles"] = json!("all"); + } + } + } return Ok(vec![("timeline.redefine".into(), json!({"feature": id, "command": cmd, "params": params}))]); } Ok(cmds) @@ -2111,6 +2244,31 @@ pub fn preview_commands(app: &SolveApp, d: &Dialog) -> Result Result, String> { + let Some(dialog) = app.dialog.as_ref().filter(|d| matches!(d.kind, Kind::Emboss { .. })) else { return Ok(None) }; + let Some((feature, _)) = dialog.editing else { return Ok(None) }; + let Some(feature) = app.session.doc.feature(feature) else { return Err("the edited feature is missing".into()) }; + let FeatureKind::Emboss { sketch: source, face: Some(face), .. } = &feature.kind else { return Ok(None) }; + if *source != sketch { + return Ok(None); + } + let model = app.session.model.state(); + let sketch = model.sketch(sketch).ok_or("the Emboss sketch is missing")?; + let body = model.body(&face.body).ok_or("the Emboss target body is missing")?; + let owner = app.session.doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0); + let plane = sketch.plane; + let frame = face.sketch_frame(&app.session.doc, feature.component, owner, body.feature).map_err(|error| error.to_string())?; + solvecraft_engine::geom::Plane::new( + solvecraft_engine::doc::apply_point(&frame, plane.origin), + solvecraft_engine::doc::apply_vector(&frame, plane.x), + solvecraft_engine::doc::apply_vector(&frame, plane.y), + ) + .map(Some) + .ok_or_else(|| "the Emboss author frame is degenerate".into()) +} + /// The commands (id, parameters) a dialog's OK runs. fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, String> { let s = &app.session; @@ -2126,13 +2284,13 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S sels(d, i).iter().filter_map(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }).collect() }; let (cmd, params): (&str, Value) = match &d.kind { - Kind::Extrude { distance, direction, operation, distance2, taper, start, all, .. } => { + Kind::Extrude { distance, direction, operation, distance2, taper, taper_present, to_offset, start, all, .. } => { need(0, "profiles or a planar face")?; let mut common = json!({"distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")}); let tapered = s.doc.eval(taper, ValueKind::Angle).map(|v| v.abs() > 1e-12).unwrap_or(!taper.trim().is_empty()); if *direction == TWO_SIDES { common["distance2"] = json!(distance2); - } else if *direction < 2 && tapered { + } else if *direction < 2 && (*taper_present || tapered) { common["taper"] = json!(taper); } if !start.trim().is_empty() { @@ -2143,6 +2301,12 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S } if to_object_on(&d.inputs) { common["to"] = to_object_point(&d.inputs).ok_or("select the object to extrude to")?; + if !to_offset.trim().is_empty() { + common["to_offset"] = json!(to_offset); + } + } + if object_on(&d.inputs, FROM_OBJECT) { + common["from"] = object_point(&d.inputs, FROM_OBJECT).ok_or("select the object to extrude from")?; } let with = |extra: Value| -> Value { let mut p = common.clone(); @@ -2165,21 +2329,18 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S } return Ok(out); } - Kind::Revolve { angle, operation } => { + Kind::Revolve { angle, operation, axis: stored_axis } => { need(0, "profiles")?; - need(1, "an axis")?; let axis = match sels(d, 1).first() { Some(Sel::Axis { name }) => json!(name), Some(Sel::SketchCurve { id }) => json!(id), - _ => return Err("select an axis".into()), + _ => stored_axis.clone().ok_or("select an axis")?, }; let (sketch, idx) = profiles(); let mut p = json!({"sketch": sketch, "profiles": idx, "axis": axis, "angle": angle, "operation": OPS.get(*operation).copied().unwrap_or("new")}); if to_object_on(&d.inputs) { p["to"] = to_object_point(&d.inputs).ok_or("select the object to turn to")?; - // A To Object turn has no second side. - return Ok(vec![("solid.revolve".into(), p)]); } ("solid.revolve", p) } @@ -2361,7 +2522,12 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S Kind::Emboss { depth, deboss } => { need(0, "profiles")?; let (sketch, idx) = profiles(); - ("solid.emboss", json!({"sketch": sketch, "profiles": idx, "depth": depth, "mode": if *deboss { "deboss" } else { "emboss" }})) + let mut p = json!({"sketch": sketch, "profiles": idx, "depth": depth, "mode": if *deboss { "deboss" } else { "emboss" }}); + if let Some(Sel::Face { body, point, .. }) = sels(d, 1).first() { + p["face"] = pt(*point); + p["targets"] = json!([body]); + } + ("solid.emboss", p) } Kind::ReplaceFace => { need(0, "faces to move")?; @@ -2783,6 +2949,18 @@ pub(crate) fn face_sel(s: &Session, p0: Vec3) -> Option { best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p0.len()).map(|x| x.1) } +fn emboss_face_sel(s: &Session, component: u64, face: &solvecraft_engine::doc::EmbossFace, persistent: &str) -> Option { + let source = s.model.state(); + let body = source.body(&face.body)?; + let index = solvecraft_engine::doc::naming::face_names(body).iter().position(|name| name == persistent)?; + let owner = s.doc.body_component(&face.body, body.feature); + let world_name = s.doc.world_body_name(&source, &face.body, owner, &face.path); + let author = solvecraft_engine::doc::EmbossFace::component_frame(&s.doc, component, &face.author_path).ok()?; + let point = solvecraft_engine::doc::apply_point(&author, face.at); + s.world_state().body(&world_name)?; + Some(Sel::Face { body: world_name, index, point }) +} + pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option { match pl { PlaneRef::Origin { name } | PlaneRef::Construction { name } => Some(Sel::Plane { name: name.clone() }), @@ -2840,16 +3018,21 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option) -> Option { let mut d = start("solid.revolve")?; - d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation) }; + let frame = feature_frame(s, f.component); + let stored_axis = match axis { + AxisRef::Line { origin, dir } => Some(json!({ + "origin": pt(solvecraft_engine::doc::apply_point(&frame, *origin)), + "dir": pt(solvecraft_engine::doc::apply_vector(&frame, *dir)) + })), + _ => None, + }; + d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation), axis: stored_axis }; if let Some(a2) = angle2 { d.extra.insert("angle2".into(), json!(a2)); } if *symmetric { d.extra.insert("direction".into(), json!("symmetric")); } - with_to_object(&mut d, s, *to); + if let Some(point) = to { + set_to_object(&mut d.inputs, &mut d.active, true); + if let Some(input) = d.inputs.iter_mut().find(|i| i.label == TO_OBJECT) { + input.items = vec![Sel::Vertex { body: String::new(), point: solvecraft_engine::doc::apply_point(&frame, *point) }]; + } + } let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default(); if let Some(inp) = d.inputs.get_mut(0) { inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect(); } let ax = match axis { AxisRef::World { axis } => Some(Sel::Axis { name: axis.to_ascii_uppercase() }), + AxisRef::SketchAxis { axis } => Some(Sel::Axis { name: axis.to_ascii_lowercase() }), AxisRef::SketchLine { curve } => Some(Sel::SketchCurve { id: curve.clone() }), _ => None, }; @@ -3289,7 +3486,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face } => { let mut d = start("solid.emboss")?; d.kind = Kind::Emboss { depth: depth.clone(), deboss: *deboss }; let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default(); @@ -3299,6 +3496,11 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { diff --git a/crates/ui-egui/src/dialogs_appearance.rs b/crates/ui-egui/src/dialogs_appearance.rs index 0fd328a5..470ecf9d 100644 --- a/crates/ui-egui/src/dialogs_appearance.rs +++ b/crates/ui-egui/src/dialogs_appearance.rs @@ -118,17 +118,20 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { let faces = app.session.selection.iter().any(|s| matches!(s, Sel::Face { .. })) && !app.session.selection.iter().any(|s| matches!(s, Sel::Body { .. })); let state = app.session.world_state(); + let body_look = |body: &solvecraft_engine::doc::ModelBody| { + app.session.doc.model_body_look(body).or_else(|| { + (body.body.paint().is_some() || body.imported.is_some() || body.body.color().is_some()).then(|| { + let color = + app.session.doc.body_color(body).map(|rgb| rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).unwrap_or([196, 199, 204]); + solvecraft_engine::doc::appearance::Look::custom(color, app.session.doc.model_body_opacity(body)) + }) + }) + }; let existing = app.session.selection.iter().find_map(|sel| match sel { - Sel::Body { name } => state.body(name).and_then(|b| app.session.doc.model_body_look(b)), - Sel::Face { body, index, .. } => state.body(body).and_then(|b| { - app.session - .doc - .face_colors(b) - .into_iter() - .find(|(i, _)| i == index) - .map(|(_, look)| look) - .or_else(|| app.session.doc.model_body_look(b)) - }), + Sel::Body { name } => state.body(name).and_then(body_look), + Sel::Face { body, index, .. } => state + .body(body) + .and_then(|b| app.session.doc.face_colors(b).into_iter().find(|(i, _)| i == index).map(|(_, look)| look).or_else(|| body_look(b))), _ => None, }); ( @@ -341,7 +344,8 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel ui.end_row(); row_label(ui, "Opacity"); ui.spacing_mut().slider_width = 110.0; - if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).fixed_decimals(2)).changed() { + // Decimal formatting must not round a saved look merely by drawing its editor. + if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).max_decimals_opt(None).custom_formatter(|value, _| format!("{value:.2}"))).changed() { k.remove_override = false; } ui.end_row(); @@ -590,6 +594,23 @@ mod tests { assert_eq!(a.session.doc.appearances.bodies.get("Body1"), a.session.doc.appearances.bodies.get("Body2")); } + #[test] + fn real_frames_and_unchanged_apply_keep_precise_opacity() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":"#abcdef","opacity":0.123456})).unwrap(); + for cancel in [true, false] { + h.app.session.selection = vec![Sel::Body { name: "Body1".into() }]; + h.app.start("appearance.assign"); + h.frames(3); + let Kind::Appearance(appearance) = &h.app.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") }; + assert_eq!(appearance.opacity, 0.123456); + h.step(&json!({"key":if cancel {"Escape"} else {"Enter"}})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); + assert_eq!(h.app.session.doc.appearances.bodies["Body1"].opacity, 0.123456); + } + } + #[test] fn immediate_apply_then_ok_has_one_undo_and_retains_precise_opacity() { let mut a = app(); diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 07d08727..b8ac856d 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -819,13 +819,11 @@ pub fn world_preview(s: &Session, cmds: &[(String, Value)], colors: Colors) -> O if cmds.is_empty() || !(moves || solvecraft_engine::frames::placed(s)) { return None; } - let mut scratch = s.scratch(); - for (id, p) in cmds { - if let Err(e) = scratch.execute(id, p) { - return Some(Err(e.to_string())); - } - } - let (before, after) = (s.world_state(), scratch.world_state()); + let preview = match s.preview_visible_world(cmds) { + Ok(preview) => preview, + Err(error) => return Some(Err(error.to_string())), + }; + let (before, after) = (preview.before, preview.after); // A moved component looks like itself (no new surface), with its old place as the ghost. let colors = Colors { added: colors.body, ..colors }; let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::preview::build(&before, &after, colors))); diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index c0c33034..9d65fc4d 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -631,10 +631,22 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel "suppress" => format!("Suppress: {n}"), _ => n.to_string(), }; + let label = v["targets"] + .as_array() + .and_then(|targets| targets.iter().find(|target| target["key"].as_str() == Some(c))) + .and_then(|target| target["label"].as_str()) + .filter(|_| matches!(kind, "component_appearance" | "face_appearance")) + .map(str::to_string) + .unwrap_or(label); ui.add_sized(vec2(156.0, 22.0), egui::Label::new(RichText::new(label).strong()).truncate()).on_hover_text(c); if ui.small_button("×").on_hover_text("Remove the column").clicked() { let key = if kind == "suppress" { "feature" } else { kind }; - run(app, error, "config.column", json!({ key: n, "delete": true })); + run( + app, + error, + "config.column", + json!({ key: if kind == "face_appearance" {c.as_str()} else {n}, "delete": true }), + ); } }); } @@ -661,7 +673,8 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel for (c, val) in tb.columns.iter().zip(vals) { ui.allocate_ui_with_layout(vec2(190.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { let (kind, _) = column_parts(c); - let shown = if kind == "appearance" { + let appearance = matches!(kind, "appearance" | "component_appearance" | "face_appearance"); + let shown = if appearance { solvecraft_engine::doc::config::appearance_cell(val) .ok() .flatten() @@ -671,11 +684,20 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel } else { val.clone() }; - let style = matches!(kind, "material" | "appearance"); + let style = kind == "material" || appearance; if style { let mut choice = None; ui.menu_button("Library", |ui| { - if ui.button(if kind == "appearance" { "Follow material" } else { "Default material" }).clicked() { + if ui + .button(if kind == "appearance" { + "Follow material" + } else if appearance { + "Follow inherited appearance" + } else { + "Default material" + }) + .clicked() + { choice = Some("Default".to_string()); ui.close(); } @@ -757,6 +779,17 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel } } } + for component in &app.session.doc.components { + if component.id != 0 && !tb.columns.contains(&format!("component_appearance:{}", component.id)) { + options.push((format!("{} (component appearance)", component.name), json!({"component_appearance":component.id}))); + } + } + for selected in &app.session.selection { + if let solvecraft_engine::Sel::Face { body, point, .. } = selected { + options + .push((format!("Selected face on {body} (shared appearance)"), json!({"face_appearance":{"body":body,"point":point}}))); + } + } let mut pick: Option = None; ui.menu_button("Add Column", |ui| { egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { @@ -766,6 +799,43 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel ui.close(); } } + ui.menu_button("Face Appearance", |ui| { + ui.label("Semantic faces share their appearance across instances."); + let state = app.session.model.state(); + for body in &state.bodies { + if body.body.is_mesh() { + continue; + } + ui.menu_button(format!("Faces on {}", body.name), |ui| { + let names = solvecraft_engine::doc::naming::face_names(body); + if let Ok(faces) = body.body.faces((body.body.size() * 2e-3).max(1e-3)) { + let mut seen = std::collections::BTreeSet::new(); + for face in faces { + let Some(name) = names.get(face.index) else { continue }; + let name = solvecraft_engine::doc::naming::strip_piece(name); + if !seen.insert(name.to_string()) { + continue; + } + let Some((id, role)) = name.strip_prefix('F').and_then(|name| name.split_once(':')) else { continue }; + let Ok(origin_feature) = id.parse() else { continue }; + let target = solvecraft_engine::doc::config::ConfigFaceTarget { + body: body.name.clone(), + origin_feature, + name: name.into(), + point: face.centroid, + }; + if app.session.doc.certify_config_face(&target).is_err() { + continue; + } + if ui.button(role).clicked() { + pick = Some(json!({"face_appearance":{"body":body.name,"point":face.centroid}})); + ui.close(); + } + } + } + }); + } + }); }); }); if let Some(p) = pick { @@ -779,6 +849,10 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel json!({ "features": [n] }) } else if let Some(n) = p.get("material") { json!({ "materials": [n] }) + } else if let Some(n) = p.get("component_appearance") { + json!({ "component_appearances": [n] }) + } else if let Some(n) = p.get("face_appearance") { + json!({ "face_appearances": [n] }) } else { json!({ "appearances": [p.get("appearance")] }) }, @@ -791,6 +865,20 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel }); ui.end_row(); configuration_rules_ui::show(ui, app, error, rule_editor); + if let Some(styles) = v["style_status"].as_array() { + for style in styles.iter().filter(|style| style["dormant"].as_bool() == Some(true)) { + ui.add( + egui::Label::new(format!( + "{}: appearance dormant while its feature is suppressed", + style["target"].as_str().unwrap_or("Face") + )) + .wrap(), + ); + } + } + if let Some(message) = v["style_error"].as_str() { + ui.add(egui::Label::new(RichText::new(message).color(t.error)).wrap()); + } if let Some(e) = error { ui.label(""); ui.label(RichText::new(e.as_str()).color(t.error)); @@ -1004,6 +1092,41 @@ mod tests { ); } + #[test] + fn configuration_component_and_face_pickers_start_fresh_and_clear_inherited() { + for (component, width) in [(true, 1400), (false, 900)] { + let mut h = crate::scenario::Harness::new(); + h.call("ui.resize", json!({"width":width,"height":720})); + if component { + h.call("engine.execute", json!({"command":"component.create","params":{"name":"Part"}})); + } + h.run(&json!([ + {"call":"engine.execute","params":{"command":"solid.box","params":{"length":10,"width":10,"height":10,"body_name":"Block"}}}, + {"start":"config.table"}, {"widget":"Add Column"} + ])) + .unwrap(); + if component { + h.step(&json!({"widget":"Part (component appearance)"})).unwrap(); + assert!(h.widget("Component: Part").is_some()); + } else { + h.run(&json!([{"widget":"Face Appearance"},{"widget":"Faces on Block"},{"widget":"+z"}])).unwrap(); + assert!(h.widget("Face: Block / +z").is_some()); + } + assert_eq!(h.app.session.doc.configs.rows.len(), 1); + h.run(&json!([{"widget":"Library"},{"widget":"ABS - Red"},{"widget":"Apply"}])).unwrap(); + if component { + assert_eq!(h.app.session.doc.appearances.components.values().next().unwrap().name, "ABS - Red"); + } else { + assert_eq!(h.app.session.doc.appearances.faces[0].look.name, "ABS - Red"); + assert!(h.app.session.doc.appearances.faces[0].strict_name); + } + h.run(&json!([{"widget":"Library"},{"widget":"Follow inherited appearance"},{"widget":"Apply"}])).unwrap(); + assert!(h.app.session.doc.appearances.components.is_empty()); + assert!(h.app.session.doc.appearances.faces.is_empty()); + crate::scenario::check(&mut h, &json!({"windows_fit":{"max_width":560}})).unwrap(); + } + } + #[test] fn configuration_style_picker_applies_then_inherits_material() { let steps: Value = serde_json::from_str(include_str!("../tests/scenarios/s80_configuration_styles.json")).unwrap(); diff --git a/crates/ui-egui/src/extrude_edit_tests.rs b/crates/ui-egui/src/extrude_edit_tests.rs new file mode 100644 index 00000000..467518e4 --- /dev/null +++ b/crates/ui-egui/src/extrude_edit_tests.rs @@ -0,0 +1,280 @@ +//! Exercise the actual feature editor, including frames rendered before OK. +use crate::scenario::Harness; +use serde_json::{Value, json}; +use solvecraft_engine::doc::{Extent, FeatureKind}; + +fn make(params: Value) -> Harness { + make_in(Harness::new(), params) +} +fn make_in(mut h: Harness, params: Value) -> Harness { + h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let mut p = json!({"sketch":"Base","distance":"10 mm","operation":"new","name":"Depth","body_name":"Main"}); + p.as_object_mut().unwrap().extend(params.as_object().unwrap().clone()); + h.app.run("solid.extrude", p).unwrap(); + h.frames(4); + h +} +fn extent(h: &Harness) -> Extent { + let FeatureKind::Extrude { extent, .. } = &h.app.session.doc.find_feature("Depth").unwrap().kind else { panic!("extrude") }; + extent.clone() +} +fn open(h: &mut Harness) { + h.step(&json!({"call":"ui.editFeature","params":{"feature":"Depth"}})).unwrap(); + h.frames(8); +} + +#[test] +fn unchanged_all_profiles_includes_profiles_added_after_edit() { + let mut h = make(json!({})); + open(&mut h); + h.step(&json!({"widget":"OK"})).unwrap(); + let FeatureKind::Extrude { profiles, .. } = &h.app.session.doc.find_feature("Depth").unwrap().kind else { panic!("extrude") }; + assert!(matches!(profiles, solvecraft_engine::doc::ProfileSel::All)); + h.app.run("sketch.edit", json!({"sketch":"Base"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[20,0],"p1":[25,5]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let state = h.app.session.world_state(); + let volume: f64 = state.bodies.iter().map(|b| solvecraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum(); + assert!((volume - 1250.0).abs() < 1e-7, "{volume}"); +} + +#[test] +fn unchanged_actual_editor_preserves_all_extents_and_parameter_identities() { + for p in [ + json!({"direction":"negative","distance":"10"}), + json!({"direction":"negative","distance":"-10 mm"}), + json!({"direction":"symmetric","distance":"5 mm"}), + json!({"distance":"10 mm","distance2":"3 mm"}), + json!({"from":[0,0,5],"start_offset":"0 mm"}), + json!({"to":[0,0,10],"to_offset":"3 mm"}), + json!({"to":[0,0,-10],"to_offset":"-2 mm"}), + json!({"taper":"0 deg"}), + ] { + let mut h = make(p.clone()); + let original = extent(&h); + let names = h.app.session.doc.find_feature("Depth").unwrap().param_names.clone(); + let before = h.app.session.world_state().body("Main").unwrap().mesh().bounds(); + open(&mut h); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{p}: {:?}", h.app.session.log); + assert_eq!(extent(&h), original, "{p}"); + assert_eq!(h.app.session.doc.find_feature("Depth").unwrap().param_names, names, "{p}"); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), before, "{p}"); + } +} + +#[test] +fn negative_edit_keeps_downstream_distance_reference_positive() { + let mut h = make(json!({"direction":"negative","distance":"10"})); + h.app.run("solid.box", json!({"length":10,"width":10,"height":"d1","corner":[30,0,0],"body_name":"Dependent"})).unwrap(); + open(&mut h); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(h.app.session.doc.eval("d1", solvecraft_engine::doc::expr::Kind::Length).unwrap(), 10.0); + assert_eq!(h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z, 10.0); +} + +#[test] +fn cancel_after_canvas_edit_preserves_document_and_undo() { + let mut h = make(json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"})); + let original = extent(&h); + let undo = h.app.session.undo.len(); + open(&mut h); + assert_eq!(crate::canvas::anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().0.z, 5.0); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"4 mm"})).unwrap(); + h.frames(8); + h.step(&json!({"widget":"Cancel"})).unwrap(); + assert_eq!(extent(&h), original); + assert_eq!(h.app.session.undo.len(), undo); +} + +#[test] +fn to_offset_canvas_edit_changes_offset_and_one_undo_restores_extent() { + let mut h = make(json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"})); + let original = extent(&h); + open(&mut h); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"4 mm"})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(extent(&h).to_offset.as_deref(), Some("4 mm")); + assert_eq!(extent(&h).distance, original.distance); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 19.0); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(extent(&h), original); +} + +#[test] +fn moved_owner_references_survive_actual_edit_with_another_component_active() { + let mut h = Harness::new(); + let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let mut h = make_in(h, json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"})); + h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20]})).unwrap(); + let other = h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap(); + h.app.run("occurrence.move", json!({"occurrence":other["occurrence"],"translate":[200,0,40]})).unwrap(); + let original = extent(&h); + let active = h.app.session.active_component; + open(&mut h); + let base = crate::canvas::anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().0; + assert_eq!(base.z, 25.0); + assert!(base.x >= 100.0 && base.x <= 110.0); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(extent(&h), original); + assert_eq!(h.app.session.active_component, active); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 37.0); +} + +#[test] +fn dragged_to_object_handle_edits_offset_not_dormant_distance() { + let mut h = make(json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"})); + open(&mut h); + let original = extent(&h); + h.step(&json!({"drag":[{"handle":"arrow"},{"world":[5,5,21]}],"ctrl":true})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log); + assert_eq!(extent(&h).distance, original.distance); + let state = h.app.session.world_state(); + let z = state.body("Main").unwrap().mesh().bounds().max.z; + assert!((z - 21.0).abs() < 0.1, "dragged end {z}"); +} + +#[test] +fn referenced_offset_recomputes_after_edit_and_save_reopen() { + let mut h = Harness::new(); + h.app.run("parameters.add", json!({"name":"clearance","expression":"2 mm"})).unwrap(); + let mut h = make_in(h, json!({"to":[0,0,15],"to_offset":"clearance"})); + open(&mut h); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(extent(&h).to_offset.as_deref(), Some("clearance")); + h.app.run("parameters.change", json!({"name":"clearance","expression":"6 mm"})).unwrap(); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 21.0); + let path = h.dir.join("extent.solvecraft"); + h.app.run("file.save", json!({"path":path})).unwrap(); + h.app.run("doc.open", json!({"path":path})).unwrap(); + assert_eq!(extent(&h).to_offset.as_deref(), Some("clearance")); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 21.0); +} + +#[test] +fn from_object_start_can_be_explicitly_changed_to_profile_plane() { + let mut h = make(json!({"from":[0,0,5]})); + open(&mut h); + h.step(&json!({"click":{"handle":"dialog:Start"}})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:Start:Profile Plane"}})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(extent(&h).from.is_none()); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z, 0.0); +} + +#[test] +fn explicit_reverse_keeps_original_expression_and_parameter_sign() { + let mut h = make(json!({"distance":"10"})); + open(&mut h); + h.step(&json!({"widget":"Reverse Direction"})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(extent(&h).distance, "10"); + assert_eq!(extent(&h).direction, solvecraft_engine::doc::Direction::Negative); + assert_eq!(h.app.session.doc.eval("d1", solvecraft_engine::doc::expr::Kind::Length).unwrap(), 10.0); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z, -10.0); +} + +#[test] +fn failed_edit_and_cancel_keep_full_history_and_existing_redo() { + let mut h = make(json!({})); + for i in 0..205 { + h.app.run("timeline.rename", json!({"feature":1,"name":format!("Base{i}")})).unwrap(); + } + h.app.run("solid.box", json!({"length":2,"width":2,"height":2,"corner":[30,0,0],"body_name":"RedoBody"})).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + let undo = h.app.session.undo.len(); + let redo = h.app.session.redo.len(); + assert_eq!(undo, 199); + assert_eq!(redo, 1); + h.app.session.selection = vec![solvecraft_engine::Sel::Body { name: "Main".into() }]; + let selection = h.app.session.selection.clone(); + let original = serde_json::to_value(&*h.app.session.doc).unwrap(); + open(&mut h); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.session.redo.len(), redo); + assert_eq!(h.app.session.selection, selection); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"missing_extent"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(h.app.dialog.is_some(), "failed edit must remain editable"); + h.step(&json!({"widget":"Cancel"})).unwrap(); + assert_eq!(serde_json::to_value(&*h.app.session.doc).unwrap(), original); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.session.redo.len(), redo); + assert_eq!(h.app.session.selection, selection); + h.app.run("edit.redo", json!({})).unwrap(); + assert!(h.app.session.world_state().body("RedoBody").is_some()); +} + +#[test] +fn tapered_from_and_signed_to_have_correct_start_volume_and_preview_parity() { + for (params, lo, hi) in [ + (json!({"from":[0,0,5],"distance":"10 mm","taper":"atan(0.1)"}), 5.0, 15.0), + (json!({"from":[0,0,5],"to":[0,0,15],"direction":"negative","taper":"atan(0.1)"}), 5.0, 15.0), + (json!({"from":[0,0,5],"to":[0,0,-5],"direction":"negative","taper":"atan(0.1)"}), -5.0, 5.0), + (json!({"from":[0,0,5],"to":[0,0,-5],"direction":"positive","taper":"atan(0.1)"}), -5.0, 5.0), + ] { + let mut h = make(params.clone()); + let state = h.app.session.world_state(); + let body = state.body("Main").unwrap(); + let bounds = body.mesh().bounds(); + assert!((bounds.min.z - lo).abs() < 1e-8 && (bounds.max.z - hi).abs() < 1e-8, "{params}: {bounds:?}"); + let expected = 10.0 * (100.0 + 120.0 + 144.0) / 3.0; + let volume = solvecraft_engine::doc::kernel::measure(&body.body).unwrap().volume; + assert!((volume - expected).abs() < 1e-5, "{params}: volume{volume} expected{expected}"); + let original = extent(&h); + open(&mut h); + let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + let preview = h.app.session.preview(&commands).unwrap(); + assert_eq!(preview.after.body("Main").unwrap().mesh().bounds(), bounds); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(extent(&h), original); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds); + } +} + +#[test] +fn adjacent_profiles_from_slanted_face_use_same_merged_centre_as_geometry() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":30,"width":30,"height":2,"corner":[-10,-10,10],"body_name":"Reference"})).unwrap(); + h.app.run("solid.move", json!({"bodies":["Reference"],"axis":[0,1,0],"angle":"atan(0.1)","origin":[0,0,10]})).unwrap(); + h.app.run("sketch.create", json!({"plane":"XY","name":"Adjacent"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[2,10]})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[10,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let angle = 0.1f64.atan(); + let point = [2.0 * angle.sin(), 0.0, 10.0 + 2.0 * angle.cos()]; + h.app.run("solid.extrude", json!({"sketch":"Adjacent","distance":10,"from":point,"name":"Depth","body_name":"Main","operation":"new"})).unwrap(); + let min_z = h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z; + open(&mut h); + let base = crate::canvas::anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().0; + assert!((base.z - min_z).abs() < 1e-7, "displayed start{} geometry start{min_z}", base.z); + // Averaging the two unmerged rectangle loops gives x=3.5; their internal + // seam must not bias the reference ray used by the extent handle. + let unmerged_height = 10.0 + 2.0 / angle.cos() - 3.5 * angle.tan(); + assert!((base.z - unmerged_height).abs() > 0.01, "fixture must distinguish the raw-profile centre"); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!((h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z - min_z).abs() < 1e-7); +} + +#[test] +fn rotated_second_occurrence_keeps_saved_from_and_to_points_exact() { + let mut h = Harness::new(); + let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let mut h = make_in(h, json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"})); + let second = h.app.run("occurrence.copy", json!({"component":owner["component"],"translate":[100,0,20]})).unwrap()["occurrence"].clone(); + h.app.run("occurrence.move", json!({"occurrence":second,"axis":[1,0,0],"angle":"17 deg"})).unwrap(); + h.app.run("component.activate", json!({"component":owner["component"],"occurrence":second,"path":[second]})).unwrap(); + let original = extent(&h); + let before: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect(); + open(&mut h); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(extent(&h), original); + let after: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect(); + assert_eq!(after, before); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 915e5f3b..bcaa663c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -54,6 +54,8 @@ mod status_tests { assert!(app.session.log.last().is_some_and(|l| l.starts_with("Redo: ") && !l.contains("edit.redo")), "{:?}", app.session.log.last()); } } +#[cfg(test)] +mod extrude_edit_tests; mod file_load; pub mod palette; pub mod params_dialog; @@ -63,6 +65,8 @@ mod preselect_tests; pub mod preview; pub mod recovery_ui; pub mod ref_images; +#[cfg(test)] +mod revolve_edit_tests; pub mod scenario; pub mod selection; pub mod shortcut_box; @@ -548,13 +552,13 @@ impl SolveApp { let Some(idx) = self.session.doc.feature_index(id) else { return }; let marker = self.session.doc.marker; self.tool = None; - if self.run("timeline.roll_to", json!({ "position": idx })).is_err() { + if dialogs::roll_marker(self, Some(idx)).is_err() { return; } match dialogs::for_feature(self, id, marker) { Some(d) => self.dialog = Some(d), None => { - let _ = self.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({}))); + let _ = dialogs::roll_marker(self, marker); self.palette.text = format!("timeline.edit {{\"feature\": {id}, \"set\": {{}}}}"); self.ui.palette_open = true; } diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index afc2ea45..d65b61d9 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -8,12 +8,187 @@ use solvecraft_engine::{Sel, Session}; use crate::dialogs::{Dialog, Kind, apply_commands}; use crate::{Services, SolveApp}; +#[test] +fn cylinder_emboss_target_face_and_edit_keep_the_anchored_operation() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap(); + let sketch = h.app.run("sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})).unwrap()["sketch"] + .as_u64() + .unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.session.selection = vec![Sel::Profile { sketch, index: 0 }]; + h.app.start("solid.emboss"); + h.frames(2); + assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 1); + let planar = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + assert!(planar[0].1.get("face").is_none()); + h.call("ui.dialogInput", json!({"index":1})); + h.call("ui.view", json!({"view":"back"})); + h.frames(2); + h.step(&json!({"click":{"world":[0,10,7.5]}})).unwrap(); + let dialog = h.app.dialog.as_ref().unwrap(); + assert!(matches!(dialog.inputs[1].items.first(), Some(Sel::Face { body, .. }) if body == "Sleeve")); + let commands = apply_commands(&h.app, dialog).unwrap(); + assert_eq!(commands[0].1["targets"], json!(["Sleeve"])); + assert!(commands[0].1.get("face").is_some()); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); + let feature = h.app.session.doc.features.last().unwrap().id; + let before = serde_json::to_value(&h.app.session.doc.feature(feature).unwrap().kind).unwrap(); + h.app.edit_feature(feature); + h.frames(2); + let edited = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(edited[0].0, "timeline.redefine"); + assert_eq!(edited[0].1["params"]["targets"], json!(["Sleeve"])); + assert!(edited[0].1["params"].get("face").is_some()); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); + assert_eq!(serde_json::to_value(&h.app.session.doc.feature(feature).unwrap().kind).unwrap(), before); +} + +#[test] +fn cylinder_emboss_edit_retains_second_source_and_author_paths_and_all_profiles() { + let mut h = crate::scenario::Harness::new(); + let source = h.app.run("component.create", json!({"name":"Source"})).unwrap()["component"].as_u64().unwrap(); + h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap(); + h.app.run("occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]})).unwrap(); + let source_second = h.app.run("occurrence.copy", json!({"component":source,"translate":[80,0,0]})).unwrap()["occurrence"].as_u64().unwrap(); + h.app.run("component.activate", json!({"component":"root"})).unwrap(); + let author = h.app.run("component.create", json!({"name":"Author"})).unwrap()["component"].as_u64().unwrap(); + h.app.run("occurrence.move", json!({"occurrence":"Author:1","axis":[1,0,0],"angle":"90 deg","translate":[-100,40,30]})).unwrap(); + let author_second = h.app.run("occurrence.copy", json!({"component":author,"translate":[0,100,0]})).unwrap()["occurrence"].as_u64().unwrap(); + h.app.run("component.activate", json!({"component":author,"occurrence":author_second})).unwrap(); + h.app.run("sketch.create", json!({"plane":{"origin":[120,0,0],"x_dir":[0,-1,0],"y_dir":[0,0,1]},"name":"Badge"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let target = h.app.session.occurrence_bodies(&[source_second], false).unwrap().into_iter().next().unwrap(); + let feature = h.app.run("solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[120,0,7.5],"depth":"1mm","targets":[target]})).unwrap() + ["feature"] + .as_u64() + .unwrap(); + let before = h.app.session.doc.feature(feature).unwrap().clone(); + let solvecraft_engine::doc::FeatureKind::Emboss { face: Some(face), .. } = &before.kind else { panic!("selected face") }; + assert_eq!(face.path, vec![source_second]); + assert_eq!(face.author_path, vec![author_second]); + h.app.run("component.activate", json!({"component":"root"})).unwrap(); + h.app.edit_feature(feature); + h.frames(2); + let dialog = h.app.dialog.as_ref().unwrap(); + let Sel::Face { body, point, .. } = &dialog.inputs[1].items[0] else { panic!("refilled face") }; + assert_eq!(body, &target); + assert!(point.dist(Vec3::new(120.0, 0.0, 7.5)) < 1e-8); + let commands = apply_commands(&h.app, dialog).unwrap(); + assert_eq!(commands[0].1["params"]["profiles"], json!("all")); + let Sel::Profile { sketch, index } = dialog.inputs[0].items[0] else { panic!("refilled profile") }; + let plane = crate::dialogs::emboss_profile_plane(&h.app, sketch).unwrap().unwrap(); + assert!(plane.origin.dist(Vec3::new(120.0, 0.0, 0.0)) < 1e-8, "{plane:?}"); + let mut guide = crate::gpu::GpuScene::default(); + crate::viewport::profile_fill(&mut guide, &h.app, sketch, index, egui::Color32::BLUE, egui::Color32::BLUE); + let (vertices, remainder) = guide.trans.as_chunks::<{ crate::gpu::TRI_SIZE }>(); + assert!(remainder.is_empty() && !vertices.is_empty()); + let lift = h.app.cam.back() * (h.app.cam.half_height() * 0.002); + for vertex in vertices { + let coordinate = |offset| f32::from_le_bytes(vertex[offset..offset + 4].try_into().unwrap()) as f64; + let point = Vec3::new(coordinate(0), coordinate(4), coordinate(8)) - lift; + assert!((point.x - 120.0).abs() < 1e-4 && point.y.abs() < 2.01 && (4.99..=10.01).contains(&point.z), "{point:?}"); + } + let preview = h.app.session.preview_world(&commands).unwrap(); + let volume = preview.after.body(&target).unwrap().mesh().measure().volume; + assert!(volume > preview.before.body(&target).unwrap().mesh().measure().volume + 10.0); + let colors = crate::preview::Colors::from_tokens(&crate::theme::Tokens::get()); + let built = crate::preview::compute(&h.app.session, &commands, colors).unwrap(); + assert!(built.replaced.contains(&target) && !built.scene.tris.is_empty(), "{:?}", built.replaced); + let source_first = h.app.session.doc.occurrence_of(source).unwrap().id; + h.app.run("browser.visibility", json!({"paths":[[source_first]],"visible":false})).unwrap(); + let hidden = crate::preview::compute(&h.app.session, &commands, colors).unwrap(); + assert_eq!(hidden.replaced, vec![target.clone()]); + let visible = h.app.session.preview_visible_world(&commands).unwrap(); + assert!(visible.before.body("Sleeve").is_none() && visible.after.body("Sleeve").is_none()); + assert!(visible.after.body(&target).is_some() && !hidden.scene.tris.is_empty()); + let mut shared = h.app.session.scratch(); + shared.execute("component.activate", &json!({"component":source,"occurrence":source_first})).unwrap(); + let new_body = shared + .preview_visible_world(&[("solid.box".into(), json!({"body_name":"NewPreview","length":2,"width":2,"height":2,"operation":"new"}))]) + .unwrap(); + let added = new_body.after.bodies.iter().filter(|body| new_body.before.body(&body.name).is_none()).collect::>(); + assert_eq!(added.len(), 1, "{:?}", added.iter().map(|body| &body.name).collect::>()); + assert!(added[0].name.contains("Source:2"), "{}", added[0].name); + let doc = h.app.session.doc.to_json(); + let history = (h.app.session.undo.len(), h.app.session.redo.len()); + let selection = h.app.session.selection.clone(); + let Kind::Emboss { depth, .. } = &mut h.app.dialog.as_mut().unwrap().kind else { panic!("Emboss editor") }; + *depth = "2 mm".into(); + let changed = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + let changed_preview = h.app.session.preview_world(&changed).unwrap(); + assert!(changed_preview.after.body(&target).unwrap().mesh().measure().volume > volume + 10.0); + let mut invalid = changed.clone(); + invalid[0].1["params"]["depth"] = json!("unknown_depth"); + assert!(h.app.session.preview_world(&invalid).is_err()); + assert_eq!(h.app.session.doc.to_json(), doc); + assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), history); + assert_eq!(h.app.session.selection, selection); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.dialog.is_none()); + assert_eq!(h.app.session.doc.feature(feature).unwrap().kind, before.kind); + h.app.edit_feature(feature); + h.frames(2); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); + let after = h.app.session.doc.feature(feature).unwrap(); + assert_eq!(after.kind, before.kind); + assert_eq!(after.face_names, before.face_names); + assert_eq!(h.app.session.active_component, 0); +} + fn app_with(script: serde_json::Value) -> SolveApp { let mut s = Session::default(); s.run_script(&script).unwrap(); SolveApp::new(s, Services::default()) } +#[test] +fn cylinder_emboss_profile_guide_uses_target_or_unique_foreign_sketch_frame() { + for target_owned in [true, false] { + let mut h = crate::scenario::Harness::new(); + let source = if target_owned { h.app.run("component.create", json!({"name":"Source"})).unwrap()["component"].as_u64().unwrap() } else { 0 }; + h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap(); + let (profile_owner, expected, target, face) = if target_owned { + h.app.run("occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]})).unwrap(); + let second = h.app.run("occurrence.copy", json!({"component":source,"translate":[80,0,0]})).unwrap()["occurrence"].as_u64().unwrap(); + let target = h.app.session.occurrence_bodies(&[second], false).unwrap().remove(0); + (source, Vec3::new(120.0, 0.0, 0.0), target, json!([120, 0, 7.5])) + } else { + let profile = h.app.run("component.create", json!({"name":"Profile"})).unwrap()["component"].as_u64().unwrap(); + h.app.run("occurrence.move", json!({"occurrence":"Profile:1","axis":[0,0,1],"angle":"90 deg","translate":[50,20,0]})).unwrap(); + (profile, Vec3::new(0.0, 10.0, 0.0), "Sleeve".to_string(), json!([0, 10, 7.5])) + }; + let plane = if target_owned { + json!({"origin":[40,0,0],"x_dir":[0,-1,0],"y_dir":[0,0,1]}) + } else { + json!({"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]}) + }; + let sketch = h.app.run("sketch.create", json!({"plane":plane,"name":"Badge"})).unwrap()["sketch"].as_u64().unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.run("component.activate", json!({"component":"root"})).unwrap(); + let feature = + h.app.run("solid.emboss", json!({"sketch":sketch,"profiles":"all","face":face,"depth":"1mm","targets":[target]})).unwrap()["feature"] + .as_u64() + .unwrap(); + h.app.edit_feature(feature); + h.frames(2); + assert!(crate::dialogs::emboss_profile_plane(&h.app, sketch).unwrap().unwrap().origin.dist(expected) < 1e-8); + if !target_owned { + h.app.run("occurrence.copy", json!({"component":profile_owner,"translate":[100,0,0]})).unwrap(); + assert!(crate::dialogs::emboss_profile_plane(&h.app, sketch).unwrap_err().contains("ambiguous")); + let mut guide = crate::gpu::GpuScene::default(); + crate::viewport::profile_fill(&mut guide, &h.app, sketch, 0, egui::Color32::BLUE, egui::Color32::BLUE); + assert!(guide.trans.is_empty() && guide.lines.is_empty()); + } + } +} + fn boxed() -> SolveApp { app_with(json!([{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}])) } diff --git a/crates/ui-egui/src/revolve_edit_tests.rs b/crates/ui-egui/src/revolve_edit_tests.rs new file mode 100644 index 00000000..5a8abd55 --- /dev/null +++ b/crates/ui-egui/src/revolve_edit_tests.rs @@ -0,0 +1,221 @@ +use crate::scenario::Harness; +use serde_json::{Value, json}; +use solvecraft_engine::doc::{AxisRef, FeatureKind}; + +fn make(h: Harness, axis: Value) -> Harness { + make_params(h, axis, json!({})) +} +fn make_params(mut h: Harness, axis: Value, extra: Value) -> Harness { + h.app.run("sketch.create", json!({"plane":"XY","name":"Profile"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[10,0],"p1":[15,5]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let mut params = json!({"sketch":"Profile","axis":axis,"angle":"90 deg","operation":"new","name":"Turn","body_name":"Main"}); + params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + h.app.run("solid.revolve", params).unwrap(); + h.frames(4); + h +} + +#[test] +fn unchanged_all_profiles_retains_future_geometry_but_reselection_is_explicit() { + for reselect in [false, true] { + let mut h = make(Harness::new(), json!("Y")); + open(&mut h); + if reselect { + let selection = h.app.dialog.as_ref().unwrap().inputs[0].items[0].clone(); + let mut dialog = h.app.dialog.take().unwrap(); + dialog.active = 0; + dialog.pick(&h.app.session, selection.clone()); + dialog.pick(&h.app.session, selection); + h.app.dialog = Some(dialog); + h.frames(4); + } + h.step(&json!({"widget":"OK"})).unwrap(); + let FeatureKind::Revolve { profiles, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") }; + assert_eq!(matches!(profiles, solvecraft_engine::doc::ProfileSel::All), !reselect); + h.app.run("sketch.edit", json!({"sketch":"Profile"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[20,0],"p1":[25,5]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let state = h.app.session.world_state(); + let volume: f64 = state.bodies.iter().map(|b| solvecraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum(); + let expected = if reselect { 490.873852 } else { 1374.446786 }; + assert!((volume - expected).abs() < 0.3, "{volume} != {expected}"); + } +} + +#[test] +fn to_object_noop_preserves_cut_targets_and_untargeted_body() { + let mut h = Harness::new(); + for name in ["A", "B"] { + h.app.run("solid.box", json!({"length":20,"width":5,"height":20,"corner":[0,0,0],"body_name":name})).unwrap(); + } + 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); + 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_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"]); +} + +#[test] +fn to_object_noop_retains_dormant_secondary_angle_and_downstream_dimension() { + let mut h = make_params(Harness::new(), json!("Y"), json!({"to":[0,2,12],"angle":"45 deg","angle2":"30 deg","direction":"symmetric"})); + h.app.run("solid.box", json!({"length":2,"width":2,"height":"(d2 / (1 rad)) * 1 mm","corner":[30,0,0],"body_name":"Dependent"})).unwrap(); + 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(); + open(&mut h); + 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); + assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::PI / 6.0).abs() < 1e-8); +} + +#[test] +fn rotated_owner_to_point_remains_exact_on_noop_edit() { + let mut h = Harness::new(); + let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let mut h = make_params(h, json!({"origin":[0,0,0],"dir":[0,2,0]}), json!({"to":[0,2,12],"angle":"45 deg"})); + h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap(); + h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap(); + let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(); + let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds(); + open(&mut h); + 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(), bounds); +} + +#[test] +fn second_active_occurrence_noop_preserves_definition_axis_target_and_instances() { + let mut h = Harness::new(); + let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let mut h = make_params(h, json!({"origin":[0,0,0],"dir":[0,2,0]}), json!({"to":[0,2,12],"angle":"45 deg"})); + let second = h.app.run("occurrence.copy", json!({"component":owner["component"],"translate":[100,0,20]})).unwrap()["occurrence"].clone(); + h.app.run("occurrence.move", json!({"occurrence":second,"axis":[0,0,1],"angle":"17 deg"})).unwrap(); + h.app.run("component.activate", json!({"component":owner["component"],"occurrence":second,"path":[second]})).unwrap(); + let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(); + let frame = h.app.session.active_component_frame(); + let expected_origin = solvecraft_engine::doc::apply_point(&frame, solvecraft_engine::geom::Vec3::ZERO); + let before: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect(); + open(&mut h); + let (origin, _) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + assert!((origin - expected_origin).len() < 1e-8); + assert!(origin.len() > 50.0, "fixture must distinguish first and second placements"); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original); + let after: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect(); + assert_eq!(after, before); + assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&vec![second.as_u64().unwrap()])); +} +fn axis(h: &Harness) -> Value { + let FeatureKind::Revolve { axis, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") }; + serde_json::to_value(axis).unwrap() +} +fn open(h: &mut Harness) { + h.step(&json!({"call":"ui.editFeature","params":{"feature":"Turn"}})).unwrap(); + h.frames(8); +} + +#[test] +fn actual_noop_editor_retains_all_revolve_axis_kinds_and_analytic_volume() { + for axis_value in [json!("Y"), json!("y"), json!("l4"), json!({"origin":[0,0,0],"dir":[0,2,0]})] { + let mut h = make(Harness::new(), axis_value.clone()); + let original = axis(&h); + let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone(); + let state = h.app.session.world_state(); + let body = state.body("Main").unwrap(); + let bounds = body.mesh().bounds(); + let radii = if axis_value == json!("l4") { 25.0 } else { 225.0 - 100.0 }; + let expected = 0.5 * radii * 5.0 * std::f64::consts::FRAC_PI_2; + let volume = solvecraft_engine::doc::kernel::measure(&body.body).unwrap().volume; + assert!((volume - expected).abs() < 0.03, "{axis_value}: {volume}"); + open(&mut h); + let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + let preview = h.app.session.preview(&commands).unwrap(); + assert_eq!(preview.after.body("Main").unwrap().mesh().bounds(), bounds); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{axis_value}: {:?}", h.app.session.log); + assert_eq!(axis(&h), original); + assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds); + } +} + +#[test] +fn revolve_angle_edit_preserves_axis_downstream_binding_and_one_undo() { + let mut h = make(Harness::new(), json!({"origin":[0,0,0],"dir":[0,2,0]})); + h.app.run("solid.box", json!({"length":2,"width":2,"height":"(d1 / (1 rad)) * 1 mm","corner":[30,0,0],"body_name":"Dependent"})).unwrap(); + let original = axis(&h); + let undo = h.app.session.undo.len(); + open(&mut h); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"180 deg"})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(axis(&h), original); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::PI).abs() < 1e-8); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(axis(&h), original); + assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::FRAC_PI_2).abs() < 1e-8); +} + +#[test] +fn rotated_owner_explicit_axis_is_lossless_with_another_component_active() { + let mut h = Harness::new(); + let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let mut h = make(h, json!({"origin":[0,0,0],"dir":[0,2,0]})); + h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap(); + h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap(); + let active = h.app.session.active_component; + let original = axis(&h); + let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds(); + open(&mut h); + let (origin, direction) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + assert!((origin.x - 100.0).abs() < 1e-8 && (origin.z - 20.0).abs() < 1e-8); + let angle = 17.0f64.to_radians(); + assert!((direction.x + angle.sin()).abs() < 1e-8 && (direction.y - angle.cos()).abs() < 1e-8); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(axis(&h), original); + assert_eq!(h.app.session.active_component, active); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds); +} + +#[test] +fn world_and_sketch_axis_handles_follow_authoring_occurrence() { + for selected in [json!("Y"), json!("y")] { + let mut h = Harness::new(); + let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let mut h = make(h, selected); + h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap(); + let original = axis(&h); + open(&mut h); + let (origin, direction) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + let angle = 17.0f64.to_radians(); + assert!((origin.x - 100.0).abs() < 1e-8 && (origin.z - 20.0).abs() < 1e-8); + assert!((direction.x + angle.sin()).abs() < 1e-8 && (direction.y - angle.cos()).abs() < 1e-8); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(axis(&h), original); + } +} + +#[test] +fn sketch_axis_cancel_restores_original_document_and_history() { + let mut h = make(Harness::new(), json!("y")); + let original = serde_json::to_value(&*h.app.session.doc).unwrap(); + let undo = h.app.session.undo.len(); + open(&mut h); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"180 deg"})).unwrap(); + h.step(&json!({"widget":"Cancel"})).unwrap(); + assert_eq!(serde_json::to_value(&*h.app.session.doc).unwrap(), original); + assert_eq!(h.app.session.undo.len(), undo); + assert!(matches!(&h.app.session.doc.find_feature("Turn").unwrap().kind,FeatureKind::Revolve{axis:AxisRef::SketchAxis{axis},..} if axis=="y")); +} diff --git a/crates/ui-egui/src/sketch_corner_ui_tests.rs b/crates/ui-egui/src/sketch_corner_ui_tests.rs index 88648237..397cc102 100644 --- a/crates/ui-egui/src/sketch_corner_ui_tests.rs +++ b/crates/ui-egui/src/sketch_corner_ui_tests.rs @@ -1,6 +1,42 @@ use super::*; use solvecraft_engine::sketch::CurveKind; +#[test] +fn clicked_extend_endpoint_reaches_exact_ellipse_and_preserves_support() { + let mut h = crate::scenario::Harness::new(); + h.app.ui.show_origin = true; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.ellipse", json!({"center":[20,0],"major":[30,0],"minor_radius":5})).unwrap(); + h.app.run("sketch.constraint.fix", json!({"entity":"e1"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,3],[5,3]],"connect":false,"infer":false})).unwrap(); + h.frames(2); + h.step(&json!({"start":"sketch.extend"})).unwrap(); + h.step(&json!({"click":{"world":[5,3,0]}})).unwrap(); + let st = h.app.session.world_state(); + let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + let end = ss.sketch.point(ss.sketch.resolve_point("l1.end").unwrap()).unwrap(); + assert!(end.dist(Vec2::new(12.0, 3.0)) < 1e-7, "clicked endpoint must extend: {end:?}, {:?}", h.app.status); + assert_eq!(ss.sketch.point(ss.sketch.resolve_point("l1.start").unwrap()), Some(Vec2::new(0.0, 3.0))); +} + +#[test] +fn derived_offset_context_action_releases_geometry_and_undo_restores() { + 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(); + let out = h.app.run("sketch.offset", json!({"curves":["e1"],"distance":-1,"chain":false})).unwrap(); + let id = out["curves"][0].as_str().unwrap().to_string(); + h.app.session.selection = vec![solvecraft_engine::Sel::SketchCurve { id: id.clone() }]; + let items = crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport); + let item = items.iter().find(|item| item.id == "sketch.offset.break").unwrap(); + assert_eq!(item.params, Some(json!({"curve":id}))); + crate::context_menu::run_item(&mut h.app, item, egui::Pos2::ZERO); + let sid = h.app.session.active_sketch.unwrap(); + assert!(h.app.session.model.state().sketch(sid).unwrap().sketch.freeform_offsets.is_empty()); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.model.state().sketch(sid).unwrap().sketch.freeform_offsets.len(), 1); +} + #[test] fn clicked_origin_corner_fillets_and_chamfers_with_origin_visible() { for command in ["sketch.fillet", "sketch.chamfer.equal_distance", "sketch.chamfer.distance_angle", "sketch.chamfer.two_distance"] { diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 55be6973..a40a8070 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -647,7 +647,13 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { } } Mode::CurveAt => { - if let (Some((id, false)), Some((p, _))) = (sketch_entity(app, &hits), at) { + let curve = active_sketch(app).and_then(|active| { + hits.iter().find_map(|hit| match hit { + Hit::SketchCurve { sketch, id, .. } if *sketch == active => Some(id.clone()), + _ => None, + }) + }); + if let (Some(id), Some((p, _))) = (curve, at) { let _ = app.run(cmd, json!({"curve": id, "at": xy(p)})); } } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 9c6d4dd2..15b579f8 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -15,6 +15,10 @@ use crate::selection::{BoxSel, origin_quads, origin_size, ray_quad}; use crate::theme::Tokens; use crate::{SolveApp, icons}; +#[cfg(test)] +#[path = "viewport_appearance_tests.rs"] +mod appearance_tests; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NavMode { Orbit, @@ -290,7 +294,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { let hidden_edges = app.ui.visual_style == 3; let hidden_col = c4(tk.text.gamma_multiply(0.35)); // Appearances: the body's opacity, and faces with looks of their own. - let opacity = s.doc.model_body_look(b).map_or(1.0, |l| l.opacity); + let opacity = s.doc.model_body_opacity(b); let face_looks: std::collections::HashMap = if shaded { s.doc.face_colors(b).into_iter().collect() } else { Default::default() }; for (ti, t) in m.triangles.iter().enumerate().filter(|_| shaded) { @@ -1086,13 +1090,18 @@ fn hidden_face_outline(sc: &mut GpuScene, m: &solvecraft_engine::geom::Mesh, fac } } -fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) { +pub(crate) fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) { let st = app.session.world_state(); let Some(ss) = st.sketch(sketch) else { return }; let Some(p) = ss.profiles.get(index) else { return }; + let plane = match crate::dialogs::emboss_profile_plane(app, sketch) { + Ok(Some(plane)) => plane, + Ok(None) => ss.plane, + Err(_) => return, + }; // Lift toward the eye so a profile on a face shows over it. let lift = app.cam.back() * (app.cam.half_height() * 0.002); - let w = |q: solvecraft_engine::geom::Vec2| (ss.plane.to_world(q) + lift).to_f32(); + let w = |q: solvecraft_engine::geom::Vec2| (plane.to_world(q) + lift).to_f32(); let col = c4(fill); // In the translucent pass (after the grid and edges) so it covers them. for [a, b, c] in p.region.triangulate(0.02) { diff --git a/crates/ui-egui/src/viewport_appearance_tests.rs b/crates/ui-egui/src/viewport_appearance_tests.rs new file mode 100644 index 00000000..1cfacedc --- /dev/null +++ b/crates/ui-egui/src/viewport_appearance_tests.rs @@ -0,0 +1,54 @@ +use super::build_scene; +use crate::{Services, SolveApp}; +use serde_json::json; +use solvecraft_engine::{Session, doc, geom::Vec3, kernel}; + +#[test] +fn placed_step_body_uses_imported_opacity_until_a_design_look_overrides_it() { + let body = kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)) + .unwrap() + .with_color(Some([0.2, 0.6, 0.8])) + .with_paint(Some(kernel::Paint { opacity: 0.4, faces: Vec::new() })); + let step = + kernel::step_export_bodies(&[kernel::ExportBody { name: "Imported glass".into(), body: &body, color: body.color() }], &Default::default()) + .unwrap(); + let mut document = doc::Document::default(); + let component = document.add_component(Some("Imported part"), 0).unwrap(); + let feature = document.add_feature(doc::FeatureKind::Import { file: "glass.step".into(), step, components: Vec::new() }, None).unwrap(); + document.features.iter_mut().find(|f| f.id == feature).unwrap().component = component; + let mut session = Session::new(document); + let source = session.model.state().bodies[0].name.clone(); + assert!((session.doc.model_body_opacity(&session.model.state().bodies[0]) - 0.4).abs() < 1e-6); + session.execute("occurrence.copy", &json!({"component":component,"translate":[30,0,0]})).unwrap(); + let mut app = SolveApp::new(session, Services::default()); + app.ui.ground_shadow = false; + app.ui.show_origin = false; + let placed = app.session.world_state().bodies.iter().find(|b| b.name != source).unwrap().name.clone(); + app.session.selection = vec![solvecraft_engine::Sel::Body { name: placed }]; + app.start("appearance.assign"); + let crate::dialogs::Kind::Appearance(appearance) = &app.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") }; + assert!((appearance.opacity - 0.4).abs() < 1e-6); + assert_eq!(appearance.color, [51, 153, 204]); + crate::dialogs::cancel(&mut app); + let scene = build_scene(&app); + assert!(!scene.glass.is_empty()); + assert!( + scene.tris.is_empty(), + "opaque triangles {}, bodies {:?}", + scene.tris.len(), + app.session.world_state().bodies.iter().map(|b| (&b.name, b.body.paint(), &b.imported)).collect::>() + ); + let (vertices, remainder) = scene.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>(); + assert!(remainder.is_empty()); + assert!(vertices.iter().all(|v| v[27] == 102)); + app.run("appearance.assign", json!({"bodies":[source],"color":"#336699","opacity":0.7})).unwrap(); + let scene = build_scene(&app); + let (vertices, remainder) = scene.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>(); + assert!(remainder.is_empty()); + assert!(vertices.iter().all(|v| v[27] == 179)); + app.run("appearance.assign", json!({"bodies":[source]})).unwrap(); + let scene = build_scene(&app); + let (vertices, remainder) = scene.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>(); + assert!(remainder.is_empty()); + assert!(vertices.iter().all(|v| v[27] == 102)); +} diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index e837086b..ce65b204 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -11,6 +11,13 @@ component, and **Paste New** creates an independent copy. Move an occurrence wit triad, then **Capture Position** to keep it or **Revert** to put it back. SolveCraft asks before the next command if a move wasn't captured. **Ground** fixes an occurrence in place. +Paste New copies nested component definitions, sketches, feature histories and generated +dimension parameters. Repeated child instances remain shared within the new copy. Materials, +appearances and wholly internal joints/motion records receive copied references. Design-wide +user parameters remain shared; retained external links and omitted cross-boundary motion +records are reported. Unsupported destructive links refuse the entire copy. See +[individual-instance and copy details](https://github.com/bherbruck/solvecraft/blob/main/docs/occurrence-workflows.md). + **Joints** (`joint.create`, J) connect two components by joint origins that snap to faces, edges, circles and vertices: rigid, revolute, slider, cylindrical, pin-slot, planar and ball, with offsets, flip and limits. **As-built Joint** keeps the current position, **Rigid Group** locks diff --git a/docs/book/src/guide/features.md b/docs/book/src/guide/features.md index 555f0561..f27211fe 100644 --- a/docs/book/src/guide/features.md +++ b/docs/book/src/guide/features.md @@ -18,6 +18,22 @@ Extruding into existing material selects **Cut**. In an empty component it selec choose **New Body** explicitly to keep an addition separate. Changing the default does not alter operations already saved in a design. +Editing an extrusion retains its signed distances, From/To Object inputs, taper expressions +and explicit body targets. Revolve edits retain world, sketch and explicit-line axes, both +angle expressions and To Object targets. An unchanged **All profiles** selection continues +to include profiles added later. Cancel restores the complete timeline; a successful edit is +one undo step, including downstream parameter changes. Editing a placed component previews the +edited feature before its downstream history. Cylindrical Emboss profile guides follow the +sketch's chosen occurrence frame. + +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. + 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 ball). See the roadmap's known limitations for the rest. diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index 663bd9c2..f617c95f 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -17,3 +17,8 @@ Saves are atomic: a crash at any moment leaves either the old file or the new on replaced version is kept as `.bak`. Unsaved changes are autosaved to a recovery folder, and the next launch offers to recover them. Older design files are upgraded on open, and files from a newer version are refused with a message. + +Current designs use format 9 for semantic configuration styles, associative free-form offsets +and explicit cylindrical emboss selections. Older geometry and expressions retain their +meaning during upgrade; missing new fields use defaults. Association records and occurrence +paths are bounded and checked before they can alter geometry. diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index d10bf732..c5d222b7 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -15,6 +15,20 @@ fillet, chamfer, offset (O), mirror, patterns, move/copy and scale. **Project** (P) and **Intersect** bring model edges into the sketch as linked geometry that follows later changes. Sketching on a face projects its edges automatically. +Extend finds intersections with exact ellipses, rational conics and spline spans. It keeps the +original line or circular support and extends only beyond the selected endpoint; conflicting +constraints or an ambiguous overlap leave the sketch unchanged. + +Offsets of smooth ellipses, conics and splines follow their source after dimension changes, +dragging and reopening a design. Their stored cubic spline has a whole-span normal-distance +error bound of 0.002 mm. Singular tangents, curvature cusps, possible self-intersections and +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. + ## Constraints and dimensions The solver is SolveCraft's own (damped Gauss–Newton with degree-of-freedom analysis). Constraints: diff --git a/docs/configurations.md b/docs/configurations.md index 06edf4eb..3de62a14 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -1,7 +1,8 @@ # Design configurations The configuration table stores named design variants. Add parameter, feature suppression, -physical material, and body appearance columns with `config.configure` or `config.column`. +physical material, body appearance, component appearance, and semantic face appearance columns +with `config.configure` or `config.column`. The desktop Configuration Table's **Add Column** menu offers the same targets. Material and appearance cells have a **Library** picker; their text fields also accept exact saved values. @@ -34,9 +35,46 @@ face appearances keep their existing priority. Physical material changes affect `delete` to remove it. Names must be unique. To remove a style column, use `config.column` with `material` or `appearance` set to the target body and `delete: true`. -Style columns target body names. Assignments remain stored when a row suppresses the feature -that produces that body, and become visible when it returns. Component and face appearance -columns are not yet supported. +Body style columns target body names. Assignments remain stored when a row suppresses the +feature that produces that body, and become visible when it returns. + +## Component and face appearances + +**Add Column** offers component appearances and a **Face Appearance** submenu organized by +body and semantic role. You can also select a viewport face before opening the menu and use +**Selected face on ...**. These assignments affect every occurrence sharing the definition. +Component columns use stable component IDs, so renaming a component preserves its rows. +Face columns use a definition body and semantic lineage, never a saved display face index. + +```json +{"command":"config.configure","params":{"component_appearances":["Part"],"face_appearances":[{"body":"Box1","point":[5,5,10]}]}} +``` + +Use a point on the current surface to capture a face. World occurrence picks are converted +to the shared definition coordinates. `config.table` returns canonical keys and readable +target labels. Use its returned face key when editing a row or deleting a face column: +`config.column` accepts `face_appearance: ""` with `delete: true`. +Component columns can be removed with `component_appearance: "Part"` and `delete: true`. + +Supported stable face roles are box axis faces, cylinder caps and unsplit side, profile-plane +extrude caps, and one-sided revolve caps. From/nonzero or variable Start Offset/Two Sides extrude caps and +symmetric/Two Sides revolve caps are not yet certified. Mesh faces, ambiguous cap names, +generic index-derived names, and sketch side names based on nearest-curve heuristics are +rejected. Coplanar split pieces of one supported semantic face share +the configured appearance. If that lineage disappears while its body remains, Apply and +Preview reject the row atomically. There is no nearest-face substitution; later ordinary +topology edits also cannot transfer its paint to a replacement face. A suppressed owning +feature keeps the assignment dormant until it returns. + +The Library's **Follow inherited appearance** removes only the selected direct component +or face override. Face paint takes priority over body, component, and physical material; +clearing it reveals inherited or imported paint. Body renames maintain face targets. +Deleting a component or originating feature removes its configured targets and row cells. +Deleting one occurrence preserves the shared definition configuration. + +Independent component copies duplicate mapped configuration columns, row values, and rules +without retargeting the source. Copying a strict configured face from a foreign body frozen +as an import currently fails explicitly because imported face lineage is not certified. Activation evaluates all row parameter expressions together, so column order does not create temporary dependency cycles. Preview and activation reject new or changed geometry errors @@ -96,6 +134,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 rules use file format 8. Older designs upgrade on opening without +Designs saved with component or strict face styles use file format 9. 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/occurrence-workflows.md b/docs/occurrence-workflows.md index d97c21df..034100d4 100644 --- a/docs/occurrence-workflows.md +++ b/docs/occurrence-workflows.md @@ -37,3 +37,11 @@ Visibility is an independent view setting for exact placements. Hide a path and Paths are checked for missing links, repeated IDs, component mismatches and a maximum depth of 64. Descendant expansion rejects more than 100,000 placements or bodies instead of returning incomplete results. Computed body names normally retain their readable occurrence labels; collisions use full occurrence IDs so visibility and body commands still address the correct source. Placed bodies retain their definition identity separately from display names. Body materials and appearances remain shared, with body overrides taking precedence over component overrides and material colors. Face overrides follow the placed geometry, including pending positions, and painted exports retain these colors. + +Paste New copies the reachable component definition graph and its feature history. Repeated children stay shared within the new copy, while the copied definitions, sketches and generated dimension/input parameters are independent of the originals. Materials and body, component and face appearances follow the copied definition identities. Pattern and mirror output names remain explicit, so later body operations follow the copied bodies. + +Configuration columns, row values and internal rules are duplicated for copied targets. Certified native face appearances follow the new feature lineage. Selected cylindrical Emboss target and authoring paths inside the copied graph follow the copied occurrences; an incompatible external authoring context is rejected before adoption. + +A body reassigned from an external component receives an independent BaseFeature before the first copied feature that modifies it. Copied modifiers then replay once. The command reports these frozen bodies. Public user parameters remain design-wide; retained external sketch, project and feature links are reported. Joints and motion records wholly inside the copied occurrence graph receive new references. Records crossing its boundary are omitted with notices. + +Copy evaluates on a scratch document, checks that original bodies remain unchanged, and verifies copied body measurements and sampled material occupancy before adopting it. Unsupported cross-boundary destructive operations, configured foreign faces without certified semantic lineage, reassigned mesh bodies, rolled-back timelines and exceeded work limits return errors without a partial copy. A successful copy selects the new occurrence, preserves your active component and unrelated pending positions, and places the copy under the chosen parent occurrence path. Activate the new occurrence to author features in it. Existing 3D sketch wire coordinate behavior is copied unchanged.