feat: preserve adaptive sketch profiles and curved modeling workflows
This commit is contained in:
1 parent
4c4a0449a4
commit
4e595deff2
62 files changed
+7114
-362
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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, §ion.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
@@ -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 ®ions {
|
for r in ®ions {
|
||||||
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
@@ -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(¤t) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Bound floating point projection separately from model geometry tolerance.
|
||||||
|
let roundoff = 128.0 * f64::EPSILON * (plane.origin.len() + current.origin.len() + source.origin.len()).max(1.0);
|
||||||
|
if roundoff > 1e-7 || (current.origin - plane.origin).dot(n).abs() > roundoff {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
projection_roundoff = projection_roundoff.max(roundoff);
|
||||||
|
let distance = (plane.origin - source.origin).dot(n);
|
||||||
|
if !distance.is_finite() || caps[usize::from(high)].is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
caps[usize::from(high)] = Some((distance, signature));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let [Some((low, low_signature)), Some((high, high_signature))] = caps else { return None };
|
||||||
|
(low_signature == high_signature && low != high).then_some((low.min(high), low.max(high), projection_roundoff, footprint))
|
||||||
|
}
|
||||||
|
|
||||||
/// Exact prismatic machining, retained in sheet material/depth metadata.
|
/// 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 ®ions {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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, ®ions, 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, ®ions, 0.0, self.t)?;
|
||||||
|
let body = local.machine_pockets(body, &mut FlatWork::new())?;
|
||||||
|
self.place_local_solid(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn place_local_solid(&self, body: Body) -> Result<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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}"))
|
||||||
|
}
|
||||||
|
fn rounded() -> (Session, u64, String) {
|
||||||
|
let mut s = Session::default();
|
||||||
|
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Rounded"}));
|
||||||
|
let sketch = s.active_sketch.unwrap();
|
||||||
|
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-38,-33],"p1":[38,33]}));
|
||||||
|
let fillet = run(&mut s, "sketch.fillet", json!({"points":["l1.start","l1.end","l2.end","l3.end"],"radius":"1 mm"}));
|
||||||
|
(s, sketch, fillet["param"].as_str().unwrap().into())
|
||||||
|
}
|
||||||
|
fn offset(s: &mut Session, curve: &str, distance: f64) -> (String, Vec<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, ¶ms).unwrap_err().to_string();
|
||||||
|
assert_eq!(s.doc.to_json(), document);
|
||||||
|
assert_eq!(history(&s.undo), undo);
|
||||||
|
assert_eq!(history(&s.redo), redo);
|
||||||
|
assert_eq!(s.active_sketch, active);
|
||||||
|
error
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn offset_guard_retired_parent_refuses_new_child_without_destroying_redo() {
|
||||||
|
let (mut s, sketch, _) = rounded();
|
||||||
|
let (_, outputs) = offset(&mut s, "l1", 1.4);
|
||||||
|
assert_eq!(outputs.len(), 4);
|
||||||
|
assert_eq!(solved(&s, sketch).offsets[0].topology.as_ref().unwrap().roles.iter().filter(|r| r.retired).count(), 4);
|
||||||
|
run(&mut s, "sketch.point", json!({"point":[100,100]}));
|
||||||
|
run(&mut s, "edit.undo", json!({}));
|
||||||
|
assert!(!s.redo.is_empty());
|
||||||
|
let error = refuses_atomically(&mut s, "sketch.offset", json!({"curves":[outputs[0]],"distance":0.2,"side":[0,0]}));
|
||||||
|
assert!(error.contains("retired corner") && error.contains("break its offset association"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn offset_guard_live_child_follows_radius_then_retirement_is_honest() {
|
||||||
|
let (mut s, sketch, radius) = rounded();
|
||||||
|
let (_, parent) = offset(&mut s, "l1", 0.2);
|
||||||
|
let (child_association, child) = offset(&mut s, &parent[0], 0.2);
|
||||||
|
let arc_radius = |s: &Session| {
|
||||||
|
let sk = solved(s, sketch);
|
||||||
|
child
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| {
|
||||||
|
let CurveKind::Arc { c, a, .. } = sk.curves.get(sk.curve_index(id)?)?.kind else { return None };
|
||||||
|
Some(sk.point(c)?.dist(sk.point(a)?))
|
||||||
|
})
|
||||||
|
.collect::<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, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rounded_offset(radius: f64) -> (Session, u64, String, String) {
|
||||||
|
let mut session = Session::default();
|
||||||
|
run(&mut session, "sketch.create", json!({"plane":"XY","name":"Rounded"}));
|
||||||
|
let sketch = session.active_sketch.unwrap();
|
||||||
|
run(&mut session, "sketch.rectangle.two_point", json!({"p0":[-37.-radius,-32.-radius],"p1":[37.+radius,32.+radius]}));
|
||||||
|
let fillet = run(&mut session, "sketch.fillet", json!({"points":["l1.start","l1.end","l2.end","l3.end"],"radius":radius}));
|
||||||
|
// The radius is the only intended source freedom: keep the four exact arc
|
||||||
|
// centres at (+/-37,+/-32), independently of the corner radius.
|
||||||
|
let centers = fillet["curves"].as_array().unwrap().iter().map(|id| format!("{}.center", id.as_str().unwrap())).collect::<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, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn breaking_offset_preserves_downstream_material_history_and_source_independence() {
|
||||||
|
for distance in [0.2, 1.4] {
|
||||||
|
let (mut session, sketch, radius) = rounded_source();
|
||||||
|
let offset = run(&mut session, "sketch.offset", json!({"curves":["l1"],"distance":distance,"side":[0,0]}));
|
||||||
|
let association = offset["association"].as_str().unwrap().to_string();
|
||||||
|
let curves = session.model.state().sketch(sketch).unwrap().sketch.offset_boundary(&association).unwrap();
|
||||||
|
run(&mut session, "sketch.finish", json!({}));
|
||||||
|
run(&mut session, "solid.extrude", json!({"sketch":sketch,"profiles":{"offset_boundaries":[association]},"distance":2,"operation":"new"}));
|
||||||
|
let before_volume = session.world_state().bodies.last().unwrap().mesh().measure().volume.abs();
|
||||||
|
run(&mut session, "sketch.edit", json!({"sketch":sketch}));
|
||||||
|
let before = session.doc.to_json();
|
||||||
|
let history = session.undo.len();
|
||||||
|
run(&mut session, "sketch.offset.break", json!({"curve":association}));
|
||||||
|
assert_eq!(session.undo.len(), history + 1);
|
||||||
|
assert!(session.model.results.iter().all(|result| result.error.is_none()));
|
||||||
|
assert!(
|
||||||
|
matches!(&session.doc.features.last().unwrap().kind, FeatureKind::Extrude { profiles: ProfileSel::Curves { loops }, .. } if loops == &vec![curves.clone()])
|
||||||
|
);
|
||||||
|
let frozen = session.doc.to_json();
|
||||||
|
assert!((session.world_state().bodies.last().unwrap().mesh().measure().volume.abs() - before_volume).abs() < 1e-6);
|
||||||
|
run(&mut session, "edit.undo", json!({}));
|
||||||
|
assert_eq!(session.doc.to_json(), before);
|
||||||
|
run(&mut session, "edit.redo", json!({}));
|
||||||
|
assert_eq!(session.doc.to_json(), frozen);
|
||||||
|
run(&mut session, "parameters.change", json!({"name":radius,"expression":"2 mm"}));
|
||||||
|
assert!(session.model.results.iter().all(|result| result.error.is_none()));
|
||||||
|
assert!((session.world_state().bodies.last().unwrap().mesh().measure().volume.abs() - before_volume).abs() < 1e-6);
|
||||||
|
let reopened = Session::new(solvecraft_doc::Document::from_json(&session.doc.to_json()).unwrap());
|
||||||
|
assert!(reopened.model.results.iter().all(|result| result.error.is_none()));
|
||||||
|
assert!((reopened.world_state().bodies.last().unwrap().mesh().measure().volume.abs() - before_volume).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rounded_source() -> (Session, u64, String) {
|
||||||
|
let mut session = Session::default();
|
||||||
|
run(&mut session, "sketch.create", json!({"plane":"XY","name":"Rounded"}));
|
||||||
|
let sketch = session.active_sketch.unwrap();
|
||||||
|
run(&mut session, "sketch.rectangle.two_point", json!({"p0":[-38,-33],"p1":[38,33]}));
|
||||||
|
let fillet = run(&mut session, "sketch.fillet", json!({"points":["l1.start","l1.end","l2.end","l3.end"],"radius":"1 mm"}));
|
||||||
|
(session, sketch, fillet["param"].as_str().unwrap().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inset_past_corner_radius_creates_a_real_boundary_and_saved_profile_identity() {
|
||||||
|
let (mut session, sketch, _) = rounded_source();
|
||||||
|
let offset = run(&mut session, "sketch.offset", json!({"curves":["l1"],"distance":1.4,"side":[0,0]}));
|
||||||
|
assert_eq!(offset["curves"].as_array().unwrap().len(), 4);
|
||||||
|
let state = session.model.state();
|
||||||
|
let solved = state.sketch(sketch).unwrap();
|
||||||
|
let record = solved.sketch.offsets.last().unwrap();
|
||||||
|
let topology = record.topology.as_ref().unwrap();
|
||||||
|
let association = topology.id.clone();
|
||||||
|
assert_eq!(topology.roles.len(), 8);
|
||||||
|
assert_eq!(topology.roles.iter().filter(|role| role.retired).count(), 4);
|
||||||
|
assert_eq!(solved.sketch.offset_boundary(&association).unwrap(), record.outputs);
|
||||||
|
assert!(topology.roles.iter().filter(|role| role.retired).all(|role| solved.sketch.curve_index(&role.output).is_none()));
|
||||||
|
run(&mut session, "sketch.finish", json!({}));
|
||||||
|
run(
|
||||||
|
&mut session,
|
||||||
|
"solid.extrude",
|
||||||
|
json!({"sketch":sketch,"profiles":{"offset_boundaries":[association]},"distance":2,"operation":"new","name":"Inner"}),
|
||||||
|
);
|
||||||
|
let feature = session.doc.features.last().unwrap();
|
||||||
|
assert!(matches!(&feature.kind, FeatureKind::Extrude { profiles: ProfileSel::OffsetBoundaries { associations }, .. } if associations.len() == 1));
|
||||||
|
let check = |session: &Session| {
|
||||||
|
assert!(session.model.results.iter().all(|result| result.error.is_none()));
|
||||||
|
let state = session.world_state();
|
||||||
|
let body = state.bodies.last().unwrap();
|
||||||
|
// The exact inward parallel set is a sharp 73.2 by 63.2 rectangle.
|
||||||
|
let mesh = body.mesh();
|
||||||
|
assert!((mesh.measure().volume.abs() - 73.2 * 63.2 * 2.0).abs() < 1e-6);
|
||||||
|
assert!(mesh.contains(Vec3::new(36.5, 31.5, 1.0)));
|
||||||
|
assert!(!mesh.contains(Vec3::new(36.7, 31.5, 1.0)));
|
||||||
|
assert!(!mesh.contains(Vec3::new(36.5, 31.7, 1.0)));
|
||||||
|
};
|
||||||
|
check(&session);
|
||||||
|
let saved = session.doc.to_json();
|
||||||
|
let reopened = Session::new(solvecraft_doc::Document::from_json(&saved).unwrap());
|
||||||
|
check(&reopened);
|
||||||
|
assert_eq!(reopened.doc.to_json(), saved);
|
||||||
|
let undo = session.undo.len();
|
||||||
|
let redo = session.redo.len();
|
||||||
|
for params in [
|
||||||
|
json!({"profiles":{"offset_boundaries":[]}}),
|
||||||
|
json!({"profiles":{"offset_boundaries":["missing"]}}),
|
||||||
|
json!({"profiles":{"offset_boundaries":[association,association]}}),
|
||||||
|
json!({"profiles":[[record.outputs[0],record.outputs[1],record.outputs[2]]]}),
|
||||||
|
] {
|
||||||
|
let mut params = params;
|
||||||
|
params["sketch"] = json!(sketch);
|
||||||
|
params["distance"] = json!(1);
|
||||||
|
assert!(session.execute("solid.extrude", ¶ms).is_err());
|
||||||
|
assert_eq!(session.doc.to_json(), saved);
|
||||||
|
assert_eq!(session.undo.len(), undo);
|
||||||
|
assert_eq!(session.redo.len(), redo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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!({}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ®ions, 0., 2.).unwrap();
|
||||||
|
assert_eq!(ring.len(), 1);
|
||||||
|
let precise_ring = ring[0].tessellate(1e-6).unwrap();
|
||||||
|
let bounds = precise_ring.bounds();
|
||||||
|
for (actual, expected) in
|
||||||
|
[(bounds.min.x, -37.8), (bounds.max.x, 37.8), (bounds.min.y, -32.8), (bounds.max.y, 32.8), (bounds.min.z, 28.), (bounds.max.z, 30.)]
|
||||||
|
{
|
||||||
|
assert!((actual - expected).abs() < 1e-8, "ring bounds {bounds:?}");
|
||||||
|
}
|
||||||
|
let height = bounds.max.z - bounds.min.z;
|
||||||
|
assert!((height - 2.).abs() < 1e-8);
|
||||||
|
let analytic_volume = regions[0].area() * height;
|
||||||
|
assert!((analytic_volume - 665.141238596591).abs() < 2e-7, "analytic ring volume {analytic_volume}");
|
||||||
|
let arc_perimeter = regions[0]
|
||||||
|
.outer
|
||||||
|
.segs
|
||||||
|
.iter()
|
||||||
|
.map(|segment| match segment {
|
||||||
|
solvecraft_geom::Seg2::Arc { radius, sweep, .. } => radius.abs() * sweep.abs(),
|
||||||
|
solvecraft_geom::Seg2::Line { .. } => 0.,
|
||||||
|
other => panic!("unexpected ring boundary {other:?}"),
|
||||||
|
})
|
||||||
|
.sum::<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);
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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(®ion), 0.0, 20.0).unwrap().pop().unwrap();
|
||||||
|
for (lo, hi) in [(20.0, 30.0), (30.0, 35.0)] {
|
||||||
|
let positive = crate::extrude(&plane, std::slice::from_ref(®ion), lo, hi).unwrap().pop().unwrap();
|
||||||
|
stock = crate::boolean(&stock, &positive, BoolOp::Union).unwrap().unwrap();
|
||||||
|
}
|
||||||
|
let mut grown = rectangle(Vec2::new(-0.3, -0.3), Vec2::new(40.3, 30.3));
|
||||||
|
grown.holes = region.holes;
|
||||||
|
let tool = crate::extrude(&plane, &[grown], 30.0, 35.6).unwrap().pop().unwrap();
|
||||||
|
let tol = stock.size().max(tool.size()) * 1e-6;
|
||||||
|
let a = stack_of(&stock, Vec3::Z, &plane, tol).expect("annular stock qualifies");
|
||||||
|
let b = stack_of(&tool, Vec3::Z, &plane, tol).expect("overrun tool qualifies");
|
||||||
|
assert_eq!(a.len(), 1);
|
||||||
|
assert_eq!(b.len(), 1);
|
||||||
|
assert!((a[0].lo - 0.0).abs() < tol && (a[0].hi - 35.0).abs() < tol);
|
||||||
|
assert!((b[0].lo - 30.0).abs() < tol && (b[0].hi - 35.6).abs() < tol);
|
||||||
|
assert_eq!(a[0].regions[0].holes.len(), 1);
|
||||||
|
assert_eq!(b[0].regions[0].holes.len(), 1);
|
||||||
|
assert!((a[0].regions[0].area() - (1200.0 - 25.0 * std::f64::consts::PI)).abs() < 1e-8);
|
||||||
|
assert!(simple_prism_boolean(&stock, &tool, BoolOp::Cut).is_none());
|
||||||
|
let result = stack_boolean(&stock, &tool, BoolOp::Cut).expect("qualified overlapping stacks").unwrap().unwrap();
|
||||||
|
assert_eq!(result.shell_count(), 1);
|
||||||
|
assert!(result.validity().is_empty());
|
||||||
|
let mesh = result.tessellate(1e-5).unwrap();
|
||||||
|
let expected = (1200.0 - 25.0 * std::f64::consts::PI) * 30.0;
|
||||||
|
// An inscribed circular mesh at radial chord error 1e-5 has volume
|
||||||
|
// deficit below 2*pi*R*height*error, conservatively 0.01 mm^3 here.
|
||||||
|
assert!((mesh.measure().volume - expected).abs() < 0.02);
|
||||||
|
for (point, present) in [
|
||||||
|
(Vec3::new(5.0, 5.0, 29.0), true),
|
||||||
|
(Vec3::new(5.0, 5.0, 31.0), false),
|
||||||
|
(Vec3::new(20.0, 15.0, 29.0), false),
|
||||||
|
(Vec3::new(24.8, 15.0, 29.0), false),
|
||||||
|
(Vec3::new(25.2, 15.0, 29.0), true),
|
||||||
|
] {
|
||||||
|
assert_eq!(mesh.contains(point), present, "{point:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn annular_partial_cut_overruns_and_interior_lumps_preserve_material() {
|
||||||
|
let area = 1200.0 - 25.0 * std::f64::consts::PI;
|
||||||
|
for seam in [0.0, 0.37] {
|
||||||
|
for plane in [Plane::XY, Plane::new(Vec3::new(30.0, 20.0, 10.0), Vec3::X, Vec3::Z).unwrap()] {
|
||||||
|
let mut region = rectangle(Vec2::ZERO, Vec2::new(40.0, 30.0));
|
||||||
|
region.holes.push(Loop2::circle_from(Vec2::new(20.0, 15.0), 5.0, seam));
|
||||||
|
let stock = crate::extrude(&plane, std::slice::from_ref(®ion), 0.0, 35.0).unwrap().pop().unwrap();
|
||||||
|
let before = crate::step_export(&[&stock], "source").unwrap();
|
||||||
|
for (lo, hi, retained, shells) in [(30.0, 35.6, 30.0, 1), (-0.6, 5.0, 30.0, 1), (10.0, 15.0, 30.0, 2), (30.0, 35.0, 30.0, 1)] {
|
||||||
|
let tool = crate::extrude(&plane, std::slice::from_ref(®ion), lo, hi).unwrap().pop().unwrap();
|
||||||
|
let result = stack_boolean(&stock, &tool, BoolOp::Cut).expect("exact aligned cut path").unwrap().unwrap();
|
||||||
|
assert_eq!(result.shell_count(), shells);
|
||||||
|
assert!(result.validity().is_empty());
|
||||||
|
let mesh = result.tessellate(1e-5).unwrap();
|
||||||
|
assert!((mesh.measure().volume - retained * area).abs() < 0.02);
|
||||||
|
let at = |x: f64, y: f64, z: f64| plane.to_world(Vec2::new(x, y)) + plane.normal() * z;
|
||||||
|
for z in [2.5, 12.5, 32.5] {
|
||||||
|
let material = !(lo < z && z < hi);
|
||||||
|
assert_eq!(mesh.contains(at(5.0, 5.0, z)), material);
|
||||||
|
assert!(!mesh.contains(at(20.0, 15.0, z)));
|
||||||
|
assert_eq!(mesh.contains(at(25.2, 15.0, z)), material);
|
||||||
|
}
|
||||||
|
let public = crate::boolean(&stock, &tool, BoolOp::Cut).unwrap().unwrap();
|
||||||
|
assert!((public.tessellate(1e-5).unwrap().measure().volume - retained * area).abs() < 0.02);
|
||||||
|
if lo == 30.0 && hi > 35.0 {
|
||||||
|
let imported = crate::step_import(&crate::step_export(&[&result], "inward annular cut").unwrap()).unwrap();
|
||||||
|
assert!(imported.warnings.is_empty());
|
||||||
|
let saved = &imported.bodies.first().unwrap().body;
|
||||||
|
assert!((saved.tessellate(1e-5).unwrap().measure().volume - 30.0 * area).abs() < 0.02);
|
||||||
|
let next = crate::extrude(&plane, std::slice::from_ref(®ion), 25.0, 31.0).unwrap().pop().unwrap();
|
||||||
|
let shortened = crate::boolean(saved, &next, BoolOp::Cut).unwrap().unwrap();
|
||||||
|
let next_volume = shortened.tessellate(1e-5).unwrap().measure().volume;
|
||||||
|
assert!(
|
||||||
|
(next_volume - 25.0 * area).abs() < 0.02,
|
||||||
|
"seam {seam}, plane {plane:?}, next cut volume {next_volume}, expected {}",
|
||||||
|
25.0 * area
|
||||||
|
);
|
||||||
|
assert!(shortened.contains(at(5.0, 5.0, 24.0)));
|
||||||
|
assert!(!shortened.contains(at(5.0, 5.0, 26.0)));
|
||||||
|
assert!(!shortened.contains(at(20.0, 15.0, 24.0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let clear = crate::extrude(&plane, std::slice::from_ref(®ion), 36.0, 40.0).unwrap().pop().unwrap();
|
||||||
|
assert!(stack_boolean(&stock, &clear, BoolOp::Cut).is_none());
|
||||||
|
let unchanged = crate::boolean(&stock, &clear, BoolOp::Cut).unwrap().unwrap();
|
||||||
|
assert!(std::sync::Arc::ptr_eq(&stock.solid, &unchanged.solid), "disjoint Cut must retain exact source topology");
|
||||||
|
let unchanged_mesh = unchanged.tessellate(1e-5).unwrap();
|
||||||
|
let source_mesh = stock.tessellate(1e-5).unwrap();
|
||||||
|
assert_eq!(unchanged_mesh.measure().volume, source_mesh.measure().volume);
|
||||||
|
// tessellate() floors tolerance at body.size()*1e-6. The arc
|
||||||
|
// polygon's area error is at most perimeter*radial chord error.
|
||||||
|
let bound = std::f64::consts::TAU * 5.0 * 35.0 * (stock.size() * 1e-6).max(1e-5) + 1e-5;
|
||||||
|
assert!((unchanged_mesh.measure().volume - 35.0 * area).abs() < bound);
|
||||||
|
assert!(!unchanged_mesh.contains(plane.to_world(Vec2::new(20.0, 15.0)) + plane.normal() * 12.5));
|
||||||
|
let all = crate::extrude(&plane, std::slice::from_ref(®ion), -1.0, 36.0).unwrap().pop().unwrap();
|
||||||
|
assert!(stack_boolean(&stock, &all, BoolOp::Cut).unwrap().unwrap().is_none());
|
||||||
|
assert_eq!(crate::step_export(&[&stock], "source").unwrap(), before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn annular_partial_cut_preserves_unequal_radii_and_conservative_refusals() {
|
||||||
|
let mut region = rectangle(Vec2::ZERO, Vec2::new(40.0, 30.0));
|
||||||
|
region.holes.push(Loop2::circle(Vec2::new(20.0, 15.0), 5.0));
|
||||||
|
let stock = crate::extrude(&Plane::XY, std::slice::from_ref(®ion), 0.0, 35.0).unwrap().pop().unwrap();
|
||||||
|
let before = crate::step_export(&[&stock], "unchanged source").unwrap();
|
||||||
|
let mut larger_hole = region.clone();
|
||||||
|
larger_hole.holes = vec![Loop2::circle(Vec2::new(20.0, 15.0), 5.1)];
|
||||||
|
let tool = crate::extrude(&Plane::XY, &[larger_hole], 30.0, 36.0).unwrap().pop().unwrap();
|
||||||
|
let result = stack_boolean(&stock, &tool, BoolOp::Cut).unwrap().unwrap().unwrap();
|
||||||
|
let expected = (1200.0 - 25.0 * std::f64::consts::PI) * 30.0 + std::f64::consts::PI * (5.1_f64.powi(2) - 25.0) * 5.0;
|
||||||
|
let mesh = result.tessellate(1e-5).unwrap();
|
||||||
|
assert!((mesh.measure().volume - expected).abs() < 0.02);
|
||||||
|
assert!(mesh.contains(Vec3::new(25.05, 15.0, 32.0)));
|
||||||
|
assert!(!mesh.contains(Vec3::new(25.2, 15.0, 32.0)));
|
||||||
|
assert!(!mesh.contains(Vec3::new(20.0, 15.0, 32.0)));
|
||||||
|
let interior = crate::extrude(&Plane::XY, std::slice::from_ref(®ion), 10.0, 15.0).unwrap().pop().unwrap();
|
||||||
|
for d in [Vec3::X, Vec3::Y] {
|
||||||
|
let plane = Plane::from_normal(Vec3::ZERO, d).unwrap();
|
||||||
|
let tol = stock.size() * 1e-6;
|
||||||
|
assert!(stack_of(&stock, d, &plane, tol).is_none(), "transverse cylinder axis must not fill the hole");
|
||||||
|
assert!(stack_of(&interior, d, &plane, tol).is_none());
|
||||||
|
}
|
||||||
|
assert!(stack_boolean(&stock, &interior, BoolOp::Union).is_none(), "Union retains its common-cap policy");
|
||||||
|
let tapered = crate::extrude_tapered(&Plane::XY.offset(30.0), &rectangle(Vec2::ZERO, Vec2::new(40.0, 30.0)), 6.0, 1.0, 0.05).unwrap();
|
||||||
|
assert!(stack_boolean(&stock, &tapered, BoolOp::Cut).is_none(), "slanted walls are not aligned prisms");
|
||||||
|
let curved_cap = crate::sphere(Vec3::new(20.0, 15.0, 35.0), 5.0).unwrap();
|
||||||
|
assert!(stack_boolean(&stock, &curved_cap, BoolOp::Cut).is_none());
|
||||||
|
let pieces =
|
||||||
|
(0..350).map(|i| crate::box_solid(Vec3::new(i as f64 * 3.0, 0.0, 0.0), Vec3::new(i as f64 * 3.0 + 1.0, 1.0, 1.0)).unwrap()).collect();
|
||||||
|
let over_budget = crate::ops::join_pieces(pieces).unwrap().unwrap();
|
||||||
|
assert!(over_budget.face_count() > 2048);
|
||||||
|
assert!(stack_boolean(&over_budget, &interior, BoolOp::Cut).is_none());
|
||||||
|
assert_eq!(crate::step_export(&[&stock], "unchanged source").unwrap(), before);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn curved_prism_wall_requires_positive_separable_rational_weights() {
|
||||||
|
let surface = |mixed: bool| {
|
||||||
|
let weight = if mixed { 2.0 } else { 1.0 };
|
||||||
|
let points = vec![
|
||||||
|
vec![mt::Vector4::new(0.0, 0.0, 0.0, 1.0), mt::Vector4::new(0.0, 0.0, 10.0, 1.0)],
|
||||||
|
vec![mt::Vector4::new(5.0, 0.0, 0.0, 1.0), mt::Vector4::new(5.0 * weight, 0.0, 10.0 * weight, weight)],
|
||||||
|
];
|
||||||
|
mt::Surface::NurbsSurface(mt::NurbsSurface::new(mt::BSplineSurface::new(
|
||||||
|
(mt::KnotVec::bezier_knot(1), mt::KnotVec::bezier_knot(1)),
|
||||||
|
points,
|
||||||
|
)))
|
||||||
|
};
|
||||||
|
assert!(wall_along(&surface(false), Vec3::Z, 1e-5, &mut 1000));
|
||||||
|
assert!(!wall_along(&surface(true), Vec3::Z, 1e-5, &mut 1000), "parallel Euclidean controls alone do not certify a rational sweep");
|
||||||
|
assert!(!wall_along(&surface(false), Vec3::Y, 1e-5, &mut 1000));
|
||||||
|
assert!(!wall_along(&surface(false), Vec3::Z, 1e-5, &mut 0));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
fn partial_depth_cuts_touching_a_through_hole_use_exact_stacked_sections() {
|
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.));
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
//! Whole-span certificates for analytic planar prism boundaries.
|
||||||
|
//! Samples propose a circle; only positive rational Bernstein bounds accept it.
|
||||||
|
use crate::body::from_p3;
|
||||||
|
use solvecraft_geom::{Plane, Seg2, Vec2};
|
||||||
|
use truck_modeling::{self as mt, BoundedCurve, Invertible, ParametricCurve};
|
||||||
|
|
||||||
|
const MAX_DEGREE: usize = 32;
|
||||||
|
const MAX_CONTROLS: usize = 4096;
|
||||||
|
type H = [f64; 3];
|
||||||
|
|
||||||
|
fn charge(work: &mut usize, n: usize) -> Option<()> {
|
||||||
|
*work = work.checked_sub(n)?;
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preflight(c: &mt::Curve, work: &mut usize) -> Option<()> {
|
||||||
|
charge(work, 1)?;
|
||||||
|
let (knots, controls) = match c {
|
||||||
|
mt::Curve::Line(_) => return Some(()),
|
||||||
|
mt::Curve::BSplineCurve(b) => (b.knot_vec(), b.control_points().len()),
|
||||||
|
mt::Curve::NurbsCurve(b) => (b.knot_vec(), b.control_points().len()),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let degree = knots.len().checked_sub(controls + 1)?;
|
||||||
|
if !(1..=MAX_DEGREE).contains(°ree) || controls > MAX_CONTROLS || knots.len() > 8192 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if knots.iter().any(|t| !t.is_finite()) || knots.windows(2).any(|w| w[1] < w[0]) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Charge decomposition before its cloning / knot insertion allocations.
|
||||||
|
charge(work, controls.checked_mul(knots.len())?.checked_mul(degree + 1)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn controls(c: &mt::Curve, plane: &Plane, h: f64, tol: f64) -> Option<(Vec<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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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));
|
||||||
|
|||||||
@@ -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()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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(®ion.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);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"] {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user