feat: preserve adaptive sketch profiles and curved modeling workflows

This commit is contained in:
bherbruck committed 2026-10-11 09:06:25 -04:00
1 parent 4c4a0449a4
commit 4e595deff2
62 files changed
+7114 -362

No files matched your search

+7
View File
@@ -29,12 +29,19 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
New face attachments retain supported construction identities and chosen occurrences; 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). 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. 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 - **Constraints and dimensions** solved by our own solver (damped Gauss–Newton with
degree-of-freedom analysis): coincident, horizontal/vertical, parallel, perpendicular, tangent, degree-of-freedom analysis): coincident, horizontal/vertical, parallel, perpendicular, tangent,
equal, concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical, equal, concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
radius, diameter and angle dimensions driven by parameters. Over-constraining edits are radius, diameter and angle dimensions driven by parameters. Over-constraining edits are
rejected; fully constrained geometry turns black. rejected; fully constrained geometry turns black.
- **Profiles** found automatically, including regions split by lines and holes inside outlines. - **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, - **Features** on a parametric timeline: extrude, revolve, sweep, loft, fillet/chamfer,
shell, draft, holes, threads, patterns, mirror, split, combine, move and construction geometry. shell, draft, holes, threads, patterns, mirror, split, combine, move and construction geometry.
The timeline rebuilds incrementally; features can be edited, suppressed, renamed, reordered The timeline rebuilds incrementally; features can be edited, suppressed, renamed, reordered
+35 -5
View File
@@ -59,7 +59,17 @@ headline counts in-scope tabs only and lists the deferred ones separately.
| M11 | 2D drawings | deferred | — | | M11 | 2D drawings | deferred | — |
| M12 | Release: installers, signing, docs, performance | planned | 16 | | 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 | | 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 `<file>.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 `<file>.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 ## 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 blend a rational B-spline through exact arcs), closed or ending at planar faces square to the
edges. edges.
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal - 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 (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 their neighbours are planes or cylinders. Other coincident curved configurations can still
fail. Bounded containment classification preserves fully internal cavities and nested 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 closed natural-spline seams remain limitations. New explicitly closed fit splines and closed
offset outputs use periodic C2 interpolation, supporting repeated certified offsets; older 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. 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 Projected analytic boundaries follow nested loop identities through resizing, including
stationary holes and rounded corners. An inset that consumes a corner arc still reports stationary holes and rounded corners. Supported convex, tangent line-and-arc boundaries
a collapse error; changing the associated output topology remains unfinished. 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, - 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 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. 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 - 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 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. 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 - Face projection: closed boundaries have a deterministic outer-first order. Refresh matches
existing geometry before assigning positions, retaining saved curve IDs and constraints existing geometry before assigning positions, retaining saved curve IDs and constraints
across enumeration changes and resize, including concentric circles. Ambiguous identities 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. 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 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 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. tapered or bend-crossing machining refuses atomically instead of restoring material later.
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface - Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
+30
View File
@@ -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<String>,
pub holes: Vec<Vec<String>>,
}
/// Which closed profiles of a sketch a feature uses. /// Which closed profiles of a sketch a feature uses.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[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 /// Profiles whose outer loop is made of exactly these curves (order-insensitive); stable
/// across parameter changes. /// across parameter changes.
Curves { loops: Vec<Vec<String>> }, Curves { loops: Vec<Vec<String>> },
/// Complete current boundaries of topology-aware analytic offset associations.
/// The local association identity survives retirement and resurrection of arc roles.
OffsetBoundaries { associations: Vec<String> },
/// Exact whole source coverage, including holes; partial source curves do not match.
WholeBoundaries { profiles: Vec<WholeProfileBoundary> },
/// The profile containing each sketch point. /// The profile containing each sketch point.
Points { points: Vec<Vec2> }, Points { points: Vec<Vec2> },
} }
@@ -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, &section.profiles)).collect()
}
_ => Vec::new(),
}
}
pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> { pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> {
match self { match self {
FeatureKind::Shell { faces, .. } FeatureKind::Shell { faces, .. }
+63 -17
View File
@@ -781,19 +781,15 @@ pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region
} }
v v
} }
ProfileSel::Curves { loops } => { ProfileSel::Curves { .. } | ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. } => {
let mut v = Vec::new(); crate::profile_selection_indices(ss, sel)?
for l in loops { .into_iter()
let mut want: Vec<&String> = l.iter().collect(); .map(|index| {
want.sort(); ps.get(index)
let found = ps.iter().find(|p| { .map(|profile| profile.region.clone())
let mut have: Vec<&String> = p.outer_curves.iter().collect(); .ok_or_else(|| DocError::Invalid("profile index changed during resolution".into()))
have.sort(); })
have == want .collect::<Result<Vec<_>>>()?
});
v.push(found.ok_or_else(|| DocError::Unknown(format!("profile bounded by {l:?} in {}", ss.name)))?.region.clone());
}
v
} }
ProfileSel::Points { points } => { ProfileSel::Points { points } => {
let mut v = Vec::new(); 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()) }) 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<Vec<Body>> {
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. /// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state.
fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -> Result<Vec<Body>> { fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -> Result<Vec<Body>> {
feature_tools_with_constructor(doc, vals, f, st, &mut None)
}
fn feature_tools_with_constructor(
doc: &Document,
vals: &BTreeMap<String, Value>,
f: &Feature,
st: &ModelState,
constructor: &mut Option<ExtrudeConstructor>,
) -> Result<Vec<Body>> {
match &f.kind { match &f.kind {
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => { 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(); 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<String, Value>, f: &Feature, st
} }
} }
let mut tools = Vec::new(); let mut tools = Vec::new();
let mut untrimmed = extent.to.is_none() && extent.from.is_none() && !extent.through_all;
for r in &regions { for r in &regions {
if *operation == Operation::Join { 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| { if exact.iter().all(|tool| {
st.bodies st.bodies
.iter() .iter()
@@ -1674,13 +1714,18 @@ fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st
} }
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets); 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::<Vec<_>>(); let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
let original = r;
let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe); 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)) { if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) {
let start = if hi > 0.0 { lo + off } else { hi + off }; 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())?; 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() { if *operation == Operation::NewBody && extent.taper.is_none() {
let cap_bindings = crate::config::extrude_cap_bindings(&ss, &tools, 10_000_000); let cap_bindings = crate::config::extrude_cap_bindings(&ss, &tools, 10_000_000);
tools = tools tools = tools
@@ -2732,10 +2777,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::Loft { operation, targets, .. } | FeatureKind::Loft { operation, targets, .. }
| FeatureKind::Coil { operation, targets, .. } | FeatureKind::Coil { operation, targets, .. }
| FeatureKind::Sweep { 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. // 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() { 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() { if !handled.is_empty() {
let effective = if targets.is_empty() { &f.participants } else { targets }; let effective = if targets.is_empty() { &f.participants } else { targets };
let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*"); let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
+183 -25
View File
@@ -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::<f64>() / 2.0 (0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::<f64>() / 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<String>]),
}
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(&current) {
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. /// Exact prismatic machining, retained in sheet material/depth metadata.
pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], targets: &[String]) -> Result<Vec<String>> { pub(super) fn sheet_cut(
f: &Feature,
st: &mut ModelState,
tools: &[Body],
targets: &[String],
constructor: Option<&ExtrudeConstructor>,
) -> Result<Vec<String>> {
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) }; let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
if extent.taper.is_some() { if extent.taper.is_some() {
return Ok(Vec::new()); 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 Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let n = ss.plane.normal(); 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); let mut span = (f64::MAX, f64::MIN);
for t in tools { let mut cap_roundoff: f64 = 0.0;
for p in t.tessellate((t.size() * 1e-3).max(1e-3))?.positions { let mut current_footprints = Vec::new();
let d = (p - ss.plane.origin).dot(n); for tool in tools {
span = (span.0.min(d), span.1.max(d)); 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 }; // Only untrimmed Distance tools may use their exact authored scalar interval.
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*"); // 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 plans = Vec::new();
let mut work = sheet::FlatWork::new(); 'sheets: for original in &st.sheets {
for original in &st.sheets { if !eligible(original) {
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 {
continue; continue;
} }
let Some(mb) = st.body(&original.body) else { 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; continue;
} }
let (a, b) = ((span.0 - zorigin) / sign, (span.1 - zorigin) / sign); 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)); 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::<Result<Vec<_>>>()?
} else {
regions.iter().map(|r| sh.region_to_flat(pi, &ss.plane, r)).collect::<Result<Vec<_>>>()?
};
if *operation == Operation::Intersect { if *operation == Operation::Intersect {
if z1 <= z0 { if z1 <= z0 {
sh.holes.push(panel); sh.holes.push(panel);
modeled = true; modeled = true;
continue; 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::<Result<Vec<_>>>()?;
let tool = sheet::material_union(mapped, &mut work)?; 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 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); 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; modeled = true;
} else if z1 > z0 { } else if z1 > z0 {
for r in &regions { for flat in mapped {
let flat = sh.region_to_flat(pi, &ss.plane, r)?;
let clipped = let clipped =
sheet::material_boolean(std::slice::from_ref(&panel), std::slice::from_ref(&flat), kernel::BoolOp::Intersect, &mut work)?; 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 { if !clipped.is_empty() && z0 <= 1e-7 && z1 >= sh.t - 1e-7 {
// Keep a single original tool boundary across structural seams. // Keep a single original tool boundary across structural seams.
// Re-clipping notch polygons creates coincident seam endpoints. // 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; modeled = true;
} else { } else {
for region in clipped { for region in clipped {
sh.pockets.push(sheet::SheetPocket { region, z0, z1 }); sh.record_pocket(region, z0, z1, &mut work)?;
modeled = true; modeled = true;
} }
} }
+8 -2
View File
@@ -33,13 +33,16 @@
//! their space; saved coordinates are never rewritten during migration. //! their space; saved coordinates are never rewritten during migration.
//! - 11: durable sketch face anchors, explicitly periodic fit splines and versioned revolve //! - 11: durable sketch face anchors, explicitly periodic fit splines and versioned revolve
//! conventions. Absent markers retain legacy attachment and interpolation behavior. //! 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 serde_json::Value;
use crate::{DocError, Document, Result}; use crate::{DocError, Document, Result};
/// The format this build writes. /// The format this build writes.
pub const FORMAT: u32 = 11; pub const FORMAT: u32 = 12;
pub fn format_string(n: u32) -> String { pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}") format!("solvecraft/{n}")
@@ -52,7 +55,8 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`. /// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>; 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. /// 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, /// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
@@ -149,6 +153,7 @@ pub fn read(s: &str) -> Result<Document> {
validate_versioned_geometry(&v)?; validate_versioned_geometry(&v)?;
validate_emboss_targets(&v)?; validate_emboss_targets(&v)?;
validate_freeform_offsets(&v)?; validate_freeform_offsets(&v)?;
crate::format_offsets::validate_raw(&v)?;
let legacy_wires = validate_wires(&v, version)?; let legacy_wires = validate_wires(&v, version)?;
validate_body_transfers(&v)?; validate_body_transfers(&v)?;
for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) { 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<Document> {
} }
} }
// Fill in what older designs (and hand-edited files) may lack; a no-op otherwise. // Fill in what older designs (and hand-edited files) may lack; a no-op otherwise.
crate::format_offsets::validate_typed(&d)?;
normalise(&mut d)?; normalise(&mut d)?;
d.format = format_string(FORMAT); d.format = format_string(FORMAT);
Ok(d) Ok(d)
+453
View File
@@ -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<ProfileSel> {
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<Vec<String>> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
json!({"outer":outer,"holes":[]})
})
.collect::<Vec<_>>();
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::<Vec<_>>();
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");
}
}
}
+4
View File
@@ -21,10 +21,14 @@ pub mod expr;
pub mod face_anchor; pub mod face_anchor;
pub use face_anchor::{FaceAnchor, FaceAttachmentStatus}; pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
pub mod format; pub mod format;
mod format_offsets;
pub use format_offsets::whole_profile_selection_from_value;
pub mod joints; pub mod joints;
pub mod motion; pub mod motion;
pub mod naming; pub mod naming;
mod params; mod params;
mod profile_refs;
pub use profile_refs::{capture_profile_selection, profile_selection_indices};
pub mod plastic; pub mod plastic;
pub mod project; pub mod project;
mod project3d; mod project3d;
+31 -2
View File
@@ -331,16 +331,20 @@ fn vote(ps: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<String>>)], tol:
if let Some(f) = idx.face_at(p, tol) if let Some(f) = idx.face_at(p, tol)
&& let Some(n) = nm.get(f) && 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) { match votes.iter_mut().find(|(v, _)| v == n) {
Some(v) => v.1 += 1, Some(v) => v.1 += 1,
None => votes.push((n.clone(), 1)), None => votes.push((n.to_string(), 1)),
} }
break; break;
} }
} }
} }
votes.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); 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`). /// 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)) 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");
}
}
+501
View File
@@ -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<WholeProfileBoundary> {
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<Vec<usize>> {
validate_whole(profiles)?;
let current = ss.profiles.iter().map(whole_boundary).collect::<Vec<_>>();
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::<Vec<_>>(), b.iter().collect::<Vec<_>>());
a.sort();
b.sort();
a == b
}
fn unique_profile(ss: &SolvedSketch, curves: &[String]) -> Result<usize> {
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<std::collections::BTreeMap<Vec<String>, 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::<Vec<_>>();
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<Vec<usize>> {
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::<BTreeSet<_>>().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::<BTreeSet<_>>().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::<Result<Vec<_>>>()?
}
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<ProfileSel> {
let checked = profile_selection_indices(ss, &ProfileSel::Indices { indices: indices.to_vec() })?;
if checked.is_empty() || checked.iter().collect::<BTreeSet<_>>().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::<BTreeSet<_>>().len() != curves.len())
})
});
if adaptive {
let identities = checked.iter().map(|index| ss.profiles.get(*index).and_then(whole_boundary)).collect::<Option<Vec<_>>>();
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::<BTreeSet<_>>().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::<Vec<_>>();
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::<BTreeSet<_>>().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());
}
}
+92 -6
View File
@@ -32,7 +32,7 @@ impl FlatWork {
pub(crate) fn new() -> Self { pub(crate) fn new() -> Self {
Self(MAX_FLAT_WORK) 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)?; self.0 = self.0.checked_sub(work).ok_or_else(flat_budget_error)?;
Ok(()) Ok(())
} }
@@ -754,16 +754,88 @@ impl SheetBody {
} }
let mut work = FlatWork::new(); let mut work = FlatWork::new();
let cells = self.cells_with_work(&mut work)?; let cells = self.cells_with_work(&mut work)?;
let body = kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?; // Validate the bounded material first, then build close to the sheet's own
self.machine_pockets(body, &mut work) // 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). /// The flat pattern as one extrusion (outline with its cut-outs).
pub fn flat_solid(&self) -> Result<Body> { pub fn flat_solid(&self) -> Result<Body> {
let regions = self.flat_regions()?; let regions = self.flat_regions()?;
let pl = plane_of(&self.frame_mat(), Vec3::ZERO, Vec3::X, Vec3::Y)?; let mut local = self.clone();
let body = kernel::extrude_planar_regions(&pl, &regions, 0.0, self.t)?; local.frame = Plane::XY;
self.machine_pockets(body, &mut FlatWork::new()) local.flat = true;
let body = kernel::extrude_planar_regions(&Plane::XY, &regions, 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<Body> {
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<bool> {
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. /// 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")); 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());
}
} }
+67 -4
View File
@@ -9,6 +9,64 @@ use solvecraft_doc::{Document, Feature, FeatureKind, Mat, Occurrence};
const CMD: &str = "component.paste_new"; const CMD: &str = "component.paste_new";
const MAX_WORK: usize = 10_000; 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<u64, u64>) -> Result<Vec<u64>> { fn cloned_path(source: &Document, path: &[u64], root: u64, destination: &[u64], occurrences: &BTreeMap<u64, u64>) -> Result<Vec<u64>> {
let start = path let start = path
.iter() .iter()
@@ -586,9 +644,13 @@ pub(super) fn paste(
let mb = solvecraft_kernel::measure(&b)?; 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 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 mut material_matches = true;
let mesh_a = a.display_mesh((a.size() * 1e-3).max(1e-3))?; let tol_a = (a.size() * 1e-3).max(1e-3).max(a.size() * 1e-6);
let mesh_b = b.display_mesh((b.size() * 1e-3).max(1e-3))?; let tol_b = (b.size() * 1e-3).max(1e-3).max(b.size() * 1e-6);
proof_work = proof_work.saturating_add(mesh_a.triangles.len().saturating_add(mesh_b.triangles.len()).saturating_mul(27)); 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 { if proof_work > 50_000_000 {
return Err(bad(CMD, "geometry verification exceeds copy work limit")); 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] { for z in [0.2, 0.5, 0.8] {
let d = ma.bbox.max - ma.bbox.min; 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); 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);
} }
} }
} }
@@ -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(); 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); 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()));
}
+11 -16
View File
@@ -100,22 +100,17 @@ fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() {
check(&mut s, area * 30.0, hole, 30.0); check(&mut s, area * 30.0, hole, 30.0);
run(&mut s, "solid.press_pull", json!({"face":[35,-10,15],"distance":5})); run(&mut s, "solid.press_pull", json!({"face":[35,-10,15],"distance":5}));
check(&mut s, area * 35.0, hole, 35.0); check(&mut s, area * 35.0, hole, 35.0);
let height = if hole { let before_cut = s.doc.to_json();
// Curved coincident Cut is an existing kernel limit; it must be atomic. run(&mut s, "solid.press_pull", json!({"face":[35,-15,15],"distance":-5}));
let before = s.doc.to_json(); let height = 30.0;
let error = s.execute("solid.press_pull", &json!({"face":[35,-15,15],"distance":-5})).unwrap_err(); check(&mut s, area * height, hole, height);
let message = error.to_string(); let after_cut = s.doc.to_json();
assert!(message.contains("takes planar faces only"), "{error}"); run(&mut s, "edit.undo", json!({}));
assert!(message.contains("the target has 2 curved faces (cylinder)"), "{error}"); assert_eq!(s.doc.to_json(), before_cut);
assert!(message.contains("the tool has 2 curved faces (cylinder)"), "{error}"); check(&mut s, area * 35.0, hole, 35.0);
assert_eq!(s.doc.to_json(), before); run(&mut s, "edit.redo", json!({}));
check(&mut s, area * 35.0, hole, 35.0); assert_eq!(s.doc.to_json(), after_cut);
35.0 check(&mut s, area * height, hole, height);
} else {
run(&mut s, "solid.press_pull", json!({"face":[35,-15,15],"distance":-5}));
check(&mut s, area * 30.0, hole, 30.0);
30.0
};
let world = s.world_state(); let world = s.world_state();
let occupied = Vec3::new(35.0, 21.0 - height, 15.0); let occupied = Vec3::new(35.0, 21.0 - height, 15.0);
let absent = Vec3::new(35.0, 19.0 - height, 15.0); let absent = Vec3::new(35.0, 19.0 - height, 15.0);
+78 -16
View File
@@ -124,17 +124,9 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
ProfileSel::All => ps.first()?.region.interior_point(), ProfileSel::All => ps.first()?.region.interior_point(),
ProfileSel::Indices { indices } => ps.get(*indices.first()?)?.region.interior_point(), ProfileSel::Indices { indices } => ps.get(*indices.first()?)?.region.interior_point(),
ProfileSel::Points { points } => *points.first()?, ProfileSel::Points { points } => *points.first()?,
ProfileSel::Curves { loops } => { selection @ (ProfileSel::Curves { .. } | ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. }) => {
let mut want: Vec<&String> = loops.first()?.iter().collect(); let indices = solvecraft_doc::profile_selection_indices(ss, &selection).ok()?;
want.sort(); ps.get(*indices.first()?)?.region.interior_point()
ps.iter()
.find(|x| {
let mut have: Vec<&String> = x.outer_curves.iter().collect();
have.sort();
have == want
})?
.region
.interior_point()
} }
}; };
Some((ss.plane.to_world(q), ss.plane.normal())) 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<u64> {
fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> { fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> {
let st = s.model.state(); let st = s.model.state();
'sels: for (sketch, sel) in kind.profile_sels_mut() { '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 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() { for i in indices.iter() {
let pr = ss let pr = ss
.profiles .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())))?; .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): // Two profiles with the same outer curves (one inside the other's holes cannot be):
// keep the index then. // 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; 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::<Result<Vec<_>>>()?;
ProfileSel::Curves { loops }
}
captured => captured,
};
} }
Ok(()) Ok(())
} }
@@ -246,6 +270,32 @@ fn same_loop(a: &[String], b: &[String]) -> bool {
pub(super) fn profiles(p: &Value, cmd: &str) -> Result<ProfileSel> { pub(super) fn profiles(p: &Value, cmd: &str) -> Result<ProfileSel> {
let Some(v) = p.get("profiles") else { return Ok(ProfileSel::All) }; let Some(v) = p.get("profiles") else { return Ok(ProfileSel::All) };
match v { 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::<Result<Vec<_>>>()?;
if associations.iter().collect::<std::collections::BTreeSet<_>>().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::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::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")), Value::Array(a) if a.is_empty() => Err(bad(cmd, "`profiles` is empty")),
@@ -1351,3 +1401,15 @@ mod patterns_tests;
#[cfg(test)] #[cfg(test)]
#[path = "revolve_convention_tests.rs"] #[path = "revolve_convention_tests.rs"]
mod revolve_convention_tests; 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;
@@ -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, &params).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<String>) {
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<u64>, 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, &params).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::<Vec<_>>()
};
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);
}
@@ -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, &params).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::<Vec<_>>();
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);
}
@@ -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, &params).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", &params).is_err());
assert_eq!(session.doc.to_json(), saved);
assert_eq!(session.undo.len(), undo);
assert_eq!(session.redo.len(), redo);
}
}
+35
View File
@@ -267,6 +267,7 @@ fn snap_fit(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use solvecraft_geom::Vec3;
use std::f64::consts::PI; use std::f64::consts::PI;
use serde_json::{Value, json}; 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()); 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) { fn plate() -> (Session, f64) {
let mut s = Session::default(); let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 60, "width": 60, "height": 3})); run(&mut s, "solid.box", json!({"length": 60, "width": 60, "height": 3}));
+455
View File
@@ -1103,3 +1103,458 @@ fn ring_cut_preserves_retained_island_through_unfold_and_refold() {
assert!((volume(&mut s) - expected).abs() < 0.2); assert!((volume(&mut s) - expected).abs() < 0.2);
material_probes(&s); 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);
}
}
+21 -1
View File
@@ -873,7 +873,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
let end = c.pos() + Vec2::from_angle((a.pos() - c.pos()).angle() + sw.to_radians()) * a.pos().dist(c.pos()); let end = c.pos() + Vec2::from_angle((a.pos() - c.pos()).angle() + sw.to_radians()) * a.pos().dist(c.pos());
let cid = match &id { let cid = match &id {
Some(i) => i.clone(), Some(i) => i.clone(),
None => sk.fresh("a"), None => sk.fresh("a")?,
}; };
let ai = match a.idx() { let ai = match a.idx() {
Some(i) => i, Some(i) => i,
@@ -1748,6 +1748,26 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("unknown sketch entity `{id}`"))); return Err(bad(cmd, format!("unknown sketch entity `{id}`")));
} }
} }
let selected_curves = curves.iter().copied().collect::<std::collections::BTreeSet<_>>();
let selected_points = points.iter().copied().collect::<std::collections::BTreeSet<_>>();
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::<std::collections::BTreeSet<_>>();
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)); sk.constraints.retain(|c| !cons.contains(&c.id));
let pids: Vec<String> = points.iter().filter_map(|q| sk.points.get(*q).map(|x| x.id.clone())).collect(); let pids: Vec<String> = points.iter().filter_map(|q| sk.points.get(*q).map(|x| x.id.clone())).collect();
sk.remove_curves(&curves); sk.remove_curves(&curves);
+138 -15
View File
@@ -4,6 +4,7 @@
use std::f64::consts::TAU; use std::f64::consts::TAU;
use serde_json::{Value, json}; use serde_json::{Value, json};
use solvecraft_doc::ProfileSel;
use solvecraft_geom::Vec2; use solvecraft_geom::Vec2;
use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, intersections}; use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, intersections};
@@ -95,7 +96,9 @@ fn curve_arg(sk: &Sketch, p: &Value, cmd: &str) -> Result<usize> {
if sk.is_linked_curve(c) { if sk.is_linked_curve(c) {
return Err(bad(cmd, format!("`{id}` is projected geometry: break its link first"))); 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")); return Err(bad(cmd, "break the offset association before editing its output"));
} }
Ok(c) Ok(c)
@@ -184,7 +187,7 @@ fn split_at(sk: &mut Sketch, ci: usize, ts: &[f64]) -> Result<(Vec<usize>, Vec<u
} }
let mut pieces = vec![ci]; let mut pieces = vec![ci];
let mut push = |sk: &mut Sketch, kind: CurveKind, prefix: &str| -> Result<()> { let mut push = |sk: &mut Sketch, kind: CurveKind, prefix: &str| -> 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 }); sk.curves.push(Curve { id, kind, construction, reversed: false, link: None, centerline: false, fixed: false });
pieces.push(sk.curves.len() - 1); pieces.push(sk.curves.len() - 1);
Ok(()) 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 { let virtual_corner = if used || c == 0 {
None None
} else { } else {
let id = sk.fresh("corner"); let id = sk.fresh("corner")?;
if let Some(pt) = sk.points.get_mut(c) { if let Some(pt) = sk.points.get_mut(c) {
pt.id = id.clone(); pt.id = id.clone();
} }
@@ -934,6 +937,14 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
sk.refresh_offsets()?; sk.refresh_offsets()?;
let sel: Vec<usize> = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::<Result<_>>()?; let sel: Vec<usize> = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::<Result<_>>()?;
let first = *sel.first().ok_or_else(|| bad(cmd, "no curves"))?; 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 made = Vec::new();
let mut cons = Vec::new(); let mut cons = Vec::new();
// Circles offset on their own. // Circles offset on their own.
@@ -954,8 +965,9 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
outputs: ids_of(sk, &made), outputs: ids_of(sk, &made),
distance: r - nr, distance: r - nr,
closed: true, closed: true,
topology: None,
}); });
return Ok((ids_of(sk, &made), cons)); return Ok((ids_of(sk, &made), cons, None));
} }
let pool: Vec<usize> = if use_chain { let pool: Vec<usize> = if use_chain {
(0..sk.curves.len()) (0..sk.curves.len())
@@ -986,7 +998,31 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
} }
let (source, forward) = order[0]; let (source, forward) = order[0];
let output = sk.add_freeform_offset(source, forward, d)?; 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::<Result<Vec<_>>>()?;
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<Piece> = let pieces: Vec<Piece> =
order.iter().map(|(c, f)| offset_piece(sk, *c, *f, d)).collect::<Option<_>>().ok_or_else(|| bad(cmd, "the offset collapses an arc"))?; order.iter().map(|(c, f)| offset_piece(sk, *c, *f, d)).collect::<Option<_>>().ok_or_else(|| bad(cmd, "the offset collapses an arc"))?;
@@ -1072,23 +1108,32 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
let outputs = ids_of(sk, &made); let outputs = ids_of(sk, &made);
if pieces.iter().all(|p| !matches!(p, Piece::Free(_))) { 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(); 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()?; 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<Value> { fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.offset.break"; 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 (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 output = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must name an offset output"))?;
let record = sk let record = sk.freeform_offsets.iter().position(|record| record.output == output);
.freeform_offsets let topology = sk
.offsets
.iter() .iter()
.position(|record| record.output == output) .filter_map(|record| record.topology.as_ref())
.ok_or_else(|| bad(cmd, "that curve has no free-form offset association"))?; .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 // A parameter edit may have changed the evaluated source without
// rewriting the stored fit points. Break must freeze the displayed, // rewriting the stored fit points. Break must freeze the displayed,
// current dimensioned geometry, rather than its stale stored predecessor. // current dimensioned geometry, rather than its stale stored predecessor.
@@ -1102,6 +1147,19 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "cannot break an offset with conflicting derived dimensions")); return Err(bad(cmd, "cannot break an offset with conflicting derived dimensions"));
} }
sk.validate_periodic_splines()?; 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 ci = sk.curve_index(output).ok_or_else(|| bad(cmd, "missing offset output"))?;
let indices = sk.curves[ci].kind.point_ids(); let indices = sk.curves[ci].kind.point_ids();
sk.freeform_offsets.remove(record); sk.freeform_offsets.remove(record);
@@ -1110,9 +1168,74 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
point.fixed = false; 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::<Vec<_>>();
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 { sk.curves.push(Curve {
id, id,
kind, kind,
@@ -826,3 +826,88 @@ fn analytic_offset_partial_delete_releases_survivors_preserves_source_and_undo()
run(&mut s, "edit.undo", json!({})); run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before); 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::<f64>()
* 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!({}));
}
}
+106 -14
View File
@@ -91,21 +91,113 @@ fn enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs() {
} }
#[test] #[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 mut s = Session::default();
let script: Value = serde_json::from_str(include_str!("../../../examples/acceptance/sensor_enclosure.json")).unwrap(); let script: Value = serde_json::from_str(include_str!("../../../examples/acceptance/sensor_enclosure.json")).unwrap();
s.run_script(&script).unwrap(); s.run_script(&script).unwrap();
run(&mut s, "parameters.change", json!({"name":"wall","expression":"3 mm"})); for (name, expression) in [("height", "30 mm"), ("board_d", "50 mm"), ("wall", "3 mm")] {
// R4 outer corners leave R1 inner corners. The requested 1.4 mm inward run(&mut s, "parameters.change", json!({"name":name,"expression":expression}));
// 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 check = |session: &Session| {
let result = s.model.result(lip.id).unwrap(); let errors = session.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| (&r.name, e))).collect::<Vec<_>>();
assert!(result.error.as_deref().is_some_and(|error| error.contains("offset collapses an arc")), "{result:?}"); let warnings = session.model.results.iter().filter_map(|r| r.warning.as_ref().map(|e| (&r.name, e))).collect::<Vec<_>>();
assert!(result.warning.as_deref().is_none_or(|warning| !warning.contains("identity is ambiguous")), "{result:?}"); assert!(errors.is_empty(), "errors {errors:?}");
let state = s.model.state(); // The earlier snap-fit operation retains its documented legacy split-face
assert!(state.sketch(lip.id).is_none()); // notice. Offset/projection failures or frozen borders are not accepted.
assert!(!state.body("Lid").unwrap().mesh().contains(solvecraft_geom::Vec3::new(0., 27.2, 24.))); assert!(
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); warnings.iter().all(|(name, message)| name.as_str() == "SnapFit1"
assert!(reopened.model.result(lip.id).unwrap().error.as_deref().is_some_and(|error| error.contains("offset collapses an arc"))); && message.as_str() == "face `F11:side:bs#7` was split; using the piece nearest the picked point"),
assert!(reopened.model.state().sketch(lip.id).is_none()); "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, &regions, 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::<f64>();
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);
} }
+44 -3
View File
@@ -73,6 +73,26 @@ pub fn grid_step(half_height: f64) -> (f64, f64) {
/// Sketch curves as world polylines with their colour. /// Sketch curves as world polylines with their colour.
pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool]) -> Vec<(Vec<Vec3>, Rgb, bool)> { pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool]) -> Vec<(Vec<Vec3>, Rgb, bool)> {
sketch_lines_with_sampler(sk, plane, active, determined, world_curve_parts)
}
fn world_curve_parts(sk: &Sketch, i: usize, plane: &Plane) -> Vec<Vec<Vec3>> {
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<Vec3>>,
) -> Vec<(Vec<Vec3>, Rgb, bool)> {
let mut out = Vec::new(); let mut out = Vec::new();
for (i, c) in sk.curves.iter().enumerate() { for (i, c) in sk.curves.iter().enumerate() {
if c.link.is_some() && sk.view.hide_projected && !sk.is_text_curve(i) { 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 { } else {
colors::SKETCH colors::SKETCH
}; };
for seg in sk.segs(i) { for part in sampler(sk, i, plane) {
out.push((seg.polyline(0.02).iter().map(|p| plane.to_world(*p)).collect(), col, c.construction || c.centerline)); out.push((part, col, c.construction || c.centerline));
} }
} }
// Virtual sharp corners (kept by fillets and chamfers): thin extension lines from the // 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 /// 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. /// sketch's Slice), so model renders match the window.
pub fn scene_with_section(s: &Session, cam: &Camera, section: Option<(Vec3, Vec3)>) -> Scene { 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<Vec<Vec3>>) -> 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<Vec<Vec3>>,
) -> Scene {
let st = s.world_state(); let st = s.world_state();
let section = section.filter(|(o, n)| o.is_finite() && n.is_finite() && n.len() > 1e-9); let section = section.filter(|(o, n)| o.is_finite() && n.is_finite() && n.len() > 1e-9);
let mut sc = Scene { 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) { if !sketch_shown(s, ss.feature) {
continue; 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 }); 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;
+142
View File
@@ -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::<Vec<_>>());
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));
}
+30 -1
View File
@@ -957,6 +957,24 @@ impl Body {
if groups.len() == 1 { if groups.len() == 1 {
return Ok(vec![self.clone()]); 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 groups
.into_iter() .into_iter()
.map(|g| { .map(|g| {
@@ -964,7 +982,18 @@ impl Body {
.into_iter() .into_iter()
.map(|i| shells.get(i).cloned().ok_or_else(|| KernelError::Failed("lumps: missing shell".into()))) .map(|i| shells.get(i).cloned().ok_or_else(|| KernelError::Failed("lumps: missing shell".into())))
.collect::<Result<Vec<_>>>()?; .collect::<Result<Vec<_>>>()?;
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() .collect()
}) })
+79
View File
@@ -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()); let invalid = joined_shells(outer.deep_copy().boundaries().iter().chain(protruding.boundaries()).cloned().collect());
assert!(invalid.separated_lumps().is_err()); 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"));
}
+1
View File
@@ -38,6 +38,7 @@ mod ops;
mod polybool; mod polybool;
mod polyhedron; mod polyhedron;
mod prism; mod prism;
mod prism_curve;
pub mod provenance; pub mod provenance;
mod sew; mod sew;
mod splitface; mod splitface;
+60 -8
View File
@@ -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 /// 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. /// curved faces by the kind of surface they lie on.
fn face_planes(b: &Body, faces: &[crate::FaceInfo]) -> std::result::Result<Vec<PlaneEq>, NotPlanar> { fn face_planes(b: &Body, faces: &[crate::FaceInfo]) -> std::result::Result<Vec<PlaneEq>, 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 curved: std::collections::BTreeMap<&'static str, usize> = std::collections::BTreeMap::new();
let mut out = Vec::with_capacity(faces.len()); 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 { if f.area <= 0.0 {
return Err(NotPlanar::Other(format!("face {} could not be meshed", f.index))); return Err(NotPlanar::Other(format!("face {} could not be meshed", f.index)));
} }
match f.plane_normal { match f.plane_normal {
Some(n) => out.push(PlaneEq { n, w: n.dot(f.centroid) }), Some(n) => out.push(PlaneEq { n, w: n.dot(f.centroid) }),
None => { None => {
let kind = match crate::face_surface(b, f.index) { let kind = match surf_of(surface, surface_tol) {
Some(FaceSurface::Cylinder { .. }) => "cylinder", Some(Surf::Cylinder { .. }) => "cylinder",
Some(FaceSurface::Cone { .. }) => "cone", Some(Surf::Cone { .. }) => "cone",
Some(FaceSurface::Sphere { .. }) => "sphere", Some(Surf::Sphere { .. }) => "sphere",
Some(FaceSurface::Torus { .. }) => "torus", Some(Surf::Torus { .. }) => "torus",
Some(FaceSurface::Plane { .. }) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))), Some(Surf::Plane { .. }) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))),
None => "freeform", None => "freeform",
}; };
*curved.entry(kind).or_insert(0) += 1; *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)) } if curved.is_empty() { Ok(out) } else { Err(NotPlanar::Curved(curved)) }
} }
@@ -792,3 +805,42 @@ fn rebuild(polys: Vec<Poly>, size: f64) -> Result<Body> {
Body::new(solid) 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()));
}
}
+434 -70
View File
@@ -120,68 +120,110 @@ struct Prism {
regions: Vec<Region2>, regions: Vec<Region2>,
} }
/// Edges of a cap face as 2D segments (lines and arcs only). /// Certify that a curved wall is swept along `d` from its surface representation.
fn seg2(e: &mt::Edge, plane: &Plane, tol: f64) -> Option<Vec<Seg2>> { /// Seam vertices alone cannot establish this: a half-cylinder's seam normals can
use mt::{BoundedCurve, ParametricCurve}; /// both be perpendicular to a transverse direction, silently filling its hole.
let c = e.oriented_curve(); fn wall_along(surface: &mt::Surface, d: Vec3, tol: f64, remaining: &mut usize) -> bool {
let (t0, t1) = c.range_tuple(); if let mt::Surface::RevolutedCurve(processor) = surface {
let pts: Vec<Vec2> = (0..=16).map(|k| plane.to_local(from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 16.0)))).collect(); let revolution = processor.entity();
let (a, b) = (*pts.first()?, *pts.last()?); let mt::Curve::Line(line) = revolution.entity_curve() else { return false };
let closed = a.dist(b) < tol; let axis = revolution.axis();
let seg_dist = |p: Vec2| { let meridian = line.1 - line.0;
let ab = b - a; let local_axis = Vec3::new(axis.x, axis.y, axis.z);
let t = ((p - a).dot(ab) / ab.dot(ab).max(1e-300)).clamp(0.0, 1.0); let local_meridian = Vec3::new(meridian.x, meridian.y, meridian.z);
(a + ab * t).dist(p) 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) { let (rows, cols) = dimensions;
return Some(vec![Seg2::Line { a, b }]); 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 Some(left) = remaining.checked_sub(count.saturating_mul(2)) else { return false };
let (p, q, r) = (a, *pts.get(5)?, *pts.get(11)?); *remaining = left;
let d = 2.0 * (p.x * (q.y - r.y) + q.x * (r.y - p.y) + r.x * (p.y - q.y)); let points: Vec<Vec<(Vec3, f64)>> = match surface {
if d.abs() < 1e-300 { mt::Surface::BSplineSurface(s) => s.control_points().iter().map(|r| r.iter().map(|p| (from_p3(*p), 1.0)).collect()).collect(),
return None; 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()
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;
} }
while da < -std::f64::consts::PI { _ => return false,
da += std::f64::consts::TAU; };
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 { false
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 }])
} }
/// The body as a prism along `d` (unit), seen in `plane` (normal `d`). /// 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<Prism> { fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
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 lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY; let mut hi = f64::NEG_INFINITY;
let mut tops: Vec<&mt::Face> = Vec::new(); let mut tops: Vec<&mt::Face> = Vec::new();
let mut top_h = Vec::new(); let mut top_h = Vec::new();
let mut bottom_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() { 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(); let surf = f.oriented_surface();
if let mt::Surface::Plane(p) = &surf { if let mt::Surface::Plane(p) = &surf {
let n = p.normal(); let n = p.normal();
@@ -203,15 +245,8 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
} }
continue; continue;
} }
// Walls: normals square to d at sampled boundary points. if !wall_along(&surf, d, tol, &mut wall_work) {
for w in f.boundaries() { return None;
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;
}
}
} }
} }
// One height for every top (and bottom) cap. // One height for every top (and bottom) cap.
@@ -219,12 +254,17 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
return None; return None;
} }
let mut regions = Vec::new(); let mut regions = Vec::new();
let mut remaining_segments = MAX_PLANAR_SEGMENTS;
let mut remaining_loops = MAX_PLANAR_SEGMENTS;
for f in tops { for f in tops {
let mut loops = Vec::new(); let mut loops = Vec::new();
for w in f.boundaries() { for w in f.boundaries() {
remaining_loops = remaining_loops.checked_sub(1)?;
let mut segs = Vec::new(); let mut segs = Vec::new();
for e in w.edge_iter() { 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 }); 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. /// Sew the exact sections between successive levels, including empty gaps.
fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64) -> Option<Result<Body>> { fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64) -> Option<Result<Body>> {
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<Region2>], tol: f64) -> Option<Result<Body>> {
use crate::sew::{EdgeSpec, FaceSpec, SurfSpec}; use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
// Faces at each level: up where the slab below sticks out, down where the one above does. // Faces at each level: up where the slab below sticks out, down where the one above does.
let diff = |x: &[Region2], y: &[Region2]| -> Option<Vec<Region2>> { let diff = |x: &[Region2], y: &[Region2]| -> Option<Vec<Region2>> {
@@ -727,13 +846,18 @@ fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<
let tol = (size * 1e-7).max(1e-9) * 10.0; let tol = (size * 1e-7).max(1e-9) * 10.0;
// Candidate directions: a's planar face normals (each line once). // Candidate directions: a's planar face normals (each line once).
let mut dirs: Vec<Vec3> = Vec::new(); let mut dirs: Vec<Vec3> = Vec::new();
let mut remaining_direction_faces = MAX_PLANAR_SEGMENTS * 2;
for f in a.solid.face_iter() { 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() { if let mt::Surface::Plane(p) = f.oriented_surface() {
let n = p.normal(); let n = p.normal();
if let Some(n) = Vec3::new(n.x, n.y, n.z).normalized() 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.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 }); 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<Result<Option<
/// A stepped extrusion (case floor plus walls), recovered from its cap events. /// A stepped extrusion (case floor plus walls), recovered from its cap events.
/// Downward caps add a section above their level; upward caps remove one. /// Downward caps add a section above their level; upward caps remove one.
fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> { fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> {
use mt::{ParametricSurface3D, SearchNearestParameter};
if let Some(p) = prism_of(b, d, plane, tol) { if let Some(p) = prism_of(b, d, plane, tol) {
return Some(vec![p]); return Some(vec![p]);
} }
@@ -800,6 +923,8 @@ fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> {
return None; return None;
} }
let mut events = Vec::new(); 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() { for f in b.solid.face_iter() {
let surf = f.oriented_surface(); let surf = f.oriented_surface();
if let mt::Surface::Plane(p) = &surf { if let mt::Surface::Plane(p) = &surf {
@@ -810,7 +935,7 @@ fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> {
for w in f.boundaries() { for w in f.boundaries() {
let mut segs = Vec::new(); let mut segs = Vec::new();
for e in w.edge_iter() { 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 }); loops.push(Loop2 { segs });
} }
@@ -823,13 +948,8 @@ fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> {
return None; return None;
} }
} else { } else {
// Curved walls must be extruded along d, not slanted or rounded caps. if !wall_along(&surf, d, tol, &mut wall_work) {
for v in f.vertex_iter() { return None;
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;
}
} }
} }
} }
@@ -862,9 +982,9 @@ fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> {
} }
fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> { fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
// 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. // 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) { if !matches!(op, BoolOp::Union | BoolOp::Cut) {
return None; return None;
} }
@@ -892,8 +1012,12 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>>
let pa = stack_of(a, d, &plane, tol)?; let pa = stack_of(a, d, &plane, tol)?;
let pb = stack_of(b, d, &plane, tol)?; let pb = stack_of(b, d, &plane, tol)?;
let (ea, eb) = (ends(&pa), ends(&pb)); let (ea, eb) = (ends(&pa), ends(&pb));
if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol { match op {
return None; 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)) 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![] } Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] }
} }
#[test] #[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::<Vec<_>>(), 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(&region), 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(&region), 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(&region), 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(&region), 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(&region), 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(&region), 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(&region), -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(&region), 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(&region), 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() { fn partial_depth_cuts_touching_a_through_hole_use_exact_stacked_sections() {
let plane = Plane::named("XY").unwrap(); let plane = Plane::named("XY").unwrap();
let mut plate = rectangle(Vec2::ZERO, Vec2::new(100., 60.)); let mut plate = rectangle(Vec2::ZERO, Vec2::new(100., 60.));
+409
View File
@@ -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(&degree) || 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<Vec<H>>, f64)> {
let spans: Vec<Vec<mt::Vector4>> = 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::<usize>() > 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<Vec<f64>> {
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<f64> {
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>, 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<H>, Vec<H>) {
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<f64> {
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<Vec<Seg2>> {
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<f64> {
(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<f64> {
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<f64> {
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::<f64>();
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::<Vec<_>>();
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<f64>, points: Vec<mt::Vector4>| {
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());
}
}
+286
View File
@@ -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<Vec<Vec3>>,
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<H>, Vec<H>) {
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<Vec3> {
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<Vec<H>>, 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<H> = 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;
+121
View File
@@ -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);
}
+2
View File
@@ -5,7 +5,9 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
mod camera; mod camera;
mod curve_display;
mod raster; mod raster;
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out}; 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}; pub use raster::{Canvas, Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent};
+5
View File
@@ -15,6 +15,7 @@ mod linalg;
mod link; mod link;
mod model; mod model;
mod offset; mod offset;
mod offset_topology;
mod periodic; mod periodic;
mod profiles; mod profiles;
mod shape; 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 link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView}; pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView};
pub use offset::OffsetChain; pub use offset::OffsetChain;
pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRoleKind, OffsetTopology};
pub use periodic::periodic_beziers; pub use periodic::periodic_beziers;
pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions}; pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions};
pub use shape::{Shape, intersections}; pub use shape::{Shape, intersections};
@@ -40,3 +42,6 @@ pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline};
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
#[cfg(test)]
mod virtual_corner_tests;
+2 -2
View File
@@ -429,7 +429,7 @@ impl Sketch {
if self.points.len() + lay.pts.len() > crate::model::MAX_POINTS { if self.points.len() + lay.pts.len() > crate::model::MAX_POINTS {
return Err(SketchError::TooLarge); 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.links.push(Link { id: id.clone(), kind, source, face_name: None, auto_face: false, lost: false });
self.build_link(&id, &lay)?; self.build_link(&id, &lay)?;
Ok(id) Ok(id)
@@ -468,7 +468,7 @@ impl Sketch {
(CurveKind::Spline { pts, control, degree, periodic }, "s") (CurveKind::Spline { pts, control, degree, periodic }, "s")
} }
}; };
let cid = self.fresh(prefix); let cid = self.fresh(prefix)?;
self.curves.push(Curve { self.curves.push(Curve {
id: cid, id: cid,
kind, kind,
+92 -25
View File
@@ -385,7 +385,7 @@ impl ConstraintKind {
LinearDiameter { p, l, .. } => (vec![p], vec![l]), LinearDiameter { p, l, .. } => (vec![p], vec![l]),
} }
} }
fn remap(&mut self, pmap: &dyn Fn(usize) -> Option<usize>, cmap: &dyn Fn(usize) -> Option<usize>) -> bool { pub(crate) fn remap(&mut self, pmap: &dyn Fn(usize) -> Option<usize>, cmap: &dyn Fn(usize) -> Option<usize>) -> bool {
use ConstraintKind::*; use ConstraintKind::*;
let mut ok = true; let mut ok = true;
let mut fp = |i: &mut usize| match pmap(*i) { 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`). /// 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<String> {
let mut n = self.counters.get(prefix).copied().unwrap_or(0); let mut n = self.counters.get(prefix).copied().unwrap_or(0);
loop { n = n.checked_add(1).ok_or_else(|| SketchError::Invalid("sketch identity counter exhausted".into()))?;
n += 1; 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}"); let id = format!("{prefix}{n}");
if !self.id_taken(&id) { if !taken.contains(id.as_str()) {
self.counters.insert(prefix.to_string(), n); self.counters.insert(prefix.to_string(), n);
return id; return Ok(id);
} }
} }
Err(SketchError::TooLarge)
} }
pub(crate) fn id_taken(&self, id: &str) -> bool { pub(crate) fn id_taken(&self, id: &str) -> bool {
self.points.iter().any(|p| p.id == id) self.points.iter().any(|p| p.id == id)
@@ -557,6 +591,7 @@ impl Sketch {
|| self.constraints.iter().any(|c| c.id == id) || self.constraints.iter().any(|c| c.id == id)
|| self.links.iter().any(|l| l.id == id) || self.links.iter().any(|l| l.id == id)
|| self.wires.iter().any(|w| w.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<usize> { pub fn point_index(&self, id: &str) -> Option<usize> {
@@ -583,7 +618,7 @@ impl Sketch {
Some(i) if self.id_taken(i) => return Err(SketchError::Duplicate(i.into())), Some(i) if self.id_taken(i) => return Err(SketchError::Duplicate(i.into())),
Some("") => return Err(SketchError::Invalid("empty id".into())), Some("") => return Err(SketchError::Invalid("empty id".into())),
Some(i) => i.to_string(), Some(i) => i.to_string(),
None => self.fresh("p"), None => self.fresh("p")?,
}; };
self.points.push(SPoint { id, pos: p, fixed: false, link: None }); self.points.push(SPoint { id, pos: p, fixed: false, link: None });
Ok(self.points.len() - 1) Ok(self.points.len() - 1)
@@ -594,7 +629,7 @@ impl Sketch {
Some(i) if self.id_taken(i) => Err(SketchError::Duplicate(i.into())), Some(i) if self.id_taken(i) => Err(SketchError::Duplicate(i.into())),
Some("") => Err(SketchError::Invalid("empty id".into())), Some("") => Err(SketchError::Invalid("empty id".into())),
Some(i) => Ok(i.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); return Err(SketchError::TooLarge);
} }
self.validate(&kind)?; 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 }); self.constraints.push(Constraint { id: id.clone(), kind, param, driven: false, text: None });
Ok(id) Ok(id)
} }
@@ -901,36 +936,68 @@ impl Sketch {
/// Each with the extension segments from the corner to the nearest end of those curves, for /// Each with the extension segments from the corner to the nearest end of those curves, for
/// drawing. /// drawing.
pub fn virtual_corners(&self) -> Vec<(usize, Vec<(Vec2, Vec2)>)> { pub fn virtual_corners(&self) -> Vec<(usize, Vec<(Vec2, Vec2)>)> {
let mut out = Vec::new(); const MAX_POINT_REFERENCES: usize = 1_000_000;
for (i, p) in self.points.iter().enumerate().skip(1) { if self.points.len() > MAX_POINTS || self.constraints.len() > MAX_CONSTRAINTS || self.curves.len() > MAX_POINT_REFERENCES {
if self.point_used_by_curve(i) { 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; continue;
} }
let on: Vec<usize> = self let Some(curve) = self.curves.get(c) else { continue };
.constraints if !matches!(curve.kind, CurveKind::Line { .. } | CurveKind::Arc { .. })
.iter() || curve.kind.point_ids().iter().any(|i| !self.point(*i).is_some_and(|p| p.is_finite()))
.filter_map(|c| match c.kind { {
ConstraintKind::PointOnCurve { p, c } if p == i => Some(c), continue;
_ => None, }
}) if seen.insert((p, c))
.filter(|c| matches!(self.curves.get(*c).map(|c| &c.kind), Some(CurveKind::Line { .. } | CurveKind::Arc { .. }))) && let Some(on) = incidence.get_mut(p)
.collect(); {
on.push(c);
}
}
let mut out = Vec::new();
for (i, on) in incidence.into_iter().enumerate().skip(1) {
if on.len() < 2 { if on.len() < 2 {
continue; continue;
} }
let Some(p) = self.point(i) else { continue };
let mut ext = Vec::new(); let mut ext = Vec::new();
for c in on { for c in on {
let Some((a, b)) = self.curves.get(c).and_then(|c| c.kind.ends()) else { continue }; 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 (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. // 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 inside = matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. })) && {
let d = pb - pa; 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) (0.0..=1.0).contains(&t)
}; };
if !inside && p.pos.dist(near) > 1e-9 { if !inside && p.dist(near) > 1e-9 {
ext.push((p.pos, near)); ext.push((p, near));
} }
} }
out.push((i, ext)); out.push((i, ext));
+7 -1
View File
@@ -9,6 +9,8 @@ pub struct OffsetChain {
pub outputs: Vec<String>, pub outputs: Vec<String>,
pub distance: f64, pub distance: f64,
pub closed: bool, pub closed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topology: Option<crate::offset_topology::OffsetTopology>,
} }
impl Sketch { impl Sketch {
@@ -57,7 +59,11 @@ impl Sketch {
} }
fn refresh_analytic_offsets(&mut self) -> Result<(), SketchError> { 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() { if offset.sources.len() != offset.outputs.len() || offset.sources.len() > 10_000 || !offset.distance.is_finite() {
return Err(SketchError::Invalid("damaged offset chain".into())); return Err(SketchError::Invalid("damaged offset chain".into()));
} }
+847
View File
@@ -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<T> = std::result::Result<T, SketchError>;
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<OffsetRole>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct OffsetCreated {
pub association: String,
pub curves: Vec<String>,
}
#[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<Arc>,
}
impl Source {
fn tangent(&self, end: bool, orientation: f64) -> Option<Vec2> {
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<Source>,
orientation: f64,
pad: f64,
}
#[derive(Clone, Copy)]
struct Span {
a: Vec2,
b: Vec2,
arc: Option<Arc>,
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<Option<Boundary>> {
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<bool> {
Ok(self.convex_boundary(sources)?.is_some())
}
}
impl Boundary {
fn build(&self, distance: f64) -> Result<Vec<Span>> {
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<usize, BTreeSet<&str>> = 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<dyn Iterator<Item = &str> + '_> = 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<Vec<String>> {
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<Option<String>> {
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<Option<String>> {
self.match_offset_boundary(ids, false)
}
pub fn offset_boundary_for_reserved_curves(&self, ids: &[String]) -> Result<Option<String>> {
self.match_offset_boundary(ids, true)
}
pub fn add_convex_offset(&mut self, sources: Vec<(String, bool)>, distance: f64) -> Result<OffsetCreated> {
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<bool> {
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<Option<Vec<String>>> {
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;
+224
View File
@@ -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::<Vec<_>>(), 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::<Vec<_>>(), 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);
}
}
}
+176 -14
View File
@@ -28,9 +28,20 @@ pub struct Profile {
pub hole_curves: Vec<Vec<String>>, pub hole_curves: Vec<Vec<String>>,
pub area: f64, pub area: f64,
pub centroid: Vec2, pub centroid: Vec2,
// Computed before exact-segment restoration; never trusted from serialized input.
#[serde(skip)]
complete_outer: Option<Vec<String>>,
#[serde(skip)]
complete_holes: Option<Vec<Vec<String>>>,
} }
impl Profile { 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<String>, Vec<Vec<String>>)> {
Some((self.complete_outer.clone()?, self.complete_holes.clone()?))
}
/// All curve ids bounding the profile. /// All curve ids bounding the profile.
pub fn curve_ids(&self) -> Vec<String> { pub fn curve_ids(&self) -> Vec<String> {
let mut v = self.outer_curves.clone(); let mut v = self.outer_curves.clone();
@@ -48,10 +59,65 @@ struct Edge {
id: String, id: String,
/// A piece of an exact free-form segment: (curve, segment, piece, pieces). /// A piece of an exact free-form segment: (curve, segment, piece, pieces).
tag: Option<Piece>, tag: Option<Piece>,
coverage: Option<Piece>,
} }
type Piece = (usize, usize, usize, usize); 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<usize, Vec<usize>>, sk: &Sketch) -> Option<Vec<String>> {
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::<usize, Vec<(usize, usize)>>::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::<Vec<_>>();
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. /// 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<f64> { fn end_param(sh: &Shape, p: Vec2, tol: f64) -> Option<f64> {
match *sh { match *sh {
@@ -373,15 +439,24 @@ pub fn find_drawn_profiles(sk: &Sketch) -> Vec<Profile> {
} }
pub fn find_profiles(sk: &Sketch) -> Vec<Profile> { pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
find_profiles_bounded(sk, MAX_EDGES)
}
fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec<Profile> {
let tol = MERGE_TOL * 10.0; let tol = MERGE_TOL * 10.0;
// Analytic shapes of the profile curves. // Analytic shapes of the profile curves.
let mut shapes: Vec<(usize, Shape)> = Vec::new(); let mut shapes: Vec<(usize, Shape)> = Vec::new();
let mut tags: Vec<Option<Piece>> = Vec::new(); let mut tags: Vec<Option<Piece>> = Vec::new();
let mut exact: std::collections::HashMap<usize, Vec<Seg2>> = std::collections::HashMap::new(); let mut exact: std::collections::HashMap<usize, Vec<Seg2>> = std::collections::HashMap::new();
let mut whole_graph_complete = true;
let mut expected = std::collections::HashMap::<usize, Vec<usize>>::new();
for (ci, c) in sk.curves.iter().enumerate() { for (ci, c) in sk.curves.iter().enumerate() {
if c.construction || c.centerline { if c.construction || c.centerline {
continue; continue;
} }
if matches!(c.kind, CurveKind::Line { .. } | CurveKind::Arc { .. } | CurveKind::Circle { .. }) {
expected.insert(ci, vec![1]);
}
match c.kind { match c.kind {
CurveKind::Line { a, b } => { CurveKind::Line { a, b } => {
if let (Some(a), Some(b)) = (sk.point(a), sk.point(b)) if let (Some(a), Some(b)) = (sk.point(a), sk.point(b))
@@ -404,9 +479,11 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
// a whole segment get the exact one back. // a whole segment get the exact one back.
_ => match sk.exact_segs(ci) { _ => match sk.exact_segs(ci) {
Some(ex) => { Some(ex) => {
let mut spans = Vec::with_capacity(ex.len());
for (k, seg) in ex.iter().enumerate() { for (k, seg) in ex.iter().enumerate() {
let poly = seg.polyline(1e-3); let poly = seg.polyline(1e-3);
let n = poly.len().saturating_sub(1); let n = poly.len().saturating_sub(1);
spans.push(n);
for (j, w) in poly.windows(2).enumerate() { for (j, w) in poly.windows(2).enumerate() {
if w[0].dist(w[1]) > MERGE_TOL { if w[0].dist(w[1]) > MERGE_TOL {
shapes.push((ci, Shape::Line { a: w[0], b: w[1] })); shapes.push((ci, Shape::Line { a: w[0], b: w[1] }));
@@ -415,6 +492,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
} }
} }
} }
expected.insert(ci, spans);
exact.insert(ci, ex); exact.insert(ci, ex);
} }
None => { None => {
@@ -427,7 +505,8 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
}, },
} }
tags.resize(shapes.len(), None); tags.resize(shapes.len(), None);
if shapes.len() > MAX_EDGES { if shapes.len() > max_edges {
whole_graph_complete = false;
break; break;
} }
} }
@@ -580,7 +659,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
grid.entry((cx, cy)).or_default().push(i); grid.entry((cx, cy)).or_default().push(i);
i i
}; };
let mut loops: Vec<(Loop2, Vec<String>, usize)> = Vec::new(); // (ccw loop, curve ids, component) let mut loops: Vec<(Loop2, Vec<String>, usize, Option<Vec<String>>)> = Vec::new(); // (ccw loop, curve ids, component)
let mut edges: Vec<Edge> = Vec::new(); let mut edges: Vec<Edge> = Vec::new();
for (k, (ci, sh)) in shapes.iter().enumerate() { for (k, (ci, sh)) in shapes.iter().enumerate() {
let mut ts = cuts.get(k).cloned().unwrap_or_default(); let mut ts = cuts.get(k).cloned().unwrap_or_default();
@@ -600,6 +679,13 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
ts.pop(); ts.pop();
} }
let id = sk.curves.get(*ci).map(|c| c.id.clone()).unwrap_or_default(); 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 { match *sh {
Shape::Line { a, b } => { Shape::Line { a, b } => {
let mut ps = vec![a]; let mut ps = vec![a];
@@ -611,14 +697,14 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
let (p, q) = (w[0], w[1]); let (p, q) = (w[0], w[1]);
if p.dist(q) > MERGE_TOL { if p.dist(q) > MERGE_TOL {
let (u, v) = (node(p), node(q)); 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 } => { Shape::Round { c, r, start, sweep } => {
let full = sweep >= TAU - 1e-12; let full = sweep >= TAU - 1e-12;
if full && ts.is_empty() { 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; continue;
} }
let mut bounds: Vec<f64> = let mut bounds: Vec<f64> =
@@ -634,7 +720,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
} }
let seg = Seg2::Arc { center: c, radius: r, start: start + t0, sweep: t1 - t0 }; let seg = Seg2::Arc { center: c, radius: r, start: start + t0, sweep: t1 - t0 };
let (u, v) = (node(seg.start()), node(seg.end())); 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<Profile> {
let mut segs = Vec::new(); let mut segs = Vec::new();
let mut ids = Vec::new(); let mut ids = Vec::new();
let mut ptags: Vec<Option<(Piece, bool)>> = Vec::new(); let mut ptags: Vec<Option<(Piece, bool)>> = Vec::new();
let mut coverage_tags = Vec::new();
let mut h = start; let mut h = start;
let mut ok = true; let mut ok = true;
for _ in 0..=edges.len() * 2 { for _ in 0..=edges.len() * 2 {
@@ -719,8 +806,10 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
if let Some(e) = edges.get(h / 2) { if let Some(e) = edges.get(h / 2) {
ids.push(e.id.clone()); ids.push(e.id.clone());
ptags.push(e.tag.map(|t| (t, h.is_multiple_of(2)))); 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 { } else {
ptags.push(None); ptags.push(None);
coverage_tags.push(None);
} }
let back = (-seg.end_tangent()).angle(); let back = (-seg.end_tangent()).angle();
let twin = h ^ 1; let twin = h ^ 1;
@@ -766,16 +855,16 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
let lp = Loop2 { segs: restore_exact(segs, &ptags, &exact) }; let lp = Loop2 { segs: restore_exact(segs, &ptags, &exact) };
if lp.signed_area() > 1e-9 { if lp.signed_area() > 1e-9 {
let c = edges.get(start / 2).map(|e| find(&mut comp, e.u)).unwrap_or(0); 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 // Nesting: parent = smallest containing loop from another component. Bounding boxes rule
// out most pairs before the point-in-loop test. // out most pairs before the point-in-loop test.
let areas: Vec<f64> = loops.iter().map(|(l, _, _)| l.signed_area().abs()).collect(); let areas: Vec<f64> = loops.iter().map(|(l, _, _, _)| l.signed_area().abs()).collect();
let boxes: Vec<(Vec2, Vec2)> = loops let boxes: Vec<(Vec2, Vec2)> = loops
.iter() .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| { 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))) (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<Profile> {
.collect(); .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 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<Option<usize>> = vec![None; loops.len()]; let mut parent_of: Vec<Option<usize>> = 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 probe = li.segs.first().map(Seg2::mid).unwrap_or_default();
let ai = areas.get(i).copied().unwrap_or(0.0); let ai = areas.get(i).copied().unwrap_or(0.0);
let mut best: Option<(f64, usize)> = None; 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); 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) { if i == j || ci == cj || aj <= ai || !inside_box(j, probe) || best.is_some_and(|(ba, _)| aj >= ba) {
continue; continue;
@@ -811,14 +900,18 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
let out: Vec<Profile> = loops let out: Vec<Profile> = loops
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, (l, ids, _))| { .map(|(i, (l, ids, _, complete_outer))| {
let kids: Vec<usize> = kids_of.get(i).cloned().unwrap_or_default(); let kids: Vec<usize> = kids_of.get(i).cloned().unwrap_or_default();
let holes: Vec<Loop2> = kids.iter().filter_map(|k| loops.get(*k).map(|(h, _, _)| h.ccw().reversed())).collect(); let holes: Vec<Loop2> = kids.iter().filter_map(|k| loops.get(*k).map(|(h, _, _, _)| h.ccw().reversed())).collect();
let hole_curves: Vec<Vec<String>> = kids.iter().filter_map(|k| loops.get(*k).map(|(_, ids, _)| ids.clone())).collect(); let hole_curves: Vec<Vec<String>> = kids.iter().filter_map(|k| loops.get(*k).map(|(_, ids, _, _)| ids.clone())).collect();
let region = Region2 { outer: l.ccw(), holes }; let region = Region2 { outer: l.ccw(), holes };
let area = region.area(); let area = region.area();
let centroid = region.centroid(); 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::<Option<Vec<_>>>();
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(); .collect();
let index: std::collections::HashMap<&str, usize> = sk.curves.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).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<Region2> {
} }
if out.is_empty() { regions.to_vec() } else { out } 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());
}
}
+98
View File
@@ -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::<Sketch>(&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);
}
+3 -3
View File
@@ -195,7 +195,7 @@ impl Sketch {
return self.add_link(kind, source, geom); return self.add_link(kind, source, geom);
} }
let id = if geom.is_empty() { 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 }); self.links.push(Link { id: id.clone(), kind, source, face_name: None, auto_face: false, lost: false });
id id
} else { } else {
@@ -217,7 +217,7 @@ impl Sketch {
for (k, pts) in ws.into_iter().enumerate() { for (k, pts) in ws.into_iter().enumerate() {
let wid = match old.get(k) { let wid = match old.get(k) {
Some(o) => o.clone(), 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() }); 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<String> { pub fn add_drawn_wire_in(&mut self, fit: &[Vec3], space: WireSpace) -> Result<String> {
let pts = drawn_polyline(fit)?; let pts = drawn_polyline(fit)?;
self.wire_budget(None, 1, pts.len() + fit.len())?; 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() }); self.wires.push(Wire { id: id.clone(), pts, space, link: None, fit: fit.to_vec() });
Ok(id) Ok(id)
} }
+4 -7
View File
@@ -2923,16 +2923,13 @@ pub(crate) fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &P
match sel { match sel {
ProfileSel::All => (0..ps.len()).collect(), ProfileSel::All => (0..ps.len()).collect(),
ProfileSel::Indices { indices } => indices.iter().copied().filter(|i| *i < 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 ProfileSel::Curves { loops } => loops
.iter() .iter()
.filter_map(|l| { .filter_map(|l| {
let mut want: Vec<&String> = l.iter().collect(); solvecraft_engine::doc::profile_selection_indices(ss, &ProfileSel::Curves { loops: vec![l.clone()] }).ok()?.first().copied()
want.sort();
ps.iter().position(|p| {
let mut have: Vec<&String> = p.outer_curves.iter().collect();
have.sort();
have == want
})
}) })
.collect(), .collect(),
ProfileSel::Points { points } => points.iter().filter_map(|q| ps.iter().position(|p| p.region.contains(*q))).collect(), ProfileSel::Points { points } => points.iter().filter_map(|q| ps.iter().position(|p| p.region.contains(*q))).collect(),
+11 -1
View File
@@ -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 // A pocket retains material below (or above) the cut. Dashed contours and a depth
// annotation distinguish it from the solid through-cut boundaries. // annotation distinguish it from the solid through-cut boundaries.
let pockets: Vec<&Value> = v["pockets"].as_array().into_iter().flatten().collect(); let pockets: Vec<&Value> = v["pockets"].as_array().into_iter().flatten().collect();
let mut pocket_annotations: Vec<(Pos2, Vec<String>)> = Vec::new();
for pocket in &pockets { for pocket in &pockets {
let outer: Vec<(f64, f64)> = pocket["outline"].as_array().into_iter().flatten().filter_map(read).collect(); 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()) { 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 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); let anchor = bounds.center_top() - vec2(0.0, 6.0);
if let Some(label) = pocket_label(pocket) { 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() { for b in v["bends"].as_array().into_iter().flatten() {
let (Some(a), Some(e)) = (read(&b["start"]), read(&b["end"])) else { continue }; let (Some(a), Some(e)) = (read(&b["start"]), read(&b["end"])) else { continue };
let col = if b["direction"] == "up" { up_col } else { down_col }; let col = if b["direction"] == "up" { up_col } else { down_col };
+37
View File
@@ -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] #[test]
fn pocket_export_button_uses_the_picker_and_preserves_depth_layers() { fn pocket_export_button_uses_the_picker_and_preserves_depth_layers() {
let mut h = crate::scenario::Harness::new(); let mut h = crate::scenario::Harness::new();
+10 -1
View File
@@ -121,6 +121,8 @@ const IMG_SIZE: usize = 24;
pub struct ViewportCallback { pub struct ViewportCallback {
pub key: u64, pub key: u64,
pub slot: SceneSlot, pub slot: SceneSlot,
pub sketch_key: u64,
pub sketch_slot: SceneSlot,
/// The highlight scene and its key. /// The highlight scene and its key.
pub hl_key: u64, pub hl_key: u64,
pub hl_slot: SceneSlot, pub hl_slot: SceneSlot,
@@ -230,6 +232,7 @@ struct Resources {
bind: wgpu::BindGroup, bind: wgpu::BindGroup,
linear_out: bool, linear_out: bool,
model: Batches, model: Batches,
sketch: Batches,
highlight: Batches, highlight: Batches,
preview: Batches, preview: Batches,
image: wgpu::RenderPipeline, image: wgpu::RenderPipeline,
@@ -828,6 +831,7 @@ impl Resources {
bind, bind,
linear_out: t.format.is_srgb(), linear_out: t.format.is_srgb(),
model: Batches::default(), model: Batches::default(),
sketch: Batches::default(),
highlight: Batches::default(), highlight: Batches::default(),
preview: Batches::default(), preview: Batches::default(),
} }
@@ -893,6 +897,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
) -> Vec<wgpu::CommandBuffer> { ) -> Vec<wgpu::CommandBuffer> {
let Some(res) = resources.get_mut::<Resources>() else { return Vec::new() }; let Some(res) = resources.get_mut::<Resources>() else { return Vec::new() };
res.model.take(device, self.key, &self.slot); 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.highlight.take(device, self.hl_key, &self.hl_slot);
res.preview.take(device, self.pv_key, &self.pv_slot); res.preview.take(device, self.pv_key, &self.pv_slot);
res.model.sort_glass(queue, self.back); res.model.sort_glass(queue, self.back);
@@ -977,8 +982,9 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
pass.draw(0..6, 0..b.count); 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, &m.under);
lines(pass, &res.overlay, &sk.under);
pass.set_bind_group(1, &res.access_bind, &[]); 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::<f32>() > 0.25); let cut = res.tri_cut.as_ref().filter(|_| self.clip[..3].iter().map(|c| c * c).sum::<f32>() > 0.25);
match cut { match cut {
@@ -996,6 +1002,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
} }
tris(pass, &res.tri_hl, &h.tris); tris(pass, &res.tri_hl, &h.tris);
lines(pass, &res.line, &m.lines); lines(pass, &res.line, &m.lines);
lines(pass, &res.line, &sk.lines);
lines(pass, &res.line_hidden, &m.hidden); lines(pass, &res.line_hidden, &m.hidden);
lines(pass, &res.line_hidden, &h.hidden); lines(pass, &res.line_hidden, &h.hidden);
lines(pass, &res.line, &pv.lines); 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_far, &m.glass);
tris(pass, &res.glass_near, &m.glass); tris(pass, &res.glass_near, &m.glass);
tris(pass, &res.trans, &m.trans); tris(pass, &res.trans, &m.trans);
tris(pass, &res.trans, &sk.trans);
tris(pass, &res.trans, &h.trans); tris(pass, &res.trans, &h.trans);
tris(pass, &res.xray, &pv.xray); tris(pass, &res.xray, &pv.xray);
lines(pass, &res.overlay, &m.overlays); lines(pass, &res.overlay, &m.overlays);
lines(pass, &res.overlay, &sk.overlays);
lines(pass, &res.overlay, &h.overlays); lines(pass, &res.overlay, &h.overlays);
} }
} }
+3
View File
@@ -83,6 +83,9 @@ pub(crate) mod sketch_attachment_ui;
#[cfg(test)] #[cfg(test)]
mod sketch_attachment_ui_tests; mod sketch_attachment_ui_tests;
pub mod sketch_dims; pub mod sketch_dims;
pub(crate) mod sketch_display;
#[cfg(test)]
mod sketch_display_ui_tests;
#[cfg(test)] #[cfg(test)]
mod sketch_edit_tests; mod sketch_edit_tests;
pub mod sketch_palette; pub mod sketch_palette;
@@ -267,3 +267,11 @@ pub fn guide_lines(app: &SolveApp) -> Vec<Vec<Vec3>> {
.map(|segment| segment.polyline(0.05).into_iter().map(|point| plane.to_world(point)).collect()) .map(|segment| segment.polyline(0.05).into_iter().map(|point| plane.to_world(point)).collect())
.collect() .collect()
} }
pub fn display_guide_lines(app: &SolveApp, proj: &crate::viewport::Proj) -> Vec<Vec<Vec3>> {
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()
}
+243
View File
@@ -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<Vec<Vec3>> {
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<Region2> {
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<Region2> {
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<Loop2> {
let mut points: Vec<Vec2> = 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(&region.outer)?;
let holes = region.holes.iter().map(&mut boundary).collect::<Option<Vec<_>>>()?;
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<Vec<Vec3>> {
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<Arc<ModelState>>,
view: u64,
curves: HashMap<(u64, usize, [u64; 9]), Arc<SampledPath>>,
profiles: HashMap<(u64, usize, [u64; 9]), Arc<Option<Region2>>>,
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<ModelState>, proj: &Proj, ppp: f32) {
self.reset(&state, proj, ppp);
}
fn reset(&mut self, state: &Arc<ModelState>, 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<ModelState>, sketch: u64, index: usize, plane: &Plane, proj: &Proj, ppp: f32) -> Arc<SampledPath> {
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<ModelState>, sketch: u64, index: usize, plane: &Plane, proj: &Proj, ppp: f32) -> Arc<Option<Region2>> {
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<Option<Region2>> {
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<SampledPath> {
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)),
)),
}
}
@@ -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<solvecraft_engine::geom::Seg2> {
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<Vec3>], 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<f64>, 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::<usize>() <= 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());
}
@@ -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::<Vec<_>>(),
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);
}
+88 -39
View File
@@ -178,7 +178,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}; };
let t = Tokens::get(); let t = Tokens::get();
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect()); 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()) let frame = egui::Frame::window(&ctx.global_style())
.fill(t.dialog_bg) .fill(t.dialog_bg)
.stroke(Stroke::new(1.0, t.dialog_border)) .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"), app.tr("ui.heading_sketch_palette"),
egui::Id::new("sc_sketch_palette"), egui::Id::new("sc_sketch_palette"),
anchor, anchor,
ctx.content_rect().bottom() - anchor.y - 8.0, bottom - anchor.y,
crate::frame::Width::Normal, crate::frame::Width::Normal,
) )
.frame(frame) .frame(frame)
.show(ctx, |ui| { .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| { ui.horizontal(|ui| {
let folded = FOLDED.with(Cell::get); let folded = FOLDED.with(Cell::get);
if ui.add(egui::Button::new(RichText::new(if folded { "+" } else { "−" }).size(14.0)).frame(false)).clicked() { 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)); OPTIONS_FOLDED.with(|f| f.set(!ofold));
} }
if !ofold { if !ofold {
egui::Grid::new("sc_palette_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { let options_height = (bottom - ui.cursor().top() - 68.0).max(24.0);
ui.label(app.tr("ui.linetype")); let options =
ui.horizontal(|ui| { egui::ScrollArea::vertical().id_salt("sc_palette_options").max_height(options_height).auto_shrink([false, true]).show(ui, |ui| {
// With a selection: convert it; without: the mode new curves are drawn in. egui::Grid::new("sc_palette_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
let current = mode(app); ui.label(app.tr("ui.linetype"));
for (i, (label, tip)) in [("—", "Normal"), ("- -", "Construction"), ("-·-", "Centerline")].iter().enumerate() { ui.horizontal(|ui| {
let on = !has_curves && current == i as u8; // With a selection: convert it; without: the mode new curves are drawn in.
let tr_tip = app.tr_label(tip); let current = mode(app);
let tip_str = if has_curves { for (i, (label, tip)) in [("—", "Normal"), ("- -", "Construction"), ("-·-", "Centerline")].iter().enumerate() {
crate::i18n::format("sketch.make_selection", &[("linetype", tr_tip)]) let on = !has_curves && current == i as u8;
} else { let tr_tip = app.tr_label(tip);
crate::i18n::format("sketch.draw_curves", &[("linetype", tr_tip)]) let tip = if has_curves {
}; crate::i18n::format("sketch.make_selection", &[("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() { } else {
linetype = Some(i as u8); 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(); crate::scenario::publish_rect("sketch_palette:options", options.inner_rect);
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.add_space(4.0); ui.add_space(4.0);
let tr_dof = crate::i18n::tr_dyn(&dof_line); ui.label(RichText::new(crate::i18n::tr_dyn(&dof_line)).color(if ok { t.sketch_accent } else { t.text_dim }));
ui.label(RichText::new(tr_dof).color(if ok { t.sketch_accent } else { t.text_dim }));
ui.add_space(4.0); ui.add_space(4.0);
ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(WIDTH - 100.0); let button = ui.add(egui::Button::new(RichText::new(app.tr("ui.finish_sketch")).color(Color32::WHITE)).fill(t.accent));
if ui.add(egui::Button::new(RichText::new(app.tr("ui.finish_sketch")).color(Color32::WHITE)).fill(t.accent)).clicked() { crate::scenario::publish_rect("sketch_palette:finish", button.rect);
crate::scenario::publish_handle("sketch_palette:finish", button.rect.center());
if button.clicked() {
finish = true; finish = true;
} }
}); });
@@ -300,6 +310,45 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
mod tests { mod tests {
use super::*; 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] #[test]
fn dof_readout() { fn dof_readout() {
assert_eq!(dof_text(0, true), "Fully constrained"); assert_eq!(dof_text(0, true), "Fully constrained");
+9 -4
View File
@@ -970,11 +970,16 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) {
{ {
painter.line_segment([*last, hs], stroke); 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. // 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 != "|") { for id in t.picks.iter().filter(|x| *x != "|") {
if let Some(ci) = ss.sketch.curve_index(id) { if let Some(ci) = ss.sketch.curve_index(id) {
let v: Vec<Pos2> = ss.sketch.polyline(ci).iter().filter_map(|q| to(*q)).collect(); crate::tools::paint_curve(&ss.sketch, ci, &ss.plane, painter, proj, Stroke::new(3.0, tk.accent.gamma_multiply(0.7)), &mut display_budget);
painter.add(egui::Shape::line(v, Stroke::new(3.0, tk.accent.gamma_multiply(0.7))));
} }
} }
} }
@@ -1028,9 +1033,9 @@ fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option<Po
&& let Some(c) = ss.sketch.constraints.iter().find(|c| &c.id == id) && let Some(c) = ss.sketch.constraints.iter().find(|c| &c.id == id)
{ {
let (cs, ps) = constraint_entities(&c.kind); let (cs, ps) = constraint_entities(&c.kind);
let mut display_budget = solvecraft_engine::render::SamplingBudget::new(32_768, 65_536);
for ci in cs { for ci in cs {
let pts: Vec<Pos2> = ss.sketch.polyline(ci).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect(); crate::tools::paint_curve(&ss.sketch, ci, &ss.plane, painter, proj, Stroke::new(3.0, tk.accent.gamma_multiply(0.7)), &mut display_budget);
painter.add(egui::Shape::line(pts, Stroke::new(3.0, tk.accent.gamma_multiply(0.7))));
} }
for pi in ps { for pi in ps {
if let Some(q) = ss.sketch.point(pi).and_then(|q| proj.to_screen(ss.plane.to_world(q))) { if let Some(q) = ss.sketch.point(pi).and_then(|q| proj.to_screen(ss.plane.to_world(q))) {
+120 -7
View File
@@ -1,13 +1,17 @@
//! Interactive sketch tools: click points on the sketch plane (or pick sketch entities) and the //! 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. //! 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 serde_json::{Value, json};
use solvecraft_engine::geom::Vec2; use solvecraft_engine::geom::Vec2;
use crate::SolveApp; use crate::SolveApp;
use crate::viewport::{Hit, Proj, pick, sketch_point_at}; 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)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind { pub enum Kind {
/// Click points; `n` points complete the shape (0 = chain until finished). /// 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. /// 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<f64>) -> Option<solvecraft_engine::sketch::Sketch> {
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::<Option<Vec<_>>>()?;
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) { pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) {
if crate::sketch3d::preview(app, t, painter, proj) { if crate::sketch3d::preview(app, t, painter, proj) {
return; 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 Some((h, _)) = t.hover.clone() else { return };
let h = crate::sketch_dims::effective(app, t, h); 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<Vec2> = t.pts.iter().map(|p| p.0).collect(); let pts: Vec<Vec2> = t.pts.iter().map(|p| p.0).collect();
let line = |a: Vec2, b: Vec2| { let line = |a: Vec2, b: Vec2| {
if let (Some(x), Some(y)) = (to(a), to(b)) { if let (Some(x), Some(y)) = (to(a), to(b)) {
painter.line_segment([x, y], stroke); painter.line_segment([x, y], stroke);
} }
}; };
let circle = |c: Vec2, r: f64| {
let v: Vec<Pos2> = (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()) { match (t.cmd, pts.as_slice()) {
("sketch.line", [.., last]) => { ("sketch.line", [.., last]) => {
line(*last, h); 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]); 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]) => line(*a, h),
(_, [a, b]) => { (_, [a, b]) => {
line(*a, *b); line(*a, *b);
+176 -47
View File
@@ -7,7 +7,7 @@ use solvecraft_engine::Sel;
use solvecraft_engine::geom::{Vec2, Vec3}; use solvecraft_engine::geom::{Vec2, Vec3};
use solvecraft_engine::render::{Camera, Mat4, Rgb}; use solvecraft_engine::render::{Camera, Mat4, Rgb};
use solvecraft_engine::sketch::CurveKind; 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 std::hash::{Hash, Hasher};
use crate::gpu::{GpuScene, GpuTarget, SceneSlot, ViewportCallback}; use crate::gpu::{GpuScene, GpuTarget, SceneSlot, ViewportCallback};
@@ -36,6 +36,10 @@ pub struct ViewportState {
pub gpu: Option<GpuTarget>, pub gpu: Option<GpuTarget>,
slot: SceneSlot, slot: SceneSlot,
key: u64, key: u64,
sketch_slot: SceneSlot,
sketch_key: u64,
physical_scale: f32,
pub(crate) display: std::sync::Mutex<crate::sketch_display::DisplayCache>,
hl_slot: SceneSlot, hl_slot: SceneSlot,
hl_key: u64, hl_key: u64,
cpu: Option<(u64, egui::TextureHandle)>, 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. /// World ↔ screen mapping for the current frame.
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct Proj { pub struct Proj {
@@ -213,6 +231,7 @@ fn visible_sketches(app: &SolveApp) -> Vec<u64> {
fn scene_key(app: &SolveApp) -> u64 { fn scene_key(app: &SolveApp) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new(); let mut h = std::collections::hash_map::DefaultHasher::new();
app.session.revision.hash(&mut h); 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.show_grid.hash(&mut h);
app.ui.visual_style.hash(&mut h); app.ui.visual_style.hash(&mut h);
app.ui.ground_shadow.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); app.tree.redefine.hash(&mut h);
crate::theme::is_dark().hash(&mut h); crate::theme::is_dark().hash(&mut h);
app.session.active_sketch.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() 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. /// A sketch curve colour from the engine, in the current theme.
fn sketch_color(t: &Tokens, c: Rgb) -> [u8; 4] { fn sketch_color(t: &Tokens, c: Rgb) -> [u8; 4] {
if c == colors::SKETCH { if c == colors::SKETCH {
@@ -258,23 +286,6 @@ fn build_scene(app: &SolveApp) -> GpuScene {
let s = &app.session; let s = &app.session;
// Bodies where their occurrences place them. // Bodies where their occurrences place them.
let st = s.world_state(); 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()); let active_bodies = s.active_occurrence_path.as_ref().and_then(|p| s.occurrence_bodies(p, true).ok());
for b in &st.bodies { for b in &st.bodies {
if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) { 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) { for sid in visible_sketches(app) {
let Some(ss) = st.sketch(sid) else { continue }; let Some(ss) = st.sketch(sid) else { continue };
let active = s.active_sketch == Some(sid); let active = s.active_sketch == Some(sid);
// Closed profiles are shaded (lifted off a face they may lie on). // Closed profiles are shaded (lifted off a face they may lie on).
let lift = ss.plane.normal() * 0.01; let lift = ss.plane.normal() * 0.01;
let profiles = if app.ui.hidden_profiles.contains(&sid) || ss.sketch.view.hide_profile { &[][..] } else { &ss.profiles[..] }; let profiles = if app.ui.hidden_profiles.contains(&sid) || ss.sketch.view.hide_profile { &[][..] } else { &ss.profiles[..] };
for p in profiles { for (index, _) in profiles.iter().enumerate() {
for tri in p.region.triangulate(0.05) { 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 { for q in tri {
sc.trans_tri((ss.plane.to_world(q) + lift).to_f32(), [0.0; 3], c4(tk.profile_fill)); 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). // Construction curves are dashed, centerlines dash-dot (lengths follow the grid).
let unit = grid_step(app.cam.half_height()).0; 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 { let segs = if !cons {
pts.windows(2).map(|w| (w[0], w[1])).collect() pts.windows(2).map(|w| (w[0], w[1])).collect()
} else if col == colors::SKETCH_CENTERLINE { } 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; 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]) { for (a, b) in dashes(&points, &[guide_unit * 0.25, guide_unit * 0.15]) {
sc.line(a.to_f32(), b.to_f32(), c4(tk.warning), 1.5, true); sc.line(a.to_f32(), b.to_f32(), c4(tk.warning), 1.5, true);
} }
@@ -388,7 +430,6 @@ fn build_scene(app: &SolveApp) -> GpuScene {
sc 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) { 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). // The bodies' box in the camera's Z-up frame (the ground is its XY plane).
let mut b = solvecraft_engine::geom::Aabb3::EMPTY; let mut b = solvecraft_engine::geom::Aabb3::EMPTY;
@@ -638,6 +679,7 @@ fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
app.session.visibility.hidden_occurrences.hash(&mut h); app.session.visibility.hidden_occurrences.hash(&mut h);
app.ui.perspective.hash(&mut h); app.ui.perspective.hash(&mut h);
(proj.rect.width().to_bits(), proj.rect.height().to_bits()).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(); let key = h.finish();
if let Some((k, hits)) = &app.viewport.last_pick if let Some((k, hits)) = &app.viewport.last_pick
&& *k == key && *k == key
@@ -684,8 +726,8 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
continue; continue;
} }
let straight = matches!(c.kind, CurveKind::Line { .. }); let straight = matches!(c.kind, CurveKind::Line { .. });
for seg in ss.sketch.segs(i) { for part in &crate::sketch_display::curve_sample(app, sid, i, &ss.plane, proj).parts {
let pts: Vec<Pos2> = seg.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect(); let pts: Vec<Pos2> = part.iter().filter_map(|q| proj.to_screen(*q)).collect();
for w in pts.windows(2) { for w in pts.windows(2) {
let dd = seg_dist(pos, w[0], w[1]) - bonus; let dd = seg_dist(pos, w[0], w[1]) - bonus;
if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) { 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 w = |q: solvecraft_engine::geom::Vec2| (plane.to_world(q) + lift).to_f32();
let col = c4(fill); let col = c4(fill);
// In the translucent pass (after the grid and edges) so it covers them. // 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(a), [0.0; 3], col);
sc.trans_tri(w(b), [0.0; 3], col); sc.trans_tri(w(b), [0.0; 3], col);
sc.trans_tri(w(c), [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) { for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) {
let poly = lp.polyline(0.02); for part in crate::sketch_display::boundary_parts(lp, &plane, &proj, app.viewport.pixels_per_point()) {
let n = poly.len(); for pair in part.windows(2) {
for i in 0..n { sc.line((pair[0] + lift).to_f32(), (pair[1] + lift).to_f32(), c4(edge), 2.0, false);
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);
} }
} }
} }
@@ -1140,6 +1182,7 @@ fn highlight_key(app: &SolveApp) -> u64 {
app.session.visibility.hidden_occurrences.hash(&mut h); app.session.visibility.hidden_occurrences.hash(&mut h);
app.cam.half_height().to_bits().hash(&mut h); app.cam.half_height().to_bits().hash(&mut h);
app.cam.back().x.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() 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)) if let Some(ss) = app.session.active_sketch.and_then(|sid| st.sketch(sid))
&& let Some(ci) = ss.sketch.curve_index(id) && let Some(ci) = ss.sketch.curve_index(id)
{ {
for seg in ss.sketch.segs(ci) { for pts in &crate::sketch_display::curve_sample(app, ss.feature, ci, &ss.plane, &display_proj(app)).parts {
let pts = seg.polyline(0.02);
for w in pts.windows(2) { 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() { if app.viewport.orbit_snap.is_some() {
ui.ctx().request_repaint(); ui.ctx().request_repaint();
} }
app.viewport.physical_scale = ui.ctx().pixels_per_point();
let proj = projection(app, rect); 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 // Suppress hover for the whole navigation gesture, including frames between pointer
// events. Testing delta made the highlight reappear whenever a held drag paused. // 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. // 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.key = key;
app.viewport.build_ms = crate::now_ms() - t0; 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); let hk = highlight_key(app);
if hk != app.viewport.hl_key { if hk != app.viewport.hl_key {
let hl = build_highlight(app); let hl = build_highlight(app);
@@ -1591,6 +1645,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let cb = ViewportCallback { let cb = ViewportCallback {
key: app.viewport.key, key: app.viewport.key,
slot: app.viewport.slot.clone(), 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_key: app.viewport.hl_key,
hl_slot: app.viewport.hl_slot.clone(), hl_slot: app.viewport.hl_slot.clone(),
pv_key: app.preview.key, 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 { if c.link.is_some() && ss.sketch.view.hide_projected {
continue; continue;
} }
let pts: Vec<Vec3> = ss.sketch.segs(i).iter().flat_map(|sg| sg.polyline(0.05)).map(|q| ss.plane.to_world(q)).collect(); let sampled = if bx.crossing {
if bx.takes(&to2(&pts)) { 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<Vec<Pos2>> = 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() }); 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) { for sid in visible_sketches(app) {
let Some(ss) = st.sketch(sid) else { continue }; let Some(ss) = st.sketch(sid) else { continue };
for (pi, p) in ss.profiles.iter().enumerate() { for (pi, p) in ss.profiles.iter().enumerate() {
let pts: Vec<Vec3> = p.region.outer.polyline(0.05).into_iter().map(|q| ss.plane.to_world(q)).collect(); let parts = crate::sketch_display::boundary_parts(&p.region.outer, &ss.plane, proj, app.viewport.pixels_per_point());
if bx.takes(&to2(&pts)) { 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 }); 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) if let Some(ss) = st.sketch(*sketch)
&& let Some(ci) = ss.sketch.curve_index(id) && let Some(ci) = ss.sketch.curve_index(id)
{ {
for seg in ss.sketch.segs(ci) { for part in &crate::sketch_display::curve_sample(app, *sketch, ci, &ss.plane, proj).parts {
let pts: Vec<Pos2> = seg.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect(); let pts: Vec<Pos2> = part.iter().filter_map(|q| proj.to_screen(*q)).collect();
painter.add(Shape::line(pts, Stroke::new(3.0, col))); 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 { if i == 0 {
painter.circle(sp, 3.5, Color32::from_rgb(240, 200, 60), Stroke::new(1.0, t.body_edge)); 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. // 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); painter.rect_stroke(Rect::from_center_size(sp, vec2(6.0, 6.0)), 0.0, Stroke::new(1.2, c), egui::StrokeKind::Middle);
} else { } else {
@@ -2157,14 +2233,18 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter,
return; return;
} }
let mut h = std::collections::hash_map::DefaultHasher::new(); 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] { 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); v.to_bits().hash(&mut h);
} }
(rect.width() as u32, rect.height() as u32).hash(&mut h); (rect.width() as u32, rect.height() as u32).hash(&mut h);
let key = h.finish(); let key = h.finish();
if app.viewport.cpu.as_ref().is_none_or(|(k, _)| *k != key) { 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 w = (rect.width() as usize).clamp(16, 4096);
let hh = (rect.height() 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); 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 { mod tests {
use super::*; use super::*;
#[test]
fn cpu_viewport_uses_section_preview_and_sketch_slice() {
fn pixels(h: &mut crate::scenario::Harness, cam: Camera) -> Vec<Color32> {
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] #[test]
fn timeline_modifiers_highlight_on_hover_and_click() { fn timeline_modifiers_highlight_on_hover_and_click() {
for command in ["solid.fillet", "solid.chamfer"] { for command in ["solid.fillet", "solid.chamfer"] {
+13 -1
View File
@@ -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 edited feature before its downstream history. Cylindrical Emboss profile guides follow the
sketch's chosen occurrence frame. 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 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 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 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. 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.
+11 -3
View File
@@ -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_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. 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, Extrude Intersect on a straight base or an unfolded sheet retains the material inside both
including interior holes and detached islands. A completely empty intersection removes the the current tool footprint and its depth interval, including interior holes, detached islands
sheet and its metadata together. Pocket profiles can move with planar panels when forming, 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, 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 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. the design; they cannot silently restore removed material during a later sheet rebuild.
+22
View File
@@ -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** **Create Sketch** (`sketch.create`) and click a plane, or pick a face first. **Finish Sketch**
returns to the model. 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 ## Geometry
Lines and polylines (L), rectangles (two-point R, three-point, centre), circles (centre C, 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 original line or circular support and extends only beyond the selected endpoint; conflicting
constraints or an ambiguous overlap leave the sketch unchanged. 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, 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 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 error bound of 0.002 mm. Singular tangents, curvature cusps, possible self-intersections and
+3 -1
View File
@@ -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 decoded. Driven appearance/material rules and repeated fixed-point evaluation are outside
the current rule scope. 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 changing their existing configuration values. Older SolveCraft versions must be updated to
open newly saved designs. open newly saved designs.
+1 -1
View File
@@ -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 pending positions only on the involved source and author paths. Failed commands restore
those pending positions; undo restores the previous attachment and placement state. 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 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 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 the stronger attachment. Unsupported legacy geometry can instead be retained on an explicit