diff --git a/README.md b/README.md index 51b0dbbd..93dfde1d 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,19 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). New face attachments retain supported construction identities and chosen occurrences; missing sources expose an explicit plane repair workflow. See [face attachments](docs/face-attachments.md). Explicitly closed fit splines and closed associative offsets use periodic C2 interpolation. + Supported convex line-and-arc offsets retain their identity when an inset consumes corner + arcs, and linked extrusions follow the current boundary. Breaking the association freezes + the boundary and keeps existing extrusions usable. + Curve drawing and picking adapt together to zoom and display scale, including small circles + at Fit and sketches in placed components. - **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. + Newly captured complete adaptive boundaries retain their outer and hole source identities + when spline span counts change. Partial or ambiguous boundaries do not qualify. - **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 diff --git a/ROADMAP.md b/ROADMAP.md index 520f2074..f61ea780 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -59,7 +59,17 @@ headline counts in-scope tabs only and lists the deferred ones separately. | M11 | 2D drawings | deferred | — | | M12 | Release: installers, signing, docs, performance | planned | 16 | | M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 | -| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/11"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | +| 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/12"`, 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 | + +## Next usability work + +- Single-line and rectangle-side dimensioning: keep endpoints reachable after Fit when the + Sketch Palette is open; support selecting a line or its two endpoints, with typed creation + committed as one undo step and cancellation leaving the sketch unchanged. +- Contextual error reporting: a **Create Issue** action beside feature errors opens a + reviewable GitHub issue draft with the error, operation settings, build diagnostics and + useful reproduction context. Include relevant screenshots or a small reproduction when + they make a geometry failure or unexpected result easier to understand. ## View navigation @@ -88,7 +98,10 @@ headline counts in-scope tabs only and lists the deferred ones separately. blend a rational B-spline through exact arcs), closed or ending at planar faces square to the edges. - Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal - sections stacked end to end) are combined exactly in 2D; bodies touching along one whole face + sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve + annular holes with overrun cutters, including inward Press Pull after a joined extrusion; + curved wall qualification rejects false transverse axes instead of filling holes. + Bodies touching along one whole face (a part and its mirror image) are stitched; coincident planar faces are pushed apart when their neighbours are planes or cylinders. Other coincident curved configurations can still fail. Bounded containment classification preserves fully internal cavities and nested @@ -125,9 +138,19 @@ headline counts in-scope tabs only and lists the deferred ones separately. closed natural-spline seams remain limitations. New explicitly closed fit splines and closed offset outputs use periodic C2 interpolation, supporting repeated certified offsets; older natural splines are preserved. Clicking the starting fit point deliberately closes a new loop. + Newly captured adaptive profile references with complete source coverage retain their outer + and hole identities through span-count changes, including parameter edits after extrusion. + Partial, intersected or ambiguous boundaries do not qualify for these whole-source references. + Legacy curve-span multisets remain exact and unchanged; already-lost selections may need + reselection. Format 12 stores the new bounded whole-boundary references. Projected analytic boundaries follow nested loop identities through resizing, including - stationary holes and rounded corners. An inset that consumes a corner arc still reports - a collapse error; changing the associated output topology remains unfinished. + stationary holes and rounded corners. Supported convex, tangent line-and-arc boundaries + can lose or regain corner arcs during an inset while preserving reserved curve identities + and linked profile selections. Format 12 saves those associations; compatible older closed + offsets upgrade structurally without changing their curve IDs. Breaking an association + freezes its current boundary and materializes downstream profile references. Child offsets + of a boundary with retired arcs still require breaking its association first; mixed or + nonconvex junctions and retained references to disappearing individual arcs remain limited. - 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. @@ -153,6 +176,12 @@ headline counts in-scope tabs only and lists the deferred ones separately. - Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to the origin; clicked UI regressions verify geometry, closed profiles and undo. +- Sketch display: drawing, picking, highlighting, tool previews and profile shading share + adaptive projected curve boundaries. Fit, zoom, display scale and placed sketch frames + invalidate the display cache without changing CAD geometry or timeline history. Work + budgets and clipped paths prevent unbounded sampling or invented edges across the eye plane. + The Sketch Palette scrolls its options on short windows and enlarged displays while + keeping constraint status and Finish Sketch visible. - Face projection: closed boundaries have a deterministic outer-first order. Refresh matches existing geometry before assigning positions, retaining saved curve IDs and constraints across enumeration changes and resize, including concentric circles. Ambiguous identities @@ -188,7 +217,8 @@ headline counts in-scope tabs only and lists the deferred ones separately. Circular cuts crossing bends remain unsupported; free-form bases use bounded sampling. Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves - exact holes and retained islands, including the union of multiple selected profiles. Unsupported + exact holes and retained islands, including the union of multiple selected profiles, and + records complementary depth layers as pockets while retaining the stock gauge. Unsupported tapered or bend-crossing machining refuses atomically instead of restoring material later. - Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 6fb8c4c0..6593252d 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -71,6 +71,15 @@ pub enum PlaneRef { }, } +/// Complete source curves of an outer boundary and each of its holes. Unlike a +/// span multiset, this identity is independent of adaptive spline subdivision. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WholeProfileBoundary { + pub outer: Vec, + pub holes: Vec>, +} + /// Which closed profiles of a sketch a feature uses. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -82,6 +91,11 @@ pub enum ProfileSel { /// Profiles whose outer loop is made of exactly these curves (order-insensitive); stable /// across parameter changes. Curves { loops: Vec> }, + /// Complete current boundaries of topology-aware analytic offset associations. + /// The local association identity survives retirement and resurrection of arc roles. + OffsetBoundaries { associations: Vec }, + /// Exact whole source coverage, including holes; partial source curves do not match. + WholeBoundaries { profiles: Vec }, /// The profile containing each sketch point. Points { points: Vec }, } @@ -1315,6 +1329,22 @@ impl FeatureKind { } } + /// Profile selections and their source sketches without cloning feature data. + pub fn profile_sels(&self) -> Vec<(u64, &ProfileSel)> { + match self { + FeatureKind::Extrude { sketch, profiles, .. } + | FeatureKind::Revolve { sketch, profiles, .. } + | FeatureKind::Sweep { sketch, profiles, .. } + | FeatureKind::Emboss { sketch, profiles, .. } + | FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)], + FeatureKind::Patch { sketch: Some(sketch), profiles, .. } => vec![(*sketch, profiles)], + FeatureKind::Loft { sections, .. } => { + sections.iter().filter(|section| section.point.is_none()).map(|section| (section.sketch, §ion.profiles)).collect() + } + _ => Vec::new(), + } + } + pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> { match self { FeatureKind::Shell { faces, .. } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index b44ede63..7a9e205c 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -781,19 +781,15 @@ pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result { - let mut v = Vec::new(); - for l in loops { - let mut want: Vec<&String> = l.iter().collect(); - want.sort(); - let found = ps.iter().find(|p| { - let mut have: Vec<&String> = p.outer_curves.iter().collect(); - have.sort(); - have == want - }); - v.push(found.ok_or_else(|| DocError::Unknown(format!("profile bounded by {l:?} in {}", ss.name)))?.region.clone()); - } - v + ProfileSel::Curves { .. } | ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. } => { + crate::profile_selection_indices(ss, sel)? + .into_iter() + .map(|index| { + ps.get(index) + .map(|profile| profile.region.clone()) + .ok_or_else(|| DocError::Invalid("profile index changed during resolution".into())) + }) + .collect::>>()? } ProfileSel::Points { points } => { let mut v = Vec::new(); @@ -1577,8 +1573,51 @@ pub fn revolve_extent_with_convention( Ok(RevolveExtent { start, sweep, rotation_axis, display_start, curved_to: to.is_some_and(|point| curved_target_any(st, point).is_some()) }) } +/// The exact input frame and scalar interval of a current, untrimmed constructor. +struct ExtrudeConstructor { + plane: Plane, + interval: (f64, f64), +} + +/// Construct planar caps locally before placing the completed tool. +fn centered_extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result> { + if !plane.origin.is_finite() + || !plane.x.is_finite() + || !plane.y.is_finite() + || (plane.x.len2() - 1.0).abs() > 1e-8 + || (plane.y.len2() - 1.0).abs() > 1e-8 + || plane.x.dot(plane.y).abs() > 1e-8 + { + return Err(DocError::Invalid("extrude frame is not finite and orthonormal".into())); + } + // Fit cap surfaces in their own coordinates. Fitting a distant world-space + // wire can tilt an otherwise exact planar cap through numeric cancellation. + let bodies = kernel::extrude(&Plane::XY, regions, start, end)?; + if *plane == Plane::XY { + return Ok(bodies); + } + let n = plane.normal(); + let m = [ + [plane.x.x, plane.x.y, plane.x.z, 0.0], + [plane.y.x, plane.y.y, plane.y.z, 0.0], + [n.x, n.y, n.z, 0.0], + [plane.origin.x, plane.origin.y, plane.origin.z, 1.0], + ]; + bodies.into_iter().map(|body| kernel::transform_matrix(&body, m).map_err(DocError::from)).collect() +} + /// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state. fn feature_tools(doc: &Document, vals: &BTreeMap, f: &Feature, st: &ModelState) -> Result> { + feature_tools_with_constructor(doc, vals, f, st, &mut None) +} + +fn feature_tools_with_constructor( + doc: &Document, + vals: &BTreeMap, + f: &Feature, + st: &ModelState, + constructor: &mut Option, +) -> Result> { match &f.kind { FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => { let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone(); @@ -1657,9 +1696,10 @@ fn feature_tools(doc: &Document, vals: &BTreeMap, f: &Feature, st } } let mut tools = Vec::new(); + let mut untrimmed = extent.to.is_none() && extent.from.is_none() && !extent.through_all; for r in ®ions { if *operation == Operation::Join { - let exact = kernel::extrude(&ss.plane, std::slice::from_ref(r), lo + off, hi + off)?; + let exact = centered_extrude(&ss.plane, std::slice::from_ref(r), lo + off, hi + off)?; if exact.iter().all(|tool| { st.bodies .iter() @@ -1674,13 +1714,18 @@ fn feature_tools(doc: &Document, vals: &BTreeMap, f: &Feature, st } let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets); let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::>(); + let original = r; let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe); - tools.extend(kernel::extrude(&ss.plane, std::slice::from_ref(&r), l2, h2)?); + untrimmed &= l2 == lo + off && h2 == hi + off && &r == original; + tools.extend(centered_extrude(&ss.plane, std::slice::from_ref(&r), l2, h2)?); } if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) { let start = if hi > 0.0 { lo + off } else { hi + off }; tools = trim_to_body(tools, &tb.body, |q: Vec3| ((q - ss.plane.origin).dot(n) - start).abs())?; } + if untrimmed { + *constructor = Some(ExtrudeConstructor { plane: ss.plane, interval: (lo + off, hi + off) }); + } if *operation == Operation::NewBody && extent.taper.is_none() { let cap_bindings = crate::config::extrude_cap_bindings(&ss, &tools, 10_000_000); tools = tools @@ -2732,10 +2777,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::Loft { operation, targets, .. } | FeatureKind::Coil { operation, targets, .. } | FeatureKind::Sweep { operation, targets, .. } => { - let tools = feature_tools(doc, vals, f, st)?; + let mut constructor = None; + let tools = feature_tools_with_constructor(doc, vals, f, st, &mut constructor)?; // Cuts through sheet metal become cut-outs of its flat pattern. if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() { - let handled = more::sheet_cut(f, st, &tools, targets)?; + let handled = more::sheet_cut(f, st, &tools, targets, constructor.as_ref())?; if !handled.is_empty() { let effective = if targets.is_empty() { &f.participants } else { targets }; let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*"); diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index e6d6add3..52209ab5 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -539,34 +539,139 @@ fn poly_area(p: &[Vec2]) -> f64 { (0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::() / 2.0 } +/// Current unmodified extrusion caps, including placement, certify the tool's depth. +/// Tessellation can drift in thickness at distant coordinates and cannot certify a slab. +fn sheet_tool_span(body: &Body, source: &Plane) -> Option<(f64, f64, f64, Option<(Plane, Region2)>)> { + use kernel::FaceSource; + #[derive(PartialEq)] + enum Signature<'a> { + Raw(&'a Region2), + Certified(&'a [String], &'a [Vec]), + } + let sources = body.face_sources()?; + if sources.len() != body.face_count() || sources.len() > 16_384 { + return None; + } + let n = source.normal(); + let mut family = None; + let mut caps = [None, None]; + let mut projection_roundoff: f64 = 0.0; + let mut footprint = None; + for (index, entry) in sources.iter().enumerate() { + let (group, cap) = match entry.as_ref()? { + FaceSource::ExtrudeSide { .. } => (None, None), + FaceSource::ExtrudeCap { high, region, plane } => (None, Some((*high, *plane, Signature::Raw(region)))), + FaceSource::CertifiedExtrudeSide { producer, sketch, .. } => (Some((*producer, *sketch)), None), + FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, plane } => { + (Some((*producer, *sketch)), Some((*high, *plane, Signature::Certified(outer, holes)))) + } + _ => return None, + }; + match family { + None => family = Some(group), + Some(previous) if previous != group => return None, + _ => {} + } + if let Some((high, plane, signature)) = cap { + if high && let Signature::Raw(region) = &signature { + footprint = Some((plane, (*region).clone())); + } + let valid = |p: &Plane| { + p.origin.is_finite() + && p.x.is_finite() + && p.y.is_finite() + && (p.x.len2() - 1.0).abs() <= 1e-10 + && (p.y.len2() - 1.0).abs() <= 1e-10 + && p.x.dot(p.y).abs() <= 1e-10 + && p.normal().cross(n).len() <= 1e-10 + }; + let current = body.planar_face(index)?; + if !valid(&plane) || !valid(¤t) { + return None; + } + // Bound floating point projection separately from model geometry tolerance. + let roundoff = 128.0 * f64::EPSILON * (plane.origin.len() + current.origin.len() + source.origin.len()).max(1.0); + if roundoff > 1e-7 || (current.origin - plane.origin).dot(n).abs() > roundoff { + return None; + } + projection_roundoff = projection_roundoff.max(roundoff); + let distance = (plane.origin - source.origin).dot(n); + if !distance.is_finite() || caps[usize::from(high)].is_some() { + return None; + } + caps[usize::from(high)] = Some((distance, signature)); + } + } + let [Some((low, low_signature)), Some((high, high_signature))] = caps else { return None }; + (low_signature == high_signature && low != high).then_some((low.min(high), low.max(high), projection_roundoff, footprint)) +} + /// Exact prismatic machining, retained in sheet material/depth metadata. -pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], targets: &[String]) -> Result> { +pub(super) fn sheet_cut( + f: &Feature, + st: &mut ModelState, + tools: &[Body], + targets: &[String], + constructor: Option<&ExtrudeConstructor>, +) -> Result> { let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) }; if extent.taper.is_some() { return Ok(Vec::new()); } + let targets = if targets.is_empty() { &f.participants } else { targets }; + let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*"); + let eligible = |original: &crate::sheet::SheetBody| { + all || if targets.is_empty() { + st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id)) + } else { + targets.contains(&original.body) + } + }; + if !st.sheets.iter().any(&eligible) { + return Ok(Vec::new()); + } let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) }; let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); let n = ss.plane.normal(); + let mut work = sheet::FlatWork::new(); + let faces = tools.iter().fold(0usize, |count, body| count.saturating_add(body.face_count())); + if tools.len() > 1000 || faces > 16_384 { + return Err(DocError::Invalid("sheet machining tools exceed the bounded geometry budget".into())); + } + work.charge(faces.saturating_mul(5))?; let mut span = (f64::MAX, f64::MIN); - for t in tools { - for p in t.tessellate((t.size() * 1e-3).max(1e-3))?.positions { - let d = (p - ss.plane.origin).dot(n); - span = (span.0.min(d), span.1.max(d)); + let mut cap_roundoff: f64 = 0.0; + let mut current_footprints = Vec::new(); + for tool in tools { + let Some((lo, hi, roundoff, footprint)) = sheet_tool_span(tool, &ss.plane) else { + // Modified tools need a current geometric prism certificate. The generic + // sheet preflight permits actual no-change, otherwise refuses stale metadata. + return Ok(Vec::new()); + }; + if span.0 != f64::MAX && ((span.0 - lo).abs() > cap_roundoff + roundoff || (span.1 - hi).abs() > cap_roundoff + roundoff) { + // Different profile tools cannot be represented as one filled depth interval. + return Ok(Vec::new()); + } + span = (span.0.min(lo), span.1.max(hi)); + cap_roundoff = cap_roundoff.max(roundoff); + if let Some(footprint) = footprint { + current_footprints.push(footprint); } } - let targets = if targets.is_empty() { &f.participants } else { targets }; - let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*"); + // Only untrimmed Distance tools may use their exact authored scalar interval. + // The cap certificate still qualifies their current physical slab above. + let selected_edges = + regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).fold(0usize, usize::saturating_add); + work.charge(faces.saturating_mul(selected_edges).saturating_mul(16))?; + let nominal = constructor.filter(|c| c.plane == ss.plane).and_then(|c| { + let placed_basis = Plane::new(c.plane.origin, c.plane.x, c.plane.y)?; + (current_footprints.len() == tools.len() + && current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r))) + .then_some(c.interval) + }); let mut plans = Vec::new(); - let mut work = sheet::FlatWork::new(); - for original in &st.sheets { - let eligible = all - || if targets.is_empty() { - st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id)) - } else { - targets.contains(&original.body) - }; - if !eligible { + 'sheets: for original in &st.sheets { + if !eligible(original) { continue; } let Some(mb) = st.body(&original.body) else { @@ -605,28 +710,81 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target continue; } let (a, b) = ((span.0 - zorigin) / sign, (span.1 - zorigin) / sign); + let roundoff = (cap_roundoff + + 128.0 * f64::EPSILON * (apply_point(&w, Vec3::ZERO).len() + ss.plane.origin.len() + span.0.abs() + span.1.abs() + sh.t).max(1.0)) + / sign.abs(); + let nominal_depth = if apply_point(&w, Vec3::ZERO) == ss.plane.origin + && let Some((lo, hi)) = nominal + && (span.0 - lo).abs() <= roundoff + && (span.1 - hi).abs() <= roundoff + { + // Equality of the two frame origins proves zero origin displacement; + // use authored distances, never an inferred endpoint coincidence. + let normal = crate::apply_vector(&w, Vec3::X).cross(crate::apply_vector(&w, Vec3::Y)); + let exact_sign = if normal == n { + 1.0 + } else if normal == -n { + -1.0 + } else { + sign + }; + Some((lo / exact_sign, hi / exact_sign)) + } else { + None + }; + if nominal_depth.is_none() && roundoff > 1e-7 { + super::sheet_boolean_unchanged(&mb.body, tools, *operation)?; + plans.push((original.body.clone(), Some((original.clone(), mb.body.clone())))); + continue 'sheets; + } + let (a, b) = nominal_depth.unwrap_or((a, b)); let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t)); + // Sub-resolution layers must not be silently erased by endpoint snapping. + let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6); + let mapped = if extent.to.is_some() && current_footprints.len() == tools.len() { + current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::>>()? + } else { + regions.iter().map(|r| sh.region_to_flat(pi, &ss.plane, r)).collect::>>()? + }; if *operation == Operation::Intersect { if z1 <= z0 { sh.holes.push(panel); modeled = true; continue; } - if z0 > 1e-7 || z1 < sh.t - 1e-7 { - super::sheet_boolean_unchanged(&mb.body, tools, *operation)?; - continue; - } - let mapped = regions.iter().map(|r| sh.region_to_flat(pi, &ss.plane, r)).collect::>>()?; let tool = sheet::material_union(mapped, &mut work)?; let retained = sheet::material_boolean(std::slice::from_ref(&panel), &tool, kernel::BoolOp::Intersect, &mut work)?; - let removed = sheet::material_boolean(&[panel], &retained, kernel::BoolOp::Cut, &mut work)?; + let removed = sheet::material_boolean(std::slice::from_ref(&panel), &retained, kernel::BoolOp::Cut, &mut work)?; + if removed.is_empty() && !sh.material_outside_depth(&panel, z0, z1, &mut work)? { + modeled = true; + continue; + } + if !retained.is_empty() && !depth_resolved { + return Err(DocError::Invalid( + "sheet machining depth is below the supported 0.000001 mm extrusion resolution; increase the layer depth".into(), + )); + } sh.holes.extend(removed); + // Preserve original stock thickness and prior machining. Intersection + // removes the complementary layers above/below every surviving region. + for region in retained { + if z0 > 0.0 { + sh.record_pocket(region.clone(), 0.0, z0, &mut work)?; + } + if z1 < sh.t { + sh.record_pocket(region, z1, sh.t, &mut work)?; + } + } modeled = true; } else if z1 > z0 { - for r in ®ions { - let flat = sh.region_to_flat(pi, &ss.plane, r)?; + for flat in mapped { let clipped = sheet::material_boolean(std::slice::from_ref(&panel), std::slice::from_ref(&flat), kernel::BoolOp::Intersect, &mut work)?; + if !clipped.is_empty() && !depth_resolved { + return Err(DocError::Invalid( + "sheet machining depth is below the supported 0.000001 mm extrusion resolution; increase the layer depth".into(), + )); + } if !clipped.is_empty() && z0 <= 1e-7 && z1 >= sh.t - 1e-7 { // Keep a single original tool boundary across structural seams. // Re-clipping notch polygons creates coincident seam endpoints. @@ -636,7 +794,7 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target modeled = true; } else { for region in clipped { - sh.pockets.push(sheet::SheetPocket { region, z0, z1 }); + sh.record_pocket(region, z0, z1, &mut work)?; modeled = true; } } diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index e60ec16f..b6610da4 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -33,13 +33,16 @@ //! their space; saved coordinates are never rewritten during migration. //! - 11: durable sketch face anchors, explicitly periodic fit splines and versioned revolve //! conventions. Absent markers retain legacy attachment and interpolation behavior. +//! - 12: persistent analytic offset boundary roles and association profile references. +//! Older analytic chains retain their source/output correspondence and saved geometry; +//! migration never invents retired entities or renumbers existing curves. use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 11; +pub const FORMAT: u32 = 12; pub fn format_string(n: u32) -> String { format!("solvecraft/{n}") @@ -52,7 +55,8 @@ 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; 10] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())]; +const JSON_STEPS: [JsonStep; 11] = + [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())]; /// Old XZ references retain their exact frame, including nested offset and angled planes. /// New origin references use +Y; freezing the old frame avoids changing sketch coordinates, @@ -149,6 +153,7 @@ pub fn read(s: &str) -> Result { validate_versioned_geometry(&v)?; validate_emboss_targets(&v)?; validate_freeform_offsets(&v)?; + crate::format_offsets::validate_raw(&v)?; let legacy_wires = validate_wires(&v, version)?; validate_body_transfers(&v)?; for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) { @@ -202,6 +207,7 @@ pub fn read(s: &str) -> Result { } } // Fill in what older designs (and hand-edited files) may lack; a no-op otherwise. + crate::format_offsets::validate_typed(&d)?; normalise(&mut d)?; d.format = format_string(FORMAT); Ok(d) diff --git a/crates/doc/src/format_offsets.rs b/crates/doc/src/format_offsets.rs new file mode 100644 index 00000000..453f0d95 --- /dev/null +++ b/crates/doc/src/format_offsets.rs @@ -0,0 +1,453 @@ +//! Pre-allocation bounds for versioned analytic offset roles and boundary references. +use std::collections::BTreeSet; + +use serde_json::Value; + +use crate::{DocError, Document, FeatureKind, ProfileSel, Result}; + +fn invalid() -> DocError { + DocError::Invalid("damaged design: invalid analytic offset topology or boundary reference".into()) +} + +fn id(value: &Value) -> Result<&str> { + value.as_str().filter(|id| !id.is_empty() && id.len() <= 512 && !id.chars().any(char::is_control)).ok_or_else(invalid) +} + +fn exact_keys(value: &Value, keys: &[&str]) -> Result<()> { + let object = value.as_object().ok_or_else(invalid)?; + if object.len() != keys.len() || object.keys().any(|key| !keys.contains(&key.as_str())) { + return Err(invalid()); + } + Ok(()) +} + +/// Validate a complete-source profile reference before copying or decoding it. +pub fn whole_profile_selection_from_value(value: &Value) -> Result { + validate_whole_reference(value)?; + serde_json::from_value(value.clone()).map_err(|_| invalid()) +} + +fn validate_whole_reference(value: &Value) -> Result<()> { + exact_keys(value, &["type", "profiles"])?; + if value.get("type").and_then(Value::as_str) != Some("whole_boundaries") { + return Err(invalid()); + } + let profiles = value.get("profiles").and_then(Value::as_array).ok_or_else(invalid)?; + if profiles.is_empty() || profiles.len() > 128 { + return Err(invalid()); + } + let mut total = 0usize; + let mut identities = BTreeSet::new(); + for profile in profiles { + exact_keys(profile, &["outer", "holes"])?; + let mut boundary = |value: &Value| -> Result> { + let values = value.as_array().ok_or_else(invalid)?; + if values.is_empty() || values.len() > 512 { + return Err(invalid()); + } + total += values.len(); + if total > 20_000 { + return Err(invalid()); + } + let mut previous = None; + // Bound each raw ID before any owned allocation. Require canonical order. + for value in values { + let current = id(value)?; + if previous.is_some_and(|previous| previous >= current) { + return Err(invalid()); + } + previous = Some(current); + } + values.iter().map(|value| Ok(id(value)?.to_owned())).collect() + }; + let outer = boundary(profile.get("outer").ok_or_else(invalid)?)?; + let holes = profile.get("holes").and_then(Value::as_array).ok_or_else(invalid)?; + if holes.len() > 128 { + return Err(invalid()); + } + let mut sorted_holes = Vec::with_capacity(holes.len()); + let mut all: BTreeSet<_> = outer.iter().cloned().collect(); + for hole in holes { + let hole = boundary(hole)?; + if sorted_holes.last().is_some_and(|previous| previous >= &hole) || hole.iter().any(|curve| !all.insert(curve.clone())) { + return Err(invalid()); + } + sorted_holes.push(hole); + } + if !identities.insert((outer, sorted_holes)) { + return Err(invalid()); + } + } + Ok(()) +} + +pub(crate) fn validate_raw(value: &Value) -> Result<()> { + let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) }; + let mut total_roles = 0usize; + for feature in features { + if feature.get("type").and_then(Value::as_str) != Some("sketch") { + continue; + } + let Some(offsets) = feature.get("sketch").and_then(|sketch| sketch.get("offsets")) else { continue }; + let offsets = offsets.as_array().ok_or_else(invalid)?; + let mut groups = BTreeSet::new(); + let mut outputs = BTreeSet::new(); + let mut point_owners = std::collections::BTreeMap::new(); + let mut sketch_roles = 0usize; + for offset in offsets { + let Some(topology) = offset.get("topology").filter(|topology| !topology.is_null()) else { continue }; + if groups.len() >= 128 { + return Err(invalid()); + } + exact_keys(topology, &["version", "id", "roles"])?; + let group = id(topology.get("id").ok_or_else(invalid)?)?; + if !groups.insert(group) + || topology.get("version").and_then(Value::as_u64) != Some(1) + || offset.get("closed").and_then(Value::as_bool) != Some(true) + { + return Err(invalid()); + } + let distance = offset.get("distance").and_then(Value::as_f64).ok_or_else(invalid)?; + if !distance.is_finite() || distance.abs() <= 1e-9 || distance.abs() >= 1e9 { + return Err(invalid()); + } + let sources = offset.get("sources").and_then(Value::as_array).ok_or_else(invalid)?; + let roles = topology.get("roles").and_then(Value::as_array).ok_or_else(invalid)?; + let active = offset.get("outputs").and_then(Value::as_array).ok_or_else(invalid)?; + if roles.is_empty() || roles.len() > 512 || roles.len() != sources.len() || active.len() > roles.len() { + return Err(invalid()); + } + total_roles = total_roles.saturating_add(roles.len()); + sketch_roles = sketch_roles.saturating_add(roles.len()); + if total_roles > 1_000_000 || sketch_roles > 20_000 { + return Err(invalid()); + } + let mut active_index = 0; + let mut source_ids = BTreeSet::new(); + for (source, role) in sources.iter().zip(roles) { + let source = source.as_array().ok_or_else(invalid)?; + if source.len() != 2 || source.get(1).and_then(Value::as_bool).is_none() { + return Err(invalid()); + } + let source = id(source.first().ok_or_else(invalid)?)?; + if !source_ids.insert(source) { + return Err(invalid()); + } + let role_object = role.as_object().ok_or_else(invalid)?; + if role_object.keys().any(|key| !matches!(key.as_str(), "output" | "kind" | "start" | "end" | "retired" | "flags")) { + return Err(invalid()); + } + if let Some(flags) = role.get("flags") { + exact_keys(flags, &["construction", "centerline", "reversed", "fixed"])?; + if flags.as_object().is_none_or(|flags| flags.values().any(|flag| flag.as_bool().is_none())) { + return Err(invalid()); + } + } + let output = id(role.get("output").ok_or_else(invalid)?)?; + if source == output || !outputs.insert(output) { + return Err(invalid()); + } + if role.get("start") == role.get("end") { + return Err(invalid()); + } + for key in ["start", "end"] { + let point = id(role.get(key).ok_or_else(invalid)?)?; + if point == output || point == source || point_owners.insert(point, group).is_some_and(|owner| owner != group) { + return Err(invalid()); + } + } + let kind = role.get("kind").and_then(Value::as_str).ok_or_else(invalid)?; + let retired = role.get("retired").and_then(Value::as_bool).ok_or_else(invalid)?; + if !matches!(kind, "line" | "arc") || (retired && kind != "arc") { + return Err(invalid()); + } + if !retired { + if active.get(active_index).map(id).transpose()?.is_none_or(|id| id != output) { + return Err(invalid()); + } + active_index += 1; + } + } + if active_index != active.len() || active_index < 3 { + return Err(invalid()); + } + } + if groups.iter().any(|group| outputs.contains(group) || point_owners.contains_key(group)) + || outputs.iter().any(|output| point_owners.contains_key(output)) + { + return Err(invalid()); + } + } + fn references(value: &Value) -> Result<()> { + if value.get("type").and_then(Value::as_str) == Some("whole_boundaries") { + return validate_whole_reference(value); + } + if value.get("type").and_then(Value::as_str) == Some("offset_boundaries") { + exact_keys(value, &["type", "associations"])?; + let ids = value.get("associations").and_then(Value::as_array).ok_or_else(invalid)?; + if ids.is_empty() || ids.len() > 128 { + return Err(invalid()); + } + let mut seen = BTreeSet::new(); + for value in ids { + if !seen.insert(id(value)?) { + return Err(invalid()); + } + } + } + Ok(()) + } + // Only feature profile fields can contain this variant. Do not impose a new + // document-wide node limit on unrelated legacy sketches or imported payloads. + for feature in features { + if let Some(selection) = feature.get("profiles") { + references(selection)?; + } + if let Some(sections) = feature.get("sections").and_then(Value::as_array) { + if sections.len() > 10_000 { + return Err(invalid()); + } + for section in sections { + if let Some(selection) = section.get("profiles") { + references(selection)?; + } + } + } + } + Ok(()) +} + +pub(crate) fn validate_typed(document: &Document) -> Result<()> { + let mut sketches = std::collections::BTreeMap::new(); + for feature in &document.features { + if let FeatureKind::Sketch { sketch, .. } = &feature.kind { + sketch.validate_offset_topologies()?; + sketches.insert(feature.id, sketch); + } + for (sketch, selection) in feature.kind.profile_sels() { + if let ProfileSel::OffsetBoundaries { associations } = selection { + // A deleted association or source sketch is a repairable model error, + // just like an ordinary missing curve reference. Keep that design loadable. + let Some(sketch) = sketches.get(&sketch) else { continue }; + for association in associations { + let count = + sketch.offsets.iter().filter_map(|offset| offset.topology.as_ref()).filter(|topology| &topology.id == association).count(); + if count > 1 { + return Err(invalid()); + } + } + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::panic)] + use super::*; + use serde_json::json; + + fn fixture() -> Value { + json!({"features":[{"type":"sketch","sketch":{"offsets":[{ + "sources":[["s0",true],["s1",true],["s2",true],["s3",true]], + "outputs":["l0","l1","l2"],"closed":true,"distance":1.4, + "topology":{"version":1,"id":"o1","roles":[ + {"output":"l0","kind":"line","start":"p0","end":"p1","retired":false}, + {"output":"a0","kind":"arc","start":"p1","end":"p2","retired":true}, + {"output":"l1","kind":"line","start":"p2","end":"p3","retired":false}, + {"output":"l2","kind":"line","start":"p3","end":"p0","retired":false} + ]} + }]}}]}) + } + + #[test] + fn whole_boundaries_raw_identity_is_bounded_canonical_and_complete() { + let good = json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s1"]]}]}); + assert!(matches!(whole_profile_selection_from_value(&good).unwrap(), ProfileSel::WholeBoundaries { profiles } if profiles.len() == 1)); + let malformed = [ + json!({"type":"whole_boundaries","profiles":[]}), + json!({"type":"whole_boundaries","profiles":[{"outer":[],"holes":[]}]}), + json!({"type":"whole_boundaries","profiles":[{"outer":["s1","s1"],"holes":[]}]}), + json!({"type":"whole_boundaries","profiles":[{"outer":["s2","s1"],"holes":[]}]}), + json!({"type":"whole_boundaries","profiles":[{"outer":["s1"],"holes":[["s1"]]}]}), + json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s1"],["s1"]]}]}), + json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s3"],["s1"]]}]}), + json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[],"extra":true}]}), + json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[]},{"outer":["s2"],"holes":[]}]}), + json!({"type":"whole_boundaries","profiles":[{"outer":["x".repeat(513)],"holes":[]}]}), + json!({"type":"whole_boundaries","profiles":[{"outer":["a\nb"],"holes":[]}]}), + ]; + for value in malformed { + assert!(whole_profile_selection_from_value(&value).is_err(), "accepted malformed reference {value}"); + assert!(validate_raw(&json!({"features":[{"type":"extrude","profiles":value}]})).is_err()); + } + let many = (0..129).map(|index| json!({"outer":[format!("c{index:03}")],"holes":[]})).collect::>(); + assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":&many[..128]})).is_ok()); + assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":many})).is_err()); + let oversized = (0..513).map(|index| format!("c{index:03}")).collect::>(); + assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":[{"outer":oversized,"holes":[]}]})).is_err()); + let too_many_holes = (0..129).map(|index| vec![format!("hole{index:03}")]).collect::>(); + assert!( + whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":[{"outer":["outer"],"holes":too_many_holes}]})).is_err() + ); + let aggregate = (0..40) + .map(|profile| { + let outer = (0..512).map(|curve| format!("profile{profile:03}curve{curve:03}")).collect::>(); + json!({"outer":outer,"holes":[]}) + }) + .collect::>(); + assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":aggregate})).is_err()); + } + + #[test] + fn raw_offset_roles_reject_aliases_order_damage_and_hostile_shapes() { + let good = fixture(); + assert!(validate_raw(&good).is_ok()); + let path = "/features/0/sketch/offsets/0"; + let mut flags = good.clone(); + flags.pointer_mut(&format!("{path}/topology/roles/1")).unwrap()["flags"] = + json!({"construction":true,"centerline":false,"reversed":true,"fixed":false}); + assert!(validate_raw(&flags).is_ok()); + *flags.pointer_mut(&format!("{path}/topology/roles/1/flags/construction")).unwrap() = json!(1); + assert!(validate_raw(&flags).is_err()); + for (suffix, replacement) in [ + ("/topology/version", json!(2)), + ("/topology/id", json!("x".repeat(513))), + ("/closed", json!(false)), + ("/distance", json!(1e9)), + ("/outputs", json!(["l1", "l0", "l2"])), + ("/outputs", json!(["l0", "a0", "l1", "l2"])), + ("/topology/roles/1/kind", json!("line")), + ("/topology/roles/1/output", json!("l0")), + ("/topology/roles/1/start", json!("a0")), + ("/topology/roles/0/kind", json!("guess")), + ("/sources/0/1", json!(0)), + ("/sources/0/0", json!("l0")), + ("/topology/roles", json!(vec![json!({}); 513])), + ] { + let mut damaged = good.clone(); + *damaged.pointer_mut(&format!("{path}{suffix}")).unwrap() = replacement; + assert!(validate_raw(&damaged).is_err(), "accepted {suffix}"); + } + let mut extra = good.clone(); + extra.pointer_mut(&format!("{path}/topology")).unwrap()["fallback"] = json!([]); + assert!(validate_raw(&extra).is_err()); + let mut excessive = good.clone(); + let chain = excessive.pointer(path).unwrap().clone(); + let groups = (0..129) + .map(|index| { + let mut chain = chain.clone(); + chain["topology"]["id"] = json!(format!("o{index}")); + for source in chain["sources"].as_array_mut().unwrap() { + source[0] = json!(format!("g{index}-{}", source[0].as_str().unwrap())); + } + for output in chain["outputs"].as_array_mut().unwrap() { + *output = json!(format!("g{index}-{}", output.as_str().unwrap())); + } + for role in chain["topology"]["roles"].as_array_mut().unwrap() { + for key in ["output", "start", "end"] { + role[key] = json!(format!("g{index}-{}", role[key].as_str().unwrap())); + } + } + chain + }) + .collect::>(); + excessive["features"][0]["sketch"]["offsets"] = json!(&groups[..128]); + assert!(validate_raw(&excessive).is_ok(), "the permitted group boundary must be accepted"); + excessive["features"][0]["sketch"]["offsets"] = json!(groups); + assert!(validate_raw(&excessive).is_err()); + } + + #[test] + fn raw_boundary_references_are_bounded_and_future_versions_refuse_first() { + let mut good = fixture(); + good["features"].as_array_mut().unwrap().push(json!({"type":"extrude","profiles":{"type":"offset_boundaries","associations":["o1"]}})); + assert!(validate_raw(&good).is_ok()); + for associations in [json!([]), json!(["o1", "o1"]), json!(["x".repeat(513)]), json!(vec!["o"; 129]), json!([0])] { + let mut damaged = good.clone(); + damaged["features"][1]["profiles"]["associations"] = associations; + assert!(validate_raw(&damaged).is_err()); + damaged["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1)); + assert!(crate::format::read(&damaged.to_string()).unwrap_err().to_string().contains("newer SolveCraft")); + } + // Absent metadata preserves the historical one-to-one chain representation. + let mut legacy = fixture(); + legacy["features"][0]["sketch"]["offsets"][0].as_object_mut().unwrap().remove("topology"); + legacy["features"][0]["sketch"]["offsets"][0]["closed"] = json!(false); + assert!(validate_raw(&legacy).is_ok()); + } + + #[test] + fn formats_one_through_eleven_keep_legacy_offset_geometry_and_ids() { + let mut sketch = solvecraft_sketch::Sketch::new(); + let center = sketch.add_point(solvecraft_geom::Vec2::new(4., 7.), Some("center")).unwrap(); + sketch.add_circle(solvecraft_geom::Vec2::new(4., 7.), 10., Some(center), Some("source")).unwrap(); + sketch.add_circle(solvecraft_geom::Vec2::new(4., 7.), 9., Some(center), Some("offset")).unwrap(); + sketch.offsets.push(solvecraft_sketch::OffsetChain { + sources: vec![("source".into(), true)], + outputs: vec!["offset".into()], + distance: 1., + closed: true, + topology: None, + }); + let mut document = Document::new("legacy offset circles"); + let sketch_id = + document.add_feature(FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Circles")).unwrap(); + let original: Value = serde_json::from_str(&document.to_json()).unwrap(); + for version in 1..=11 { + let mut old = original.clone(); + old["format"] = json!(crate::format::format_string(version)); + let upgraded = crate::format::read(&old.to_string()).unwrap(); + let saved: Value = serde_json::from_str(&upgraded.to_json()).unwrap(); + assert_eq!(saved["features"][0]["sketch"]["offsets"], original["features"][0]["sketch"]["offsets"]); + assert_eq!(saved["features"][0]["sketch"]["curves"], original["features"][0]["sketch"]["curves"]); + assert_eq!(saved["features"][0]["sketch"]["points"], original["features"][0]["sketch"]["points"]); + let mut model = crate::Model::new(); + model.evaluate(&upgraded); + assert!(model.results.iter().all(|result| result.error.is_none())); + let state = model.state(); + let solved = state.sketch(sketch_id).unwrap(); + let radius = solved.sketch.curves.iter().find(|curve| curve.id == "offset").map(|curve| &curve.kind); + assert!(matches!(radius,Some(solvecraft_sketch::CurveKind::Circle {r,..}) if *r ==9.)); + } + } + + #[test] + fn missing_boundary_or_defining_sketch_reopens_as_a_model_error_without_retarget() { + for missing_sketch in [false, true] { + let mut sketch = solvecraft_sketch::Sketch::new(); + sketch.add_circle(solvecraft_geom::Vec2::new(0., 0.), 10., None, Some("unrelated-circle")).unwrap(); + let mut document = Document::new("repairable lost boundary"); + let id = document.add_feature(FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap(); + document + .add_feature( + FeatureKind::Extrude { + sketch: if missing_sketch { 999_999 } else { id }, + profiles: ProfileSel::OffsetBoundaries { associations: vec!["deleted-o1".into()] }, + extent: crate::Extent { + distance: "2".into(), + direction: crate::Direction::Positive, + distance2: None, + start_offset: None, + through_all: false, + taper: None, + to: None, + to_offset: None, + from: None, + }, + operation: crate::Operation::NewBody, + targets: vec![], + }, + None, + ) + .unwrap(); + let reopened = crate::format::read(&document.to_json()).unwrap(); + assert_eq!(reopened, document); + let mut model = crate::Model::new(); + model.evaluate(&reopened); + assert!(model.results.last().unwrap().error.is_some()); + assert!(model.state().bodies.is_empty(), "lost boundary must not select the remaining ordinary circle"); + } + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 02e849a1..916d9475 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -21,10 +21,14 @@ pub mod expr; pub mod face_anchor; pub use face_anchor::{FaceAnchor, FaceAttachmentStatus}; pub mod format; +mod format_offsets; +pub use format_offsets::whole_profile_selection_from_value; pub mod joints; pub mod motion; pub mod naming; mod params; +mod profile_refs; +pub use profile_refs::{capture_profile_selection, profile_selection_indices}; pub mod plastic; pub mod project; mod project3d; diff --git a/crates/doc/src/naming.rs b/crates/doc/src/naming.rs index 133958bf..275e13f7 100644 --- a/crates/doc/src/naming.rs +++ b/crates/doc/src/naming.rs @@ -331,16 +331,20 @@ fn vote(ps: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc>)], tol: if let Some(f) = idx.face_at(p, tol) && let Some(n) = nm.get(f) { + // A later operation may merge several pieces of the same input face. + // Count their shared ancestry before testing the majority; a piece + // number identifies topology subdivision, not a different source. + let n = strip_piece(n); match votes.iter_mut().find(|(v, _)| v == n) { Some(v) => v.1 += 1, - None => votes.push((n.clone(), 1)), + None => votes.push((n.to_string(), 1)), } break; } } } votes.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); - votes.first().filter(|(_, n)| *n * 2 >= ps.len().max(1)).map(|(n, _)| strip_piece(n).to_string()) + votes.first().filter(|(_, n)| *n * 2 >= ps.len().max(1) && votes.get(1).is_none_or(|(_, other)| n > other)).map(|(n, _)| n.clone()) } /// A face name without its piece number (`#k`). @@ -722,3 +726,28 @@ pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec } best.map(|(_, index, q, split)| (index, q, split)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merged_face_votes_collectively_for_pieces_of_one_source() { + let body = ModelBody::new("Input".into(), solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(4., 1., 1.)).unwrap(), 1); + let mut mesh = Mesh::default(); + let mut points = Vec::new(); + for i in 0..3u32 { + let x = f64::from(i); + mesh.positions.extend([Vec3::new(x, 0., 0.), Vec3::new(x + 0.8, 0., 0.), Vec3::new(x, 0.8, 0.)]); + mesh.triangles.push([i * 3, i * 3 + 1, i * 3 + 2]); + mesh.tri_face.push(i); + points.push(Vec3::new(x + 0.2, 0.2, 0.)); + } + let pieces = Arc::new(vec!["F1:side:wall#1".into(), "F1:side:wall#2".into(), "F1:side:wall#3".into()]); + assert_eq!(vote(&points, &[(&body, FaceIndex::new(&mesh, 3), pieces)], 1e-8, None).as_deref(), Some("F1:side:wall")); + let distinct = Arc::new(vec!["F1:side:wall#1".into(), "F2:side:wall#2".into(), "F3:side:wall#3".into()]); + let inputs = [(&body, FaceIndex::new(&mesh, 3), distinct)]; + assert_eq!(vote(&points, &inputs, 1e-8, None), None); + assert_eq!(vote(&points[..2], &inputs, 1e-8, None), None, "equal different-source votes must not pick a lexicographic winner"); + } +} diff --git a/crates/doc/src/profile_refs.rs b/crates/doc/src/profile_refs.rs new file mode 100644 index 00000000..ff92be90 --- /dev/null +++ b/crates/doc/src/profile_refs.rs @@ -0,0 +1,501 @@ +//! Exact profile references through analytic offset topology transitions. +use std::collections::BTreeSet; + +use crate::{DocError, ProfileSel, Result, SolvedSketch, WholeProfileBoundary}; + +const MAX_SELECTIONS: usize = 128; + +fn whole_boundary(profile: &solvecraft_sketch::Profile) -> Option { + let (outer, holes) = profile.whole_curve_boundaries()?; + Some(WholeProfileBoundary { outer, holes }) +} + +fn validate_whole(profiles: &[WholeProfileBoundary]) -> Result<()> { + if profiles.is_empty() || profiles.len() > MAX_SELECTIONS { + return Err(DocError::Invalid("whole-source profile selection needs bounded unique profiles".into())); + } + let mut total = 0usize; + let mut identities = BTreeSet::new(); + for profile in profiles { + if profile.holes.len() > 128 || !identities.insert((&profile.outer, &profile.holes)) { + return Err(DocError::Invalid("invalid whole-source profile holes or duplicate identity".into())); + } + let mut all = BTreeSet::new(); + for boundary in std::iter::once(&profile.outer).chain(&profile.holes) { + total += boundary.len(); + if boundary.is_empty() || boundary.len() > 512 || total > 20_000 { + return Err(DocError::Invalid("whole-source boundary exceeds its identity budget".into())); + } + let mut previous = None; + for curve in boundary { + if curve.is_empty() + || curve.len() > 512 + || curve.chars().any(char::is_control) + || previous.is_some_and(|previous| previous >= curve) + || !all.insert(curve) + { + return Err(DocError::Invalid("whole-source boundary needs canonical disjoint curve identities".into())); + } + previous = Some(curve); + } + } + if profile.holes.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(DocError::Invalid("whole-source holes need canonical unique identities".into())); + } + } + Ok(()) +} + +fn whole_profile_indices(ss: &SolvedSketch, profiles: &[WholeProfileBoundary]) -> Result> { + validate_whole(profiles)?; + let current = ss.profiles.iter().map(whole_boundary).collect::>(); + profiles + .iter() + .map(|identity| { + let mut matches = current.iter().enumerate().filter(|(_, candidate)| candidate.as_ref() == Some(identity)); + let (index, _) = matches.next().ok_or_else(|| DocError::Unknown(format!("complete source profile in {}", ss.name)))?; + if matches.next().is_some() { + return Err(DocError::Invalid(format!("ambiguous complete source profile in {}", ss.name))); + } + Ok(index) + }) + .collect() +} + +fn same_ids(a: &[String], b: &[String]) -> bool { + if a.len() != b.len() { + return false; + } + let (mut a, mut b) = (a.iter().collect::>(), b.iter().collect::>()); + a.sort(); + b.sort(); + a == b +} + +fn unique_profile(ss: &SolvedSketch, curves: &[String]) -> Result { + let mut matches = ss.profiles.iter().enumerate().filter(|(_, profile)| same_ids(curves, &profile.outer_curves)); + let (index, _) = matches.next().ok_or_else(|| DocError::Unknown(format!("profile bounded by {curves:?} in {}", ss.name)))?; + if matches.next().is_some() { + return Err(DocError::Invalid(format!("ambiguous profile boundary in {}", ss.name))); + } + Ok(index) +} + +fn reserved_boundaries(ss: &SolvedSketch) -> Result, String>> { + let mut found = std::collections::BTreeMap::new(); + for offset in &ss.sketch.offsets { + let Some(topology) = &offset.topology else { continue }; + let mut reserved = topology.roles.iter().map(|role| role.output.clone()).collect::>(); + reserved.sort(); + if found.insert(reserved, topology.id.clone()).is_some() { + return Err(DocError::Invalid("profile role set belongs to multiple offset associations".into())); + } + } + Ok(found) +} + +/// Resolve exact selection identities to the solved sketch's current profile indices. +/// Complete reserved role sets follow their association; partial sets never expand. +pub fn profile_selection_indices(ss: &SolvedSketch, selection: &ProfileSel) -> Result> { + let indices = match selection { + ProfileSel::All => (0..ss.profiles.len()).collect(), + ProfileSel::Indices { indices } => indices.clone(), + ProfileSel::Curves { loops } => { + let boundaries = reserved_boundaries(ss)?; + let mut indices = Vec::with_capacity(loops.len()); + for curves in loops { + let mut key = curves.clone(); + key.sort(); + if let Some(id) = boundaries.get(&key) { + if curves.len() > 512 || curves.iter().collect::>().len() != curves.len() { + return Err(DocError::Invalid("offset profile has repeated or excessive curve roles".into())); + } + indices.push(unique_profile(ss, &ss.sketch.offset_boundary(id)?)?); + } else { + // Preserve the historical first exact ordinary-loop match. + indices.push( + ss.profiles + .iter() + .position(|profile| same_ids(curves, &profile.outer_curves)) + .ok_or_else(|| DocError::Unknown(format!("profile bounded by {curves:?} in {}", ss.name)))?, + ); + } + } + indices + } + ProfileSel::OffsetBoundaries { associations } => { + if associations.is_empty() + || associations.len() > MAX_SELECTIONS + || associations.iter().collect::>().len() != associations.len() + { + return Err(DocError::Invalid("offset profile selection needs unique bounded association identities".into())); + } + associations.iter().map(|id| unique_profile(ss, &ss.sketch.offset_boundary(id)?)).collect::>>()? + } + ProfileSel::WholeBoundaries { profiles } => whole_profile_indices(ss, profiles)?, + ProfileSel::Points { points } => { + let mut indices = Vec::with_capacity(points.len()); + for point in points { + let (index, _) = ss + .profiles + .iter() + .enumerate() + .filter(|(_, profile)| profile.region.contains(*point)) + .min_by(|(_, a), (_, b)| a.area.total_cmp(&b.area)) + .ok_or_else(|| DocError::Unknown(format!("profile at {:?} in {}", [point.x, point.y], ss.name)))?; + indices.push(index); + } + indices + } + }; + if indices.iter().any(|index| *index >= ss.profiles.len()) { + return Err(DocError::Invalid(format!("out-of-range profile selection in {}", ss.name))); + } + Ok(indices) +} + +/// Persist selected complete offset boundaries by association identity. Mixed selections +/// retain exact ordinary loops and the complete reserved role set of each offset boundary. +pub fn capture_profile_selection(ss: &SolvedSketch, indices: &[usize]) -> Result { + let checked = profile_selection_indices(ss, &ProfileSel::Indices { indices: indices.to_vec() })?; + if checked.is_empty() || checked.iter().collect::>().len() != checked.len() { + return Ok(ProfileSel::Indices { indices: indices.to_vec() }); + } + // Capture adaptive free-form boundaries only with complete source provenance, + // including every hole. Ordinary analytic captures keep their historical form. + let adaptive = checked.iter().any(|index| { + ss.profiles.get(*index).is_some_and(|profile| { + std::iter::once(&profile.outer_curves) + .chain(&profile.hole_curves) + .any(|curves| curves.iter().collect::>().len() != curves.len()) + }) + }); + if adaptive { + let identities = checked.iter().map(|index| ss.profiles.get(*index).and_then(whole_boundary)).collect::>>(); + if let Some(profiles) = identities { + // Dynamic analytic offset groups need their own association identities. + // Do not silently flatten a mixed selection into current active curve IDs. + let mut dynamic = false; + for profile in &profiles { + for boundary in std::iter::once(&profile.outer).chain(&profile.holes) { + dynamic |= ss.sketch.offset_boundary_for_curves(boundary)?.is_some(); + } + } + if !dynamic && whole_profile_indices(ss, &profiles)? == checked { + return Ok(ProfileSel::WholeBoundaries { profiles }); + } + } + } + let mut associations = Vec::new(); + let mut loops = Vec::new(); + for index in checked { + let curves = &ss.profiles.get(index).ok_or_else(|| DocError::Invalid("profile index changed during capture".into()))?.outer_curves; + if curves.is_empty() { + return Ok(ProfileSel::Indices { indices: indices.to_vec() }); + } + // Ordinary free-form loops retain one curve ID per exact span. That + // historical multiset is valid profile identity, but is not a candidate + // for the unique reserved-role set of a topology-aware association. + let association = + if curves.iter().collect::>().len() == curves.len() { ss.sketch.offset_boundary_for_curves(curves)? } else { None }; + if let Some(id) = association { + unique_profile(ss, curves)?; + let offset = ss + .sketch + .offsets + .iter() + .filter_map(|offset| offset.topology.as_ref()) + .find(|topology| topology.id == id) + .ok_or_else(|| DocError::Unknown(format!("offset association {id}")))?; + loops.push(offset.roles.iter().map(|role| role.output.clone()).collect()); + associations.push(id); + } else { + if ss.profiles.iter().filter(|profile| same_ids(curves, &profile.outer_curves)).count() != 1 { + return Ok(ProfileSel::Indices { indices: indices.to_vec() }); + } + loops.push(curves.clone()); + } + } + if associations.len() > MAX_SELECTIONS { + return Err(DocError::Invalid("too many offset association profiles".into())); + } + if associations.len() == loops.len() { Ok(ProfileSel::OffsetBoundaries { associations }) } else { Ok(ProfileSel::Curves { loops }) } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::panic)] + use super::*; + use solvecraft_geom::{Plane, Vec2}; + use solvecraft_sketch::{Sketch, find_profiles, solve}; + + fn rounded_source(sketch: &mut Sketch, radius: f64) -> Vec<(String, bool)> { + let endpoints = [ + Vec2::new(-37., -32. - radius), + Vec2::new(37., -32. - radius), + Vec2::new(37. + radius, -32.), + Vec2::new(37. + radius, 32.), + Vec2::new(37., 32. + radius), + Vec2::new(-37., 32. + radius), + Vec2::new(-37. - radius, 32.), + Vec2::new(-37. - radius, -32.), + ]; + let points: Vec<_> = + endpoints.iter().enumerate().map(|(index, point)| sketch.add_point(*point, Some(&format!("source-p{index}"))).unwrap()).collect(); + let centers = [Vec2::new(37., -32.), Vec2::new(37., 32.), Vec2::new(-37., 32.), Vec2::new(-37., -32.)]; + for index in 0..8 { + let id = format!("source-edge{index}"); + if index % 2 == 0 { + sketch.add_line_pts(points[index], points[(index + 1) % 8], Some(&id)).unwrap(); + } else { + let center = centers[index / 2]; + sketch + .add_arc( + center, + endpoints[index], + endpoints[(index + 1) % 8], + [None, Some(points[index]), Some(points[(index + 1) % 8])], + Some(&id), + ) + .unwrap(); + } + } + for point in &mut sketch.points { + point.fixed = true; + } + (0..8).map(|index| (format!("source-edge{index}"), true)).collect() + } + + fn set_source_radius(sketch: &mut Sketch, radius: f64) { + let points = [ + Vec2::new(-37., -32. - radius), + Vec2::new(37., -32. - radius), + Vec2::new(37. + radius, -32.), + Vec2::new(37. + radius, 32.), + Vec2::new(37., 32. + radius), + Vec2::new(-37., 32. + radius), + Vec2::new(-37. - radius, 32.), + Vec2::new(-37. - radius, -32.), + ]; + for (index, value) in points.into_iter().enumerate() { + sketch.points.iter_mut().find(|point| point.id == format!("source-p{index}")).unwrap().pos = value; + } + } + + #[test] + fn captured_sharp_offset_profile_survives_arc_resurrection_and_reopen() { + let mut sketch = Sketch::new(); + let sources = rounded_source(&mut sketch, 1.); + let created = sketch.add_convex_offset(sources, 1.4).unwrap(); + assert_eq!(created.curves.len(), 4); + let mut document = crate::Document::new("selected logical inner boundary"); + let feature = + document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap(); + let mut model = crate::Model::new(); + model.evaluate(&document); + let state = model.state(); + let solved = state.sketch(feature).unwrap(); + let index = unique_profile(solved, &created.curves).unwrap(); + let selection = capture_profile_selection(solved, &[index]).unwrap(); + assert_eq!(selection, ProfileSel::OffsetBoundaries { associations: vec![created.association.clone()] }); + let original_roles = solved.sketch.offsets[0].topology.as_ref().unwrap().roles.iter().map(|role| role.output.clone()).collect::>(); + let sharp_area = crate::select_profiles(solved, &selection).unwrap()[0].area(); + assert!((sharp_area - 73.2 * 63.2).abs() < 1e-7, "{sharp_area}"); + document + .add_feature( + crate::FeatureKind::Extrude { + sketch: feature, + profiles: selection.clone(), + extent: crate::Extent { + distance: "2".into(), + direction: crate::Direction::Positive, + distance2: None, + start_offset: None, + through_all: false, + taper: None, + to: None, + to_offset: None, + from: None, + }, + operation: crate::Operation::NewBody, + targets: vec![], + }, + None, + ) + .unwrap(); + model.evaluate(&document); + let volume = solvecraft_kernel::measure(&model.state().bodies[0].body).unwrap().volume; + assert!((volume - 2. * 73.2 * 63.2).abs() < 1e-6, "{volume}"); + let reopened = crate::Document::from_json(&document.to_json()).unwrap(); + assert_eq!(reopened, document); + let mut cold = crate::Model::new(); + cold.evaluate(&reopened); + assert!((solvecraft_kernel::measure(&cold.state().bodies[0].body).unwrap().volume - volume).abs() < 1e-6); + let crate::FeatureKind::Sketch { sketch, .. } = &mut document.features[0].kind else { panic!("sketch") }; + set_source_radius(sketch, 2.); + model.evaluate(&document); + assert!(model.results.iter().all(|result| result.error.is_none()), "{:?}", model.results); + let state = model.state(); + let solved = state.sketch(feature).unwrap(); + let active = solved.sketch.offset_boundary(&created.association).unwrap(); + assert_eq!(active, original_roles); + let rounded_area = 75.2 * 65.2 - (4. - std::f64::consts::PI) * 0.6 * 0.6; + assert!((crate::select_profiles(solved, &selection).unwrap()[0].area() - rounded_area).abs() < 1e-7); + let volume = solvecraft_kernel::measure(&model.state().bodies[0].body).unwrap().volume; + // Kernel volume integrates the curved display mesh; the analytic region above + // checks the exact rounded geometry independently of that tessellation. + assert!((volume - 2. * rounded_area).abs() < 0.002, "{volume}"); + assert!( + crate::select_profiles(solved, &ProfileSel::Curves { loops: vec![created.curves.clone()] }).is_err(), + "partial old active set must not expand after resurrection" + ); + assert_eq!( + profile_selection_indices(solved, &ProfileSel::Curves { loops: vec![original_roles] }).unwrap(), + profile_selection_indices(solved, &selection).unwrap() + ); + let reopened = crate::Document::from_json(&document.to_json()).unwrap(); + cold.evaluate(&reopened); + assert!((solvecraft_kernel::measure(&cold.state().bodies[0].body).unwrap().volume - volume).abs() < 1e-5); + } + + #[test] + fn ordinary_profile_selection_keeps_legacy_large_and_duplicate_semantics() { + let mut sketch = Sketch::new(); + sketch.add_circle(Vec2::new(0., 0.), 3., None, Some("circle")).unwrap(); + let report = solve(&mut sketch); + let profiles = find_profiles(&sketch); + let ss = SolvedSketch { feature: 1, name: "ordinary circle".into(), plane: Plane::XY, sketch, report, profiles }; + assert_eq!(ss.profiles.len(), 1); + let indices = vec![0; 600]; + let selection = ProfileSel::Indices { indices: indices.clone() }; + assert_eq!(profile_selection_indices(&ss, &selection).unwrap(), indices); + assert_eq!(capture_profile_selection(&ss, &indices).unwrap(), selection); + let points = ProfileSel::Points { points: vec![Vec2::new(0., 0.); 600] }; + assert_eq!(profile_selection_indices(&ss, &points).unwrap(), indices); + let curves = ProfileSel::Curves { loops: vec![vec!["circle".into()]; 600] }; + let regions = crate::select_profiles(&ss, &curves).unwrap(); + assert_eq!(regions.len(), 600); + assert!(regions.iter().all(|region| (region.area() - std::f64::consts::PI * 9.).abs() < 1e-9)); + assert!(profile_selection_indices(&ss, &ProfileSel::Indices { indices: vec![1] }).is_err()); + } + + #[test] + fn periodic_offset_ring_whole_source_survives_adaptive_spans_and_reopen() { + let mut sketch = Sketch::new(); + let major = sketch.add_point(Vec2::new(10., 0.), None).unwrap(); + let ellipse = sketch.add_curve(solvecraft_sketch::CurveKind::Ellipse { c: 0, m: major, r: 5. }, None).unwrap(); + let first = sketch.add_freeform_offset(ellipse, true, -1.).unwrap(); + sketch.add_freeform_offset(first, true, -0.5).unwrap(); + let mut document = crate::Document::new("periodic offset ring multiset"); + let feature = + document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap(); + let mut model = crate::Model::new(); + model.evaluate(&document); + let state = model.state(); + let solved = state.sketch(feature).unwrap(); + assert_eq!(solved.profiles.len(), 3); + let (index, profile) = solved.profiles.iter().enumerate().min_by(|(_, a), (_, b)| a.area.total_cmp(&b.area)).unwrap(); + assert!(profile.outer_curves.len() > 1); + assert_eq!(profile.outer_curves.iter().collect::>().len(), 1); + assert!(solved.sketch.offset_boundary_for_curves(&profile.outer_curves).is_err(), "strict association identity still refuses repeated roles"); + let selection = capture_profile_selection(solved, &[index]).unwrap(); + assert!(matches!(&selection, ProfileSel::WholeBoundaries { profiles } if profiles.len() == 1 && profiles[0].holes.len() == 1)); + let legacy_spans = ProfileSel::Curves { loops: vec![profile.outer_curves.clone()] }; + assert_eq!(profile_selection_indices(solved, &legacy_spans).unwrap(), vec![index]); + let legacy_roundtrip: ProfileSel = serde_json::from_str(&serde_json::to_string(&legacy_spans).unwrap()).unwrap(); + assert_eq!(legacy_roundtrip, legacy_spans, "ordinary legacy span multiplicities remain serialized exactly"); + assert_eq!(profile_selection_indices(solved, &selection).unwrap(), vec![index]); + let selected = crate::select_profiles(solved, &selection).unwrap(); + assert_eq!(selected.len(), 1); + assert!((selected[0].area() - profile.area).abs() < 1e-10); + assert!(selected[0].contains(Vec2::new(11.25, 0.))); + assert!(!selected[0].contains(Vec2::new(10.5, 0.))); + assert!(!selected[0].contains(Vec2::new(11.75, 0.))); + let saved = serde_json::to_string(&selection).unwrap(); + let reopened: ProfileSel = serde_json::from_str(&saved).unwrap(); + let reopened_doc = crate::Document::from_json(&document.to_json()).unwrap(); + let mut cold = crate::Model::new(); + cold.evaluate(&reopened_doc); + let cold_state = cold.state(); + let regions = crate::select_profiles(cold_state.sketch(feature).unwrap(), &reopened).unwrap(); + assert_eq!(regions, selected); + + // An identical complete identity in two profiles is ambiguous, even when + // their boundary geometry and graph coverage certificates are identical. + let mut ambiguous = cold_state.sketch(feature).unwrap().clone(); + ambiguous.profiles.push(ambiguous.profiles[index].clone()); + assert!(profile_selection_indices(&ambiguous, &reopened).is_err()); + + document + .add_feature( + crate::FeatureKind::Extrude { + sketch: feature, + profiles: selection.clone(), + extent: crate::Extent { + distance: "2".into(), + direction: crate::Direction::Positive, + distance2: None, + start_offset: None, + through_all: false, + taper: None, + to: None, + to_offset: None, + from: None, + }, + operation: crate::Operation::NewBody, + targets: vec![], + }, + None, + ) + .unwrap(); + model.evaluate(&document); + let initial_volume = solvecraft_kernel::measure(&model.state().bodies[0].body).unwrap().volume; + // Kernel measurement integrates the curved mesh. Scale the established + // periodic-ring tolerance (0.05 mm³ at height3) to this height2 extrusion. + // Exact profile identity and cold/warm equality below remain strict. + let volume_tolerance = 0.05 * 2. / 3.; + assert!( + (initial_volume - 2. * profile.area).abs() < volume_tolerance, + "initial kernel volume {initial_volume}, analytic area {}", + profile.area + ); + let crate::FeatureKind::Sketch { sketch, .. } = &mut document.features[0].kind else { panic!("sketch") }; + sketch.points[major].pos.x = 12.; + model.evaluate(&document); + assert!(model.results.iter().all(|result| result.error.is_none()), "{:?}", model.results); + let updated_state = model.state(); + let updated = updated_state.sketch(feature).unwrap(); + assert!(profile_selection_indices(updated, &legacy_spans).is_err(), "legacy tessellation-span multiplicity intentionally remains exact"); + let updated_index = profile_selection_indices(updated, &selection).unwrap()[0]; + let updated_region = &updated.profiles[updated_index].region; + // Probe away from exact spline-span seams, using the independent analytic + // ellipse point and unit outward gradient rather than a stored profile sample. + let parameter = 0.2_f64; + let source_point = Vec2::new(12. * parameter.cos(), 5. * parameter.sin()); + let normal = Vec2::new(parameter.cos() / 12., parameter.sin() / 5.).normalized().unwrap(); + let ring_point = source_point + normal * 1.25; + assert!(updated_region.contains(ring_point)); + assert!(!updated_region.contains(source_point + normal * 0.5)); + assert!(!updated_region.contains(source_point + normal * 1.75)); + let updated_volume = solvecraft_kernel::measure(&updated_state.bodies[0].body).unwrap().volume; + assert!(updated_volume > initial_volume); + assert!( + (updated_volume - 2. * updated_region.area()).abs() < volume_tolerance, + "updated kernel volume {updated_volume}, analytic area {}", + updated_region.area() + ); + let reopened_document = crate::Document::from_json(&document.to_json()).unwrap(); + cold.evaluate(&reopened_document); + assert!(cold.results.iter().all(|result| result.error.is_none())); + assert!((solvecraft_kernel::measure(&cold.state().bodies[0].body).unwrap().volume - updated_volume).abs() < 1e-6); + + // A new hole is a semantic boundary change, not the old ring. Whole-source + // identities include holes and must not silently absorb the new material cutout. + let crate::FeatureKind::Sketch { sketch, .. } = &mut document.features[0].kind else { panic!("sketch") }; + sketch.add_circle(ring_point, 0.1, None, Some("new-hole")).unwrap(); + model.evaluate(&document); + let changed = model.state(); + assert!(profile_selection_indices(changed.sketch(feature).unwrap(), &selection).is_err()); + assert!(model.results.last().unwrap().error.is_some()); + } +} diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index eb06fe07..d34bc152 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -32,7 +32,7 @@ impl FlatWork { pub(crate) fn new() -> Self { Self(MAX_FLAT_WORK) } - fn charge(&mut self, work: usize) -> Result<()> { + pub(crate) fn charge(&mut self, work: usize) -> Result<()> { self.0 = self.0.checked_sub(work).ok_or_else(flat_budget_error)?; Ok(()) } @@ -754,16 +754,88 @@ impl SheetBody { } let mut work = FlatWork::new(); let cells = self.cells_with_work(&mut work)?; - let body = kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?; - self.machine_pockets(body, &mut work) + // Validate the bounded material first, then build close to the sheet's own + // origin. Fitting planes after a distant component transfer loses precision. + let mut local = self.clone(); + local.frame = Plane::XY; + let body = kernel::sew(&local.faces(&cells, 0.0, self.t, &mut work)?)?; + let body = local.machine_pockets(body, &mut work)?; + self.place_local_solid(body) } /// The flat pattern as one extrusion (outline with its cut-outs). pub fn flat_solid(&self) -> Result { let regions = self.flat_regions()?; - let pl = plane_of(&self.frame_mat(), Vec3::ZERO, Vec3::X, Vec3::Y)?; - let body = kernel::extrude_planar_regions(&pl, ®ions, 0.0, self.t)?; - self.machine_pockets(body, &mut FlatWork::new()) + let mut local = self.clone(); + local.frame = Plane::XY; + local.flat = true; + let body = kernel::extrude_planar_regions(&Plane::XY, ®ions, 0.0, self.t)?; + let body = local.machine_pockets(body, &mut FlatWork::new())?; + self.place_local_solid(body) + } + + fn place_local_solid(&self, body: Body) -> Result { + if self.frame == Plane::XY { Ok(body) } else { Ok(kernel::transform_matrix(&body, self.frame_mat())?) } + } + + /// Retain bounded derived machining records, without duplicating identical replay work. + pub(crate) fn record_pocket(&mut self, region: Region2, z0: f64, z1: f64, work: &mut FlatWork) -> Result<()> { + if !z0.is_finite() || !z1.is_finite() || z0 < 0.0 || z1 > self.t || z0 >= z1 { + return Err(DocError::Invalid("invalid sheet pocket depth".into())); + } + if self.pockets.len() > 1000 { + return Err(flat_budget_error()); + } + let pocket = SheetPocket { region, z0, z1 }; + let (old_edges, old_points) = regions_complexity(self.pockets.iter().map(|p| &p.region))?; + let (new_edges, new_points) = region_complexity(std::slice::from_ref(&pocket.region))?; + work.charge( + old_edges.saturating_add(old_points).saturating_add(new_edges.saturating_add(new_points).saturating_mul(self.pockets.len().max(1))), + )?; + if self.pockets.contains(&pocket) { + return Ok(()); + } + if self.pockets.len() >= 1000 { + return Err(flat_budget_error()); + } + if old_edges.saturating_add(new_edges) > MAX_FLAT_POINTS || old_points.saturating_add(new_points) > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + self.pockets.push(pocket); + Ok(()) + } + + /// Certify whether a planar panel has material outside a retained thickness interval. + /// Classification uses exact planar arrangements at every pocket depth boundary. + pub(crate) fn material_outside_depth(&self, panel: &Region2, z0: f64, z1: f64, work: &mut FlatWork) -> Result { + if !z0.is_finite() || !z1.is_finite() || z0 < 0.0 || z1 > self.t || z0 >= z1 { + return Err(DocError::Invalid("invalid sheet retained depth".into())); + } + if self.pockets.len() > 1000 { + return Err(flat_budget_error()); + } + regions_complexity(self.pockets.iter().map(|p| &p.region))?; + let mut levels = vec![0.0, self.t, z0, z1]; + for p in &self.pockets { + if !p.z0.is_finite() || !p.z1.is_finite() || p.z0 < 0.0 || p.z1 > self.t || p.z0 >= p.z1 { + return Err(DocError::Invalid("invalid sheet pocket depth".into())); + } + levels.extend([p.z0, p.z1]); + } + levels.sort_by(f64::total_cmp); + levels.dedup(); + work.charge(levels.len().saturating_mul(self.pockets.len().max(1)))?; + for zs in levels.windows(2) { + if zs[0] >= z0 && zs[1] <= z1 { + continue; + } + let active: Vec<_> = self.pockets.iter().filter(|p| p.z0 <= zs[0] && p.z1 >= zs[1]).map(|p| p.region.clone()).collect(); + let removed = material_union(active, work)?; + if !material_boolean(std::slice::from_ref(panel), &removed, kernel::BoolOp::Cut, work)?.is_empty() { + return Ok(true); + } + } + Ok(false) } /// Replay exact planar machining after forming the full-thickness material. @@ -1963,4 +2035,18 @@ mod tests { assert!(s.flat_solid().unwrap_err().to_string().contains("depth")); } } + #[test] + fn pocket_recording_deduplicates_at_limit_and_rejects_growth_before_mutation() { + let mut s = plate(100.0, 60.0); + let pocket = + SheetPocket { region: rect(Vec2::new(1.0, 1.0), Vec2::new(2.0, 1.0), Vec2::new(2.0, 2.0), Vec2::new(1.0, 2.0)), z0: 0.0, z1: 0.5 }; + s.pockets = vec![pocket.clone(); 1000]; + let mut work = FlatWork::new(); + s.record_pocket(pocket.region.clone(), pocket.z0, pocket.z1, &mut work).unwrap(); + assert_eq!(s.pockets.len(), 1000); + assert!(s.record_pocket(pocket.region.clone(), 0.0, 0.6, &mut work).is_err()); + assert_eq!(s.pockets.len(), 1000); + s.pockets.push(pocket.clone()); + assert!(s.record_pocket(pocket.region, 0.0, 0.5, &mut work).is_err()); + } } diff --git a/crates/engine/src/cmd/component_clone.rs b/crates/engine/src/cmd/component_clone.rs index ad3ed31e..dd84a04b 100644 --- a/crates/engine/src/cmd/component_clone.rs +++ b/crates/engine/src/cmd/component_clone.rs @@ -9,6 +9,64 @@ use solvecraft_doc::{Document, Feature, FeatureKind, Mat, Occurrence}; const CMD: &str = "component.paste_new"; const MAX_WORK: usize = 10_000; +fn mesh_distance(mesh: &solvecraft_geom::Mesh, point: solvecraft_geom::Vec3) -> f64 { + mesh.triangles + .iter() + .filter_map(|triangle| mesh.tri(triangle)) + .map(|[a, b, c]| { + let normal = (b - a).cross(c - a); + let squared = normal.dot(normal); + let projected = if squared > 0. { point - normal * ((point - a).dot(normal) / squared) } else { point }; + if squared > 0. && [(a, b), (b, c), (c, a)].iter().all(|(start, end)| (*end - *start).cross(projected - *start).dot(normal) >= 0.) { + return point.dist(projected); + } + [(a, b), (b, c), (c, a)] + .iter() + .map(|(start, end)| { + let edge = *end - *start; + let t = if edge.dot(edge) > 0. { ((point - *start).dot(edge) / edge.dot(edge)).clamp(0., 1.) } else { 0. }; + point.dist(*start + edge * t) + }) + .fold(f64::INFINITY, f64::min) + }) + .fold(f64::INFINITY, f64::min) +} + +fn material_probe_matches(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, point: solvecraft_geom::Vec3, tol_a: f64, tol_b: f64) -> bool { + if !point.is_finite() || !tol_a.is_finite() || !tol_b.is_finite() || tol_a <= 0. || tol_b <= 0. { + return false; + } + if a.contains(point) == b.contains(point) { + return true; + } + // A chord approximation does not define material ON a boundary. Certify + // nearby stock and void instead, outside both meshes' approximation strips. + if mesh_distance(a, point) > tol_a || mesh_distance(b, point) > tol_b { + return false; + } + let offset = 4. * tol_a.max(tol_b); + let mut material = false; + let mut void = false; + for axis in [solvecraft_geom::Vec3::X, solvecraft_geom::Vec3::Y, solvecraft_geom::Vec3::Z] { + for sign in [-1., 1.] { + let q = point + axis * (sign * offset); + if !q.is_finite() { + return false; + } + if mesh_distance(a, q) <= tol_a || mesh_distance(b, q) <= tol_b { + continue; + } + let inside = a.contains(q); + if inside != b.contains(q) { + return false; + } + material |= inside; + void |= !inside; + } + } + material && void +} + fn cloned_path(source: &Document, path: &[u64], root: u64, destination: &[u64], occurrences: &BTreeMap) -> Result> { let start = path .iter() @@ -586,9 +644,13 @@ pub(super) fn paste( 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)); + let tol_a = (a.size() * 1e-3).max(1e-3).max(a.size() * 1e-6); + let tol_b = (b.size() * 1e-3).max(1e-3).max(b.size() * 1e-6); + let mesh_a = a.display_mesh(tol_a)?; + let mesh_b = b.display_mesh(tol_b)?; + // Precharge all 27 candidates, six possible replacements, their triangle + // distances and the three containment rays before any proof traversal. + proof_work = proof_work.saturating_add(mesh_a.triangles.len().saturating_add(mesh_b.triangles.len()).saturating_mul(27 * 7 * 4)); if proof_work > 50_000_000 { return Err(bad(CMD, "geometry verification exceeds copy work limit")); } @@ -597,7 +659,8 @@ pub(super) fn paste( 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); + let roundoff = 64. * f64::EPSILON * (1. + p.len()); + material_matches &= material_probe_matches(&mesh_a, &mesh_b, p, tol_a + roundoff, tol_b + roundoff); } } } diff --git a/crates/engine/src/cmd/component_clone_tests.rs b/crates/engine/src/cmd/component_clone_tests.rs index b0c59425..c8743937 100644 --- a/crates/engine/src/cmd/component_clone_tests.rs +++ b/crates/engine/src/cmd/component_clone_tests.rs @@ -465,3 +465,57 @@ fn transferred_frozen_face_paint_ignores_consumed_legacy_offset() { let paint = s.doc.appearances.faces.iter().find(|face| face.body == "Foreign (copy)").unwrap(); assert!(paint.point.dist(expected) < 1e-6, "{:?} versus {expected:?}", paint.point); } + +#[test] +fn boundary_material_proof_requires_resolvable_stock_and_void() { + use solvecraft_geom::Vec3; + let a = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().tessellate(1e-3).unwrap(); + let b = solvecraft_kernel::box_solid(Vec3::new(1e-5, 0., 0.), Vec3::new(10.00001, 10., 10.)).unwrap().tessellate(1e-3).unwrap(); + let p = Vec3::new(5e-6, 5., 5.); + assert_ne!(a.contains(p), b.contains(p)); + assert!((super::mesh_distance(&a, p) - 5e-6).abs() < 1e-12); + assert!((super::mesh_distance(&b, p) - 5e-6).abs() < 1e-12); + assert!(super::material_probe_matches(&a, &b, p, 1e-3, 1e-3)); + let shifted = solvecraft_kernel::box_solid(Vec3::new(1., 0., 0.), Vec3::new(11., 10., 10.)).unwrap().tessellate(1e-3).unwrap(); + assert!(!super::material_probe_matches(&a, &shifted, p, 1e-3, 1e-3)); + let thin = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 10., 1e-4)).unwrap().tessellate(1e-6).unwrap(); + let thin_shifted = solvecraft_kernel::box_solid(Vec3::new(0., 0., 1e-4), Vec3::new(10., 10., 2e-4)).unwrap().tessellate(1e-6).unwrap(); + // Every resolvable replacement is void: thin ambiguous material is refused. + assert!(!super::material_probe_matches(&thin, &thin_shifted, Vec3::new(5., 5., 5e-5), 1e-3, 1e-3)); + assert!(!super::material_probe_matches(&a, &b, p, f64::INFINITY, 1e-3)); +} + +#[test] +fn frozen_bore_boundary_copy_preserves_stock_void_and_reopen() { + use solvecraft_geom::Vec3; + let bore = |center: f64| { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":30,"width":20,"height":10,"body_name":"Drilled"})); + run(&mut s, "solid.cylinder", json!({"base":[center,10,0],"radius":4,"height":10,"operation":"cut"})); + s + }; + let mut s = bore(10.); + let source = s.model.state().body("Drilled").unwrap().body.tessellate(1e-3).unwrap(); + // The exact R4 wall passes through x6; a chord may end either side of that + // cardinal angle, so its distance is bounded by the requested sag tolerance. + assert!(super::mesh_distance(&source, Vec3::new(6., 10., 5.)) <= 1e-3); + let shifted = bore(11.).model.state().body("Drilled").unwrap().body.tessellate(1e-3).unwrap(); + let lost = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(30., 20., 10.)).unwrap().tessellate(1e-3).unwrap(); + assert!(!super::material_probe_matches(&source, &shifted, Vec3::new(6.5, 10., 5.), 1e-3, 1e-3)); + assert!(!super::material_probe_matches(&source, &lost, Vec3::new(10., 10., 5.), 1e-3, 1e-3)); + let created = run(&mut s, "component.from_bodies", json!({"bodies":["Drilled"]})); + let part = created["components"][0]["component"].as_u64().unwrap(); + let copied = copy(&mut s, part)["component"].as_u64().unwrap(); + let check = |session: &Session| { + let state = session.model.state(); + let body = state.bodies.iter().find(|body| session.doc.model_body_component(body) == copied).unwrap(); + let mesh = body.body.tessellate(1e-3).unwrap(); + for (point, stock) in + [(Vec3::new(5.5, 10., 5.), true), (Vec3::new(6.5, 10., 5.), false), (Vec3::new(10., 10., 5.), false), (Vec3::new(18., 10., 5.), true)] + { + assert_eq!(mesh.contains(point), stock, "{point:?}"); + } + }; + check(&s); + check(&Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap())); +} diff --git a/crates/engine/src/cmd/face_tests.rs b/crates/engine/src/cmd/face_tests.rs index caadf707..031e7011 100644 --- a/crates/engine/src/cmd/face_tests.rs +++ b/crates/engine/src/cmd/face_tests.rs @@ -100,22 +100,17 @@ fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() { check(&mut s, area * 30.0, hole, 30.0); run(&mut s, "solid.press_pull", json!({"face":[35,-10,15],"distance":5})); check(&mut s, area * 35.0, hole, 35.0); - let height = if hole { - // Curved coincident Cut is an existing kernel limit; it must be atomic. - let before = s.doc.to_json(); - let error = s.execute("solid.press_pull", &json!({"face":[35,-15,15],"distance":-5})).unwrap_err(); - let message = error.to_string(); - assert!(message.contains("takes planar faces only"), "{error}"); - assert!(message.contains("the target has 2 curved faces (cylinder)"), "{error}"); - assert!(message.contains("the tool has 2 curved faces (cylinder)"), "{error}"); - assert_eq!(s.doc.to_json(), before); - check(&mut s, area * 35.0, hole, 35.0); - 35.0 - } else { - run(&mut s, "solid.press_pull", json!({"face":[35,-15,15],"distance":-5})); - check(&mut s, area * 30.0, hole, 30.0); - 30.0 - }; + let before_cut = s.doc.to_json(); + run(&mut s, "solid.press_pull", json!({"face":[35,-15,15],"distance":-5})); + let height = 30.0; + check(&mut s, area * height, hole, height); + let after_cut = s.doc.to_json(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before_cut); + check(&mut s, area * 35.0, hole, 35.0); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.to_json(), after_cut); + check(&mut s, area * height, hole, height); let world = s.world_state(); let occupied = Vec3::new(35.0, 21.0 - height, 15.0); let absent = Vec3::new(35.0, 19.0 - height, 15.0); diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 755fec72..d9ef37b8 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -124,17 +124,9 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> { ProfileSel::All => ps.first()?.region.interior_point(), ProfileSel::Indices { indices } => ps.get(*indices.first()?)?.region.interior_point(), ProfileSel::Points { points } => *points.first()?, - ProfileSel::Curves { loops } => { - let mut want: Vec<&String> = loops.first()?.iter().collect(); - want.sort(); - ps.iter() - .find(|x| { - let mut have: Vec<&String> = x.outer_curves.iter().collect(); - have.sort(); - have == want - })? - .region - .interior_point() + selection @ (ProfileSel::Curves { .. } | ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. }) => { + let indices = solvecraft_doc::profile_selection_indices(ss, &selection).ok()?; + ps.get(*indices.first()?)?.region.interior_point() } }; Some((ss.plane.to_world(q), ss.plane.normal())) @@ -216,9 +208,14 @@ pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result { fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> { let st = s.model.state(); 'sels: for (sketch, sel) in kind.profile_sels_mut() { - let ProfileSel::Indices { indices } = sel else { continue }; let Some(ss) = st.sketch(sketch) else { continue }; - let mut loops = Vec::with_capacity(indices.len()); + if !matches!( + sel, + ProfileSel::Indices { .. } | ProfileSel::Curves { .. } | ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. } + ) { + continue; + } + let indices = solvecraft_doc::profile_selection_indices(ss, sel)?; for i in indices.iter() { let pr = ss .profiles @@ -226,12 +223,39 @@ fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> { .ok_or_else(|| EngineError::Other(format!("no profile {i} in sketch `{}` (it has {})", ss.name, ss.profiles.len())))?; // Two profiles with the same outer curves (one inside the other's holes cannot be): // keep the index then. - if pr.outer_curves.is_empty() || ss.profiles.iter().filter(|q| same_loop(&q.outer_curves, &pr.outer_curves)).count() != 1 { + if !matches!(sel, ProfileSel::WholeBoundaries { .. }) + && (pr.outer_curves.is_empty() || ss.profiles.iter().filter(|q| same_loop(&q.outer_curves, &pr.outer_curves)).count() != 1) + { continue 'sels; } - loops.push(pr.outer_curves.clone()); } - *sel = ProfileSel::Curves { loops }; + let captured = solvecraft_doc::capture_profile_selection(ss, &indices)?; + *sel = match captured { + ProfileSel::OffsetBoundaries { associations } + if associations.iter().any(|id| { + s.doc + .sketch(sketch) + .is_ok_and(|raw| !raw.offsets.iter().filter_map(|offset| offset.topology.as_ref()).any(|topology| topology.id == *id)) + }) => + { + // Legacy upgrade descriptors belong to the solved copy. Persist the + // complete original role IDs instead of an unsaved association ID. + let loops = associations + .iter() + .map(|id| { + ss.sketch + .offsets + .iter() + .filter_map(|offset| offset.topology.as_ref()) + .find(|topology| topology.id == *id) + .map(|topology| topology.roles.iter().map(|role| role.output.clone()).collect()) + .ok_or_else(|| EngineError::Other(format!("offset association `{id}` changed during profile capture"))) + }) + .collect::>>()?; + ProfileSel::Curves { loops } + } + captured => captured, + }; } Ok(()) } @@ -246,6 +270,32 @@ fn same_loop(a: &[String], b: &[String]) -> bool { pub(super) fn profiles(p: &Value, cmd: &str) -> Result { let Some(v) = p.get("profiles") else { return Ok(ProfileSel::All) }; match v { + Value::Object(fields) => { + if fields.get("type").and_then(Value::as_str) == Some("whole_boundaries") { + return solvecraft_doc::whole_profile_selection_from_value(v).map_err(|error| bad(cmd, error.to_string())); + } + let associations = fields + .get("offset_boundaries") + .or_else(|| (fields.get("type").and_then(Value::as_str) == Some("offset_boundaries")).then(|| fields.get("associations")).flatten()) + .and_then(Value::as_array) + .ok_or_else(|| bad(cmd, "profile object must provide `offset_boundaries: [association ids]`"))?; + if associations.is_empty() || associations.len() > 128 { + return Err(bad(cmd, "offset profile selection needs 1 to 128 association ids")); + } + let associations = associations + .iter() + .map(|id| { + id.as_str() + .filter(|id| !id.is_empty() && id.len() <= 512) + .map(str::to_string) + .ok_or_else(|| bad(cmd, "offset association ids must be nonempty strings of at most 512 bytes")) + }) + .collect::>>()?; + if associations.iter().collect::>().len() != associations.len() { + return Err(bad(cmd, "offset association ids must be unique")); + } + Ok(ProfileSel::OffsetBoundaries { associations }) + } Value::String(s) if s.eq_ignore_ascii_case("all") => Ok(ProfileSel::All), Value::Number(n) => Ok(ProfileSel::Indices { indices: vec![n.as_u64().ok_or_else(|| bad(cmd, "profile index"))? as usize] }), Value::Array(a) if a.is_empty() => Err(bad(cmd, "`profiles` is empty")), @@ -1351,3 +1401,15 @@ mod patterns_tests; #[cfg(test)] #[path = "revolve_convention_tests.rs"] mod revolve_convention_tests; + +#[cfg(test)] +#[path = "offset_topology_tests.rs"] +mod offset_topology_tests; + +#[cfg(test)] +#[path = "offset_identity_tests.rs"] +mod offset_identity_tests; + +#[cfg(test)] +#[path = "offset_edit_guard_tests.rs"] +mod offset_edit_guard_tests; diff --git a/crates/engine/src/cmd/offset_edit_guard_tests.rs b/crates/engine/src/cmd/offset_edit_guard_tests.rs new file mode 100644 index 00000000..ec535c09 --- /dev/null +++ b/crates/engine/src/cmd/offset_edit_guard_tests.rs @@ -0,0 +1,183 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use serde_json::{Value, json}; +use solvecraft_doc::{Document, FeatureKind, ProfileSel}; +use solvecraft_geom::Vec2; +use solvecraft_sketch::{CurveKind, Sketch}; + +use crate::{Session, Snapshot}; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}")) +} +fn rounded() -> (Session, u64, String) { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY","name":"Rounded"})); + let sketch = s.active_sketch.unwrap(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-38,-33],"p1":[38,33]})); + let fillet = run(&mut s, "sketch.fillet", json!({"points":["l1.start","l1.end","l2.end","l3.end"],"radius":"1 mm"})); + (s, sketch, fillet["param"].as_str().unwrap().into()) +} +fn offset(s: &mut Session, curve: &str, distance: f64) -> (String, Vec) { + let result = run(s, "sketch.offset", json!({"curves":[curve],"distance":distance,"side":[0,0]})); + (result["association"].as_str().unwrap().into(), result["curves"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().into()).collect()) +} +fn solved(s: &Session, id: u64) -> Sketch { + s.model.state().sketch(id).unwrap().sketch.clone() +} +fn arc_centres(sk: &Sketch, curves: &[String]) -> Vec<(String, usize, Vec2)> { + curves + .iter() + .filter_map(|id| { + let CurveKind::Arc { c, .. } = sk.curves.get(sk.curve_index(id)?)?.kind else { return None }; + Some((id.clone(), c, sk.point(c)?)) + }) + .collect() +} +fn history(stack: &[Snapshot]) -> Vec<(String, String, Option, String)> { + stack.iter().map(|entry| (entry.label.clone(), entry.doc.to_json(), entry.active_sketch, format!("{:?}", entry.pending_moves))).collect() +} +fn refuses_atomically(s: &mut Session, command: &str, params: Value) -> String { + let document = s.doc.to_json(); + let undo = history(&s.undo); + let redo = history(&s.redo); + let active = s.active_sketch; + let error = s.execute(command, ¶ms).unwrap_err().to_string(); + assert_eq!(s.doc.to_json(), document); + assert_eq!(history(&s.undo), undo); + assert_eq!(history(&s.redo), redo); + assert_eq!(s.active_sketch, active); + error +} + +#[test] +fn offset_guard_retired_parent_refuses_new_child_without_destroying_redo() { + let (mut s, sketch, _) = rounded(); + let (_, outputs) = offset(&mut s, "l1", 1.4); + assert_eq!(outputs.len(), 4); + assert_eq!(solved(&s, sketch).offsets[0].topology.as_ref().unwrap().roles.iter().filter(|r| r.retired).count(), 4); + run(&mut s, "sketch.point", json!({"point":[100,100]})); + run(&mut s, "edit.undo", json!({})); + assert!(!s.redo.is_empty()); + let error = refuses_atomically(&mut s, "sketch.offset", json!({"curves":[outputs[0]],"distance":0.2,"side":[0,0]})); + assert!(error.contains("retired corner") && error.contains("break its offset association"), "{error}"); +} + +#[test] +fn offset_guard_live_child_follows_radius_then_retirement_is_honest() { + let (mut s, sketch, radius) = rounded(); + let (_, parent) = offset(&mut s, "l1", 0.2); + let (child_association, child) = offset(&mut s, &parent[0], 0.2); + let arc_radius = |s: &Session| { + let sk = solved(s, sketch); + child + .iter() + .filter_map(|id| { + let CurveKind::Arc { c, a, .. } = sk.curves.get(sk.curve_index(id)?)?.kind else { return None }; + Some(sk.point(c)?.dist(sk.point(a)?)) + }) + .collect::>() + }; + assert_eq!(arc_radius(&s).len(), 4); + assert!(arc_radius(&s).iter().all(|r| (*r - 0.6).abs() < 1e-6)); + run(&mut s, "parameters.change", json!({"name":radius,"expression":"2 mm"})); + assert!(s.model.results.iter().all(|r| r.error.is_none())); + assert!(arc_radius(&s).iter().all(|r| (*r - 1.6).abs() < 1e-6)); + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "solid.extrude", + json!({"sketch":sketch,"profiles":{"offset_boundaries":[child_association]},"distance":2,"operation":"new","body_name":"Child"}), + ); + let before = s.doc.to_json(); + let undo = history(&s.undo); + let redo = history(&s.redo); + match s.execute("parameters.change", &json!({"name":radius,"expression":"0.1 mm"})) { + Err(error) => { + assert!(error.to_string().contains("offset"), "{error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(history(&s.undo), undo); + assert_eq!(history(&s.redo), redo); + } + Ok(_) => { + assert!(s.model.results.iter().any(|r| r.error.is_some()), "retired parent must not keep a stale successful child"); + assert!(s.world_state().body("Child").is_none()); + let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.results.iter().any(|r| r.error.is_some())); + assert!(reopened.world_state().body("Child").is_none()); + } + } +} + +#[test] +fn offset_guard_partial_delete_requires_break_and_surviving_arc_centres_detach() { + let (mut s, sketch, _) = rounded(); + let (association, outputs) = offset(&mut s, "l1", 0.2); + let error = refuses_atomically(&mut s, "sketch.delete", json!({"entities":[outputs[0]]})); + assert!(error.contains("break the offset association"), "{error}"); + let source = solved(&s, sketch); + let source_ids: Vec<_> = source.curves.iter().filter(|c| !outputs.contains(&c.id)).map(|c| c.id.clone()).collect(); + let source_centres = arc_centres(&source, &source_ids); + assert_eq!(source_centres.len(), 4); + run(&mut s, "sketch.offset.break", json!({"curve":association})); + let released = solved(&s, sketch); + let independent = arc_centres(&released, &outputs); + assert_eq!(independent.len(), 4); + assert!(independent.iter().all(|(_, centre, _)| source_centres.iter().all(|(_, source, _)| centre != source))); + // Released geometry is editable: anchor its new centres independently, then + // move a source centre. Shared source nodes would block that actual move. + let released_centres: Vec<_> = independent.iter().map(|(id, _, _)| format!("{id}.center")).collect(); + run(&mut s, "sketch.constraint.fix", json!({"entities":released_centres,"fixed":true})); + run(&mut s, "sketch.delete", json!({"entities":[outputs[0]]})); + let deleted = solved(&s, sketch); + assert!(deleted.curve_index(&outputs[0]).is_none()); + let survivors = arc_centres(&deleted, &outputs); + let (source_id, _, position) = source_centres.first().unwrap(); + let target = *position + Vec2::new(5.0, 5.0); + run(&mut s, "sketch.move_point", json!({"point":format!("{source_id}.center"),"to":[target.x,target.y]})); + assert!(s.model.results.iter().all(|r| r.error.is_none())); + let changed = solved(&s, sketch); + for (id, _, position) in independent { + let after = arc_centres(&changed, std::slice::from_ref(&id)); + assert_eq!(after.len(), 1); + assert!( + after[0].2.dist(position) < 1e-8, + "{id}: released {position:?}, after deletion {:?}, after source move {:?}; deleted curve {}", + survivors.iter().find(|(curve, _, _)| curve == &id), + after[0], + outputs[0] + ); + } + let moved = arc_centres(&changed, std::slice::from_ref(source_id)); + assert_eq!(moved.len(), 1); + assert!(moved[0].2.dist(target) < 1e-8); + assert!(moved[0].2.dist(*position) > 5.0); +} + +#[test] +fn offset_guard_complete_delete_keeps_dangling_profile_loadable_without_circle_retarget() { + let (mut s, sketch, _) = rounded(); + let (association, outputs) = offset(&mut s, "l1", 0.2); + run(&mut s, "sketch.circle.center", json!({"center":[100,0],"radius":3,"id":"Unrelated"})); + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "solid.extrude", + json!({"sketch":sketch,"profiles":{"offset_boundaries":[association]},"distance":2,"operation":"new","name":"OffsetBody","body_name":"Selected"}), + ); + run(&mut s, "sketch.edit", json!({"sketch":sketch})); + run(&mut s, "sketch.delete", json!({"entities":outputs})); + let saved = s.doc.to_json(); + let FeatureKind::Extrude { profiles: ProfileSel::OffsetBoundaries { associations }, .. } = &s.doc.find_feature("OffsetBody").unwrap().kind else { + panic!("explicit offset identity lost") + }; + assert_eq!(associations, &vec![association]); + let check = |s: &Session| { + assert!(solved(s, sketch).curve_index("Unrelated").is_some()); + assert!(s.model.results.iter().find(|r| r.id == s.doc.find_feature("OffsetBody").unwrap().id).unwrap().error.is_some()); + assert!(s.world_state().body("Selected").is_none(), "dangling selection must never adopt the unrelated circle"); + }; + check(&s); + let reopened = Session::new(Document::from_json(&saved).unwrap()); + check(&reopened); + assert_eq!(reopened.doc.to_json(), saved); +} diff --git a/crates/engine/src/cmd/offset_identity_tests.rs b/crates/engine/src/cmd/offset_identity_tests.rs new file mode 100644 index 00000000..90be0ab9 --- /dev/null +++ b/crates/engine/src/cmd/offset_identity_tests.rs @@ -0,0 +1,136 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use serde_json::{Value, json}; +use solvecraft_doc::{Document, FeatureKind, ProfileSel}; + +use crate::Session; + +fn run(session: &mut Session, command: &str, params: Value) -> Value { + session.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}")) +} + +fn rounded_offset(radius: f64) -> (Session, u64, String, String) { + let mut session = Session::default(); + run(&mut session, "sketch.create", json!({"plane":"XY","name":"Rounded"})); + let sketch = session.active_sketch.unwrap(); + run(&mut session, "sketch.rectangle.two_point", json!({"p0":[-37.-radius,-32.-radius],"p1":[37.+radius,32.+radius]})); + let fillet = run(&mut session, "sketch.fillet", json!({"points":["l1.start","l1.end","l2.end","l3.end"],"radius":radius})); + // The radius is the only intended source freedom: keep the four exact arc + // centres at (+/-37,+/-32), independently of the corner radius. + let centers = fillet["curves"].as_array().unwrap().iter().map(|id| format!("{}.center", id.as_str().unwrap())).collect::>(); + run(&mut session, "sketch.constraint.fix", json!({"entities":centers,"fixed":true})); + let offset = run(&mut session, "sketch.offset", json!({"curves":["l1"],"distance":1.4,"side":[0,0]})); + run(&mut session, "sketch.finish", json!({})); + (session, sketch, fillet["param"].as_str().unwrap().into(), offset["association"].as_str().unwrap().into()) +} + +fn volume(session: &Session, feature: u64) -> f64 { + let state = session.model.state(); + let body = state.bodies.iter().find(|body| body.feature == feature).unwrap(); + solvecraft_kernel::measure(&body.body).unwrap().volume +} + +fn healthy(session: &Session) { + assert!(session.model.results.iter().all(|result| result.error.is_none()), "{:?}", session.model.results); +} + +#[test] +fn legacy_offset_capture_persists_reserved_curves_without_a_transient_association() { + let (session, sketch, radius, _) = rounded_offset(2.); + let mut document = (*session.doc).clone(); + let raw = document.sketch_mut(sketch).unwrap(); + let reserved = raw.offsets[0].outputs.clone(); + assert_eq!(reserved.len(), 8); + raw.offsets[0].topology = None; + document.format = "solvecraft/11".into(); + let mut session = Session::new(Document::from_json(&document.to_json()).unwrap()); + assert!(session.doc.sketch(sketch).unwrap().offsets[0].topology.is_none()); + let state = session.model.state(); + let solved = state.sketch(sketch).unwrap(); + assert!(solved.sketch.offsets[0].topology.is_some(), "legacy upgrade belongs only to the evaluated copy"); + let index = solvecraft_doc::profile_selection_indices(solved, &ProfileSel::Curves { loops: vec![reserved.clone()] }).unwrap()[0]; + let extrusion = run(&mut session, "solid.extrude", json!({"sketch":sketch,"profiles":[index],"distance":2,"name":"Legacy inner"}))["feature"] + .as_u64() + .unwrap(); + assert!( + matches!(&session.doc.feature(extrusion).unwrap().kind,FeatureKind::Extrude {profiles:ProfileSel::Curves {loops},..} if loops == &vec![reserved.clone()]) + ); + assert!(session.doc.sketch(sketch).unwrap().offsets[0].topology.is_none()); + run(&mut session, "parameters.change", json!({"name":radius,"expression":"1 mm"})); + healthy(&session); + assert_eq!(session.model.state().sketch(sketch).unwrap().sketch.offsets[0].outputs.len(), 4); + assert!( + (volume(&session, extrusion) - 73.2 * 63.2 * 2.).abs() < 1e-6, + "legacy sharp volume {} bounds {:?}", + volume(&session, extrusion), + session.model.state().bodies.last().unwrap().mesh().bounds() + ); + let saved = session.doc.to_json(); + let mut reopened = Session::new(Document::from_json(&saved).unwrap()); + healthy(&reopened); + assert_eq!(reopened.doc.to_json(), saved); + assert!(reopened.doc.sketch(sketch).unwrap().offsets[0].topology.is_none()); + assert!((volume(&reopened, extrusion) - 73.2 * 63.2 * 2.).abs() < 1e-6); + run(&mut reopened, "parameters.change", json!({"name":radius,"expression":"2 mm"})); + healthy(&reopened); + let state = reopened.model.state(); + assert_eq!(state.sketch(sketch).unwrap().sketch.offsets[0].outputs, reserved); + let expected = 2. * (75.2 * 65.2 - (4. - std::f64::consts::PI) * 0.6 * 0.6); + assert!((volume(&reopened, extrusion) - expected).abs() < 0.002); +} + +#[test] +fn pasted_offset_boundary_has_scoped_identity_and_independent_dimensions_and_material() { + let (mut session, sketch, radius, association) = rounded_offset(1.); + let extrusion = run( + &mut session, + "solid.extrude", + json!({"sketch":sketch,"profiles":{"offset_boundaries":[association]},"distance":2,"name":"Inner"}), + )["feature"] + .as_u64() + .unwrap(); + let source_body = session.model.state().bodies[0].name.clone(); + run(&mut session, "material.assign", json!({"bodies":[source_body],"material":"Brass"})); + run(&mut session, "timeline.copy", json!({"features":[sketch,extrusion]})); + let pasted = run(&mut session, "timeline.paste", json!({"translate":[100,0,0]})); + assert_eq!(pasted["errors"], json!([])); + let copied_sketch = pasted["pasted"][0]["id"].as_u64().unwrap(); + let copied_extrusion = pasted["pasted"][1]["id"].as_u64().unwrap(); + assert_ne!(copied_sketch, sketch); + assert!( + matches!(&session.doc.feature(copied_extrusion).unwrap().kind,FeatureKind::Extrude {sketch:source,profiles:ProfileSel::OffsetBoundaries {associations},..} if *source==copied_sketch && associations == &vec![association.clone()]) + ); + let source = session.doc.sketch(sketch).unwrap(); + let copy = session.doc.sketch(copied_sketch).unwrap(); + assert_eq!(source.offsets[0].topology.as_ref().unwrap().id, copy.offsets[0].topology.as_ref().unwrap().id); + let dimension = source.constraints.iter().position(|constraint| constraint.param.as_deref() == Some(&radius)).unwrap(); + let copied_radius = copy.constraints[dimension].param.clone().unwrap(); + assert_ne!(copied_radius, radius); + let copied_body = session.model.state().bodies.iter().find(|body| body.feature == copied_extrusion).unwrap().name.clone(); + run(&mut session, "material.assign", json!({"bodies":[copied_body],"material":"Aluminum"})); + let sharp = 73.2 * 63.2 * 2.; + assert!((volume(&session, extrusion) - sharp).abs() < 1e-6); + assert!((volume(&session, copied_extrusion) - sharp).abs() < 1e-6); + run(&mut session, "parameters.change", json!({"name":copied_radius,"expression":"2 mm"})); + healthy(&session); + let state = session.model.state(); + assert_eq!(state.sketch(sketch).unwrap().sketch.offsets[0].outputs.len(), 4); + assert_eq!(state.sketch(copied_sketch).unwrap().sketch.offsets[0].outputs.len(), 8); + assert!((volume(&session, extrusion) - sharp).abs() < 1e-6); + let rounded = 2. * (75.2 * 65.2 - (4. - std::f64::consts::PI) * 0.6 * 0.6); + assert!( + (volume(&session, copied_extrusion) - rounded).abs() < 0.002, + "copied rounded volume {} expected {rounded} bounds {:?}", + volume(&session, copied_extrusion), + session.model.state().bodies.last().unwrap().mesh().bounds() + ); + assert_eq!(session.doc.materials.get(&source_body).map(String::as_str), Some("Brass")); + assert_eq!(session.doc.materials.get(&copied_body).map(String::as_str), Some("Aluminum")); + let reopened = Session::new(Document::from_json(&session.doc.to_json()).unwrap()); + healthy(&reopened); + assert!((volume(&reopened, extrusion) - sharp).abs() < 1e-6); + assert!((volume(&reopened, copied_extrusion) - rounded).abs() < 0.002); + let state = reopened.world_state(); + let bounds = state.body(&copied_body).unwrap().mesh().bounds(); + assert!((bounds.min.x - (100. - 37.6)).abs() < 1e-6); + assert!((bounds.max.x - (100. + 37.6)).abs() < 1e-6); +} diff --git a/crates/engine/src/cmd/offset_topology_tests.rs b/crates/engine/src/cmd/offset_topology_tests.rs new file mode 100644 index 00000000..e1f01eed --- /dev/null +++ b/crates/engine/src/cmd/offset_topology_tests.rs @@ -0,0 +1,109 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use serde_json::{Value, json}; +use solvecraft_doc::{FeatureKind, ProfileSel}; +use solvecraft_geom::Vec3; + +use crate::Session; + +fn run(session: &mut Session, command: &str, params: Value) -> Value { + session.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}")) +} + +#[test] +fn breaking_offset_preserves_downstream_material_history_and_source_independence() { + for distance in [0.2, 1.4] { + let (mut session, sketch, radius) = rounded_source(); + let offset = run(&mut session, "sketch.offset", json!({"curves":["l1"],"distance":distance,"side":[0,0]})); + let association = offset["association"].as_str().unwrap().to_string(); + let curves = session.model.state().sketch(sketch).unwrap().sketch.offset_boundary(&association).unwrap(); + run(&mut session, "sketch.finish", json!({})); + run(&mut session, "solid.extrude", json!({"sketch":sketch,"profiles":{"offset_boundaries":[association]},"distance":2,"operation":"new"})); + let before_volume = session.world_state().bodies.last().unwrap().mesh().measure().volume.abs(); + run(&mut session, "sketch.edit", json!({"sketch":sketch})); + let before = session.doc.to_json(); + let history = session.undo.len(); + run(&mut session, "sketch.offset.break", json!({"curve":association})); + assert_eq!(session.undo.len(), history + 1); + assert!(session.model.results.iter().all(|result| result.error.is_none())); + assert!( + matches!(&session.doc.features.last().unwrap().kind, FeatureKind::Extrude { profiles: ProfileSel::Curves { loops }, .. } if loops == &vec![curves.clone()]) + ); + let frozen = session.doc.to_json(); + assert!((session.world_state().bodies.last().unwrap().mesh().measure().volume.abs() - before_volume).abs() < 1e-6); + run(&mut session, "edit.undo", json!({})); + assert_eq!(session.doc.to_json(), before); + run(&mut session, "edit.redo", json!({})); + assert_eq!(session.doc.to_json(), frozen); + run(&mut session, "parameters.change", json!({"name":radius,"expression":"2 mm"})); + assert!(session.model.results.iter().all(|result| result.error.is_none())); + assert!((session.world_state().bodies.last().unwrap().mesh().measure().volume.abs() - before_volume).abs() < 1e-6); + let reopened = Session::new(solvecraft_doc::Document::from_json(&session.doc.to_json()).unwrap()); + assert!(reopened.model.results.iter().all(|result| result.error.is_none())); + assert!((reopened.world_state().bodies.last().unwrap().mesh().measure().volume.abs() - before_volume).abs() < 1e-6); + } +} + +fn rounded_source() -> (Session, u64, String) { + let mut session = Session::default(); + run(&mut session, "sketch.create", json!({"plane":"XY","name":"Rounded"})); + let sketch = session.active_sketch.unwrap(); + run(&mut session, "sketch.rectangle.two_point", json!({"p0":[-38,-33],"p1":[38,33]})); + let fillet = run(&mut session, "sketch.fillet", json!({"points":["l1.start","l1.end","l2.end","l3.end"],"radius":"1 mm"})); + (session, sketch, fillet["param"].as_str().unwrap().to_string()) +} + +#[test] +fn inset_past_corner_radius_creates_a_real_boundary_and_saved_profile_identity() { + let (mut session, sketch, _) = rounded_source(); + let offset = run(&mut session, "sketch.offset", json!({"curves":["l1"],"distance":1.4,"side":[0,0]})); + assert_eq!(offset["curves"].as_array().unwrap().len(), 4); + let state = session.model.state(); + let solved = state.sketch(sketch).unwrap(); + let record = solved.sketch.offsets.last().unwrap(); + let topology = record.topology.as_ref().unwrap(); + let association = topology.id.clone(); + assert_eq!(topology.roles.len(), 8); + assert_eq!(topology.roles.iter().filter(|role| role.retired).count(), 4); + assert_eq!(solved.sketch.offset_boundary(&association).unwrap(), record.outputs); + assert!(topology.roles.iter().filter(|role| role.retired).all(|role| solved.sketch.curve_index(&role.output).is_none())); + run(&mut session, "sketch.finish", json!({})); + run( + &mut session, + "solid.extrude", + json!({"sketch":sketch,"profiles":{"offset_boundaries":[association]},"distance":2,"operation":"new","name":"Inner"}), + ); + let feature = session.doc.features.last().unwrap(); + assert!(matches!(&feature.kind, FeatureKind::Extrude { profiles: ProfileSel::OffsetBoundaries { associations }, .. } if associations.len() == 1)); + let check = |session: &Session| { + assert!(session.model.results.iter().all(|result| result.error.is_none())); + let state = session.world_state(); + let body = state.bodies.last().unwrap(); + // The exact inward parallel set is a sharp 73.2 by 63.2 rectangle. + let mesh = body.mesh(); + assert!((mesh.measure().volume.abs() - 73.2 * 63.2 * 2.0).abs() < 1e-6); + assert!(mesh.contains(Vec3::new(36.5, 31.5, 1.0))); + assert!(!mesh.contains(Vec3::new(36.7, 31.5, 1.0))); + assert!(!mesh.contains(Vec3::new(36.5, 31.7, 1.0))); + }; + check(&session); + let saved = session.doc.to_json(); + let reopened = Session::new(solvecraft_doc::Document::from_json(&saved).unwrap()); + check(&reopened); + assert_eq!(reopened.doc.to_json(), saved); + let undo = session.undo.len(); + let redo = session.redo.len(); + for params in [ + json!({"profiles":{"offset_boundaries":[]}}), + json!({"profiles":{"offset_boundaries":["missing"]}}), + json!({"profiles":{"offset_boundaries":[association,association]}}), + json!({"profiles":[[record.outputs[0],record.outputs[1],record.outputs[2]]]}), + ] { + let mut params = params; + params["sketch"] = json!(sketch); + params["distance"] = json!(1); + assert!(session.execute("solid.extrude", ¶ms).is_err()); + assert_eq!(session.doc.to_json(), saved); + assert_eq!(session.undo.len(), undo); + assert_eq!(session.redo.len(), redo); + } +} diff --git a/crates/engine/src/cmd/plastic.rs b/crates/engine/src/cmd/plastic.rs index a62a09a1..86e333e1 100644 --- a/crates/engine/src/cmd/plastic.rs +++ b/crates/engine/src/cmd/plastic.rs @@ -267,6 +267,7 @@ fn snap_fit(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod tests { + use solvecraft_geom::Vec3; use std::f64::consts::PI; use serde_json::{Value, json}; @@ -352,6 +353,40 @@ mod tests { assert!(s.execute("plastic.snap_fit", &json!({"position": [5, 5, 4], "direction": [0, 0, 1], "hook": [0, 0, 1], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3})).is_err()); } + #[test] + fn enclosure_snap_fit_follows_resized_front_wall() { + let mut s = Session::default(); + let script: Value = serde_json::from_str(include_str!("../../../../examples/acceptance/sensor_enclosure.json")).unwrap(); + s.run_script(&script).unwrap(); + let feature = s.doc.find_feature("SnapFit1").unwrap().clone(); + run(&mut s, "parameters.change", json!({"name":"board_d","expression":"50 mm"})); + let before = s.model.state_before(feature.id); + let name = feature.face_names.first().unwrap(); + let point = solvecraft_doc::naming::point_on_face(&before, name, Vec3::new(0., -28., 20.)) + .expect("the original wall identity survives merged pieces") + .0; + assert!((point - Vec3::new(0., -33., 20.)).len() < 1e-8, "{point:?}"); + assert!(s.model.results.iter().all(|result| result.error.is_none())); + let check = |session: &Session| { + let before = session.model.state_before(feature.id); + let after = &session.model.result(feature.id).unwrap().state; + let old = before.body("Case").unwrap(); + let body = after.body("Case").unwrap(); + let added = solvecraft_kernel::measure(&body.body).unwrap().volume - solvecraft_kernel::measure(&old.body).unwrap().volume; + // Rectangular arm + triangular catch; the small attachment overlap lies inside the wall. + assert!((added - (8. * 1.2 + 0.8 * 1.5 / 2.) * 6.).abs() < 0.05, "{added}"); + let mesh = body.mesh(); + assert!(mesh.contains(Vec3::new(0., -29., 19.4)), "the arm follows the inner wall"); + assert!(mesh.contains(Vec3::new(0., -25.9, 20.2)), "the catch follows the arm"); + assert!(!mesh.contains(Vec3::new(0., -22., 19.4)), "the old arm placement is empty"); + assert!(!mesh.contains(Vec3::new(0., -24., 19.4)), "the arm ends at its specified length"); + }; + check(&s); + let cold = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(cold.model.results.iter().all(|result| result.error.is_none())); + check(&cold); + } + fn plate() -> (Session, f64) { let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 60, "width": 60, "height": 3})); diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 0c5fd39e..1b185501 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -1103,3 +1103,458 @@ fn ring_cut_preserves_retained_island_through_unfold_and_refold() { assert!((volume(&mut s) - expected).abs() < 0.2); material_probes(&s); } + +fn retained_depth_check(s: &mut Session, want: f64, z0: f64, z1: f64) { + use solvecraft_geom::Vec3; + assert!((volume(s) - want).abs() < 1e-6, "{} vs {want}", volume(s)); + let state = s.model.state(); + assert_eq!(state.sheets.len(), 1); + assert_eq!(state.sheets[0].t, 2.5, "rule stock gauge is preserved"); + assert!(state.bodies.iter().any(|b| b.body.contains(Vec3::new(25.0, 25.0, (z0 + z1) * 0.5)))); + assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(25.0, 25.0, z0 - 0.1)))); + assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(25.0, 25.0, z1 + 0.1)))); + assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(10.0, 25.0, (z0 + z1) * 0.5)))); + let bounds = state.bodies[0].mesh().bounds(); + assert!((bounds.min.z - z0).abs() < 1e-8 && (bounds.max.z - z1).abs() < 1e-8, "{bounds:?}"); +} + +fn depth_step_reopen(s: &mut Session, suffix: &str, want: f64, inside: solvecraft_geom::Vec3, outside: solvecraft_geom::Vec3) { + let path = std::env::temp_dir().join(format!("solvecraft-depth-intersect-{suffix}-{}.step", std::process::id())); + run(s, "file.export", json!({"path":path.to_string_lossy()})); + let mut imported = Session::default(); + run(&mut imported, "doc.open", json!({"path":path.to_string_lossy()})); + assert!((volume(&mut imported) - want).abs() < 1e-6); + assert!(imported.model.state().bodies.iter().any(|b| b.body.contains(inside))); + assert!(!imported.model.state().bodies.iter().any(|b| b.body.contains(outside))); + std::fs::remove_file(path).unwrap(); +} + +/// The generic sketch importer intentionally drops DXF layers/depth. Reimport only the +/// material outline at a separately known depth, never pretend all entities reconstruct3D. +fn dxf_outline_only(text: &str) -> String { + let lines: Vec<_> = text.lines().collect(); + let pairs: Vec<_> = lines.as_chunks::<2>().0.iter().filter_map(|p| Some((p.first()?.trim(), *p.get(1)?))).collect(); + let mut out = String::from("0\nSECTION\n2\nENTITIES\n"); + let mut start = 0; + for end in 1..=pairs.len() { + if end == pairs.len() || pairs.get(end).is_some_and(|p| p.0 == "0") { + let entity = &pairs[start..end]; + if entity.first().is_some_and(|p| matches!(p.1, "LINE" | "ARC" | "CIRCLE")) && entity.iter().any(|p| p.0 == "8" && p.1 == "OUTLINE") { + for (code, value) in entity { + out.push_str(&format!("{code}\n{value}\n")); + } + } + start = end; + } + } + out.push_str("0\nENDSEC\n0\nEOF\n"); + out +} + +#[test] +fn partial_depth_intersect_retains_bottom_top_and_middle_material_through_history_and_exports() { + use solvecraft_geom::Vec3; + for (offset, z0, z1, layers) in [ + (0.0, 0.0, 1.0, vec![("top", 1.0, 2.5)]), + (1.5, 1.5, 2.5, vec![("bottom", 0.0, 1.5)]), + (0.5, 0.5, 1.5, vec![("bottom", 0.0, 0.5), ("top", 1.5, 2.5)]), + ] { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "RetainDepth", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"RetainDepth","distance":1,"start_offset":offset,"operation":"intersect","name":"RetainSlab"})); + retained_depth_check(&mut s, 100.0, z0, z1); + let exported = flat(&mut s); + assert_eq!(exported["thickness"], 2.5); + assert_eq!(exported["pockets"].as_array().unwrap().len(), layers.len()); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + for (side, a, b) in &layers { + assert!( + exported["pockets"] + .as_array() + .unwrap() + .iter() + .any(|p| p["side"] == *side && p["z0_mm"] == *a && p["z1_mm"] == *b && p["depth_mm"] == b - a) + ); + assert!(dxf.contains(&format!("POCKET_{}_Z{a:.6}_TO_{b:.6}_MM", side.to_uppercase()))); + } + let outline = dxf_outline_only(&dxf); + assert_eq!(solvecraft_io::read_dxf(&outline).unwrap().len(), 4); + let mut outline_import = Session::default(); + run(&mut outline_import, "sketch.insert_dxf", json!({"text":outline,"name":"Outline"})); + run(&mut outline_import, "sketch.finish", json!({})); + run(&mut outline_import, "solid.extrude", json!({"distance":1})); + assert!((volume(&mut outline_import) - 100.0).abs() < 1e-6); + run(&mut s, "edit.undo", json!({})); + assert!((volume(&mut s) - 15000.0).abs() < 1e-6); + run(&mut s, "edit.redo", json!({})); + for command in ["sheet.unfold", "sheet.refold"] { + run(&mut s, command, json!({})); + retained_depth_check(&mut s, 100.0, z0, z1); + assert_eq!(flat(&mut s)["pockets"], exported["pockets"]); + } + let mut reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + retained_depth_check(&mut reopened, 100.0, z0, z1); + depth_step_reopen(&mut s, &format!("offset{offset}"), 100.0, Vec3::new(25.0, 25.0, (z0 + z1) * 0.5), Vec3::new(25.0, 25.0, z1 + 0.1)); + } +} + +#[test] +fn partial_depth_intersect_preserves_prior_pockets_and_actual_interval_intersections() { + use solvecraft_geom::Vec3; + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "OldPocket", [20.0, 20.0], [25.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"OldPocket","distance":1,"operation":"cut"})); + rectangle_tool(&mut s, "RetainDepth", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"RetainDepth","distance":1,"start_offset":0.5,"operation":"intersect"})); + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + assert!((volume(&mut s) - 75.0).abs() < 1e-6); + let state = s.model.state(); + let body = &state.bodies[0].body; + assert!(!body.contains(Vec3::new(22.0, 25.0, 0.75))); + assert!(body.contains(Vec3::new(22.0, 25.0, 1.25))); + assert!(body.contains(Vec3::new(27.0, 25.0, 0.75))); + assert!(!body.contains(Vec3::new(27.0, 25.0, 0.25))); + } + let records = s.model.state().sheets[0].pockets.clone(); + run(&mut s, "solid.extrude", json!({"sketch":"RetainDepth","distance":1.5,"start_offset":0.25,"operation":"intersect"})); + assert!((volume(&mut s) - 75.0).abs() < 1e-6); + assert_eq!(s.model.state().sheets[0].pockets, records, "no-change leaves replay work unchanged"); + run(&mut s, "solid.extrude", json!({"sketch":"RetainDepth","distance":0.25,"start_offset":1.0,"operation":"intersect"})); + assert!((volume(&mut s) - 25.0).abs() < 1e-6); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - 25.0).abs() < 1e-6); + let mut cold = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!((volume(&mut cold) - 25.0).abs() < 1e-6); + depth_step_reopen(&mut s, "prior", 25.0, Vec3::new(22.0, 25.0, 1.125), Vec3::new(22.0, 25.0, 0.75)); +} + +#[test] +fn partial_depth_intersect_keeps_disconnected_tool_islands_and_annular_curves() { + use solvecraft_geom::Vec3; + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Islands", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "sketch.edit", json!({"sketch":"Islands"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[60,20],"p1":[70,30]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch":"Islands","distance":1,"operation":"intersect"})); + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + assert!((volume(&mut s) - 200.0).abs() < 1e-6); + assert_eq!(s.model.state().sheets[0].flat_regions().unwrap().len(), 2); + for x in [25.0, 65.0] { + assert!(s.model.state().bodies[0].body.contains(Vec3::new(x, 25.0, 0.5))); + } + assert!(!s.model.state().bodies[0].body.contains(Vec3::new(45.0, 25.0, 0.5))); + } + depth_step_reopen(&mut s, "islands", 200.0, Vec3::new(65.0, 25.0, 0.5), Vec3::new(45.0, 25.0, 0.5)); + let mut ring = Session::default(); + base(&mut ring, 100.0, 60.0); + run(&mut ring, "sketch.create", json!({"plane":"XY","name":"Ring"})); + for radius in [10.0, 5.0] { + run(&mut ring, "sketch.circle.center", json!({"center":[50,30],"radius":radius})); + } + run(&mut ring, "sketch.finish", json!({})); + run(&mut ring, "solid.extrude", json!({"sketch":"Ring","profiles":[{"point":[57,30]}],"distance":1,"operation":"intersect"})); + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut ring, command, json!({})); + } + let state = ring.model.state(); + let area: f64 = state.sheets[0].flat_regions().unwrap().iter().map(|r| r.area()).sum(); + assert!((area - 75.0 * PI).abs() < 1e-8); + assert!((volume(&mut ring) - 75.0 * PI).abs() < 0.1); + assert!(state.bodies[0].body.contains(Vec3::new(57.0, 30.0, 0.5))); + assert!(!state.bodies[0].body.contains(Vec3::new(50.0, 30.0, 0.5))); + assert!(!state.bodies[0].body.contains(Vec3::new(57.0, 30.0, 1.5))); + } + let dxf = run(&mut ring, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + assert!(dxf.contains("POCKET_TOP_Z1.000000_TO_2.500000_MM")); + assert_eq!(solvecraft_io::read_dxf(&dxf_outline_only(&dxf)).unwrap().len(), 2); +} + +#[test] +fn partial_depth_intersect_deletes_material_already_removed_by_a_pocket_atomically() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Removed", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Removed","distance":1,"operation":"cut"})); + run(&mut s, "solid.extrude", json!({"sketch":"Removed","distance":1,"operation":"intersect"})); + assert!(s.model.state().bodies.is_empty()); + assert!(s.model.state().sheets.is_empty()); + run(&mut s, "edit.undo", json!({})); + assert!((volume(&mut s) - 14900.0).abs() < 1e-6); + run(&mut s, "edit.redo", json!({})); + assert!(s.model.state().bodies.is_empty()); +} + +#[test] +fn partial_depth_intersect_flipped_tool_and_transferred_sheet_keep_depth_side_and_world_pose() { + use solvecraft_geom::Vec3; + let mut flipped = Session::default(); + base(&mut flipped, 100.0, 60.0); + run(&mut flipped, "sketch.create", json!({"name":"FlippedTool","plane":{"origin":[0,0,2.5],"x_dir":[1,0,0],"y_dir":[0,-1,0]}})); + run(&mut flipped, "sketch.rectangle.two_point", json!({"p0":[20,-30],"p1":[30,-20]})); + run(&mut flipped, "sketch.finish", json!({})); + run(&mut flipped, "solid.extrude", json!({"sketch":"FlippedTool","distance":1,"operation":"intersect"})); + retained_depth_check(&mut flipped, 100.0, 1.5, 2.5); + assert_eq!(flat(&mut flipped)["pockets"][0]["side"], "bottom"); + assert_eq!(flat(&mut flipped)["pockets"][0]["depth_mm"], 1.5); + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "RetainDepth", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"RetainDepth","distance":1,"start_offset":0.5,"operation":"intersect","name":"RetainSlab"})); + let before = s.world_state().bodies[0].mesh().bounds(); + let name = s.model.state().sheets[0].body.clone(); + let destination = run(&mut s, "component.create", json!({"name":"Destination","parent":0})); + let owner = destination["component"].as_u64().unwrap(); + let occurrence = destination["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":"90 deg","capture":true})); + run(&mut s, "component.move_bodies", json!({"component":owner,"bodies":[name]})); + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + let state = s.model.state(); + let sh = &state.sheets[0]; + assert_eq!(sh.t, 2.5); + assert!(sh.frame.origin.dist(Vec3::new(-30.0, -10.0, 20.0)) < 1e-8); + assert!(sh.frame.normal().dist(Vec3::Y) < 1e-8); + assert_eq!(sh.pockets.len(), 2); + assert_eq!(state.body(&name).unwrap().component, owner); + assert!(state.body(&name).unwrap().transferred); + let world = s.world_state(); + let body = &world.body(&name).unwrap().body; + assert!(body.contains(Vec3::new(25.0, 25.0, 1.0))); + assert!(!body.contains(Vec3::new(25.0, 25.0, 0.25))); + assert!(!body.contains(Vec3::new(25.0, 25.0, 2.0))); + let bounds = world.body(&name).unwrap().mesh().bounds(); + assert!(before.min.dist(bounds.min) < 1e-8 && before.max.dist(bounds.max) < 1e-8); + assert!((solvecraft_kernel::measure(body).unwrap().volume - 100.0).abs() < 1e-6); + } + let mut reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + let reopened_world = reopened.world_state(); + let body = &reopened_world.body(&name).unwrap().body; + assert!(body.contains(Vec3::new(25.0, 25.0, 1.0))); + assert!((solvecraft_kernel::measure(body).unwrap().volume - 100.0).abs() < 1e-6); + run(&mut reopened, "timeline.edit", json!({"feature":"RetainSlab","set":{"extent":{"distance":"1.5"}}})); + let world = reopened.world_state(); + let body = &world.body(&name).unwrap().body; + assert!((solvecraft_kernel::measure(body).unwrap().volume - 150.0).abs() < 1e-6); + assert!(body.contains(Vec3::new(25.0, 25.0, 1.75))); + assert!(!body.contains(Vec3::new(25.0, 25.0, 2.25))); +} + +#[test] +fn partial_depth_intersect_full_gauge_in_rotated_frames_does_not_invent_pockets() { + use solvecraft_geom::Vec3; + for (angle, translate) in [(37.0, [30.0, 20.0, 10.0]), (121.0, [12345.678, 98765.432, 4321.123])] { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + let name = s.model.state().sheets[0].body.clone(); + let destination = run(&mut s, "component.create", json!({"name":"Destination","parent":0})); + let owner = destination["component"].as_u64().unwrap(); + run( + &mut s, + "occurrence.move", + json!({"occurrence":destination["occurrence"],"translate":translate,"axis":[1,2,3],"angle":format!("{angle} deg"),"capture":true}), + ); + run(&mut s, "component.move_bodies", json!({"component":owner,"bodies":[name]})); + run(&mut s, "component.activate", json!({"component":owner})); + run(&mut s, "sketch.create", json!({"name":"WorldTool","plane":{"origin":[0,0,0],"x_dir":[1,0,0],"y_dir":[0,1,0]}})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]})); + run(&mut s, "sketch.finish", json!({})); + let mut tool_session = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + run(&mut tool_session, "solid.extrude", json!({"sketch":"WorldTool","distance":2.5,"body_name":"CapWitness"})); + let witness_state = tool_session.model.state(); + let witness = &witness_state.body("CapWitness").unwrap().body; + let tool_plane = witness_state.sketches.last().unwrap().plane; + let sources = witness.face_sources().unwrap(); + assert_eq!(sources.len(), witness.face_count()); + let mut cap_count = 0; + for (index, source) in sources.iter().enumerate() { + if let Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { high, plane, .. }) = source { + cap_count += 1; + let current = witness.planar_face(index).unwrap(); + assert!(current.normal().cross(tool_plane.normal()).len() < 1e-10); + assert!((current.origin - plane.origin).dot(tool_plane.normal()).abs() < 1e-8); + assert!(((plane.origin - tool_plane.origin).dot(tool_plane.normal()) - if *high { 2.5 } else { 0.0 }).abs() < 1e-8); + } + } + assert_eq!(cap_count, 2); + run(&mut s, "solid.extrude", json!({"sketch":"WorldTool","distance":2.5,"operation":"intersect","name":"PostTransferRetain"})); + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + assert!(s.model.state().sheets[0].pockets.is_empty(), "full gauge must not acquire roundoff-depth pockets"); + let world = s.world_state(); + let body = &world.body(&name).unwrap().body; + assert!((solvecraft_kernel::measure(body).unwrap().volume - 250.0).abs() < 1e-5); + assert!(body.contains(Vec3::new(25.0, 25.0, 0.1))); + assert!(body.contains(Vec3::new(25.0, 25.0, 2.4))); + assert!(!body.contains(Vec3::new(10.0, 25.0, 1.0))); + } + let mut cold = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(cold.model.state().sheets[0].pockets.is_empty()); + assert!(flat(&mut cold)["pockets"].as_array().unwrap().is_empty()); + run(&mut s, "timeline.edit", json!({"feature":"PostTransferRetain","set":{"extent":{"distance":"1","start_offset":"0.5"}}})); + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + assert_eq!(s.model.state().sheets[0].pockets.len(), 2); + let world = s.world_state(); + let body = &world.body(&name).unwrap().body; + let measured = solvecraft_kernel::measure(body).unwrap().volume; + assert!((measured - 100.0).abs() < 1e-5, "retained volume {measured}"); + assert!(body.contains(Vec3::new(25.0, 25.0, 1.0))); + assert!(!body.contains(Vec3::new(25.0, 25.0, 0.1))); + assert!(!body.contains(Vec3::new(25.0, 25.0, 2.4))); + } + let cold = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + let world = cold.world_state(); + let body = &world.body(&name).unwrap().body; + assert!((solvecraft_kernel::measure(body).unwrap().volume - 100.0).abs() < 1e-5); + assert!(body.contains(Vec3::new(25.0, 25.0, 1.0))); + assert!(!body.contains(Vec3::new(25.0, 25.0, 0.1))); + } +} + +#[test] +fn partial_depth_intersect_planar_to_object_retains_current_target_depth() { + use solvecraft_geom::Vec3; + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + let name = s.model.state().sheets[0].body.clone(); + run(&mut s, "solid.box", json!({"corner":[200,0,1.5],"length":10,"width":10,"height":1,"body_name":"Roof"})); + rectangle_tool(&mut s, "Tool", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Tool","to":[205,5,1.5],"start_offset":0.5,"operation":"intersect","participants":[name]})); + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({"body":name})); + } + let state = s.model.state(); + let body = &state.body(&name).unwrap().body; + assert!((solvecraft_kernel::measure(body).unwrap().volume - 100.0).abs() < 1e-6); + assert!(body.contains(Vec3::new(25.0, 25.0, 1.0))); + assert!(!body.contains(Vec3::new(25.0, 25.0, 0.1))); + assert!(!body.contains(Vec3::new(25.0, 25.0, 2.0))); + assert_eq!(state.sheets[0].pockets.len(), 2); + assert!((solvecraft_kernel::measure(&state.body("Roof").unwrap().body).unwrap().volume - 100.0).abs() < 1e-6); + } + let cold = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!((solvecraft_kernel::measure(&cold.model.state().body(&name).unwrap().body).unwrap().volume - 100.0).abs() < 1e-6); +} + +#[test] +fn partial_depth_intersect_curved_to_object_refuses_unrepresentable_depth_atomically() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + let name = s.model.state().sheets[0].body.clone(); + run(&mut s, "solid.cylinder", json!({"base":[25,20,12],"axis":[0,1,0],"radius":11,"height":10,"body_name":"CurvedRoof"})); + rectangle_tool(&mut s, "Tool", [24.0, 23.0], [26.0, 27.0]); + let before = s.doc.to_json(); + let error = + s.execute("solid.extrude", &json!({"sketch":"Tool","to":[36,25,12],"operation":"intersect","participants":[name]})).unwrap_err().to_string(); + assert!(error.contains("sheet machining"), "{error}"); + assert_eq!(s.doc.to_json(), before); + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({"body":name})); + } + let state = s.model.state(); + assert!((solvecraft_kernel::measure(&state.body(&name).unwrap().body).unwrap().volume - 15000.0).abs() < 1e-6); + assert!(state.sheets[0].pockets.is_empty()); + } +} + +#[test] +fn partial_depth_intersect_curved_to_object_preserves_current_trimmed_cap_footprint() { + use solvecraft_geom::Vec3; + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + let name = s.model.state().sheets[0].body.clone(); + run(&mut s, "solid.cylinder", json!({"base":[25,25,0],"axis":[0,0,1],"radius":2,"height":20,"body_name":"CurvedRoof"})); + run(&mut s, "solid.box", json!({"corner":[19,19,1],"length":12,"width":12,"height":1,"body_name":"StopPlate"})); + run(&mut s, "solid.combine", json!({"target":"CurvedRoof","tools":["StopPlate"],"operation":"join"})); + rectangle_tool(&mut s, "Tool", [20.0, 20.0], [30.0, 30.0]); + let mut tool_session = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + run(&mut tool_session, "solid.extrude", json!({"sketch":"Tool","to":[27,25,10],"body_name":"TrimWitness"})); + let ts = tool_session.model.state(); + let witness = &ts.body("TrimWitness").unwrap().body; + assert!((solvecraft_kernel::measure(witness).unwrap().volume - (100.0 - 4.0 * std::f64::consts::PI)).abs() < 0.01); + assert!(witness.contains(Vec3::new(21.0, 21.0, 0.5))); + assert!(!witness.contains(Vec3::new(25.0, 25.0, 0.5))); + run(&mut s, "solid.extrude", json!({"sketch":"Tool","to":[27,25,10],"operation":"intersect","participants":[name]})); + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({"body":name})); + } + let state = s.model.state(); + let body = &state.body(&name).unwrap().body; + let measured = solvecraft_kernel::measure(body).unwrap().volume; + assert!((measured - (100.0 - 4.0 * std::f64::consts::PI)).abs() < 0.01); + let area: f64 = state.sheets[0].flat_regions().unwrap().iter().map(|r| r.area()).sum(); + assert!((area - (100.0 - 4.0 * std::f64::consts::PI)).abs() < 1e-8); + assert!(body.contains(Vec3::new(21.0, 21.0, 0.5))); + assert!(!body.contains(Vec3::new(25.0, 25.0, 0.5))); + assert!(!body.contains(Vec3::new(21.0, 21.0, 1.5))); + } + let cold = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(!cold.model.state().body(&name).unwrap().body.contains(Vec3::new(25.0, 25.0, 0.5))); + let dxf = run(&mut s, "sheet.export_dxf", json!({"body":name}))["dxf"].as_str().unwrap().to_string(); + assert!(dxf.contains("POCKET_TOP_Z1.000000_TO_2.500000_MM")); + let outline = dxf_outline_only(&dxf); + assert_eq!(solvecraft_io::read_dxf(&outline).unwrap().len(), 5); + let mut outline_import = Session::default(); + run(&mut outline_import, "sketch.insert_dxf", json!({"text":outline,"name":"Outline"})); + run(&mut outline_import, "sketch.finish", json!({})); + run(&mut outline_import, "solid.extrude", json!({"distance":1,"profiles":[{"point":[21,21]}]})); + assert!((volume(&mut outline_import) - (100.0 - 4.0 * std::f64::consts::PI)).abs() < 0.01); + assert!(!outline_import.model.state().bodies[0].body.contains(Vec3::new(25.0, 25.0, 0.5))); + let path = std::env::temp_dir().join(format!("solvecraft-current-trim-cap-{}.step", std::process::id())); + run(&mut s, "file.export", json!({"path":path.to_string_lossy(),"bodies":[name]})); + let mut imported = Session::default(); + run(&mut imported, "doc.open", json!({"path":path.to_string_lossy()})); + assert!((volume(&mut imported) - (100.0 - 4.0 * std::f64::consts::PI)).abs() < 0.01); + assert!(!imported.model.state().bodies[0].body.contains(Vec3::new(25.0, 25.0, 0.5))); + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn partial_depth_intersect_sub_extrusion_resolution_layers_refuse_atomically() { + for (distance, offset) in [(2.5 - 1e-8, 1e-8), (2.5 - 1e-8, 0.0), (1e-8, 0.0)] { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + let name = s.model.state().sheets[0].body.clone(); + let destination = run(&mut s, "component.create", json!({"name":"Far","parent":0})); + let owner = destination["component"].as_u64().unwrap(); + run( + &mut s, + "occurrence.move", + json!({"occurrence":destination["occurrence"],"translate":[12345.678,98765.432,4321.123],"axis":[1,2,3],"angle":"121 deg","capture":true}), + ); + run(&mut s, "component.move_bodies", json!({"component":owner,"bodies":[name]})); + run(&mut s, "component.activate", json!({"component":owner})); + run(&mut s, "sketch.create", json!({"name":"Tool","plane":{"origin":[0,0,0],"x_dir":[1,0,0],"y_dir":[0,1,0]}})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]})); + run(&mut s, "sketch.finish", json!({})); + let before = s.doc.to_json(); + let error = s + .execute("solid.extrude", &json!({"sketch":"Tool","distance":distance,"start_offset":offset,"operation":"intersect"})) + .unwrap_err() + .to_string(); + assert!(error.contains("resolution") || error.contains("distance is zero") || error.contains("sheet machining"), "{error}"); + assert_eq!(s.doc.to_json(), before); + assert!(s.model.state().sheets[0].pockets.is_empty()); + assert!((solvecraft_kernel::measure(&s.world_state().body(&name).unwrap().body).unwrap().volume - 15000.0).abs() < 1e-5); + } +} diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index ffdfd478..e6cffb36 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -873,7 +873,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result { let end = c.pos() + Vec2::from_angle((a.pos() - c.pos()).angle() + sw.to_radians()) * a.pos().dist(c.pos()); let cid = match &id { Some(i) => i.clone(), - None => sk.fresh("a"), + None => sk.fresh("a")?, }; let ai = match a.idx() { Some(i) => i, @@ -1748,6 +1748,26 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, format!("unknown sketch entity `{id}`"))); } } + let selected_curves = curves.iter().copied().collect::>(); + let selected_points = points.iter().copied().collect::>(); + let removed = sk + .curves + .iter() + .enumerate() + .filter(|(index, curve)| selected_curves.contains(index) || curve.kind.point_ids().iter().any(|point| selected_points.contains(point))) + .map(|(_, curve)| curve.id.as_str()) + .collect::>(); + for offset in &sk.offsets { + let Some(topology) = &offset.topology else { continue }; + if offset.outputs.iter().any(|id| removed.contains(id.as_str())) + && topology + .roles + .iter() + .any(|role| !role.retired && role.kind == solvecraft_sketch::OffsetRoleKind::Arc && !removed.contains(role.output.as_str())) + { + return Err(bad(cmd, "break the offset association before deleting only part of its curved boundary")); + } + } sk.constraints.retain(|c| !cons.contains(&c.id)); let pids: Vec = points.iter().filter_map(|q| sk.points.get(*q).map(|x| x.id.clone())).collect(); sk.remove_curves(&curves); diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 21582431..540bda2d 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -4,6 +4,7 @@ use std::f64::consts::TAU; use serde_json::{Value, json}; +use solvecraft_doc::ProfileSel; use solvecraft_geom::Vec2; use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, intersections}; @@ -95,7 +96,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) { + if sk.freeform_offsets.iter().any(|offset| offset.output == id) + || sk.offsets.iter().filter_map(|offset| offset.topology.as_ref()).any(|topology| topology.roles.iter().any(|role| role.output == id)) + { return Err(bad(cmd, "break the offset association before editing its output")); } Ok(c) @@ -184,7 +187,7 @@ fn split_at(sk: &mut Sketch, ci: usize, ts: &[f64]) -> Result<(Vec, Vec Result<()> { - let id = sk.fresh(prefix); + let id = sk.fresh(prefix)?; sk.curves.push(Curve { id, kind, construction, reversed: false, link: None, centerline: false, fixed: false }); pieces.push(sk.curves.len() - 1); Ok(()) @@ -549,7 +552,7 @@ fn keep_corner(sk: &mut Sketch, c: usize, [(ca, na), (cb, nb)]: [(usize, usize); let virtual_corner = if used || c == 0 { None } else { - let id = sk.fresh("corner"); + let id = sk.fresh("corner")?; if let Some(pt) = sk.points.get_mut(c) { pt.id = id.clone(); } @@ -934,6 +937,14 @@ fn offset(s: &mut Session, p: &Value) -> Result { 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"))?; + if sk.offsets.iter().filter_map(|offset| offset.topology.as_ref()).any(|topology| { + topology.roles.iter().any(|role| role.retired) && topology.roles.iter().any(|role| !role.retired && ids.contains(&role.output)) + }) { + return Err(bad( + cmd, + "offsets of a boundary with retired corner arcs are not supported yet; break its offset association to use the current fixed topology", + )); + } let mut made = Vec::new(); let mut cons = Vec::new(); // Circles offset on their own. @@ -954,8 +965,9 @@ fn offset(s: &mut Session, p: &Value) -> Result { outputs: ids_of(sk, &made), distance: r - nr, closed: true, + topology: None, }); - return Ok((ids_of(sk, &made), cons)); + return Ok((ids_of(sk, &made), cons, None)); } let pool: Vec = if use_chain { (0..sk.curves.len()) @@ -986,7 +998,31 @@ fn offset(s: &mut Session, p: &Value) -> Result { } let (source, forward) = order[0]; let output = sk.add_freeform_offset(source, forward, d)?; - return Ok((ids_of(sk, &[output]), cons)); + return Ok((ids_of(sk, &[output]), cons, None)); + } + let sources = order + .iter() + .map(|(index, forward)| { + sk.curves.get(*index).map(|curve| (curve.id.clone(), *forward)).ok_or_else(|| bad(cmd, "offset chain source vanished")) + }) + .collect::>>()?; + if closed && sk.convex_offset_supported(&sources)? { + let created = sk.add_convex_offset(sources.clone(), d)?; + let roles = sk + .offsets + .iter() + .filter_map(|offset| offset.topology.as_ref()) + .find(|topology| topology.id == created.association) + .map(|topology| topology.roles.clone()) + .ok_or_else(|| bad(cmd, "new offset association is missing"))?; + for (source, role) in sources.iter().zip(&roles) { + if role.kind == solvecraft_sketch::OffsetRoleKind::Line { + let a = sk.curve_index(&source.0).ok_or_else(|| bad(cmd, "offset source line vanished"))?; + let b = sk.curve_index(&role.output).ok_or_else(|| bad(cmd, "offset output line vanished"))?; + cons.push(add_c(sk, ConstraintKind::Parallel { a, b })?); + } + } + return Ok((created.curves, cons, Some(created.association))); } 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"))?; @@ -1072,23 +1108,32 @@ fn offset(s: &mut Session, p: &Value) -> Result { let outputs = ids_of(sk, &made); if pieces.iter().all(|p| !matches!(p, Piece::Free(_))) { let sources = order.iter().filter_map(|(i, forward)| sk.curves.get(*i).map(|c| (c.id.clone(), *forward))).collect(); - sk.offsets.push(solvecraft_sketch::OffsetChain { sources, outputs: outputs.clone(), distance: d, closed }); + sk.offsets.push(solvecraft_sketch::OffsetChain { sources, outputs: outputs.clone(), distance: d, closed, topology: None }); sk.refresh_offsets()?; } - Ok((outputs, cons)) + Ok((outputs, cons, None)) })?; - Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info})) + let mut response = json!({"curves":out.0,"constraints":out.1,"sketch":info}); + if let Some(association) = out.2 { + response["association"] = json!(association); + } + Ok(response) } fn break_offset(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.offset.break"; + let sketch = super::sketch::target_sketch(s, p, cmd)?; let (out, info) = edit(s, p, cmd, true, |sk, doc| { let output = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must name an offset output"))?; - let record = sk - .freeform_offsets + let record = sk.freeform_offsets.iter().position(|record| record.output == output); + let topology = sk + .offsets .iter() - .position(|record| record.output == output) - .ok_or_else(|| bad(cmd, "that curve has no free-form offset association"))?; + .filter_map(|record| record.topology.as_ref()) + .any(|record| record.id == output || record.roles.iter().any(|role| role.output == output)); + if record.is_none() && !topology { + return Err(bad(cmd, "curve is not an offset output")); + } // A parameter edit may have changed the evaluated source without // rewriting the stored fit points. Break must freeze the displayed, // current dimensioned geometry, rather than its stale stored predecessor. @@ -1102,6 +1147,19 @@ fn break_offset(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "cannot break an offset with conflicting derived dimensions")); } sk.validate_periodic_splines()?; + if let Some(association) = sk + .offsets + .iter() + .filter_map(|record| record.topology.as_ref()) + .find(|record| record.id == output || record.roles.iter().any(|role| role.output == output)) + .map(|record| record.id.clone()) + { + freeze_offset_profiles(doc, sketch, sk, &association)?; + } + if let Some(curves) = sk.break_offset_topology(output)? { + return Ok((output.to_string(), Some(curves))); + } + let record = record.ok_or_else(|| bad(cmd, "offset association changed while materializing its current geometry"))?; 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); @@ -1110,9 +1168,74 @@ fn break_offset(s: &mut Session, p: &Value) -> Result { point.fixed = false; } } - Ok(output.to_string()) + Ok((output.to_string(), None)) })?; - Ok(json!({"curve":out,"sketch":info})) + let mut response = json!({"curve":out.0,"sketch":info}); + if let Some(curves) = out.1 { + response["curves"] = json!(curves); + } + Ok(response) +} + +/// A broken logical boundary becomes ordinary geometry. Keep downstream features +/// on that exact current boundary while other associations retain their full roles. +fn freeze_offset_profiles(doc: &mut solvecraft_doc::Document, sketch: u64, boundary: &Sketch, association: &str) -> Result<()> { + let cmd = "sketch.offset.break"; + let mut groups = std::collections::BTreeMap::new(); + let mut work = 0usize; + for offset in &boundary.offsets { + let Some(topology) = &offset.topology else { continue }; + work = work.saturating_add(topology.roles.len()); + if groups.len() >= 128 || topology.roles.len() > 512 || work > 1_000_000 { + return Err(bad(cmd, "too many offset roles to freeze safely")); + } + let reserved = topology.roles.iter().map(|role| role.output.clone()).collect::>(); + if groups.insert(topology.id.clone(), (offset.outputs.clone(), reserved)).is_some() { + return Err(bad(cmd, "ambiguous offset association identity")); + } + } + let (active, reserved) = groups.get(association).ok_or_else(|| bad(cmd, "offset association disappeared before freezing"))?; + let mut reserved_sorted = reserved.clone(); + reserved_sorted.sort(); + for feature in &mut doc.features { + for (source, selection) in feature.kind.profile_sels_mut() { + if source != sketch { + continue; + } + match selection { + ProfileSel::OffsetBoundaries { associations } if associations.iter().any(|id| id == association) => { + let mut loops = Vec::with_capacity(associations.len()); + for id in associations.iter() { + let (current, all_roles) = groups.get(id).ok_or_else(|| bad(cmd, format!("cannot freeze missing offset boundary `{id}`")))?; + let roles = if id == association { current } else { all_roles }; + work = work.saturating_add(roles.len()); + if work > 1_000_000 { + return Err(bad(cmd, "too many downstream offset references to freeze safely")); + } + loops.push(roles.clone()); + } + *selection = ProfileSel::Curves { loops }; + } + ProfileSel::Curves { loops } => { + for ids in loops { + work = work.saturating_add(ids.len()); + if work > 1_000_000 { + return Err(bad(cmd, "too many downstream curve references to freeze safely")); + } + if ids.len() == reserved_sorted.len() { + let mut sorted = ids.clone(); + sorted.sort(); + if sorted == reserved_sorted { + *ids = active.clone(); + } + } + } + } + _ => {} + } + } + } + Ok(()) } // --------------------------------------------------------------------------------------------- @@ -1199,7 +1322,7 @@ fn copy_through( } } }; - let id = sk.fresh(prefix); + let id = sk.fresh(prefix)?; sk.curves.push(Curve { id, kind, diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index df31c181..a453db76 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -826,3 +826,88 @@ fn analytic_offset_partial_delete_releases_survivors_preserves_source_and_undo() run(&mut s, "edit.undo", json!({})); assert_eq!(s.doc.to_json(), before); } + +#[test] +fn periodic_offset_extrusion_survives_post_extrude_span_changes_and_cold_reopen() { + let mut s = new_sketch(); + run(&mut s, "sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})); + run(&mut s, "sketch.dimension", json!({"entities":["e1"],"type":"major","value":10})); + let a = run(&mut s, "sketch.offset", json!({"curves":["e1"],"distance":-1,"chain":false}))["curves"][0].as_str().unwrap().to_string(); + run(&mut s, "sketch.offset", json!({"curves":[a],"distance":-0.5,"chain":false})); + let sk_id = s.active_sketch.unwrap(); + let initial = solvecraft_sketch::find_profiles(&sketch(&s)); + let (index, ring) = initial.iter().enumerate().min_by(|(_, a), (_, b)| a.area.total_cmp(&b.area)).unwrap(); + let initial_count = ring.outer_curves.len(); + let boundary = ring.whole_curve_boundaries().unwrap(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch":sk_id,"profiles":[index],"distance":3,"operation":"new"})); + let initial_doc = s.doc.to_json(); + for major in [12.0, 16.0, 20.0] { + run(&mut s, "parameters.change", json!({"name":"d1","expression":major})); + let state = s.world_state(); + let sk = state.sketch(sk_id).unwrap(); + let ring = sk.profiles.iter().find(|p| p.whole_curve_boundaries().as_ref() == Some(&boundary)).unwrap(); + assert_ne!(ring.outer_curves.len(), initial_count, "the repro must actually change graph span multiplicity"); + // Length-only intent leaves the centre and axis pose free. Measure that rigid frame, + // then derive independent ellipse normal probes (not fitted-output points). + let CurveKind::Ellipse { c, m, r } = sk.sketch.curves[sk.sketch.curve_index("e1").unwrap()].kind else { panic!("ellipse") }; + let centre = sk.sketch.point(c).unwrap(); + let axis = sk.sketch.point(m).unwrap() - centre; + assert!((axis.len() - major).abs() < 1e-6); + assert!((r - 5.0).abs() < 1e-6); + let u = axis / axis.len(); + let v = Vec2::new(-u.y, u.x); + let t: f64 = 0.2; + let ellipse = centre + u * (major * t.cos()) + v * (5.0 * t.sin()); + let gradient = u * (t.cos() / major) + v * (t.sin() / 5.0); + let normal = gradient / gradient.len(); + eprintln!("major={major} centre={centre:?} axis={axis:?}"); + assert!(ring.region.contains(ellipse + normal * 1.25), "major={major}, ring area={}", ring.area); + assert!(!ring.region.contains(ellipse + normal * 0.5)); + assert!(!ring.region.contains(ellipse + normal * 1.75)); + let n = 4096; + let h = std::f64::consts::TAU / n as f64; + let perimeter = (0..=n) + .map(|i| { + let t = i as f64 * h; + (major * major * t.sin().powi(2) + 25.0 * t.cos().powi(2)).sqrt() + * if i == 0 || i == n { + 1.0 + } else if i % 2 == 0 { + 2.0 + } else { + 4.0 + } + }) + .sum::() + * h + / 3.0; + let expected = (0.5 * perimeter + 1.25 * std::f64::consts::PI) * 3.0; + assert_eq!(state.bodies.len(), 1); + let body = &state.bodies[0].body; + for (distance, material) in [(1.25, true), (0.5, false), (1.75, false)] { + let point = ellipse + normal * distance; + assert_eq!(body.contains(solvecraft_geom::Vec3::new(point.x, point.y, 1.5)), material); + } + // Repeat at the exact major-axis seam as a physical material control. + for (distance, material) in [(1.25, true), (0.5, false), (1.75, false)] { + let point = centre + u * (major + distance); + assert_eq!(ring.region.contains(point), material); + assert_eq!(body.contains(solvecraft_geom::Vec3::new(point.x, point.y, 1.5)), material); + } + let volume = solvecraft_kernel::measure(body).unwrap().volume; + assert!((volume - expected).abs() < 0.05, "{major}: {volume} vs independent {expected}"); + let mut cold = Session { doc: solvecraft_doc::format::read(&s.doc.to_json()).unwrap().into(), ..Default::default() }; + cold.refresh(); + let cold_state = cold.world_state(); + assert_eq!(cold_state.bodies.len(), 1); + let cold_volume = solvecraft_kernel::measure(&cold_state.bodies[0].body).unwrap().volume; + assert!((cold_volume - volume).abs() < 1e-8); + let edited = s.doc.to_json(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), initial_doc); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.to_json(), edited); + run(&mut s, "edit.undo", json!({})); + } +} diff --git a/crates/engine/src/pattern_suppress_tests.rs b/crates/engine/src/pattern_suppress_tests.rs index ee5ba758..b47fcef0 100644 --- a/crates/engine/src/pattern_suppress_tests.rs +++ b/crates/engine/src/pattern_suppress_tests.rs @@ -91,21 +91,113 @@ fn enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs() { } #[test] -fn enclosure_inward_offset_collapse_reports_an_error_instead_of_a_stale_lip() { +fn enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen() { + use solvecraft_doc::FeatureKind; + use solvecraft_geom::{Vec2, Vec3}; + use solvecraft_sketch::CurveKind; + let mut s = Session::default(); let script: Value = serde_json::from_str(include_str!("../../../examples/acceptance/sensor_enclosure.json")).unwrap(); s.run_script(&script).unwrap(); - run(&mut s, "parameters.change", json!({"name":"wall","expression":"3 mm"})); - // R4 outer corners leave R1 inner corners. The requested 1.4 mm inward - // offset needs a topology transition, which this offset operation refuses. - let lip = s.doc.features.iter().find(|f| f.name == "Lid lip" && matches!(f.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap(); - let result = s.model.result(lip.id).unwrap(); - assert!(result.error.as_deref().is_some_and(|error| error.contains("offset collapses an arc")), "{result:?}"); - assert!(result.warning.as_deref().is_none_or(|warning| !warning.contains("identity is ambiguous")), "{result:?}"); - let state = s.model.state(); - assert!(state.sketch(lip.id).is_none()); - assert!(!state.body("Lid").unwrap().mesh().contains(solvecraft_geom::Vec3::new(0., 27.2, 24.))); - let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); - assert!(reopened.model.result(lip.id).unwrap().error.as_deref().is_some_and(|error| error.contains("offset collapses an arc"))); - assert!(reopened.model.state().sketch(lip.id).is_none()); + for (name, expression) in [("height", "30 mm"), ("board_d", "50 mm"), ("wall", "3 mm")] { + run(&mut s, "parameters.change", json!({"name":name,"expression":expression})); + } + let check = |session: &Session| { + let errors = session.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| (&r.name, e))).collect::>(); + let warnings = session.model.results.iter().filter_map(|r| r.warning.as_ref().map(|e| (&r.name, e))).collect::>(); + assert!(errors.is_empty(), "errors {errors:?}"); + // The earlier snap-fit operation retains its documented legacy split-face + // notice. Offset/projection failures or frozen borders are not accepted. + assert!( + warnings.iter().all(|(name, message)| name.as_str() == "SnapFit1" + && message.as_str() == "face `F11:side:bs#7` was split; using the piece nearest the picked point"), + "warnings {warnings:?}" + ); + assert!(session.model.results.iter().filter(|r| r.name == "Lid lip").all(|r| r.warning.is_none())); + let state = session.model.state(); + let feature = session.doc.features.iter().find(|f| f.name == "Lid lip" && matches!(f.kind, FeatureKind::Extrude { .. })).unwrap(); + let FeatureKind::Extrude { sketch, profiles, .. } = &feature.kind else { + panic!("lip extrusion"); + }; + let solved = state.sketch(*sketch).unwrap(); + assert!((solved.plane.origin.z - 30.).abs() < 1e-8); + let inner = &solved.sketch.offsets[1]; + assert_eq!(inner.outputs.len(), 4); + let mut corners = Vec::new(); + for id in &inner.outputs { + let curve = &solved.sketch.curves[solved.sketch.curve_index(id).unwrap()]; + let CurveKind::Line { a, b } = curve.kind else { + panic!("extinct arcs must leave line supports: {curve:?}"); + }; + for point in [solved.sketch.points[a].pos, solved.sketch.points[b].pos] { + assert!((point.x.abs() - 36.6).abs() < 1e-8 && (point.y.abs() - 31.6).abs() < 1e-8, "{point:?}"); + if !corners.iter().any(|p: &Vec2| p.dist(point) < 1e-8) { + corners.push(point); + } + } + } + assert_eq!(corners.len(), 4); + let regions = solvecraft_doc::select_profiles(solved, profiles).unwrap(); + assert_eq!(regions.len(), 1); + assert!((regions[0].area() - 332.570619298295).abs() < 1e-7, "area {}", regions[0].area()); + let ring = solvecraft_kernel::extrude(&solved.plane, ®ions, 0., 2.).unwrap(); + assert_eq!(ring.len(), 1); + let precise_ring = ring[0].tessellate(1e-6).unwrap(); + let bounds = precise_ring.bounds(); + for (actual, expected) in + [(bounds.min.x, -37.8), (bounds.max.x, 37.8), (bounds.min.y, -32.8), (bounds.max.y, 32.8), (bounds.min.z, 28.), (bounds.max.z, 30.)] + { + assert!((actual - expected).abs() < 1e-8, "ring bounds {bounds:?}"); + } + let height = bounds.max.z - bounds.min.z; + assert!((height - 2.).abs() < 1e-8); + let analytic_volume = regions[0].area() * height; + assert!((analytic_volume - 665.141238596591).abs() < 2e-7, "analytic ring volume {analytic_volume}"); + let arc_perimeter = regions[0] + .outer + .segs + .iter() + .map(|segment| match segment { + solvecraft_geom::Seg2::Arc { radius, sweep, .. } => radius.abs() * sweep.abs(), + solvecraft_geom::Seg2::Line { .. } => 0., + other => panic!("unexpected ring boundary {other:?}"), + }) + .sum::(); + assert!((arc_perimeter - std::f64::consts::TAU * 0.8).abs() < 1e-8); + assert_eq!(regions[0].holes.len(), 1); + assert!(regions[0].holes[0].segs.iter().all(|segment| matches!(segment, solvecraft_geom::Seg2::Line { .. }))); + // Body::tessellate floors the requested sag tolerance at size * 1e-6. + // Only the outer arcs incur chord error: their tolerance-wide boundary + // strip bounds the volume error without changing CAD or export tolerances. + let actual_tol = 1e-6_f64.max(ring[0].size() * 1e-6); + let discretization_bound = height * (arc_perimeter * actual_tol + std::f64::consts::PI * actual_tol.powi(2)); + let measured = precise_ring.measure().volume.abs(); + assert!( + (measured - analytic_volume).abs() <= discretization_bound, + "ring mesh volume {measured}, analytic {analytic_volume}, bound {discretization_bound}" + ); + let lid = state.body("Lid").unwrap().body.tessellate(1e-4).unwrap(); + for (point, material) in [ + (Vec3::new(0., 32.2, 29.), true), + (Vec3::new(0., 32.9, 29.), false), + (Vec3::new(0., 31.5, 29.), false), + (Vec3::new(36.7, 31.7, 29.), true), + (Vec3::new(36.5, 31.5, 29.), false), + (Vec3::new(37.55, 32.55, 29.), true), + (Vec3::new(37.7, 32.7, 29.), false), + // These points expose the stale old rim and the old height. + (Vec3::new(0., 27.2, 29.), false), + (Vec3::new(0., 27.2, 24.), false), + ] { + assert_eq!(precise_ring.contains(point), material, "standalone ring {point:?}"); + assert_eq!(lid.contains(point), material, "joined lid {point:?}"); + } + assert!(!lid.contains(Vec3::new(-33.5, -28.5, 32.))); + assert!(lid.contains(Vec3::new(-31.5, -28.5, 32.))); + }; + check(&s); + let saved = s.doc.to_json(); + let reopened = Session::new(solvecraft_doc::Document::from_json(&saved).unwrap()); + check(&reopened); + assert_eq!(reopened.doc.to_json(), saved); } diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 6da37897..89eaf902 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -73,6 +73,26 @@ pub fn grid_step(half_height: f64) -> (f64, f64) { /// Sketch curves as world polylines with their colour. pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool]) -> Vec<(Vec, Rgb, bool)> { + sketch_lines_with_sampler(sk, plane, active, determined, world_curve_parts) +} + +fn world_curve_parts(sk: &Sketch, i: usize, plane: &Plane) -> Vec> { + sk.segs(i).into_iter().map(|seg| seg.polyline(0.02).iter().map(|p| plane.to_world(*p)).collect()).collect() +} + +/// Sketch display paths with the same visibility and styling as [`sketch_lines`]. +/// +/// The sampler receives each visible 2D curve once and returns world-coordinate +/// parts. Parts remain separate, so a view-dependent sampler can leave clipping +/// gaps without drawing a line across them. Already placed 3D wires retain their +/// existing paths. Sampling changes only display geometry, never the sketch. +pub fn sketch_lines_with_sampler( + sk: &Sketch, + plane: &Plane, + active: bool, + determined: &[bool], + mut sampler: impl FnMut(&Sketch, usize, &Plane) -> Vec>, +) -> Vec<(Vec, Rgb, bool)> { let mut out = Vec::new(); for (i, c) in sk.curves.iter().enumerate() { if c.link.is_some() && sk.view.hide_projected && !sk.is_text_curve(i) { @@ -93,8 +113,8 @@ pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool } else { colors::SKETCH }; - for seg in sk.segs(i) { - out.push((seg.polyline(0.02).iter().map(|p| plane.to_world(*p)).collect(), col, c.construction || c.centerline)); + for part in sampler(sk, i, plane) { + out.push((part, col, c.construction || c.centerline)); } } // Virtual sharp corners (kept by fillets and chamfers): thin extension lines from the @@ -137,6 +157,23 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene { /// the app passes the cut its viewport shows (an open Section Analysis dialog's preview, a /// sketch's Slice), so model renders match the window. pub fn scene_with_section(s: &Session, cam: &Camera, section: Option<(Vec3, Vec3)>) -> Scene { + scene_with_section_and_sketch_sampler(s, cam, section, world_curve_parts) +} + +/// A headless scene using supplied display paths for its visible sketch curves. +/// Callers can use the same pixel sampler as a GPU viewport without changing the +/// body, grid, appearance, section or sketch styling policies of [`scene`]. +pub fn scene_with_sketch_sampler(s: &Session, cam: &Camera, sampler: impl FnMut(&Sketch, usize, &Plane) -> Vec>) -> Scene { + scene_with_section_and_sketch_sampler(s, cam, s.section, sampler) +} + +/// A headless scene with an explicit section and supplied sketch display paths. +pub fn scene_with_section_and_sketch_sampler( + s: &Session, + cam: &Camera, + section: Option<(Vec3, Vec3)>, + mut sampler: impl FnMut(&Sketch, usize, &Plane) -> Vec>, +) -> Scene { let st = s.world_state(); let section = section.filter(|(o, n)| o.is_finite() && n.is_finite() && n.len() > 1e-9); let mut sc = Scene { @@ -215,7 +252,7 @@ pub fn scene_with_section(s: &Session, cam: &Camera, section: Option<(Vec3, Vec3 if !sketch_shown(s, ss.feature) { continue; } - for (pts, col, cons) in sketch_lines(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) { + for (pts, col, cons) in sketch_lines_with_sampler(&ss.sketch, &ss.plane, active, &ss.report.curve_determined, &mut sampler) { sc.lines.push(SceneLine { points: pts, color: col, width: if cons { 1.0 } else { 1.6 }, on_top: active }); } } @@ -402,3 +439,7 @@ mod tests { } } } + +#[cfg(test)] +#[path = "view_display_tests.rs"] +mod display_tests; diff --git a/crates/engine/src/view_display_tests.rs b/crates/engine/src/view_display_tests.rs new file mode 100644 index 00000000..b666c9ad --- /dev/null +++ b/crates/engine/src/view_display_tests.rs @@ -0,0 +1,142 @@ +use super::*; +use serde_json::json; +use solvecraft_geom::Vec2; +use solvecraft_sketch::{LinkGeom, LinkKind, LinkSource}; + +fn line(sk: &mut Sketch, y: f64) -> usize { + sk.add_line(Vec2::new(0., y), Vec2::new(2., y), None, None, None).unwrap() +} + +#[test] +fn sampler_keeps_style_precedence_hidden_references_text_and_disjoint_parts() { + let mut sk = Sketch::new(); + let ordinary = line(&mut sk, 0.); + let fixed = line(&mut sk, 1.); + sk.curves[fixed].fixed = true; + sk.curves[fixed].construction = true; + let centerline = line(&mut sk, 2.); + sk.curves[centerline].centerline = true; + let construction = line(&mut sk, 3.); + sk.curves[construction].construction = true; + let determined = line(&mut sk, 4.); + let projected = sk.add_link(LinkKind::Project, LinkSource::Origin, &[LinkGeom::Line(Vec2::new(0., 5.), Vec2::new(2., 5.))]).unwrap(); + let lost = sk.add_link(LinkKind::Project, LinkSource::Origin, &[LinkGeom::Line(Vec2::new(0., 6.), Vec2::new(2., 6.))]).unwrap(); + sk.set_link_lost(&lost, true); + let text = sk + .add_link( + LinkKind::Text, + LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0. }, + &[LinkGeom::Line(Vec2::new(0., 7.), Vec2::new(2., 7.))], + ) + .unwrap(); + let mut determined_flags = vec![false; sk.curves.len()]; + determined_flags[determined] = true; + let saved = serde_json::to_value(&sk).unwrap(); + let mut called = Vec::new(); + let lines = sketch_lines_with_sampler(&sk, &Plane::XY, true, &determined_flags, |_, i, _| { + called.push(i); + vec![vec![Vec3::new(i as f64, 0., 0.), Vec3::new(i as f64, 1., 0.)], vec![Vec3::new(i as f64, 3., 0.), Vec3::new(i as f64, 4., 0.)]] + }); + assert_eq!(called, (0..sk.curves.len()).collect::>()); + for (i, expected) in [ + colors::SKETCH, + colors::SKETCH_LOCKED, + colors::SKETCH_CENTERLINE, + colors::SKETCH_CONSTRUCTION, + colors::SKETCH_FIXED, + colors::SKETCH_PROJECTED, + colors::SKETCH_LOST, + colors::SKETCH, + ] + .into_iter() + .enumerate() + { + for part in &lines[2 * i..2 * i + 2] { + assert_eq!(part.1, expected); + assert_eq!(part.0.len(), 2); + } + assert_eq!(lines[2 * i].2, [fixed, centerline, construction].contains(&i)); + } + assert_eq!(serde_json::to_value(&sk).unwrap(), saved); + sk.view.hide_projected = true; + called.clear(); + let finished = sketch_lines_with_sampler(&sk, &Plane::XY, false, &determined_flags, |_, i, _| { + called.push(i); + vec![vec![Vec3::ZERO, Vec3::X]] + }); + assert_eq!(called, vec![ordinary, fixed, centerline, construction, determined, 7]); + assert_eq!(finished[0].1, colors::SKETCH_DONE); + assert_eq!(finished[4].1, colors::SKETCH_DONE); + assert_eq!(finished[5].1, colors::SKETCH_DONE); + assert!(sk.link(&projected).is_some() && sk.link(&text).is_some()); +} + +#[test] +fn legacy_paths_match_sampler_wrapper_and_placed_wires_are_unchanged() { + let mut sk = Sketch::new(); + sk.add_circle(Vec2::new(2., 3.), 1., None, None).unwrap(); + sk.add_drawn_wire(&[Vec3::new(8., 9., 10.), Vec3::new(11., 12., 13.)]).unwrap(); + let plane = Plane::new(Vec3::new(20., 30., 40.), Vec3::Y, Vec3::Z).unwrap(); + let old = sketch_lines(&sk, &plane, true, &[false]); + let adapted = sketch_lines_with_sampler(&sk, &plane, true, &[false], world_curve_parts); + assert_eq!(old, adapted); + let clipped = sketch_lines_with_sampler(&sk, &plane, true, &[], |_, _, _| Vec::new()); + assert_eq!(clipped, vec![(sk.wires[0].pts.clone(), colors::SKETCH, false)]); + let mut included = sk.wires[0].clone(); + included.id = "included".into(); + included.fit.clear(); + sk.wires.push(included); + let wires = sketch_lines_with_sampler(&sk, &plane, false, &[], |_, _, _| Vec::new()); + assert_eq!(wires[0].1, colors::SKETCH_DONE); + assert_eq!(wires[1], (sk.wires[1].pts.clone(), colors::SKETCH_PROJECTED, false)); + sk.view.hide_projected = true; + assert_eq!(sketch_lines_with_sampler(&sk, &plane, false, &[], |_, _, _| Vec::new()), vec![wires[0].clone()]); +} + +#[test] +fn scene_sampler_uses_exact_active_occurrence_plane_and_preserves_other_scene_content() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length":4,"width":5,"height":6})).unwrap(); + let component = s.execute("component.create", &json!({"name":"Part"})).unwrap()["component"].as_u64().unwrap(); + let occurrence = s.execute("occurrence.copy", &json!({"component":component,"translate":[20,30,40]})).unwrap()["occurrence"].as_u64().unwrap(); + s.execute("occurrence.move", &json!({"occurrence":occurrence,"angle":"90 deg","axis":[1,0,0],"origin":[20,30,40],"capture":false})).unwrap(); + s.execute("component.activate", &json!({"component":component,"occurrence":occurrence,"path":[occurrence]})).unwrap(); + s.execute("sketch.create", &json!({"plane":"XY"})).unwrap(); + s.execute("sketch.circle.center", &json!({"center":[2,3],"radius":1})).unwrap(); + s.section = Some((Vec3::new(0., 0., 3.), Vec3::Z)); + let cam = Camera::default(); + let old = scene(&s, &cam); + let default = scene_with_sketch_sampler(&s, &cam, world_curve_parts); + assert_eq!(old.lines.len(), default.lines.len()); + for (a, b) in old.lines.iter().zip(&default.lines) { + assert_eq!((&a.points, a.color, a.width, a.on_top), (&b.points, b.color, b.width, b.on_top)); + } + let mut planes = Vec::new(); + let parts = vec![vec![Vec3::new(22., 33., 40.), Vec3::new(23., 33., 40.)], vec![Vec3::new(25., 33., 40.), Vec3::new(26., 33., 40.)]]; + let sampled = scene_with_sketch_sampler(&s, &cam, |_, _, plane| { + planes.push(*plane); + parts.clone() + }); + assert_eq!(planes.len(), 1); + assert!(planes[0].origin.dist(Vec3::new(20., 30., 40.)) < 1e-9); + assert!(planes[0].x.dist(Vec3::X) < 1e-9); + assert!(planes[0].y.dist(Vec3::Z) < 1e-9); + assert_eq!(sampled.clip, old.clip); + assert_eq!(sampled.background, old.background); + assert_eq!(sampled.radius, old.radius); + assert_eq!(sampled.meshes.len(), old.meshes.len()); + assert!(Arc::ptr_eq(&sampled.meshes[0].mesh, &old.meshes[0].mesh)); + assert_eq!(sampled.meshes[0].color, old.meshes[0].color); + assert_eq!(sampled.meshes[0].face_colors, old.meshes[0].face_colors); + let lines = &sampled.lines[sampled.lines.len() - 2..]; + for (line, part) in lines.iter().zip(parts) { + assert_eq!(line.points, part); + assert!(line.on_top); + assert_eq!(line.width, 1.6); + } + let active = s.active_sketch.unwrap(); + s.execute("sketch.finish", &json!({})).unwrap(); + s.visibility.hidden_sketches.push(active); + let hidden = scene_with_sketch_sampler(&s, &cam, |_, _, _| panic!("hidden sketch sampled")); + assert!(hidden.lines.iter().all(|line| !line.on_top)); +} diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 44998c36..022eedc4 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -957,6 +957,24 @@ impl Body { if groups.len() == 1 { return Ok(vec![self.clone()]); } + // Grouping keeps existing shells; certificates belong to their + // exact face identities, not the new lump's positional face index. + // This is not propagation through a geometry-changing operation. + let sources_by_face = if let Some(sources) = &self.face_sources { + if sources.len() != self.solid.face_iter().count() { + return Err(KernelError::Invalid("lumps: face source table does not match the body".into())); + } + let mut by_id = std::collections::HashMap::new(); + for (face, source) in self.solid.face_iter().zip(sources.iter()) { + if by_id.insert(face.id(), Some((face.orientation(), source))).is_some() { + // A repeated identity has no unique source association. + by_id.insert(face.id(), None); + } + } + Some(by_id) + } else { + None + }; groups .into_iter() .map(|g| { @@ -964,7 +982,18 @@ impl Body { .into_iter() .map(|i| shells.get(i).cloned().ok_or_else(|| KernelError::Failed("lumps: missing shell".into()))) .collect::>>()?; - Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("lump: {e}"))).and_then(Body::new) + let result = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("lump: {e}"))).and_then(Body::new)?; + let sources = sources_by_face.as_ref().map(|old| { + result + .solid + .face_iter() + .map(|face| match old.get(&face.id()) { + Some(Some((orientation, source))) if *orientation == face.orientation() => (*source).clone(), + _ => None, + }) + .collect() + }); + Ok(result.with_face_sources(sources.map(std::sync::Arc::new))) }) .collect() }) diff --git a/crates/kernel/src/body_lump_tests.rs b/crates/kernel/src/body_lump_tests.rs index 722ef31a..8cbab6fa 100644 --- a/crates/kernel/src/body_lump_tests.rs +++ b/crates/kernel/src/body_lump_tests.rs @@ -222,3 +222,82 @@ fn shortcut_clearance_refuses_curved_contact_hidden_between_mesh_vertices() { let invalid = joined_shells(outer.deep_copy().boundaries().iter().chain(protruding.boundaries()).cloned().collect()); assert!(invalid.separated_lumps().is_err()); } + +fn certified_lump_witness() -> Body { + use solvecraft_geom::{Loop2, Plane, Region2, Vec2}; + // The current trimmed lower prism: a rectangular tool with a circular stop + // removed. Its cap footprint differs materially from the original sketch. + let region = Region2 { + outer: Loop2::polygon(&[Vec2::new(20., 20.), Vec2::new(30., 20.), Vec2::new(30., 30.), Vec2::new(20., 30.)]), + holes: vec![Loop2::circle(Vec2::new(25., 25.), 2.)], + }; + let lower = crate::extrude(&Plane::XY, &[region], 0., 1.).unwrap().pop().unwrap(); + let named = box_solid(Vec3::new(40., 20., 5.), Vec3::new(42., 23., 7.)).unwrap().with_box_certificate(202).unwrap(); + let unknown = box_solid(Vec3::new(50., 20., 5.), Vec3::new(51., 21., 6.)).unwrap().with_face_sources(None); + let sources: std::collections::HashMap<_, _> = [&lower, &named] + .into_iter() + .flat_map(|body| body.solid.face_iter().zip(body.face_sources().unwrap()).map(|(face, source)| (face.id(), source.clone()))) + .collect(); + // Deliberately put the larger named box first, then the trimmed tool. An + // index-based copy of the source table would attach certificates wrongly. + let shells = [&named, &unknown, &lower].into_iter().flat_map(|b| b.solid.boundaries().iter().cloned()).collect(); + let body = Body::new(Solid::try_new(shells).unwrap()).unwrap(); + let table = body.solid.face_iter().map(|f| sources.get(&f.id()).cloned().flatten()).collect(); + body.with_face_sources(Some(std::sync::Arc::new(table))) +} + +#[test] +fn grouping_lumps_preserves_only_unchanged_face_certificates_and_trimmed_caps() { + let body = certified_lump_witness(); + let expected: std::collections::HashMap<_, _> = + body.solid.face_iter().zip(body.face_sources().unwrap()).map(|(f, source)| (f.id(), (f.orientation(), source.clone()))).collect(); + let before = step_export(&[&body], "source").unwrap(); + let lumps = body.lumps().unwrap(); + assert_eq!(lumps.len(), 3); + let mut total_faces = 0; + let mut raw_caps = 0; + let mut named_faces = 0; + let mut unknown_faces = 0; + for lump in &lumps { + assert_eq!(lump.face_sources().unwrap().len(), lump.face_count()); + for (f, source) in lump.solid.face_iter().zip(lump.face_sources().unwrap()) { + let (orientation, original) = expected.get(&f.id()).expect("grouping must retain original face IDs"); + assert_eq!(*orientation, f.orientation()); + assert_eq!(source, original); + match source { + Some(FaceSource::ExtrudeCap { region, high, plane }) => { + assert_eq!(region.holes.len(), 1); + assert!((region.area() - (100. - 4. * std::f64::consts::PI)).abs() < 1e-10); + assert!((plane.origin.z - if *high { 1. } else { 0. }).abs() < 1e-10); + raw_caps += 1; + } + Some(FaceSource::Box { producer, .. }) => { + assert_eq!(*producer, Some(202)); + named_faces += 1; + } + None => unknown_faces += 1, + _ => {} + } + total_faces += 1; + } + let mesh = lump.tessellate(1e-5).unwrap(); + if mesh.contains(Vec3::new(21., 21., 0.5)) { + assert!(!mesh.contains(Vec3::new(25., 25., 0.5)), "trimmed stop hole must not refill"); + assert!((mesh.measure().volume - (100. - 4. * std::f64::consts::PI)).abs() < 0.002); + } + } + assert_eq!(total_faces, body.face_count()); + assert_eq!(raw_caps, 2); + assert_eq!(named_faces, 6); + assert_eq!(unknown_faces, 6); + assert_eq!(step_export(&[&body], "source").unwrap(), before); +} + +#[test] +fn grouping_lumps_does_not_infer_absent_or_misaligned_sources() { + let body = certified_lump_witness(); + let absent = body.clone().with_face_sources(None); + assert!(absent.lumps().unwrap().iter().all(|b| b.face_sources().is_none())); + let malformed = body.with_face_sources(Some(std::sync::Arc::new(vec![None]))); + assert!(malformed.lumps().unwrap_err().to_string().contains("face source table")); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 5c6d28a9..9430c68e 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -38,6 +38,7 @@ mod ops; mod polybool; mod polyhedron; mod prism; +mod prism_curve; pub mod provenance; mod sew; mod splitface; diff --git a/crates/kernel/src/polybool.rs b/crates/kernel/src/polybool.rs index f793a6ce..3d0055ae 100644 --- a/crates/kernel/src/polybool.rs +++ b/crates/kernel/src/polybool.rs @@ -225,28 +225,41 @@ impl NotPlanar { /// The plane of each face (from its mesh, which must be flat), or what stands in the way: the /// curved faces by the kind of surface they lie on. fn face_planes(b: &Body, faces: &[crate::FaceInfo]) -> std::result::Result, NotPlanar> { - use crate::FaceSurface; + use crate::offset::{Surf, surf_of}; + // Match face_surface's classification tolerance, but compute the body bounds only + // once. Walking to each indexed face separately made curved-body diagnostics quadratic. + let surface_tol = (b.size() * 1e-7).max(1e-9) * 100.0; + let mut surfaces = b.solid.face_iter(); let mut curved: std::collections::BTreeMap<&'static str, usize> = std::collections::BTreeMap::new(); let mut out = Vec::with_capacity(faces.len()); - for f in faces { + for (index, f) in faces.iter().enumerate() { + let Some(surface) = surfaces.next() else { + return Err(NotPlanar::Other("mesh is inconsistent".into())); + }; + if f.index != index { + return Err(NotPlanar::Other("mesh is inconsistent".into())); + } if f.area <= 0.0 { return Err(NotPlanar::Other(format!("face {} could not be meshed", f.index))); } match f.plane_normal { Some(n) => out.push(PlaneEq { n, w: n.dot(f.centroid) }), None => { - let kind = match crate::face_surface(b, f.index) { - Some(FaceSurface::Cylinder { .. }) => "cylinder", - Some(FaceSurface::Cone { .. }) => "cone", - Some(FaceSurface::Sphere { .. }) => "sphere", - Some(FaceSurface::Torus { .. }) => "torus", - Some(FaceSurface::Plane { .. }) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))), + let kind = match surf_of(surface, surface_tol) { + Some(Surf::Cylinder { .. }) => "cylinder", + Some(Surf::Cone { .. }) => "cone", + Some(Surf::Sphere { .. }) => "sphere", + Some(Surf::Torus { .. }) => "torus", + Some(Surf::Plane { .. }) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))), None => "freeform", }; *curved.entry(kind).or_insert(0) += 1; } } } + if surfaces.next().is_some() { + return Err(NotPlanar::Other("mesh is inconsistent".into())); + } if curved.is_empty() { Ok(out) } else { Err(NotPlanar::Curved(curved)) } } @@ -792,3 +805,42 @@ fn rebuild(polys: Vec, size: f64) -> Result { Body::new(solid) }) } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod diagnostic_tests { + use super::*; + + #[test] + fn compound_curved_diagnostics_count_every_surface_kind() { + // Many independent shells exercise the error path without performing a costly + // Boolean. Each cylinder has two analytic half-cylinder walls and two flat caps. + let mut shells = Vec::new(); + for i in 0..64 { + let cylinder = crate::cylinder(Vec3::X * (i as f64 * 6.0), Vec3::Z, 2.0, 5.0).unwrap(); + shells.extend(cylinder.solid.boundaries().iter().cloned()); + } + let sphere = crate::sphere(Vec3::new(0.0, 12.0, 0.0), 2.0).unwrap(); + let sphere_faces = sphere.face_count(); + shells.extend(sphere.solid.boundaries().iter().cloned()); + let body = guard("diagnostic compound", || Body::new(Solid::try_new(shells).unwrap())).unwrap(); + let faces = body.faces(0.02).unwrap(); + let error = face_planes(&body, &faces).err().unwrap(); + let expected = std::collections::BTreeMap::from([("cylinder", 128), ("sphere", sphere_faces)]); + assert_eq!(error, NotPlanar::Curved(expected)); + assert_eq!(error.describe("tool"), format!("the tool has {} curved faces (cylinder, sphere)", 128 + sphere_faces)); + } + + #[test] + fn planar_mesh_inconsistency_does_not_become_curved_classification() { + let body = crate::box_solid(Vec3::ZERO, Vec3::new(4.0, 5.0, 6.0)).unwrap(); + let mut faces = body.faces(0.01).unwrap(); + assert_eq!(face_planes(&body, &faces).unwrap().len(), 6); + faces[0].plane_normal = None; + assert_eq!(face_planes(&body, &faces).err().unwrap(), NotPlanar::Other("face 0 is planar but its mesh is not flat".into())); + faces[0].area = 0.0; + assert_eq!(face_planes(&body, &faces).err().unwrap(), NotPlanar::Other("face 0 could not be meshed".into())); + faces[0].index = 999; + assert_eq!(face_planes(&body, &faces).err().unwrap(), NotPlanar::Other("mesh is inconsistent".into())); + } +} diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index a6c19847..cbab7a59 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -120,68 +120,110 @@ struct Prism { regions: Vec, } -/// Edges of a cap face as 2D segments (lines and arcs only). -fn seg2(e: &mt::Edge, plane: &Plane, tol: f64) -> Option> { - use mt::{BoundedCurve, ParametricCurve}; - let c = e.oriented_curve(); - let (t0, t1) = c.range_tuple(); - let pts: Vec = (0..=16).map(|k| plane.to_local(from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 16.0)))).collect(); - let (a, b) = (*pts.first()?, *pts.last()?); - let closed = a.dist(b) < tol; - let seg_dist = |p: Vec2| { - let ab = b - a; - let t = ((p - a).dot(ab) / ab.dot(ab).max(1e-300)).clamp(0.0, 1.0); - (a + ab * t).dist(p) +/// Certify that a curved wall is swept along `d` from its surface representation. +/// Seam vertices alone cannot establish this: a half-cylinder's seam normals can +/// both be perpendicular to a transverse direction, silently filling its hole. +fn wall_along(surface: &mt::Surface, d: Vec3, tol: f64, remaining: &mut usize) -> bool { + if let mt::Surface::RevolutedCurve(processor) = surface { + let revolution = processor.entity(); + let mt::Curve::Line(line) = revolution.entity_curve() else { return false }; + let axis = revolution.axis(); + let meridian = line.1 - line.0; + let local_axis = Vec3::new(axis.x, axis.y, axis.z); + let local_meridian = Vec3::new(meridian.x, meridian.y, meridian.z); + let (Some(a), Some(m)) = (local_axis.normalized(), local_meridian.normalized()) else { return false }; + if a.cross(m).len() > 1e-9 { + return false; // A conical meridian is not a cylindrical wall. + } + let placed = *processor.transform() * mt::Vector4::new(axis.x, axis.y, axis.z, 0.0); + return placed.w.abs() < 1e-12 && Vec3::new(placed.x, placed.y, placed.z).normalized().is_some_and(|a| a.cross(d).len() < 1e-9); + } + let dimensions = match surface { + mt::Surface::BSplineSurface(s) => (s.control_points().len(), s.control_points().first().map_or(0, Vec::len)), + mt::Surface::NurbsSurface(s) => (s.control_points().len(), s.control_points().first().map_or(0, Vec::len)), + _ => return false, }; - if !closed && pts.iter().all(|p| seg_dist(*p) < tol) { - return Some(vec![Seg2::Line { a, b }]); + let (rows, cols) = dimensions; + let count = rows.saturating_mul(cols); + let rectangular = match surface { + mt::Surface::BSplineSurface(s) => s.control_points().iter().all(|r| r.len() == cols), + mt::Surface::NurbsSurface(s) => s.control_points().iter().all(|r| r.len() == cols), + _ => false, + }; + if rows < 2 || cols < 2 || count > 16_384 || !rectangular { + return false; } - // Circle through three samples. - let (p, q, r) = (a, *pts.get(5)?, *pts.get(11)?); - let d = 2.0 * (p.x * (q.y - r.y) + q.x * (r.y - p.y) + r.x * (p.y - q.y)); - if d.abs() < 1e-300 { - return None; - } - let sq = |v: Vec2| v.x * v.x + v.y * v.y; - let cx = (sq(p) * (q.y - r.y) + sq(q) * (r.y - p.y) + sq(r) * (p.y - q.y)) / d; - let cy = (sq(p) * (r.x - q.x) + sq(q) * (p.x - r.x) + sq(r) * (q.x - p.x)) / d; - let center = Vec2::new(cx, cy); - let radius = center.dist(a); - if !pts.iter().all(|x| (x.dist(center) - radius).abs() < tol) { - return None; - } - let ang = |v: Vec2| (v - center).angle(); - let start = ang(a); - // Signed sweep by unwrapping the samples. - let mut sweep = 0.0; - let mut prev = start; - for x in pts.iter().skip(1) { - let mut da = ang(*x) - prev; - while da > std::f64::consts::PI { - da -= std::f64::consts::TAU; + let Some(left) = remaining.checked_sub(count.saturating_mul(2)) else { return false }; + *remaining = left; + let points: Vec> = match surface { + mt::Surface::BSplineSurface(s) => s.control_points().iter().map(|r| r.iter().map(|p| (from_p3(*p), 1.0)).collect()).collect(), + mt::Surface::NurbsSurface(s) => { + s.control_points().iter().map(|r| r.iter().map(|p| (Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w), p.w)).collect()).collect() } - while da < -std::f64::consts::PI { - da += std::f64::consts::TAU; + _ => return false, + }; + if points.iter().any(|r| r.len() != cols) + || points.iter().flatten().any(|(p, w)| !w.is_finite() || *w <= 0.0 || [p.x, p.y, p.z].iter().any(|x| !x.is_finite())) + { + return false; + } + let max_weight = points.iter().flatten().map(|p| p.1).fold(0.0, f64::max); + if points.iter().flatten().any(|p| p.1 / max_weight < 1e-12) { + return false; + } + // Either parameter can be the extrusion direction. Projected positions must + // be invariant in that parameter; rational weights must also separate into + // a product of one factor per parameter, otherwise mixed weights bend the wall. + for transpose in [false, true] { + let (groups, length) = if transpose { (cols, rows) } else { (rows, cols) }; + let at = |i: usize, j: usize| if transpose { points[j][i] } else { points[i][j] }; + let mut valid = true; + let mut extended = false; + for i in 0..groups { + let (base, base_w) = at(i, 0); + for j in 0..length { + let (p, w) = at(i, j); + let delta = p - base; + let magnitude = [p.x.abs(), p.y.abs(), p.z.abs(), base.x.abs(), base.y.abs(), base.z.abs()].into_iter().fold(1.0, f64::max); + if delta.cross(d).len() > tol * 1e-3 + magnitude * f64::EPSILON * 128.0 { + valid = false; + break; + } + extended |= delta.dot(d).abs() > tol * 1e-3; + let lhs = w / max_weight * (at(0, 0).1 / max_weight); + let rhs = base_w / max_weight * (at(0, j).1 / max_weight); + if (lhs - rhs).abs() > 1e-12 * lhs.abs().max(rhs.abs()) { + valid = false; + break; + } + } + if !valid { + break; + } + } + if valid && extended { + return true; } - sweep += da; - prev = ang(*x); } - if closed { - let h = sweep / 2.0; - return Some(vec![Seg2::Arc { center, radius, start, sweep: h }, Seg2::Arc { center, radius, start: start + h, sweep: h }]); - } - Some(vec![Seg2::Arc { center, radius, start, sweep }]) + false } /// The body as a prism along `d` (unit), seen in `plane` (normal `d`). fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option { - use mt::{ParametricSurface3D, SearchNearestParameter}; + // A simple extrusion with N section edges has N walls plus its cap faces. + // Even disconnected two-arc sections need at most 2N faces. Keep that + // complete planar arrangement envelope, independently of stepped limits. + let mut remaining_faces = MAX_PLANAR_SEGMENTS * 2; let mut lo = f64::INFINITY; let mut hi = f64::NEG_INFINITY; let mut tops: Vec<&mt::Face> = Vec::new(); let mut top_h = Vec::new(); let mut bottom_h = Vec::new(); + let mut wall_work = 262_144; + let mut curve_work = 2_000_000usize; for f in b.solid.face_iter() { + remaining_faces = remaining_faces.checked_sub(1)?; + curve_work = curve_work.checked_sub(1)?; let surf = f.oriented_surface(); if let mt::Surface::Plane(p) = &surf { let n = p.normal(); @@ -203,15 +245,8 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option { } continue; } - // Walls: normals square to d at sampled boundary points. - for w in f.boundaries() { - for v in w.vertex_iter() { - let (u, s) = surf.search_nearest_parameter(v.point(), None, 50)?; - let n = surf.normal(u, s); - if Vec3::new(n.x, n.y, n.z).dot(d).abs() > 1e-7 { - return None; - } - } + if !wall_along(&surf, d, tol, &mut wall_work) { + return None; } } // One height for every top (and bottom) cap. @@ -219,12 +254,17 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option { return None; } let mut regions = Vec::new(); + let mut remaining_segments = MAX_PLANAR_SEGMENTS; + let mut remaining_loops = MAX_PLANAR_SEGMENTS; for f in tops { let mut loops = Vec::new(); for w in f.boundaries() { + remaining_loops = remaining_loops.checked_sub(1)?; let mut segs = Vec::new(); for e in w.edge_iter() { - segs.extend(seg2(e, plane, tol)?); + let segments = crate::prism_curve::segment(e, plane, tol, &mut curve_work)?; + remaining_segments = remaining_segments.checked_sub(segments.len())?; + segs.extend(segments); } loops.push(Loop2 { segs }); } @@ -648,6 +688,85 @@ fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option /// Sew the exact sections between successive levels, including empty gaps. fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec], tol: f64) -> Option> { + if levels.len() != slabs.len() + 1 || slabs.len() > 128 || plane.normal().dot(d) < 1. - 1e-12 { + return None; + } + let mut blocks = Vec::new(); + let mut start = None; + for (i, slab) in slabs.iter().enumerate() { + if slab.is_empty() { + if let Some(first) = start.take() { + blocks.push((first, i)); + } + } else if start.is_none() { + start = Some(i); + } + } + if let Some(first) = start { + blocks.push((first, slabs.len())); + } + if blocks.is_empty() { + return None; + } + Some((|| { + let mut pieces = Vec::new(); + let mut remaining_constructed_faces = MAX_PLANAR_SEGMENTS * 2; + let mut remaining_faces = MAX_PLANAR_SEGMENTS * 2; + for (first, end) in blocks { + let section = slabs.get(first).ok_or_else(|| KernelError::Failed("missing prism section".into()))?; + if slabs[first..end].iter().all(|s| s == section) { + let edge_count = section.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).flat_map(|l| &l.segs).fold(0usize, |n, s| { + n.saturating_add(match s { + Seg2::Arc { sweep, .. } if sweep.abs() > 1.5 * std::f64::consts::PI => 2, + _ => 1, + }) + }); + remaining_constructed_faces = remaining_constructed_faces + .checked_sub(edge_count.saturating_add(section.len().saturating_mul(2))) + .ok_or_else(|| KernelError::Failed("prism construction face budget".into()))?; + // This is a NEW exact extrusion of the current Boolean section, + // not copied lineage from either operand's old footprint. + for piece in crate::extrude(plane, section, levels[first], levels[end])? { + remaining_faces = + remaining_faces.checked_sub(piece.face_count()).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?; + pieces.push(piece); + } + } else { + let piece = sew_connected_sections(plane, d, &levels[first..=end], &slabs[first..end], tol) + .ok_or_else(|| KernelError::Failed("stepped prism construction failed".into()))??; + remaining_faces = + remaining_faces.checked_sub(piece.face_count()).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?; + pieces.push(piece); + } + } + let mut old = std::collections::HashMap::new(); + let mut shells = Vec::new(); + let mut remaining_map_faces = MAX_PLANAR_SEGMENTS * 2; + for piece in &pieces { + for (index, face) in piece.solid.face_iter().enumerate() { + remaining_map_faces = remaining_map_faces.checked_sub(1).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?; + let source = piece.face_sources().and_then(|s| s.get(index)).and_then(Option::as_ref); + if old.insert(face.id(), Some((face.orientation(), source))).is_some() { + old.insert(face.id(), None); + } + } + shells.extend(piece.solid.boundaries().iter().cloned()); + } + let body = Body::new(crate::body::Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("prism compound: {e}")))?)?; + let sources = body + .solid + .face_iter() + .map(|face| match old.get(&face.id()) { + Some(Some((orientation, source))) if *orientation == face.orientation() => source.cloned(), + _ => None, + }) + .collect(); + Ok(body.with_face_sources(Some(std::sync::Arc::new(sources)))) + })()) +} + +/// A genuinely stepped connected axial block has no single extrusion footprint. +fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec], tol: f64) -> Option> { use crate::sew::{EdgeSpec, FaceSpec, SurfSpec}; // Faces at each level: up where the slab below sticks out, down where the one above does. let diff = |x: &[Region2], y: &[Region2]| -> Option> { @@ -727,13 +846,18 @@ fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option = Vec::new(); + let mut remaining_direction_faces = MAX_PLANAR_SEGMENTS * 2; for f in a.solid.face_iter() { + remaining_direction_faces = remaining_direction_faces.checked_sub(1)?; if let mt::Surface::Plane(p) = f.oriented_surface() { let n = p.normal(); if let Some(n) = Vec3::new(n.x, n.y, n.z).normalized() && !dirs.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9) { dirs.push(if n.dot(Vec3::new(1e-3, 1e-6, 1.0)) < 0.0 { -n } else { n }); + if dirs.len() == 8 { + break; + } } } } @@ -792,7 +916,6 @@ fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option Option> { - use mt::{ParametricSurface3D, SearchNearestParameter}; if let Some(p) = prism_of(b, d, plane, tol) { return Some(vec![p]); } @@ -800,6 +923,8 @@ fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option> { return None; } let mut events = Vec::new(); + let mut wall_work = 262_144; + let mut curve_work = 2_000_000; for f in b.solid.face_iter() { let surf = f.oriented_surface(); if let mt::Surface::Plane(p) = &surf { @@ -810,7 +935,7 @@ fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option> { for w in f.boundaries() { let mut segs = Vec::new(); for e in w.edge_iter() { - segs.extend(seg2(e, plane, tol)?); + segs.extend(crate::prism_curve::segment(e, plane, tol, &mut curve_work)?); } loops.push(Loop2 { segs }); } @@ -823,13 +948,8 @@ fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option> { return None; } } else { - // Curved walls must be extruded along d, not slanted or rounded caps. - for v in f.vertex_iter() { - let (u, w) = surf.search_nearest_parameter(v.point(), None, 50)?; - let n = surf.normal(u, w); - if Vec3::new(n.x, n.y, n.z).dot(d).abs() > 1e-7 { - return None; - } + if !wall_along(&surf, d, tol, &mut wall_work) { + return None; } } } @@ -862,9 +982,9 @@ fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option> { } fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { - // Common-cap joins and partial-depth cuts of aligned stepped prisms can + // Common-cap joins and overlapping partial-depth cuts of aligned stepped prisms can // retain their exact section boundaries instead of intersecting meshes. - // Other extents retain their existing B-rep path. + // Nonaligned bodies retain their existing B-rep path. if !matches!(op, BoolOp::Union | BoolOp::Cut) { return None; } @@ -892,8 +1012,12 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> let pa = stack_of(a, d, &plane, tol)?; let pb = stack_of(b, d, &plane, tol)?; let (ea, eb) = (ends(&pa), ends(&pb)); - if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol { - return None; + match op { + BoolOp::Union if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol => return None, + // A cut tool may extend past a cap into empty space. Exact slab sections + // discard that space rather than intersecting coincident cylinder walls. + BoolOp::Cut if ea.1 <= eb.0 + tol || eb.1 <= ea.0 + tol => return None, + _ => {} } Some((d, plane, pa, pb)) })?; @@ -945,6 +1069,246 @@ mod planar_tests { Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] } } #[test] + fn simple_prism_retains_large_analytic_section_budget() { + let count = 2050; + let radius = 1000.; + let points: Vec<_> = (0..count) + .map(|i| { + let angle = std::f64::consts::TAU * i as f64 / count as f64; + Vec2::new(radius * angle.cos(), radius * angle.sin()) + }) + .collect(); + let region = Region2 { outer: Loop2::polygon(&points), holes: vec![] }; + let stock = crate::extrude(&Plane::XY, &[region], 0., 2.).unwrap().pop().unwrap(); + assert_eq!(stock.face_count(), count + 2); + assert!(stock.face_count() > 2048 && stock.validity().is_empty()); + let before: Vec<_> = stock.solid.vertex_iter().map(|v| (v.id(), v.point())).collect(); + let recovered = prism_of(&stock, Vec3::Z, &Plane::XY, 1e-5).expect("large simple line prism remains supported"); + assert_eq!(recovered.regions.len(), 1); + assert_eq!(recovered.regions[0].outer.segs.len(), count); + assert!(recovered.regions[0].outer.segs.iter().all(|s| matches!(s, Seg2::Line { .. }))); + let area = count as f64 * radius * radius * (std::f64::consts::TAU / count as f64).sin() / 2.; + assert!((recovered.regions[0].area() - area).abs() < area * 1e-12); + assert!((recovered.lo - 0.).abs() < 1e-8 && (recovered.hi - 2.).abs() < 1e-8); + let cutter = crate::box_solid(Vec3::new(-1001., -1001., -1.), Vec3::new(1001., 1001., 3.)).unwrap(); + assert!( + simple_prism_boolean(&stock, &cutter, BoolOp::Cut) + .expect("large analytic section routes through the exact simple path") + .unwrap() + .is_none() + ); + assert_eq!(stock.solid.vertex_iter().map(|v| (v.id(), v.point())).collect::>(), before); + } + #[test] + fn curved_stop_cut_constructs_fresh_current_cap_for_only_uniform_lump() { + let cylinder = crate::cylinder(Vec3::new(25., 25., 0.), Vec3::Z, 2., 20.).unwrap(); + let stop = crate::box_solid(Vec3::new(19., 19., 1.), Vec3::new(31., 31., 2.)).unwrap(); + let roof = crate::boolean(&cylinder, &stop, BoolOp::Union).unwrap().unwrap(); + let original = rectangle(Vec2::new(20., 20.), Vec2::new(30., 30.)); + let tool = crate::extrude(&Plane::XY, &[original], 0., 25.).unwrap().pop().unwrap(); + let before_tool = crate::step_export(&[&tool], "tool").unwrap(); + let before_roof = crate::step_export(&[&roof], "roof").unwrap(); + let trimmed = crate::boolean(&tool, &roof, BoolOp::Cut).unwrap().unwrap(); + let lumps = trimmed.lumps().unwrap(); + assert_eq!(lumps.len(), 2); + let mut lower = 0; + let mut upper = 0; + for lump in &lumps { + assert!(lump.validity().is_empty()); + let mesh = lump.tessellate(1e-5).unwrap(); + if mesh.contains(Vec3::new(21., 21., 0.5)) { + lower += 1; + assert!(!mesh.contains(Vec3::new(25., 25., 0.5))); + assert!(!mesh.contains(Vec3::new(21., 21., 1.5))); + assert!((mesh.measure().volume - (100. - 4. * std::f64::consts::PI)).abs() < 0.002); + let sources = lump.face_sources().expect("fresh actual extrusion has complete sources"); + assert_eq!(sources.len(), lump.face_count()); + assert!(sources.iter().all(Option::is_some)); + let mut caps = 0; + for source in sources.iter().flatten() { + match source { + crate::FaceSource::ExtrudeCap { region, plane, .. } => { + assert_eq!(region.holes.len(), 1); + assert!((region.area() - (100. - 4. * std::f64::consts::PI)).abs() < 1e-8); + assert!(plane.normal().z.abs() > 1. - 1e-10); + assert!((plane.origin.z - if plane.normal().z > 0. { 1. } else { 0. }).abs() < 1e-8); + caps += 1; + } + crate::FaceSource::ExtrudeSide { .. } => {} + _ => panic!("old producer lineage must not survive a changed Boolean footprint"), + } + } + assert_eq!(caps, 2); + } else { + upper += 1; + assert!(mesh.contains(Vec3::new(21., 21., 3.))); + assert!(!mesh.contains(Vec3::new(25., 25., 3.))); + assert!(mesh.contains(Vec3::new(25., 25., 22.))); + assert!(lump.face_sources().is_none_or(|s| s.iter().all(Option::is_none)), "genuinely stepped geometry remains uncertified"); + assert!((mesh.measure().volume - (18. * (100. - 4. * std::f64::consts::PI) + 500.)).abs() < 0.02); + } + } + assert_eq!((lower, upper), (1, 1)); + assert_eq!(crate::step_export(&[&tool], "tool").unwrap(), before_tool); + assert_eq!(crate::step_export(&[&roof], "roof").unwrap(), before_roof); + } + #[test] + fn annular_inward_tool_recovers_exact_stacks_before_cap_policy() { + let plane = Plane::XY; + let mut region = rectangle(Vec2::ZERO, Vec2::new(40.0, 30.0)); + region.holes.push(Loop2::circle(Vec2::new(20.0, 15.0), 5.0)); + let mut stock = crate::extrude(&plane, std::slice::from_ref(®ion), 0.0, 20.0).unwrap().pop().unwrap(); + for (lo, hi) in [(20.0, 30.0), (30.0, 35.0)] { + let positive = crate::extrude(&plane, std::slice::from_ref(®ion), lo, hi).unwrap().pop().unwrap(); + stock = crate::boolean(&stock, &positive, BoolOp::Union).unwrap().unwrap(); + } + let mut grown = rectangle(Vec2::new(-0.3, -0.3), Vec2::new(40.3, 30.3)); + grown.holes = region.holes; + let tool = crate::extrude(&plane, &[grown], 30.0, 35.6).unwrap().pop().unwrap(); + let tol = stock.size().max(tool.size()) * 1e-6; + let a = stack_of(&stock, Vec3::Z, &plane, tol).expect("annular stock qualifies"); + let b = stack_of(&tool, Vec3::Z, &plane, tol).expect("overrun tool qualifies"); + assert_eq!(a.len(), 1); + assert_eq!(b.len(), 1); + assert!((a[0].lo - 0.0).abs() < tol && (a[0].hi - 35.0).abs() < tol); + assert!((b[0].lo - 30.0).abs() < tol && (b[0].hi - 35.6).abs() < tol); + assert_eq!(a[0].regions[0].holes.len(), 1); + assert_eq!(b[0].regions[0].holes.len(), 1); + assert!((a[0].regions[0].area() - (1200.0 - 25.0 * std::f64::consts::PI)).abs() < 1e-8); + assert!(simple_prism_boolean(&stock, &tool, BoolOp::Cut).is_none()); + let result = stack_boolean(&stock, &tool, BoolOp::Cut).expect("qualified overlapping stacks").unwrap().unwrap(); + assert_eq!(result.shell_count(), 1); + assert!(result.validity().is_empty()); + let mesh = result.tessellate(1e-5).unwrap(); + let expected = (1200.0 - 25.0 * std::f64::consts::PI) * 30.0; + // An inscribed circular mesh at radial chord error 1e-5 has volume + // deficit below 2*pi*R*height*error, conservatively 0.01 mm^3 here. + assert!((mesh.measure().volume - expected).abs() < 0.02); + for (point, present) in [ + (Vec3::new(5.0, 5.0, 29.0), true), + (Vec3::new(5.0, 5.0, 31.0), false), + (Vec3::new(20.0, 15.0, 29.0), false), + (Vec3::new(24.8, 15.0, 29.0), false), + (Vec3::new(25.2, 15.0, 29.0), true), + ] { + assert_eq!(mesh.contains(point), present, "{point:?}"); + } + } + #[test] + fn annular_partial_cut_overruns_and_interior_lumps_preserve_material() { + let area = 1200.0 - 25.0 * std::f64::consts::PI; + for seam in [0.0, 0.37] { + for plane in [Plane::XY, Plane::new(Vec3::new(30.0, 20.0, 10.0), Vec3::X, Vec3::Z).unwrap()] { + let mut region = rectangle(Vec2::ZERO, Vec2::new(40.0, 30.0)); + region.holes.push(Loop2::circle_from(Vec2::new(20.0, 15.0), 5.0, seam)); + let stock = crate::extrude(&plane, std::slice::from_ref(®ion), 0.0, 35.0).unwrap().pop().unwrap(); + let before = crate::step_export(&[&stock], "source").unwrap(); + for (lo, hi, retained, shells) in [(30.0, 35.6, 30.0, 1), (-0.6, 5.0, 30.0, 1), (10.0, 15.0, 30.0, 2), (30.0, 35.0, 30.0, 1)] { + let tool = crate::extrude(&plane, std::slice::from_ref(®ion), lo, hi).unwrap().pop().unwrap(); + let result = stack_boolean(&stock, &tool, BoolOp::Cut).expect("exact aligned cut path").unwrap().unwrap(); + assert_eq!(result.shell_count(), shells); + assert!(result.validity().is_empty()); + let mesh = result.tessellate(1e-5).unwrap(); + assert!((mesh.measure().volume - retained * area).abs() < 0.02); + let at = |x: f64, y: f64, z: f64| plane.to_world(Vec2::new(x, y)) + plane.normal() * z; + for z in [2.5, 12.5, 32.5] { + let material = !(lo < z && z < hi); + assert_eq!(mesh.contains(at(5.0, 5.0, z)), material); + assert!(!mesh.contains(at(20.0, 15.0, z))); + assert_eq!(mesh.contains(at(25.2, 15.0, z)), material); + } + let public = crate::boolean(&stock, &tool, BoolOp::Cut).unwrap().unwrap(); + assert!((public.tessellate(1e-5).unwrap().measure().volume - retained * area).abs() < 0.02); + if lo == 30.0 && hi > 35.0 { + let imported = crate::step_import(&crate::step_export(&[&result], "inward annular cut").unwrap()).unwrap(); + assert!(imported.warnings.is_empty()); + let saved = &imported.bodies.first().unwrap().body; + assert!((saved.tessellate(1e-5).unwrap().measure().volume - 30.0 * area).abs() < 0.02); + let next = crate::extrude(&plane, std::slice::from_ref(®ion), 25.0, 31.0).unwrap().pop().unwrap(); + let shortened = crate::boolean(saved, &next, BoolOp::Cut).unwrap().unwrap(); + let next_volume = shortened.tessellate(1e-5).unwrap().measure().volume; + assert!( + (next_volume - 25.0 * area).abs() < 0.02, + "seam {seam}, plane {plane:?}, next cut volume {next_volume}, expected {}", + 25.0 * area + ); + assert!(shortened.contains(at(5.0, 5.0, 24.0))); + assert!(!shortened.contains(at(5.0, 5.0, 26.0))); + assert!(!shortened.contains(at(20.0, 15.0, 24.0))); + } + } + let clear = crate::extrude(&plane, std::slice::from_ref(®ion), 36.0, 40.0).unwrap().pop().unwrap(); + assert!(stack_boolean(&stock, &clear, BoolOp::Cut).is_none()); + let unchanged = crate::boolean(&stock, &clear, BoolOp::Cut).unwrap().unwrap(); + assert!(std::sync::Arc::ptr_eq(&stock.solid, &unchanged.solid), "disjoint Cut must retain exact source topology"); + let unchanged_mesh = unchanged.tessellate(1e-5).unwrap(); + let source_mesh = stock.tessellate(1e-5).unwrap(); + assert_eq!(unchanged_mesh.measure().volume, source_mesh.measure().volume); + // tessellate() floors tolerance at body.size()*1e-6. The arc + // polygon's area error is at most perimeter*radial chord error. + let bound = std::f64::consts::TAU * 5.0 * 35.0 * (stock.size() * 1e-6).max(1e-5) + 1e-5; + assert!((unchanged_mesh.measure().volume - 35.0 * area).abs() < bound); + assert!(!unchanged_mesh.contains(plane.to_world(Vec2::new(20.0, 15.0)) + plane.normal() * 12.5)); + let all = crate::extrude(&plane, std::slice::from_ref(®ion), -1.0, 36.0).unwrap().pop().unwrap(); + assert!(stack_boolean(&stock, &all, BoolOp::Cut).unwrap().unwrap().is_none()); + assert_eq!(crate::step_export(&[&stock], "source").unwrap(), before); + } + } + } + #[test] + fn annular_partial_cut_preserves_unequal_radii_and_conservative_refusals() { + let mut region = rectangle(Vec2::ZERO, Vec2::new(40.0, 30.0)); + region.holes.push(Loop2::circle(Vec2::new(20.0, 15.0), 5.0)); + let stock = crate::extrude(&Plane::XY, std::slice::from_ref(®ion), 0.0, 35.0).unwrap().pop().unwrap(); + let before = crate::step_export(&[&stock], "unchanged source").unwrap(); + let mut larger_hole = region.clone(); + larger_hole.holes = vec![Loop2::circle(Vec2::new(20.0, 15.0), 5.1)]; + let tool = crate::extrude(&Plane::XY, &[larger_hole], 30.0, 36.0).unwrap().pop().unwrap(); + let result = stack_boolean(&stock, &tool, BoolOp::Cut).unwrap().unwrap().unwrap(); + let expected = (1200.0 - 25.0 * std::f64::consts::PI) * 30.0 + std::f64::consts::PI * (5.1_f64.powi(2) - 25.0) * 5.0; + let mesh = result.tessellate(1e-5).unwrap(); + assert!((mesh.measure().volume - expected).abs() < 0.02); + assert!(mesh.contains(Vec3::new(25.05, 15.0, 32.0))); + assert!(!mesh.contains(Vec3::new(25.2, 15.0, 32.0))); + assert!(!mesh.contains(Vec3::new(20.0, 15.0, 32.0))); + let interior = crate::extrude(&Plane::XY, std::slice::from_ref(®ion), 10.0, 15.0).unwrap().pop().unwrap(); + for d in [Vec3::X, Vec3::Y] { + let plane = Plane::from_normal(Vec3::ZERO, d).unwrap(); + let tol = stock.size() * 1e-6; + assert!(stack_of(&stock, d, &plane, tol).is_none(), "transverse cylinder axis must not fill the hole"); + assert!(stack_of(&interior, d, &plane, tol).is_none()); + } + assert!(stack_boolean(&stock, &interior, BoolOp::Union).is_none(), "Union retains its common-cap policy"); + let tapered = crate::extrude_tapered(&Plane::XY.offset(30.0), &rectangle(Vec2::ZERO, Vec2::new(40.0, 30.0)), 6.0, 1.0, 0.05).unwrap(); + assert!(stack_boolean(&stock, &tapered, BoolOp::Cut).is_none(), "slanted walls are not aligned prisms"); + let curved_cap = crate::sphere(Vec3::new(20.0, 15.0, 35.0), 5.0).unwrap(); + assert!(stack_boolean(&stock, &curved_cap, BoolOp::Cut).is_none()); + let pieces = + (0..350).map(|i| crate::box_solid(Vec3::new(i as f64 * 3.0, 0.0, 0.0), Vec3::new(i as f64 * 3.0 + 1.0, 1.0, 1.0)).unwrap()).collect(); + let over_budget = crate::ops::join_pieces(pieces).unwrap().unwrap(); + assert!(over_budget.face_count() > 2048); + assert!(stack_boolean(&over_budget, &interior, BoolOp::Cut).is_none()); + assert_eq!(crate::step_export(&[&stock], "unchanged source").unwrap(), before); + } + #[test] + fn curved_prism_wall_requires_positive_separable_rational_weights() { + let surface = |mixed: bool| { + let weight = if mixed { 2.0 } else { 1.0 }; + let points = vec![ + vec![mt::Vector4::new(0.0, 0.0, 0.0, 1.0), mt::Vector4::new(0.0, 0.0, 10.0, 1.0)], + vec![mt::Vector4::new(5.0, 0.0, 0.0, 1.0), mt::Vector4::new(5.0 * weight, 0.0, 10.0 * weight, weight)], + ]; + mt::Surface::NurbsSurface(mt::NurbsSurface::new(mt::BSplineSurface::new( + (mt::KnotVec::bezier_knot(1), mt::KnotVec::bezier_knot(1)), + points, + ))) + }; + assert!(wall_along(&surface(false), Vec3::Z, 1e-5, &mut 1000)); + assert!(!wall_along(&surface(true), Vec3::Z, 1e-5, &mut 1000), "parallel Euclidean controls alone do not certify a rational sweep"); + assert!(!wall_along(&surface(false), Vec3::Y, 1e-5, &mut 1000)); + assert!(!wall_along(&surface(false), Vec3::Z, 1e-5, &mut 0)); + } + #[test] fn partial_depth_cuts_touching_a_through_hole_use_exact_stacked_sections() { let plane = Plane::named("XY").unwrap(); let mut plate = rectangle(Vec2::ZERO, Vec2::new(100., 60.)); diff --git a/crates/kernel/src/prism_curve.rs b/crates/kernel/src/prism_curve.rs new file mode 100644 index 00000000..b71add6f --- /dev/null +++ b/crates/kernel/src/prism_curve.rs @@ -0,0 +1,409 @@ +//! Whole-span certificates for analytic planar prism boundaries. +//! Samples propose a circle; only positive rational Bernstein bounds accept it. +use crate::body::from_p3; +use solvecraft_geom::{Plane, Seg2, Vec2}; +use truck_modeling::{self as mt, BoundedCurve, Invertible, ParametricCurve}; + +const MAX_DEGREE: usize = 32; +const MAX_CONTROLS: usize = 4096; +type H = [f64; 3]; + +fn charge(work: &mut usize, n: usize) -> Option<()> { + *work = work.checked_sub(n)?; + Some(()) +} + +fn preflight(c: &mt::Curve, work: &mut usize) -> Option<()> { + charge(work, 1)?; + let (knots, controls) = match c { + mt::Curve::Line(_) => return Some(()), + mt::Curve::BSplineCurve(b) => (b.knot_vec(), b.control_points().len()), + mt::Curve::NurbsCurve(b) => (b.knot_vec(), b.control_points().len()), + _ => return None, + }; + let degree = knots.len().checked_sub(controls + 1)?; + if !(1..=MAX_DEGREE).contains(°ree) || controls > MAX_CONTROLS || knots.len() > 8192 { + return None; + } + if knots.iter().any(|t| !t.is_finite()) || knots.windows(2).any(|w| w[1] < w[0]) { + return None; + } + // Charge decomposition before its cloning / knot insertion allocations. + charge(work, controls.checked_mul(knots.len())?.checked_mul(degree + 1)?) +} + +fn controls(c: &mt::Curve, plane: &Plane, h: f64, tol: f64) -> Option<(Vec>, f64)> { + let spans: Vec> = match c { + mt::Curve::BSplineCurve(b) => { + b.bezier_decomposition().into_iter().map(|s| s.control_points().iter().map(|p| mt::Vector4::new(p.x, p.y, p.z, 1.)).collect()).collect() + } + mt::Curve::NurbsCurve(b) => b.non_rationalized().bezier_decomposition().into_iter().map(|s| s.control_points().clone()).collect(), + _ => return None, + }; + if spans.is_empty() || spans.len() > 256 || spans.iter().map(Vec::len).sum::() > 8192 { + return None; + } + let mut error: f64 = 0.; + let mut out = Vec::new(); + for s in spans { + if !(2..=MAX_DEGREE + 1).contains(&s.len()) { + return None; + } + let max_w = s.iter().map(|p| p.w).fold(0., f64::max); + if !max_w.is_finite() || max_w <= 0. { + return None; + } + let mut span = Vec::new(); + for p in s { + if ![p.x, p.y, p.z, p.w].iter().all(|x| x.is_finite()) || p.w / max_w < 1e-12 { + return None; + } + let v = from_p3(mt::Point3::new(p.x / p.w, p.y / p.w, p.z / p.w)); + let local = plane.to_local(v); + let magnitude = v.x.abs() + v.y.abs() + v.z.abs() + plane.origin.len(); + let e = magnitude * f64::EPSILON * 256.; + if !local.x.is_finite() || !local.y.is_finite() || (plane.height(v) - h).abs() + e > tol { + return None; + } + error = error.max(e); + let w = p.w / max_w; + span.push([local.x * w, local.y * w, w]); + } + out.push(span); + } + (error < tol * 0.1).then_some((out, error)) +} + +fn product(a: &[f64], b: &[f64], work: &mut usize) -> Option> { + charge(work, a.len().checked_mul(b.len())?)?; + let (m, n) = (a.len() - 1, b.len() - 1); + let choose = |degree: usize| { + let mut v = vec![1.; degree + 1]; + for i in 1..=degree { + v[i] = v[i - 1] * (degree + 1 - i) as f64 / i as f64; + } + v + }; + let (cm, cn, ct) = (choose(m), choose(n), choose(m + n)); + let mut out = vec![0.; m + n + 1]; + for (i, ai) in a.iter().enumerate() { + for (j, bj) in b.iter().enumerate() { + out[i + j] += ai * bj * cm[i] * cn[j] / ct[i + j]; + } + } + out.iter().all(|x| x.is_finite()).then_some(out) +} + +fn derivative(a: &[f64]) -> Vec { + let n = (a.len() - 1) as f64; + a.windows(2).map(|w| (w[1] - w[0]) * n).collect() +} + +fn determinant(a: &[f64], b: &[f64], work: &mut usize) -> Option<(Vec, f64)> { + let (ab, ba) = (product(a, &derivative(b), work)?, product(b, &derivative(a), work)?); + let max = |v: &[f64]| v.iter().map(|x| x.abs()).fold(0., f64::max); + // Bernstein product weights are positive and sum to one. Bound absolute + // summands from the inputs, never from a possibly cancelled output sum. + let scale = max(a) * max(&derivative(b)) + max(b) * max(&derivative(a)); + let error = scale * f64::EPSILON * 128. * (a.len() * a.len()) as f64; + Some((ab.iter().zip(&ba).map(|(x, y)| x - y).collect(), error)) +} + +fn split(c: &[H]) -> (Vec, Vec) { + let mut q = c.to_vec(); + let mut left = vec![q[0]]; + let mut right = vec![q[q.len() - 1]]; + while q.len() > 1 { + q = q.windows(2).map(|p| std::array::from_fn(|i| (p[0][i] + p[1][i]) * 0.5)).collect(); + left.push(q[0]); + right.push(q[q.len() - 1]); + } + right.reverse(); + (left, right) +} + +fn point(p: H) -> Vec2 { + Vec2::new(p[0] / p[2], p[1] / p[2]) +} + +fn line_span(c: &[H], a: Vec2, b: Vec2, tol: f64, error: f64, work: &mut usize) -> Option<()> { + let d = b - a; + let length = d.len(); + if length <= tol { + return None; + } + let d = d / length; + let mut projected = Vec::new(); + let weights: Vec<_> = c.iter().map(|p| p[2]).collect(); + for p in c { + let v = point(*p) - a; + let t = v.dot(d); + if (v.x * d.y - v.y * d.x).abs() + error > tol || t < -tol + error || t > length + tol - error { + return None; + } + projected.push(t * p[2]); + } + // W*T' - T*W' proves oriented traversal, rather than merely collinearity. + let (der, mut err) = determinant(&weights, &projected, work)?; + err += error * (c.len() * c.len()) as f64 * 32.; + if !der.iter().all(|d| *d > err) { + return None; + } + Some(()) +} + +fn circle_span(c: &[H], center: Vec2, radius: f64, tol: f64, source_error: f64, depth: usize, work: &mut usize) -> Option { + charge(work, c.len())?; + let x: Vec<_> = c.iter().map(|p| p[0] - center.x * p[2]).collect(); + let y: Vec<_> = c.iter().map(|p| p[1] - center.y * p[2]).collect(); + let w: Vec<_> = c.iter().map(|p| p[2]).collect(); + let min_w = w.iter().copied().fold(f64::INFINITY, f64::min); + if min_w < 1e-12 { + return None; + } + let (xx, yy, ww) = (product(&x, &x, work)?, product(&y, &y, work)?, product(&w, &w, work)?); + let max = |v: &[f64]| v.iter().map(|x| x.abs()).fold(0., f64::max); + let scale = max(&x).powi(2) + max(&y).powi(2) + radius * radius * max(&w).powi(2); + let coordinate_error = source_error + (center.len() + radius) * f64::EPSILON * (depth + 1) as f64 * 128. * c.len() as f64; + let roundoff = scale * f64::EPSILON * 256. * (c.len() * c.len()) as f64; + let residual = xx.iter().zip(&yy).zip(&ww).map(|((a, b), w)| (a + b - radius * radius * w).abs()).fold(0., f64::max); + let radial_tol = tol - coordinate_error; + if radial_tol <= 0. || (residual + roundoff) / (min_w * min_w) > radial_tol * (2. * radius - radial_tol) { + return None; + } + let (angular, mut error) = determinant(&x, &y, work)?; + let extent = x.iter().chain(&y).map(|v| v.abs()).fold(0., f64::max); + error += coordinate_error * extent * (c.len() * c.len()) as f64 * 32.; + let positive = angular.iter().all(|v| *v > error); + let negative = angular.iter().all(|v| *v < -error); + let a = point(c[0]) - center; + let b = point(c[c.len() - 1]) - center; + let mid = a + b; + // One open angular half-plane plus a derivative sign rules out hidden turns. + let half_plane = mid.len() > radius * 1e-8 && c.iter().all(|p| (point(*p) - center).dot(mid) > coordinate_error * mid.len()); + if (positive || negative) && half_plane { + let cross = a.x * b.y - a.y * b.x; + let sweep = cross.atan2(a.dot(b)); + if (positive && sweep > 0.) || (negative && sweep < 0.) { + return Some(sweep); + } + return None; + } + if depth >= 12 { + return None; + } + let (left, right) = split(c); + let a = circle_span(&left, center, radius, tol, source_error, depth + 1, work)?; + let b = circle_span(&right, center, radius, tol, source_error, depth + 1, work)?; + (a.signum() == b.signum()).then_some(a + b) +} + +pub(super) fn segment(e: &mt::Edge, plane: &Plane, tol: f64, work: &mut usize) -> Option> { + if !tol.is_finite() || tol <= 0. { + return None; + } + let mut c = e.curve(); + preflight(&c, work)?; + if !e.orientation() { + c.invert(); + } + let (t0, t1) = c.range_tuple(); + if !t0.is_finite() || !t1.is_finite() || t1 <= t0 { + return None; + } + let (a3, b3) = (from_p3(c.subs(t0)), from_p3(c.subs(t1))); + let (a, b) = (plane.to_local(a3), plane.to_local(b3)); + if !a3.is_finite() + || !b3.is_finite() + || ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite()) + || a3.dist(from_p3(e.front().point())) > tol + || b3.dist(from_p3(e.back().point())) > tol + { + return None; + } + if matches!(c, mt::Curve::Line(_)) { + let error = (a3.len() + b3.len() + plane.origin.len()) * f64::EPSILON * 256.; + return (error < tol * 0.1 && a.dist(b) > tol && (plane.height(a3) - plane.height(b3)).abs() + error <= tol) + .then_some(vec![Seg2::Line { a, b }]); + } + let (spans, error) = controls(&c, plane, plane.height(a3), tol)?; + if spans.windows(2).any(|pair| point(pair[0][pair[0].len() - 1]).dist(point(pair[1][0])) + 2. * error > tol) + || point(spans.first()?.first().copied()?).dist(a) + error > tol + || point(spans.last()?.last().copied()?).dist(b) + error > tol + { + return None; + } + if a.dist(b) > tol && spans.iter().all(|s| line_span(s, a, b, tol, error, work).is_some()) { + return Some(vec![Seg2::Line { a, b }]); + } + // Samples are only a candidate proposal. Whole-span implicit and oriented + // angular proofs below are required before converting any spline to an arc. + let q = plane.to_local(from_p3(c.subs(t0 + (t1 - t0) * 5. / 16.))) - a; + let r = plane.to_local(from_p3(c.subs(t0 + (t1 - t0) * 11. / 16.))) - a; + let det = 2. * (q.x * r.y - q.y * r.x); + if !det.is_finite() || det.abs() < 1e-300 { + return None; + } + let offset = Vec2::new((q.dot(q) * r.y - r.dot(r) * q.y) / det, (q.x * r.dot(r) - r.x * q.dot(q)) / det); + let (center, radius) = (a + offset, offset.len()); + if !radius.is_finite() || radius <= tol || !center.x.is_finite() || !center.y.is_finite() { + return None; + } + let mut sweep: f64 = 0.; + for span in &spans { + let ds = circle_span(span, center, radius, tol, error, 0, work)?; + if sweep != 0. && sweep.signum() != ds.signum() { + return None; + } + sweep += ds; + } + let start = (a - center).angle(); + let end = center + Vec2::new((start + sweep).cos(), (start + sweep).sin()) * radius; + if end.dist(b) + error > tol { + return None; + } + let closed = a.dist(b) < tol; + let angular_tol = tol / radius; + if sweep.abs() > std::f64::consts::TAU + angular_tol || sweep.abs() < angular_tol { + return None; + } + if closed { + if (sweep.abs() - std::f64::consts::TAU).abs() > angular_tol { + return None; + } + let h = sweep.signum() * std::f64::consts::PI; + Some(vec![Seg2::Arc { center, radius, start, sweep: h }, Seg2::Arc { center, radius, start: start + h, sweep: h }]) + } else { + Some(vec![Seg2::Arc { center, radius, start, sweep }]) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use mt::builder; + + fn choose(n: usize, k: usize) -> f64 { + (0..k).fold(1., |v, i| v * (n - i) as f64 / (i + 1) as f64) + } + fn bernstein(power: &[f64], degree: usize) -> Vec { + (0..=degree).map(|i| (0..=i.min(power.len() - 1)).map(|k| power[k] * choose(i, k) / choose(degree, k)).sum()).collect() + } + fn multiply(a: &[f64], b: &[f64]) -> Vec { + let mut out = vec![0.; a.len() + b.len() - 1]; + for (i, x) in a.iter().enumerate() { + for (j, y) in b.iter().enumerate() { + out[i + j] += x * y; + } + } + out + } + fn trap() -> Vec { + let mut p = vec![1.]; + for k in 0..=16 { + p = multiply(&p, &[-(k as f64) / 16., 1.]); + } + let mid = (0..=16).map(|k| 1. / 32. - k as f64 / 16.).product::(); + p.iter_mut().for_each(|v| *v /= mid); + p + } + fn edge(x: &[f64], y: &[f64], w: &[f64], degree: usize) -> mt::Edge { + let (x, y, w) = (bernstein(x, degree), bernstein(y, degree), bernstein(w, degree)); + let controls = x.iter().zip(y).zip(w).map(|((x, y), w)| mt::Vector4::new(*x, y, 0., w)).collect(); + let c = mt::Curve::NurbsCurve(mt::NurbsCurve::new(mt::BSplineCurve::new(mt::KnotVec::bezier_knot(degree), controls))); + let a = mt::Vertex::new(c.subs(0.)); + let b = mt::Vertex::new(c.subs(1.)); + mt::Edge::new(&a, &b, c) + } + + #[test] + fn sample_aliasing_does_not_certify_spline_caps() { + let e = edge(&[0., 10.], &trap(), &[1.], 17); + for k in 0..=16 { + assert!(e.curve().subs(k as f64 / 16.).y.abs() < 1e-4, "old sample locations must see a line"); + } + assert!((e.curve().subs(1. / 32.).y - 1.).abs() < 1e-6); + assert!(segment(&e, &Plane::XY, 1e-3, &mut 2_000_000).is_none()); + let front = e.front(); + let back = e.back(); + let low_right = mt::Vertex::new(mt::Point3::new(10., -3., 0.)); + let low_left = mt::Vertex::new(mt::Point3::new(0., -3., 0.)); + let wire: mt::Wire = + vec![e.clone(), builder::line(back, &low_right), builder::line(&low_right, &low_left), builder::line(&low_left, front)].into(); + let cap = builder::try_attach_plane(&[wire]).unwrap(); + let body = crate::body::Body::new(builder::tsweep(&cap, mt::Vector3::new(0., 0., 1.))).unwrap(); + assert!(body.validity().is_empty()); + let tool = crate::box_solid(solvecraft_geom::Vec3::new(-1., -4., 0.5), solvecraft_geom::Vec3::new(12., 7., 1.)).unwrap(); + assert!( + crate::prism::prism_boolean(&body, &tool, crate::BoolOp::Cut).is_none(), + "an actual extruded aliased spline cap must reach the generic route" + ); + let q = trap(); + let mut perturb = q.iter().map(|v| v * 0.1).collect::>(); + perturb[0] += 1.; + let w = std::f64::consts::FRAC_1_SQRT_2; + let x = multiply(&[10., 20. * (w - 1.), 10. * (1. - 2. * w)], &perturb); + let y = multiply(&[0., 20. * w, 10. * (1. - 2. * w)], &perturb); + let circle = edge(&x, &y, &[1., 2. * (w - 1.), 2. * (1. - w)], 19); + for k in 0..=16 { + let p = circle.curve().subs(k as f64 / 16.); + assert!((p.x.hypot(p.y) - 10.).abs() < 1e-3, "old sample locations must see a circle"); + } + let p = circle.curve().subs(1. / 32.); + assert!((p.x.hypot(p.y) - 11.).abs() < 1e-5); + assert!(segment(&circle, &Plane::XY, 1e-3, &mut 2_000_000).is_none()); + } + + #[test] + fn genuine_curves_and_oriented_traversal_are_certified() { + let a = mt::Vertex::new(mt::Point3::new(10., 0., 0.)); + let b = mt::Vertex::new(mt::Point3::new(0., 10., 0.)); + let arc = builder::circle_arc(&a, &b, mt::Point3::new(10. / 2f64.sqrt(), 10. / 2f64.sqrt(), 0.)); + for e in [&arc, &arc.inverse()] { + let result = segment(e, &Plane::XY, 1e-5, &mut 2_000_000).unwrap(); + let Seg2::Arc { center, radius, sweep, .. } = result[0] else { panic!("expected arc") }; + assert!(center.len() < 1e-8 && (radius - 10.).abs() < 1e-8); + assert!((sweep.abs() - std::f64::consts::FRAC_PI_2).abs() < 1e-8); + assert_eq!(sweep > 0., e.orientation()); + } + let line = edge(&[0., 10.], &[0.], &[1.], 17); + assert!(matches!(segment(&line, &Plane::XY, 1e-5, &mut 2_000_000).unwrap()[0], Seg2::Line { .. })); + // The longitudinal derivative is negative at t=1/3, independently + // establishing genuine retracing rather than a stationary tangent. + let backtrack = edge(&[0., 10., -35., 35.], &[0.], &[1.], 3); + assert!(backtrack.curve().der(1. / 3.).x < -1.); + assert!(segment(&backtrack, &Plane::XY, 1e-5, &mut 2_000_000).is_none()); + assert!(segment(&arc, &Plane::XY, 1e-5, &mut 0).is_none()); + } + + #[test] + fn discontinuous_and_multiple_winding_curves_decline() { + let edge_from = |knots: Vec, points: Vec| { + let c = mt::Curve::NurbsCurve(mt::NurbsCurve::new(mt::BSplineCurve::new(mt::KnotVec::from(knots), points))); + let (lo, hi) = c.range_tuple(); + mt::Edge::new(&mt::Vertex::new(c.subs(lo)), &mt::Vertex::new(c.subs(hi)), c) + }; + let w = std::f64::consts::FRAC_1_SQRT_2; + let p = |x, y, w| mt::Vector4::new(x, y, 0., w); + let disconnected = edge_from( + vec![0., 0., 0., 1., 1., 1., 2., 2., 2.], + vec![p(10., 0., 1.), p(10. * w, 10. * w, w), p(0., 10., 1.), p(-10., 0., 1.), p(-10. * w, -10. * w, w), p(0., -10., 1.)], + ); + assert!(segment(&disconnected, &Plane::XY, 1e-5, &mut 2_000_000).is_none()); + let skipped_line = edge_from(vec![0., 0., 1., 1., 2., 2.], vec![p(0., 0., 1.), p(2., 0., 1.), p(5., 0., 1.), p(10., 0., 1.)]); + assert!(segment(&skipped_line, &Plane::XY, 1e-5, &mut 2_000_000).is_none()); + let mut points = vec![p(10., 0., 1.)]; + let mut knots = vec![0., 0., 0.]; + for i in 0..8 { + let a = i as f64 * std::f64::consts::FRAC_PI_2; + let b = a + std::f64::consts::FRAC_PI_2; + points.push(p(10. * ((a + b) / 2.).cos(), 10. * ((a + b) / 2.).sin(), w)); + points.push(p(10. * b.cos(), 10. * b.sin(), 1.)); + knots.extend([i as f64 + 1.; 2]); + } + knots.push(8.); + let twice = edge_from(knots, points); + assert!(segment(&twice, &Plane::XY, 1e-5, &mut 2_000_000).is_none()); + } +} diff --git a/crates/render/src/curve_display.rs b/crates/render/src/curve_display.rs new file mode 100644 index 00000000..4098680a --- /dev/null +++ b/crates/render/src/curve_display.rs @@ -0,0 +1,286 @@ +//! Pixel-bounded display paths. CAD segments remain untouched; positive homogeneous +//! control hulls bound perspective projection, including looping and rational curves. +use solvecraft_geom::{Plane, Seg2, Vec2, Vec3}; + +use crate::Mat4; + +const MAX_VERTICES: usize = 4096; +const MAX_DEPTH: u8 = 24; +const PIXEL_ERROR: f64 = 0.5; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PixelCurveView { + pub vp: Mat4, + pub width_px: f64, + pub height_px: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SamplingQuality { + Complete, + BudgetLimited, + Invalid, +} + +/// Shared per-frame allocation and traversal limits. Each segment also has a local cap. +#[derive(Clone, Debug)] +pub struct SamplingBudget { + pub remaining_vertices: usize, + pub remaining_nodes: usize, +} + +impl SamplingBudget { + pub fn new(max_vertices: usize, max_nodes: usize) -> Self { + Self { remaining_vertices: max_vertices.min(1_000_000), remaining_nodes: max_nodes.min(2_000_000) } + } +} + +impl Default for SamplingBudget { + fn default() -> Self { + Self::new(200_000, 400_000) + } +} + +#[derive(Clone, Debug)] +pub struct SampledPath { + pub parts: Vec>, + pub quality: SamplingQuality, + pub vertex_count: usize, +} + +type H = [f64; 4]; + +fn mix(a: H, b: H, t: f64) -> H { + std::array::from_fn(|i| a[i] * (1.0 - t) + b[i] * t) +} + +fn halves(c: &[H]) -> (Vec, Vec) { + let mut work = c.to_vec(); + let mut l = Vec::with_capacity(c.len()); + let mut r = Vec::with_capacity(c.len()); + while let (Some(a), Some(b)) = (work.first(), work.last()) { + l.push(*a); + r.push(*b); + if work.len() == 1 { + break; + } + work = work.windows(2).map(|p| mix(p[0], p[1], 0.5)).collect(); + } + r.reverse(); + (l, r) +} + +fn world(h: H) -> Option { + let p = Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3]); + (h[3] > 0.0 && [p.x, p.y, p.z].iter().all(|x| x.is_finite())).then_some(p) +} + +fn project_h(m: Mat4, h: H) -> H { + std::array::from_fn(|r| (0..4).map(|c| m.0[c][r] * h[c]).sum()) +} + +// Wgpu clip volume (depth 0..w), plus positive w to avoid bridging the eye plane. +fn distances(p: H, view: &PixelCurveView, clip_xy: bool) -> [f64; 7] { + // A one-pixel guard band retains the approximating chord of every visible + // curve point, even when that chord lies just outside the viewport. + let x = if clip_xy { p[3] * (1.0 + 2.0 / view.width_px) } else { f64::INFINITY }; + let y = if clip_xy { p[3] * (1.0 + 2.0 / view.height_px) } else { f64::INFINITY }; + [x + p[0], x - p[0], y + p[1], y - p[1], p[2], p[3] - p[2], p[3] - 1e-12] +} + +fn screen(p: H, view: &PixelCurveView) -> Vec2 { + Vec2::new(p[0] / p[3] * view.width_px * 0.5, p[1] / p[3] * view.height_px * 0.5) +} + +fn distance_segment(p: Vec2, a: Vec2, b: Vec2) -> f64 { + let d = b - a; + let t = ((p - a).dot(d) / d.dot(d).max(1e-300)).clamp(0.0, 1.0); + p.dist(a + d * t) +} + +fn controls(segment: &Seg2, plane: &Plane) -> Option<(Vec>, H)> { + let mut terms: H = [0.0; 4]; + let mut h = |p: Vec2, w: f64| { + for (r, (o, x, y)) in [(plane.origin.x, plane.x.x, plane.y.x), (plane.origin.y, plane.x.y, plane.y.y), (plane.origin.z, plane.x.z, plane.y.z)] + .into_iter() + .enumerate() + { + terms[r] = terms[r].max((o.abs() + x.abs() * p.x.abs() + y.abs() * p.y.abs()) * w.abs()); + } + terms[3] = terms[3].max(w.abs()); + let p = plane.to_world(p); + [p.x * w, p.y * w, p.z * w, w] + }; + let spans = match *segment { + Seg2::Line { a, b } => vec![vec![h(a, 1.0), h(b, 1.0)]], + Seg2::Cubic { p0, p1, p2, p3 } => vec![vec![h(p0, 1.0), h(p1, 1.0), h(p2, 1.0), h(p3, 1.0)]], + Seg2::Conic { a, apex, b, w } if w > 0.0 => vec![vec![h(a, 1.0), h(apex, w), h(b, 1.0)]], + Seg2::Bezier { n, p } if (1..=7).contains(&n) => vec![p[..=n as usize].iter().map(|p| h(*p, 1.0)).collect()], + Seg2::Arc { center, radius, start, sweep } + if radius > 0.0 && start.is_finite() && sweep.is_finite() && sweep.abs() <= std::f64::consts::TAU + 1e-9 => + { + let count = (sweep.abs() / std::f64::consts::FRAC_PI_2).ceil().max(1.0) as usize; + (0..count) + .map(|i| { + let a = start + sweep * i as f64 / count as f64; + let b = start + sweep * (i + 1) as f64 / count as f64; + let mid = (a + b) * 0.5; + let w = ((b - a) * 0.5).cos(); + let p = |t: f64, r: f64| center + Vec2::new(t.cos(), t.sin()) * r; + vec![h(p(a, radius), 1.0), h(p(mid, radius / w), w), h(p(b, radius), 1.0)] + }) + .collect() + } + _ => return None, + }; + spans.iter().flatten().all(|p| p.iter().all(|x| x.is_finite()) && p[3] > 0.0).then_some((spans, terms)) +} + +/// Samples one exact segment to a conservative 0.5 physical-pixel control-hull +/// bound. Clipped gaps remain separate parts. Budget-limited paths may omit unresolved +/// portions; callers must not treat their quality as an accuracy certificate. +pub fn sample_segment(segment: &Seg2, plane: &Plane, view: &PixelCurveView, budget: &mut SamplingBudget) -> SampledPath { + sample(segment, plane, view, budget, true) +} + +/// Retains offscreen XY boundaries for display-only fill triangulation. Eye/near/far +/// clipping and budgets still apply; only a complete closed path can form a fill. +pub fn sample_segment_unclipped(segment: &Seg2, plane: &Plane, view: &PixelCurveView, budget: &mut SamplingBudget) -> SampledPath { + sample(segment, plane, view, budget, false) +} + +fn sample(segment: &Seg2, plane: &Plane, view: &PixelCurveView, budget: &mut SamplingBudget, clip_xy: bool) -> SampledPath { + let mut out = SampledPath { parts: Vec::new(), quality: SamplingQuality::Complete, vertex_count: 0 }; + if !view.width_px.is_finite() + || !view.height_px.is_finite() + || view.width_px <= 0.0 + || view.height_px <= 0.0 + || view.width_px > 1e7 + || view.height_px > 1e7 + || view.vp.0.iter().flatten().any(|x| !x.is_finite()) + { + out.quality = SamplingQuality::Invalid; + return out; + } + let Some((spans, source_terms)) = controls(segment, plane) else { + out.quality = SamplingQuality::Invalid; + return out; + }; + let mut stack: Vec<_> = spans + .into_iter() + .rev() + .map(|c| { + let magnitude: H = std::array::from_fn(|i| c.iter().map(|p| p[i].abs()).fold(source_terms[i], f64::max)); + let terms: H = std::array::from_fn(|r| (0..4).map(|i| view.vp.0[i][r].abs() * magnitude[i]).sum()); + (c, 0u8, terms) + }) + .collect(); + let mut connected = false; + let mut nodes = 0usize; + while let Some((c, depth, terms)) = stack.pop() { + if budget.remaining_nodes == 0 || nodes == 32_768 { + out.quality = SamplingQuality::BudgetLimited; + break; + } + budget.remaining_nodes -= 1; + nodes += 1; + let q: Vec = c.iter().map(|h| project_h(view.vp, *h)).collect(); + if q.iter().flatten().any(|x| !x.is_finite()) { + out.quality = SamplingQuality::Invalid; + break; + } + let e: H = terms.map(|t| t * f64::EPSILON * (f64::from(depth) + 4.0) * 64.0); + let clip_error = [ + e[0] + e[3] * (1.0 + 2.0 / view.width_px), + e[0] + e[3] * (1.0 + 2.0 / view.width_px), + e[1] + e[3] * (1.0 + 2.0 / view.height_px), + e[1] + e[3] * (1.0 + 2.0 / view.height_px), + e[2], + e[2] + e[3], + e[3], + ]; + if (0..7).any(|i| q.iter().all(|p| distances(*p, view, clip_xy)[i] < -clip_error[i])) { + connected = false; + continue; + } + let positive = q.iter().all(|p| p[3] > 1e-12); + let depth_safe = c.len() == 2 || (4..7).all(|i| q.iter().all(|p| distances(*p, view, clip_xy)[i] >= 0.0)); + let flat = positive + && q.first().zip(q.last()).is_some_and(|(a, b)| { + let (a, b) = (screen(*a, view), screen(*b, view)); + let magnitude = q.iter().map(|p| screen(*p, view).len()).fold(1.0, f64::max); + // Keep floating-point roundoff inside the advertised bound. + // Absolute summands also cover cancellation in VP multiplication. + // Subdivision propagates roundoff from the original homogeneous hull. + let projection_error = q + .iter() + .map(|p| { + let safe_w = p[3] - e[3]; + if safe_w <= 0.0 { + return f64::INFINITY; + } + let x = (e[0] + (p[0] / p[3]).abs() * e[3]) / safe_w * view.width_px * 0.5; + let y = (e[1] + (p[1] / p[3]).abs() * e[3]) / safe_w * view.height_px * 0.5; + x.hypot(y) + }) + .fold(0.0, f64::max); + let allowance = projection_error + magnitude * f64::EPSILON * 128.0; + allowance < PIXEL_ERROR && q.iter().all(|p| distance_segment(screen(*p, view), a, b) + allowance <= PIXEL_ERROR) + }); + if !flat || !depth_safe { + if depth == MAX_DEPTH || out.vertex_count >= MAX_VERTICES { + out.quality = SamplingQuality::BudgetLimited; + connected = false; + continue; + } + let (l, r) = halves(&c); + stack.push((r, depth + 1, terms)); + stack.push((l, depth + 1, terms)); + continue; + } + let (Some(a), Some(b), Some(qa), Some(qb)) = (c.first(), c.last(), q.first(), q.last()) else { continue }; + let mut lo: f64 = 0.0; + let mut hi: f64 = 1.0; + for (a, b) in distances(*qa, view, clip_xy).into_iter().zip(distances(*qb, view, clip_xy)) { + if a < 0.0 && b < 0.0 { + hi = -1.0; + break; + } + if a < 0.0 { + lo = lo.max(a / (a - b)); + } else if b < 0.0 { + hi = hi.min(a / (a - b)); + } + } + if lo > hi { + connected = false; + continue; + } + let (Some(a), Some(b)) = (world(mix(*a, *b, lo)), world(mix(*a, *b, hi))) else { + out.quality = SamplingQuality::Invalid; + break; + }; + // Exact consecutive subdivision endpoints share their homogeneous value. + let join = connected && lo == 0.0 && out.parts.last().and_then(|p| p.last()).is_some_and(|p| *p == a); + let need = if join { 1 } else { 2 }; + if need > budget.remaining_vertices || out.vertex_count + need > MAX_VERTICES { + out.quality = SamplingQuality::BudgetLimited; + break; + } + budget.remaining_vertices -= need; + out.vertex_count += need; + if join { + if let Some(part) = out.parts.last_mut() { + part.push(b); + } + } else { + out.parts.push(vec![a, b]); + } + connected = hi == 1.0; + } + out +} + +#[cfg(test)] +#[path = "curve_display_tests.rs"] +mod tests; diff --git a/crates/render/src/curve_display_tests.rs b/crates/render/src/curve_display_tests.rs new file mode 100644 index 00000000..a3f45505 --- /dev/null +++ b/crates/render/src/curve_display_tests.rs @@ -0,0 +1,121 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +use super::*; + +fn plane() -> Plane { + Plane::new(Vec3::ZERO, Vec3::X, Vec3::Y).unwrap() +} + +fn view(scale: f64, pixel_scale: f64) -> PixelCurveView { + PixelCurveView { + vp: Mat4([[scale, 0.0, 0.0, 0.0], [0.0, scale, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]), + width_px: 1400.0 * pixel_scale, + height_px: 900.0 * pixel_scale, + } +} + +fn dense_error(s: &Seg2, plane: &Plane, v: &PixelCurveView, path: &SampledPath) -> f64 { + let segments: Vec<_> = + path.parts.iter().flat_map(|part| part.windows(2)).map(|w| (screen(v.vp.apply(w[0]), v), screen(v.vp.apply(w[1]), v))).collect(); + (0..=12_000) + .filter_map(|i| { + let q = v.vp.apply(plane.to_world(s.point_at(i as f64 / 12_000.0))); + (distances(q, v, true).iter().all(|x| *x >= 0.0)) + .then(|| segments.iter().map(|(a, b)| distance_segment(screen(q, v), *a, *b)).fold(f64::INFINITY, f64::min)) + }) + .fold(0.0, f64::max) +} + +#[test] +fn exact_arcs_ellipses_rational_and_looping_control_hulls_bound_pixels() { + let mut degree7 = [Vec2::ZERO; 8]; + for (i, p) in degree7.iter_mut().enumerate() { + *p = Vec2::new(i as f64 / 4.0 - 0.875, if i % 2 == 0 { -0.8 } else { 0.8 }); + } + let curves = [ + Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: 0.27, sweep: -5.8 }, + Seg2::Conic { a: Vec2::new(0.8, 0.0), apex: Vec2::new(0.8, 0.025), b: Vec2::new(0.0, 0.025), w: 0.5_f64.sqrt() }, + Seg2::Conic { a: Vec2::new(-0.8, -0.4), apex: Vec2::new(0.0, 0.8), b: Vec2::new(0.8, -0.4), w: 0.06 }, + Seg2::Cubic { p0: Vec2::new(-0.8, 0.0), p1: Vec2::new(0.9, 0.9), p2: Vec2::new(-0.9, -0.9), p3: Vec2::new(0.8, 0.0) }, + Seg2::Cubic { p0: Vec2::new(0.0, -0.4), p1: Vec2::new(1.0, 0.9), p2: Vec2::new(-1.0, 0.9), p3: Vec2::new(0.0, -0.4) }, + Seg2::Bezier { n: 7, p: degree7 }, + ]; + for scale in [0.003, 0.03, 0.3, 1.3] { + for pixels in [1.0, 1.5, 2.0] { + for s in curves { + let v = view(scale, pixels); + let path = sample_segment(&s, &plane(), &v, &mut SamplingBudget::default()); + assert_eq!(path.quality, SamplingQuality::Complete, "{s:?}"); + assert!(dense_error(&s, &plane(), &v, &path) <= 0.501, "{s:?}"); + assert!(path.vertex_count < 1024); + } + } + } +} + +#[test] +fn fit_circle_zoom_budget_and_closed_unclipped_fill() { + let s = Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: 0.0, sweep: std::f64::consts::TAU }; + let small = sample_segment(&s, &plane(), &view(0.003, 1.0), &mut SamplingBudget::default()); + let fitted = sample_segment(&s, &plane(), &view(219.0 / 700.0, 1.0), &mut SamplingBudget::default()); + assert!(fitted.vertex_count > small.vertex_count); + assert!(dense_error(&s, &plane(), &view(219.0 / 700.0, 1.0), &fitted) < 0.5); + let fill = sample_segment_unclipped(&s, &plane(), &view(3.0, 2.0), &mut SamplingBudget::default()); + assert_eq!(fill.quality, SamplingQuality::Complete); + assert_eq!(fill.parts.len(), 1); + let part = &fill.parts[0]; + assert!(part.first().unwrap().dist(*part.last().unwrap()) < 1e-12); + let limited = sample_segment_unclipped(&s, &plane(), &view(1e20, 1.0), &mut SamplingBudget::new(64, 1000)); + assert_eq!(limited.quality, SamplingQuality::BudgetLimited); + assert!(limited.vertex_count <= 64); + assert!(limited.parts.iter().flatten().all(|p| [p.x, p.y, p.z].iter().all(|v| v.is_finite()))); +} + +#[test] +fn perspective_tilt_exact_s_curve_and_clipping_do_not_bridge_gaps() { + let p = Plane::new(Vec3::new(0.0, 0.0, -2.0), Vec3::new(0.8, 0.0, 0.6), Vec3::Y).unwrap(); + let v = PixelCurveView { + vp: Mat4([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, -1.01, -1.0], [0.0, 0.0, -0.101, 0.0]]), + width_px: 900.0, + height_px: 720.0, + }; + let s = Seg2::Cubic { p0: Vec2::new(-0.9, 0.0), p1: Vec2::new(0.9, 1.0), p2: Vec2::new(-0.9, -1.0), p3: Vec2::new(0.9, 0.0) }; + let path = sample_segment(&s, &p, &v, &mut SamplingBudget::default()); + assert_eq!(path.quality, SamplingQuality::Complete); + assert!(dense_error(&s, &p, &v, &path) < 0.501); + let crossing = Plane::new(Vec3::new(0.0, 0.0, -0.15), Vec3::new(0.6, 0.0, 0.8), Vec3::Y).unwrap(); + let s = Seg2::Arc { center: Vec2::ZERO, radius: 0.2, start: std::f64::consts::PI, sweep: std::f64::consts::TAU }; + let path = sample_segment_unclipped(&s, &crossing, &v, &mut SamplingBudget::default()); + assert!(!path.parts.is_empty()); + assert_eq!(path.quality, SamplingQuality::BudgetLimited); + for point in path.parts.iter().flatten() { + let q = v.vp.apply(*point); + assert!(q[3] > 0.0 && q[2] >= -1e-10); + } + assert!(path.parts.len() >= 2); +} + +#[test] +fn invalid_weights_matrices_and_shared_frame_exhaustion_are_explicit() { + let bad = Seg2::Conic { a: Vec2::ZERO, apex: Vec2::X, b: Vec2::Y, w: -1.0 }; + assert_eq!(sample_segment(&bad, &plane(), &view(1.0, 1.0), &mut SamplingBudget::default()).quality, SamplingQuality::Invalid); + let line = Seg2::Line { a: Vec2::ZERO, b: Vec2::X }; + let mut budget = SamplingBudget::new(2, 2); + assert_eq!(sample_segment(&line, &plane(), &view(0.2, 1.0), &mut budget).quality, SamplingQuality::Complete); + assert_eq!(sample_segment(&line, &plane(), &view(0.2, 1.0), &mut budget).quality, SamplingQuality::BudgetLimited); + let mut bad = view(1.0, 1.0); + bad.vp.0[0][0] = f64::NAN; + assert_eq!(sample_segment(&line, &plane(), &bad, &mut SamplingBudget::default()).quality, SamplingQuality::Invalid); + // The projected coordinates themselves are small, but huge VP summands cancel. + // Do not certify accuracy from the small result alone. + let far = Plane::new(Vec3::new(1e15, 0.0, 0.0), Vec3::X, Vec3::Y).unwrap(); + let mut cancellation = view(0.2, 1.0); + cancellation.vp.0[3][0] = -2e14; + let path = sample_segment_unclipped(&line, &far, &cancellation, &mut SamplingBudget::new(16, 100)); + assert_eq!(path.quality, SamplingQuality::BudgetLimited); + assert!(path.vertex_count <= 16); + let plane_cancellation = Plane::new(Vec3::new(1e9, 0.0, 0.0), Vec3::X, Vec3::Y).unwrap(); + let local_circle = Seg2::Arc { center: Vec2::new(-1e9 + 1.0, 0.0), radius: 1.0, start: 0.0, sweep: std::f64::consts::TAU }; + let path = sample_segment_unclipped(&local_circle, &plane_cancellation, &view(1e9, 1.0), &mut SamplingBudget::new(16, 100)); + assert_eq!(path.quality, SamplingQuality::BudgetLimited); + assert!(path.vertex_count <= 16); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 56d940d0..90f5e999 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -5,7 +5,9 @@ #![forbid(unsafe_code)] mod camera; +mod curve_display; mod raster; pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out}; +pub use curve_display::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped}; pub use raster::{Canvas, Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent}; diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index e48cccdd..fa909052 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -15,6 +15,7 @@ mod linalg; mod link; mod model; mod offset; +mod offset_topology; mod periodic; mod profiles; mod shape; @@ -31,6 +32,7 @@ pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE}; pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES}; pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView}; pub use offset::OffsetChain; +pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRoleKind, OffsetTopology}; pub use periodic::periodic_beziers; pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions}; pub use shape::{Shape, intersections}; @@ -40,3 +42,6 @@ pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline}; #[cfg(test)] mod tests; + +#[cfg(test)] +mod virtual_corner_tests; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index 274c9a0b..cf20eb60 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -429,7 +429,7 @@ impl Sketch { if self.points.len() + lay.pts.len() > crate::model::MAX_POINTS { return Err(SketchError::TooLarge); } - let id = self.fresh("j"); + let id = self.fresh("j")?; self.links.push(Link { id: id.clone(), kind, source, face_name: None, auto_face: false, lost: false }); self.build_link(&id, &lay)?; Ok(id) @@ -468,7 +468,7 @@ impl Sketch { (CurveKind::Spline { pts, control, degree, periodic }, "s") } }; - let cid = self.fresh(prefix); + let cid = self.fresh(prefix)?; self.curves.push(Curve { id: cid, kind, diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 8f1be9fd..fa0f6016 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -385,7 +385,7 @@ impl ConstraintKind { LinearDiameter { p, l, .. } => (vec![p], vec![l]), } } - fn remap(&mut self, pmap: &dyn Fn(usize) -> Option, cmap: &dyn Fn(usize) -> Option) -> bool { + pub(crate) fn remap(&mut self, pmap: &dyn Fn(usize) -> Option, cmap: &dyn Fn(usize) -> Option) -> bool { use ConstraintKind::*; let mut ok = true; let mut fp = |i: &mut usize| match pmap(*i) { @@ -540,16 +540,50 @@ impl Sketch { } /// A new unused id with the given prefix (`l` → `l3`). - pub fn fresh(&mut self, prefix: &str) -> String { + pub fn fresh(&mut self, prefix: &str) -> Result { let mut n = self.counters.get(prefix).copied().unwrap_or(0); - loop { - n += 1; + n = n.checked_add(1).ok_or_else(|| SketchError::Invalid("sketch identity counter exhausted".into()))?; + let first = format!("{prefix}{n}"); + if !self.id_taken(&first) { + self.counters.insert(prefix.to_string(), n); + return Ok(first); + } + let budget = self + .points + .len() + .saturating_add(self.curves.len()) + .saturating_add(self.constraints.len()) + .saturating_add(self.links.len()) + .saturating_add(self.wires.len()) + .saturating_add( + self.offsets.iter().filter_map(|r| r.topology.as_ref()).fold(0usize, |n, r| n.saturating_add(r.roles.len()).saturating_add(1)), + ) + .saturating_add(1); + // A damaged counter may start before a long occupied prefix. Index + // reservations once so probing that prefix cannot rescan all geometry + // for each candidate ID. + let mut taken: std::collections::BTreeSet<&str> = self + .points + .iter() + .map(|p| p.id.as_str()) + .chain(self.curves.iter().map(|c| c.id.as_str())) + .chain(self.constraints.iter().map(|c| c.id.as_str())) + .chain(self.links.iter().map(|l| l.id.as_str())) + .chain(self.wires.iter().map(|w| w.id.as_str())) + .collect(); + for topology in self.offsets.iter().filter_map(|r| r.topology.as_ref()) { + taken.insert(topology.id.as_str()); + taken.extend(topology.roles.iter().map(|r| r.output.as_str())); + } + for _ in 0..budget.saturating_sub(1) { + n = n.checked_add(1).ok_or_else(|| SketchError::Invalid("sketch identity counter exhausted".into()))?; let id = format!("{prefix}{n}"); - if !self.id_taken(&id) { + if !taken.contains(id.as_str()) { self.counters.insert(prefix.to_string(), n); - return id; + return Ok(id); } } + Err(SketchError::TooLarge) } pub(crate) fn id_taken(&self, id: &str) -> bool { self.points.iter().any(|p| p.id == id) @@ -557,6 +591,7 @@ impl Sketch { || self.constraints.iter().any(|c| c.id == id) || self.links.iter().any(|l| l.id == id) || self.wires.iter().any(|w| w.id == id) + || self.offsets.iter().filter_map(|r| r.topology.as_ref()).any(|r| r.id == id || r.roles.iter().any(|role| role.output == id)) } pub fn point_index(&self, id: &str) -> Option { @@ -583,7 +618,7 @@ impl Sketch { Some(i) if self.id_taken(i) => return Err(SketchError::Duplicate(i.into())), Some("") => return Err(SketchError::Invalid("empty id".into())), Some(i) => i.to_string(), - None => self.fresh("p"), + None => self.fresh("p")?, }; self.points.push(SPoint { id, pos: p, fixed: false, link: None }); Ok(self.points.len() - 1) @@ -594,7 +629,7 @@ impl Sketch { Some(i) if self.id_taken(i) => Err(SketchError::Duplicate(i.into())), Some("") => Err(SketchError::Invalid("empty id".into())), Some(i) => Ok(i.into()), - None => Ok(self.fresh(prefix)), + None => self.fresh(prefix), } } @@ -701,7 +736,7 @@ impl Sketch { return Err(SketchError::TooLarge); } self.validate(&kind)?; - let id = self.fresh("k"); + let id = self.fresh("k")?; self.constraints.push(Constraint { id: id.clone(), kind, param, driven: false, text: None }); Ok(id) } @@ -901,36 +936,68 @@ impl Sketch { /// Each with the extension segments from the corner to the nearest end of those curves, for /// drawing. pub fn virtual_corners(&self) -> Vec<(usize, Vec<(Vec2, Vec2)>)> { - let mut out = Vec::new(); - for (i, p) in self.points.iter().enumerate().skip(1) { - if self.point_used_by_curve(i) { + const MAX_POINT_REFERENCES: usize = 1_000_000; + if self.points.len() > MAX_POINTS || self.constraints.len() > MAX_CONSTRAINTS || self.curves.len() > MAX_POINT_REFERENCES { + return Vec::new(); + } + let mut remaining = MAX_POINT_REFERENCES; + for curve in &self.curves { + let count = match &curve.kind { + CurveKind::Line { .. } | CurveKind::Ellipse { .. } => 2, + CurveKind::Circle { .. } => 1, + CurveKind::Arc { .. } | CurveKind::Conic { .. } => 3, + CurveKind::Spline { pts, .. } => pts.len(), + }; + let Some(left) = remaining.checked_sub(count) else { return Vec::new() }; + remaining = left; + } + // Index curve usage once, including centres and spline interior points. + let mut used = vec![false; self.points.len()]; + for curve in &self.curves { + for point in curve.kind.point_ids() { + if let Some(slot) = used.get_mut(point) { + *slot = true; + } + } + } + let mut incidence = vec![Vec::new(); self.points.len()]; + let mut seen = std::collections::HashSet::new(); + for constraint in &self.constraints { + let ConstraintKind::PointOnCurve { p, c } = constraint.kind else { continue }; + if p == 0 || used.get(p).copied() != Some(false) || !self.point(p).is_some_and(|p| p.is_finite()) { continue; } - let on: Vec = self - .constraints - .iter() - .filter_map(|c| match c.kind { - ConstraintKind::PointOnCurve { p, c } if p == i => Some(c), - _ => None, - }) - .filter(|c| matches!(self.curves.get(*c).map(|c| &c.kind), Some(CurveKind::Line { .. } | CurveKind::Arc { .. }))) - .collect(); + let Some(curve) = self.curves.get(c) else { continue }; + if !matches!(curve.kind, CurveKind::Line { .. } | CurveKind::Arc { .. }) + || curve.kind.point_ids().iter().any(|i| !self.point(*i).is_some_and(|p| p.is_finite())) + { + continue; + } + if seen.insert((p, c)) + && let Some(on) = incidence.get_mut(p) + { + on.push(c); + } + } + let mut out = Vec::new(); + for (i, on) in incidence.into_iter().enumerate().skip(1) { if on.len() < 2 { continue; } + let Some(p) = self.point(i) else { continue }; let mut ext = Vec::new(); for c in on { let Some((a, b)) = self.curves.get(c).and_then(|c| c.kind.ends()) else { continue }; let (Some(pa), Some(pb)) = (self.point(a), self.point(b)) else { continue }; - let near = if p.pos.dist(pa) <= p.pos.dist(pb) { pa } else { pb }; + let near = if p.dist(pa) <= p.dist(pb) { pa } else { pb }; // A line only needs one when the corner is beyond its ends. let inside = matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. })) && { let d = pb - pa; - let t = (p.pos - pa).dot(d) / d.dot(d).max(1e-300); + let t = (p - pa).dot(d) / d.dot(d).max(1e-300); (0.0..=1.0).contains(&t) }; - if !inside && p.pos.dist(near) > 1e-9 { - ext.push((p.pos, near)); + if !inside && p.dist(near) > 1e-9 { + ext.push((p, near)); } } out.push((i, ext)); diff --git a/crates/sketch/src/offset.rs b/crates/sketch/src/offset.rs index cca8ec3e..00e22236 100644 --- a/crates/sketch/src/offset.rs +++ b/crates/sketch/src/offset.rs @@ -9,6 +9,8 @@ pub struct OffsetChain { pub outputs: Vec, pub distance: f64, pub closed: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub topology: Option, } impl Sketch { @@ -57,7 +59,11 @@ impl Sketch { } fn refresh_analytic_offsets(&mut self) -> Result<(), SketchError> { - for offset in self.offsets.clone() { + for index in 0..self.offsets.len() { + if self.refresh_convex_offset(index)? { + continue; + } + let offset = self.offsets.get(index).cloned().ok_or_else(|| SketchError::Invalid("missing offset chain".into()))?; 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/sketch/src/offset_topology.rs b/crates/sketch/src/offset_topology.rs new file mode 100644 index 00000000..aea8bb0a --- /dev/null +++ b/crates/sketch/src/offset_topology.rs @@ -0,0 +1,847 @@ +//! Exact convex parallel boundaries with persistent extinct arc identities. +use std::collections::{BTreeMap, BTreeSet}; +use std::f64::consts::TAU; + +use serde::{Deserialize, Serialize}; +use solvecraft_geom::{Seg2, Vec2}; + +use crate::{Curve, CurveKind, OffsetChain, Sketch, SketchError}; +type Result = std::result::Result; +const MAX_GROUPS: usize = 128; +const MAX_ROLES: usize = 512; +const MAX_TOTAL_ROLES: usize = 20_000; +const MAX_WORK: usize = 262_144; +fn invalid(message: &str) -> SketchError { + SketchError::Invalid(message.into()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OffsetRoleKind { + Line, + Arc, +} +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct OffsetRoleFlags { + pub construction: bool, + pub centerline: bool, + pub reversed: bool, + pub fixed: bool, +} +impl OffsetRoleFlags { + fn is_default(&self) -> bool { + *self == Self::default() + } + fn of(curve: &Curve) -> Self { + Self { construction: curve.construction, centerline: curve.centerline, reversed: curve.reversed, fixed: curve.fixed } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct OffsetRole { + pub output: String, + pub kind: OffsetRoleKind, + pub start: String, + pub end: String, + pub retired: bool, + #[serde(default, skip_serializing_if = "OffsetRoleFlags::is_default")] + pub flags: OffsetRoleFlags, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct OffsetTopology { + pub version: u8, + pub id: String, + pub roles: Vec, +} +#[derive(Clone, Debug, PartialEq)] +pub struct OffsetCreated { + pub association: String, + pub curves: Vec, +} + +#[derive(Clone, Copy)] +struct Arc { + centre: Vec2, + radius: f64, + start: f64, + sweep: f64, + centre_index: usize, +} +#[derive(Clone, Copy)] +struct Source { + a: Vec2, + b: Vec2, + arc: Option, +} +impl Source { + fn tangent(&self, end: bool, orientation: f64) -> Option { + match self.arc { + Some(arc) => (if end { self.b } else { self.a } - arc.centre).normalized().map(|r| r.perp() * orientation), + None => (self.b - self.a).normalized(), + } + } +} +struct Boundary { + source: Vec, + orientation: f64, + pad: f64, +} +#[derive(Clone, Copy)] +struct Span { + a: Vec2, + b: Vec2, + arc: Option, + retired: bool, +} +fn angle_in(angle: f64, arc: Arc) -> bool { + (angle - arc.start).rem_euclid(TAU) <= arc.sweep + 1e-12 +} +fn max_dot(v: Vec2, arc: Arc) -> f64 { + let mut value = v.dot(Vec2::from_angle(arc.start)).max(v.dot(Vec2::from_angle(arc.start + arc.sweep))); + if angle_in(v.angle(), arc) { + value = value.max(v.len()); + } + value +} +fn arc_projection(arc: Arc, n: Vec2) -> f64 { + arc.centre.dot(n) + arc.radius * max_dot(n, arc) +} +fn arc_against_arc(output: Arc, source: Arc) -> f64 { + let delta = output.centre - source.centre; + let mut candidates = vec![source.start, source.start + source.sweep]; + for theta in [output.start, output.start + output.sweep, delta.angle()] { + if angle_in(theta, source) { + candidates.push(theta); + } + } + for phi in [output.start, output.start + output.sweep] { + let theta = (delta + Vec2::from_angle(phi) * output.radius).angle(); + if angle_in(theta, source) { + candidates.push(theta); + } + } + candidates + .into_iter() + .map(|theta| { + let n = Vec2::from_angle(theta); + delta.dot(n) + output.radius * max_dot(n, output) + }) + .fold(f64::NEG_INFINITY, f64::max) +} + +impl Sketch { + fn convex_boundary(&self, sources: &[(String, bool)]) -> Result> { + if !(3..=MAX_ROLES).contains(&sources.len()) { + return Ok(None); + } + let mut seen = BTreeSet::new(); + let mut source = Vec::with_capacity(sources.len()); + for (id, forward) in sources { + if !seen.insert(id) { + return Err(invalid("duplicate convex offset source")); + } + let curve = self + .curves + .get(self.curve_index(id).ok_or_else(|| invalid("offset source vanished"))?) + .ok_or_else(|| invalid("offset source vanished"))?; + let get = |i| self.point(i).ok_or_else(|| invalid("offset source point vanished")); + let piece = match curve.kind { + CurveKind::Line { a, b } => { + let (a, b) = (get(a)?, get(b)?); + if *forward { Source { a, b, arc: None } } else { Source { a: b, b: a, arc: None } } + } + CurveKind::Arc { c, a, b } => { + let (centre, a, b) = (get(c)?, get(a)?, get(b)?); + let radius = centre.dist(a); + let start = (a - centre).angle(); + let sweep = ((b - centre).angle() - start).rem_euclid(TAU); + if !radius.is_finite() || radius <= 1e-9 || sweep <= 1e-9 || (centre.dist(b) - radius).abs() > 1e-6 { + return Ok(None); + } + let arc = Some(Arc { centre, radius, start, sweep, centre_index: c }); + if *forward { Source { a, b, arc } } else { Source { a: b, b: a, arc } } + } + _ => return Ok(None), + }; + if !piece.a.is_finite() || !piece.b.is_finite() || piece.a.dist(piece.b) <= 1e-9 { + return Ok(None); + } + source.push(piece); + } + let origin = source.first().ok_or_else(|| invalid("empty offset boundary"))?.a; + let scale = source.iter().flat_map(|p| [p.a, p.b]).map(|p| p.dist(origin)).fold(1.0, f64::max); + let pad = 1e-7 + 256.0 * f64::EPSILON * scale; + if !pad.is_finite() || pad >= 0.002 { + return Err(invalid("convex offset coordinate precision exceeds certificate")); + } + let mut area = 0.0; + for (i, piece) in source.iter().enumerate() { + if piece.b.dist(source.get((i + 1) % source.len()).ok_or_else(|| invalid("missing offset neighbour"))?.a) > pad { + return Ok(None); + } + area += match piece.arc { + Some(arc) => { + let forward = sources.get(i).is_some_and(|x| x.1); + Seg2::Arc { + center: arc.centre - origin, + radius: arc.radius, + start: if forward { arc.start } else { arc.start + arc.sweep }, + sweep: if forward { arc.sweep } else { -arc.sweep }, + } + .area_term() + } + None => (piece.a - origin).cross(piece.b - origin) * 0.5, + }; + } + if !area.is_finite() || area.abs() <= pad * scale { + return Ok(None); + } + let orientation = area.signum(); + let mut turn = 0.0; + let mut lines = 0; + for (i, piece) in source.iter().enumerate() { + if let Some(arc) = piece.arc { + if sources.get(i).is_none_or(|x| if orientation > 0.0 { !x.1 } else { x.1 }) { + return Ok(None); + } + turn += arc.sweep; + } else { + lines += 1; + } + let end = piece.tangent(true, orientation).ok_or_else(|| invalid("singular offset tangent"))?; + let next = + source.get((i + 1) % source.len()).and_then(|p| p.tangent(false, orientation)).ok_or_else(|| invalid("singular offset tangent"))?; + if end.dot(next) < 1.0 - 1e-8 || end.cross(next).abs() > 1e-6 { + return Ok(None); + } + } + // A regular closed G1 boundary with monotone curvature and total turning + // exactly 2pi is a simple convex boundary. Reject loops and sharp sources. + if lines < 3 || (turn - TAU).abs() > 1e-6 { + return Ok(None); + } + Ok(Some(Boundary { source, orientation, pad })) + } + pub fn convex_offset_supported(&self, sources: &[(String, bool)]) -> Result { + Ok(self.convex_boundary(sources)?.is_some()) + } +} +impl Boundary { + fn build(&self, distance: f64) -> Result> { + if !distance.is_finite() || distance.abs() <= 1e-9 || distance.abs() >= 1e9 { + return Err(invalid("invalid convex offset distance")); + } + let inset = distance * self.orientation; + let mut spans = Vec::with_capacity(self.source.len()); + for piece in &self.source { + let span = match piece.arc { + Some(mut arc) => { + arc.radius -= inset; + let retired = arc.radius <= 1e-9; + if retired { + Span { a: piece.a, b: piece.b, arc: Some(arc), retired: true } + } else { + let a = arc.centre + (piece.a - arc.centre).normalized().ok_or_else(|| invalid("singular offset arc"))? * arc.radius; + let b = arc.centre + (piece.b - arc.centre).normalized().ok_or_else(|| invalid("singular offset arc"))? * arc.radius; + Span { a, b, arc: Some(arc), retired: false } + } + } + None => { + let shift = (piece.b - piece.a).normalized().ok_or_else(|| invalid("singular offset line"))?.perp() * distance; + Span { a: piece.a + shift, b: piece.b + shift, arc: None, retired: false } + } + }; + spans.push(span); + } + let active: Vec<_> = spans.iter().enumerate().filter_map(|(i, s)| (!s.retired).then_some(i)).collect(); + if active.len() < 3 { + return Err(invalid("the convex offset region disappears")); + } + for (position, i) in active.iter().copied().enumerate() { + let j = *active.get((position + 1) % active.len()).ok_or_else(|| invalid("missing active offset neighbour"))?; + let a = *spans.get(i).ok_or_else(|| invalid("missing offset span"))?; + let b = *spans.get(j).ok_or_else(|| invalid("missing offset span"))?; + let next = (i + 1) % spans.len(); + let joint = if next == j { + if a.b.dist(b.a) > self.pad { + return Err(invalid("offset tangent junction no longer meets")); + } + a.b + } else { + let retired = *spans.get(next).ok_or_else(|| invalid("missing retired offset span"))?; + if (next + 1) % spans.len() != j + || !retired.retired + || a.arc.is_some() + || b.arc.is_some() + || retired.arc.is_none_or(|x| x.sweep >= std::f64::consts::PI - 1e-8) + { + return Err(invalid("offset arc extinction requires two adjacent straight supports")); + } + let (u, v) = (a.b - a.a, b.b - b.a); + let cross = u.cross(v); + if cross.abs() <= 1e-10 * u.len() * v.len() { + return Err(invalid("ambiguous offset extinction junction")); + } + let q = a.a + u * ((b.a - a.a).cross(v) / cross); + if !q.is_finite() || q.x.abs() >= 1e9 || q.y.abs() >= 1e9 { + return Err(invalid("unbounded offset extinction junction")); + } + if let Some(s) = spans.get_mut(next) { + s.a = q; + s.b = q; + } + q + }; + if let Some(s) = spans.get_mut(i) { + s.b = joint; + } + if let Some(s) = spans.get_mut(j) { + s.a = joint; + } + } + let mut work = 0usize; + for (index, span) in spans.iter().enumerate().filter(|(_, s)| !s.retired) { + if span.a.dist(span.b) <= self.pad { + return Err(invalid("the convex offset region disappears")); + } + if span.arc.is_none() { + let source = self.source.get(index).ok_or_else(|| invalid("missing source span"))?; + if (span.b - span.a).dot(source.b - source.a) <= self.pad * source.a.dist(source.b) { + return Err(invalid("offset line is overrun by its neighbours")); + } + } + for source in &self.source { + work += 1; + if work > MAX_WORK { + return Err(invalid("convex offset certificate work limit")); + } + let (maximum, limit) = match source.arc { + Some(arc) => { + let maximum = match span.arc { + Some(output) => arc_against_arc(output, arc), + None => max_dot(span.a - arc.centre, arc).max(max_dot(span.b - arc.centre, arc)), + }; + (maximum, arc.radius - inset) + } + None => { + let normal = (source.b - source.a).normalized().ok_or_else(|| invalid("singular source support"))?.perp() * -self.orientation; + let maximum = match span.arc { + Some(mut arc) => { + arc.centre -= source.a; + arc_projection(arc, normal) + } + None => (span.a - source.a).dot(normal).max((span.b - source.a).dot(normal)), + }; + (maximum, -inset) + } + }; + if !maximum.is_finite() || maximum > limit + self.pad { + return Err(invalid("convex offset fails whole-boundary normal support certificate")); + } + } + } + Ok(spans) + } +} + +impl Sketch { + /// Validate saved role ownership without evaluating source geometry/parameters. + pub fn validate_offset_topologies(&self) -> Result<()> { + if self.offsets.iter().all(|r| r.topology.is_none()) { + return Ok(()); + } + let mut groups = BTreeSet::new(); + let mut outputs = BTreeSet::new(); + let mut total = 0usize; + let point_index: BTreeMap<_, _> = self.points.iter().enumerate().map(|(i, p)| (p.id.as_str(), i)).collect(); + let curve_index: BTreeMap<_, _> = self.curves.iter().enumerate().map(|(i, p)| (p.id.as_str(), i)).collect(); + let mut owners: BTreeMap> = BTreeMap::new(); + for curve in &self.curves { + for p in curve.kind.point_ids() { + owners.entry(p).or_default().insert(curve.id.as_str()); + } + } + let other_ids: BTreeSet<&str> = self + .points + .iter() + .map(|x| x.id.as_str()) + .chain(self.constraints.iter().map(|x| x.id.as_str())) + .chain(self.links.iter().map(|x| x.id.as_str())) + .chain(self.wires.iter().map(|x| x.id.as_str())) + .collect(); + let id_ok = |id: &str| !id.is_empty() && id.len() <= 512; + for record in &self.offsets { + let Some(topology) = &record.topology else { continue }; + total = total.checked_add(topology.roles.len()).ok_or(SketchError::TooLarge)?; + if topology.version != 1 + || !record.closed + || !id_ok(&topology.id) + || !(3..=MAX_ROLES).contains(&topology.roles.len()) + || total > MAX_TOTAL_ROLES + || topology.roles.len() != record.sources.len() + || !record.distance.is_finite() + || record.distance.abs() <= 1e-9 + || record.distance.abs() >= 1e9 + || !groups.insert(topology.id.as_str()) + || groups.len() > MAX_GROUPS + || curve_index.contains_key(topology.id.as_str()) + || other_ids.contains(topology.id.as_str()) + { + return Err(invalid("damaged convex offset topology")); + } + let active: Vec<_> = topology.roles.iter().filter(|r| !r.retired).map(|r| r.output.as_str()).collect(); + if active.len() < 3 || !record.outputs.iter().map(String::as_str).eq(active) { + return Err(invalid("damaged offset active role order")); + } + let record_outputs: BTreeSet<_> = topology.roles.iter().map(|r| r.output.as_str()).collect(); + let mut sources = BTreeSet::new(); + for ((source, forward), role) in record.sources.iter().zip(&topology.roles) { + if !id_ok(source) + || !sources.insert(source.as_str()) + || !id_ok(&role.output) + || !id_ok(&role.start) + || !id_ok(&role.end) + || role.start == role.end + || role.output == *source + || !outputs.insert(role.output.as_str()) + || other_ids.contains(role.output.as_str()) + { + return Err(invalid("damaged offset reserved identity")); + } + let source_curve = self + .curves + .get(*curve_index.get(source.as_str()).ok_or_else(|| invalid("missing offset source role"))?) + .ok_or_else(|| invalid("missing offset source role"))?; + let centre = match (&source_curve.kind, role.kind) { + (CurveKind::Line { .. }, OffsetRoleKind::Line) => None, + (CurveKind::Arc { c, .. }, OffsetRoleKind::Arc) => Some(*c), + _ => return Err(invalid("offset source changed role kind")), + }; + if role.retired && role.kind != OffsetRoleKind::Arc { + return Err(invalid("only offset arcs may retire")); + } + let a = *point_index.get(role.start.as_str()).ok_or_else(|| invalid("missing offset endpoint role"))?; + let b = *point_index.get(role.end.as_str()).ok_or_else(|| invalid("missing offset endpoint role"))?; + for p in [a, b] { + if p == 0 + || self.points.get(p).is_none_or(|p| !p.fixed || p.link.is_some()) + || owners.get(&p).is_none_or(|users| users.is_empty() || users.iter().any(|id| !record_outputs.contains(id))) + { + return Err(invalid("offset endpoints must be fixed private group roles")); + } + } + if role.retired { + if curve_index.contains_key(role.output.as_str()) { + return Err(invalid("retired offset curve is still active")); + } + } else { + let output = self + .curves + .get(*curve_index.get(role.output.as_str()).ok_or_else(|| invalid("missing active offset curve"))?) + .ok_or_else(|| invalid("missing active offset curve"))?; + let valid = match output.kind { + CurveKind::Line { a: pa, b: pb } => role.kind == OffsetRoleKind::Line && pa == a && pb == b, + CurveKind::Arc { c, a: pa, b: pb } => { + role.kind == OffsetRoleKind::Arc && centre == Some(c) && if *forward { pa == a && pb == b } else { pa == b && pb == a } + } + _ => false, + }; + if !valid || output.link.is_some() { + return Err(invalid("active offset curve has incompatible point roles")); + } + } + } + if sources.iter().any(|id| record_outputs.contains(id)) { + return Err(invalid("offset source/output alias")); + } + } + if groups.iter().any(|id| outputs.contains(id)) { + return Err(invalid("offset group/output identity alias")); + } + // Index associations once, rather than copying every source for every + // output. Charge all legacy records too, before allocating the graph. + let mut budget = self.offsets.len().saturating_add(self.freeform_offsets.len()); + for r in &self.offsets { + budget = budget.saturating_add(r.sources.len()).saturating_add(r.topology.as_ref().map_or(r.outputs.len(), |t| t.roles.len())); + } + budget = budget.saturating_add(self.freeform_offsets.len().saturating_mul(2)); + if budget > MAX_WORK { + return Err(invalid("offset dependency work limit")); + } + let count = self.offsets.len() + self.freeform_offsets.len(); + let mut producer = BTreeMap::new(); + for (index, record) in self.offsets.iter().enumerate() { + let ids: Box + '_> = match &record.topology { + Some(t) => Box::new(t.roles.iter().map(|r| r.output.as_str())), + None => Box::new(record.outputs.iter().map(String::as_str)), + }; + for id in ids { + if producer.insert(id, index).is_some() { + return Err(invalid("offset output has multiple producers")); + } + } + } + for (index, record) in self.freeform_offsets.iter().enumerate() { + if producer.insert(record.output.as_str(), self.offsets.len() + index).is_some() { + return Err(invalid("offset output has multiple producers")); + } + } + let mut edges = vec![BTreeSet::new(); count]; + for (index, record) in self.offsets.iter().enumerate() { + for (source, _) in &record.sources { + if let Some(parent) = producer.get(source.as_str()) { + edges.get_mut(index).ok_or_else(|| invalid("invalid dependency node"))?.insert(*parent); + } + } + } + for (index, record) in self.freeform_offsets.iter().enumerate() { + if let Some(parent) = producer.get(record.source.as_str()) { + edges.get_mut(self.offsets.len() + index).ok_or_else(|| invalid("invalid dependency node"))?.insert(*parent); + } + } + let mut colour = vec![0u8; count]; + for start in 0..count { + if colour.get(start).is_none_or(|c| *c != 0) { + continue; + } + let mut stack = vec![(start, false)]; + while let Some((node, finish)) = stack.pop() { + if finish { + *colour.get_mut(node).ok_or_else(|| invalid("invalid dependency node"))? = 2; + continue; + } + let state = *colour.get(node).ok_or_else(|| invalid("invalid dependency node"))?; + if state == 2 { + continue; + } + if state == 1 { + return Err(invalid("cyclic offset association")); + } + *colour.get_mut(node).ok_or_else(|| invalid("invalid dependency node"))? = 1; + stack.push((node, true)); + for &parent in edges.get(node).ok_or_else(|| invalid("invalid dependency node"))? { + stack.push((parent, false)); + } + } + } + Ok(()) + } + pub fn offset_boundary(&self, id: &str) -> Result> { + let mut matching = self.offsets.iter().filter(|r| r.topology.as_ref().is_some_and(|t| t.id == id)); + let record = matching.next().ok_or_else(|| invalid("unknown offset boundary association"))?; + if matching.next().is_some() { + return Err(invalid("ambiguous offset boundary association")); + } + Ok(record.outputs.clone()) + } + fn match_offset_boundary(&self, ids: &[String], reserved: bool) -> Result> { + let requested: BTreeSet<_> = ids.iter().map(String::as_str).collect(); + if ids.is_empty() || requested.len() != ids.len() { + return Err(invalid("duplicate or empty offset boundary selection")); + } + let mut found = None; + for record in &self.offsets { + let Some(topology) = &record.topology else { continue }; + let expected: BTreeSet<_> = if reserved { + topology.roles.iter().map(|r| r.output.as_str()).collect() + } else { + record.outputs.iter().map(String::as_str).collect() + }; + if expected == requested { + if found.is_some() { + return Err(invalid("ambiguous offset boundary selection")); + } + found = Some(topology.id.clone()); + } + } + Ok(found) + } + pub fn offset_boundary_for_curves(&self, ids: &[String]) -> Result> { + self.match_offset_boundary(ids, false) + } + pub fn offset_boundary_for_reserved_curves(&self, ids: &[String]) -> Result> { + self.match_offset_boundary(ids, true) + } + + pub fn add_convex_offset(&mut self, sources: Vec<(String, bool)>, distance: f64) -> Result { + let mut candidate = self.clone(); + let boundary = candidate.convex_boundary(&sources)?.ok_or_else(|| invalid("offset source is not a closed convex G1 analytic boundary"))?; + let spans = boundary.build(distance)?; + if candidate.offsets.iter().filter(|r| r.topology.is_some()).count() >= MAX_GROUPS { + return Err(SketchError::TooLarge); + } + let association = candidate.fresh("o")?; + let mut nodes = Vec::new(); + // One node per original directed joint. Retired arcs keep both endpoint + // roles, each still used by an adjacent surviving straight output. + for span in &spans { + let point = candidate.add_point(span.a, None)?; + if let Some(p) = candidate.points.get_mut(point) { + p.fixed = true; + } + nodes.push(point); + } + let mut roles = Vec::new(); + let mut outputs = Vec::new(); + for (i, span) in spans.iter().enumerate() { + let a = *nodes.get(i).ok_or_else(|| invalid("missing output node"))?; + let b = *nodes.get((i + 1) % nodes.len()).ok_or_else(|| invalid("missing output node"))?; + let kind = if span.arc.is_some() { OffsetRoleKind::Arc } else { OffsetRoleKind::Line }; + let output = candidate.fresh(if kind == OffsetRoleKind::Arc { "a" } else { "l" })?; + let role = OffsetRole { + output: output.clone(), + kind, + start: candidate.points.get(a).ok_or_else(|| invalid("missing output node"))?.id.clone(), + end: candidate.points.get(b).ok_or_else(|| invalid("missing output node"))?.id.clone(), + retired: span.retired, + flags: OffsetRoleFlags::default(), + }; + if !span.retired { + let curve_kind = match span.arc { + Some(arc) => { + if sources.get(i).is_some_and(|x| x.1) { + CurveKind::Arc { c: arc.centre_index, a, b } + } else { + CurveKind::Arc { c: arc.centre_index, a: b, b: a } + } + } + None => CurveKind::Line { a, b }, + }; + candidate.curves.push(Curve { + id: output.clone(), + kind: curve_kind, + construction: false, + reversed: false, + link: None, + centerline: false, + fixed: false, + }); + outputs.push(output); + } + roles.push(role); + } + candidate.offsets.push(OffsetChain { + sources, + outputs: outputs.clone(), + distance, + closed: true, + topology: Some(OffsetTopology { version: 1, id: association.clone(), roles }), + }); + candidate.validate_offset_topologies()?; + *self = candidate; + Ok(OffsetCreated { association, curves: outputs }) + } + + pub(crate) fn refresh_convex_offset(&mut self, index: usize) -> Result { + let record = self.offsets.get(index).cloned().ok_or_else(|| invalid("missing offset association"))?; + if !record.closed { + return Ok(false); + } + let boundary = self.convex_boundary(&record.sources)?; + let Some(boundary) = boundary else { + if record.topology.is_some() { + return Err(invalid("topology-aware offset source is no longer convex G1")); + } + return Ok(false); + }; + let spans = boundary.build(record.distance)?; + let mut topology = match record.topology.clone() { + Some(t) => t, + None => { + if record.outputs.len() != record.sources.len() { + return Err(invalid("damaged legacy offset correspondence")); + } + let mut roles = Vec::new(); + for ((output, (_, forward)), source) in record.outputs.iter().zip(&record.sources).zip(&boundary.source) { + let curve = self + .curves + .get(self.curve_index(output).ok_or_else(|| invalid("missing legacy offset output"))?) + .ok_or_else(|| invalid("missing legacy offset output"))?; + let (a, b, kind) = match curve.kind { + CurveKind::Line { a, b } if source.arc.is_none() => (a, b, OffsetRoleKind::Line), + CurveKind::Arc { c, a, b } if source.arc.is_some_and(|arc| arc.centre_index == c) => { + if *forward { + (a, b, OffsetRoleKind::Arc) + } else { + (b, a, OffsetRoleKind::Arc) + } + } + _ => return Err(invalid("legacy offset output changed role")), + }; + roles.push(OffsetRole { + output: output.clone(), + kind, + start: self.points.get(a).ok_or_else(|| invalid("missing legacy offset node"))?.id.clone(), + end: self.points.get(b).ok_or_else(|| invalid("missing legacy offset node"))?.id.clone(), + retired: false, + flags: OffsetRoleFlags::of(curve), + }); + } + OffsetTopology { version: 1, id: self.fresh("o")?, roles } + } + }; + if topology.roles.len() != spans.len() { + return Err(invalid("damaged convex offset role count")); + } + let retiring: BTreeSet<_> = + topology.roles.iter().zip(&spans).filter_map(|(r, s)| (s.retired && !r.retired).then_some(r.output.clone())).collect(); + let dead: BTreeSet<_> = self.curves.iter().enumerate().filter_map(|(i, c)| retiring.contains(c.id.as_str()).then_some(i)).collect(); + for role in &mut topology.roles { + if retiring.contains(role.output.as_str()) { + let curve = self + .curves + .get(self.curve_index(&role.output).ok_or_else(|| invalid("missing retiring curve"))?) + .ok_or_else(|| invalid("missing retiring curve"))?; + role.flags = OffsetRoleFlags::of(curve); + } + } + for constraint in &self.constraints { + let mut kind = constraint.kind.clone(); + if !kind.remap(&Some, &|c| (!dead.contains(&c)).then_some(c)) { + return Err(invalid("an extinguished offset arc is referenced by a constraint")); + } + } + if self.offsets.iter().any(|r| r.sources.iter().any(|s| retiring.contains(s.0.as_str()))) + || self.freeform_offsets.iter().any(|r| retiring.contains(r.source.as_str())) + { + return Err(invalid("an extinguished offset arc is retained by a dependent association")); + } + let mut map = vec![None; self.curves.len()]; + let mut next = 0usize; + for (i, slot) in map.iter_mut().enumerate() { + if !dead.contains(&i) { + *slot = Some(next); + next += 1; + } + } + let mut i = 0; + self.curves.retain(|_| { + let keep = !dead.contains(&i); + i += 1; + keep + }); + for constraint in &mut self.constraints { + if !constraint.kind.remap(&Some, &|i| map.get(i).copied().flatten()) { + return Err(invalid("offset constraint remapping failed")); + } + } + let mut outputs = Vec::new(); + for (i, (role, span)) in topology.roles.iter_mut().zip(&spans).enumerate() { + let a = self.point_index(&role.start).ok_or_else(|| invalid("missing reserved offset endpoint"))?; + let b = self.point_index(&role.end).ok_or_else(|| invalid("missing reserved offset endpoint"))?; + for (point, pos) in [(a, span.a), (b, span.b)] { + let p = self.points.get_mut(point).ok_or_else(|| invalid("missing reserved offset endpoint"))?; + p.pos = pos; + p.fixed = true; + } + if !span.retired { + let kind = match span.arc { + Some(arc) => { + if record.sources.get(i).is_some_and(|x| x.1) { + CurveKind::Arc { c: arc.centre_index, a, b } + } else { + CurveKind::Arc { c: arc.centre_index, a: b, b: a } + } + } + None => CurveKind::Line { a, b }, + }; + if let Some(ci) = self.curve_index(&role.output) { + if let Some(curve) = self.curves.get_mut(ci) { + curve.kind = kind; + } + } else { + if !role.retired { + return Err(invalid("active offset output vanished")); + } + if self.points.iter().any(|p| p.id == role.output) + || self.constraints.iter().any(|c| c.id == role.output) + || self.links.iter().any(|l| l.id == role.output) + || self.wires.iter().any(|w| w.id == role.output) + { + return Err(invalid("retired offset identity was stolen")); + } + self.curves.push(Curve { + id: role.output.clone(), + kind, + construction: role.flags.construction, + reversed: role.flags.reversed, + link: None, + centerline: role.flags.centerline, + fixed: role.flags.fixed, + }); + } + outputs.push(role.output.clone()); + } + role.retired = span.retired; + } + let record = self.offsets.get_mut(index).ok_or_else(|| invalid("missing offset association"))?; + record.outputs = outputs; + record.topology = Some(topology); + self.validate_offset_topologies()?; + Ok(true) + } + + /// Caller materializes current driving values before freezing the association. + pub fn break_offset_topology(&mut self, output: &str) -> Result>> { + let Some(index) = + self.offsets.iter().position(|r| r.topology.as_ref().is_some_and(|t| t.id == output || t.roles.iter().any(|role| role.output == output))) + else { + return Ok(None); + }; + let mut candidate = self.clone(); + candidate.refresh_offsets()?; + let record = candidate.offsets.get(index).cloned().ok_or_else(|| invalid("missing offset association"))?; + let outputs: BTreeSet<_> = record.outputs.iter().map(String::as_str).collect(); + let owned: BTreeSet<_> = candidate + .curves + .iter() + .filter(|c| outputs.contains(c.id.as_str())) + .flat_map(|c| match c.kind { + CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. } => vec![a, b], + _ => Vec::new(), + }) + .collect(); + // Arc centres were deliberately shared with their analytic sources. + // Detach each distinct centre once, so released geometry stays frozen + // when the source is subsequently moved or dimensioned. + let centres: BTreeSet<_> = candidate + .curves + .iter() + .filter_map(|c| { + if outputs.contains(c.id.as_str()) + && let CurveKind::Arc { c, .. } = c.kind + { + return Some(c); + } + None + }) + .collect(); + let mut detached = BTreeMap::new(); + for centre in centres { + let position = candidate.points.get(centre).ok_or_else(|| invalid("missing offset arc centre"))?.pos; + detached.insert(centre, candidate.add_point(position, None)?); + } + for curve in &mut candidate.curves { + if outputs.contains(curve.id.as_str()) + && let CurveKind::Arc { c, .. } = &mut curve.kind + && let Some(&point) = detached.get(c) + { + *c = point; + } + } + candidate.offsets.remove(index); + for point in owned { + if point != 0 + && candidate.curves.iter().all(|c| !c.kind.uses(point) || outputs.contains(c.id.as_str())) + && let Some(p) = candidate.points.get_mut(point) + { + p.fixed = false; + } + } + *self = candidate; + Ok(Some(record.outputs)) + } +} + +#[cfg(test)] +#[path = "offset_topology_tests.rs"] +mod tests; diff --git a/crates/sketch/src/offset_topology_tests.rs b/crates/sketch/src/offset_topology_tests.rs new file mode 100644 index 00000000..26816fa2 --- /dev/null +++ b/crates/sketch/src/offset_topology_tests.rs @@ -0,0 +1,224 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use super::*; +use crate::find_profiles; + +fn rounded(radius: f64) -> (Sketch, Vec<(String, bool)>) { + let mut sk = Sketch::new(); + let p = positions(radius); + let nodes: Vec<_> = p.iter().enumerate().map(|(i, p)| sk.add_point(*p, Some(&format!("source-p{i}"))).unwrap()).collect(); + let centres = [Vec2::new(37., -32.), Vec2::new(37., 32.), Vec2::new(-37., 32.), Vec2::new(-37., -32.)]; + let mut sources = Vec::new(); + for i in 0..8 { + let id = format!("source-edge{i}"); + if i % 2 == 0 { + sk.add_line_pts(nodes[i], nodes[(i + 1) % 8], Some(&id)).unwrap(); + } else { + sk.add_arc(centres[i / 2], p[i], p[(i + 1) % 8], [None, Some(nodes[i]), Some(nodes[(i + 1) % 8])], Some(&id)).unwrap(); + } + sources.push((id, true)); + } + for point in &mut sk.points { + point.fixed = true; + } + (sk, sources) +} +fn positions(r: f64) -> [Vec2; 8] { + [ + Vec2::new(-37., -32. - r), + Vec2::new(37., -32. - r), + Vec2::new(37. + r, -32.), + Vec2::new(37. + r, 32.), + Vec2::new(37., 32. + r), + Vec2::new(-37., 32. + r), + Vec2::new(-37. - r, 32.), + Vec2::new(-37. - r, -32.), + ] +} +fn resize(sk: &mut Sketch, r: f64) { + for (i, value) in positions(r).into_iter().enumerate() { + sk.points.iter_mut().find(|p| p.id == format!("source-p{i}")).unwrap().pos = value; + } +} +fn json(sk: &Sketch) -> String { + serde_json::to_string(sk).unwrap() +} +fn area(sk: &Sketch, ids: &[String]) -> f64 { + let mut only = sk.clone(); + only.curves.retain(|c| ids.contains(&c.id)); + let profiles = find_profiles(&only); + assert_eq!(profiles.len(), 1); + profiles[0].area +} + +#[test] +fn convex_extinction_material_ring_and_identity_resurrection() { + let (mut sk, sources) = rounded(1.); + let source_points = sk.points.clone(); + let outer = sk.add_convex_offset(sources.clone(), 0.2).unwrap(); + let inner = sk.add_convex_offset(sources, 1.4).unwrap(); + assert_eq!(inner.curves.len(), 4); + assert!((area(&sk, &inner.curves) - 4626.24).abs() < 1e-8); + let ring_area = area(&sk, &outer.curves) - area(&sk, &inner.curves); + assert!((ring_area - 332.570619298295).abs() < 1e-8); + assert_eq!(&sk.points[..source_points.len()], source_points.as_slice()); + let topology = sk.offsets[1].topology.clone().unwrap(); + let nodes: Vec<_> = topology.roles.iter().map(|r| (r.start.clone(), r.end.clone())).collect(); + let reserved: Vec<_> = topology.roles.iter().map(|r| r.output.clone()).collect(); + assert_eq!(sk.offset_boundary_for_reserved_curves(&reserved).unwrap(), Some(inner.association.clone())); + let saved = json(&sk); + sk = serde_json::from_str(&saved).unwrap(); + sk.validate_offset_topologies().unwrap(); + resize(&mut sk, 2.); + sk.refresh_offsets().unwrap(); + assert_eq!(sk.offset_boundary(&inner.association).unwrap(), reserved); + assert_eq!(sk.offsets[1].topology.as_ref().unwrap().roles.iter().map(|r| (r.start.clone(), r.end.clone())).collect::>(), nodes); + assert!(sk.offset_boundary_for_reserved_curves(&inner.curves).unwrap().is_none()); + assert!((area(&sk, &reserved) - (4. * 37.6 * 32.6 - (4. - std::f64::consts::PI) * 0.6 * 0.6)).abs() < 1e-8); + resize(&mut sk, 1.); + sk.refresh_offsets().unwrap(); + assert_eq!(sk.offset_boundary(&inner.association).unwrap(), inner.curves); +} + +#[test] +fn convex_legacy_upgrade_preserves_all_reserved_curve_ids() { + let (mut sk, sources) = rounded(2.); + let made = sk.add_convex_offset(sources, 1.4).unwrap(); + let nodes = sk.points.clone(); + sk.offsets[0].topology = None; + resize(&mut sk, 1.); + sk.refresh_offsets().unwrap(); + let topology = sk.offsets[0].topology.as_ref().unwrap(); + assert_eq!(topology.roles.iter().map(|r| r.output.clone()).collect::>(), made.curves); + assert_eq!(sk.points.len(), nodes.len()); + assert_eq!(sk.offsets[0].outputs.len(), 4); + resize(&mut sk, 2.); + sk.refresh_offsets().unwrap(); + assert_eq!(sk.offsets[0].outputs, made.curves); +} + +#[test] +fn convex_break_detaches_arc_centres_and_preserves_released_geometry() { + let (mut sk, sources) = rounded(2.); + let made = sk.add_convex_offset(sources, 1.4).unwrap(); + sk.break_offset_topology(&made.association).unwrap(); + let output_points: BTreeSet<_> = sk.curves.iter().filter(|c| made.curves.contains(&c.id)).flat_map(|c| c.kind.point_ids()).collect(); + let frozen: Vec<_> = output_points.iter().map(|&p| (p, sk.points[p].pos)).collect(); + let source_points: BTreeSet<_> = sk.curves.iter().filter(|c| c.id.starts_with("source-edge")).flat_map(|c| c.kind.point_ids()).collect(); + assert!(output_points.is_disjoint(&source_points)); + for p in source_points { + sk.points[p].pos += Vec2::new(7., -3.); + } + sk.refresh_offsets().unwrap(); + for (p, pos) in frozen { + assert_eq!(sk.points[p].pos, pos); + } + assert!((area(&sk, &made.curves) - (4. * 37.6 * 32.6 - (4. - std::f64::consts::PI) * 0.36)).abs() < 1e-8); +} + +#[test] +fn convex_clockwise_and_exact_radius_threshold_are_real_closed_regions() { + let (mut sk, mut sources) = rounded(1.4); + sources.reverse(); + for (_, forward) in &mut sources { + *forward = false; + } + let made = sk.add_convex_offset(sources, -1.4).unwrap(); + assert_eq!(made.curves.len(), 4); + assert!((area(&sk, &made.curves) - 4. * 37. * 32.).abs() < 1e-8); +} + +#[test] +fn convex_retained_arc_dependencies_refuse_atomically() { + let (mut sk, sources) = rounded(2.); + let made = sk.add_convex_offset(sources, 1.4).unwrap(); + let arc = made.curves.iter().find(|id| matches!(sk.curves[sk.curve_index(id).unwrap()].kind, CurveKind::Arc { .. })).unwrap(); + let index = sk.curve_index(arc).unwrap(); + sk.add_constraint(crate::ConstraintKind::Radius { c: index, value: 0.6 }, None).unwrap(); + resize(&mut sk, 1.); + let before = json(&sk); + assert!(sk.refresh_offsets().unwrap_err().to_string().contains("constraint")); + assert_eq!(json(&sk), before); +} + +#[test] +fn convex_reserved_identity_and_counter_bounds_are_enforced() { + let (mut sk, sources) = rounded(1.); + sk.add_convex_offset(sources, 1.4).unwrap(); + let retired = sk.offsets[0].topology.as_ref().unwrap().roles.iter().find(|r| r.retired).unwrap().output.clone(); + assert!(sk.add_line(Vec2::new(1., 2.), Vec2::new(2., 3.), None, None, Some(&retired)).is_err()); + let mut stored = serde_json::to_value(&sk).unwrap(); + stored["counters"]["o"] = serde_json::json!(u64::MAX); + sk = serde_json::from_value(stored).unwrap(); + let before = json(&sk); + assert!(sk.fresh("o").is_err()); + assert_eq!(json(&sk), before); + let mut invalid = sk.clone(); + invalid.offsets[0].topology.as_mut().unwrap().roles[0].start = "source-p0".into(); + assert!(invalid.validate_offset_topologies().is_err()); + let mut wide = Sketch::new(); + for i in 1..=2048 { + wide.add_point(Vec2::new(i as f64, 1.), Some(&format!("o{i}"))).unwrap(); + } + assert_eq!(wide.fresh("o").unwrap(), "o2049"); +} + +#[test] +fn convex_retired_arc_flags_survive_reopen_and_resurrection() { + let (mut sk, sources) = rounded(2.); + let made = sk.add_convex_offset(sources, 1.4).unwrap(); + let arc = made.curves.iter().find(|id| matches!(sk.curves[sk.curve_index(id).unwrap()].kind, CurveKind::Arc { .. })).unwrap().clone(); + let index = sk.curve_index(&arc).unwrap(); + let curve = &mut sk.curves[index]; + curve.construction = true; + curve.centerline = true; + curve.reversed = true; + curve.fixed = true; + resize(&mut sk, 1.); + sk.refresh_offsets().unwrap(); + sk = serde_json::from_str(&json(&sk)).unwrap(); + sk.validate_offset_topologies().unwrap(); + resize(&mut sk, 2.); + sk.refresh_offsets().unwrap(); + let curve = &sk.curves[sk.curve_index(&arc).unwrap()]; + assert!(curve.construction && curve.centerline && curve.reversed && curve.fixed); +} + +#[test] +fn convex_dependency_preflight_and_disappearing_region_are_atomic() { + let (mut sk, sources) = rounded(1.); + let before = json(&sk); + assert!(sk.add_convex_offset(sources.clone(), 40.).is_err()); + assert_eq!(json(&sk), before); + sk.add_convex_offset(sources, 1.4).unwrap(); + sk.offsets.push(OffsetChain { + sources: vec![("unrelated".into(), true); MAX_WORK], + outputs: Vec::new(), + distance: 1., + closed: false, + topology: None, + }); + assert!(sk.validate_offset_topologies().unwrap_err().to_string().contains("work limit")); +} + +#[test] +fn convex_rotated_translated_support_is_independent_of_world_origin() { + let (mut sk, sources) = rounded(1.); + let angle: f64 = 0.317; + let offset = Vec2::new(1e6, -2e6); + let map = |p: Vec2| Vec2::new(p.x * angle.cos() - p.y * angle.sin(), p.x * angle.sin() + p.y * angle.cos()) + offset; + for p in &mut sk.points { + p.pos = map(p.pos); + } + let made = sk.add_convex_offset(sources, 1.4).unwrap(); + for curve in sk.curves.iter().filter(|c| made.curves.contains(&c.id)) { + let CurveKind::Line { a, b } = curve.kind else { + panic!("collapsed boundary should be straight"); + }; + for i in 0..=10 { + let world = sk.points[a].pos * (1. - i as f64 / 10.) + sk.points[b].pos * (i as f64 / 10.); + let q = world - offset; + let local = Vec2::new(q.x * angle.cos() + q.y * angle.sin(), -q.x * angle.sin() + q.y * angle.cos()); + assert!(local.x.abs() <= 36.6 + 1e-8 && local.y.abs() <= 31.6 + 1e-8); + } + } +} diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs index a2ce3596..f43a6f2f 100644 --- a/crates/sketch/src/profiles.rs +++ b/crates/sketch/src/profiles.rs @@ -28,9 +28,20 @@ pub struct Profile { pub hole_curves: Vec>, pub area: f64, pub centroid: Vec2, + // Computed before exact-segment restoration; never trusted from serialized input. + #[serde(skip)] + complete_outer: Option>, + #[serde(skip)] + complete_holes: Option>>, } impl Profile { + /// Whole source boundaries, certified by complete graph-piece coverage. Intersections, + /// missing spans and unknown sampled geometry cannot acquire this persistent identity. + pub fn whole_curve_boundaries(&self) -> Option<(Vec, Vec>)> { + Some((self.complete_outer.clone()?, self.complete_holes.clone()?)) + } + /// All curve ids bounding the profile. pub fn curve_ids(&self) -> Vec { let mut v = self.outer_curves.clone(); @@ -48,10 +59,65 @@ struct Edge { id: String, /// A piece of an exact free-form segment: (curve, segment, piece, pieces). tag: Option, + coverage: Option, } type Piece = (usize, usize, usize, usize); +/// Prove each represented source uses all its original spans exactly once and consistently +/// in one direction. Work and storage are bounded by the existing graph-edge budget. +fn complete_loop(tags: &[Option<(Piece, bool)>], expected: &std::collections::HashMap>, sk: &Sketch) -> Option> { + use std::collections::{BTreeMap, BTreeSet}; + if tags.is_empty() || tags.len() > MAX_EDGES { + return None; + } + let mut seen = BTreeSet::new(); + let mut directions = BTreeMap::new(); + let mut counts = BTreeMap::>::new(); + let mut previous_curve = tags.last().copied().flatten()?.0.0; + let mut runs = BTreeSet::new(); + for tag in tags { + let ((ci, segment, piece, pieces), forward) = (*tag)?; + let expected_pieces = *expected.get(&ci)?.get(segment)?; + if pieces == 0 || pieces != expected_pieces || piece >= pieces || !seen.insert((ci, segment, piece)) { + return None; + } + if directions.insert(ci, forward).is_some_and(|old| old != forward) { + return None; + } + if previous_curve != ci && !runs.insert(ci) { + return None; + } + previous_curve = ci; + counts.entry(ci).or_default().push((segment, piece)); + } + let mut ids = Vec::with_capacity(counts.len()); + for (ci, order) in counts { + let spans = expected.get(&ci)?; + let total = spans.iter().try_fold(0usize, |sum, n| sum.checked_add(*n))?; + if spans.is_empty() || spans.contains(&0) || total > MAX_EDGES || total != order.len() { + return None; + } + let mut offsets = Vec::with_capacity(spans.len()); + let mut offset = 0; + for count in spans { + offsets.push(offset); + offset += count; + } + let forward = *directions.get(&ci)?; + let indices = order.iter().map(|(segment, piece)| offsets[*segment] + piece).collect::>(); + if indices.windows(2).any(|w| w[1] != if forward { (w[0] + 1) % total } else { (w[0] + total - 1) % total }) { + return None; + } + ids.push(sk.curves.get(ci)?.id.clone()); + } + ids.sort(); + if ids.windows(2).any(|w| w[0] == w[1]) { + return None; + } + Some(ids) +} + /// Parameter of `p` at an end of the shape (lines: 0 or 1), for intersections at end points. fn end_param(sh: &Shape, p: Vec2, tol: f64) -> Option { match *sh { @@ -373,15 +439,24 @@ pub fn find_drawn_profiles(sk: &Sketch) -> Vec { } pub fn find_profiles(sk: &Sketch) -> Vec { + find_profiles_bounded(sk, MAX_EDGES) +} + +fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { let tol = MERGE_TOL * 10.0; // Analytic shapes of the profile curves. let mut shapes: Vec<(usize, Shape)> = Vec::new(); let mut tags: Vec> = Vec::new(); let mut exact: std::collections::HashMap> = std::collections::HashMap::new(); + let mut whole_graph_complete = true; + let mut expected = std::collections::HashMap::>::new(); for (ci, c) in sk.curves.iter().enumerate() { if c.construction || c.centerline { continue; } + if matches!(c.kind, CurveKind::Line { .. } | CurveKind::Arc { .. } | CurveKind::Circle { .. }) { + expected.insert(ci, vec![1]); + } match c.kind { CurveKind::Line { a, b } => { if let (Some(a), Some(b)) = (sk.point(a), sk.point(b)) @@ -404,9 +479,11 @@ pub fn find_profiles(sk: &Sketch) -> Vec { // a whole segment get the exact one back. _ => match sk.exact_segs(ci) { Some(ex) => { + let mut spans = Vec::with_capacity(ex.len()); for (k, seg) in ex.iter().enumerate() { let poly = seg.polyline(1e-3); let n = poly.len().saturating_sub(1); + spans.push(n); for (j, w) in poly.windows(2).enumerate() { if w[0].dist(w[1]) > MERGE_TOL { shapes.push((ci, Shape::Line { a: w[0], b: w[1] })); @@ -415,6 +492,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec { } } } + expected.insert(ci, spans); exact.insert(ci, ex); } None => { @@ -427,7 +505,8 @@ pub fn find_profiles(sk: &Sketch) -> Vec { }, } tags.resize(shapes.len(), None); - if shapes.len() > MAX_EDGES { + if shapes.len() > max_edges { + whole_graph_complete = false; break; } } @@ -580,7 +659,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec { grid.entry((cx, cy)).or_default().push(i); i }; - let mut loops: Vec<(Loop2, Vec, usize)> = Vec::new(); // (ccw loop, curve ids, component) + let mut loops: Vec<(Loop2, Vec, usize, Option>)> = Vec::new(); // (ccw loop, curve ids, component) let mut edges: Vec = Vec::new(); for (k, (ci, sh)) in shapes.iter().enumerate() { let mut ts = cuts.get(k).cloned().unwrap_or_default(); @@ -600,6 +679,13 @@ pub fn find_profiles(sk: &Sketch) -> Vec { ts.pop(); } let id = sk.curves.get(*ci).map(|c| c.id.clone()).unwrap_or_default(); + let coverage = tags.get(k).copied().flatten().or_else(|| { + sk.curves + .get(*ci) + .filter(|c| matches!(c.kind, CurveKind::Line { .. } | CurveKind::Arc { .. } | CurveKind::Circle { .. })) + .map(|_| (*ci, 0, 0, 1)) + }); + let coverage = if ts.is_empty() { coverage } else { None }; match *sh { Shape::Line { a, b } => { let mut ps = vec![a]; @@ -611,14 +697,14 @@ pub fn find_profiles(sk: &Sketch) -> Vec { let (p, q) = (w[0], w[1]); if p.dist(q) > MERGE_TOL { let (u, v) = (node(p), node(q)); - edges.push(Edge { seg: Seg2::Line { a: p, b: q }, u, v, id: id.clone(), tag }); + edges.push(Edge { seg: Seg2::Line { a: p, b: q }, u, v, id: id.clone(), tag, coverage }); } } } Shape::Round { c, r, start, sweep } => { let full = sweep >= TAU - 1e-12; if full && ts.is_empty() { - loops.push((Loop2::circle(c, r), vec![id.clone()], usize::MAX - *ci)); + loops.push((Loop2::circle(c, r), vec![id.clone()], usize::MAX - *ci, whole_graph_complete.then(|| vec![id.clone()]))); continue; } let mut bounds: Vec = @@ -634,7 +720,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec { } let seg = Seg2::Arc { center: c, radius: r, start: start + t0, sweep: t1 - t0 }; let (u, v) = (node(seg.start()), node(seg.end())); - edges.push(Edge { seg, u, v, id: id.clone(), tag: None }); + edges.push(Edge { seg, u, v, id: id.clone(), tag: None, coverage }); } } } @@ -705,6 +791,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec { let mut segs = Vec::new(); let mut ids = Vec::new(); let mut ptags: Vec> = Vec::new(); + let mut coverage_tags = Vec::new(); let mut h = start; let mut ok = true; for _ in 0..=edges.len() * 2 { @@ -719,8 +806,10 @@ pub fn find_profiles(sk: &Sketch) -> Vec { if let Some(e) = edges.get(h / 2) { ids.push(e.id.clone()); ptags.push(e.tag.map(|t| (t, h.is_multiple_of(2)))); + coverage_tags.push(e.coverage.map(|t| (t, h.is_multiple_of(2)))); } else { ptags.push(None); + coverage_tags.push(None); } let back = (-seg.end_tangent()).angle(); let twin = h ^ 1; @@ -766,16 +855,16 @@ pub fn find_profiles(sk: &Sketch) -> Vec { let lp = Loop2 { segs: restore_exact(segs, &ptags, &exact) }; if lp.signed_area() > 1e-9 { let c = edges.get(start / 2).map(|e| find(&mut comp, e.u)).unwrap_or(0); - loops.push((lp, ids, c)); + loops.push((lp, ids, c, whole_graph_complete.then(|| complete_loop(&coverage_tags, &expected, sk)).flatten())); } } // Nesting: parent = smallest containing loop from another component. Bounding boxes rule // out most pairs before the point-in-loop test. - let areas: Vec = loops.iter().map(|(l, _, _)| l.signed_area().abs()).collect(); + let areas: Vec = loops.iter().map(|(l, _, _, _)| l.signed_area().abs()).collect(); let boxes: Vec<(Vec2, Vec2)> = loops .iter() - .map(|(l, _, _)| { + .map(|(l, _, _, _)| { l.polyline(1e-3).iter().fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), p| { (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y))) }) @@ -783,11 +872,11 @@ pub fn find_profiles(sk: &Sketch) -> Vec { .collect(); let inside_box = |j: usize, p: Vec2| boxes.get(j).is_some_and(|(lo, hi)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y); let mut parent_of: Vec> = vec![None; loops.len()]; - for (i, (li, _, ci)) in loops.iter().enumerate() { + for (i, (li, _, ci, _)) in loops.iter().enumerate() { let probe = li.segs.first().map(Seg2::mid).unwrap_or_default(); let ai = areas.get(i).copied().unwrap_or(0.0); let mut best: Option<(f64, usize)> = None; - for (j, (lj, _, cj)) in loops.iter().enumerate() { + for (j, (lj, _, cj, _)) in loops.iter().enumerate() { let aj = areas.get(j).copied().unwrap_or(0.0); if i == j || ci == cj || aj <= ai || !inside_box(j, probe) || best.is_some_and(|(ba, _)| aj >= ba) { continue; @@ -811,14 +900,18 @@ pub fn find_profiles(sk: &Sketch) -> Vec { let out: Vec = loops .iter() .enumerate() - .map(|(i, (l, ids, _))| { + .map(|(i, (l, ids, _, complete_outer))| { let kids: Vec = kids_of.get(i).cloned().unwrap_or_default(); - let holes: Vec = kids.iter().filter_map(|k| loops.get(*k).map(|(h, _, _)| h.ccw().reversed())).collect(); - let hole_curves: Vec> = kids.iter().filter_map(|k| loops.get(*k).map(|(_, ids, _)| ids.clone())).collect(); + let holes: Vec = kids.iter().filter_map(|k| loops.get(*k).map(|(h, _, _, _)| h.ccw().reversed())).collect(); + let hole_curves: Vec> = kids.iter().filter_map(|k| loops.get(*k).map(|(_, ids, _, _)| ids.clone())).collect(); let region = Region2 { outer: l.ccw(), holes }; let area = region.area(); let centroid = region.centroid(); - Profile { region, outer_curves: ids.clone(), hole_curves, area, centroid } + let mut complete_holes = kids.iter().map(|k| loops.get(*k)?.3.clone()).collect::>>(); + if let Some(holes) = &mut complete_holes { + holes.sort(); + } + Profile { region, outer_curves: ids.clone(), hole_curves, area, centroid, complete_outer: complete_outer.clone(), complete_holes } }) .collect(); let index: std::collections::HashMap<&str, usize> = sk.curves.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect(); @@ -932,3 +1025,72 @@ pub fn merge_regions(regions: &[Region2]) -> Vec { } if out.is_empty() { regions.to_vec() } else { out } } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod whole_boundary_tests { + use super::*; + + #[test] + fn whole_boundary_coverage_requires_every_piece_once_in_one_direction() { + let mut sk = Sketch::new(); + sk.add_circle(Vec2::ZERO, 5.0, None, Some("c1")).unwrap(); + let expected = std::collections::HashMap::from([(0, vec![2, 3])]); + let full = vec![ + Some(((0, 0, 0, 2), true)), + Some(((0, 0, 1, 2), true)), + Some(((0, 1, 0, 3), true)), + Some(((0, 1, 1, 3), true)), + Some(((0, 1, 2, 3), true)), + ]; + assert_eq!(complete_loop(&full, &expected, &sk), Some(vec!["c1".into()])); + assert!(complete_loop(&full[..4], &expected, &sk).is_none()); + let mut duplicate = full.clone(); + duplicate[4] = duplicate[3]; + assert!(complete_loop(&duplicate, &expected, &sk).is_none()); + let mut cut = full.clone(); + cut[2] = None; + assert!(complete_loop(&cut, &expected, &sk).is_none()); + let mut permuted = full.clone(); + permuted.swap(2, 3); + assert!(complete_loop(&permuted, &expected, &sk).is_none()); + assert!(complete_loop(&full, &std::collections::HashMap::new(), &sk).is_none()); + // A skipped tiny graph span still belongs to the original curve and cannot be ignored. + let omitted = std::collections::HashMap::from([(0, vec![2, 4])]); + assert!(complete_loop(&full, &omitted, &sk).is_none()); + let mut reverse = full; + reverse[2] = Some(((0, 1, 0, 3), false)); + assert!(complete_loop(&reverse, &expected, &sk).is_none()); + } + + #[test] + fn whole_boundary_refuses_globally_truncated_graph_context() { + let mut sk = Sketch::new(); + sk.add_circle(Vec2::ZERO, 10.0, None, Some("outline")).unwrap(); + sk.add_circle(Vec2::ZERO, 5.0, None, Some("hole")).unwrap(); + sk.add_line(Vec2::new(-12.0, 0.0), Vec2::new(12.0, 0.0), None, None, Some("later-crossing")).unwrap(); + // Exercise the production truncation branch with a small deterministic budget; + // the old display still has outlines, but the omitted crossing forbids persistence. + let displayed = find_profiles_bounded(&sk, 1); + assert_eq!(displayed.len(), 2); + assert!(displayed.iter().all(|p| p.whole_curve_boundaries().is_none())); + } + + #[test] + fn whole_boundary_records_holes_but_refuses_intersection_fragments() { + let mut sk = Sketch::new(); + sk.add_circle(Vec2::ZERO, 10.0, None, Some("outer")).unwrap(); + sk.add_circle(Vec2::ZERO, 5.0, None, Some("hole")).unwrap(); + let profiles = find_profiles(&sk); + let annulus = profiles.iter().find(|p| !p.region.holes.is_empty()).unwrap(); + assert_eq!(annulus.whole_curve_boundaries(), Some((vec!["outer".into()], vec![vec!["hole".into()]]))); + // A line through the ring creates bounded fragments of both original circles. + sk.add_line(Vec2::new(-12.0, 0.0), Vec2::new(12.0, 0.0), None, None, Some("cut")).unwrap(); + let profiles = find_profiles(&sk); + assert!(!profiles.is_empty()); + assert!(profiles.iter().all(|p| p.whole_curve_boundaries().is_none())); + // Serialized profiles cannot forge this runtime certificate. + let copy: Profile = serde_json::from_str(&serde_json::to_string(annulus).unwrap()).unwrap(); + assert!(copy.whole_curve_boundaries().is_none()); + } +} diff --git a/crates/sketch/src/virtual_corner_tests.rs b/crates/sketch/src/virtual_corner_tests.rs new file mode 100644 index 00000000..ea4d411d --- /dev/null +++ b/crates/sketch/src/virtual_corner_tests.rs @@ -0,0 +1,98 @@ +#![allow(clippy::unwrap_used, clippy::panic)] + +use crate::{Constraint, ConstraintKind, CurveKind, Sketch}; +use solvecraft_geom::Vec2; + +fn point(sk: &mut Sketch, x: f64, y: f64) -> usize { + sk.add_point(Vec2::new(x, y), None).unwrap() +} + +fn on(sk: &mut Sketch, p: usize, c: usize) { + sk.add_constraint(ConstraintKind::PointOnCurve { p, c }, None).unwrap(); +} + +#[test] +fn virtual_corner_extensions_keep_constraint_order_and_distinct_curves() { + let mut sk = Sketch::new(); + let p = point(&mut sk, 10., 10.); + let horizontal = sk.add_line(Vec2::new(0., 10.), Vec2::new(8., 10.), None, None, None).unwrap(); + let vertical = sk.add_line(Vec2::new(10., 0.), Vec2::new(10., 7.), None, None, None).unwrap(); + on(&mut sk, p, vertical); + on(&mut sk, p, vertical); + assert!(sk.virtual_corners().is_empty(), "one curve is not a sharp corner even with duplicate constraints"); + on(&mut sk, p, horizontal); + assert_eq!(sk.virtual_corners(), vec![(p, vec![(Vec2::new(10., 10.), Vec2::new(10., 7.)), (Vec2::new(10., 10.), Vec2::new(8., 10.))])]); + let copy = serde_json::from_str::(&serde_json::to_string(&sk).unwrap()).unwrap(); + assert_eq!(copy.virtual_corners(), sk.virtual_corners()); +} + +#[test] +fn virtual_corner_inside_lines_stays_visible_without_extensions_and_origin_is_excluded() { + let mut sk = Sketch::new(); + let p = point(&mut sk, 1., 1.); + let a = sk.add_line(Vec2::new(0., 1.), Vec2::new(2., 1.), None, None, None).unwrap(); + let b = sk.add_line(Vec2::new(1., 0.), Vec2::new(1., 2.), None, None, None).unwrap(); + for c in [a, b] { + on(&mut sk, p, c); + on(&mut sk, 0, c); + } + assert_eq!(sk.virtual_corners(), vec![(p, vec![])]); +} + +#[test] +fn curve_centres_and_spline_interior_points_are_used_and_invalid_incidence_is_safe() { + let mut sk = Sketch::new(); + let centre = point(&mut sk, 10., 10.); + let interior = point(&mut sk, 20., 20.); + let a = sk.add_line(Vec2::new(0., 10.), Vec2::new(8., 10.), None, None, None).unwrap(); + let b = sk.add_line(Vec2::new(10., 0.), Vec2::new(10., 8.), None, None, None).unwrap(); + sk.add_arc(Vec2::new(10., 10.), Vec2::new(11., 10.), Vec2::new(10., 11.), [Some(centre), None, None], None).unwrap(); + let first = point(&mut sk, 19., 19.); + let last = point(&mut sk, 21., 21.); + let mut spline = sk.curves[a].clone(); + spline.id = "interior_spline".into(); + spline.kind = CurveKind::Spline { pts: vec![first, interior, last], control: false, degree: 3, periodic: false }; + sk.curves.push(spline); + for p in [centre, interior] { + for c in [a, b] { + on(&mut sk, p, c); + } + } + for (p, c) in [(usize::MAX, a), (centre, usize::MAX)] { + sk.constraints.push(Constraint { + id: format!("damaged{p}{c}"), + kind: ConstraintKind::PointOnCurve { p, c }, + param: None, + driven: false, + text: None, + }); + } + assert!(sk.virtual_corners().is_empty()); +} + +#[test] +fn many_virtual_corners_are_indexed_in_point_order() { + let mut sk = Sketch::new(); + let mut expected = Vec::new(); + for i in 0..3500 { + let x = i as f64 * 3. + 1.; + let p = point(&mut sk, x, 1.); + let a = sk.add_line(Vec2::new(x - 1., 1.), Vec2::new(x - 0.5, 1.), None, None, None).unwrap(); + let b = sk.add_line(Vec2::new(x, 0.), Vec2::new(x, 0.5), None, None, None).unwrap(); + on(&mut sk, p, a); + on(&mut sk, p, b); + expected.push((p, vec![(Vec2::new(x, 1.), Vec2::new(x - 0.5, 1.)), (Vec2::new(x, 1.), Vec2::new(x, 0.5))])); + } + assert_eq!(sk.virtual_corners(), expected); +} + +#[test] +fn oversized_reference_input_is_refused_without_scanning_or_mutating_it() { + let mut sk = Sketch::new(); + let a = sk.add_line(Vec2::new(0., 1.), Vec2::new(2., 1.), None, None, None).unwrap(); + let before = sk.points.clone(); + sk.curves[a].kind = CurveKind::Spline { pts: vec![usize::MAX; 1_000_001], control: false, degree: 3, periodic: false }; + assert!(sk.virtual_corners().is_empty()); + assert_eq!(sk.points, before); + assert_eq!(sk.curves[a].kind.point_ids().len(), 1_000_001); +} diff --git a/crates/sketch/src/wire.rs b/crates/sketch/src/wire.rs index 178588b2..7abbd6de 100644 --- a/crates/sketch/src/wire.rs +++ b/crates/sketch/src/wire.rs @@ -195,7 +195,7 @@ impl Sketch { return self.add_link(kind, source, geom); } let id = if geom.is_empty() { - let id = self.fresh("j"); + let id = self.fresh("j")?; self.links.push(Link { id: id.clone(), kind, source, face_name: None, auto_face: false, lost: false }); id } else { @@ -217,7 +217,7 @@ impl Sketch { for (k, pts) in ws.into_iter().enumerate() { let wid = match old.get(k) { Some(o) => o.clone(), - None => self.fresh("w"), + None => self.fresh("w")?, }; self.wires.push(Wire { id: wid, pts, space: WireSpace::Component, link: Some(id.to_string()), fit: Vec::new() }); } @@ -233,7 +233,7 @@ impl Sketch { pub fn add_drawn_wire_in(&mut self, fit: &[Vec3], space: WireSpace) -> Result { let pts = drawn_polyline(fit)?; self.wire_budget(None, 1, pts.len() + fit.len())?; - let id = self.fresh(if fit.len() == 1 { "q" } else { "w" }); + let id = self.fresh(if fit.len() == 1 { "q" } else { "w" })?; self.wires.push(Wire { id: id.clone(), pts, space, link: None, fit: fit.to_vec() }); Ok(id) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2e6caa23..590d34f5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2923,16 +2923,13 @@ pub(crate) fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &P match sel { ProfileSel::All => (0..ps.len()).collect(), ProfileSel::Indices { indices } => indices.iter().copied().filter(|i| *i < ps.len()).collect(), + ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. } => { + solvecraft_engine::doc::profile_selection_indices(ss, sel).unwrap_or_default() + } ProfileSel::Curves { loops } => loops .iter() .filter_map(|l| { - let mut want: Vec<&String> = l.iter().collect(); - want.sort(); - ps.iter().position(|p| { - let mut have: Vec<&String> = p.outer_curves.iter().collect(); - have.sort(); - have == want - }) + solvecraft_engine::doc::profile_selection_indices(ss, &ProfileSel::Curves { loops: vec![l.clone()] }).ok()?.first().copied() }) .collect(), ProfileSel::Points { points } => points.iter().filter_map(|q| ps.iter().position(|p| p.region.contains(*q))).collect(), diff --git a/crates/ui-egui/src/dialogs_sheet.rs b/crates/ui-egui/src/dialogs_sheet.rs index dafea6e2..92a73924 100644 --- a/crates/ui-egui/src/dialogs_sheet.rs +++ b/crates/ui-egui/src/dialogs_sheet.rs @@ -744,6 +744,7 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool { // A pocket retains material below (or above) the cut. Dashed contours and a depth // annotation distinguish it from the solid through-cut boundaries. let pockets: Vec<&Value> = v["pockets"].as_array().into_iter().flatten().collect(); + let mut pocket_annotations: Vec<(Pos2, Vec)> = Vec::new(); for pocket in &pockets { let outer: Vec<(f64, f64)> = pocket["outline"].as_array().into_iter().flatten().filter_map(read).collect(); for contour in std::iter::once(&pocket["outline"]).chain(pocket["cutouts"].as_array().into_iter().flatten()) { @@ -757,10 +758,19 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool { let bounds = outer.iter().map(|p| to(*p)).fold(Rect::NOTHING, |r, p| r.union(Rect::from_min_max(p, p))); let anchor = bounds.center_top() - vec2(0.0, 6.0); if let Some(label) = pocket_label(pocket) { - painter.text(anchor, Align2::CENTER_BOTTOM, label, FontId::proportional(12.0), t.accent); + if let Some((_, labels)) = pocket_annotations.iter_mut().find(|(at, _)| at.distance(anchor) < 1.0) { + if !labels.contains(&label) { + labels.push(label); + } + } else { + pocket_annotations.push((anchor, vec![label])); + } } } } + for (anchor, labels) in pocket_annotations { + painter.text(anchor, Align2::CENTER_BOTTOM, labels.join("\n"), FontId::proportional(12.0), t.accent); + } for b in v["bends"].as_array().into_iter().flatten() { let (Some(a), Some(e)) = (read(&b["start"]), read(&b["end"])) else { continue }; let col = if b["direction"] == "up" { up_col } else { down_col }; diff --git a/crates/ui-egui/src/dialogs_sheet_tests.rs b/crates/ui-egui/src/dialogs_sheet_tests.rs index 24203a97..16ccfbb7 100644 --- a/crates/ui-egui/src/dialogs_sheet_tests.rs +++ b/crates/ui-egui/src/dialogs_sheet_tests.rs @@ -218,6 +218,43 @@ fn flat_pattern_islands_fit_before_the_actual_command_panel() { ); } +#[test] +fn coincident_top_and_bottom_pocket_depths_share_a_readable_annotation() { + let mut app = plate(); + app.run("sheet.flange", json!({"sketch":"Base"})).unwrap(); + app.run("sketch.create", json!({"plane":"XY","name":"Retain"})).unwrap(); + app.run("sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]})).unwrap(); + app.run("sketch.finish", json!({})).unwrap(); + app.run("solid.extrude", json!({"sketch":"Retain","distance":1,"start_offset":0.5,"operation":"intersect"})).unwrap(); + let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap(); + assert_eq!(flat["cutouts"], json!([])); + assert_eq!(flat["pockets"].as_array().unwrap().len(), 2); + let doc = app.session.doc.to_json(); + app.start("sheet.flat_pattern"); + let mut dialog = app.dialog.clone().unwrap(); + let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") }; + *data = Some(Ok(flat)); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("coincident-pocket-test"))); + assert!(overlay(&painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog)); + }); + output.textures_delta.clear(); + let labels: Vec<_> = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.text().contains("pocket:") => Some(text.galley.text()), + _ => None, + }) + .collect(); + assert_eq!(labels.len(), 1, "coincident depths must not overwrite one another"); + assert!(labels[0].contains("Top pocket: 1 mm deep")); + assert!(labels[0].contains("Bottom pocket: 0.5 mm deep")); + assert!(labels[0].contains('\n')); + assert_eq!(app.session.doc.to_json(), doc); +} + #[test] fn pocket_export_button_uses_the_picker_and_preserves_depth_layers() { let mut h = crate::scenario::Harness::new(); diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 32d10492..b8df3f79 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -121,6 +121,8 @@ const IMG_SIZE: usize = 24; pub struct ViewportCallback { pub key: u64, pub slot: SceneSlot, + pub sketch_key: u64, + pub sketch_slot: SceneSlot, /// The highlight scene and its key. pub hl_key: u64, pub hl_slot: SceneSlot, @@ -230,6 +232,7 @@ struct Resources { bind: wgpu::BindGroup, linear_out: bool, model: Batches, + sketch: Batches, highlight: Batches, preview: Batches, image: wgpu::RenderPipeline, @@ -828,6 +831,7 @@ impl Resources { bind, linear_out: t.format.is_srgb(), model: Batches::default(), + sketch: Batches::default(), highlight: Batches::default(), preview: Batches::default(), } @@ -893,6 +897,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { ) -> Vec { let Some(res) = resources.get_mut::() else { return Vec::new() }; res.model.take(device, self.key, &self.slot); + res.sketch.take(device, self.sketch_key, &self.sketch_slot); res.highlight.take(device, self.hl_key, &self.hl_slot); res.preview.take(device, self.pv_key, &self.pv_slot); res.model.sort_glass(queue, self.back); @@ -977,8 +982,9 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { pass.draw(0..6, 0..b.count); } }; - let (m, h, pv) = (&res.model, &res.highlight, &res.preview); + let (m, sk, h, pv) = (&res.model, &res.sketch, &res.highlight, &res.preview); lines(pass, &res.overlay, &m.under); + lines(pass, &res.overlay, &sk.under); pass.set_bind_group(1, &res.access_bind, &[]); let cut = res.tri_cut.as_ref().filter(|_| self.clip[..3].iter().map(|c| c * c).sum::() > 0.25); match cut { @@ -996,6 +1002,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { } tris(pass, &res.tri_hl, &h.tris); lines(pass, &res.line, &m.lines); + lines(pass, &res.line, &sk.lines); lines(pass, &res.line_hidden, &m.hidden); lines(pass, &res.line_hidden, &h.hidden); lines(pass, &res.line, &pv.lines); @@ -1012,9 +1019,11 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { tris(pass, &res.glass_far, &m.glass); tris(pass, &res.glass_near, &m.glass); tris(pass, &res.trans, &m.trans); + tris(pass, &res.trans, &sk.trans); tris(pass, &res.trans, &h.trans); tris(pass, &res.xray, &pv.xray); lines(pass, &res.overlay, &m.overlays); + lines(pass, &res.overlay, &sk.overlays); lines(pass, &res.overlay, &h.overlays); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0c387aba..dd9767b5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -83,6 +83,9 @@ pub(crate) mod sketch_attachment_ui; #[cfg(test)] mod sketch_attachment_ui_tests; pub mod sketch_dims; +pub(crate) mod sketch_display; +#[cfg(test)] +mod sketch_display_ui_tests; #[cfg(test)] mod sketch_edit_tests; pub mod sketch_palette; diff --git a/crates/ui-egui/src/sketch_attachment_ui.rs b/crates/ui-egui/src/sketch_attachment_ui.rs index f06e2482..455cce2e 100644 --- a/crates/ui-egui/src/sketch_attachment_ui.rs +++ b/crates/ui-egui/src/sketch_attachment_ui.rs @@ -267,3 +267,11 @@ pub fn guide_lines(app: &SolveApp) -> Vec> { .map(|segment| segment.polyline(0.05).into_iter().map(|point| plane.to_world(point)).collect()) .collect() } + +pub fn display_guide_lines(app: &SolveApp, proj: &crate::viewport::Proj) -> Vec> { + let Some((plane, sketch)) = lost_guide(app) else { return Vec::new() }; + let mut budget = solvecraft_engine::render::SamplingBudget::new(65_536, 262_144); + (0..sketch.curves.len()) + .flat_map(|index| crate::sketch_display::curve_with_budget(sketch, index, &plane, proj, app.viewport.pixels_per_point(), &mut budget).parts) + .collect() +} diff --git a/crates/ui-egui/src/sketch_display.rs b/crates/ui-egui/src/sketch_display.rs new file mode 100644 index 00000000..981c8193 --- /dev/null +++ b/crates/ui-egui/src/sketch_display.rs @@ -0,0 +1,243 @@ +//! Display-only sampling. CAD curves, profiles and constraints are never changed here. +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use solvecraft_engine::doc::ModelState; +use solvecraft_engine::geom::{Loop2, Plane, Region2, Vec2, Vec3}; +use solvecraft_engine::render::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped}; +use solvecraft_engine::sketch::Sketch; + +use crate::SolveApp; +use crate::viewport::Proj; + +pub fn projection_key(proj: &Proj, ppp: f32) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + for n in proj.vp.0.iter().flatten() { + n.to_bits().hash(&mut h); + } + for n in [proj.rect.left(), proj.rect.top(), proj.rect.width(), proj.rect.height(), ppp] { + n.to_bits().hash(&mut h); + } + h.finish() +} + +pub fn pixel_view(proj: &Proj, ppp: f32) -> PixelCurveView { + PixelCurveView { vp: proj.vp, width_px: f64::from(proj.rect.width() * ppp), height_px: f64::from(proj.rect.height() * ppp) } +} + +pub fn curve_with_budget(sketch: &Sketch, index: usize, plane: &Plane, proj: &Proj, ppp: f32, budget: &mut SamplingBudget) -> SampledPath { + let segments = sketch.exact_segs(index).unwrap_or_else(|| sketch.segs(index)); + let mut local = SamplingBudget::new(budget.remaining_vertices.min(4096), budget.remaining_nodes.min(65_536)); + let initial = local.clone(); + let mut out = SampledPath { parts: Vec::new(), quality: SamplingQuality::Complete, vertex_count: 0 }; + for segment in segments { + let sampled = sample_segment(&segment, plane, &pixel_view(proj, ppp), &mut local); + if sampled.quality != SamplingQuality::Complete { + out.quality = sampled.quality; + } + out.vertex_count += sampled.vertex_count; + out.parts.extend(sampled.parts); + } + budget.remaining_vertices -= initial.remaining_vertices - local.remaining_vertices; + budget.remaining_nodes -= initial.remaining_nodes - local.remaining_nodes; + out +} + +pub fn curve_unclipped(sketch: &Sketch, index: usize, plane: &Plane, proj: &Proj, ppp: f32) -> SampledPath { + let mut budget = SamplingBudget::new(4096, 65_536); + let mut out = SampledPath { parts: Vec::new(), quality: SamplingQuality::Complete, vertex_count: 0 }; + for segment in sketch.exact_segs(index).unwrap_or_else(|| sketch.segs(index)) { + let sampled = sample_segment_unclipped(&segment, plane, &pixel_view(proj, ppp), &mut budget); + if sampled.quality != SamplingQuality::Complete { + out.quality = sampled.quality; + } + out.vertex_count += sampled.vertex_count; + out.parts.extend(sampled.parts); + } + out +} + +#[cfg(test)] +pub fn curve_parts(sketch: &Sketch, index: usize, plane: &Plane, proj: &Proj, ppp: f32) -> Vec> { + curve_with_budget(sketch, index, plane, proj, ppp, &mut SamplingBudget::new(4096, 65_536)).parts +} + +/// A closed display region made from complete, unclipped adaptive boundaries. Near-plane +/// clipping or an exhausted budget suppresses the fill instead of inventing closing edges. +#[cfg(test)] +pub fn display_region(region: &Region2, plane: &Plane, proj: &Proj, ppp: f32) -> Option { + let mut budget = SamplingBudget::new(4096, 65_536); + display_region_with_budget(region, plane, proj, ppp, &mut budget) +} + +fn display_region_with_budget(region: &Region2, plane: &Plane, proj: &Proj, ppp: f32, shared: &mut SamplingBudget) -> Option { + let mut budget = SamplingBudget::new(shared.remaining_vertices.min(4096), shared.remaining_nodes.min(65_536)); + let initial = budget.clone(); + let result = (|| { + let mut boundary = |loop_: &Loop2| -> Option { + let mut points: Vec = Vec::new(); + for segment in &loop_.segs { + let sampled = sample_segment_unclipped(segment, plane, &pixel_view(proj, ppp), &mut budget); + if sampled.quality != SamplingQuality::Complete || sampled.parts.len() != 1 { + return None; + } + let part = sampled.parts.first()?; + // Both exact endpoints must survive; a clipped boundary cannot close a region. + if part.first()?.dist(plane.to_world(segment.start())) > 1e-8 || part.last()?.dist(plane.to_world(segment.end())) > 1e-8 { + return None; + } + for point in part { + let point = plane.to_local(*point); + if points.last().is_none_or(|last| last.dist(point) > 1e-12) { + points.push(point); + } + } + } + if points.len() < 3 || points.first()?.dist(*points.last()?) > 1e-8 { + return None; + } + points.pop(); + Some(Loop2::polygon(&points)) + }; + let outer = boundary(®ion.outer)?; + let holes = region.holes.iter().map(&mut boundary).collect::>>()?; + Some(Region2 { outer, holes }) + })(); + shared.remaining_vertices -= initial.remaining_vertices - budget.remaining_vertices; + shared.remaining_nodes -= initial.remaining_nodes - budget.remaining_nodes; + result +} + +#[cfg(test)] +pub fn region_triangles(region: &Region2, plane: &Plane, proj: &Proj, ppp: f32) -> Vec<[Vec2; 3]> { + display_region(region, plane, proj, ppp).map_or_else(Vec::new, |display| fill_triangles(&display, &mut 65_536)) +} + +/// Bound the slab triangulator's allocation before calling it. Every slab can emit +/// at most one triangle per edge; complex fills are omitted rather than cut into holes. +pub fn fill_triangles(display: &Region2, budget: &mut usize) -> Vec<[Vec2; 3]> { + let mut heights = Vec::new(); + let mut edges = 0usize; + for loop_ in std::iter::once(&display.outer).chain(&display.holes) { + for segment in &loop_.segs { + let (a, b) = (segment.start(), segment.end()); + if (a.y - b.y).abs() > 1e-12 { + edges += 1; + heights.extend([a.y, b.y]); + } + } + } + heights.sort_by(f64::total_cmp); + heights.dedup_by(|a, b| (*a - *b).abs() < 1e-12); + if edges.saturating_mul(heights.len().saturating_sub(1)) > *budget { + return Vec::new(); + } + let triangles = display.triangulate(1.0); + *budget = budget.saturating_sub(triangles.len()); + triangles +} + +pub fn boundary_parts(loop_: &Loop2, plane: &Plane, proj: &Proj, ppp: f32) -> Vec> { + let mut budget = SamplingBudget::new(4096, 65_536); + loop_.segs.iter().flat_map(|segment| sample_segment(segment, plane, &pixel_view(proj, ppp), &mut budget).parts).collect() +} + +pub struct DisplayCache { + state: Option>, + view: u64, + curves: HashMap<(u64, usize, [u64; 9]), Arc>, + profiles: HashMap<(u64, usize, [u64; 9]), Arc>>, + budget: SamplingBudget, +} + +impl Default for DisplayCache { + fn default() -> Self { + Self { state: None, view: 0, curves: HashMap::new(), profiles: HashMap::new(), budget: SamplingBudget::new(131_072, 1_048_576) } + } +} + +impl DisplayCache { + #[cfg(test)] + pub fn len(&self) -> usize { + self.curves.len() + } + + pub fn prepare(&mut self, state: Arc, proj: &Proj, ppp: f32) { + self.reset(&state, proj, ppp); + } + + fn reset(&mut self, state: &Arc, proj: &Proj, ppp: f32) { + let view = projection_key(proj, ppp); + if view != self.view || self.state.as_ref().is_none_or(|old| !Arc::ptr_eq(old, state)) { + self.curves.clear(); + self.profiles.clear(); + self.budget = SamplingBudget::new(131_072, 1_048_576); + self.view = view; + self.state = Some(Arc::clone(state)); + } + } + + fn curve(&mut self, state: Arc, sketch: u64, index: usize, plane: &Plane, proj: &Proj, ppp: f32) -> Arc { + self.reset(&state, proj, ppp); + let plane_key = + [plane.origin.x, plane.origin.y, plane.origin.z, plane.x.x, plane.x.y, plane.x.z, plane.y.x, plane.y.y, plane.y.z].map(f64::to_bits); + let key = (sketch, index, plane_key); + if !self.curves.contains_key(&key) && (self.curves.len() >= 4096 || self.budget.remaining_vertices == 0 || self.budget.remaining_nodes == 0) { + return Arc::new(SampledPath { parts: Vec::new(), quality: SamplingQuality::BudgetLimited, vertex_count: 0 }); + } + self.curves + .entry(key) + .or_insert_with(|| { + Arc::new(state.sketch(sketch).map_or_else( + || SampledPath { parts: Vec::new(), quality: SamplingQuality::Invalid, vertex_count: 0 }, + |ss| curve_with_budget(&ss.sketch, index, plane, proj, ppp, &mut self.budget), + )) + }) + .clone() + } + + fn profile(&mut self, state: Arc, sketch: u64, index: usize, plane: &Plane, proj: &Proj, ppp: f32) -> Arc> { + self.reset(&state, proj, ppp); + let plane_key = + [plane.origin.x, plane.origin.y, plane.origin.z, plane.x.x, plane.x.y, plane.x.z, plane.y.x, plane.y.y, plane.y.z].map(f64::to_bits); + let key = (sketch, index, plane_key); + if !self.profiles.contains_key(&key) + && (self.profiles.len() >= 1024 || self.budget.remaining_vertices == 0 || self.budget.remaining_nodes == 0) + { + return Arc::new(None); + } + self.profiles + .entry(key) + .or_insert_with(|| { + Arc::new( + state + .sketch(sketch) + .and_then(|ss| ss.profiles.get(index)) + .and_then(|profile| display_region_with_budget(&profile.region, plane, proj, ppp, &mut self.budget)), + ) + }) + .clone() + } +} + +pub fn profile_region(app: &SolveApp, sketch: u64, index: usize, plane: &Plane, proj: &Proj) -> Arc> { + let state = app.session.world_state(); + let ppp = app.viewport.pixels_per_point(); + match app.viewport.display.lock() { + Ok(mut cache) => cache.profile(state, sketch, index, plane, proj, ppp), + Err(_) => Arc::new(None), + } +} + +pub fn curve_sample(app: &SolveApp, sketch: u64, index: usize, plane: &Plane, proj: &Proj) -> Arc { + let state = app.session.world_state(); + let ppp = app.viewport.pixels_per_point(); + match app.viewport.display.lock() { + Ok(mut cache) => cache.curve(state, sketch, index, plane, proj, ppp), + Err(_) => Arc::new(state.sketch(sketch).map_or_else( + || SampledPath { parts: Vec::new(), quality: SamplingQuality::Invalid, vertex_count: 0 }, + |ss| curve_with_budget(&ss.sketch, index, plane, proj, ppp, &mut SamplingBudget::new(4096, 65_536)), + )), + } +} diff --git a/crates/ui-egui/src/sketch_display_preview_tests.rs b/crates/ui-egui/src/sketch_display_preview_tests.rs new file mode 100644 index 00000000..aad5aae8 --- /dev/null +++ b/crates/ui-egui/src/sketch_display_preview_tests.rs @@ -0,0 +1,122 @@ +use super::{Tool, preview_curve}; +use crate::scenario::Harness; +use crate::viewport::Proj; +use serde_json::{Value, json}; +use solvecraft_engine::geom::{Plane, Vec2, Vec3}; +use solvecraft_engine::render::{Camera, Mat4}; +use solvecraft_engine::sketch::Sketch; + +fn tool(command: &str, points: &[[f64; 2]]) -> Tool { + let mut tool = Tool::for_command(command).unwrap(); + tool.pts = points.iter().map(|p| (Vec2::new(p[0], p[1]), None)).collect(); + tool +} +fn spans(sk: &Sketch) -> Vec { + sk.exact_segs(0).unwrap_or_else(|| sk.segs(0)) +} +fn projection(scale: f64) -> Proj { + Proj { + cam: Camera::default(), + vp: Mat4([[scale / 700.0, 0.0, 0.0, 0.0], [0.0, scale / 450.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.5, 1.0]]), + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)), + } +} +fn screen_error(paths: &[Vec], point: Vec3, proj: &Proj, ppp: f32) -> f32 { + let point = proj.to_screen(point).unwrap(); + paths + .iter() + .flat_map(|part| part.windows(2)) + .map(|pair| { + let a = proj.to_screen(pair[0]).unwrap(); + let b = proj.to_screen(pair[1]).unwrap(); + let ab = b - a; + let t = if ab.length_sq() > 0.0 { ((point - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + point.distance(a + ab * t) * ppp + }) + .fold(f32::INFINITY, f32::min) +} + +#[test] +fn pixel_preview_temporary_curves_match_committed_geometry_and_sampling() { + let cases: Vec<(&str, Vec<[f64; 2]>, [f64; 2], Option, Value)> = vec![ + ("sketch.circle.center", vec![[0., 0.]], [1., 0.], None, json!({"center":[0,0],"radius":1})), + ("sketch.circle.two_point", vec![[-1., 0.]], [1., 0.], None, json!({"p0":[-1,0],"p1":[1,0]})), + ("sketch.circle.three_point", vec![[1., 0.], [-1., 0.]], [0., 1.], None, json!({"p0":[1,0],"p1":[-1,0],"p2":[0,1]})), + ("sketch.arc.three_point", vec![[1., 0.], [-1., 0.]], [0., -1.], None, json!({"start":[1,0],"end":[-1,0],"through":[0,-1]})), + ( + "sketch.arc.center_point", + vec![[0., 0.], [1., 0.]], + [0., -1.], + Some(-std::f64::consts::FRAC_PI_2), + json!({"center":[0,0],"start":[1,0],"sweep":-90}), + ), + ("sketch.ellipse", vec![[0., 0.], [1., 0.]], [0., 0.05], None, json!({"center":[0,0],"major":[1,0],"minor":[0,0.05]})), + ("sketch.spline.fit_point", vec![[0., 0.], [0.3, 0.8], [0.6, -0.8]], [1., 0.], None, json!({"points":[[0,0],[0.3,0.8],[0.6,-0.8],[1,0]]})), + ( + "sketch.spline.control_point_5", + vec![[0., 0.], [0.2, 0.9], [0.4, -0.9], [0.6, 0.9], [0.8, -0.9]], + [1., 0.], + None, + json!({"points":[[0,0],[0.2,0.9],[0.4,-0.9],[0.6,0.9],[0.8,-0.9],[1,0]]}), + ), + ("sketch.spline.fit_point", vec![[0., 0.], [1., 0.], [0.5, 1.]], [0., 0.], None, json!({"points":[[0,0],[1,0],[0.5,1]],"periodic":true})), + ]; + for (command, points, hover, sweep, params) in cases { + let tool = tool(command, &points); + let preview = preview_curve(&tool, Vec2::new(hover[0], hover[1]), sweep).unwrap(); + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run(command, params).unwrap(); + let state = h.app.session.world_state(); + let committed = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + let a = spans(&preview); + let b = spans(committed); + assert_eq!(a.len(), b.len(), "{command}"); + for (a, b) in a.iter().zip(&b) { + for i in 0..=50 { + assert!(a.point_at(i as f64 / 50.0).dist(b.point_at(i as f64 / 50.0)) < 1e-8, "{command}"); + } + } + for ppp in [1.0, 1.5, 2.0] { + for scale in [2.0, 20.0, 220.0, 1000.0] { + let proj = projection(scale / f64::from(ppp)); + let temporary = crate::sketch_display::curve_parts(&preview, 0, &Plane::XY, &proj, ppp); + let permanent = crate::sketch_display::curve_parts(committed, 0, &Plane::XY, &proj, ppp); + assert_eq!(temporary, permanent, "{command}"); + assert!(temporary.iter().map(Vec::len).sum::() <= 65_536); + let mut visible = 0; + for segment in &a { + for i in 0..=300 { + let point = Plane::XY.to_world(segment.point_at(i as f64 / 300.0)); + if !proj.rect.contains(proj.to_screen(point).unwrap()) { + continue; + } + visible += 1; + let error = screen_error(&temporary, point, &proj, ppp); + assert!(error <= 1.0, "{command}, scale{scale}, ppp{ppp}: {error}"); + } + } + if scale <= 220.0 { + assert_eq!(visible, a.len() * 301, "the low/normal zoom fixture must test the entire curve: {command}"); + } + } + } + } +} + +#[test] +fn pixel_preview_fully_offscreen_circle_is_clipped_without_false_connections() { + let curve = preview_curve(&tool("sketch.circle.center", &[[0., 0.]]), Vec2::X, None).unwrap(); + let proj = projection(1000.0); + // The viewport half diagonal is about 832px: a 1000px circle never enters it. + assert!((proj.rect.width() * 0.5).hypot(proj.rect.height() * 0.5) < 1000.0); + assert!(crate::sketch_display::curve_parts(&curve, 0, &Plane::XY, &proj, 1.0).is_empty()); +} + +#[test] +fn pixel_preview_rejects_degenerate_and_unbounded_input_without_changing_document() { + assert!(preview_curve(&tool("sketch.circle.center", &[[0., 0.]]), Vec2::new(f64::NAN, 0.), None).is_none()); + assert!(preview_curve(&tool("sketch.arc.three_point", &[[0., 0.], [1., 0.]]), Vec2::new(2., 0.), None).is_none()); + let points = vec![[0., 0.]; 500]; + assert!(preview_curve(&tool("sketch.spline.fit_point", &points), Vec2::X, None).is_none()); +} diff --git a/crates/ui-egui/src/sketch_display_ui_tests.rs b/crates/ui-egui/src/sketch_display_ui_tests.rs new file mode 100644 index 00000000..c9e5a7c2 --- /dev/null +++ b/crates/ui-egui/src/sketch_display_ui_tests.rs @@ -0,0 +1,286 @@ +//! Independent physical-pixel and interaction proofs for display-only sampling. +use crate::scenario::Harness; +use crate::sketch_display::{curve_sample, projection_key}; +use crate::viewport::{Hit, Proj, pick, projection}; +use serde_json::json; +use solvecraft_engine::geom::{Plane, Vec2, Vec3}; +use solvecraft_engine::render::{SampledPath, SamplingQuality}; +use std::sync::Arc; + +fn circle(h: &mut Harness) -> u64 { + h.app.ui.show_origin = false; + h.app.run("parameters.add", json!({"name":"foreground_diameter","expression":"2 mm"})).unwrap(); + h.app.run("sketch.create", json!({"plane":"XY","name":"PixelCircle"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap(); + h.app.run("sketch.dimension", json!({"entities":["c1"],"type":"diameter","value":"foreground_diameter"})).unwrap(); + h.settle(); + h.step(&json!({"call":"ui.view","params":{"view":"top"}})).unwrap(); + h.step(&json!({"call":"ui.view","params":{"view":"fit"}})).unwrap(); + h.settle(); + h.app.session.active_sketch.unwrap() +} + +fn segment_distance(p: egui::Pos2, a: egui::Pos2, b: egui::Pos2) -> f64 { + let dx = f64::from(b.x - a.x); + let dy = f64::from(b.y - a.y); + let px = f64::from(p.x - a.x); + let py = f64::from(p.y - a.y); + let length = dx * dx + dy * dy; + let t = if length > 0.0 { ((px * dx + py * dy) / length).clamp(0.0, 1.0) } else { 0.0 }; + (px - t * dx).hypot(py - t * dy) +} + +fn dense_circle_error(path: &SampledPath, plane: &Plane, proj: &Proj, radius: f64, ppp: f32) -> f64 { + assert_eq!(path.quality, SamplingQuality::Complete); + assert!(path.vertex_count <= 4096); + let lines: Vec<_> = + path.parts.iter().flat_map(|part| part.windows(2)).map(|pair| (proj.to_screen(pair[0]).unwrap(), proj.to_screen(pair[1]).unwrap())).collect(); + assert!(!lines.is_empty()); + (0..8192) + .map(|i| { + let angle = i as f64 * std::f64::consts::TAU / 8192.0; + let p = proj.to_screen(plane.to_world(Vec2::new(radius * angle.cos(), radius * angle.sin()))).unwrap(); + lines.iter().map(|&(a, b)| segment_distance(p, a, b)).fold(f64::INFINITY, f64::min) * f64::from(ppp) + }) + .fold(0.0, f64::max) +} + +#[test] +fn pixel_circle_fit_exact_half_chord_hover_click_and_model_invariance() { + let mut h = Harness::new(); + let sid = circle(&mut h); + let baseline = h.app.session.doc.to_json(); + let history = (h.app.session.undo.len(), h.app.session.redo.len()); + let state = h.app.session.world_state(); + let ss = state.sketch(sid).unwrap(); + let plane = ss.plane; + let index = ss.sketch.curve_index("c1").unwrap(); + // Ordinary circles already expose exact analytic Arc spans through segs; + // exact_segs is the supplemental freeform extractor. + let area: f64 = ss.sketch.segs(index).iter().map(|s| s.area_term()).sum(); + assert!((area.abs() - std::f64::consts::PI).abs() < 1e-10); + let proj = projection(&h.app, h.app.viewport.rect.unwrap()); + let radius_px = proj.to_screen(plane.to_world(Vec2::new(1.0, 0.0))).unwrap().distance(proj.to_screen(plane.origin).unwrap()) + * h.app.viewport.pixels_per_point(); + assert!(radius_px > 180.0, "fixture must expose the old R1 Fit sag: {radius_px}"); + let sampled = curve_sample(&h.app, sid, index, &plane, &proj); + assert!(dense_circle_error(&sampled, &plane, &proj, 1.0, h.app.viewport.pixels_per_point()) <= 1.0); + // Midpoints of the old ten-chord pick outline. No centre/point handle is nearby. + for k in [0, 2, 5, 7] { + let angle = (2 * k + 1) as f64 * std::f64::consts::PI / 10.0; + let world = plane.to_world(Vec2::new(angle.cos(), angle.sin())); + let screen = proj.to_screen(world).unwrap(); + assert!(pick(&h.app, &proj, screen).iter().any(|hit| matches!(hit, Hit::SketchCurve { sketch, id, .. } if *sketch == sid && id == "c1"))); + h.step(&json!({"move":{"world":[world.x,world.y,world.z]}})).unwrap(); + assert!( + matches!(&h.app.viewport.hover, Some(Hit::SketchCurve{sketch,id,..}) if *sketch == sid && id == "c1"), + "hover: {:?}", + h.app.viewport.hover + ); + h.step(&json!({"click":{"world":[world.x,world.y,world.z]}})).unwrap(); + assert!(h.app.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::SketchCurve{id} if id == "c1"))); + h.app.session.selection.clear(); + } + assert_eq!(h.app.session.doc.to_json(), baseline, "display and selection must not alter stored geometry, constraints or expressions"); + assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), history); +} + +#[test] +fn pixel_cache_full_view_resize_and_hidpi_invalidate_within_same_grid_bucket() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"corner":[-0.1,-0.1,-0.2],"length":0.2,"width":0.2,"height":0.2,"body_name":"CachedStock"})).unwrap(); + let sid = circle(&mut h); + let baseline = h.app.session.doc.to_json(); + let history = (h.app.session.undo.len(), h.app.session.redo.len()); + let state = h.app.session.world_state(); + assert_eq!(state.bodies.len(), 1, "body batch invariance needs an actual mesh"); + let ss = state.sketch(sid).unwrap(); + let index = ss.sketch.curve_index("c1").unwrap(); + let plane = ss.plane; + let first = projection(&h.app, h.app.viewport.rect.unwrap()); + let keys = crate::viewport::display_scene_keys(&h.app, &first); + let a = curve_sample(&h.app, sid, index, &plane, &first); + assert!(Arc::ptr_eq(&a, &curve_sample(&h.app, sid, index, &plane, &first))); + let grid = solvecraft_engine::view::grid_step(h.app.cam.half_height()); + // Find a real zoom change which keeps the complete grid bucket unchanged. + let distance = h.app.cam.distance; + for factor in [0.8, 0.9, 1.1, 1.2] { + h.app.cam.distance = distance * factor; + if solvecraft_engine::view::grid_step(h.app.cam.half_height()) == grid { + break; + } + } + assert_eq!(solvecraft_engine::view::grid_step(h.app.cam.half_height()), grid); + assert_ne!(h.app.cam.distance, distance); + let zoom = projection(&h.app, first.rect); + let zoom_keys = crate::viewport::display_scene_keys(&h.app, &zoom); + assert_eq!(keys.0, zoom_keys.0, "sketch zoom must not invalidate the body GPU batch"); + assert_ne!(keys.1, zoom_keys.1); + assert_ne!(projection_key(&first, 1.0), projection_key(&zoom, 1.0)); + let b = curve_sample(&h.app, sid, index, &plane, &zoom); + assert!(!Arc::ptr_eq(&a, &b), "full VP must invalidate even when grid spacing is unchanged"); + assert!(dense_circle_error(&b, &plane, &zoom, 1.0, 1.0) <= 1.0); + let resized = projection(&h.app, egui::Rect::from_min_size(first.rect.min, first.rect.size() * 0.75)); + let resized_keys = crate::viewport::display_scene_keys(&h.app, &resized); + assert_eq!(keys.0, resized_keys.0); + assert_ne!(zoom_keys.1, resized_keys.1); + let c = curve_sample(&h.app, sid, index, &plane, &resized); + assert!(!Arc::ptr_eq(&b, &c)); + h.app.viewport.set_pixels_per_point(2.0); + let hidpi_keys = crate::viewport::display_scene_keys(&h.app, &resized); + assert_eq!(keys.0, hidpi_keys.0); + assert_ne!(resized_keys.1, hidpi_keys.1); + let d = curve_sample(&h.app, sid, index, &plane, &resized); + assert!(!Arc::ptr_eq(&c, &d)); + assert!(d.vertex_count > c.vertex_count, "HiDPI must refine physical error, not just invalidate a key"); + assert!(dense_circle_error(&d, &plane, &resized, 1.0, 2.0) <= 1.0); + assert_eq!(h.app.session.doc.to_json(), baseline); + assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), history); +} + +#[test] +fn pixel_adapter_uses_exact_ellipse_periodic_fit_and_control_spline_spans() { + for (command, params) in [ + ("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":0.3})), + ("sketch.spline.fit_point", json!({"points":[[0,0],[10,2],[5,10],[-1,4]],"periodic":true,"connect":false})), + ("sketch.spline.control_point_5", json!({"points":[[0,0],[2,12],[4,-12],[6,12],[8,-12],[10,0]],"connect":false})), + ] { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run(command, params).unwrap(); + h.settle(); + h.step(&json!({"call":"ui.view","params":{"view":"top"}})).unwrap(); + h.step(&json!({"call":"ui.view","params":{"view":"fit"}})).unwrap(); + h.settle(); + let baseline = h.app.session.doc.to_json(); + let sid = h.app.session.active_sketch.unwrap(); + let state = h.app.session.world_state(); + let ss = state.sketch(sid).unwrap(); + let exact = ss.sketch.exact_segs(0).unwrap(); + assert!( + exact.iter().all(|s| !matches!(s, solvecraft_engine::geom::Seg2::Line { .. })), + "freeform display must reach the exact span extractor" + ); + let proj = projection(&h.app, h.app.viewport.rect.unwrap()); + let path = curve_sample(&h.app, sid, 0, &ss.plane, &proj); + assert_eq!(path.quality, SamplingQuality::Complete, "{command}"); + assert!(path.vertex_count <= 4096); + let lines: Vec<_> = + path.parts.iter().flat_map(|p| p.windows(2)).map(|p| (proj.to_screen(p[0]).unwrap(), proj.to_screen(p[1]).unwrap())).collect(); + let mut worst = 0.0f64; + for segment in exact { + for i in 0..=2048 { + let point = segment.point_at(i as f64 / 2048.0); + let screen = proj.to_screen(ss.plane.to_world(point)).unwrap(); + let distance = lines.iter().map(|&(a, b)| segment_distance(screen, a, b)).fold(f64::INFINITY, f64::min); + worst = worst.max(distance * f64::from(h.app.viewport.pixels_per_point())); + } + } + assert!(worst <= 1.0, "{command} projected error: {worst}px"); + assert_eq!(h.app.session.doc.to_json(), baseline); + } +} + +#[test] +fn pixel_curve_second_nested_rotated_occurrence_uses_its_world_plane() { + let mut h = Harness::new(); + let parent = h.app.run("component.create", json!({"name":"Assembly"})).unwrap(); + let parent_component = parent["component"].as_u64().unwrap(); + let first = parent["occurrence"].as_u64().unwrap(); + let child = h.app.run("component.create", json!({"name":"Part"})).unwrap(); + let component = child["component"].as_u64().unwrap(); + let leaf = child["occurrence"].as_u64().unwrap(); + h.app.run("component.activate", json!({"component":0})).unwrap(); + let second = h.app.run("occurrence.copy", json!({"component":parent_component})).unwrap()["occurrence"].as_u64().unwrap(); + h.app.run("occurrence.move", json!({"occurrence":second,"translate":[20,30,40],"axis":[1,0,0],"angle":"35 deg","capture":true})).unwrap(); + h.app.run("component.activate", json!({"component":component,"occurrence":leaf,"path":[second,leaf]})).unwrap(); + h.app.run("browser.visibility", json!({"paths":[[first]],"visible":false})).unwrap(); + let sid = circle(&mut h); + let baseline = h.app.session.doc.to_json(); + let state = h.app.session.world_state(); + let ss = state.sketch(sid).unwrap(); + assert!(ss.plane.origin.dist(Vec3::new(20.0, 30.0, 40.0)) < 1e-8); + let tilt = 35.0f64.to_radians(); + assert!(ss.plane.x.dist(Vec3::X) < 1e-8); + assert!( + ss.plane.y.dist(Vec3::new(0.0, tilt.cos(), tilt.sin())) < 1e-8, + "independent expected occurrence rotation must tilt the actual sketch plane" + ); + let proj = projection(&h.app, h.app.viewport.rect.unwrap()); + let path = curve_sample(&h.app, sid, 0, &ss.plane, &proj); + assert!(dense_circle_error(&path, &ss.plane, &proj, 1.0, h.app.viewport.pixels_per_point()) <= 1.0); + let world = ss.plane.to_world(Vec2::new(0.8090169943749475, 0.5877852522924731)); + h.step(&json!({"click":{"world":[world.x,world.y,world.z]}})).unwrap(); + assert!(h.app.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::SketchCurve{id} if id == "c1"))); + assert_eq!(h.app.session.doc.to_json(), baseline); +} + +#[test] +fn pixel_annulus_fill_keeps_hole_and_cpu_bridge_matches_cached_boundaries() { + let mut h = Harness::new(); + let sid = circle(&mut h); + h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":0.5})).unwrap(); + h.settle(); + let baseline = h.app.session.doc.to_json(); + let state = h.app.session.world_state(); + let ss = state.sketch(sid).unwrap(); + let proj = projection(&h.app, h.app.viewport.rect.unwrap()); + let ppp = h.app.viewport.pixels_per_point(); + let profiles = solvecraft_engine::sketch::find_profiles(&ss.sketch); + let ring = profiles.iter().find(|p| p.region.holes.len() == 1).unwrap(); + let display = crate::sketch_display::display_region(&ring.region, &ss.plane, &proj, ppp).unwrap(); + assert_eq!(display.holes.len(), 1); + let triangles = crate::sketch_display::region_triangles(&ring.region, &ss.plane, &proj, ppp); + assert!(!triangles.is_empty()); + let mut area = 0.0; + let center = proj.to_screen(ss.plane.origin).unwrap(); + let inner_px = proj.to_screen(ss.plane.to_world(Vec2::new(0.5, 0.0))).unwrap().distance(center); + for t in &triangles { + let cross = |a: Vec2, b: Vec2| a.x * b.y - a.y * b.x; + area += cross(t[1] - t[0], t[2] - t[0]).abs() * 0.5; + // No triangle can contain the hole centre; every edge remains outside the + // exact inner circle up to the advertised physical display error. + let signs = [cross(t[1] - t[0], -t[0]), cross(t[2] - t[1], -t[1]), cross(t[0] - t[2], -t[2])]; + assert!(!(signs.iter().all(|s| *s >= 0.0) || signs.iter().all(|s| *s <= 0.0))); + for i in 0..3 { + let a = proj.to_screen(ss.plane.to_world(t[i])).unwrap(); + let b = proj.to_screen(ss.plane.to_world(t[(i + 1) % 3])).unwrap(); + assert!((f64::from(inner_px) - segment_distance(center, a, b)) * f64::from(ppp) <= 1.0); + } + } + assert!((area - std::f64::consts::PI * 0.75).abs() < 0.02, "adaptive fill area: {area}"); + let cpu = solvecraft_engine::view::sketch_lines_with_sampler(&ss.sketch, &ss.plane, true, &[], |sk, index, plane| { + crate::sketch_display::curve_parts(sk, index, plane, &proj, ppp) + }); + let cached: Vec<_> = (0..ss.sketch.curves.len()).flat_map(|index| curve_sample(&h.app, sid, index, &ss.plane, &proj).parts.clone()).collect(); + assert_eq!( + cpu.iter().map(|(points, _, _)| points.clone()).collect::>(), + cached, + "CPU styling bridge and GPU/picking cache must retain identical boundary parts" + ); + assert_eq!(h.app.session.doc.to_json(), baseline); +} + +#[test] +fn pixel_frame_budget_does_not_cache_unbounded_empty_curve_results() { + let mut h = Harness::new(); + let sid = circle(&mut h); + let baseline = h.app.session.doc.to_json(); + let state = h.app.session.world_state(); + let mut plane = state.sketch(sid).unwrap().plane; + let proj = projection(&h.app, h.app.viewport.rect.unwrap()); + let mut limited = 0; + for i in 0..5000 { + // Distinct legal placement keys of one source avoid manufacturing a huge + // document. Even exhausted samples must not add entries forever. + plane.origin.x = i as f64 * 1e-7; + let path = curve_sample(&h.app, sid, 0, &plane, &proj); + assert!(path.vertex_count <= 4096); + if path.quality == SamplingQuality::BudgetLimited { + limited += 1; + } + } + assert!(limited > 0, "fixture must actually exhaust the shared frame budget"); + assert!(h.app.viewport.display.lock().unwrap().len() <= 4096); + assert_eq!(h.app.session.doc.to_json(), baseline); +} diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index 921c0654..07123169 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -178,7 +178,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { }; let t = Tokens::get(); let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect()); - let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE); + let bottom = vp.bottom().min(ctx.content_rect().bottom()) - 8.0; + // Prefer the space below Axis View, but reclaim it on short/HiDPI views. + let top = (vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE).min((bottom - 360.0).max(vp.top())); + let anchor = egui::pos2(vp.right(), top); let frame = egui::Frame::window(&ctx.global_style()) .fill(t.dialog_bg) .stroke(Stroke::new(1.0, t.dialog_border)) @@ -205,12 +208,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { app.tr("ui.heading_sketch_palette"), egui::Id::new("sc_sketch_palette"), anchor, - ctx.content_rect().bottom() - anchor.y - 8.0, + bottom - anchor.y, crate::frame::Width::Normal, ) .frame(frame) .show(ctx, |ui| { - ui.set_width(WIDTH); + let width = WIDTH.min((vp.width() - 24.0).max(120.0)); + ui.set_width(width); ui.horizontal(|ui| { let folded = FOLDED.with(Cell::get); if ui.add(egui::Button::new(RichText::new(if folded { "+" } else { "−" }).size(14.0)).frame(false)).clicked() { @@ -229,47 +233,53 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { OPTIONS_FOLDED.with(|f| f.set(!ofold)); } if !ofold { - egui::Grid::new("sc_palette_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label(app.tr("ui.linetype")); - ui.horizontal(|ui| { - // With a selection: convert it; without: the mode new curves are drawn in. - let current = mode(app); - for (i, (label, tip)) in [("—", "Normal"), ("- -", "Construction"), ("-·-", "Centerline")].iter().enumerate() { - let on = !has_curves && current == i as u8; - let tr_tip = app.tr_label(tip); - let tip_str = if has_curves { - crate::i18n::format("sketch.make_selection", &[("linetype", tr_tip)]) - } else { - crate::i18n::format("sketch.draw_curves", &[("linetype", tr_tip)]) - }; - if ui.add(egui::Button::new(*label).selected(on).min_size(vec2(30.0, 20.0))).on_hover_text(tip_str).clicked() { - linetype = Some(i as u8); + let options_height = (bottom - ui.cursor().top() - 68.0).max(24.0); + let options = + egui::ScrollArea::vertical().id_salt("sc_palette_options").max_height(options_height).auto_shrink([false, true]).show(ui, |ui| { + egui::Grid::new("sc_palette_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(app.tr("ui.linetype")); + ui.horizontal(|ui| { + // With a selection: convert it; without: the mode new curves are drawn in. + let current = mode(app); + for (i, (label, tip)) in [("—", "Normal"), ("- -", "Construction"), ("-·-", "Centerline")].iter().enumerate() { + let on = !has_curves && current == i as u8; + let tr_tip = app.tr_label(tip); + let tip = if has_curves { + crate::i18n::format("sketch.make_selection", &[("linetype", tr_tip)]) + } else { + crate::i18n::format("sketch.draw_curves", &[("linetype", tr_tip)]) + }; + if ui.add(egui::Button::new(*label).selected(on).min_size(vec2(30.0, 20.0))).on_hover_text(tip).clicked() { + linetype = Some(i as u8); + } + } + }); + ui.end_row(); + ui.label(app.tr("ui.look_at")); + if ui.button(app.tr("ui.look_at")).clicked() { + look = true; } - } + ui.end_row(); + for (k, label) in TOGGLES { + // The label is part of the checkbox: clicking it toggles too. + let mut on = shown(k); + if ui.checkbox(&mut on, app.tr_label(label)).changed() { + set = Some((k.to_string(), on)); + } + ui.end_row(); + } + }); }); - ui.end_row(); - ui.label(app.tr("ui.look_at")); - if ui.button(app.tr("ui.look_at")).clicked() { - look = true; - } - ui.end_row(); - for (k, label) in TOGGLES { - // The label is part of the checkbox: clicking it toggles too. - let mut on = shown(k); - if ui.checkbox(&mut on, app.tr_label(label)).changed() { - set = Some((k.to_string(), on)); - } - ui.end_row(); - } - }); + crate::scenario::publish_rect("sketch_palette:options", options.inner_rect); } ui.add_space(4.0); - let tr_dof = crate::i18n::tr_dyn(&dof_line); - ui.label(RichText::new(tr_dof).color(if ok { t.sketch_accent } else { t.text_dim })); + ui.label(RichText::new(crate::i18n::tr_dyn(&dof_line)).color(if ok { t.sketch_accent } else { t.text_dim })); ui.add_space(4.0); - ui.horizontal(|ui| { - ui.add_space(WIDTH - 100.0); - if ui.add(egui::Button::new(RichText::new(app.tr("ui.finish_sketch")).color(Color32::WHITE)).fill(t.accent)).clicked() { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let button = ui.add(egui::Button::new(RichText::new(app.tr("ui.finish_sketch")).color(Color32::WHITE)).fill(t.accent)); + crate::scenario::publish_rect("sketch_palette:finish", button.rect); + crate::scenario::publish_handle("sketch_palette:finish", button.rect.center()); + if button.clicked() { finish = true; } }); @@ -300,6 +310,45 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { mod tests { use super::*; + #[test] + fn palette_footer_and_scrolled_lower_option_remain_reachable_at_scale_and_resize() { + for (scale, height) in [(1.0, 900.0), (2.0, 900.0), (2.0, 720.0)] { + FOLDED.with(|v| v.set(false)); + OPTIONS_FOLDED.with(|v| v.set(false)); + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap(); + h.call("ui.resize", json!({"width":1400,"height":height})); + h.call("ui.prefs", json!({"ui_scale":scale})); + h.settle(); + let baseline = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + let vp = h.app.viewport.rect.unwrap(); + let footer = crate::scenario::rect_of("sketch_palette:finish").unwrap(); + assert!(vp.contains_rect(footer), "scale{scale} height{height}: {footer:?} outside {vp:?}"); + let options = crate::scenario::rect_of("sketch_palette:options").unwrap(); + assert!(vp.contains_rect(options)); + h.call("ui.scroll", json!({"x":options.center().x,"y":options.center().y,"delta":-1000})); + h.settle(); + assert_eq!(h.app.session.doc.to_json(), baseline); + assert_eq!(h.app.session.undo.len(), undo); + let [x, y] = h.widget("3D Sketch").unwrap(); + assert!(options.contains(egui::pos2(x as f32, y as f32)), "lower option must be visible after scrolling"); + h.call("ui.click", json!({"x":x,"y":y})); + h.settle(); + assert!(h.app.session.world_state().sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch.view.three_d); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), baseline); + h.settle(); + let footer = crate::scenario::rect_of("sketch_palette:finish").unwrap(); + h.call("ui.click", json!({"x":footer.center().x,"y":footer.center().y})); + h.settle(); + assert!(h.app.session.active_sketch.is_none(), "Finish Sketch must work at scale{scale} height{height}"); + assert_eq!(h.app.session.doc.to_json(), baseline); + } + } + #[test] fn dof_readout() { assert_eq!(dof_text(0, true), "Fully constrained"); diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index c54c857c..1c0eeaa9 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -970,11 +970,16 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { { painter.line_segment([*last, hs], stroke); } + if let Some((h, _)) = t.hover.as_ref() + && let Some(curve) = crate::tools::preview_curve(t, *h, None) + { + crate::tools::paint_preview_curve(&curve, &ss.plane, painter, proj, stroke); + } // Picked curves highlighted. + let mut display_budget = solvecraft_engine::render::SamplingBudget::new(32_768, 65_536); for id in t.picks.iter().filter(|x| *x != "|") { if let Some(ci) = ss.sketch.curve_index(id) { - let v: Vec = ss.sketch.polyline(ci).iter().filter_map(|q| to(*q)).collect(); - painter.add(egui::Shape::line(v, Stroke::new(3.0, tk.accent.gamma_multiply(0.7)))); + crate::tools::paint_curve(&ss.sketch, ci, &ss.plane, painter, proj, Stroke::new(3.0, tk.accent.gamma_multiply(0.7)), &mut display_budget); } } } @@ -1028,9 +1033,9 @@ fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option = ss.sketch.polyline(ci).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect(); - painter.add(egui::Shape::line(pts, Stroke::new(3.0, tk.accent.gamma_multiply(0.7)))); + crate::tools::paint_curve(&ss.sketch, ci, &ss.plane, painter, proj, Stroke::new(3.0, tk.accent.gamma_multiply(0.7)), &mut display_budget); } for pi in ps { if let Some(q) = ss.sketch.point(pi).and_then(|q| proj.to_screen(ss.plane.to_world(q))) { diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index a2a68433..f3dfa581 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -1,13 +1,17 @@ //! Interactive sketch tools: click points on the sketch plane (or pick sketch entities) and the //! tool runs the matching command. Tools are UI state only; the work is done by commands. -use egui::{Pos2, Shape, Stroke}; +use egui::{Pos2, Stroke}; use serde_json::{Value, json}; use solvecraft_engine::geom::Vec2; use crate::SolveApp; use crate::viewport::{Hit, Proj, pick, sketch_point_at}; +#[cfg(test)] +#[path = "sketch_display_preview_tests.rs"] +mod display_preview_tests; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Kind { /// Click points; `n` points complete the shape (0 = chain until finished). @@ -350,6 +354,117 @@ pub fn finish(app: &mut SolveApp) { } /// Rubber-band preview of the shape being drawn. +pub(crate) fn paint_preview_curve( + sketch: &solvecraft_engine::sketch::Sketch, + plane: &solvecraft_engine::geom::Plane, + painter: &egui::Painter, + proj: &Proj, + stroke: Stroke, +) { + paint_curve(sketch, 0, plane, painter, proj, stroke, &mut solvecraft_engine::render::SamplingBudget::new(4096, 65_536)); +} + +pub(crate) fn paint_curve( + sketch: &solvecraft_engine::sketch::Sketch, + index: usize, + plane: &solvecraft_engine::geom::Plane, + painter: &egui::Painter, + proj: &Proj, + stroke: Stroke, + budget: &mut solvecraft_engine::render::SamplingBudget, +) { + for part in crate::sketch_display::curve_with_budget(sketch, index, plane, proj, painter.ctx().pixels_per_point(), budget).parts { + let screen: Vec<_> = part.into_iter().filter_map(|point| proj.to_screen(point)).collect(); + if screen.len() >= 2 { + painter.add(egui::Shape::line(screen, stroke)); + } + } +} + +/// Temporary analytic geometry uses the same exact span extraction as committed curves. +/// It never solves constraints or modifies the document. +pub(crate) fn preview_curve(t: &Tool, hover: Vec2, sweep: Option) -> Option { + use solvecraft_engine::sketch::{CurveKind, Sketch}; + if !hover.is_finite() || t.pts.len() >= solvecraft_engine::sketch::MAX_SPLINE_POINTS || t.pts.iter().any(|p| !p.0.is_finite()) { + return None; + } + let mut sk = Sketch::new(); + let pts: Vec<_> = t.pts.iter().map(|p| p.0).collect(); + match (t.cmd, pts.as_slice()) { + ("sketch.circle.center" | "sketch.polygon.inscribed" | "sketch.polygon.circumscribed", [c]) => { + sk.add_circle(*c, c.dist(hover), None, None).ok()?; + } + ("sketch.circle.two_point", [a]) => { + sk.add_circle((*a + hover) * 0.5, a.dist(hover) * 0.5, None, None).ok()?; + } + ("sketch.circle.three_point" | "sketch.arc.three_point", [a, b]) => { + let ab = *b - *a; + let ah = hover - *a; + let d = 2.0 * ab.cross(ah); + if !d.is_finite() || d.abs() < 1e-12 { + return None; + } + let centre = *a + Vec2::new(ah.y * ab.len2() - ab.y * ah.len2(), ab.x * ah.len2() - ah.x * ab.len2()) / d; + if t.cmd == "sketch.circle.three_point" { + sk.add_circle(centre, centre.dist(*a), None, None).ok()?; + } else { + let angle = |p: Vec2| (p - centre).angle(); + let span = |p: Vec2| (angle(p) - angle(*a)).rem_euclid(std::f64::consts::TAU); + let ccw = span(hover) < span(*b); + sk.add_arc(centre, if ccw { *a } else { *b }, if ccw { *b } else { *a }, [None; 3], None).ok()?; + if let Some(curve) = sk.curves.first_mut() { + curve.reversed = !ccw; + } + } + } + ("sketch.arc.center_point", [c, a]) => { + let start = (*a - *c).angle(); + let sweep = sweep.unwrap_or_else(|| ((hover - *c).angle() - start).rem_euclid(std::f64::consts::TAU)); + if !sweep.is_finite() || sweep.abs() <= 1e-9 || sweep.abs() >= std::f64::consts::TAU { + return None; + } + let b = *c + Vec2::from_angle(start + sweep) * c.dist(*a); + sk.add_arc(*c, if sweep > 0.0 { *a } else { b }, if sweep > 0.0 { b } else { *a }, [None; 3], None).ok()?; + if let Some(curve) = sk.curves.first_mut() { + curve.reversed = sweep < 0.0; + } + } + ("sketch.ellipse", [c, m]) => { + let u = (*m - *c).normalized()?; + let d = hover - *c; + let k = 1.0 - (d.dot(u) / c.dist(*m)).powi(2); + if !k.is_finite() || k <= 1e-12 { + return None; + } + let r = d.cross(u).abs() / k.sqrt(); + let c = sk.add_point(*c, None).ok()?; + let m = sk.add_point(*m, None).ok()?; + sk.add_curve(CurveKind::Ellipse { c, m, r }, None).ok()?; + } + ("sketch.spline.fit_point" | "sketch.spline.control_point" | "sketch.spline.control_point_5", points) if !points.is_empty() => { + let periodic = t.cmd == "sketch.spline.fit_point" && points.len() >= 3 && points.first() == Some(&hover); + let mut indices = points.iter().map(|p| sk.add_point(*p, None).ok()).collect::>>()?; + if periodic { + indices.push(*indices.first()?); + } else if points.last() != Some(&hover) { + indices.push(sk.add_point(hover, None).ok()?); + } + sk.add_curve( + CurveKind::Spline { + pts: indices, + control: t.cmd != "sketch.spline.fit_point", + degree: if t.cmd == "sketch.spline.control_point_5" { 5 } else { 3 }, + periodic, + }, + None, + ) + .ok()?; + } + _ => return None, + } + Some(sk) +} + pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { if crate::sketch3d::preview(app, t, painter, proj) { return; @@ -377,16 +492,16 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { } let Some((h, _)) = t.hover.clone() else { return }; let h = crate::sketch_dims::effective(app, t, h); + if let Some(curve) = preview_curve(t, h, crate::sketch_dims::locked_value(app, t, "Sweep")) { + paint_preview_curve(&curve, &ss.plane, painter, proj, stroke); + return; + } let pts: Vec = t.pts.iter().map(|p| p.0).collect(); let line = |a: Vec2, b: Vec2| { if let (Some(x), Some(y)) = (to(a), to(b)) { painter.line_segment([x, y], stroke); } }; - let circle = |c: Vec2, r: f64| { - let v: Vec = (0..=64).filter_map(|i| to(c + Vec2::from_angle(i as f64 / 64.0 * std::f64::consts::TAU) * r)).collect(); - painter.add(Shape::line(v, stroke)); - }; match (t.cmd, pts.as_slice()) { ("sketch.line", [.., last]) => { line(*last, h); @@ -419,8 +534,6 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { line(k[i], k[(i + 1) % 4]); } } - ("sketch.circle.center" | "sketch.polygon.inscribed" | "sketch.polygon.circumscribed", [c]) => circle(*c, c.dist(h)), - ("sketch.circle.two_point", [a]) => circle((*a + h) * 0.5, a.dist(h) * 0.5), (_, [a]) => line(*a, h), (_, [a, b]) => { line(*a, *b); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index a1fb98bf..d75e4dec 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -7,7 +7,7 @@ use solvecraft_engine::Sel; use solvecraft_engine::geom::{Vec2, Vec3}; use solvecraft_engine::render::{Camera, Mat4, Rgb}; use solvecraft_engine::sketch::CurveKind; -use solvecraft_engine::view::{colors, grid_step, sketch_consumed, sketch_lines}; +use solvecraft_engine::view::{colors, grid_step, sketch_consumed, sketch_lines_with_sampler}; use std::hash::{Hash, Hasher}; use crate::gpu::{GpuScene, GpuTarget, SceneSlot, ViewportCallback}; @@ -36,6 +36,10 @@ pub struct ViewportState { pub gpu: Option, slot: SceneSlot, key: u64, + sketch_slot: SceneSlot, + sketch_key: u64, + physical_scale: f32, + pub(crate) display: std::sync::Mutex, hl_slot: SceneSlot, hl_key: u64, cpu: Option<(u64, egui::TextureHandle)>, @@ -112,6 +116,20 @@ pub enum Hit { }, } +impl ViewportState { + pub fn pixels_per_point(&self) -> f32 { + if self.physical_scale > 0.0 { self.physical_scale } else { 1.0 } + } + #[cfg(test)] + pub(crate) fn set_pixels_per_point(&mut self, scale: f32) { + self.physical_scale = scale; + } +} + +fn display_proj(app: &SolveApp) -> Proj { + projection(app, app.viewport.rect.unwrap_or_else(|| Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)))) +} + /// World ↔ screen mapping for the current frame. #[derive(Clone, Copy)] pub struct Proj { @@ -213,6 +231,7 @@ fn visible_sketches(app: &SolveApp) -> Vec { fn scene_key(app: &SolveApp) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); app.session.revision.hash(&mut h); + (std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h); app.ui.show_grid.hash(&mut h); app.ui.visual_style.hash(&mut h); app.ui.ground_shadow.hash(&mut h); @@ -232,13 +251,22 @@ fn scene_key(app: &SolveApp) -> u64 { app.tree.redefine.hash(&mut h); crate::theme::is_dark().hash(&mut h); app.session.active_sketch.hash(&mut h); - let (minor, _) = grid_step(app.cam.half_height()); - minor.to_bits().hash(&mut h); - ((app.cam.target.x / (minor * 5.0)).round() as i64).hash(&mut h); - ((app.cam.target.y / (minor * 5.0)).round() as i64).hash(&mut h); h.finish() } +fn sketch_scene_key(app: &SolveApp, proj: &Proj) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + scene_key(app).hash(&mut h); + (std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h); + crate::sketch_display::projection_key(proj, app.viewport.pixels_per_point()).hash(&mut h); + h.finish() +} + +#[cfg(test)] +pub(crate) fn display_scene_keys(app: &SolveApp, proj: &Proj) -> (u64, u64) { + (scene_key(app), sketch_scene_key(app, proj)) +} + /// A sketch curve colour from the engine, in the current theme. fn sketch_color(t: &Tokens, c: Rgb) -> [u8; 4] { if c == colors::SKETCH { @@ -258,23 +286,6 @@ fn build_scene(app: &SolveApp) -> GpuScene { let s = &app.session; // Bodies where their occurrences place them. let st = s.world_state(); - if app.ui.show_grid && !active_view(app).hide_grid { - let (minor, major) = grid_step(app.cam.half_height()); - let ext = (app.cam.half_height() * 3.0 / major).ceil() * major; - // The ground plane: XY with Z up, XZ with Y up (laid out in the camera's Z-up frame). - let tf = app.cam.to_frame(app.cam.target); - let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0); - let w3 = |p: Vec3| app.cam.from_frame(p).to_f32(); - let n = ((ext / minor) as i64).clamp(1, 300); - for i in -n..=n { - let t = i as f64 * minor; - let maj = (t / major).round() * major == t; - let col = if maj { c4(tk.grid_major) } else { c4(tk.grid) }; - let w = if maj { 1.0 } else { 0.7 }; - sc.under_line(w3(c + Vec3::new(t, -ext, 0.0)), w3(c + Vec3::new(t, ext, 0.0)), col, w); - sc.under_line(w3(c + Vec3::new(-ext, t, 0.0)), w3(c + Vec3::new(ext, t, 0.0)), col, w); - } - } let active_bodies = s.active_occurrence_path.as_ref().and_then(|p| s.occurrence_bodies(p, true).ok()); for b in &st.bodies { if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) { @@ -351,14 +362,43 @@ fn build_scene(app: &SolveApp) -> GpuScene { } } } + + sc +} + +/// A soft dark ellipse on the ground under the shown bodies (darkest in the middle). +fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { + let tk = Tokens::get(); + let mut sc = GpuScene::default(); + let s = &app.session; + let st = s.world_state(); + let mut fill_budget = 131_072; + if app.ui.show_grid && !active_view(app).hide_grid { + let (minor, major) = grid_step(app.cam.half_height()); + let ext = (app.cam.half_height() * 3.0 / major).ceil() * major; + // The ground plane: XY with Z up, XZ with Y up (laid out in the camera's Z-up frame). + let tf = app.cam.to_frame(app.cam.target); + let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0); + let w3 = |p: Vec3| app.cam.from_frame(p).to_f32(); + let n = ((ext / minor) as i64).clamp(1, 300); + for i in -n..=n { + let t = i as f64 * minor; + let maj = (t / major).round() * major == t; + let col = if maj { c4(tk.grid_major) } else { c4(tk.grid) }; + let w = if maj { 1.0 } else { 0.7 }; + sc.under_line(w3(c + Vec3::new(t, -ext, 0.0)), w3(c + Vec3::new(t, ext, 0.0)), col, w); + sc.under_line(w3(c + Vec3::new(-ext, t, 0.0)), w3(c + Vec3::new(ext, t, 0.0)), col, w); + } + } for sid in visible_sketches(app) { let Some(ss) = st.sketch(sid) else { continue }; let active = s.active_sketch == Some(sid); // Closed profiles are shaded (lifted off a face they may lie on). let lift = ss.plane.normal() * 0.01; let profiles = if app.ui.hidden_profiles.contains(&sid) || ss.sketch.view.hide_profile { &[][..] } else { &ss.profiles[..] }; - for p in profiles { - for tri in p.region.triangulate(0.05) { + for (index, _) in profiles.iter().enumerate() { + let display = crate::sketch_display::profile_region(app, sid, index, &ss.plane, proj); + for tri in display.as_ref().as_ref().map_or_else(Vec::new, |region| crate::sketch_display::fill_triangles(region, &mut fill_budget)) { for q in tri { sc.trans_tri((ss.plane.to_world(q) + lift).to_f32(), [0.0; 3], c4(tk.profile_fill)); } @@ -366,7 +406,9 @@ fn build_scene(app: &SolveApp) -> GpuScene { } // Construction curves are dashed, centerlines dash-dot (lengths follow the grid). let unit = grid_step(app.cam.half_height()).0; - for (pts, col, cons) in sketch_lines(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) { + for (pts, col, cons) in sketch_lines_with_sampler(&ss.sketch, &ss.plane, active, &ss.report.curve_determined, |_, index, plane| { + crate::sketch_display::curve_sample(app, sid, index, plane, proj).parts.clone() + }) { let segs = if !cons { pts.windows(2).map(|w| (w[0], w[1])).collect() } else if col == colors::SKETCH_CENTERLINE { @@ -380,7 +422,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { } } let guide_unit = grid_step(app.cam.half_height()).0; - for points in crate::sketch_attachment_ui::guide_lines(app) { + for points in crate::sketch_attachment_ui::display_guide_lines(app, proj) { for (a, b) in dashes(&points, &[guide_unit * 0.25, guide_unit * 0.15]) { sc.line(a.to_f32(), b.to_f32(), c4(tk.warning), 1.5, true); } @@ -388,7 +430,6 @@ fn build_scene(app: &SolveApp) -> GpuScene { sc } -/// A soft dark ellipse on the ground under the shown bodies (darkest in the middle). fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc::ModelState) { // The bodies' box in the camera's Z-up frame (the ground is its XY plane). let mut b = solvecraft_engine::geom::Aabb3::EMPTY; @@ -638,6 +679,7 @@ fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec { app.session.visibility.hidden_occurrences.hash(&mut h); app.ui.perspective.hash(&mut h); (proj.rect.width().to_bits(), proj.rect.height().to_bits()).hash(&mut h); + crate::sketch_display::projection_key(proj, app.viewport.pixels_per_point()).hash(&mut h); let key = h.finish(); if let Some((k, hits)) = &app.viewport.last_pick && *k == key @@ -684,8 +726,8 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { continue; } let straight = matches!(c.kind, CurveKind::Line { .. }); - for seg in ss.sketch.segs(i) { - let pts: Vec = seg.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect(); + for part in &crate::sketch_display::curve_sample(app, sid, i, &ss.plane, proj).parts { + let pts: Vec = part.iter().filter_map(|q| proj.to_screen(*q)).collect(); for w in pts.windows(2) { let dd = seg_dist(pos, w[0], w[1]) - bonus; if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) { @@ -1096,17 +1138,17 @@ pub(crate) fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index 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) { + let proj = display_proj(app); + let display = crate::sketch_display::profile_region(app, sketch, index, &plane, &proj); + for [a, b, c] in display.as_ref().as_ref().map_or_else(Vec::new, |region| crate::sketch_display::fill_triangles(region, &mut 65_536)) { sc.trans_tri(w(a), [0.0; 3], col); sc.trans_tri(w(b), [0.0; 3], col); sc.trans_tri(w(c), [0.0; 3], col); } for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) { - let poly = lp.polyline(0.02); - let n = poly.len(); - for i in 0..n { - if let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) { - sc.line(w(*a), w(*b), c4(edge), 2.0, false); + for part in crate::sketch_display::boundary_parts(lp, &plane, &proj, app.viewport.pixels_per_point()) { + for pair in part.windows(2) { + sc.line((pair[0] + lift).to_f32(), (pair[1] + lift).to_f32(), c4(edge), 2.0, false); } } } @@ -1140,6 +1182,7 @@ fn highlight_key(app: &SolveApp) -> u64 { app.session.visibility.hidden_occurrences.hash(&mut h); app.cam.half_height().to_bits().hash(&mut h); app.cam.back().x.to_bits().hash(&mut h); + crate::sketch_display::projection_key(&display_proj(app), app.viewport.pixels_per_point()).hash(&mut h); h.finish() } @@ -1258,10 +1301,9 @@ fn build_highlight(app: &SolveApp) -> GpuScene { if let Some(ss) = app.session.active_sketch.and_then(|sid| st.sketch(sid)) && let Some(ci) = ss.sketch.curve_index(id) { - for seg in ss.sketch.segs(ci) { - let pts = seg.polyline(0.02); + for pts in &crate::sketch_display::curve_sample(app, ss.feature, ci, &ss.plane, &display_proj(app)).parts { for w in pts.windows(2) { - sc.line(ss.plane.to_world(w[0]).to_f32(), ss.plane.to_world(w[1]).to_f32(), c4(t.sel_edge), 3.5, true); + sc.line(w[0].to_f32(), w[1].to_f32(), c4(t.sel_edge), 3.5, true); } } } @@ -1540,7 +1582,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if app.viewport.orbit_snap.is_some() { ui.ctx().request_repaint(); } + app.viewport.physical_scale = ui.ctx().pixels_per_point(); let proj = projection(app, rect); + if let Ok(mut cache) = app.viewport.display.lock() { + cache.prepare(app.session.world_state(), &proj, app.viewport.pixels_per_point()); + } // Suppress hover for the whole navigation gesture, including frames between pointer // events. Testing delta made the highlight reappear whenever a held drag paused. // Resolve it before rendering so the GPU scene and screen overlays use the same hit. @@ -1579,6 +1625,14 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { app.viewport.key = key; app.viewport.build_ms = crate::now_ms() - t0; } + let sk = sketch_scene_key(app, &proj); + if sk != app.viewport.sketch_key { + let scene = build_sketch_scene(app, &proj); + if let Ok(mut slot) = app.viewport.sketch_slot.lock() { + *slot = Some(scene); + } + app.viewport.sketch_key = sk; + } let hk = highlight_key(app); if hk != app.viewport.hl_key { let hl = build_highlight(app); @@ -1591,6 +1645,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let cb = ViewportCallback { key: app.viewport.key, slot: app.viewport.slot.clone(), + sketch_key: app.viewport.sketch_key, + sketch_slot: app.viewport.sketch_slot.clone(), hl_key: app.viewport.hl_key, hl_slot: app.viewport.hl_slot.clone(), pv_key: app.preview.key, @@ -1954,8 +2010,20 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { if c.link.is_some() && ss.sketch.view.hide_projected { continue; } - let pts: Vec = ss.sketch.segs(i).iter().flat_map(|sg| sg.polyline(0.05)).map(|q| ss.plane.to_world(q)).collect(); - if bx.takes(&to2(&pts)) { + let sampled = if bx.crossing { + crate::sketch_display::curve_sample(app, ss.feature, i, &ss.plane, proj) + } else { + std::sync::Arc::new(crate::sketch_display::curve_unclipped(&ss.sketch, i, &ss.plane, proj, app.viewport.pixels_per_point())) + }; + let parts: Vec> = sampled.parts.iter().map(|part| to2(part)).collect(); + let takes = if bx.crossing { + parts.iter().any(|part| bx.takes(part)) + } else { + sampled.quality == solvecraft_engine::render::SamplingQuality::Complete + && !parts.is_empty() + && parts.iter().all(|part| bx.takes(part)) + }; + if takes { out.push(Sel::SketchCurve { id: c.id.clone() }); } } @@ -2029,8 +2097,16 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { for sid in visible_sketches(app) { let Some(ss) = st.sketch(sid) else { continue }; for (pi, p) in ss.profiles.iter().enumerate() { - let pts: Vec = p.region.outer.polyline(0.05).into_iter().map(|q| ss.plane.to_world(q)).collect(); - if bx.takes(&to2(&pts)) { + let parts = crate::sketch_display::boundary_parts(&p.region.outer, &ss.plane, proj, app.viewport.pixels_per_point()); + let takes = if bx.crossing { + parts.iter().any(|part| bx.takes(&to2(part))) + } else { + crate::sketch_display::profile_region(app, sid, pi, &ss.plane, proj).as_ref().as_ref().is_some_and(|display| { + let points: Vec<_> = display.outer.segs.iter().map(|segment| ss.plane.to_world(segment.start())).collect(); + bx.takes(&to2(&points)) + }) + }; + if takes { out.push(Sel::Profile { sketch: sid, index: pi }); } } @@ -2089,8 +2165,8 @@ fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { if let Some(ss) = st.sketch(*sketch) && let Some(ci) = ss.sketch.curve_index(id) { - for seg in ss.sketch.segs(ci) { - let pts: Vec = seg.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect(); + for part in &crate::sketch_display::curve_sample(app, *sketch, ci, &ss.plane, proj).parts { + let pts: Vec = part.iter().filter_map(|q| proj.to_screen(*q)).collect(); painter.add(Shape::line(pts, Stroke::new(3.0, col))); } } @@ -2131,7 +2207,7 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { }; if i == 0 { painter.circle(sp, 3.5, Color32::from_rgb(240, 200, 60), Stroke::new(1.0, t.body_edge)); - } else if virtual_corners.contains(&i) { + } else if virtual_corners.binary_search(&i).is_ok() { // A virtual sharp corner: an open square, it is not on any drawn curve. painter.rect_stroke(Rect::from_center_size(sp, vec2(6.0, 6.0)), 0.0, Stroke::new(1.2, c), egui::StrokeKind::Middle); } else { @@ -2157,14 +2233,18 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, return; } let mut h = std::collections::hash_map::DefaultHasher::new(); - scene_key(app).hash(&mut h); + sketch_scene_key(app, proj).hash(&mut h); for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] { v.to_bits().hash(&mut h); } (rect.width() as u32, rect.height() as u32).hash(&mut h); let key = h.finish(); if app.viewport.cpu.as_ref().is_none_or(|(k, _)| *k != key) { - let scene = solvecraft_engine::view::scene(&app.session, &proj.cam); + let mut budget = solvecraft_engine::render::SamplingBudget::new(131_072, 1_048_576); + let scene = + solvecraft_engine::view::scene_with_section_and_sketch_sampler(&app.session, &proj.cam, section_plane(app), |sk, index, plane| { + crate::sketch_display::curve_with_budget(sk, index, plane, proj, app.viewport.pixels_per_point(), &mut budget).parts + }); let w = (rect.width() as usize).clamp(16, 4096); let hh = (rect.height() as usize).clamp(16, 4096); let c = solvecraft_engine::render::render(&scene, &proj.cam, w, hh); @@ -2526,6 +2606,55 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool { mod tests { use super::*; + #[test] + fn cpu_viewport_uses_section_preview_and_sketch_slice() { + fn pixels(h: &mut crate::scenario::Harness, cam: Camera) -> Vec { + let rect = Rect::from_min_size(Pos2::ZERO, vec2(320.0, 240.0)); + let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect }; + h.app.viewport.no_pixels = false; + // Force a texture upload so this test reads the actual CPU rasterizer result. + h.app.viewport.cpu = None; + let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { + cpu_render(&mut h.app, ui.ctx(), ui.painter(), rect, &proj); + }); + let id = h.app.viewport.cpu.as_ref().unwrap().1.id(); + let upload = out.textures_delta.set.get(&id).unwrap().iter().find(|delta| delta.pos.is_none()).unwrap(); + let egui::ImageData::Color(image) = &upload.image; + assert_eq!(image.size, [320, 240]); + let pixels = image.pixels.clone(); + out.textures_delta.clear(); + pixels + } + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); + let cam = solvecraft_engine::view::home_camera(&h.app.session); + let whole = pixels(&mut h, cam); + let before = h.app.session.doc.clone(); + let undo = h.app.session.undo.len(); + h.app.session.selection = vec![Sel::Plane { name: "XZ".into() }]; + h.app.start("inspect.section"); + let Some(crate::dialogs::Kind::Section { offset, .. }) = h.app.dialog.as_mut().map(|d| &mut d.kind) else { panic!("section dialog") }; + *offset = "15 mm".into(); + assert!(h.app.session.section.is_none()); + let preview = pixels(&mut h, cam); + assert!(whole.iter().zip(&preview).filter(|(a, b)| a != b).count() > 100, "CPU viewport ignored uncommitted section preview"); + assert!(std::sync::Arc::ptr_eq(&before, &h.app.session.doc)); + assert_eq!(h.app.session.undo.len(), undo); + h.app.run("inspect.section", json!({"plane":"XZ","offset":15})).unwrap(); + h.app.dialog = None; + assert_eq!(preview, pixels(&mut h, cam), "preview and committed section must rasterize identically"); + + h.app.session.section = None; + h.app.run("sketch.create", json!({"plane":{"origin":[0,0,10],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap(); + let whole = pixels(&mut h, cam); + h.app.run("sketch.options", json!({"slice":true})).unwrap(); + assert!(h.app.session.section.is_none()); + let sliced = pixels(&mut h, cam); + assert!(whole.iter().zip(&sliced).filter(|(a, b)| a != b).count() > 100, "CPU viewport ignored active Sketch Slice"); + h.app.run("sketch.options", json!({"slice":false})).unwrap(); + assert_eq!(whole, pixels(&mut h, cam), "disabling Slice restores the whole-body pixels"); + } + #[test] fn timeline_modifiers_highlight_on_hover_and_click() { for command in ["solid.fillet", "solid.chamfer"] { diff --git a/docs/book/src/guide/features.md b/docs/book/src/guide/features.md index 16124695..e940728e 100644 --- a/docs/book/src/guide/features.md +++ b/docs/book/src/guide/features.md @@ -26,6 +26,14 @@ one undo step, including downstream parameter changes. Editing a placed componen edited feature before its downstream history. Cylindrical Emboss profile guides follow the sketch's chosen occurrence frame. +Newly selected adaptive profiles store complete outer and hole source identities when the +entire boundary can be qualified. Changing an ellipse or periodic offset after extrusion can +then change its spline span count while keeping the same selected material region. A partial, +intersected or ambiguous boundary cannot satisfy that whole-source reference; changing the +hole identities also makes it lost. Older curve-span selections retain their exact stored +multisets. If an older feature has already lost its profile after subdivision changed, edit +the feature and reselect the intended current region to capture the new identity. + New symmetric revolves split the entered angle evenly around the sketch profile, regardless of which way the selected axis points. Two Sides measures the second angle backward and the first angle forward along the sketch-normal sweep direction. Profiles on opposite sides of @@ -96,4 +104,8 @@ canvases). It runs as one undoable step and lists the features that would go wit Face Extrude, face Revolve, and Press Pull retain linked generated profiles. When a modified planar face has no construction certificate, these internal profiles use the legacy named-face attachment and projection behavior so upstream edits still update the plane and outline. This compatibility path is private to those modeling tools: newly attached user sketches require a certified face or an explicitly fixed plane. Legacy internal attachments can follow the nearest matching face after topology changes; inspect their result after major upstream edits. Activate the intended occurrence before generating a face profile from a repeated component; capture a separately moved source position before creating a legacy profile across components. -A negative Press Pull across a planar face containing a round through-hole can still fail when the Boolean cutter has coincident curved faces. This operation reports an error and preserves the design atomically; positive face extrusion and Press Pull retain the hole and remain linked. +Negative Press Pull across supported aligned prismatic faces retains round through-holes, +including after a joined extrusion and in a rotated component occurrence. The cut partitions +the current material into axial sections, preserving the hole through undo, redo, and reopen. +Walls that cannot be qualified as a parallel extrusion still use the general Boolean path +and may report an unsupported coincident-surface operation atomically. diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 46c803e5..fd98984c 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -47,9 +47,17 @@ DXF pocket boundaries use `POCKET_BOTTOM_Z…_TO_…_MM`, `POCKET_TOP_Z…_TO_ `POCKET_INTERNAL_Z…_TO_…_MM` layers. These are machining operations, so do not treat every pocket layer as a through-cut contour when importing the drawing into a CAM tool. -Extrude Intersect on a straight base or an unfolded sheet retains the exact planar material, -including interior holes and detached islands. A completely empty intersection removes the -sheet and its metadata together. Pocket profiles can move with planar panels when forming, +Extrude Intersect on a straight base or an unfolded sheet retains the material inside both +the current tool footprint and its depth interval, including interior holes, detached islands +and earlier pockets. A shallow intersection records the removed layers above and below the +retained material as top and bottom pockets. The sheet rule keeps the original stock gauge; +the remaining solid can be thinner. A completely empty intersection removes the sheet and +its metadata together. DXF records pocket depth in its layer names; importing that DXF as +ordinary sketch geometry does not reconstruct the machining depths automatically. Each +positive retained or removed depth layer must meet the kernel's 0.000001 mm extrusion +resolution; smaller layers report an error rather than being rounded away. To Object can +retain a tool that trims to a straight prism, using its actual trimmed footprint. A curved +tool with varying depth is refused before changing sheet geometry. Pocket profiles can move with planar panels when forming, but a pocket crossing a bend zone cannot yet be refolded. Machining folded bend material, tapered sheet cuts and other unsupported 3D sheet Cut/Intersect operations report an error before changing the design; they cannot silently restore removed material during a later sheet rebuild. diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 7b4e0ce7..511867d8 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -4,6 +4,13 @@ A sketch lies on a plane: an origin plane, a construction plane or a planar face **Create Sketch** (`sketch.create`) and click a plane, or pick a face first. **Finish Sketch** returns to the model. +Curves adapt to the current zoom and display scale. Picking and highlighting use the same +visible boundary, including small circles at Fit and sketches in rotated components. +Profile shading follows those curves while preserving holes. Zooming changes the display +without changing sketch geometry, dimensions, or undo history. +On short windows or enlarged displays, the Sketch Palette's options scroll while its +constraint status and **Finish Sketch** button remain visible. + ## Geometry Lines and polylines (L), rectangles (two-point R, three-point, centre), circles (centre C, @@ -24,6 +31,21 @@ Extend finds intersections with exact ellipses, rational conics and spline spans original line or circular support and extends only beyond the selected endpoint; conflicting constraints or an ambiguous overlap leave the sketch unchanged. +Closed convex chains of tangent lines and circular arcs support inward offsets that consume +a rounded corner. When the offset passes the corner radius, the neighbouring straight +supports meet at a sharp corner. The surviving lines and endpoint IDs stay stable; the +retired arcs return under their original IDs if a later edit restores their radius. +Whole-boundary profile selections follow this transition through save and reopen, so an +extruded lip can remain associative. Existing eligible closed analytic offsets upgrade +without changing their saved geometry or IDs. + +This transition requires a closed convex boundary with tangent junctions and a certified +nonempty region. An extinguished arc referenced individually by a constraint or another +offset cannot retire. Creating another offset from an already sharp retired boundary also +requires breaking its association first. Open, nonconvex and mixed free-form chains keep +their existing behavior. Break Offset Association freezes the current boundary, including +private arc centres and construction flags, for independent editing. + 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 diff --git a/docs/configurations.md b/docs/configurations.md index 5125fdd3..483d17c6 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -146,6 +146,8 @@ 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. -Newly saved designs use file format 11, including optional durable face attachments. Older designs upgrade on opening without +Newly saved designs use file format 12, including optional durable face attachments and +analytic offset boundary identities and qualified adaptive whole-profile references, including +holes. Older designs upgrade on opening without changing their existing configuration values. Older SolveCraft versions must be updated to open newly saved designs. diff --git a/docs/face-attachments.md b/docs/face-attachments.md index d96f60a6..fd155cab 100644 --- a/docs/face-attachments.md +++ b/docs/face-attachments.md @@ -47,7 +47,7 @@ A missing author path also requires explicit repair. Reattachment and creation c pending positions only on the involved source and author paths. Failed commands restore those pending positions; undo restores the previous attachment and placement state. -File format 11 saves these optional attachments. Existing designs with face references +File format 11 introduced these optional attachments, which format 12 also preserves. Existing designs with face references without an attachment retain their legacy geometry and lookup behavior when opened; they are not silently upgraded. Redefining such a sketch onto a supported selected face creates the stronger attachment. Unsupported legacy geometry can instead be retained on an explicit