feat: strengthen modeling, configuration, and assembly workflows

Preserve linked sketch identities through deterministic face projection and resizing. Fix origin-corner picking, persistent inline navigation, appearance editing, configuration styles, component properties, contact sweeps, sheet exports, analytic STEP offsets, and bounded rolling-ball fit accuracy.

Validated with full cargo xtask ci, native workflow screenshots, repeated enclosure replays, and analytic geometry regressions.
This commit is contained in:
bherbruck
2026-10-11 00:37:26 -04:00
parent 22672585c9
commit d71bdb78ec
54 changed files with 4179 additions and 316 deletions
+33 -10
View File
@@ -1,6 +1,6 @@
# SolveCraft roadmap
Status as of **2026-10-10**. SolveCraft targets Fusion-style parametric modelling (sketch →
Status as of **2026-10-11**. SolveCraft targets Fusion-style parametric modelling (sketch →
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
## At a glance
@@ -8,7 +8,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
| Question | Answer |
|---|---|
| Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
| Command parity (in scope) | **328 / 541 (61%)** in-scope commands; SOLID + SKETCH **185 / 291 (64%)** — [docs/parity.md](docs/parity.md) |
| Command parity (in scope) | **331 / 541 (61%)** in-scope commands; SOLID + SKETCH **186 / 291 (64%)** — [docs/parity.md](docs/parity.md); registry breadth does not establish workflow or geometric parity |
| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
@@ -48,18 +48,18 @@ headline counts in-scope tabs only and lists the deferred ones separately.
| M0 | Vertical slice | done | — |
| M1 | Sketch depth: done — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs | done | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace | deferred | — |
| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 |
| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: installers, signing, docs, performance | planned | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Left: configuration rules, per-row materials and appearances | 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/2"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Left: configuration rules and component/face style columns | in progress | 4 |
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<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/7"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
## Known limitations
@@ -89,7 +89,9 @@ headline counts in-scope tabs only and lists the deferred ones separately.
that cut through curved faces (trimmed faces after booleans) are split onto the surface first.
- STEP import (our own reader, crates/kernel/src/step_in; all 29 Fusion STEP files pass
`cargo xtask step-corpus`) reads solids (B-rep with analytic and B-spline geometry), units,
product names, colours and assemblies; offset surfaces and pcurve-only edges are not read yet,
product names, colours and assemblies; analytic offsets on planes, cylinders, cones, spheres
and regular tori are read exactly (signed/nested distances, units and face sense). Freeform
offset surfaces and pcurve-only edges are not read yet,
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
- Persistent naming (plan/adr/0002) names faces from their history and edges by their faces;
@@ -97,12 +99,33 @@ headline counts in-scope tabs only and lists the deferred ones separately.
face history (exact through coincident surfaces) is not wired in yet.
- Modelled threads are cut as a faceted body (curved faces within 1/200 of the pitch); Extrude
and Revolve To Object on a curved face cut the tool back to the target's surface.
- Free-form curves cut at a tangent touch (no clean crossing) enter that profile as polylines.
- Explicit sketch breaks preserve exact ellipses, fit/control splines (including high degrees)
and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent
touch (no clean crossing) enter that profile as polylines.
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
- Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not:
filleted boxes, bosses, domes, countersinks); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
sections ruled.
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
the origin; clicked UI regressions verify geometry, closed profiles and undo.
- Face projection: closed boundaries have a deterministic outer-first order. Refresh matches
existing geometry before assigning positions, retaining saved curve IDs and constraints
across enumeration changes and resize, including concentric circles. Ambiguous identities
retain the last geometry and report a lost reference; permutation matching is bounded.
- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes,
selecting visited values for replacement. Expression completion retains forward-Tab
priority; real keyboard regressions cover rectangles, lines and single-value circles.
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
check, not a new Fusion oracle result. Under-resolved fits return an error.
- Contact driving checks the trajectory between clear endpoints, including linked joints,
playback and sparse motion exports. Checks use bounded tessellation samples, not exact
continuous collision detection; local thin walls and small details remain limitations.
See [contact-set limits and recovery](docs/contact-sets.md).
## Kernel
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
+87 -4
View File
@@ -1,6 +1,6 @@
//! Configurations: a table whose rows are variants of the design. Each column is a parameter
//! (its expression per row) or a feature (suppressed or not per row); activating a row sets
//! those values in the design, which then rebuilds as usual.
//! expression, feature suppression, body material or body appearance. Activating a row sets
//! those values in the design atomically, which then rebuilds as usual.
use serde::{Deserialize, Serialize};
@@ -13,12 +13,16 @@ pub enum Column {
Param(String),
/// A feature's suppression (by feature id).
Suppress(u64),
/// A body's physical material (library name, or Default to clear).
Material(String),
/// A body's appearance (library name, serialized Look, or empty to clear).
Appearance(String),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ConfigRow {
pub name: String,
/// One cell per column: an expression, or "true"/"false" for suppression.
/// One cell per column: expression, suppression, material name or appearance look.
pub cells: Vec<String>,
}
@@ -39,28 +43,69 @@ impl ConfigTable {
}
}
/// Parse an appearance cell. Empty/Default follows the body's material; named library looks
/// and JSON Look objects support both convenient editing and lossless custom colours.
pub fn appearance_cell(value: &str) -> Result<Option<crate::appearance::Look>> {
let value = value.trim();
if value.is_empty() || value.eq_ignore_ascii_case("default") {
return Ok(None);
}
if value.len() > 4096 {
return Err(DocError::Invalid("appearance value too long".into()));
}
if let Some(look) = crate::appearance::library(value) {
return Ok(Some(look));
}
let look: crate::appearance::Look =
serde_json::from_str(value).map_err(|_| DocError::Invalid(format!("unknown configuration appearance `{value}`")))?;
if look.name.chars().count() > 128 || !look.opacity.is_finite() || !(0.0..=1.0).contains(&look.opacity) {
return Err(DocError::Invalid("appearance requires a name up to 128 characters and opacity in 0..1".into()));
}
Ok(Some(look))
}
/// Parse a physical material cell, returning None for the default material.
pub fn material_cell(value: &str) -> Result<Option<String>> {
let name = crate::MATERIALS
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(value.trim()))
.map(|(n, _)| *n)
.ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
Ok((name != "Default").then(|| name.to_string()))
}
impl Document {
/// A column's current value in the design.
pub fn config_current(&self, c: &Column) -> Option<String> {
match c {
Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, _)| e),
Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()),
Column::Material(body) => Some(self.materials.get(body).cloned().unwrap_or_else(|| "Default".into())),
Column::Appearance(body) => Some(self.appearances.bodies.get(body).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default()),
}
}
/// A column's display key: `param:<name>` or `suppress:<feature name>`.
/// A column's display key, prefixed by its kind to distinguish targets with equal names.
pub fn config_key(&self, c: &Column) -> String {
match c {
Column::Param(n) => format!("param:{n}"),
Column::Suppress(id) => format!("suppress:{}", self.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| id.to_string())),
Column::Material(body) => format!("material:{body}"),
Column::Appearance(body) => format!("appearance:{body}"),
}
}
/// Set the design to a row's values (all or nothing).
pub fn apply_configuration(&mut self, row: &str) -> Result<()> {
let r = self.configs.rows.iter().find(|r| r.name == row).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{row}`")))?;
if self.configs.columns.len() > MAX_COLUMNS || self.configs.rows.len() > MAX_ROWS || r.cells.len() != self.configs.columns.len() {
return Err(DocError::Invalid("configuration table exceeds limits or has incomplete rows".into()));
}
let mut next = self.clone();
for (c, v) in self.configs.columns.iter().zip(&r.cells) {
if v.len() > 4096 {
return Err(DocError::Invalid("configuration value too long".into()));
}
match c {
Column::Param(n) => {
if next.config_current(c).as_deref() != Some(v.as_str()) {
@@ -76,6 +121,22 @@ impl Document {
let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?;
f.suppressed = on;
}
Column::Material(body) => match material_cell(v)? {
Some(material) => {
next.materials.insert(body.clone(), material);
}
None => {
next.materials.remove(body);
}
},
Column::Appearance(body) => match appearance_cell(v)? {
Some(look) => {
next.appearances.bodies.insert(body.clone(), look);
}
None => {
next.appearances.bodies.remove(body);
}
},
}
}
next.configs.active = Some(row.to_string());
@@ -86,9 +147,14 @@ impl Document {
/// The active row's values all hold in the design.
pub fn configuration_matches(&self) -> bool {
let Some(r) = self.configs.active.as_ref().and_then(|a| self.configs.rows.iter().find(|r| r.name == *a)) else { return false };
if r.cells.len() != self.configs.columns.len() {
return false;
}
self.configs.columns.iter().zip(&r.cells).all(|(c, v)| match c {
Column::Suppress(_) => self.config_current(c).as_deref() == Some(v.trim()),
Column::Param(_) => self.config_current(c).is_some_and(|x| x.replace(' ', "") == v.replace(' ', "")),
Column::Material(body) => material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()),
Column::Appearance(body) => appearance_cell(v).is_ok_and(|look| self.appearances.bodies.get(body) == look.as_ref()),
})
}
}
@@ -114,4 +180,21 @@ mod tests {
assert!(d.apply_configuration("Bad").is_err());
assert_eq!(d, before, "all or nothing");
}
#[test]
fn invalid_saved_appearance_rolls_back_material_and_parameters() {
let mut d = Document::new("A");
d.set_param("width", "10 mm", None, None).unwrap();
d.configs.columns = vec![Column::Param("width".into()), Column::Material("Body".into()), Column::Appearance("Body".into())];
d.configs.rows.push(ConfigRow {
name: "Broken".into(),
cells: vec!["20 mm".into(), "Steel".into(), r#"{"name":"Invalid","color":[1,2,3],"opacity":2}"#.into()],
});
let before = d.clone();
assert!(d.apply_configuration("Broken").is_err());
assert_eq!(d, before);
assert!(appearance_cell(r#"{"name":"Invalid","color":[1,2,3],"opacity":1e999}"#).is_err());
assert!(appearance_cell(&"a".repeat(4097)).is_err());
assert!(appearance_cell("default").unwrap().is_none());
}
}
+64 -2
View File
@@ -20,13 +20,16 @@
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
//! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become
//! explicit planes retaining their previous axes, including offset-plane expressions.
//! - 7: configuration tables can include body material and appearance columns. Version 6
//! rows need no transformation; the version stamp lets older readers request an update
//! instead of encountering an unknown column variant.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 6;
pub const FORMAT: u32 = 7;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -39,7 +42,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 5] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6];
const JSON_STEPS: [JsonStep; 6] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(())];
/// Old XZ references retain their exact frame, including nested offset and angled planes.
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
@@ -210,6 +213,65 @@ fn normalise(d: &mut Document) -> Result<()> {
mod tests {
use super::*;
#[test]
fn version_six_configuration_rows_upgrade_without_changing_values_or_features() {
use crate::config::{Column, ConfigRow};
let mut d = Document::new("legacy configurations");
d.set_param("width", "10 mm", None, None).unwrap();
let feature = d
.add_feature(
crate::FeatureKind::Box {
corner: ["0".into(), "0".into(), "0".into()],
length: "width".into(),
width: "20".into(),
height: "5".into(),
operation: crate::Operation::NewBody,
},
None,
)
.unwrap();
d.configs.columns = vec![Column::Param("width".into()), Column::Suppress(feature)];
d.configs.rows = vec![
ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "false".into()] },
ConfigRow { name: "Alternate".into(), cells: vec!["width / 2 + 5 mm".into(), "yes".into()] },
];
d.configs.active = Some("Default".into());
let mut legacy: Value = serde_json::from_str(&d.to_json()).unwrap();
legacy["format"] = Value::from("solvecraft/6");
let original_table = legacy["configs"].clone();
let upgraded = read(&legacy.to_string()).unwrap();
assert_eq!(upgraded, d, "the identity migration preserves the complete design");
assert_eq!(serde_json::to_value(&upgraded.configs).unwrap(), original_table);
assert_eq!(upgraded.format, "solvecraft/7");
assert!(upgraded.configuration_matches());
legacy.as_object_mut().unwrap().remove("configs");
assert!(read(&legacy.to_string()).unwrap().configs.is_empty());
legacy["configs"] = serde_json::json!({"columns":[],"rows":[]});
assert!(read(&legacy.to_string()).unwrap().configs.is_empty());
}
#[test]
fn version_seven_styles_roundtrip_and_newer_versions_are_refused_before_columns() {
use crate::config::{Column, ConfigRow};
let mut d = Document::new("configured styles");
d.configs.columns = vec![Column::Material("Body".into()), Column::Appearance("Body".into())];
d.configs.rows = vec![ConfigRow {
name: "Blue steel".into(),
cells: vec!["Steel".into(), r#"{"name":"Custom blue","color":[40,90,186],"opacity":0.4}"#.into()],
}];
d.apply_configuration("Blue steel").unwrap();
let mut value: Value = serde_json::from_str(&d.to_json()).unwrap();
assert_eq!(value["format"], "solvecraft/7");
let roundtrip = read(&value.to_string()).unwrap();
assert_eq!(roundtrip, d);
assert!(roundtrip.configuration_matches());
value["format"] = Value::from(format_string(FORMAT + 1));
value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]);
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolveCraft") && error.contains("update SolveCraft"), "{error}");
assert!(!error.contains("unknown variant"), "format refusal precedes decoding new variants");
}
#[test]
fn bulk_input_repair_preserves_existing_names_and_reuses_surplus_names() {
let mut d = Document::new("input repair");
+4 -2
View File
@@ -829,8 +829,10 @@ impl Document {
}
// Configuration cells refer to parameters too.
for r in &mut self.configs.rows {
for cell in &mut r.cells {
*cell = expr::rename_reference(cell, old, new);
for (column, cell) in self.configs.columns.iter().zip(&mut r.cells) {
if matches!(column, crate::config::Column::Param(_)) {
*cell = expr::rename_reference(cell, old, new);
}
}
}
if let Some(c) = self.param_comments.remove(old) {
+4 -1
View File
@@ -20,6 +20,9 @@ const AXIS_HALF: f64 = 100.0;
/// Points in one fitted chain at most (longer chains are thinned).
const MAX_CHAIN: usize = 4000;
#[path = "project_order.rs"]
mod order;
/// A resolved link: its geometry in sketch coordinates and the (possibly moved) reference.
#[derive(Clone, Debug)]
pub struct Resolved {
@@ -133,7 +136,7 @@ fn resolve_uncached(
LinkKind::Intersect => section(plane, &mesh, Some(f)),
_ => {
let mut g = Vec::new();
for e in mesh.face_edges(f) {
for e in order::face_edges(&mesh, f, plane)? {
if mesh.seams.get(e).copied().unwrap_or(false) {
continue;
}
+290
View File
@@ -0,0 +1,290 @@
//! Stable face projection order: outer boundary first, then holes by geometry.
//! B-rep edge indices depend on face enumeration and must not choose sketch curve IDs.
use std::collections::BTreeMap;
use solvecraft_geom::{Mesh, Plane, Vec3};
use crate::{DocError, Result};
type Key = (i64, i64, i64);
pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> {
let bounds = mesh.bounds();
let origin = bounds.min;
let local_origin = plane.to_local(origin);
let tol = (bounds.diagonal() * 1e-8).max(1e-9);
if !tol.is_finite() {
return Err(DocError::Invalid("face projection has invalid bounds".into()));
}
let key = |p: Vec3| -> Key {
let p = p - origin;
((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64)
};
let mut edges: Vec<usize> = mesh.face_edges(face).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).collect();
if edges.iter().any(|e| !mesh.edges.get(*e).is_some_and(|p| p.len() >= 2 && p.iter().all(|p| p.is_finite()))) {
return Err(DocError::Invalid("face projection has an invalid boundary edge".into()));
}
if edges.len() > solvecraft_sketch::MAX_LINK_ENTITIES {
return Err(DocError::Invalid("face projection exceeds the boundary edge limit".into()));
}
let geometry_key = |e: usize| {
let pts = mesh.edges.get(e).map(Vec::as_slice).unwrap_or_default();
let lo = pts.iter().copied().fold(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::min);
let hi = pts.iter().copied().fold(Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), Vec3::max);
(key(lo), key(hi))
};
edges.sort_by_key(|e| geometry_key(*e));
let mut at: BTreeMap<Key, Vec<usize>> = BTreeMap::new();
for e in &edges {
if let Some(pts) = mesh.edges.get(*e) {
for point in [pts.first(), pts.last()].into_iter().flatten() {
at.entry(key(*point)).or_default().push(*e);
}
}
}
let mut used = vec![false; mesh.edges.len()];
let mut boundaries = Vec::new();
for start_edge in &edges {
if used.get(*start_edge).copied().unwrap_or(true) {
continue;
}
let Some(pts) = mesh.edges.get(*start_edge) else { continue };
let (Some(a), Some(b)) = (pts.first(), pts.last()) else { continue };
let start = if key(*a) <= key(*b) { *a } else { *b };
let mut current = start;
let mut edge = *start_edge;
let mut boundary = Vec::new();
let mut twice_area = 0.0;
for _ in 0..edges.len() {
let Some(pts) = mesh.edges.get(edge) else { break };
let (Some(a), Some(b)) = (pts.first(), pts.last()) else { break };
let forward = current.dist(*a) <= current.dist(*b);
for pair in pts.windows(2) {
let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue };
let (a, b) = (plane.to_local(*a) - local_origin, plane.to_local(*b) - local_origin);
twice_area += if forward { a.cross(b) } else { b.cross(a) };
}
current = if forward { *b } else { *a };
if let Some(slot) = used.get_mut(edge) {
*slot = true;
}
boundary.push(edge);
if current.dist(start) <= tol {
break;
}
// Neighbouring quantization cells cover points on either side of a rounding
// boundary. Connectivity is checked in world space, not in the projection.
let (x, y, z) = key(current);
let mut candidates = Vec::new();
for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
let cell = (x.saturating_add(dx), y.saturating_add(dy), z.saturating_add(dz));
if let Some(list) = at.get(&cell) {
candidates.extend(list.iter().copied().filter(|e| {
!used.get(*e).copied().unwrap_or(true)
&& mesh.edges.get(*e).is_some_and(|p| {
p.first().is_some_and(|a| a.dist(current) <= tol) || p.last().is_some_and(|b| b.dist(current) <= tol)
})
}));
}
}
}
}
candidates.sort_by_key(|e| geometry_key(*e));
candidates.dedup();
if candidates.len() > 1 {
return Err(DocError::Invalid("face projection has a branched boundary".into()));
}
let Some(next) = candidates.first() else { break };
edge = *next;
}
boundary.sort_by_key(|e| geometry_key(*e));
let closed = current.dist(start) <= tol;
if !closed || !twice_area.is_finite() {
return Err(DocError::Invalid("face projection has an open or invalid boundary".into()));
}
// Equal-sized holes use their geometric key, not round-off from summing the
// same area at different positions or walking their edges in reverse.
let area = (twice_area.abs() / (tol * bounds.diagonal().max(tol))).round() as i64;
let first = boundary.first().copied().map(geometry_key);
boundaries.push((area, first, boundary));
}
boundaries.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
Ok(boundaries.into_iter().flat_map(|(_, _, edges)| edges).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn rectangles() -> Mesh {
let mut mesh = Mesh::default();
for (x, y) in [(40.0, 30.0), (38.0, 28.0), (4.0, 2.0)] {
let pts = [Vec3::new(-x, -y, 0.0), Vec3::new(x, -y, 0.0), Vec3::new(x, y, 0.0), Vec3::new(-x, y, 0.0)];
mesh.positions.extend(pts);
for i in 0..4 {
mesh.edges.push(vec![pts[i], pts[(i + 1) % 4]]);
mesh.edge_faces.push(vec![7]);
}
}
mesh
}
#[test]
fn boundaries_and_edge_identity_are_independent_of_mesh_enumeration() {
let baseline = rectangles();
let expected: Vec<_> =
face_edges(&baseline, 7, &Plane::XY).unwrap().into_iter().map(|e| super::super::fit_world(&Plane::XY, &baseline.edges[e])).collect();
for shift in 0..baseline.edges.len() {
for reverse in [false, true] {
let mut mesh = baseline.clone();
mesh.edges = (0..mesh.edges.len()).map(|i| baseline.edges[(i * 5) % baseline.edges.len()].clone()).collect();
mesh.edges.rotate_left(shift);
if reverse {
mesh.edges.reverse();
for pts in &mut mesh.edges {
pts.reverse();
}
}
let actual: Vec<_> = face_edges(&mesh, 7, &Plane::XY)
.unwrap()
.into_iter()
.map(|e| {
let mut pts = mesh.edges[e].clone();
// Lines' direction does not change their geometry.
if pts.first().unwrap().x > pts.last().unwrap().x || pts.first().unwrap().y > pts.last().unwrap().y {
pts.reverse();
}
super::super::fit_world(&Plane::XY, &pts)
})
.collect();
let normalized: Vec<_> = expected
.iter()
.map(|g| {
let solvecraft_sketch::LinkGeom::Line(a, b) = g[0] else { panic!("line") };
if a.x > b.x || a.y > b.y { vec![solvecraft_sketch::LinkGeom::Line(b, a)] } else { g.clone() }
})
.collect();
assert_eq!(actual, normalized);
let ordered = face_edges(&mesh, 7, &Plane::XY).unwrap();
for (loop_index, chunk) in ordered.chunks(4).enumerate() {
let want = [40.0, 38.0, 4.0][loop_index];
assert!(chunk.iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x.abs() <= want));
assert!(chunk.iter().flat_map(|e| &mesh.edges[*e]).any(|p| p.x.abs() == want));
}
}
}
}
#[test]
fn rounded_boundary_arc_sweeps_survive_individual_edge_reversal() {
use solvecraft_geom::{Seg2, Vec2};
use solvecraft_sketch::LinkGeom;
let mut mesh = Mesh::default();
for (extent, radius) in [(10.0, 3.0), (8.0, 1.0)] {
for k in 0..4 {
let angle = k as f64 * std::f64::consts::FRAC_PI_2;
let rotate = |p: Vec2| (0..k).fold(p, |p, _| p.perp());
let centre = rotate(Vec2::new(extent - radius, extent - radius));
let arc = Seg2::Arc { center: centre, radius, start: angle, sweep: std::f64::consts::FRAC_PI_2 };
let line = Seg2::Line { a: arc.point_at(1.0), b: rotate(Vec2::new(-(extent - radius), extent)) };
for seg in [arc, line] {
let pts: Vec<_> = (0..=16).map(|i| Plane::XY.to_world(seg.point_at(i as f64 / 16.0))).collect();
mesh.positions.extend(pts.iter().copied());
mesh.edges.push(pts);
mesh.edge_faces.push(vec![2]);
}
}
}
let normalized = |mesh: &Mesh| -> Vec<Vec<LinkGeom>> {
face_edges(mesh, 2, &Plane::XY)
.unwrap()
.into_iter()
.map(|e| {
super::super::fit_world(&Plane::XY, &mesh.edges[e])
.into_iter()
.map(|g| match g {
LinkGeom::Line(a, b) if (a.x, a.y) > (b.x, b.y) => LinkGeom::Line(b, a),
g => g,
})
.collect()
})
.collect()
};
let expected = normalized(&mesh);
for shift in 0..mesh.edges.len() {
let mut permuted = mesh.clone();
permuted.edges = (0..mesh.edges.len()).map(|i| mesh.edges[(i * 5 + shift) % mesh.edges.len()].clone()).collect();
for (i, points) in permuted.edges.iter_mut().enumerate() {
if i % 2 == 0 {
points.reverse();
}
}
let actual = normalized(&permuted);
for (a, b) in actual.iter().zip(&expected) {
assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b) {
match (a, b) {
(LinkGeom::Line(a, b), LinkGeom::Line(c, d)) => assert!(a.dist(*c) < 1e-9 && b.dist(*d) < 1e-9),
(LinkGeom::Arc { c, a, b }, LinkGeom::Arc { c: cc, a: aa, b: bb }) => {
assert!(c.dist(*cc) < 1e-9 && a.dist(*aa) < 1e-9 && b.dist(*bb) < 1e-9);
}
_ => panic!("boundary changed kind"),
}
}
}
}
}
#[test]
fn incomplete_and_branched_boundaries_are_reported_without_partial_geometry() {
let mut open = rectangles();
open.edges.pop();
open.edge_faces.pop();
assert!(face_edges(&open, 7, &Plane::XY).unwrap_err().to_string().contains("open"));
let mut branched = rectangles();
branched.edges.push(vec![Vec3::new(-40.0, -30.0, 0.0), Vec3::ZERO]);
branched.edge_faces.push(vec![7]);
assert!(face_edges(&branched, 7, &Plane::XY).is_err());
let mut invalid = rectangles();
invalid.edges[0].push(Vec3::new(f64::NAN, 0.0, 0.0));
assert!(face_edges(&invalid, 7, &Plane::XY).unwrap_err().to_string().contains("invalid"));
}
#[test]
fn equal_area_holes_use_geometric_order_after_translation() {
let mut baseline = rectangles();
baseline.edges.truncate(4);
baseline.edge_faces.truncate(4);
for x in [-12.0, 12.0] {
let pts = [Vec3::new(x - 2.0, -2.0, 0.0), Vec3::new(x + 2.0, -2.0, 0.0), Vec3::new(x + 2.0, 2.0, 0.0), Vec3::new(x - 2.0, 2.0, 0.0)];
for i in 0..4 {
baseline.edges.push(vec![pts[i], pts[(i + 1) % 4]]);
baseline.edge_faces.push(vec![7]);
}
}
let shift = Vec3::new(12345.6789, -23456.7891, 987.0);
for p in &mut baseline.positions {
*p += shift;
}
for points in &mut baseline.edges {
for p in points {
*p += shift;
}
}
for offset in 0..baseline.edges.len() {
let mut mesh = baseline.clone();
mesh.edges = (0..mesh.edges.len()).map(|i| baseline.edges[(i * 5 + offset) % baseline.edges.len()].clone()).collect();
for (i, points) in mesh.edges.iter_mut().enumerate() {
if i % 2 == 0 {
points.reverse();
}
}
let order = face_edges(&mesh, 7, &Plane::XY).unwrap();
assert!(order[4..8].iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x < shift.x));
assert!(order[8..12].iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x > shift.x));
}
}
}
+8
View File
@@ -413,6 +413,14 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
for f in d.appearances.faces.iter_mut().filter(|f| f.body == old) {
f.body = name.to_string();
}
for column in &mut d.configs.columns {
match column {
solvecraft_doc::config::Column::Material(body) | solvecraft_doc::config::Column::Appearance(body) if *body == old => {
*body = name.to_string();
}
_ => {}
}
}
if let Some(o) = d.body_offsets.remove(&old) {
d.body_offsets.insert(name.to_string(), o);
}
+9
View File
@@ -11,6 +11,9 @@ use super::CommandSpec;
use crate::params::{bad, bool_, num, str_, string_list, vec3};
use crate::{Result, Session};
#[path = "component_properties.rs"]
mod properties;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.create", "New Component", new_component)
.at("SOLID", "ASSEMBLE")
@@ -21,6 +24,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("bodies: [names] → one new component per body, named after it"),
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design)"),
CommandSpec::new("component.list", "List Components", list).noundo(),
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
.icon("measure").noundo()
.params("component?: id or name (default active); descendants?: bool (default true). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates"),
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
.icon("measure").noundo()
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true). Physical properties of this placement in world coordinates, including pending moves"),
CommandSpec::new("component.rename", "Rename Component", rename).params("component, name"),
CommandSpec::new("component.delete", "Delete Component", delete).params("component: deletes it, its subcomponents, occurrences and features"),
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).params("bodies: [names], component"),
@@ -0,0 +1,142 @@
//! Physical properties of an assembly placement, with original body material assignments.
use serde_json::{Value, json};
use solvecraft_doc::{Document, Mat};
use solvecraft_geom::Vec3;
use crate::params::bad;
use crate::{Result, Session};
fn descendants(p: &Value, cmd: &str) -> Result<bool> {
match p.get("descendants") {
None => Ok(true),
Some(Value::Bool(v)) => Ok(*v),
Some(_) => Err(bad(cmd, "`descendants` must be a boolean")),
}
}
fn placed_doc(s: &Session) -> Document {
let mut doc = (*s.doc).clone();
for occurrence in &mut doc.occurrences {
if let Some(transform) = s.pending_moves.get(&occurrence.id) {
occurrence.transform = *transform;
}
}
doc
}
/// A property total must cover the entire assembly. The display traversal intentionally
/// truncates hostile designs, so use an explicit error instead of accepting its prefix.
fn placements(doc: &Document, cmd: &str) -> Result<Vec<(u64, Vec<u64>, Mat)>> {
let mut out = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)];
let mut stack = vec![(0, Vec::<u64>::new(), solvecraft_doc::IDENTITY)];
while let Some((component, path, transform)) = stack.pop() {
for occurrence in doc.occurrences.iter().filter(|o| o.parent == component) {
if path.len() >= 64 || out.len() >= 100_000 {
return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)"));
}
let mut child_path = path.clone();
child_path.push(occurrence.id);
let world = solvecraft_doc::mat_mul(&transform, &occurrence.transform);
out.push((occurrence.component, child_path.clone(), world));
stack.push((occurrence.component, child_path, world));
}
}
Ok(out)
}
pub(super) fn component(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.properties";
let id = if p.get("component").is_some() { super::component(s, p, "component", cmd)? } else { s.active_component };
let doc = placed_doc(s);
let path = doc.first_path(id);
let name = if id == 0 {
doc.name.clone()
} else {
doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).ok_or_else(|| bad(cmd, "no active component"))?
};
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?;
out["component"] = json!(id);
out["name"] = json!(name);
Ok(out)
}
pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "occurrence.properties";
let id = super::occurrence(s, p, cmd)?;
let doc = placed_doc(s);
let paths: Vec<Vec<u64>> = placements(&doc, cmd)?.into_iter().filter(|(_, path, _)| path.last() == Some(&id)).map(|(_, path, _)| path).collect();
let path = if let Some(value) = p.get("path") {
let path = value
.as_array()
.filter(|a| !a.is_empty() && a.len() <= 64)
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
.ok_or_else(|| bad(cmd, "`path` must list 1…64 occurrence ids"))?;
if !paths.contains(&path) {
return Err(bad(cmd, "`path` does not identify a placement of this occurrence"));
}
path
} else {
match paths.as_slice() {
[path] => path.clone(),
[] => return Err(bad(cmd, "occurrence has no placement")),
_ => return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")),
}
};
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?;
out["occurrence"] = json!(id);
out["name"] = json!(doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.as_str()));
Ok(out)
}
fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, cmd: &str) -> Result<Value> {
let state = s.model.state();
let mut bodies = Vec::new();
let (mut volume, mut area, mut mass) = (0.0, 0.0, 0.0);
let mut moment = Vec3::ZERO;
let (mut min, mut max) =
(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY));
let (mut faces, mut edges, mut vertices) = (0, 0, 0);
let mut placement_count = 0;
for (component, path, transform) in
placements(doc, cmd)?.into_iter().filter(|(_, path, _)| if descendants { path.starts_with(selected) } else { path == selected })
{
placement_count += 1;
for body in state.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component) {
let transform: Mat = doc.body_offsets.get(&body.name).map(|offset| solvecraft_doc::mat_mul(&transform, offset)).unwrap_or(transform);
let placed = solvecraft_kernel::transform_matrix(&body.body, transform)?;
let measured = solvecraft_kernel::measure(&placed)?;
let density = doc.density(&body.name);
let body_mass = measured.volume * density / 1000.0;
volume += measured.volume;
area += measured.area;
mass += body_mass;
moment += measured.centroid * body_mass;
min = Vec3::new(min.x.min(measured.bbox.min.x), min.y.min(measured.bbox.min.y), min.z.min(measured.bbox.min.z));
max = Vec3::new(max.x.max(measured.bbox.max.x), max.y.max(measured.bbox.max.y), max.z.max(measured.bbox.max.z));
faces += measured.merged.faces;
edges += measured.merged.edges;
vertices += measured.merged.vertices;
bodies.push(json!({"name": body.name, "component": component, "path": path,
"material": doc.materials.get(&body.name).map(String::as_str).unwrap_or("Default"),
"density_g_cm3": density, "mass_g": body_mass, "volume_mm3": measured.volume,
"area_mm2": measured.area, "center_of_mass": measured.centroid,
"bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
}
}
if !(volume.is_finite() && area.is_finite() && mass.is_finite() && moment.is_finite()) {
return Err(bad(cmd, "physical properties exceed supported numeric range"));
}
let centroid = if mass > 0.0 { Some(moment / mass) } else { None };
if centroid.is_some_and(|point| !point.is_finite()) {
return Err(bad(cmd, "center of mass exceeds supported numeric range"));
}
let bbox = if bodies.is_empty() { Value::Null } else { json!({"min": min, "max": max}) };
Ok(json!({"path": selected, "coordinate_frame": "world", "descendants": descendants,
"placement_count": placement_count, "body_count": bodies.len(), "bodies": bodies,
"total": {"volume_mm3": volume, "area_mm2": area, "mass_g": mass,
"center_of_mass": centroid, "bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}}))
}
#[cfg(test)]
#[path = "component_properties_tests.rs"]
mod tests;
@@ -0,0 +1,129 @@
use crate::Session;
use serde_json::{Value, json};
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
s.execute(cmd, &params).unwrap()
}
fn close(actual: &Value, expected: f64) {
assert!((actual.as_f64().unwrap() - expected).abs() < 1e-6, "{actual} != {expected}");
}
fn box_in(s: &mut Session, name: &str) -> (u64, u64, String) {
let c = run(s, "component.create", json!({"name": name}));
run(s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let body = s.model.state().bodies.last().unwrap().name.clone();
(c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap(), body)
}
#[test]
fn assembly_counts_instances_and_resolves_original_materials() {
let mut s = Session::default();
let (component, occurrence, body) = box_in(&mut s, "Block");
run(&mut s, "material.assign", json!({"bodies": [body], "material": "Steel"}));
let copy = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20, 0, 0]}));
let rho = s.doc.density(&body);
let root = run(&mut s, "component.properties", json!({"component": "root"}));
assert_eq!(root["body_count"], 2);
close(&root["total"]["mass_g"], 2.0 * rho);
close(&root["total"]["volume_mm3"], 2000.0);
close(&root["total"]["center_of_mass"][0], 15.0);
let first = run(&mut s, "component.properties", json!({"component": component}));
assert_eq!(first["body_count"], 1);
close(&first["total"]["center_of_mass"][0], 5.0);
let second = run(&mut s, "occurrence.properties", json!({"occurrence": copy["occurrence"]}));
close(&second["total"]["center_of_mass"][0], 25.0);
assert_eq!(second["bodies"][0]["material"], "Steel");
assert_eq!(first["path"], json!([occurrence]));
}
#[test]
fn mass_centroid_is_density_weighted_and_pending_moves_are_read_only() {
let mut s = Session::default();
let (a, oa, steel) = box_in(&mut s, "Steel block");
run(&mut s, "material.assign", json!({"bodies": [steel], "material": "Steel"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let (_, ob, plastic) = box_in(&mut s, "Plastic block");
run(&mut s, "material.assign", json!({"bodies": [plastic], "material": "ABS Plastic"}));
run(&mut s, "occurrence.move", json!({"occurrence": ob, "translate": [30, 0, 0], "capture": false}));
let doc = s.doc.clone();
let moves = s.pending_moves.clone();
let r = run(&mut s, "component.properties", json!({"component": 0}));
let ra = s.doc.density(&steel);
let rb = s.doc.density(&plastic);
close(&r["total"]["center_of_mass"][0], (5.0 * ra + 35.0 * rb) / (ra + rb));
close(&r["total"]["bbox"]["max"][0], 40.0);
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc));
assert_eq!(s.pending_moves, moves);
run(&mut s, "occurrence.move", json!({"occurrence": oa, "translate": [10, 0, 0], "axis": [0, 0, 1], "angle": "90 deg"}));
let r = run(&mut s, "component.properties", json!({"component": a}));
close(&r["total"]["center_of_mass"][0], 5.0);
close(&r["total"]["center_of_mass"][1], 5.0);
}
#[test]
fn nested_instances_require_a_path_and_can_exclude_descendants() {
let mut s = Session::default();
let parent = run(&mut s, "component.create", json!({"name": "Assembly"}));
let (_, child_occurrence, _) = box_in(&mut s, "Child");
let copy = run(&mut s, "occurrence.copy", json!({"component": parent["component"], "translate": [100, 0, 0]}));
assert!(s.execute("occurrence.properties", &json!({"occurrence": child_occurrence})).is_err());
let r = run(&mut s, "occurrence.properties", json!({"occurrence": child_occurrence, "path": [copy["occurrence"], child_occurrence]}));
close(&r["total"]["center_of_mass"][0], 105.0);
let r = run(&mut s, "component.properties", json!({"component": parent["component"]}));
assert_eq!(r["body_count"], 1);
let r = run(&mut s, "component.properties", json!({"component": parent["component"], "descendants": false}));
assert_eq!(r["body_count"], 0);
assert!(r["total"]["center_of_mass"].is_null());
assert!(r["total"]["bbox"].is_null());
}
#[test]
fn body_reassignment_offsets_preserve_world_properties() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let body = s.model.state().bodies.first().unwrap().name.clone();
let c = run(&mut s, "component.create", json!({"name": "Moved"}));
run(&mut s, "occurrence.move", json!({"occurrence": c["occurrence"], "translate": [100, 0, 0]}));
run(&mut s, "component.move_bodies", json!({"bodies": [body], "component": c["component"]}));
let r = run(&mut s, "component.properties", json!({"component": c["component"]}));
close(&r["total"]["center_of_mass"][0], 5.0);
}
#[test]
fn properties_reject_hostile_identifiers_and_options() {
let mut s = Session::default();
let (_, occurrence, _) = box_in(&mut s, "Block");
for p in [json!({"component": []}), json!({"component": "missing"}), json!({"descendants": 1})] {
assert!(s.execute("component.properties", &p).is_err());
}
for path in [json!([]), json!(["bad"]), json!([0]), json!(vec![occurrence; 65])] {
assert!(s.execute("occurrence.properties", &json!({"occurrence": occurrence, "path": path})).is_err());
}
}
#[test]
fn compact_exponentially_instanced_assembly_refuses_partial_totals() {
let mut s = Session::default();
let mut last_occurrence = 0;
// Only 17 component definitions and 34 occurrences, but over 100000 placements.
for _ in 0..17 {
let parent = s.active_component;
let component = s.doc_mut().add_component(None, parent).unwrap();
last_occurrence = s.doc_mut().add_occurrence(component, parent, solvecraft_doc::IDENTITY).unwrap();
s.active_component = component;
}
for command in ["component.properties", "occurrence.properties"] {
let error = s.execute(command, &json!({"component": 0, "occurrence": last_occurrence})).unwrap_err();
assert!(error.to_string().contains("traversal limits"), "{error}");
}
}
#[test]
fn excessively_nested_assembly_refuses_partial_totals() {
let mut s = Session::default();
for _ in 0..65 {
let parent = s.active_component;
s.active_component = s.doc_mut().add_component(None, parent).unwrap();
}
let error = s.execute("component.properties", &json!({"component": 0})).unwrap_err();
assert!(error.to_string().contains("traversal limits"), "{error}");
}
+181 -8
View File
@@ -10,21 +10,33 @@ use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("config.configure", "Configure", configure).at("SOLID", "CONFIGURE").icon("params").params(
"params?: [parameter names]; features?: [feature names] (columns to add; with no rows yet, a \"Default\" row takes the current values)",
"params?: [parameter names]; features?: [feature names]; materials?: [body names]; appearances?: [body names] (columns to add; with no rows yet, a \"Default\" row takes the current values)",
),
CommandSpec::new("config.table", "Display Configuration Table", show)
.at("SOLID", "CONFIGURE")
.icon("params")
.noundo()
.params("→ columns (param:<name> or suppress:<feature>), rows, the active row and whether the design still matches it"),
CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? (name); delete?: bool"),
CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? | material? | appearance? (target name); delete?: bool"),
CommandSpec::new("config.row", "Configuration Row", row).params(
"name; from? (row to copy; default the current values); values?: {param name or feature name: expression or true/false}; rename?; delete?: bool",
"name; from? (row to copy; default current values); values?: {column key: expression, suppression, material name, appearance name or Look object}; rename?; delete?: bool",
),
CommandSpec::new("config.activate", "Activate Configuration", activate).params("row: name — sets its parameter values and suppressions"),
];
fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
if ["param", "feature", "material", "appearance"].iter().filter(|k| p.get(**k).is_some()).count() != 1 {
return Err(bad(cmd, "give exactly one of `param`, `feature`, `material`, or `appearance`"));
}
for (key, appearance) in [("material", false), ("appearance", true)] {
if let Some(body) = str_(p, key) {
let c = if appearance { Column::Appearance(body.to_string()) } else { Column::Material(body.to_string()) };
if !s.doc.configs.columns.contains(&c) && s.model.state().body(body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
return Ok(c);
}
}
if let Some(n) = str_(p, "param") {
if !s.doc.all_param_exprs().iter().any(|(m, _, _)| m == n) {
return Err(bad(cmd, format!("no parameter `{n}`")));
@@ -63,6 +75,13 @@ fn configure(s: &mut Session, p: &Value) -> Result<Value> {
let c = column_of(s, &json!({ "feature": n }), cmd)?;
add_column(s, c, cmd)?;
}
for key in ["materials", "appearances"] {
for n in string_list(p, key) {
let field = if key == "materials" { "material" } else { "appearance" };
let c = column_of(s, &json!({ field: n }), cmd)?;
add_column(s, c, cmd)?;
}
}
if s.doc.configs.rows.is_empty() {
let cells = s.doc.configs.columns.iter().map(|c| s.doc.config_current(c).unwrap_or_default()).collect();
let t = &mut s.doc_mut().configs;
@@ -122,32 +141,54 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(vals) = p.get("values") {
let vals = vals.as_object().ok_or_else(|| bad(cmd, "`values` must map columns to values"))?;
for (k, v) in vals {
let text = match v {
let mut text = match v {
Value::String(t) => t.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::Object(_) if k.starts_with("appearance:") => serde_json::to_string(v).map_err(|e| bad(cmd, e.to_string()))?,
_ => return Err(bad(cmd, format!("value for `{k}` must be an expression or true/false"))),
};
if text.len() > 4096 {
return Err(bad(cmd, "value too long"));
}
let key = k.strip_prefix("param:").or_else(|| k.strip_prefix("suppress:")).unwrap_or(k);
let col = s
.doc
.configs
.columns
.iter()
.position(|c| match c {
Column::Param(n) => n == key,
Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == key),
.position(|c| {
s.doc.config_key(c) == *k
|| match c {
Column::Param(n) => n == k,
Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == *k),
Column::Material(_) | Column::Appearance(_) => false,
}
})
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
match s.doc.configs.columns.get(col) {
Some(Column::Material(_)) => {
text = solvecraft_doc::config::material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
}
Some(Column::Appearance(_)) => {
text = match solvecraft_doc::config::appearance_cell(&text).map_err(|e| bad(cmd, e.to_string()))? {
Some(look) => serde_json::to_string(&look).map_err(|e| bad(cmd, e.to_string()))?,
None => String::new(),
};
}
_ => {}
}
if let Some(cell) = cells.get_mut(col) {
*cell = text;
}
}
}
let rename = str_(p, "rename").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).map(str::to_string);
if p.get("rename").is_some() && (rename.is_none() || idx.is_none()) {
return Err(bad(cmd, "rename requires an existing row and a nonempty name up to 128 bytes"));
}
if rename.as_ref().is_some_and(|n| n != &name && s.doc.configs.rows.iter().any(|r| &r.name == n)) {
return Err(bad(cmd, "configuration name already exists"));
}
let t = &mut s.doc_mut().configs;
match idx {
Some(i) => {
@@ -236,4 +277,136 @@ mod tests {
run(&mut s, "config.column", json!({"feature": fillet, "delete": true}));
assert_eq!(run(&mut s, "config.table", json!({}))["columns"].as_array().map(Vec::len), Some(1));
}
#[test]
fn configuration_materials_and_looks_are_undoable_and_persistent() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let body = s.model.state().bodies.first().unwrap().name.clone();
run(&mut s, "config.configure", json!({"materials": [body], "appearances": [body]}));
let material = format!("material:{body}");
let appearance = format!("appearance:{body}");
run(&mut s, "config.row", json!({"name": "Metal", "values": {material.clone(): "steel", appearance.clone(): "ABS - Red"}}));
run(
&mut s,
"config.row",
json!({"name": "Custom", "from": "Metal", "values": {
material.clone(): "Aluminum", appearance.clone(): {"name": "Ocean", "color": [1, 2, 3], "opacity": 0.4}
}}),
);
run(&mut s, "config.activate", json!({"row": "Metal"}));
assert_eq!(s.doc.materials.get(&body).map(String::as_str), Some("Steel"));
assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().color, [196, 40, 40]);
let mass = run(&mut s, "inspect.measure", json!({}));
assert!((mass["bodies"][0]["mass_g"].as_f64().unwrap() - 7.85).abs() < 1e-6);
assert_eq!(run(&mut s, "config.table", json!({}))["matches"], true);
run(&mut s, "config.activate", json!({"row": "Custom"}));
let mass = run(&mut s, "inspect.measure", json!({}));
assert!((mass["bodies"][0]["mass_g"].as_f64().unwrap() - 2.7).abs() < 1e-6);
assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().opacity, 0.4);
let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
assert_eq!(reopened, *s.doc);
assert!(reopened.configuration_matches());
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.materials.get(&body).map(String::as_str), Some("Steel"));
run(&mut s, "config.activate", json!({"row": "Default"}));
assert!(!s.doc.materials.contains_key(&body));
assert!(!s.doc.appearances.bodies.contains_key(&body));
run(&mut s, "config.activate", json!({"row": "Metal"}));
run(&mut s, "appearance.assign", json!({"bodies": [body], "appearance": "ABS - Blue"}));
assert!(!s.doc.configuration_matches());
run(&mut s, "config.column", json!({"appearance": body, "delete": true}));
assert_eq!(s.doc.configs.columns.len(), 1);
assert!(s.doc.configs.rows.iter().all(|r| r.cells.len() == 1));
}
#[test]
fn configuration_styles_reject_hostile_values_without_changes() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let body = s.model.state().bodies.first().unwrap().name.clone();
run(&mut s, "config.configure", json!({"materials": [body], "appearances": [body]}));
let material = format!("material:{body}");
let appearance = format!("appearance:{body}");
for params in [
json!({"name": "Bad", "values": {material: "Unobtainium"}}),
json!({"name": "Bad", "values": {appearance.clone(): "#oops"}}),
json!({"name": "Bad", "values": {appearance.clone(): {"name": "Invalid", "color": [1, 2, 300], "opacity": 1}}}),
json!({"name": "Bad", "values": {appearance: {"name": "Invalid", "color": [1, 2, 3], "opacity": -1}}}),
json!({"name": "Default", "rename": ""}),
] {
let before = s.doc.clone();
assert!(s.execute("config.row", &params).is_err(), "{params}");
assert_eq!(s.doc, before);
}
assert!(s.execute("config.column", &json!({"appearance": "missing"})).is_err());
assert!(s.execute("config.column", &json!({"appearance": body, "material": body})).is_err());
run(&mut s, "config.row", json!({"name": "Other"}));
assert!(s.execute("config.row", &json!({"name": "Other", "rename": "Default"})).is_err());
// A malformed persisted row must fail atomically rather than silently apply its prefix.
s.doc_mut().configs.rows.first_mut().unwrap().cells.pop();
let before = s.doc.clone();
assert!(s.execute("config.activate", &json!({"row": "Default"})).is_err());
assert_eq!(s.doc, before);
}
#[test]
fn configuration_captures_unicode_custom_appearance_names() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let body = s.model.state().bodies.first().unwrap().name.clone();
let name = "é".repeat(128);
run(&mut s, "appearance.assign", json!({"bodies": [body], "appearance": name, "color": [40, 90, 186]}));
run(&mut s, "config.configure", json!({"appearances": [body]}));
run(&mut s, "appearance.assign", json!({"bodies": [body]}));
run(&mut s, "config.activate", json!({"row": "Default"}));
assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().name, name);
assert!(s.doc.configuration_matches());
let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
assert!(reopened.configuration_matches());
}
#[test]
fn configuration_styles_survive_parameter_and_body_renames() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "Steel", "expression": "10 mm"}));
run(&mut s, "parameters.change", json!({"name": "double", "expression": "Steel * 2"}));
run(&mut s, "solid.box", json!({"length": "double", "width": 10, "height": 10, "body_name": "Block"}));
run(&mut s, "config.configure", json!({"params": ["Steel", "double"], "materials": ["Block"], "appearances": ["Block"]}));
run(
&mut s,
"config.row",
json!({"name": "Styled", "values": {
"param:Steel": "20 mm", "param:double": "Steel * 3", "material:Block": "Steel", "appearance:Block": "Steel - Satin"
}}),
);
run(&mut s, "config.activate", json!({"row": "Styled"}));
let styles: Vec<Vec<String>> = s.doc.configs.rows.iter().map(|r| r.cells.iter().skip(2).cloned().collect()).collect();
run(&mut s, "parameters.rename", json!({"name": "Steel", "new_name": "gauge"}));
let after: Vec<Vec<String>> = s.doc.configs.rows.iter().map(|r| r.cells.iter().skip(2).cloned().collect()).collect();
assert_eq!(after, styles, "renaming an expression reference must preserve style cells verbatim");
let table = run(&mut s, "config.table", json!({}));
assert_eq!(table["columns"][0], "param:gauge");
assert_eq!(table["rows"][1]["values"]["param:double"], "gauge * 3");
assert!(s.doc.configuration_matches());
run(&mut s, "body.rename", json!({"body": "Block", "name": "Housing"}));
let table = run(&mut s, "config.table", json!({}));
assert_eq!(table["columns"][2], "material:Housing");
assert_eq!(table["columns"][3], "appearance:Housing");
assert!(s.doc.configuration_matches());
run(&mut s, "edit.undo", json!({}));
assert_eq!(run(&mut s, "config.table", json!({}))["columns"][2], "material:Block");
run(&mut s, "edit.redo", json!({}));
let mut reopened = Session::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap());
run(&mut reopened, "config.activate", json!({"row": "Default"}));
assert!(!reopened.doc.materials.contains_key("Housing"));
assert!(!reopened.doc.appearances.bodies.contains_key("Housing"));
run(&mut reopened, "config.activate", json!({"row": "Styled"}));
assert_eq!(reopened.doc.materials.get("Housing").map(String::as_str), Some("Steel"));
assert_eq!(reopened.doc.appearances.bodies.get("Housing").unwrap().name, "Steel - Satin");
assert!(!reopened.doc.materials.contains_key("Block"));
assert!(!reopened.doc.appearances.bodies.contains_key("Block"));
assert!((volume(&mut reopened) - 6000.0).abs() < 1e-6);
assert!(reopened.doc.configuration_matches());
}
}
+251
View File
@@ -0,0 +1,251 @@
//! Bounded, chronological checks of a joint trajectory. This samples tessellated geometry;
//! it is deliberately not advertised as exact continuous collision detection.
use std::collections::{BTreeSet, HashMap};
use std::sync::Arc;
use solvecraft_doc::joints;
use solvecraft_doc::{IDENTITY, Mat, apply_point, mat_mul};
use solvecraft_geom::{Aabb3, Mesh, Vec3};
use crate::params::bad;
use crate::{Result, Session};
const MAX_SAMPLES: usize = 4096;
const MAX_PLACEMENTS: usize = 4096;
const MAX_BODIES: usize = 256;
const MAX_TRIANGLES: usize = 50_000;
const MAX_WORK: usize = 50_000_000;
fn limit(message: &str) -> crate::EngineError {
bad("contact.sweep", message)
}
struct CachedBody {
path: Vec<u64>,
offset: Mat,
mesh: Arc<Mesh>,
size: f64,
}
pub(super) struct ContactGeometry {
bodies: Vec<CachedBody>,
pairs: Vec<(usize, usize, String, String)>,
step: f64,
}
impl ContactGeometry {
pub(super) fn new(s: &Session) -> Result<Self> {
let assembly = &s.doc.assembly;
let sets: Vec<Vec<u64>> = if assembly.contact_all {
vec![s.doc.occurrences.iter().map(|o| o.id).collect()]
} else {
assembly.contact_sets.iter().filter(|set| !set.suppressed).map(|set| set.occurrences.clone()).collect()
};
let mut out = Self { bodies: Vec::new(), pairs: Vec::new(), step: f64::INFINITY };
if sets.is_empty() {
return Ok(out);
}
let state = s.model.state();
let mut meshes: HashMap<String, Arc<Mesh>> = HashMap::new();
let mut stack = vec![(0, Vec::<u64>::new())];
let mut count = 0;
while let Some((component, path)) = stack.pop() {
count += 1;
if count > MAX_PLACEMENTS || path.len() > 64 {
return Err(limit("assembly exceeds contact sweep placement budget"));
}
for occurrence in s.doc.occurrences.iter().filter(|o| o.parent == component) {
let mut child = path.clone();
child.push(occurrence.id);
stack.push((occurrence.component, child));
if stack.len() > MAX_PLACEMENTS {
return Err(limit("assembly exceeds contact sweep placement budget"));
}
}
if !sets.iter().any(|set| path.iter().any(|id| set.contains(id))) {
continue;
}
for body in state.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == component) {
if body.body.is_mesh() {
return Err(limit("contact sweep does not support mesh bodies; disable their contact set to move them"));
}
if out.bodies.len() >= MAX_BODIES {
return Err(limit("too many bodies for a contact sweep"));
}
let mesh = if let Some(mesh) = meshes.get(&body.name) {
mesh.clone()
} else {
let mesh = body.body.tessellate((body.body.size() * 1e-3).max(1e-3))?;
if mesh.triangles.len() > MAX_TRIANGLES || mesh.positions.len().saturating_add(mesh.triangles.len().saturating_mul(4)) > 200_000 {
return Err(limit("body exceeds contact sweep tessellation budget"));
}
let extent = mesh.bounds().size();
// Whole-body widths along bounded distinct facet normals catch tilted
// plates without interpreting skinny mesh triangles as thin material.
// Local thin walls of a wider body remain a sampling limitation.
let mut thickness = extent.x.min(extent.y).min(extent.z);
let mut directions: Vec<Vec3> = Vec::new();
for triangle in &mesh.triangles {
let Some([a, b, c]) = mesh.tri(triangle) else {
return Err(limit("invalid contact tessellation"));
};
let Some(direction) = (b - a).cross(c - a).normalized() else { continue };
if directions.len() < 32 && !directions.iter().any(|d| d.dot(direction).abs() > 1.0 - 1e-6) {
directions.push(direction);
}
}
for direction in directions {
let (mut low, mut high) = (f64::INFINITY, f64::NEG_INFINITY);
for point in &mesh.positions {
let projected = point.dot(direction);
low = low.min(projected);
high = high.max(projected);
}
thickness = thickness.min(high - low);
}
if !thickness.is_finite() || thickness <= 1e-9 {
return Err(limit("body is too thin for a bounded contact sweep"));
}
out.step = out.step.min(thickness * 0.25);
let mesh = Arc::new(mesh);
meshes.insert(body.name.clone(), mesh.clone());
mesh
};
out.bodies.push(CachedBody {
path: path.clone(),
offset: s.doc.body_offsets.get(&body.name).copied().unwrap_or(IDENTITY),
mesh,
size: body.body.size(),
});
}
}
let mut pairs = BTreeSet::new();
for set in &sets {
let tagged: Vec<(usize, u64)> =
out.bodies.iter().enumerate().filter_map(|(i, b)| b.path.iter().rev().find(|id| set.contains(id)).map(|id| (i, *id))).collect();
for (i, (a, oa)) in tagged.iter().enumerate() {
for (b, ob) in tagged.iter().skip(i + 1) {
if oa != ob {
pairs.insert((*a, *b, *oa, *ob));
}
}
}
}
let name = |id| s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default();
out.pairs = pairs.into_iter().map(|(a, b, oa, ob)| (a, b, name(oa), name(ob))).collect();
Ok(out)
}
pub(super) fn has_pairs(&self) -> bool {
!self.pairs.is_empty()
}
fn frame(body: &CachedBody, s: &Session) -> Result<Mat> {
let mut frame = IDENTITY;
for id in &body.path {
let occurrence = s.doc.occurrences.iter().find(|o| o.id == *id).ok_or_else(|| limit("contact occurrence disappeared"))?;
frame = mat_mul(&frame, &occurrence.transform);
}
Ok(mat_mul(&frame, &body.offset))
}
fn bounds(&self, s: &Session) -> Result<Aabb3> {
let mut bounds = Aabb3::EMPTY;
for body in &self.bodies {
let frame = Self::frame(body, s)?;
for point in &body.mesh.positions {
bounds.add(apply_point(&frame, *point));
}
}
Ok(bounds)
}
pub(super) fn samples(&self, start: &Session, end: &Session) -> Result<usize> {
let a = joints::joint_values(&start.doc.assembly);
let b = joints::joint_values(&end.doc.assembly);
let bounds = self.bounds(start)?.union(&self.bounds(end)?);
let state = start.model.state();
let (mut linear, mut angular, mut radius) = (0.0, 0.0, 0.0_f64);
for joint in start.doc.assembly.joints.iter().filter(|j| !j.suppressed) {
for (index, _) in joint.kind.dofs().iter().enumerate() {
let delta = (a.get(&joint.id).and_then(|q| q.get(index)).copied().unwrap_or(0.0)
- b.get(&joint.id).and_then(|q| q.get(index)).copied().unwrap_or(0.0))
.abs();
if joint.kind.is_angle(index) {
angular += delta;
if delta == 0.0 {
continue;
}
for session in [start, end] {
for origin in [&joint.a, &joint.b] {
let frame = session.doc.origin_world(&state, origin)?;
let pivot = apply_point(&frame, Vec3::ZERO);
let far = Vec3::new(
(bounds.min.x - pivot.x).abs().max((bounds.max.x - pivot.x).abs()),
(bounds.min.y - pivot.y).abs().max((bounds.max.y - pivot.y).abs()),
(bounds.min.z - pivot.z).abs().max((bounds.max.z - pivot.z).abs()),
);
radius = radius.max(far.len());
}
}
} else {
linear += delta;
}
}
}
let required = ((linear + angular * (radius + linear)) / self.step).max(angular / 1_f64.to_radians()).ceil().max(1.0);
if !required.is_finite() || required > MAX_SAMPLES as f64 {
return Err(limit("motion exceeds contact sweep sampling budget; drive the joint in smaller increments"));
}
let vertices: usize = self.bodies.iter().map(|body| body.mesh.positions.len()).sum();
if (required as usize).saturating_mul(vertices.saturating_add(self.pairs.len())) > MAX_WORK {
return Err(limit("motion exceeds contact sweep transform budget; simplify the contact set or use smaller increments"));
}
Ok(required as usize)
}
pub(super) fn first_hit(&self, s: &Session, work: &mut usize) -> Result<Option<(String, String, f64)>> {
let meshes: Vec<Mesh> = self
.bodies
.iter()
.map(|body| {
let frame = Self::frame(body, s)?;
let positions = body.mesh.positions.iter().map(|p| apply_point(&frame, *p)).collect();
Ok(Mesh { positions, triangles: body.mesh.triangles.clone(), ..Mesh::default() })
})
.collect::<Result<_>>()?;
for (a, b, aname, bname) in &self.pairs {
let (Some(ma), Some(mb), Some(ba), Some(bb)) = (meshes.get(*a), meshes.get(*b), self.bodies.get(*a), self.bodies.get(*b)) else {
return Err(limit("invalid contact geometry pair"));
};
let (aabb, bbb) = (ma.bounds(), mb.bounds());
if aabb.max.x < bbb.min.x
|| bbb.max.x < aabb.min.x
|| aabb.max.y < bbb.min.y
|| bbb.max.y < aabb.min.y
|| aabb.max.z < bbb.min.z
|| bbb.max.z < aabb.min.z
{
continue;
}
let cost = ma
.positions
.len()
.saturating_add(ma.triangles.len().saturating_mul(4))
.saturating_mul(mb.triangles.len())
.saturating_add(mb.positions.len().saturating_add(mb.triangles.len().saturating_mul(4)).saturating_mul(ma.triangles.len()));
*work = work.saturating_add(cost);
if *work > MAX_WORK {
return Err(limit("motion exceeds contact sweep collision budget; simplify the contact set or use smaller increments"));
}
if let Some(depth) = super::mesh_overlap(ma, mb, 1e-4 * ba.size.max(bb.size).max(1.0)) {
return Ok(Some((aname.clone(), bname.clone(), depth)));
}
}
Ok(None)
}
}
#[cfg(test)]
#[path = "contact_sweep_tests.rs"]
mod tests;
@@ -0,0 +1,170 @@
use crate::Session;
use serde_json::{Value, json};
use solvecraft_geom::Vec3;
fn run(s: &mut Session, command: &str, p: Value) -> Value {
s.execute(command, &p).unwrap()
}
fn occurrence(s: &Session, name: &str) -> u64 {
s.doc.occurrence_of(s.doc.find_component(name).unwrap()).unwrap().id
}
fn slider() -> Session {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": 0}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
}
let a = occurrence(&s, "A");
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
s
}
fn rail_value(s: &Session) -> f64 {
s.doc.assembly.joints.iter().find(|j| j.name == "Rail").unwrap().values[0]
}
#[test]
fn slider_cannot_tunnel_to_a_collision_free_endpoint() {
let mut s = slider();
let result = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -30}));
assert!(result["stopped_by_contact"].is_object(), "{result}");
assert!(rail_value(&s).abs() < 2e-3, "{}", rail_value(&s));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
assert!((rail_value(&s) - 10.0).abs() < 1e-6, "can move away from contact");
run(&mut s, "contact.disable_all", json!({}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -100000}));
assert!((rail_value(&s) + 100000.0).abs() < 1e-6, "disabled contact skips sweep budget");
}
#[test]
fn revolute_cannot_cross_a_blocker_between_clear_endpoints() {
let mut s = Session::default();
for (name, corner) in [("Arm", [20, -2, -2]), ("Blocker", [-2, 20, -2])] {
run(&mut s, "component.activate", json!({"component": 0}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": corner}));
}
run(
&mut s,
"joint.create",
json!({"type": "revolute", "a": {"occurrence": 0, "point": [0,0,0]}, "b": {"occurrence": "Arm", "point": [0,0,0]}, "flip": true, "name": "Pivot"}),
);
run(&mut s, "contact.create", json!({"occurrences": ["Arm", "Blocker"]}));
let result = run(&mut s, "joint.drive", json!({"joint": "Pivot", "value": "180 deg"}));
assert!(result["stopped_by_contact"].is_object(), "{result}");
let angle = s.doc.assembly.joints.iter().find(|j| j.name == "Pivot").unwrap().values[0];
assert!(angle > 60_f64.to_radians() && angle < 90_f64.to_radians(), "angle={angle}");
let arm = solvecraft_doc::apply_point(&solvecraft_doc::joints::occurrence_world(&s.doc, occurrence(&s, "Arm")), Vec3::new(22.0, 0.0, 0.0));
assert!(arm.y > 20.0 && arm.x > 2.0, "{arm:?}");
}
#[test]
fn linked_slider_uses_effective_travel_for_contact() {
let mut s = slider();
run(&mut s, "component.activate", json!({"component": 0}));
run(&mut s, "component.create", json!({"name": "Input"}));
run(
&mut s,
"joint.create",
json!({"type": "slider", "a": {"occurrence": 0, "point": [0,0,0]}, "b": {"occurrence": "Input", "point": [0,0,0]}, "flip": true, "name": "Input Joint"}),
);
run(&mut s, "joint.drive", json!({"joint": "Input Joint", "value": 1}));
run(&mut s, "joint.motion_link", json!({"a": "Input Joint", "b": "Rail", "ratio": 10}));
let result = run(&mut s, "joint.drive", json!({"joint": "Input Joint", "value": -3}));
assert!(result["stopped_by_contact"].is_object(), "{result}");
let input = s.doc.assembly.joints.iter().find(|j| j.name == "Input Joint").unwrap().values[0];
assert!(input.abs() < 2e-4, "{input}");
}
#[test]
fn motion_play_and_sparse_export_share_swept_contact_checks() {
let mut s = slider();
run(&mut s, "motion.study", json!({"name": "Crossing", "steps": 100, "keys": [{"joint": "Rail", "points": [[0,10], [100,-30]]}]}));
let doc = s.doc.clone();
let exported = run(&mut s, "motion.export", json!({"study": "Crossing", "samples": 2}));
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc), "export leaves document untouched");
assert!(exported["samples"][1]["stopped_by_contact"].is_object(), "{exported}");
let played = run(&mut s, "motion.play", json!({"study": "Crossing", "step": 100}));
assert!(played["stopped_by_contact"].is_object(), "{played}");
assert!(rail_value(&s).abs() < 2e-3);
}
#[test]
fn over_budget_sweep_errors_and_restores_document() {
let mut s = slider();
let doc = s.doc.clone();
let error = s.execute("joint.drive", &json!({"joint": "Rail", "value": -100000})).unwrap_err();
assert!(error.to_string().contains("sampling budget"), "{error}");
assert_eq!(*s.doc, *doc);
assert!((rail_value(&s) - 10.0).abs() < 1e-6);
}
#[test]
fn thin_tilted_plates_cannot_cross_between_axis_bound_samples() {
let mut s = Session::default();
let normal = solvecraft_doc::apply_vector(&solvecraft_doc::rigid(Vec3::ZERO, Vec3::ZERO, Vec3::new(1.0, 1.0, 0.0), 55_f64.to_radians()), Vec3::Z);
for name in ["Plate A", "Plate B"] {
run(&mut s, "component.activate", json!({"component": 0}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 0.05, "corner": [-5,-5,-0.025], "body_name": name}));
run(&mut s, "solid.move", json!({"bodies": [name], "axis": [1,1,0], "angle": "55 deg"}));
}
let (a, b) = (occurrence(&s, "Plate A"), occurrence(&s, "Plate B"));
run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": normal * 2.0}));
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
run(
&mut s,
"joint.as_built",
json!({"type": "slider", "a": "Plate A", "b": "Plate B", "at": {"occurrence": 0, "point": [0,0,0], "z": normal}, "name": "Slide"}),
);
run(&mut s, "contact.create", json!({"occurrences": ["Plate A", "Plate B"]}));
let result = run(&mut s, "joint.drive", json!({"joint": "Slide", "value": -4}));
assert!(result["stopped_by_contact"].is_object(), "{result}");
let value = s.doc.assembly.joints.iter().find(|j| j.name == "Slide").unwrap().values[0];
assert!(value > -2.0 && value < -1.9, "{value}");
}
#[test]
fn ordinary_curved_bodies_keep_a_practical_sampling_step() {
for curved in ["sphere", "fillet"] {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Blocker"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0,-5,-5]}));
run(&mut s, "component.activate", json!({"component": 0}));
run(&mut s, "component.create", json!({"name": "Curved"}));
if curved == "sphere" {
run(&mut s, "solid.sphere", json!({"radius": 5, "center": [20,0,0]}));
} else {
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [15,-5,-5]}));
run(&mut s, "solid.fillet", json!({"edges": [[15,-5,0]], "radius": 2}));
}
run(
&mut s,
"joint.as_built",
json!({"type": "slider", "a": 0, "b": "Curved", "at": {"occurrence": 0, "point": [0,0,0], "z": [1,0,0]}, "name": "Slide"}),
);
run(&mut s, "contact.create", json!({"occurrences": ["Blocker", "Curved"]}));
let geometry = super::ContactGeometry::new(&s).unwrap();
assert!(geometry.step > 1.0, "{curved}: tessellation must not force tiny steps: {}", geometry.step);
let result = run(&mut s, "joint.drive", json!({"joint": "Slide", "value": -30}));
assert!(result["stopped_by_contact"].is_object(), "{curved}: {result}");
let value = s.doc.assembly.joints.iter().find(|j| j.name == "Slide").unwrap().values[0];
assert!(value > -5.02 && value < -4.9, "{curved}: {value}");
}
}
#[test]
fn initially_overlapping_assembly_can_move_out_for_recovery() {
let mut s = slider();
run(&mut s, "contact.disable_all", json!({}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
run(&mut s, "contact.enable_sets", json!({"enabled": true}));
assert!(!run(&mut s, "contact.check", json!({}))["collisions"].as_array().unwrap().is_empty());
let result = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
assert!(result["stopped_by_contact"].is_null(), "recovery must not stick: {result}");
assert!((rail_value(&s) - 10.0).abs() < 1e-6);
assert!(run(&mut s, "contact.check", json!({}))["collisions"].as_array().unwrap().is_empty());
}
+95 -16
View File
@@ -296,36 +296,111 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"feature": id, "position": to}))
}
fn input_keys(kind: &FeatureKind) -> Vec<(String, solvecraft_doc::expr::Kind)> {
let mut keys: Vec<_> = kind.inputs().into_iter().map(|(label, _, unit)| (label.to_string(), unit)).collect();
if let FeatureKind::Fillet { style, .. } = kind {
if let Some((key, _)) = keys.first_mut() {
*key = "fillet.radius".into();
}
if let solvecraft_doc::FilletStyle::Variable { mid, .. } = style {
for ((key, _), (position, _)) in keys.iter_mut().skip(2).zip(mid) {
*key = format!("fillet.mid.{}", position.to_bits());
}
}
}
keys
}
fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.redefine";
let id = feature_id(s, p.get("feature"), cmd)?;
let cid = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name the feature's command"))?;
let spec = super::find_command(cid).ok_or_else(|| bad(cmd, format!("no command `{cid}`")))?;
if spec.id.starts_with("timeline.") || spec.id.starts_with("select.") || spec.id.starts_with("sketch.") {
// Only modeling constructors may run here. In particular, a scratch session does not
// isolate filesystem writes performed by export commands.
let constructor = super::features::COMMANDS
.iter()
.chain(super::features_more::COMMANDS)
.chain(super::construct::COMMANDS)
.chain(super::surface::COMMANDS)
.any(|candidate| candidate.id == spec.id)
|| matches!(
spec.id,
"sheet.flange"
| "sheet.hem"
| "sheet.fold"
| "sheet.unfold"
| "sheet.refold"
| "sheet.convert"
| "plastic.boss"
| "plastic.lip"
| "plastic.snap_fit"
| "plastic.rest"
);
if !constructor {
return Err(bad(cmd, format!("`{cid}` does not make a feature")));
}
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
let mut params = p.get("params").cloned().unwrap_or_else(|| json!({}));
let idx = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
let old = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
let marker = s.doc.marker;
// Build the new definition where the feature sits, against the model before it.
s.doc_mut().marker = Some(idx);
s.refresh();
let r = (spec.run)(s, &params);
let made = r.as_ref().ok().and_then(|v| v.get("feature")).and_then(Value::as_u64);
let new = made.and_then(|nid| s.doc.feature(nid).cloned());
if let Some(nid) = made {
s.doc_mut().features.retain(|f| f.id != nid);
if !old.participants.is_empty()
&& let Some(parameters) = params.as_object_mut()
{
parameters.entry("participants").or_insert_with(|| json!(old.participants));
}
s.doc_mut().marker = marker;
r?;
let new = new.ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
// Run the constructor on a scratch session: failed edits must not disturb sketch mode,
// selections, component authoring state or the original feature's definition.
let mut trial = s.scratch();
trial.active_component = old.component;
trial.active_sketch = None;
trial.selection = s.selection.clone();
trial.doc_mut().marker = Some(idx);
trial.refresh();
(spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
let r = (spec.run)(&mut trial, &params)?;
let made = r.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
let new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) {
return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid)));
}
if let Some(slot) = s.doc_mut().feature_mut(id) {
slot.kind = new.kind;
// Optional inputs can move other inputs' positions (e.g. Distance 2 goes before Taper).
// Keep each existing model parameter attached to its semantic input, rather than its
// old index. Constructor-assigned names for new inputs cannot reuse the old dimensions.
let mut old_inputs: Vec<_> = input_keys(&old.kind).into_iter().zip(&old.param_names).collect();
let param_names: Vec<String> = input_keys(&new.kind)
.iter()
.enumerate()
.map(|(i, (label, kind))| {
if let Some(k) = old_inputs.iter().position(|((old_label, old_kind), _)| old_label == label && old_kind == kind) {
old_inputs.remove(k).1.clone()
} else {
new.param_names.get(i).cloned().unwrap_or_default()
}
})
.collect();
trial.doc_mut().features.retain(|f| f.id != made);
let added = trial.doc.features.len().saturating_sub(s.doc.features.len());
trial.doc_mut().marker = s.doc.marker.map(|marker| if marker > idx { marker.saturating_add(added) } else { marker });
if made == s.doc.next_id && trial.doc.next_id == made.saturating_add(1) {
trial.doc_mut().next_id = s.doc.next_id;
}
if let Some(slot) = trial.doc_mut().feature_mut(id) {
slot.kind = new.kind;
slot.edge_refs = new.edge_refs;
slot.edge_names = new.edge_names;
slot.face_names = new.face_names;
slot.param_names = param_names;
if params.get("participants").is_some() {
slot.participants = new.participants;
}
}
// New optional inputs need model parameter names, while existing dimensions keep theirs.
trial.doc_mut().name_feature_inputs(id);
trial.refresh();
if let Some(e) = trial.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
s.doc = trial.doc;
s.refresh();
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
@@ -344,3 +419,7 @@ fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
let broken: Vec<String> = newly_broken(&s.model, &after).into_iter().map(|(n, _)| n).collect();
Ok(json!({"deleted_with_it": names(&gone), "would_fail": broken}))
}
#[cfg(test)]
#[path = "edit_tests.rs"]
mod tests;
+268
View File
@@ -0,0 +1,268 @@
//! Edit Feature must replace the selections as well as the numerical definition.
use serde_json::{Value, json};
use crate::{Sel, Session};
fn run(s: &mut Session, id: &str, params: Value) -> Value {
s.execute(id, &params).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
}
#[test]
fn redefining_a_blend_replaces_persistent_edge_selection_and_survives_resize() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 20]], "radius": 2, "name": "Round"}));
let round_id = s.doc.find_feature("Round").unwrap().id;
let body_name = s.model.state().bodies[0].name.clone();
s.doc_mut().feature_mut(round_id).unwrap().participants = vec![body_name];
s.refresh();
let old = s.doc.find_feature("Round").unwrap().clone();
let before = (*s.doc).clone();
run(&mut s, "timeline.redefine", json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[0, 15, 20]], "radius": 2}}));
let edited = s.doc.find_feature("Round").unwrap().clone();
assert_eq!(edited.id, old.id);
assert_eq!(edited.param_names, old.param_names);
assert_eq!(edited.participants, old.participants);
assert_ne!(edited.edge_names, old.edge_names, "the new edge must be the reference used on recompute");
assert_ne!(edited.edge_refs, old.edge_refs);
assert_eq!(s.doc.next_id, before.next_id, "temporary definitions must not consume feature ids");
run(&mut s, "edit.undo", json!({}));
assert_eq!(*s.doc, before);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.find_feature("Round").unwrap().edge_names, edited.edge_names);
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"length": "60", "height": "25"}}));
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let expected = 60.0 * 30.0 * 25.0 - (4.0 - std::f64::consts::PI) * 30.0;
assert!((volume - expected).abs() < expected * 1e-4, "{volume} != {expected}");
assert!(s.model.result(old.id).unwrap().warning.is_none());
}
#[test]
fn redefining_preserves_body_names_participants_and_marker_without_temporary_features() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5, "name": "Base", "body_name": "Housing"}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [30, 0, 0], "name": "Other"}));
let id = s.doc.find_feature("Base").unwrap().id;
run(&mut s, "timeline.rollback", json!({"position": 1}));
let before = s.doc.feature(id).unwrap().clone();
run(&mut s, "timeline.redefine", json!({"feature": id, "command": "solid.box", "params": {"length": 25, "width": 10, "height": 5}}));
let after = s.doc.feature(id).unwrap();
assert_eq!(s.doc.features.len(), 2);
assert_eq!(s.doc.marker, Some(1));
assert_eq!(after.body_names, before.body_names);
assert_eq!(after.participants, before.participants);
assert_eq!(after.param_names, before.param_names);
assert_eq!(after.name, before.name);
assert!(s.model.state().body("Housing").is_some());
}
#[test]
fn failed_or_non_feature_redefinitions_leave_session_and_history_intact() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5, "name": "Base"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s.selection = vec![Sel::Feature { id: s.doc.find_feature("Base").unwrap().id }];
let doc = s.doc.clone();
let sketch = s.active_sketch;
let selection = s.selection.clone();
let undo = s.undo.len();
for (command, params) in [
("solid.cylinder", json!({"radius": 5, "height": 10})),
("solid.box", json!({"length": "unknown_parameter", "width": 10, "height": 5})),
("file.export", json!({"path": "should-not-be-created.step"})),
("edit.undo", json!({})),
] {
assert!(s.execute("timeline.redefine", &json!({"feature": "Base", "command": command, "params": params})).is_err());
assert_eq!(*s.doc, *doc, "{command}");
assert_eq!(s.active_sketch, sketch, "{command}");
assert_eq!(s.selection, selection, "{command}");
assert_eq!(s.undo.len(), undo, "{command}");
}
}
#[test]
fn redefining_an_extrude_names_new_optional_inputs() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5, "name": "Pad"}));
let distance = s.doc.find_feature("Pad").unwrap().param_names[0].clone();
run(&mut s, "timeline.redefine", json!({"feature": "Pad", "command": "solid.extrude", "params": {"distance": 5, "start_offset": 2}}));
let f = s.doc.find_feature("Pad").unwrap();
assert_eq!(f.param_names.len(), f.kind.inputs().len());
assert_eq!(f.param_names[0], distance);
assert!(f.param_names.iter().all(|name| !name.is_empty()));
assert_eq!(s.doc.features.len(), 2);
let offset = s.doc.find_feature("Pad").unwrap().param_names.last().unwrap().clone();
run(
&mut s,
"timeline.redefine",
json!({"feature": "Pad", "command": "solid.extrude", "params": {"distance": 5, "distance2": 2, "start_offset": 2}}),
);
let f = s.doc.find_feature("Pad").unwrap();
let inputs = f.kind.inputs();
let offset_index = inputs.iter().position(|(label, _, _)| *label == "Start offset").unwrap();
assert_eq!(f.param_names[offset_index], offset, "adding Distance 2 must not steal the offset dimension's parameter name");
let distance2_index = inputs.iter().position(|(label, _, _)| *label == "Distance 2").unwrap();
assert_ne!(f.param_names[distance2_index], offset);
}
#[test]
fn redefining_a_shell_replaces_persistent_face_selection() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
run(&mut s, "solid.shell", json!({"faces": [[20, 15, 20]], "thickness": 2, "name": "Hollow"}));
let before = s.doc.find_feature("Hollow").unwrap().face_names.clone();
assert!(!before.is_empty());
run(&mut s, "timeline.redefine", json!({"feature": "Hollow", "command": "solid.shell", "params": {"faces": [[20, 15, 0]], "thickness": 2}}));
let after = s.doc.find_feature("Hollow").unwrap().face_names.clone();
assert!(!after.is_empty());
assert_ne!(before, after);
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "30"}}));
let id = s.doc.find_feature("Hollow").unwrap().id;
assert!(s.model.result(id).unwrap().error.is_none());
assert!(s.model.result(id).unwrap().warning.is_none());
assert_eq!(s.doc.find_feature("Hollow").unwrap().face_names, after);
}
#[test]
fn redefine_rejects_sheet_exports_before_they_touch_any_files() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"name": "Plate"}));
let doc = s.doc.clone();
let base = std::env::temp_dir().join(format!("solvecraft-redefine-export-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
for command in ["sheet.export_dxf", "sheet.export_bend_table"] {
for existing in [false, true] {
let path = base.join(format!("{}-{existing}.out", command.replace('.', "-")));
if existing {
std::fs::write(&path, b"original file").unwrap();
} else if path.exists() {
std::fs::remove_file(&path).unwrap();
}
let error = s.execute("timeline.redefine", &json!({"feature": "Plate", "command": command, "params": {"path": path}})).unwrap_err();
assert!(error.to_string().contains("does not make a feature"), "{error}");
assert_eq!(*s.doc, *doc);
if existing {
assert_eq!(std::fs::read(&path).unwrap(), b"original file");
std::fs::remove_file(&path).unwrap();
} else {
assert!(!path.exists(), "{command} must not create a file before refusing the edit");
}
}
}
for command in ["sheet.manage_rules", "sheet.rules", "sheet.flat_pattern", "parameters.list", "plastic.manage_rules"] {
let error = s.execute("timeline.redefine", &json!({"feature": "Plate", "command": command, "params": {"name": "unexpected"}})).unwrap_err();
assert!(error.to_string().contains("does not make a feature"), "{error}");
assert_eq!(*s.doc, *doc);
}
std::fs::remove_dir(&base).unwrap();
}
#[test]
fn redefine_keeps_allocator_above_component_and_occurrence_ids() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "body_name": "Base"}));
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [10, 0, 0], "body_name": "Tool"}));
run(&mut s, "solid.combine", json!({"target": "Base", "tools": ["Tool"], "name": "Join"}));
run(
&mut s,
"timeline.redefine",
json!({"feature": "Join", "command": "solid.combine", "params": {"target": "Base", "tools": ["Tool"], "new_component": true, "component_name": "Merged"}}),
);
let max_id =
s.doc.components.iter().map(|c| c.id).chain(s.doc.occurrences.iter().map(|o| o.id)).chain(s.doc.features.iter().map(|f| f.id)).max().unwrap();
assert!(s.doc.next_id > max_id);
run(&mut s, "component.create", json!({"name": "Next", "parent": 0}));
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2}));
let ids: Vec<u64> =
s.doc.components.iter().map(|c| c.id).chain(s.doc.occurrences.iter().map(|o| o.id)).chain(s.doc.features.iter().map(|f| f.id)).collect();
let unique: std::collections::BTreeSet<u64> = ids.iter().copied().collect();
assert_eq!(ids.len(), unique.len(), "feature, component and occurrence ids must remain unique: {ids:?}");
}
#[test]
fn redefine_maps_placement_inputs_in_the_feature_owner_frame() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "name": "RootBox", "body_name": "RootBody"}));
let owner = run(&mut s, "component.create", json!({"name": "Owner"}));
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [10, 0, 0], "name": "OwnedBox", "body_name": "OwnedBody"}));
run(&mut s, "solid.fillet", json!({"edges": [[12, 0, 4]], "radius": 0.5, "body": "OwnedBody", "name": "OwnerRound"}));
run(&mut s, "occurrence.move", json!({"occurrence": owner["occurrence"], "translate": [100, 0, 0]}));
let other = run(&mut s, "component.create", json!({"name": "Other", "parent": 0}));
run(&mut s, "occurrence.move", json!({"occurrence": other["occurrence"], "translate": [200, 0, 0]}));
let active = s.active_component;
run(
&mut s,
"timeline.redefine",
json!({"feature": "RootBox", "command": "solid.box", "params": {"length": 4, "width": 4, "height": 4, "corner": [2, 0, 0]}}),
);
run(
&mut s,
"timeline.redefine",
json!({"feature": "OwnedBox", "command": "solid.box", "params": {"length": 4, "width": 4, "height": 4, "corner": [110, 0, 0]}}),
);
run(
&mut s,
"timeline.redefine",
json!({"feature": "OwnerRound", "command": "solid.fillet", "params": {"edges": [[112, 0, 4]], "radius": 0.75, "body": "OwnedBody"}}),
);
assert_eq!(s.active_component, active, "editing must not activate the owner in the real session");
assert_eq!(s.doc.find_feature("OwnedBox").unwrap().component, owner["component"].as_u64().unwrap());
let root = run(&mut s, "inspect.measure", json!({"bodies": ["RootBody"]}));
let owned = run(&mut s, "inspect.measure", json!({"bodies": ["OwnedBody"]}));
assert!((root["bodies"][0]["bbox"]["min"][0].as_f64().unwrap() - 2.0).abs() < 1e-8);
assert!((owned["bodies"][0]["bbox"]["min"][0].as_f64().unwrap() - 110.0).abs() < 1e-8);
}
#[test]
fn redefining_fillet_style_keeps_primary_parameter_and_downstream_references() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30}));
run(&mut s, "solid.fillet", json!({"edges": [[10, 10, 30]], "radius": 2, "name": "Round"}));
let parameter = s.doc.find_feature("Round").unwrap().param_names[0].clone();
run(&mut s, "solid.box", json!({"length": parameter, "width": 2, "height": 2, "corner": [50, 0, 0], "name": "Dependent"}));
run(
&mut s,
"timeline.redefine",
json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[10, 10, 30]], "radius": 3, "type": "chord"}}),
);
assert_eq!(s.doc.find_feature("Round").unwrap().param_names[0], parameter);
let dependent = s.doc.find_feature("Dependent").unwrap().id;
assert!(s.model.result(dependent).unwrap().error.is_none());
let value = run(&mut s, "expr.evaluate", json!({"expression": parameter}));
assert_eq!(value["value"], 3.0);
}
#[test]
fn inserting_variable_fillet_control_preserves_parameters_at_existing_positions() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30}));
run(
&mut s,
"solid.fillet",
json!({"edges": [[10, 10, 30]], "radius": 2, "radius2": 2, "type": "variable", "start": [10, 0, 30], "mid": [[0.25, 3], [0.75, 4]], "name": "Round"}),
);
let parameters = s.doc.find_feature("Round").unwrap().param_names.clone();
run(&mut s, "solid.box", json!({"length": parameters[2], "width": parameters[3], "height": 2, "corner": [50, 0, 0], "name": "Dependent"}));
run(
&mut s,
"timeline.redefine",
json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[10, 10, 30]], "radius": 2, "radius2": 2, "type": "variable", "start": [10, 0, 30], "mid": [[0.1, 2.5], [0.25, 3], [0.75, 4]]}}),
);
let edited = s.doc.find_feature("Round").unwrap();
assert_eq!(edited.param_names[0..2], parameters[0..2]);
assert_eq!(edited.param_names[3..5], parameters[2..4]);
assert!(!parameters.contains(&edited.param_names[2]), "the inserted control needs a new dimension");
let dependent = s.doc.find_feature("Dependent").unwrap().id;
assert!(s.model.result(dependent).unwrap().error.is_none());
let length = run(&mut s, "expr.evaluate", json!({"expression": parameters[2]}));
let width = run(&mut s, "expr.evaluate", json!({"expression": parameters[3]}));
assert_eq!(length["value"], 3.0);
assert_eq!(width["value"], 4.0);
}
+49 -16
View File
@@ -12,6 +12,9 @@ use super::joints::{joint_id, occurrence_id, resolve, value};
use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
#[path = "contact_sweep.rs"]
mod contact_sweep;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params(
"enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other",
@@ -87,7 +90,11 @@ fn placed(s: &Session) -> Vec<(Vec<u64>, Body)> {
continue;
}
for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp && !b.body.is_mesh()) {
if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, m) {
// Moving a body into another component preserves its world placement using an
// offset from its authoring frame. Contact and explode centres must use that
// same offset as display and physical properties.
let frame = s.doc.body_offsets.get(&b.name).map(|offset| solvecraft_doc::mat_mul(&m, offset)).unwrap_or(m);
if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, frame) {
out.push((path.clone(), t));
}
}
@@ -131,8 +138,11 @@ pub fn collisions(s: &Session) -> Vec<(String, String, f64)> {
/// tested against the other closed mesh: inside by ray parity, and deeper than the tolerance.
fn overlap(a: &Body, b: &Body) -> Option<f64> {
let (ma, mb) = (a.tessellate((a.size() * 1e-3).max(1e-3)).ok()?, b.tessellate((b.size() * 1e-3).max(1e-3)).ok()?);
mesh_overlap(&ma, &mb, 1e-4 * a.size().max(b.size()).max(1.0))
}
fn mesh_overlap(ma: &solvecraft_geom::Mesh, mb: &solvecraft_geom::Mesh, tol: f64) -> Option<f64> {
let (ba, bb) = (ma.bounds(), mb.bounds());
let tol = 1e-4 * a.size().max(b.size()).max(1.0);
if ba.max.x < bb.min.x + tol
|| bb.max.x < ba.min.x + tol
|| ba.max.y < bb.min.y + tol
@@ -142,7 +152,7 @@ fn overlap(a: &Body, b: &Body) -> Option<f64> {
{
return None;
}
let depth = deepest(&ma, &mb, tol).max(deepest(&mb, &ma, tol));
let depth = deepest(ma, mb, tol).max(deepest(mb, ma, tol));
(depth > tol).then_some(depth)
}
@@ -247,9 +257,12 @@ fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
/// Set joint values, stopping short where contact sets would collide: the values are moved
/// from where they are toward `want` as far as they go without a collision.
fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)]) -> Result<Value> {
let baseline = s.doc.clone();
let start: Vec<(u64, Vec<f64>)> =
want.iter().map(|(id, _)| (*id, s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone()).unwrap_or_default())).collect();
let set = |s: &mut Session, t: f64| {
// Trial order must not affect free anchors or linked joint placements.
s.doc = baseline.clone();
let doc = s.doc_mut();
for ((id, a), (_, b)) in start.iter().zip(want) {
if let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == *id) {
@@ -260,25 +273,45 @@ fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)]) -> Result<Value
}
resolve(s)
};
let mut sol = set(s, 1.0);
let cache = if s.doc.assembly.contact_enabled { Some(contact_sweep::ContactGeometry::new(s)?) } else { None };
let sol;
let mut stopped = None;
if s.doc.assembly.contact_enabled
&& let Some(hit) = collisions(s).into_iter().next()
{
// Already colliding where it started: don't search, just report.
if let Some(cache) = cache.filter(|cache| cache.has_pairs()) {
let mut end = s.scratch();
set(&mut end, 1.0);
let samples = cache.samples(s, &end)?;
let mut work = 0;
set(s, 0.0);
if collisions(s).is_empty() {
let (mut lo, mut hi) = (0.0, 1.0);
for _ in 0..14 {
let mid = (lo + hi) / 2.0;
set(s, mid);
if collisions(s).is_empty() { lo = mid } else { hi = mid }
// Keep the existing recovery behaviour: an initially overlapping assembly may move
// out of its invalid placement. Sweeps constrain motions that start collision-free.
if cache.first_hit(s, &mut work)?.is_none() {
let mut lo = 0.0;
let mut bracket = None;
for sample in 1..=samples {
let hi = sample as f64 / samples as f64;
set(s, hi);
if let Some(hit) = cache.first_hit(s, &mut work)? {
bracket = Some((lo, hi, hit));
break;
}
lo = hi;
}
if let Some((mut lo, mut hi, hit)) = bracket {
for _ in 0..14 {
let mid = (lo + hi) / 2.0;
set(s, mid);
if cache.first_hit(s, &mut work)?.is_none() { lo = mid } else { hi = mid }
}
sol = set(s, lo);
stopped = Some(json!({"a": hit.0, "b": hit.1, "fraction": lo}));
} else {
sol = set(s, 1.0);
}
sol = set(s, lo);
stopped = Some(json!({"a": hit.0, "b": hit.1, "fraction": lo}));
} else {
sol = set(s, 1.0);
}
} else {
sol = set(s, 1.0);
}
let values: Vec<Value> = want
.iter()
+34
View File
@@ -71,6 +71,40 @@ fn contact_sets_stop_a_driven_joint_at_touching() {
assert!(s.execute("contact.create", &json!({"occurrences": ["A"]})).is_err());
}
#[test]
fn contact_uses_preserved_body_placements_after_component_reassignment() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Transferred"}));
let component = run(&mut s, "component.create", json!({"name": "Carrier"}));
run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "translate": [100, 0, 0]}));
run(&mut s, "component.move_bodies", json!({"bodies": ["Transferred"], "component": component["component"]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let probe = run(&mut s, "component.create", json!({"name": "Probe"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [5, 0, 0]}));
run(&mut s, "contact.create", json!({"occurrences": [component["occurrence"], probe["occurrence"]]}));
let collisions = run(&mut s, "contact.check", json!({}));
assert_eq!(collisions["collisions"].as_array().map(Vec::len), Some(1), "body stayed at x=0: {collisions}");
run(&mut s, "occurrence.move", json!({"occurrence": probe["occurrence"], "translate": [100, 0, 0]}));
let collisions = run(&mut s, "contact.check", json!({}));
assert_eq!(collisions["collisions"].as_array().map(Vec::len), Some(0), "probe moved away: {collisions}");
}
#[test]
fn automatic_explode_centres_follow_reassigned_bodies() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Transferred"}));
let component = run(&mut s, "component.create", json!({"name": "Carrier"}));
run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "translate": [100, 0, 0]}));
run(&mut s, "component.move_bodies", json!({"bodies": ["Transferred"], "component": component["component"]}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Neighbour"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
let exploded = run(&mut s, "explode.create", json!({"scale": 1}));
let moves = exploded["moves"].as_array().unwrap();
let carrier = moves.iter().find(|m| m["occurrence"] == component["occurrence"]).unwrap();
assert!((carrier["translate"][0].as_f64().unwrap() + 10.0).abs() < 1e-6, "{exploded}");
}
#[test]
fn motion_study_plays_and_exports() {
let mut s = rail();
+62 -5
View File
@@ -45,6 +45,9 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf)
.noundo()
.params("body?, path? → DXF (mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"),
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table)
.noundo()
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates"),
];
fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
@@ -142,8 +145,9 @@ fn fold(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.fold";
let (sketch, curve, a, b) = match p.get("points") {
Some(v) => {
let pts = v.as_array().map(|a| a.iter().filter_map(vec3).collect::<Vec<Vec3>>()).unwrap_or_default();
let [a, b] = pts[..] else { return Err(bad(cmd, "`points` must be two points [x, y, z]")) };
let pts = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`points` must be two points [x, y, z]"))?;
let a = pts.first().and_then(vec3).ok_or_else(|| bad(cmd, "`points` must be two finite points [x, y, z]"))?;
let b = pts.get(1).and_then(vec3).ok_or_else(|| bad(cmd, "`points` must be two finite points [x, y, z]"))?;
(None, None, a, b)
}
None => {
@@ -306,6 +310,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
w(2, "TABLES");
w(0, "TABLE");
w(2, "LAYER");
w(70, "3");
for (name, colour) in [("OUTLINE", "7"), ("BEND_UP", "1"), ("BEND_DOWN", "5")] {
w(0, "LAYER");
w(2, name);
@@ -338,17 +343,25 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
for seg in &h.outer.segs {
match *seg {
solvecraft_geom::Seg2::Arc { center, radius, start, sweep } => {
if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9 {
w(0, "CIRCLE");
w(8, "OUTLINE");
w(10, &f(center.x));
w(20, &f(center.y));
w(40, &f(radius));
continue;
}
let (a0, a1) = if sweep >= 0.0 { (start, start + sweep) } else { (start + sweep, start) };
w(0, "ARC");
w(8, "OUTLINE");
w(10, &f(center.x));
w(20, &f(center.y));
w(40, &f(radius));
w(50, &f(a0.to_degrees()));
w(51, &f(a1.to_degrees()));
w(50, &f(a0.to_degrees().rem_euclid(360.0)));
w(51, &f(a1.to_degrees().rem_euclid(360.0)));
}
other => {
let pts: Vec<Vec2> = (0..=8).map(|k| other.point_at(k as f64 / 8.0)).collect();
let pts = other.polyline(1e-3);
for p in pts.windows(2) {
line(&mut w, "OUTLINE", p[0], p[1]);
}
@@ -375,6 +388,50 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"dxf": text, "bends": sh.bends().len()}))
}
fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.export_bend_table";
let format = str_(p, "format").unwrap_or("csv").to_ascii_lowercase();
if !matches!(format.as_str(), "csv" | "json") {
return Err(bad(cmd, "`format` must be csv or json"));
}
let sh = sheet_of(s, p, cmd)?;
let table = flat_json(&sh)?;
let text = if format == "json" {
serde_json::to_string_pretty(&table).map_err(|e| EngineError::Other(e.to_string()))?
} else {
// Always quote text fields: body/rule names may include commas, quotes or newlines.
let quote = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
let mut csv =
"body,rule,thickness_mm,k_factor,bend,angle_deg,direction,radius_mm,allowance_mm,length_mm,start_x_mm,start_y_mm,end_x_mm,end_y_mm\r\n"
.to_string();
for b in sh.bends() {
csv.push_str(&format!(
"{},{},{:.9},{:.9},{},{:.9},{},{:.9},{:.9},{:.9},{:.9},{:.9},{:.9},{:.9}\r\n",
quote(&sh.body),
quote(&sh.rule),
sh.t,
sh.k,
b.index,
b.angle_deg,
if b.up { "up" } else { "down" },
b.radius,
b.allowance,
b.length,
b.start.x,
b.start.y,
b.end.x,
b.end.y
));
}
csv
};
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "format": format, "bends": sh.bends().len()}));
}
Ok(json!({"format": format, "text": text, "bends": sh.bends().len()}))
}
#[cfg(test)]
#[path = "sheet_tests.rs"]
mod tests;
+77
View File
@@ -344,3 +344,80 @@ fn fold_keeps_the_larger_side_either_way() {
}
}
}
#[test]
fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() {
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "Shop, \"steel\"", "thickness": 2, "bend_radius": 3, "k_factor": 0.4}));
base(&mut s, 100.0, 60.0);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20}));
let undo = s.undo.len();
let csv = run(&mut s, "sheet.export_bend_table", json!({}));
let text = csv["text"].as_str().unwrap();
assert!(text.starts_with("body,rule,thickness_mm,k_factor,bend,angle_deg,direction,"));
assert!(text.contains("\"Shop, \"\"steel\"\"\""), "{text}");
assert_eq!(text.lines().count(), 2);
assert_eq!(csv["bends"], 1);
let exported = run(&mut s, "sheet.export_bend_table", json!({"format": "JSON"}));
let table: Value = serde_json::from_str(exported["text"].as_str().unwrap()).unwrap();
assert_eq!(table["thickness"], 2.0);
assert_eq!(table["bends"][0]["direction"], "up");
assert!((table["bends"][0]["allowance"].as_f64().unwrap() - FRAC_PI_2 * 3.8).abs() < 1e-9);
assert_eq!(s.undo.len(), undo, "export does not create an undo step");
run(&mut s, "sheet.manage_rules", json!({"name": "Shop, \"steel\"", "k_factor": 0.5}));
let updated = run(&mut s, "sheet.export_bend_table", json!({"format": "json"}));
let table: Value = serde_json::from_str(updated["text"].as_str().unwrap()).unwrap();
assert!((table["bends"][0]["allowance"].as_f64().unwrap() - FRAC_PI_2 * 4.0).abs() < 1e-9);
assert!(s.execute("sheet.export_bend_table", &json!({"format": "xml"})).is_err());
assert!(s.execute("sheet.export_bend_table", &json!({"body": "Missing"})).is_err());
let mut empty = Session::default();
assert!(empty.execute("sheet.export_bend_table", &json!({})).is_err());
}
#[test]
fn malformed_fold_points_are_rejected_atomically() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
let before = serde_json::to_value(&s.doc).unwrap();
for points in [
json!([[40, 0, 2.5], null, [40, 60, 2.5]]),
json!([[40, 0, 2.5], [40, 60, 2.5], "junk"]),
json!([[40, 0, 2.5], [40, 60]]),
json!([[40, 0, 2.5]]),
json!("not points"),
] {
assert!(s.execute("sheet.fold", &json!({"points": points})).is_err());
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
}
}
#[test]
fn flat_dxf_preserves_exact_cutout_entities() {
use solvecraft_geom::{Loop2, Region2, Seg2, Vec2};
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
let mut sh = s.model.state().sheets[0].clone();
sh.holes = vec![Region2 {
outer: Loop2 { segs: vec![Seg2::Arc { center: Vec2::new(40.0, 30.0), radius: 5.0, start: -PI, sweep: -2.0 * PI }] },
holes: Vec::new(),
}];
let dxf = super::flat_dxf(&sh).unwrap();
assert_eq!(dxf.matches("0\nCIRCLE\n").count(), 1, "{dxf}");
assert!(!dxf.contains("0\nARC\n"), "a full circle must not become a zero-length ARC");
let read = solvecraft_io::read_dxf(&dxf).unwrap();
assert_eq!(read.len(), 5);
assert!(read.iter().any(|g| matches!(g, solvecraft_io::Geom2::Circle(c, r) if *c == Vec2::new(40.0, 30.0) && (*r - 5.0).abs() < 1e-9)));
sh.holes[0].outer = Loop2 {
segs: vec![
Seg2::Line { a: Vec2::new(10.0, 10.0), b: Vec2::new(30.0, 10.0) },
Seg2::Arc { center: Vec2::new(20.0, 10.0), radius: 10.0, start: 0.0, sweep: PI },
],
};
let dxf = super::flat_dxf(&sh).unwrap();
assert_eq!(dxf.matches("0\nLINE\n").count(), 5, "four outline lines and one exact cutout line");
assert_eq!(dxf.matches("0\nARC\n").count(), 1);
assert!(dxf.contains("50\n0.000000\n51\n180.000000\n"));
let read = solvecraft_io::read_dxf(&dxf).unwrap();
assert_eq!(read.len(), 6);
assert!(read.iter().any(|g| matches!(g, solvecraft_io::Geom2::Arc { c, a, b } if *c == Vec2::new(20.0, 10.0) && a.dist(Vec2::new(30.0, 10.0)) < 1e-9 && b.dist(Vec2::new(10.0, 10.0)) < 1e-9)));
}
+44 -8
View File
@@ -12,7 +12,7 @@ use crate::params::bad;
/// Exact pieces of a free-form curve (polyline lines for the few kinds without them).
fn pieces(sk: &Sketch, ci: usize) -> Vec<Seg2> {
sk.exact_segs(ci).unwrap_or_else(|| sk.polyline(ci).windows(2).map(|w| Seg2::Line { a: w[0], b: w[1] }).collect())
sk.exact_segs(ci).unwrap_or_else(|| sk.segs(ci))
}
/// Position along the pieces (piece index + local parameter) closest to `p`, and the distance.
@@ -44,7 +44,7 @@ fn project(sk: &Sketch, ci: usize, p: Vec2) -> Vec2 {
}
/// Where other curves meet free-form curve `ci`: (position along it, the point, the other curve).
fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> {
fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2], only_freeform: bool) -> Vec<(f64, Vec2, usize)> {
let mine = sk.polyline(ci);
let tol = 1e-6;
let mut out: Vec<(f64, Vec2, usize)> = Vec::new();
@@ -57,8 +57,8 @@ fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> {
let (t, u) = ((c - a).cross(s) / den, (c - a).cross(r) / den);
((-1e-9..=1.0 + 1e-9).contains(&t) && (-1e-9..=1.0 + 1e-9).contains(&u)).then(|| a + r * t)
};
for (oi, _) in sk.curves.iter().enumerate() {
if oi == ci {
for (oi, curve) in sk.curves.iter().enumerate() {
if oi == ci || (only_freeform && !curve.kind.is_freeform()) {
continue;
}
let other = sk.polyline(oi);
@@ -90,7 +90,7 @@ fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> {
q = n;
}
let (u, d) = locate(segs, q);
if d < tol && !out.iter().any(|(_, y, _)| y.dist(q) < tol * 10.0) {
if d < tol && project(sk, oi, q).dist(q) < tol && !out.iter().any(|(_, y, _)| y.dist(q) < tol * 10.0) {
out.push((u, q, oi));
}
}
@@ -99,6 +99,15 @@ fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> {
out
}
/// Crossings for an analytic target with free-form cutters. Analytic-to-analytic crossings
/// remain in the shape intersection routine; this supplies the missing mixed pairs.
pub(super) fn analytic_cut_points(sk: &Sketch, ci: usize) -> Vec<(Vec2, usize)> {
if !sk.curves.iter().any(|c| c.kind.is_freeform()) {
return Vec::new();
}
cuts(sk, ci, &pieces(sk, ci), true).into_iter().map(|(_, q, oi)| (q, oi)).collect()
}
/// Exact sub-pieces between positions `ua` < `ub` (positions may run past the end on closed
/// curves; they wrap).
fn span(segs: &[Seg2], ua: f64, ub: f64) -> Vec<Seg2> {
@@ -176,18 +185,45 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option<usize>, end: Option<
/// Trim (`remove`) or break a free-form curve at the crossings around `at`.
pub(super) fn trim_or_break(sk: &mut Sketch, ci: usize, at: Vec2, remove: bool, cmd: &str) -> Result<Value> {
modify(sk, ci, at, remove, None, cmd)
}
/// Break at explicit positions, projected onto the exact curve. Closed curves can be opened
/// at one point; open curves require interior positions.
pub(super) fn break_at_points(sk: &mut Sketch, ci: usize, points: &[Vec2], cmd: &str) -> Result<Value> {
modify(sk, ci, Vec2::ZERO, false, Some(points), cmd)
}
fn modify(sk: &mut Sketch, ci: usize, at: Vec2, remove: bool, points: Option<&[Vec2]>, cmd: &str) -> Result<Value> {
let segs = pieces(sk, ci);
if segs.is_empty() {
return Err(bad(cmd, "the curve has no shape"));
}
let n = segs.len() as f64;
let closed = segs.first().zip(segs.last()).is_some_and(|(a, b)| a.start().dist(b.end()) < 1e-9);
let cs: Vec<(f64, Vec2, usize)> = cuts(sk, ci, &segs).into_iter().filter(|c| closed || (c.0 > 1e-9 && c.0 < n - 1e-9)).collect();
let cs: Vec<(f64, Vec2, Option<usize>)> = if let Some(points) = points {
let mut positions: Vec<f64> = points.iter().map(|q| locate(&segs, *q).0).collect();
if positions.iter().any(|u| !u.is_finite() || (!closed && (*u <= 1e-9 || *u >= n - 1e-9))) {
return Err(bad(cmd, "break points must project inside the curve, away from its ends"));
}
if closed {
for u in &mut positions {
if *u >= n - 1e-9 {
*u = 0.0;
}
}
}
positions.sort_by(f64::total_cmp);
positions.dedup_by(|a, b| (*a - *b).abs() <= 1e-9);
positions.into_iter().map(|u| (u, point_at(&segs, u), None)).collect()
} else {
cuts(sk, ci, &segs, false).into_iter().filter(|c| closed || (c.0 > 1e-9 && c.0 < n - 1e-9)).map(|(u, q, c)| (u, q, Some(c))).collect()
};
let (uq, _) = locate(&segs, at);
let ends = sk.curves.get(ci).and_then(|c| c.kind.ends());
let construction = sk.curves.get(ci).is_some_and(|c| c.construction);
// Intervals between consecutive cuts (closed curves wrap round).
let mut bounds: Vec<(f64, Option<usize>)> = cs.iter().map(|c| (c.0, Some(c.2))).collect();
let mut bounds: Vec<(f64, Option<usize>)> = cs.iter().map(|c| (c.0, c.2)).collect();
if bounds.is_empty() {
if remove {
sk.remove_curves(&[ci]);
@@ -239,7 +275,7 @@ pub(super) fn trim_or_break(sk: &mut Sketch, ci: usize, at: Vec2, remove: bool,
// Cut points stay on the curves that cut there.
for ((_, _, oi), (_, pi)) in cs.iter().zip(&cut_pt) {
let used = sk.curves.iter().any(|c| c.kind.uses(*pi));
if used {
if used && let Some(oi) = oi {
add_c(sk, ConstraintKind::PointOnCurve { p: *pi, c: *oi })?;
}
}
+21 -7
View File
@@ -9,7 +9,7 @@ use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, interse
use super::sketch::{add_c, edit, ids_of};
use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2};
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec2_list};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
@@ -54,7 +54,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "MODIFY")
.icon("break")
.enabled(in_sketch)
.params("curve: id, at: [x,y] (split at the nearest intersections around it) | params: split at points: [[x,y]…]"),
.params("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)"),
CommandSpec::new("sketch.scale", "Sketch Scale", scale)
.at("SKETCH", "MODIFY")
.icon("scale")
@@ -135,6 +135,13 @@ fn cut_points(sk: &Sketch, ci: usize) -> Vec<(f64, Vec2, usize)> {
}
}
}
for (q, oi) in super::sketch_freeform::analytic_cut_points(sk, ci) {
if let Some(t) = on_bounded(&sh, q)
&& !out.iter().any(|(_, x, _)| x.dist(q) < TOL * 10.0)
{
out.push((t, q, oi));
}
}
out
}
@@ -314,17 +321,24 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.break";
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let points = if p.get("points").is_some() { Some(vec2_list(cmd, p, "points", 1000)?) } else { None };
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let ci = curve_arg(sk, p, cmd)?;
if sk.curves.get(ci).is_some_and(|c| c.kind.is_freeform()) {
let at = req_vec2(cmd, p, "at")?;
let r = super::sketch_freeform::trim_or_break(sk, ci, at, false, cmd)?;
let r = if let Some(points) = &points {
super::sketch_freeform::break_at_points(sk, ci, points, cmd)?
} else {
let at = req_vec2(cmd, p, "at")?;
super::sketch_freeform::trim_or_break(sk, ci, at, false, cmd)?
};
doc.prune_model_params();
return Ok(r["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default());
}
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
let (ts, cutters): (Vec<f64>, Vec<Option<usize>>) = if let Some(list) = p.get("points").and_then(Value::as_array) {
let mut v: Vec<f64> = list.iter().take(1000).filter_map(vec2).map(|q| sh.param(sh.project(q))).collect();
let (ts, cutters): (Vec<f64>, Vec<Option<usize>>) = if let Some(list) = &points {
let mut v: Vec<f64> = list.iter().map(|q| sh.param(sh.project(*q))).collect();
v.sort_by(|a, b| a.total_cmp(b));
v.dedup_by(|a, b| (*a - *b).abs() <= 1e-9);
let n = v.len();
(v, vec![None; n])
} else {
+99 -7
View File
@@ -37,6 +37,102 @@ fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
#[test]
fn explicit_freeform_break_preserves_exact_geometry_and_undo() {
for (command, params) in [
("sketch.spline.control_point", json!({"points": [[0,0], [0,12], [20,12], [20,0]], "connect": false})),
("sketch.spline.control_point_5", json!({"points": [[0,0], [0,12], [5,20], [12,20], [20,12], [20,0]], "connect": false})),
("sketch.spline.fit_point", json!({"points": [[0,0], [6,12], [14,8], [20,0]], "connect": false})),
("sketch.conic", json!({"start": [0,0], "end": [20,0], "apex": [10,20], "rho": 0.65, "connect": false})),
] {
let mut s = new_sketch();
let id = ids(&run(&mut s, command, params)["curves"])[0].clone();
let before = sketch(&s);
let exact = before.exact_segs(before.curve_index(&id).unwrap()).unwrap();
let a = exact.first().unwrap().point_at(0.25);
let b = exact.last().unwrap().point_at(0.75);
let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [b, a, a]}));
assert!(ids(&out["curves"]).len() >= 3, "{command}: {out}");
let after = sketch(&s);
let segs: Vec<_> = after.curves.iter().enumerate().flat_map(|(i, _)| after.exact_segs(i).unwrap_or_default()).collect();
for old in &exact {
for k in 0..=40 {
let q = old.point_at(k as f64 / 40.0);
let d = segs.iter().map(|seg| seg.point_at(seg.closest_param(q)).dist(q)).fold(f64::INFINITY, f64::min);
assert!(d < 1e-6, "{command}: geometry moved {d} at {q:?}");
}
}
let encoded = serde_json::to_value(&after).unwrap();
let restored: solvecraft_sketch::Sketch = serde_json::from_value(encoded).unwrap();
assert_eq!(restored.curves.len(), after.curves.len());
run(&mut s, "edit.undo", json!({}));
assert!(sketch(&s).curve_index(&id).is_some());
}
}
#[test]
fn explicit_ellipse_break_keeps_closed_profile() {
let mut s = new_sketch();
let id = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0,0], "major": [10,0], "minor": [0,4]}))["curves"])[0].clone();
let area = profiles(&s)[0];
let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [[10,0], [0,4], [10,0]]}));
assert_eq!(ids(&out["curves"]).len(), 4, "{out}");
let after = sketch(&s);
assert!(after.curves.iter().all(|c| matches!(c.kind, CurveKind::Conic { .. })));
assert_eq!(profiles(&s).len(), 1);
assert!((profiles(&s)[0] - area).abs() < 1e-6);
run(&mut s, "edit.undo", json!({}));
let original = sketch(&s);
let q = original.exact_segs(original.curve_index(&id).unwrap()).unwrap()[0].point_at(0.4);
let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [q]}));
assert_eq!(ids(&out["curves"]).len(), 5, "a single cut inside a quadrant: {out}");
assert_eq!(profiles(&s).len(), 1);
assert!((profiles(&s)[0] - area).abs() < 1e-6);
}
#[test]
fn explicit_break_rejects_bad_points_atomically_and_merges_duplicates() {
for freeform in [false, true] {
let mut s = new_sketch();
let out = if freeform {
run(&mut s, "sketch.spline.control_point", json!({"points": [[0,0], [0,10], [20,10], [20,0]], "connect": false}))
} else {
run(&mut s, "sketch.line", json!({"points": [[0,0], [20,0]], "connect": false}))
};
let id = ids(&out["curves"])[0].clone();
let before = serde_json::to_value(sketch(&s)).unwrap();
for points in [json!([]), json!("wrong"), json!([[8, 4], ["bad", 0]]), json!([[0, 0]]), json!([[1e20, 0]]), json!(vec![[10, 5]; 1001])] {
assert!(s.execute("sketch.break", &json!({"curve": id, "points": points})).is_err());
assert_eq!(serde_json::to_value(sketch(&s)).unwrap(), before);
}
let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [[10,7], [10,7]]}));
assert_eq!(ids(&out["curves"]).len(), 2, "{out}");
}
}
#[test]
fn analytic_trim_and_break_use_freeform_cutters() {
let mut s = new_sketch();
let line = ids(&run(&mut s, "sketch.line", json!({"points": [[-5,5], [25,5]], "connect": false}))["curves"])[0].clone();
run(&mut s, "sketch.spline.control_point", json!({"points": [[0,0], [0,10], [20,10], [20,0]], "connect": false}));
let out = run(&mut s, "sketch.break", json!({"curve": line, "at": [10,5]}));
assert_eq!(ids(&out["curves"]).len(), 3, "{out}");
let sk = sketch(&s);
for id in ids(&out["curves"]) {
let shape = sk.shape(sk.curve_index(&id).unwrap()).unwrap();
assert!(matches!(shape, solvecraft_sketch::Shape::Line { a, b } if (a.y - 5.0).abs() < 1e-6 && (b.y - 5.0).abs() < 1e-6));
}
let mut s = new_sketch();
let circle = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0,0], "radius": 5}))["curves"])[0].clone();
run(&mut s, "sketch.ellipse", json!({"center": [0,0], "major": [8,0], "minor": [0,3]}));
run(&mut s, "sketch.trim", json!({"curve": circle, "at": [5,0]}));
let sk = sketch(&s);
let ci = sk.curve_index(&circle).unwrap();
assert!(matches!(sk.curves[ci].kind, CurveKind::Arc { .. }));
assert!(sk.shape(ci).unwrap().dist(Vec2::new(-5.0, 0.0)) < 1e-6);
}
#[test]
fn trim_extend_break() {
let mut s = new_sketch();
@@ -219,13 +315,9 @@ fn ellipse_splines_conic() {
run(&mut s, "sketch.constraint.coincident", json!({"a": "q", "b": eid}));
let sk = sketch(&s);
let q = sk.point(sk.point_index("q").unwrap()).unwrap();
let poly = sk.polyline(sk.curve_index(&eid).unwrap());
let d = poly.windows(2).map(|w| {
let (a, b) = (w[0], w[1]);
let t = ((q - a).dot(b - a) / (b - a).len2()).clamp(0.0, 1.0);
q.dist(a + (b - a) * t)
});
assert!(d.fold(f64::INFINITY, f64::min) < 1e-6, "{q:?}");
let exact = sk.exact_segs(sk.curve_index(&eid).unwrap()).unwrap();
let distance = exact.iter().map(|seg| q.dist(seg.point_at(seg.closest_param(q)))).fold(f64::INFINITY, f64::min);
assert!(distance < 1e-6, "on the exact ellipse: {q:?}");
// Fit spline closed by a line: one profile.
let sp = run(&mut s, "sketch.spline.fit_point", json!({"points": [[100, 0], [110, 8], [120, 3], [130, 0]]}));
let sid = ids(&sp["curves"])[0].clone();
+7 -1
View File
@@ -345,7 +345,13 @@ impl Drop for Nested {
pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
if id == "timeline.redefine" {
let cmd = params.get("command")?.as_str()?;
let inner = to_local(s, cmd, params.get("params")?)?;
let key = params.get("feature").and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_u64().map(|id| id.to_string())))?;
let feature = s.doc.find_feature(&key)?;
// Edit Feature authors in the existing feature's frame, even when another
// component is active. Body-relative picks still use the picked body's frame.
let mut context = s.scratch();
context.active_component = feature.component;
let inner = to_local(&context, cmd, params.get("params")?)?;
let mut out = params.clone();
out["params"] = inner;
return Some(out);
+7 -3
View File
@@ -77,10 +77,14 @@ fn world_picks_map_into_the_component() {
assert!(volume(&mut s) < v0 - 1.0, "the fillet cut material");
// A sketch on the moved top face sits at world z = 10 over the moved block.
run(&mut s, "sketch.create", json!({"plane": {"face": [70, 10, 10]}, "name": "Pad"}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 10], "radius": 2}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 10], "radius": 2, "id": "PadCircle"}));
let sketch = run(&mut s, "sketch.inspect", json!({"sketch": "Pad"}));
let center = sketch["curves"].as_array().unwrap().iter().find(|c| c["id"] == "PadCircle").unwrap()["center"].as_str().unwrap().to_string();
run(&mut s, "sketch.finish", json!({}));
let pad = sketch_point_world(&mut s, "Pad", "p1");
assert!((pad.z - 10.0).abs() < 1e-6 && pad.x > 50.0, "{pad:?}");
// A projected boundary's first point is an enumeration detail; check the circle
// that was actually authored in the moved component's local sketch coordinates.
let pad = sketch_point_world(&mut s, "Pad", &center);
assert!(near(pad, Vec3::new(70.0, 10.0, 10.0)), "{pad:?}");
let v1 = volume(&mut s);
let h = run(&mut s, "solid.hole", json!({"position": [60, 10, 10], "diameter": 4, "depth": 5}));
assert!(volume(&mut s) < v1 - 1.0, "the hole cut material: {h}");
+236 -44
View File
@@ -19,6 +19,144 @@ fn unsupported(msg: &str) -> KernelError {
/// Samples per edge for the contact curves and the blend surface.
const SAMPLES: usize = 24;
const MAX_SAMPLES: usize = 369;
const MAX_CHAIN_SAMPLES: usize = 100_000;
struct Fit {
surface: mt::Surface,
contacts: [mt::Curve; 2],
/// Parameter range belonging to this edge (the fit includes neighbouring sections).
range: (f64, f64),
}
struct Guide {
sections: Vec<Section>,
lengths: Vec<f64>,
}
impl Guide {
fn new(sections: Vec<Section>) -> Result<Self> {
let mut lengths = vec![0.0];
let mut total = 0.0;
for pair in sections.windows(2) {
let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue };
total += a.c.dist(b.c);
lengths.push(total);
}
if !total.is_finite() || total <= 1e-12 {
return Err(unsupported("degenerate rolling-ball spine"));
}
Ok(Self { sections, lengths })
}
fn anchor(&self, along: f64) -> Option<(Vec3, Vec3)> {
if self.lengths.len() < 2 || !along.is_finite() || !(0.0..=1.0).contains(&along) {
return None;
}
let distance = self.lengths.last()? * along;
let hi = self.lengths.partition_point(|length| *length < distance).clamp(1, self.lengths.len().checked_sub(1)?);
let (a, b) = (self.sections.get(hi - 1)?, self.sections.get(hi)?);
let (lo, hi_length) = (self.lengths.get(hi - 1)?, self.lengths.get(hi)?);
let span = hi_length - lo;
if span <= 1e-12 {
return None;
}
let fraction = (distance - lo) / span;
Some((a.c + (b.c - a.c) * fraction, (b.c - a.c).normalized()?))
}
}
/// One interpolation for both the face and its boundary curves. Fitting the contacts
/// independently gives different end conditions and leaves gaps between the same faces.
fn fit_sections(sections: &[Vec<Section>], k: usize, closed: bool, round: bool) -> Result<Fit> {
use mt::Cut;
let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}"));
let n = sections.len();
let list = sections.get(k).ok_or_else(|| fail("section"))?;
let before = usize::from(closed || k > 0) * 2;
let mut ext = Vec::with_capacity(list.len() + 4);
if before > 0 {
let prev = sections.get((k + n - 1) % n).ok_or_else(|| fail("section"))?;
ext.extend(prev.iter().rev().skip(1).take(2).rev().copied());
}
ext.extend(list.iter().copied());
if closed || k + 1 < n {
let next = sections.get((k + 1) % n).ok_or_else(|| fail("section"))?;
ext.extend(next.iter().skip(1).take(2).copied());
}
let ctrl: Vec<[mt::Vector4; 3]> = ext.iter().map(|s| section_controls(s, round)).collect::<Option<_>>().ok_or_else(|| fail("section"))?;
let mut rows = Vec::with_capacity(3);
let mut vknots = None;
for j in 0..3 {
let xyz: Vec<Vec3> = ctrl.iter().filter_map(|c| c.get(j)).map(|c| Vec3::new(c.x, c.y, c.z)).collect();
let ww: Vec<Vec3> = ctrl.iter().filter_map(|c| c.get(j)).map(|c| Vec3::new(c.w, 0.0, 0.0)).collect();
let bx = crate::build::interpolate_cubic(&xyz).ok_or_else(|| fail("blend surface"))?;
let bw = crate::build::interpolate_cubic(&ww).ok_or_else(|| fail("blend surface"))?;
vknots = Some(bx.knot_vec().clone());
rows.push(bx.control_points().iter().zip(bw.control_points()).map(|(p, w)| mt::Vector4::new(p.x, p.y, p.z, w.x)).collect::<Vec<_>>());
}
let vk = vknots.ok_or_else(|| fail("knots"))?;
let den = ext.len().saturating_sub(1) as f64;
if den <= 0.0 {
return Err(fail("empty sections"));
}
let range = (before as f64 / den, (before + list.len().saturating_sub(1)) as f64 / den);
let contact = |row: usize| -> Result<mt::Curve> {
let points = rows.get(row).cloned().ok_or_else(|| fail("contact row"))?;
let bs = mt::BSplineCurve::try_new(vk.clone(), points).map_err(|e| fail(&e.to_string()))?;
let mut curve = mt::NurbsCurve::new(bs);
if range.0 > 0.0 {
curve = curve.cut(range.0);
}
if range.1 < 1.0 {
let _ = curve.cut(range.1);
}
Ok(mt::Curve::NurbsCurve(curve))
};
let contacts = [contact(0)?, contact(2)?];
let bs = mt::BSplineSurface::try_new((mt::KnotVec::bezier_knot(2), vk), rows).map_err(|e| fail(&e.to_string()))?;
Ok(Fit { surface: mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs)), contacts, range })
}
/// Compare the fitted strip with a solved circular section between interpolation nodes.
/// This checks the supporting contacts as well as the middle of the fillet band.
fn section_fits(fit: &Fit, section: &Section, along: f64, round: bool, sigma: f64, tolerance: f64) -> bool {
use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter};
let Some(ctrl) = section_controls(section, round) else { return false };
let v = fit.range.0 + (fit.range.1 - fit.range.0) * along;
for u in [0.0, 0.25, 0.5, 0.75, 1.0] {
let [a, b, c] = ctrl;
let h = a * (1.0 - u) * (1.0 - u) + b * (2.0 * u * (1.0 - u)) + c * u * u;
if !h.w.is_finite() || h.w <= 1e-12 {
return false;
}
let exact = Vec3::new(h.x / h.w, h.y / h.w, h.z / h.w);
// The input intersection edge may use piecewise leader-curve parameters. A
// parameter-speed change is not a geometric error: compare nearest points, with
// contacts constrained to the corresponding boundary and all probes to the strip.
let (probe_u, probe_v) = if u == 0.0 || u == 1.0 {
let Some(contact) = fit.contacts.get(usize::from(u == 1.0)) else { return false };
let Some(parameter) = contact.search_nearest_parameter(p3(exact), v, 32) else { return false };
(u, parameter.clamp(fit.range.0, fit.range.1))
} else {
let Some((pu, pv)) = fit.surface.search_nearest_parameter(p3(exact), Some((u, v)), 32) else { return false };
(pu.clamp(0.0, 1.0), pv.clamp(fit.range.0, fit.range.1))
};
let actual = from_p3(fit.surface.subs(probe_u, probe_v));
if !actual.is_finite() || actual.dist(exact) > tolerance {
return false;
}
if round && (u == 0.0 || u == 1.0) {
let Some(want) = ((section.c - exact) * sigma).normalized() else { return false };
let normal = fit.surface.normal(probe_u, probe_v);
let Some(normal) = Vec3::new(normal.x, normal.y, normal.z).normalized() else { return false };
if normal.dot(want).abs() < 0.5f64.to_radians().cos() {
return false;
}
}
}
true
}
/// One cross-section of the blend: ball centre and its contacts with side 1 and side 2.
#[derive(Clone, Copy, Debug)]
@@ -102,6 +240,16 @@ fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> {
/// Blend the closed chain of edges (ids in any order) with radius `r`.
pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<Solid> {
curve_blend_with_limit(solid, ids, size, r, round, MAX_SAMPLES)
}
fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, max_samples: usize) -> Result<Solid> {
if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) {
return Err(KernelError::Invalid("curve blend size and radius must be finite and positive".into()));
}
if !(4..=MAX_SAMPLES).contains(&max_samples) {
return Err(KernelError::Invalid("curve blend section limit is outside the supported range".into()));
}
let tol = (size * 1e-7).max(1e-9);
let shells = solid.boundaries();
let (si, shell) = shells
@@ -303,16 +451,85 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
vsec.push(s);
}
let mut esec: Vec<Vec<Section>> = Vec::with_capacity(n);
for (k, e) in chain.iter().enumerate() {
let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?];
for j in 1..SAMPLES - 1 {
let (p, t) = tangent_at(e, j as f64 / (SAMPLES - 1) as f64).ok_or_else(|| KernelError::Failed("tangent".into()))?;
list.push(solve(&s1, &s2, p, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?);
// Uniform refinement preserves a common spacing across neighbouring patches. Work is
// capped: a difficult strip returns an error instead of accepting an inaccurate fit.
let fit_tolerance = (size * 1e-5).clamp(1e-5, 0.001);
// At a seam the contact lies on the seam plane, which need not be square to the
// original edge. Its ball centre therefore need not lie in the edge's normal plane.
// Interpolate that plane's displacement so the interior sections tend to the exact
// seam sections, rather than leaving a finite jump as sampling gets finer.
let offsets: Vec<(f64, f64)> = chain
.iter()
.enumerate()
.map(|(k, edge)| {
let (p0, t0) = tangent_at(edge, 0.0).ok_or_else(|| KernelError::Failed("tangent".into()))?;
let (p1, t1) = tangent_at(edge, 1.0).ok_or_else(|| KernelError::Failed("tangent".into()))?;
let start = vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?;
let end = vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?;
Ok(((start.c - p0).dot(t0), (end.c - p1).dot(t1)))
})
.collect::<Result<_>>()?;
let section_at = |k: usize, edge: &mt::Edge, along: f64| -> Result<Section> {
let (p, t) = tangent_at(edge, along).ok_or_else(|| KernelError::Failed("tangent".into()))?;
let (start, end) = offsets.get(k).copied().ok_or_else(|| KernelError::Failed("section offset".into()))?;
let anchor = p + t * (start * (1.0 - along) + end * along);
solve(&s1, &s2, anchor, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))
};
let mut samples = SAMPLES.min(max_samples);
let (esec, fits) = loop {
if n.checked_mul(samples).is_none_or(|count| count > MAX_CHAIN_SAMPLES) {
return Err(unsupported("curve blend exceeds the section fitting limit"));
}
list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?);
esec.push(list);
}
let mut guides = Vec::with_capacity(n);
for (k, e) in chain.iter().enumerate() {
let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?];
for j in 1..samples - 1 {
list.push(section_at(k, e, j as f64 / (samples - 1) as f64)?);
}
list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?);
guides.push(Guide::new(list)?);
}
// Intersection curves inherit their leader's piecewise parameter speed. Fitting
// those parameters creates oscillations even on a geometrically smooth spine.
// Resample by distance along the rolling centres, projecting each interpolated
// centre back onto the exact two offset surfaces before it becomes a section.
let uniform_section = |k: usize, along: f64| -> Result<Section> {
let guide = guides.get(k).ok_or_else(|| KernelError::Failed("spine guide".into()))?;
if along == 0.0 {
return guide.sections.first().copied().ok_or_else(|| KernelError::Failed("section".into()));
}
if along == 1.0 {
return guide.sections.last().copied().ok_or_else(|| KernelError::Failed("section".into()));
}
let (anchor, tangent) = guide.anchor(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?;
solve(&s1, &s2, anchor, tangent, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine"))
};
let esec: Vec<Vec<Section>> = (0..n)
.map(|k| (0..samples).map(|j| uniform_section(k, j as f64 / (samples - 1) as f64)).collect::<Result<Vec<_>>>())
.collect::<Result<_>>()?;
let fits = crate::guard("fit curve blend", || (0..n).map(|k| fit_sections(&esec, k, closed, round)).collect::<Result<Vec<_>>>())?;
let mut accurate = true;
'edges: for k in 0..n {
let fit = fits.get(k).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?;
for j in 0..samples - 1 {
for fraction in [0.25, 0.5, 0.75] {
let along = (j as f64 + fraction) / (samples - 1) as f64;
let section = uniform_section(k, along)?;
if !crate::guard("check curve blend fit", || Ok(section_fits(fit, &section, along, round, sigma, fit_tolerance)))? {
accurate = false;
break 'edges;
}
}
}
}
if accurate {
break (esec, fits);
}
if samples >= max_samples {
return Err(KernelError::Failed("curve blend: could not fit the rolling-ball surface within the accuracy and section limits".into()));
}
samples = (2 * samples - 1).min(max_samples);
};
crate::guard("curve blend", || {
let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}"));
// Vertices and arcs at the chain's vertices.
@@ -334,47 +551,18 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let mut c2: Vec<mt::Edge> = Vec::with_capacity(n);
let mut blends: Vec<mt::Surface> = Vec::with_capacity(n);
for (k, list) in esec.iter().enumerate() {
let q1: Vec<Vec3> = list.iter().map(|s| s.q1).collect();
let q2: Vec<Vec3> = list.iter().map(|s| s.q2).collect();
let k1 = (k + 1) % nv;
let (a1, b1, a2, b2) = (v1.get(k), v1.get(k1), v2.get(k), v2.get(k1));
let (Some(a1), Some(b1), Some(a2), Some(b2)) = (a1, b1, a2, b2) else { return Err(fail("vertex")) };
let cv1 = crate::build::interpolate_cubic(&q1).ok_or_else(|| fail("contact curve"))?;
let cv2 = crate::build::interpolate_cubic(&q2).ok_or_else(|| fail("contact curve"))?;
let e1 = mt::Edge::new(a1, b1, mt::Curve::BSplineCurve(cv1));
let e2 = mt::Edge::new(a2, b2, mt::Curve::BSplineCurve(cv2));
// The blend surface: each control row interpolated along the edge (homogeneous),
// through two sections of each neighbouring edge too, so that neighbouring blend
// faces meet smoothly.
let mut ext: Vec<Section> = Vec::with_capacity(list.len() + 4);
if closed || k > 0 {
let prev = esec.get((k + n - 1) % n).ok_or_else(|| fail("section"))?;
ext.extend(prev.iter().rev().skip(1).take(2).rev().copied());
}
ext.extend(list.iter().copied());
if closed || k + 1 < n {
let next = esec.get((k + 1) % n).ok_or_else(|| fail("section"))?;
ext.extend(next.iter().skip(1).take(2).copied());
}
let ctrl: Vec<[mt::Vector4; 3]> = ext.iter().map(|s| section_controls(s, round)).collect::<Option<_>>().ok_or_else(|| fail("section"))?;
let mut rows: Vec<Vec<mt::Vector4>> = Vec::with_capacity(3);
let mut vknots = None;
for j in 0..3 {
let xyz: Vec<Vec3> = ctrl.iter().map(|c| Vec3::new(c[j].x, c[j].y, c[j].z)).collect();
let ww: Vec<Vec3> = ctrl.iter().map(|c| Vec3::new(c[j].w, 0.0, 0.0)).collect();
let bx = crate::build::interpolate_cubic(&xyz).ok_or_else(|| fail("blend surface"))?;
let bw = crate::build::interpolate_cubic(&ww).ok_or_else(|| fail("blend surface"))?;
vknots = Some(bx.knot_vec().clone());
rows.push(bx.control_points().iter().zip(bw.control_points()).map(|(p, w)| mt::Vector4::new(p.x, p.y, p.z, w.x)).collect());
}
let vk = vknots.ok_or_else(|| fail("knots"))?;
let uk = mt::KnotVec::bezier_knot(2);
let bs = mt::BSplineSurface::try_new((uk, vk), rows).map_err(|e| fail(&e.to_string()))?;
let mut surface = mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs));
let fit = fits.get(k).ok_or_else(|| fail("fit"))?;
let [cv1, cv2] = &fit.contacts;
let e1 = mt::Edge::new(a1, b1, cv1.clone());
let e2 = mt::Edge::new(a2, b2, cv2.clone());
let mut surface = fit.surface.clone();
// Outward: toward the ball centre on a concave edge, away on a convex one.
{
use mt::{ParametricSurface3D, SearchNearestParameter};
let s = list.get(SAMPLES / 2).ok_or_else(|| fail("section"))?;
let s = list.get(list.len() / 2).ok_or_else(|| fail("section"))?;
let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * r;
if let Some((u, v)) = surface.search_nearest_parameter(p3(mid), None, 100) {
let nn = surface.normal(u, v);
@@ -527,3 +715,7 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
Solid::try_new(shells).map_err(|e| fail(&format!("invalid solid: {e}")))
})
}
#[cfg(test)]
#[path = "curveblend_tests.rs"]
mod tests;
+194
View File
@@ -0,0 +1,194 @@
//! Independent analytic reference: the centre of a ball tangent to two perpendicular
//! cylinders lies on the intersection of their radius-offset cylinders.
use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
use super::*;
use crate::{Body, BoolOp, boolean, cylinder, fillet};
fn tee(scale: f64, origin: Vec3) -> Body {
let main = cylinder(origin + Vec3::new(-40.0, 0.0, 0.0) * scale, Vec3::X, 15.0 * scale, 80.0 * scale).unwrap();
let branch = cylinder(origin, Vec3::Z, 10.0 * scale, 35.0 * scale).unwrap();
boolean(&main, &branch, BoolOp::Union).unwrap().unwrap()
}
fn analytic_section(theta: f64, scale: f64, origin: Vec3) -> (Vec3, Vec3, Vec3) {
let centre = Vec3::new(13.0 * theta.cos(), 13.0 * theta.sin(), (324.0 - 169.0 * theta.sin().powi(2)).sqrt());
let main = Vec3::new(centre.x, centre.y * 15.0 / 18.0, centre.z * 15.0 / 18.0);
let branch = Vec3::new(centre.x * 10.0 / 13.0, centre.y * 10.0 / 13.0, centre.z);
(origin + centre * scale, origin + main * scale, origin + branch * scale)
}
fn nearest_blend(surfaces: &[mt::Surface], point: Vec3) -> (f64, Vec3) {
surfaces
.iter()
.filter_map(|s| {
let (u, v) = s.search_nearest_parameter(p3(point), None, 100)?;
let actual = from_p3(s.subs(u, v));
let n = s.normal(u, v);
Some((actual.dist(point), Vec3::new(n.x, n.y, n.z).normalized()?))
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.unwrap()
}
fn distance_to_analytic_arc(theta: f64, point: Vec3) -> f64 {
let (centre, main, branch) = analytic_section(theta, 1.0, Vec3::ZERO);
let a = (main - centre).normalized().unwrap();
let b = (branch - centre).normalized().unwrap();
let plane = a.cross(b).normalized().unwrap();
let radial = point - centre;
let projected = radial - plane * radial.dot(plane);
let direction = projected.normalized().unwrap();
let angle = a.dot(b).clamp(-1.0, 1.0).acos();
let from_main = a.dot(direction).clamp(-1.0, 1.0).acos();
let from_branch = b.dot(direction).clamp(-1.0, 1.0).acos();
if (from_main + from_branch - angle).abs() < 1e-7 { point.dist(centre + direction * 3.0) } else { point.dist(main).min(point.dist(branch)) }
}
fn distance_to_analytic_band(point: Vec3) -> f64 {
// The arc's XY azimuth stays between its two contacts' azimuths. Their difference
// for this tee is less than 0.1 rad; bracket generously around the query azimuth.
let azimuth = point.y.atan2(point.x);
let (mut lo, mut hi) = (azimuth - 0.3, azimuth + 0.3);
for _ in 0..55 {
let a = lo + (hi - lo) * 0.38196601125;
let b = lo + (hi - lo) * 0.61803398875;
if distance_to_analytic_arc(a, point) < distance_to_analytic_arc(b, point) {
hi = b;
} else {
lo = a;
}
}
distance_to_analytic_arc((lo + hi) * 0.5, point)
}
#[test]
fn fitted_contact_edges_coincide_with_their_surface_boundaries() {
let sections: Vec<Vec<Section>> = (0..4)
.map(|k| {
(0..SAMPLES)
.map(|i| {
let theta = std::f64::consts::FRAC_PI_2 * (k as f64 + i as f64 / (SAMPLES - 1) as f64);
let (c, q1, q2) = analytic_section(theta, 1.0, Vec3::ZERO);
Section { c, q1, q2 }
})
.collect()
})
.collect();
for k in 0..sections.len() {
let fit = crate::guard("contact consistency", || fit_sections(&sections, k, true, true)).unwrap();
for (side, curve) in fit.contacts.iter().enumerate() {
let (lo, hi) = curve.range_tuple();
for i in 0..=200 {
let parameter = lo + (hi - lo) * i as f64 / 200.0;
let edge = from_p3(curve.subs(parameter));
let surface = from_p3(fit.surface.subs(side as f64, parameter));
assert!(edge.dist(surface) < 1e-9, "contact-to-surface gap {}", edge.dist(surface));
}
}
}
}
#[test]
fn rolling_ball_matches_analytic_tee_and_contacts() {
for (scale, origin) in [(1.0, Vec3::ZERO), (0.25, Vec3::new(23.0, -17.0, 9.0))] {
let body = tee(scale, origin);
let point = origin + Vec3::new(0.0, 10.0, 125f64.sqrt()) * scale;
let blend = fillet(&body, &[point], 3.0 * scale).unwrap();
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
let surfaces: Vec<_> = blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect();
assert!(!surfaces.is_empty());
let mut distance: f64 = 0.0;
let mut tangent_angle: f64 = 0.0;
for i in 0..96 {
// Offset the angular samples from patch seams; contact endpoints are tested below.
let theta = std::f64::consts::TAU * (i as f64 + 0.37) / 96.0;
let (centre, main, branch) = analytic_section(theta, scale, origin);
let a = (main - centre).normalized().unwrap();
let b = (branch - centre).normalized().unwrap();
let angle = a.dot(b).clamp(-1.0, 1.0).acos();
for fraction in [0.0, 0.25, 0.5, 0.75, 1.0] {
// Spherical linear interpolation of the radial unit vectors, independent of
// the production rational control-point construction and fitting parameters.
let radial = (a * ((1.0 - fraction) * angle).sin() + b * (fraction * angle).sin()) / angle.sin();
let exact = centre + radial * (3.0 * scale);
let (error, normal) = nearest_blend(&surfaces, exact);
distance = distance.max(error);
if fraction == 0.0 || fraction == 1.0 {
let want = (centre - exact).normalized().unwrap();
tangent_angle = tangent_angle.max(normal.dot(want).abs().min(1.0).acos().to_degrees());
}
}
}
assert!(distance <= 0.005, "scale {scale}: analytic surface deviation {distance} mm");
assert!(tangent_angle <= 0.5, "scale {scale}: contact normals differ by {tangent_angle} degrees");
// The reverse direction uses points on the actual trimmed face mesh, so an
// untrimmed neighbouring surface cannot conceal an overshooting blend patch.
let mesh = blend.tessellate_with(0.002, true).unwrap();
let mut reverse_distance: f64 = 0.0;
let mut probes = 0;
for point in mesh.positions.iter().step_by(19) {
let p = (*point - origin) / scale;
if (p.x * p.x + p.y * p.y).sqrt() <= 10.000001
|| (p.y * p.y + p.z * p.z).sqrt() <= 15.000001
|| !(10.0..20.0).contains(&p.z)
|| p.x.abs() > 16.0
{
continue;
}
probes += 1;
reverse_distance = reverse_distance.max(distance_to_analytic_band(p) * scale);
}
assert!(probes > 300, "only {probes} trimmed surface points checked");
assert!(reverse_distance <= 0.005, "scale {scale}: reverse analytic deviation {reverse_distance} mm");
eprintln!("scale {scale}: surface {distance:.9} mm, reverse {reverse_distance:.9} mm, contact normals {tangent_angle:.6} degrees");
let mut contact_count = 0;
for edge in Body::unique_edges(&blend.solid) {
let curve = edge.oriented_curve();
let (lo, hi) = curve.range_tuple();
let endpoints = [from_p3(curve.subs(lo)), from_p3(curve.subs(hi))].map(|p| (p - origin) / scale);
if endpoints.iter().any(|p| !(9.0..20.0).contains(&p.z) || p.x.abs() > 16.0) {
continue;
}
let branch = endpoints.iter().all(|p| ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() < 1e-5);
let main = endpoints.iter().all(|p| ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() < 1e-5);
if !branch && !main {
continue;
}
contact_count += 1;
for i in 0..=200 {
let p = (from_p3(curve.subs(lo + (hi - lo) * i as f64 / 200.0)) - origin) / scale;
let radius_error = if branch { ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() } else { ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() };
assert!(radius_error * scale <= 0.002, "supporting cylinder deviation {} mm", radius_error * scale);
}
}
assert!(contact_count >= 4, "only {contact_count} contacts checked");
}
}
#[test]
fn rolling_ball_refinement_cap_returns_an_error_and_preserves_input() {
let body = tee(1.0, Vec3::ZERO);
let solid = body.deep_copy();
let ids: Vec<_> = Body::unique_edges(&solid)
.iter()
.filter(|edge| {
let curve = edge.oriented_curve();
let (lo, hi) = curve.range_tuple();
let p = from_p3(curve.subs((lo + hi) * 0.5));
p.z > 10.0 && ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() < 0.01 && ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() < 0.01
})
.map(mt::Edge::id)
.collect();
assert!(!ids.is_empty());
let before = crate::step_export(&[&body], "unchanged").unwrap();
let input_before = crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap();
// Four sections deliberately undersample the saddle; production starts at 24.
let error = crate::guard("limited rolling ball", || curve_blend_with_limit(&solid, &ids, body.size(), 3.0, true, 4)).unwrap_err();
assert!(error.to_string().contains("accuracy and section limits"), "{error}");
assert_eq!(crate::step_export(&[&body], "unchanged").unwrap(), before);
assert_eq!(crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap(), input_before);
}
+64 -6
View File
@@ -1,7 +1,9 @@
//! STEP geometry (ISO 10303-42 entities) to kernel curves and surfaces.
//!
//! Points, placements, lines, conics, B-splines (plain and rational), elementary surfaces and
//! swept surfaces. Edge curves are trimmed to their vertices here, so every kernel edge runs
//! swept surfaces, with exact offsets of planes, cylinders, cones, spheres and regular tori.
//! Free-form offsets and offsets through collapsed radii remain explicit import warnings.
//! Edge curves are trimmed to their vertices here, so every kernel edge runs
//! exactly from its start vertex to its end vertex. Lengths are scaled to millimetres while
//! reading.
@@ -761,6 +763,59 @@ pub(crate) fn bspline_surface(cx: &Ctx, e: &Entity) -> R<mt::Surface> {
}
}
/// Resolve exact analytic offsets. Each intermediate offset is checked: an invalid inner
/// offset must not become valid merely because an outer offset cancels its distance.
/// Entity attributes follow ISO 10303-42 (`basis_surface`, `distance`, `self_intersect`).
fn offset_basis(cx: &Ctx, id: u64, depth: usize) -> R<(u64, f64)> {
if depth > MAX_GEOM_DEPTH {
return Err("surface definitions nested too deeply".into());
}
let e = entity(cx, id)?;
if e.name() != "OFFSET_SURFACE" {
return Ok((id, 0.0));
}
let p = e.params();
let basis = p.get(1).and_then(Param::as_ref_id).ok_or("offset surface without basis")?;
let distance = length(cx, p.get(2), "offset distance")?;
if !matches!(p.get(3).and_then(Param::as_enum), Some("T" | "F" | "U")) {
return Err("offset surface without a logical self_intersect flag".into());
}
let (basis, inner) = offset_basis(cx, basis, depth + 1)?;
let offset = inner + distance;
if !offset.is_finite() || offset.abs() >= MAX_COORD {
return Err("offset distance out of range after unit conversion".into());
}
let e = entity(cx, basis)?;
let p = e.params();
match e.name() {
"PLANE" => {}
"CYLINDRICAL_SURFACE" | "SPHERICAL_SURFACE" => {
let radius = length(cx, p.get(2), "radius")?;
if radius <= 0.0 || !(1e-9..MAX_COORD).contains(&(radius + offset)) {
return Err("offset surface collapses or reverses its radius".into());
}
}
"CONICAL_SURFACE" => {
let radius = length(cx, p.get(2), "radius")?;
let angle = num(p.get(3), "semi angle")? * cx.ang;
if radius < 0.0 || !(angle.abs() > 1e-9 && angle.abs() < FRAC_PI_2 - 1e-9) || !(0.0..MAX_COORD).contains(&(radius + offset / angle.cos()))
{
return Err("offset cone has an invalid reference radius or angle".into());
}
}
"TOROIDAL_SURFACE" => {
let major = length(cx, p.get(2), "major radius")?;
let minor = length(cx, p.get(3), "minor radius")?;
if minor <= 0.0 || major <= minor || major >= MAX_COORD || !(1e-9..major).contains(&(minor + offset)) {
return Err("offset torus collapses or becomes self-intersecting".into());
}
}
_ if offset == 0.0 => {}
n => return Err(format!("unsupported OFFSET_SURFACE basis surface {n}")),
}
Ok((basis, offset))
}
/// A STEP surface entity as a kernel surface whose normal is the STEP surface normal.
/// `extent` holds points of the face boundary (bounds the analytic surfaces' parameter ranges).
/// `sense` false gives the opposite normal (a face with `same_sense = .F.`).
@@ -776,6 +831,7 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
if depth > MAX_GEOM_DEPTH {
return Err("surface definitions nested too deeply".into());
}
let (id, offset) = offset_basis(cx, id, depth)?;
let e = entity(cx, id)?;
if e.is_complex() {
return bspline_surface(cx, e).map(flip);
@@ -785,11 +841,12 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
match e.name() {
"PLANE" => {
let f = placement()?;
Ok(flip(mt::Surface::Plane(mt::Plane::new(f.o, f.o + f.x, f.o + f.y))))
let o = f.o + f.z * offset;
Ok(flip(mt::Surface::Plane(mt::Plane::new(o, o + f.x, o + f.y))))
}
"CYLINDRICAL_SURFACE" => {
let f = seam_frame(&placement()?, extent, seam);
let r = length(cx, p.get(2), "radius")?;
let r = length(cx, p.get(2), "radius")? + offset;
if r <= 0.0 {
return Err("cylinder radius must be positive".into());
}
@@ -799,8 +856,9 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
}
"CONICAL_SURFACE" => {
let f = seam_frame(&placement()?, extent, seam);
let r = length(cx, p.get(2), "radius")?;
let basis_r = length(cx, p.get(2), "radius")?;
let a = num(p.get(3), "semi angle")? * cx.ang;
let r = basis_r + offset / a.cos();
if r < 0.0 || !(a.abs() > 1e-9 && a.abs() < FRAC_PI_2 - 1e-9) {
return Err("bad cone".into());
}
@@ -821,7 +879,7 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
}
"SPHERICAL_SURFACE" => {
let f = placement()?;
let r = length(cx, p.get(2), "radius")?;
let r = length(cx, p.get(2), "radius")? + offset;
if r <= 0.0 {
return Err("sphere radius must be positive".into());
}
@@ -844,7 +902,7 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
}
"TOROIDAL_SURFACE" => {
let f = placement()?;
let (big, small) = (length(cx, p.get(2), "major radius")?, length(cx, p.get(3), "minor radius")?);
let (big, small) = (length(cx, p.get(2), "major radius")?, length(cx, p.get(3), "minor radius")? + offset);
if small <= 0.0 || big < 0.0 {
return Err("bad torus radii".into());
}
+100
View File
@@ -76,6 +76,106 @@ fn imported_bodies_take_features() {
const HEAD: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('t','',(''),(''),'','hand','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));\nENDSEC;\nDATA;\n";
const TAIL: &str = "ENDSEC;\nEND-ISO-10303-21;\n";
/// Original fixtures: elementary basis surfaces plus offsets, with no external CAD files.
fn offset_geometry(definition: &str, offset: &str, len: f64, sense: bool, points: &[mt::Point3]) -> crate::Result<mt::Surface> {
let text = format!(
"{HEAD}#1=CARTESIAN_POINT('',(0.,0.,0.));\n#2=AXIS2_PLACEMENT_3D('',#1,$,$);\n#3={definition};\n#4=OFFSET_SURFACE('',#3,{offset},.U.);\n{TAIL}"
);
let ex = p21::parse(&text).unwrap();
let cx = Ctx { ex: &ex, len, ang: 1.0, tol: 1e-6 };
crate::guard("test offset surface", || geom::surface(&cx, 4, points, &[], sense, 0).map_err(KernelError::Failed))
}
#[test]
fn offset_surface_analytic_geometry_and_orientation() {
use mt::{InnerSpace, MetricSpace, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
let angle: f64 = 0.4;
let cases = [
("PLANE('',#2)".to_string(), mt::Point3::new(2.0, 3.0, 1.0), mt::Vector3::unit_z()),
("CYLINDRICAL_SURFACE('',#2,5.)".to_string(), mt::Point3::new(6.0, 0.0, 2.0), mt::Vector3::unit_x()),
("SPHERICAL_SURFACE('',#2,5.)".to_string(), mt::Point3::new(6.0, 0.0, 0.0), mt::Vector3::unit_x()),
("TOROIDAL_SURFACE('',#2,10.,2.)".to_string(), mt::Point3::new(13.0, 0.0, 0.0), mt::Vector3::unit_x()),
(
format!("CONICAL_SURFACE('',#2,5.,{angle})"),
mt::Point3::new(5.0 + 1.0 / angle.cos() + 2.0 * angle.tan(), 0.0, 2.0),
mt::Vector3::new(angle.cos(), 0.0, -angle.sin()),
),
];
for (definition, point, normal) in cases {
for sense in [true, false] {
let extent = [point, point + mt::Vector3::unit_z()];
let surface = offset_geometry(&definition, "1.", 1.0, sense, &extent).unwrap();
let uv = surface.search_nearest_parameter(point, None, 100).unwrap();
assert!(surface.subs(uv.0, uv.1).distance(point) < 1e-6, "{definition}");
assert!(surface.normal(uv.0, uv.1).dot(normal) * if sense { 1.0 } else { -1.0 } > 0.99, "{definition}, sense {sense}");
}
}
let p = mt::Point3::new(40.0, 0.0, 2.0);
let surface = offset_geometry("CYLINDRICAL_SURFACE('',#2,5.)", "-1.", 10.0, true, &[p, p + mt::Vector3::unit_z()]).unwrap();
let uv = surface.search_nearest_parameter(p, None, 100).unwrap();
assert!(surface.subs(uv.0, uv.1).distance(p) < 1e-6);
}
#[test]
fn offset_surfaces_nested_and_hostile() {
let points = [mt::Point3::new(0.0, 0.0, 0.0), mt::Point3::new(0.0, 0.0, 1.0)];
for (basis, offset) in [
("CYLINDRICAL_SURFACE('',#2,5.)", "-5."),
("SPHERICAL_SURFACE('',#2,5.)", "-6."),
("TOROIDAL_SURFACE('',#2,10.,2.)", "8."),
("TOROIDAL_SURFACE('',#2,10.,2.)", "-2."),
("CONICAL_SURFACE('',#2,5.,0.4)", "-10."),
("PLANE('',#2)", "1.E100"),
("PLANE('',#2)", "$"),
] {
assert!(offset_geometry(basis, offset, 1.0, true, &points).is_err(), "{basis}, {offset}");
}
let text = format!("{HEAD}#1=OFFSET_SURFACE('',#1,1.,.F.);{TAIL}");
let ex = p21::parse(&text).unwrap();
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
assert!(geom::surface(&cx, 1, &points, &[], true, 0).unwrap_err().contains("nested too deeply"));
let text = format!(
"{HEAD}#1=CARTESIAN_POINT('',(0.,0.,0.));#2=AXIS2_PLACEMENT_3D('',#1,$,$);#3=CYLINDRICAL_SURFACE('',#2,5.);#4=OFFSET_SURFACE('',#3,-6.,.F.);#5=OFFSET_SURFACE('',#4,6.,.F.);{TAIL}"
);
let ex = p21::parse(&text).unwrap();
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
assert!(geom::surface(&cx, 5, &points, &[], true, 0).is_err());
}
#[test]
fn offset_surface_cube_import_is_closed_and_editable() {
// Move each basis plane half a file unit inward; nested quarter-unit offsets
// bring its supporting surface back to the original unit cube boundary.
let mut entities = cube_entities();
let origins = [(0.0, 0.0, 0.5), (0.0, 0.0, 0.5), (0.0, 0.5, 0.0), (0.5, 0.0, 0.0), (1.0, 0.5, 0.0), (0.5, 1.0, 0.0)];
for (f, (x, y, z)) in origins.into_iter().enumerate() {
let base = 400 + f * 20;
let extra = 1000 + f * 10;
entities = entities
.replace(&format!("#{}=PLANE('',#{});", base + 10, base + 9), &format!("#{}=OFFSET_SURFACE('',#{},0.25,.F.);", base + 10, extra + 3));
entities += &format!(
"#{extra}=CARTESIAN_POINT('',({x},{y},{z}));\n#{}=AXIS2_PLACEMENT_3D('',#{extra},#{},$);\n#{}=PLANE('',#{});\n#{}=OFFSET_SURFACE('',#{},0.25,.F.);\n",
extra + 1,
base + 8,
extra + 2,
extra + 1,
extra + 3,
extra + 2
);
}
entities += &context(10, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))");
entities += "#9=ADVANCED_BREP_SHAPE_REPRESENTATION('Offsets',(#101),#10);\n";
let import = step_import(&format!("{HEAD}{entities}{TAIL}")).unwrap();
assert!(import.warnings.is_empty(), "{:?}", import.warnings);
assert!(import.bodies[0].closed);
assert_eq!(import.bodies[0].file_faces, 6);
let body = &import.bodies[0].body;
assert!(rel(measure(body).unwrap().volume, 1000.0) < 1e-6);
let cut = cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 12.0).unwrap();
let result = boolean(body, &cut, BoolOp::Cut).unwrap().unwrap();
assert!(rel(measure(&result).unwrap().volume, 1000.0 - 40.0 * std::f64::consts::PI) < 1e-3);
}
/// A hand-written unit cube brep (`#100`…) in a representation with the given unit and items.
fn cube_entities() -> String {
let mut s = String::new();
+141 -31
View File
@@ -300,6 +300,13 @@ fn same_geometry(a: &[(LCurve, Vec<Vec2>)], b: &[(LCurve, Vec<Vec2>)]) -> bool {
if a.len() != b.len() {
return false;
}
// Unchanged ordered links are common; avoid a quadratic permutation scan.
if a.iter().zip(b).all(|(x, y)| matches(x, y)) {
return true;
}
if a.len() > 512 {
return false;
}
let mut used = vec![false; b.len()];
a.iter().all(|x| {
let hit = b.iter().enumerate().find(|(j, y)| !used.get(*j).copied().unwrap_or(true) && matches(x, y)).map(|(j, _)| j);
@@ -330,6 +337,48 @@ fn curve_points(c: &LCurve) -> Vec<usize> {
}
}
/// Incidence roles distinguish nearby arc centres, endpoints and hole centres during a
/// resize. Spatial proximity alone can select a different kind of point in a dense rim.
fn point_roles(curves: &[LCurve], point_count: usize) -> Vec<[usize; 10]> {
let mut roles = vec![[0; 10]; point_count];
let mut add = |point: usize, role: usize| {
if let Some(count) = roles.get_mut(point).and_then(|r| r.get_mut(role)) {
*count += 1;
}
};
for curve in curves {
match *curve {
LCurve::Line(a, b) => {
add(a, 0);
add(b, 0);
}
LCurve::Circle(c, _) => add(c, 1),
LCurve::Arc(c, a, b) => {
add(c, 2);
add(a, 3);
add(b, 3);
}
LCurve::Conic(a, b, x, _) => {
add(a, 4);
add(b, 4);
add(x, 5);
}
LCurve::Ellipse(c, m, _) => {
add(c, 6);
add(m, 7);
}
LCurve::Spline(a, b, first, count, ..) => {
add(a, 8);
add(b, 8);
for i in first..first + count {
add(i, 9);
}
}
}
}
roles
}
impl Sketch {
pub fn link(&self, id: &str) -> Option<&Link> {
self.links.iter().find(|l| l.id == id)
@@ -539,26 +588,6 @@ impl Sketch {
if let Some(l) = self.links.iter_mut().find(|l| l.id == id) {
l.lost = false;
}
if same {
for (k, pi) in points.iter().enumerate() {
let p = order.get(k).and_then(|li| lay.pts.get(*li)).copied();
if let (Some(p), Some(sp)) = (p, self.points.get_mut(*pi)) {
sp.pos = p;
}
}
for (ci, c) in curves.iter().zip(&new) {
if let (LCurve::Circle(_, r), Some(Curve { kind: CurveKind::Circle { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
*slot = *r;
}
if let (LCurve::Conic(.., r), Some(Curve { kind: CurveKind::Conic { rho, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
*rho = *r;
}
if let (LCurve::Ellipse(.., r), Some(Curve { kind: CurveKind::Ellipse { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
*slot = *r;
}
}
return Ok(false);
}
// The same geometry walked in another order or direction (e.g. a face loop re-read after
// a rollback) is not a change: keep the entities, and the constraints on them.
if let Some(cur) = &cur {
@@ -588,6 +617,7 @@ impl Sketch {
&& points.len() == lay.pts.len()
&& cur.len() == lay.curves.len()
&& points.len() <= 512
&& cur.len() <= 512
{
let old: Vec<Vec2> = points.iter().filter_map(|i| self.point(*i)).collect();
let normalized = |ps: &[Vec2]| -> Vec<Vec2> {
@@ -596,9 +626,11 @@ impl Sketch {
ps.iter().map(|p| Vec2::new((p.x - lo.x) / (hi.x - lo.x).max(1e-9), (p.y - lo.y) / (hi.y - lo.y).max(1e-9))).collect()
};
let (a, b) = (normalized(&old), normalized(&lay.pts));
let (old_roles, fresh_roles) = (point_roles(cur, old.len()), point_roles(&lay.curves, lay.pts.len()));
let mut mapping = Vec::new();
for p in &a {
let mut candidates: Vec<(usize, f64)> = b.iter().enumerate().map(|(i, q)| (i, p.dist(*q))).collect();
for (old_i, p) in a.iter().enumerate() {
let mut candidates: Vec<(usize, f64)> =
b.iter().enumerate().filter(|(i, _)| old_roles.get(old_i) == fresh_roles.get(*i)).map(|(i, q)| (i, p.dist(*q))).collect();
candidates.sort_by(|x, y| x.1.total_cmp(&y.1));
if let Some((i, d)) = candidates.first()
&& *d < 0.1
@@ -613,22 +645,59 @@ impl Sketch {
let mut used = Vec::new();
let mut curve_map = Vec::new();
if mapping.len() == points.len() {
for c in cur {
for (curve_i, c) in cur.iter().enumerate() {
let mapped: Option<Vec<usize>> = curve_points(c).iter().map(|i| mapping.get(*i).copied()).collect();
let found = lay
let candidates: Vec<usize> = lay
.curves
.iter()
.enumerate()
.find(|(i, fresh)| {
if used.contains(i) || !same_shape(c, fresh) {
return false;
.filter_map(|(i, fresh)| {
if !same_shape(c, fresh) {
return None;
}
let fresh_pts = curve_points(fresh);
mapped.as_ref().is_some_and(|p| {
*p == fresh_pts || matches!(c, LCurve::Line(..)) && p.iter().rev().copied().collect::<Vec<_>>() == fresh_pts
})
mapped
.as_ref()
.is_some_and(|p| {
*p == fresh_pts || matches!(c, LCurve::Line(..)) && p.iter().rev().copied().collect::<Vec<_>>() == fresh_pts
})
.then_some(i)
})
.map(|(i, _)| i);
.collect();
let found = if candidates.len() == 1 {
candidates.first().copied()
} else if let LCurve::Circle(center, _) = c {
// Concentric circular boundaries share a point graph. Their radius
// order identifies the inner/outer rings through an upstream resize.
let mut previous: Vec<(usize, f64)> = cur
.iter()
.enumerate()
.filter_map(|(i, x)| match x {
LCurve::Circle(p, r) if p == center => Some((i, *r)),
_ => None,
})
.collect();
let mut incoming: Vec<(usize, f64)> = candidates
.iter()
.filter_map(|i| match lay.curves.get(*i) {
Some(LCurve::Circle(_, r)) => Some((*i, *r)),
_ => None,
})
.collect();
previous.sort_by(|a, b| a.1.total_cmp(&b.1));
incoming.sort_by(|a, b| a.1.total_cmp(&b.1));
let distinct = |xs: &[(usize, f64)]| {
xs.windows(2).all(|w| matches!(w, [a, b] if b.1 - a.1 > MERGE_TOL * (1.0 + a.1.abs().max(b.1.abs()))))
};
if previous.len() == incoming.len() && distinct(&previous) && distinct(&incoming) {
previous.iter().position(|(i, _)| *i == curve_i).and_then(|rank| incoming.get(rank).map(|(i, _)| *i))
} else {
None
}
} else {
None
};
let found = found.filter(|i| !used.contains(i));
if let Some(i) = found {
used.push(i);
curve_map.push(i);
@@ -649,10 +718,47 @@ impl Sketch {
{
*r = *value;
}
if let (Some(Curve { kind: CurveKind::Conic { rho, .. }, .. }), Some(LCurve::Conic(.., value))) =
(self.curves.get_mut(*ci), lay.curves.get(li))
{
*rho = *value;
}
if let (Some(Curve { kind: CurveKind::Ellipse { r, .. }, .. }), Some(LCurve::Ellipse(.., value))) =
(self.curves.get_mut(*ci), lay.curves.get(li))
{
*r = *value;
}
}
return Ok(false);
}
}
// Equal topology alone does not identify a face boundary: an outer rim and a
// hole can have identical point graphs. Never move saved curve ids between them
// when the geometric match is ambiguous; the document reports a lost link.
if same && self.link(id).is_some_and(|l| matches!(l.source, LinkSource::Face { .. })) {
self.set_link_lost(id, true);
return Err(SketchError::Invalid("projected face boundary identity is ambiguous; last geometry retained".into()));
}
if same {
for (k, pi) in points.iter().enumerate() {
let p = order.get(k).and_then(|li| lay.pts.get(*li)).copied();
if let (Some(p), Some(sp)) = (p, self.points.get_mut(*pi)) {
sp.pos = p;
}
}
for (ci, c) in curves.iter().zip(&new) {
if let (LCurve::Circle(_, r), Some(Curve { kind: CurveKind::Circle { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
*slot = *r;
}
if let (LCurve::Conic(.., r), Some(Curve { kind: CurveKind::Conic { rho, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
*rho = *r;
}
if let (LCurve::Ellipse(.., r), Some(Curve { kind: CurveKind::Ellipse { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
*slot = *r;
}
}
return Ok(false);
}
// Rebuild: drop the old entities (constraints on them go too) and make new ones.
self.drop_link_entities(id);
self.build_link(id, &lay)?;
@@ -724,3 +830,7 @@ impl Sketch {
self.links.retain(|_| k.next().copied().unwrap_or(true));
}
}
#[cfg(test)]
#[path = "link_identity_tests.rs"]
mod identity_tests;
+158
View File
@@ -0,0 +1,158 @@
use super::*;
use crate::{ConstraintKind, solve};
fn square(size: f64) -> Vec<LinkGeom> {
let pts = [Vec2::new(-size, -size), Vec2::new(size, -size), Vec2::new(size, size), Vec2::new(-size, size)];
(0..4).map(|i| LinkGeom::Line(pts[i], pts[(i + 1) % 4])).collect()
}
fn face() -> LinkSource {
LinkSource::Face { body: "Case".into(), at: Vec3::ZERO }
}
fn ring(outer_first: bool, outer: f64, inner: f64) -> Vec<LinkGeom> {
let (first, second) = if outer_first { (outer, inner) } else { (inner, outer) };
square(first).into_iter().chain(square(second)).collect()
}
#[test]
fn saved_face_link_keeps_inner_ids_when_boundary_order_changes() {
let mut sk = Sketch::new();
// An older saved face may have enumerated its inner boundary first.
let link = sk.add_link(LinkKind::Project, face(), &ring(false, 10.0, 8.0)).unwrap();
let inner = sk.link_curves(&link)[0];
let corner = match sk.curves[inner].kind {
CurveKind::Line { a, .. } => a,
_ => panic!(),
};
let free = sk.add_point(Vec2::new(-8.0, -8.0), None).unwrap();
sk.add_constraint(ConstraintKind::Coincident { p: free, q: corner }, None).unwrap();
let serialized = serde_json::to_string(&sk).unwrap();
let mut sk: Sketch = serde_json::from_str(&serialized).unwrap();
let before = sk.clone();
assert!(!sk.update_link(&link, &ring(true, 10.0, 8.0)).unwrap());
assert_eq!(sk, before, "canonical outer-first enumeration must not reinterpret saved ids");
assert!(solve(&mut sk).ok());
assert_eq!(sk.points[free].pos, Vec2::new(-8.0, -8.0));
}
#[test]
fn resized_and_reordered_face_keeps_constraints_on_the_same_boundary() {
let mut sk = Sketch::new();
let link = sk.add_link(LinkKind::Project, face(), &ring(false, 10.0, 8.0)).unwrap();
let points = sk.link_points(&link);
let original: Vec<Vec2> = points.iter().map(|p| sk.points[*p].pos).collect();
let ids: Vec<String> = sk.link_curves(&link).iter().map(|c| sk.curves[*c].id.clone()).collect();
let free = sk.add_point(Vec2::new(-8.0, -8.0), None).unwrap();
sk.add_constraint(ConstraintKind::Coincident { p: free, q: points[0] }, None).unwrap();
let fresh: Vec<LinkGeom> = ring(true, 10.0, 8.0)
.into_iter()
.map(|g| match g {
LinkGeom::Line(a, b) => LinkGeom::Line(Vec2::new(b.x * 2.0, b.y * 3.0), Vec2::new(a.x * 2.0, a.y * 3.0)),
_ => panic!(),
})
.collect();
assert!(!sk.update_link(&link, &fresh).unwrap());
for (p, old) in points.iter().zip(original) {
assert_eq!(sk.points[*p].pos, Vec2::new(old.x * 2.0, old.y * 3.0));
}
assert_eq!(ids, sk.link_curves(&link).iter().map(|c| sk.curves[*c].id.clone()).collect::<Vec<_>>());
assert!(solve(&mut sk).ok());
assert!(sk.points[free].pos.dist(Vec2::new(-16.0, -24.0)) < 1e-7);
}
#[test]
fn ambiguous_face_refresh_preserves_last_geometry_and_marks_link_lost() {
let mut sk = Sketch::new();
let link = sk.add_link(LinkKind::Project, face(), &ring(true, 10.0, 9.9)).unwrap();
let before = sk.clone();
assert!(sk.update_link(&link, &ring(false, 10.0, 2.0)).is_err());
assert_eq!(sk.points, before.points);
assert_eq!(sk.curves, before.curves);
assert!(sk.link(&link).unwrap().lost);
}
#[test]
fn large_face_permutation_is_bounded_and_preserves_geometry() {
let mut sk = Sketch::new();
let geom: Vec<LinkGeom> = (1..=129).flat_map(|i| square(i as f64)).collect();
let link = sk.add_link(LinkKind::Project, face(), &geom).unwrap();
assert!(!sk.update_link(&link, &geom).unwrap(), "ordered unchanged geometry needs no permutation scan");
let before = sk.clone();
let reversed: Vec<LinkGeom> = (1..=129).rev().flat_map(|i| square(i as f64)).collect();
assert!(sk.update_link(&link, &reversed).is_err());
assert_eq!(sk.points, before.points);
assert_eq!(sk.curves, before.curves);
}
#[test]
fn geometric_refresh_updates_conic_and_ellipse_scalar_values() {
let mut sk = Sketch::new();
let geom = |rho, minor| {
vec![
LinkGeom::Conic { a: Vec2::new(1.0, 1.0), apex: Vec2::new(2.0, 3.0), b: Vec2::new(4.0, 1.0), rho },
LinkGeom::Ellipse { c: Vec2::new(10.0, 10.0), major: Vec2::new(14.0, 10.0), minor },
]
};
let link = sk.add_link(LinkKind::Project, LinkSource::Origin, &geom(0.4, 2.0)).unwrap();
assert!(!sk.update_link(&link, &geom(0.6, 3.0)).unwrap());
assert!(matches!(sk.curves[0].kind, CurveKind::Conic { rho, .. } if rho == 0.6));
assert!(matches!(sk.curves[1].kind, CurveKind::Ellipse { r, .. } if r == 3.0));
}
#[test]
fn many_curves_sharing_few_points_do_not_enter_permutation_matching() {
let mut sk = Sketch::new();
let geom = vec![LinkGeom::Line(Vec2::new(1.0, 1.0), Vec2::new(2.0, 2.0)); 513];
let link = sk.add_link(LinkKind::Project, face(), &geom).unwrap();
assert_eq!(sk.link_points(&link).len(), 2);
let before = sk.clone();
let shifted = vec![LinkGeom::Line(Vec2::new(3.0, 3.0), Vec2::new(4.0, 4.0)); 513];
assert!(sk.update_link(&link, &shifted).is_err());
assert_eq!(sk.points, before.points);
assert_eq!(sk.curves, before.curves);
assert!(sk.link(&link).unwrap().lost);
}
#[test]
fn concentric_face_resize_keeps_inner_outer_circle_identity() {
let mut sk = Sketch::new();
let link = sk.add_link(LinkKind::Project, face(), &[LinkGeom::Circle(Vec2::ZERO, 8.0), LinkGeom::Circle(Vec2::ZERO, 10.0)]).unwrap();
let ids: Vec<String> = sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect();
assert!(!sk.update_link(&link, &[LinkGeom::Circle(Vec2::ZERO, 20.0), LinkGeom::Circle(Vec2::ZERO, 16.0)]).unwrap());
assert!(matches!(sk.curves[0].kind, CurveKind::Circle { r, .. } if r == 16.0));
assert!(matches!(sk.curves[1].kind, CurveKind::Circle { r, .. } if r == 20.0));
assert_eq!(ids, sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect::<Vec<_>>());
// Coincident circles do not have a unique boundary identity.
let before = sk.clone();
assert!(sk.update_link(&link, &[LinkGeom::Circle(Vec2::ZERO, 18.0), LinkGeom::Circle(Vec2::ZERO, 18.0)]).is_err());
assert_eq!(sk.points, before.points);
assert_eq!(sk.curves, before.curves);
}
#[test]
fn resized_dense_rim_matches_arc_centres_by_role_before_proximity() {
let mut sk = Sketch::new();
let geometry = |half_width: f64, x: f64| {
let mut out = square(half_width);
let c = Vec2::new(x, -16.5);
let left = c - Vec2::X * 3.0;
let right = c + Vec2::X * 3.0;
out.push(LinkGeom::Arc { c, a: right, b: left });
out.push(LinkGeom::Arc { c, a: left, b: right });
out
};
let link = sk.add_link(LinkKind::Project, face(), &geometry(38.0, -26.5)).unwrap();
let arcs: Vec<usize> = sk.link_curves(&link).into_iter().filter(|i| matches!(sk.curves[*i].kind, CurveKind::Arc { .. })).collect();
let ids: Vec<String> = arcs.iter().map(|i| sk.curves[*i].id.clone()).collect();
// The old centre is spatially closer in normalized space to a fresh endpoint.
// Both semicircles must still keep the same shared centre and logical endpoints.
assert!(!sk.update_link(&link, &geometry(43.0, -31.5)).unwrap());
for i in &arcs {
let CurveKind::Arc { c, a, b } = sk.curves[*i].kind else { panic!() };
assert_eq!(sk.points[c].pos, Vec2::new(-31.5, -16.5));
assert!((sk.points[a].pos.dist(sk.points[c].pos) - 3.0).abs() < 1e-9);
assert!((sk.points[b].pos.dist(sk.points[c].pos) - 3.0).abs() < 1e-9);
}
assert_eq!(ids, arcs.iter().map(|i| sk.curves[*i].id.clone()).collect::<Vec<_>>());
}
+47 -2
View File
@@ -9,7 +9,7 @@
//! gives the remaining degrees of freedom and which points are fully determined.
use serde::{Deserialize, Serialize};
use solvecraft_geom::Vec2;
use solvecraft_geom::{Seg2, Vec2};
use crate::linalg::{Mat, determined_vars, solve as lin_solve};
use crate::model::{ConstraintKind, CurveKind, Sketch};
@@ -177,6 +177,51 @@ impl State<'_> {
_ => Vec::new(),
}
}
/// Point-on-curve constraints use the exact shape. A polyline residual would move an
/// exact crossing onto its chord, changing geometry merely by splitting it.
fn freeform_dist(&self, c: usize, q: Vec2) -> f64 {
let segs: Vec<Seg2> = match self.sk.curves.get(c).map(|c| &c.kind) {
Some(CurveKind::Ellipse { c: ci, m, .. }) => {
let center = self.p(*ci);
let u = self.p(*m) - center;
let a = u.len();
let r = self.radius_of(c);
if a <= 1e-12 || r <= 1e-12 {
return q.dist(center);
}
let delta = q - center;
let x = delta.dot(u) / (a * a);
let y = delta.cross(u) / (a * r);
return (x.hypot(y) - 1.0) * a.min(r);
}
Some(CurveKind::Spline { pts, control, degree }) => {
let p: Vec<Vec2> = pts.iter().map(|i| self.p(*i)).collect();
if p.len() < 2 {
return q.dist(p.first().copied().unwrap_or_default());
}
if *control {
let k = (*degree as usize).min(solvecraft_geom::MAX_BEZIER_DEGREE).min(p.len().saturating_sub(1)).max(1);
crate::curves::bspline_beziers(&p, k).iter().filter_map(|span| solvecraft_geom::bezier(span)).collect()
} else {
crate::curves::fit_beziers(&p).into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect()
}
}
Some(CurveKind::Conic { a, b, apex, rho }) => {
vec![Seg2::Conic { a: self.p(*a), b: self.p(*b), apex: self.p(*apex), w: rho / (1.0 - rho).max(1e-9) }]
}
_ => Vec::new(),
};
segs.iter()
.map(|seg| {
let t = seg.closest_param(q);
let delta = q - seg.point_at(t);
let d = delta.len();
(d, if seg.derivative(t).cross(delta) >= 0.0 { d } else { -d })
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, signed)| signed)
.unwrap_or_else(|| crate::curves::polyline_signed_dist(&self.poly(c), q))
}
/// Radius of a circle or arc; an ellipse's minor radius.
fn radius_of(&self, c: usize) -> f64 {
match self.sk.curves.get(c).map(|x| &x.kind) {
@@ -281,7 +326,7 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec<f64>) {
PointOnCurve { p, c } => {
let pp = s.p(p);
if s.is_freeform(c) {
out.push(crate::curves::polyline_signed_dist(&s.poly(c), pp));
out.push(s.freeform_dist(c, pp));
} else if s.is_line(c) {
let (a, b) = s.line(c);
out.push(line_dist(pp, a, b));
+45 -9
View File
@@ -366,7 +366,7 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
v.push(act("ui.editFeature", "Edit Feature", "").with(json!({ "feature": feature })).on(feature.is_some()));
v.push(act("ui.editSketch", "Edit Profile Sketch", "sketch").with(json!({ "sketch": sketch })).on(sketch.is_some()));
v.push(Item::sep());
v.push(act("ui.appearance", "Appearance", "").key("A").on(false));
v.push(act("ui.appearance", "Appearance", "appearance").key("A"));
v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": [face] })));
v.push(Item::sep());
v.push(act("ui.delete", "Delete", "delete").key("Del"));
@@ -437,7 +437,7 @@ fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec<Item> {
cmd(app, "component.from_bodies", "Create Components from Bodies").with(json!({ "bodies": names })),
Item::sep(),
act("ui.material", "Physical Material", "").with(json!({ "bodies": names })),
act("ui.appearance", "Appearance", "").key("A").on(false),
act("ui.appearance", "Appearance", "appearance").key("A").with(json!({ "bodies": names })),
act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })),
Item::sep(),
act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": names })),
@@ -530,8 +530,13 @@ fn component_items(app: &SolveApp, id: u64) -> Vec<Item> {
}
v.push(Item::sep());
v.push(act("ui.material", "Physical Material", "").with(json!({ "bodies": names })).on(!bodies.is_empty()));
v.push(act("ui.appearance", "Appearance", "").key("A").on(false));
v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })).on(!bodies.is_empty()));
v.push(
act("ui.appearance", "Appearance", "appearance")
.key("A")
.with(if id == 0 { json!({ "bodies": names }) } else { json!({"component": id}) })
.on(id != 0 || !bodies.is_empty()),
);
v.push(act("ui.properties", "Properties", "measure").with(json!({ "component": id })));
v.push(Item::sep());
v.push(act("ui.export", "Export…", "export").with(json!({ "bodies": names })).on(!bodies.is_empty()));
v.push(act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": names })).on(!bodies.is_empty()));
@@ -716,10 +721,20 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
};
start_rename(app, what, at);
}
"ui.properties" => match app.session.execute("inspect.measure", &json!({ "bodies": bodies })) {
Ok(v) => app.menu.props = Some((if bodies.len() == 1 { bodies.join("") } else { format!("{} bodies", bodies.len()) }, v)),
Err(e) => app.set_status(e.to_string(), true),
},
"ui.properties" => {
let command = if p.get("component").is_some() { "component.properties" } else { "inspect.measure" };
let params = if command == "component.properties" { p.clone() } else { json!({ "bodies": bodies }) };
match app.session.execute(command, &params) {
Ok(v) => {
let title = v["name"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| if bodies.len() == 1 { bodies.join("") } else { format!("{} bodies", bodies.len()) });
app.menu.props = Some((title, v));
}
Err(e) => app.set_status(e.to_string(), true),
}
}
"ui.export" => {
let name = bodies.first().cloned().unwrap_or_else(|| app.session.doc.name.clone());
if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&format!("{name}.step"), &["step", "stp", "igs", "stl", "3mf", "obj"]))
@@ -847,6 +862,26 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
app.edit_feature(f);
}
}
"ui.appearance" => {
if item.params.is_some() {
let items: Vec<Value> = bodies.iter().map(|b| json!({"type": "body", "name": b})).collect();
let _ = app.run("select.set", json!({ "items": items }));
}
app.start("appearance.assign");
if let Some(name) = id_of(&p, "component").and_then(|id| app.session.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()))
&& let Some(d) = app.dialog.as_mut()
&& let crate::dialogs::Kind::Appearance(k) = &mut d.kind
{
if let Some(id) = id_of(&p, "component")
&& let Some(look) = app.session.doc.appearances.components.get(&id)
{
k.color = look.color;
k.opacity = look.opacity;
k.look = Some(look.name.clone());
}
k.component = Some(name);
}
}
"ui.material" => {
let items: Vec<Value> = bodies.iter().map(|b| json!({"type": "body", "name": b})).collect();
let _ = app.run("select.set", json!({ "items": items }));
@@ -1502,7 +1537,8 @@ fn show_props(app: &mut SolveApp, ctx: &egui::Context) {
ui.label("Area");
ui.label(crate::prefs::show_mm(app, f(total, "area_mm2"), 2));
ui.end_row();
if let [b] = bodies.as_slice() {
let geometry = if total.get("center_of_mass").is_some() { Some(total) } else { bodies.first().filter(|_| bodies.len() == 1) };
if let Some(b) = geometry {
if let Some(c) = b["center_of_mass"].as_array() {
let c: Vec<String> = c.iter().map(|x| crate::prefs::show_mm_bare(app, x.as_f64().unwrap_or(f64::NAN))).collect();
ui.label("Center of mass");
+12 -1
View File
@@ -1226,6 +1226,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
.stroke(Stroke::new(1.0, t.dialog_border))
.corner_radius(egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 })
.shadow(egui::Shadow { offset: [-2, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(40) });
// The area includes the border as well as the inner margins; reserve all of it so a
// horizontally scrolling table cannot push a wide dialog two pixels past its limit.
let horizontal_frame = frame.inner_margin.leftf() + frame.inner_margin.rightf() + 2.0 * frame.stroke.width;
// OK is offered once the inputs make a feature.
let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. });
let valid = !has_ok || !d.previews() || (apply_commands(app, &d).is_ok() && app.preview.error.is_none());
@@ -1245,7 +1248,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.spacing_mut().text_edit_width = FIELD_W;
// Long texts (errors, notes) wrap instead of widening the dialog.
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
let margin = 2.0 * ui.style().spacing.window_margin.leftf();
let margin = horizontal_frame;
let min_w = if wide { 480.0 } else { DIALOG_MIN_W } - margin;
ui.set_min_width(min_w);
ui.set_max_width(if wide { DIALOG_WIDE_MAX_W } else { DIALOG_MAX_W } - margin);
@@ -1268,7 +1271,15 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
return;
}
ui.add_space(2.0);
// Configuration tables own their column widths and scrolling; the two-column form
// grid would squeeze their row names and action buttons into a label-sized cell.
if let Kind::Motion(k @ crate::dialogs_motion::Mo::Configurations { .. }) = &mut d.kind {
enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs);
}
egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
if matches!(&d.kind, Kind::Motion(crate::dialogs_motion::Mo::Configurations { .. })) {
return;
}
placement_row(&mut d, ui);
input_rows(&mut d, ui);
match &mut d.kind {
+251 -48
View File
@@ -19,12 +19,12 @@ pub struct Look {
pub name: String,
pub category: String,
pub color: [u8; 3],
pub opacity: f32,
pub opacity: f64,
}
/// Built-in looks, used until the engine offers a library (`appearance.library`).
fn builtin() -> Vec<Look> {
let l = |cat: &str, name: &str, c: [u8; 3], o: f32| Look { name: name.into(), category: cat.into(), color: c, opacity: o };
let l = |cat: &str, name: &str, c: [u8; 3], o: f64| Look { name: name.into(), category: cat.into(), color: c, opacity: o };
vec![
l("Metal", "Aluminum - Satin", [196, 199, 204], 1.0),
l("Metal", "Steel - Brushed", [160, 164, 170], 1.0),
@@ -63,7 +63,7 @@ pub fn library(app: &mut SolveApp) -> Vec<Look> {
name: x.get("name")?.as_str()?.to_string(),
category: x.get("category").and_then(Value::as_str).unwrap_or("Library").to_string(),
color: parse_color(x.get("color")?)?,
opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0) as f32,
opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0),
})
})
.collect();
@@ -96,27 +96,61 @@ pub struct Ap {
/// Apply to faces instead of bodies.
pub faces: bool,
pub color: [u8; 3],
pub opacity: f32,
pub opacity: f64,
/// The look picked last (its name), if the colour came from one.
pub look: Option<String>,
/// Library category shown (0: all).
pub category: usize,
/// Library names can be searched without changing the selected look.
pub search: String,
/// A Browser component target, inherited by its bodies unless they have overrides.
pub component: Option<String>,
pub remove_override: bool,
/// The last successful immediate Apply/drop, so OK does not duplicate its undo step.
pub applied: Option<Value>,
/// What the last drop or Apply did.
pub note: Option<String>,
}
/// The dialog for the Appearance command.
pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
(id == "appearance.assign").then(|| {
let faces = app.session.selection.iter().any(|s| matches!(s, Sel::Face { .. }))
&& !app.session.selection.iter().any(|s| matches!(s, Sel::Body { .. }));
let state = app.session.world_state();
let existing = app.session.selection.iter().find_map(|sel| match sel {
Sel::Body { name } => state.body(name).and_then(|b| app.session.doc.body_look(name, b.feature)),
Sel::Face { body, index, .. } => state.body(body).and_then(|b| {
app.session
.doc
.face_colors(b)
.into_iter()
.find(|(i, _)| i == index)
.map(|(_, look)| look)
.or_else(|| app.session.doc.body_look(body, b.feature))
}),
_ => None,
});
(
Kind::Appearance(Ap { faces: false, color: [196, 199, 204], opacity: 1.0, look: None, category: 0, note: None }),
vec![SelInput::new("Objects", BODIES, true)],
Kind::Appearance(Ap {
faces,
color: existing.as_ref().map_or([196, 199, 204], |l| l.color),
opacity: existing.as_ref().map_or(1.0, |l| l.opacity),
look: existing.map(|l| l.name),
category: 0,
search: String::new(),
component: None,
remove_override: false,
applied: None,
note: None,
}),
vec![SelInput::new("Objects", if faces { FACES } else { BODIES }, true)],
)
})
}
/// The command giving `targets` (bodies or faces) a look.
fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f32, look: Option<&str>) -> Option<Value> {
fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f64, look: Option<&str>) -> Option<Value> {
let bodies: Vec<String> = targets.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
let faces: Vec<Value> = targets
.iter()
@@ -139,9 +173,7 @@ fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f32, look: Option<&str
p["bodies"] = json!(owners);
}
}
if opacity < 0.999 {
p["opacity"] = json!((opacity * 1000.0).round() / 1000.0);
}
p["opacity"] = json!(opacity);
if let Some(n) = look {
p["appearance"] = json!(n);
}
@@ -150,8 +182,35 @@ fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f32, look: Option<&str
/// The commands OK runs: the current look on the picked objects (nothing when none are picked).
pub fn commands(k: &Ap, inputs: &[SelInput]) -> Vec<(String, Value)> {
// Remove override already ran immediately; OK closes without creating a second undo step.
if k.remove_override {
return Vec::new();
}
let items = inputs.first().map(|i| i.items.as_slice()).unwrap_or(&[]);
apply_params(items, k.color, k.opacity, k.look.as_deref()).map(|p| vec![("appearance.assign".to_string(), p)]).unwrap_or_default()
let p = selected_params(k, items);
if p.as_ref().is_some_and(|p| k.applied.as_ref() == Some(p)) {
return Vec::new();
}
p.map(|p| vec![("appearance.assign".to_string(), p)]).unwrap_or_default()
}
fn selected_params(k: &Ap, items: &[Sel]) -> Option<Value> {
if let Some(component) = &k.component {
Some(json!({"components": [component], "color": hex(k.color), "opacity": k.opacity, "appearance": k.look}))
} else {
apply_params(items, k.color, k.opacity, k.look.as_deref())
}
}
/// Removing an override restores the inherited component or material appearance.
fn clear_params(k: &Ap, items: &[Sel]) -> Option<Value> {
let mut p = selected_params(k, items)?;
if let Some(obj) = p.as_object_mut() {
obj.remove("appearance");
obj.remove("color");
obj.remove("opacity");
}
Some(p)
}
/// What a swatch dropped now would land on: the face or body under the cursor in the viewport,
@@ -166,32 +225,43 @@ fn drop_target(app: &SolveApp, faces: bool) -> Vec<Sel> {
/// The looks in this design (colour and how many items wear it): the engine's list when it has
/// one, else the body colours.
fn in_design(app: &mut SolveApp) -> Vec<([u8; 3], usize)> {
fn in_design(app: &mut SolveApp) -> Vec<(Look, usize)> {
if let Ok(v) = app.session.execute("appearance.list", &json!({}))
&& let Some(list) = v.get("in_design").and_then(Value::as_array)
{
let count = |x: &Value, k: &str| x.get(k).and_then(Value::as_array).map_or(0, Vec::len);
return list
.iter()
.filter_map(|x| Some((parse_color(x.get("color")?)?, count(x, "bodies") + count(x, "faces") + count(x, "components"))))
.filter_map(|x| {
Some((
Look {
name: x.get("name").and_then(Value::as_str).unwrap_or("Custom").to_string(),
category: "In This Design".into(),
color: parse_color(x.get("color")?)?,
opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0),
},
count(x, "bodies") + count(x, "faces") + count(x, "components"),
))
})
.collect();
}
let mut out: Vec<([u8; 3], usize)> = Vec::new();
for c in app.session.doc.appearances.bodies.values().map(|l| l.color) {
match out.iter_mut().find(|(x, _)| *x == c) {
let mut out: Vec<(Look, usize)> = Vec::new();
for l in app.session.doc.appearances.bodies.values() {
let look = Look { name: l.name.clone(), category: "In This Design".into(), color: l.color, opacity: l.opacity };
match out.iter_mut().find(|(x, _)| *x == look) {
Some(e) => e.1 += 1,
None => out.push((c, 1)),
None => out.push((look, 1)),
}
}
out
}
fn c32(c: [u8; 3], opacity: f32) -> Color32 {
fn c32(c: [u8; 3], opacity: f64) -> Color32 {
Color32::from_rgba_unmultiplied(c[0], c[1], c[2], (opacity.clamp(0.0, 1.0) * 255.0) as u8)
}
/// A swatch: a rounded square of the look; returns its response (click, drag).
fn swatch(ui: &mut egui::Ui, id: egui::Id, color: [u8; 3], opacity: f32, picked: bool, tip: &str, payload: Look) -> egui::Response {
fn swatch(ui: &mut egui::Ui, id: egui::Id, color: [u8; 3], opacity: f64, picked: bool, tip: &str, payload: Look) -> egui::Response {
let t = Tokens::get();
let r = ui
.dnd_drag_source(id, payload, |ui| {
@@ -200,7 +270,7 @@ fn swatch(ui: &mut egui::Ui, id: egui::Id, color: [u8; 3], opacity: f32, picked:
if opacity < 0.999 {
let h = rect.width() / 2.0;
for (dx, dy) in [(0.0, 0.0), (h, h)] {
ui.painter().rect_filled(egui::Rect::from_min_size(rect.min + vec2(dx, dy), vec2(h, h)), 0.0, Color32::from_gray(200));
ui.painter().rect_filled(egui::Rect::from_min_size(rect.min + vec2(dx, dy), vec2(h, h)), 0.0, t.hover);
}
}
ui.painter().rect(
@@ -238,13 +308,23 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
let t = Tokens::get();
// Apply To: bodies or faces (the input follows).
row_label(ui, "Apply To");
let mut mode = usize::from(k.faces);
combo(ui, "ap_mode", &["Bodies/Components", "Faces"], &mut mode);
ui.end_row();
if (mode == 1) != k.faces {
k.faces = mode == 1;
if let Some(i) = inputs.first_mut() {
*i = SelInput::new("Objects", if k.faces { FACES } else { BODIES }, true);
if let Some(component) = &k.component {
ui.label(format!("Component: {component}"));
ui.end_row();
row_label(ui, "");
if ui.button("Select bodies or faces instead").clicked() {
k.component = None;
}
ui.end_row();
} else {
let mut mode = usize::from(k.faces);
combo(ui, "ap_mode", &["Bodies", "Faces"], &mut mode);
ui.end_row();
if (mode == 1) != k.faces {
k.faces = mode == 1;
if let Some(i) = inputs.first_mut() {
*i = SelInput::new("Objects", if k.faces { FACES } else { BODIES }, true);
}
}
}
// The current look: colour and opacity.
@@ -254,35 +334,71 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
if ui.color_edit_button_srgb(&mut rgb).changed() {
k.color = rgb;
k.look = None;
k.remove_override = false;
}
ui.label(RichText::new(k.look.clone().unwrap_or_else(|| hex(k.color))).color(t.text_dim));
});
ui.end_row();
row_label(ui, "Opacity");
ui.spacing_mut().slider_width = 110.0;
ui.add(egui::Slider::new(&mut k.opacity, 0.05..=1.0).fixed_decimals(2));
if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).fixed_decimals(2)).changed() {
k.remove_override = false;
}
ui.end_row();
row_label(ui, "");
let picked: Vec<Sel> = inputs.first().map(|i| i.items.clone()).unwrap_or_default();
if ui.add_enabled(!picked.is_empty(), egui::Button::new("Apply to selection")).clicked()
&& let Some(p) = apply_params(&picked, k.color, k.opacity, k.look.as_deref())
{
k.note = Some(match app.run("appearance.assign", p) {
Ok(_) => format!("applied to {} item(s)", picked.len()),
Err(e) => e,
});
}
ui.horizontal(|ui| {
let has_target = !picked.is_empty() || k.component.is_some();
if ui.add_enabled(has_target, egui::Button::new("Apply to selection")).clicked()
&& let Some(p) = selected_params(k, &picked)
{
k.remove_override = false;
k.note = Some(match app.run("appearance.assign", p.clone()) {
Ok(_) => {
k.applied = Some(p);
"Appearance applied".into()
}
Err(e) => e,
});
}
if ui
.add_enabled(has_target, egui::Button::new("Remove override"))
.on_hover_text("Restore the inherited component or physical material appearance")
.clicked()
&& let Some(p) = clear_params(k, &picked)
{
k.note = Some(match app.run("appearance.assign", p) {
Ok(_) => {
k.remove_override = true;
k.applied = None;
"Inherited appearance restored".into()
}
Err(e) => e,
});
}
});
ui.end_row();
// In this design.
let used = in_design(app);
if !used.is_empty() {
row_label(ui, "In This Design");
let items: Vec<(usize, &([u8; 3], usize))> = used.iter().enumerate().collect();
swatch_rows(ui, &items, |ui, (i, (c, n))| {
let look = Look { name: hex(*c), category: "In This Design".into(), color: *c, opacity: 1.0 };
if swatch(ui, egui::Id::new(("ap_used", *i)), *c, 1.0, k.color == *c, &format!("{} · {n} item(s)", hex(*c)), look).clicked() {
k.color = *c;
k.look = None;
let items: Vec<_> = used.iter().enumerate().collect();
swatch_rows(ui, &items, |ui, (i, (look, n))| {
if swatch(
ui,
egui::Id::new(("ap_used", *i)),
look.color,
look.opacity,
k.color == look.color && k.opacity == look.opacity,
&format!("{} · {n} item(s)", look.name),
look.clone(),
)
.clicked()
{
k.color = look.color;
k.opacity = look.opacity;
k.look = Some(look.name.clone());
k.remove_override = false;
}
});
ui.end_row();
@@ -299,9 +415,18 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
row_label(ui, "Library");
combo(ui, "ap_cat", &refs, &mut k.category);
ui.end_row();
row_label(ui, "Search");
let search = ui.add(egui::TextEdit::singleline(&mut k.search).hint_text("Appearance name").desired_width(190.0));
crate::scenario::publish_handle("appearance:search", search.rect.center());
ui.end_row();
row_label(ui, "");
let want = cats.get(k.category).cloned().unwrap_or_default();
let shown: Vec<(usize, &Look)> = lib.iter().enumerate().filter(|(_, l)| k.category == 0 || l.category == want).collect();
let query = k.search.trim().to_lowercase();
let shown: Vec<(usize, &Look)> =
lib.iter().enumerate().filter(|(_, l)| (k.category == 0 || l.category == want) && l.name.to_lowercase().contains(&query)).collect();
if shown.is_empty() {
ui.label(RichText::new("No matching appearances").color(t.text_dim));
}
swatch_rows(ui, &shown, |ui, (i, l)| {
let picked_look = k.look.as_deref() == Some(l.name.as_str());
if swatch(ui, egui::Id::new(("ap_lib", *i)), l.color, l.opacity, picked_look, &format!("{} (drag onto a body or face)", l.name), (*l).clone())
@@ -310,6 +435,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
k.color = l.color;
k.opacity = l.opacity;
k.look = Some(l.name.clone());
k.remove_override = false;
}
});
ui.end_row();
@@ -319,10 +445,14 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
let targets = drop_target(app, k.faces);
k.color = look.color;
k.opacity = look.opacity;
k.look = (look.category != "In This Design").then(|| look.name.clone());
k.look = Some(look.name.clone());
k.remove_override = false;
if let Some(p) = apply_params(&targets, look.color, look.opacity, k.look.as_deref()) {
k.note = Some(match app.run("appearance.assign", p) {
Ok(_) => format!("{} on {}", look.name, target_names(&targets)),
k.note = Some(match app.run("appearance.assign", p.clone()) {
Ok(_) => {
k.applied = Some(p);
format!("{} on {}", look.name, target_names(&targets))
}
Err(e) => e,
});
}
@@ -392,7 +522,11 @@ mod tests {
a.run(&id, p).unwrap();
}
assert_eq!(a.session.doc.appearances.bodies.get("Body2").map(|l| l.color), Some([196, 40, 36]));
assert_eq!(in_design(&mut a), vec![([196, 40, 36], 2)]);
let used = in_design(&mut a);
assert_eq!(used.len(), 1);
assert_eq!(used[0].0.color, [196, 40, 36]);
assert_eq!(used[0].0.name, "Paint - Red");
assert_eq!(used[0].1, 2);
}
/// A swatch dropped on a face in face mode targets the face; otherwise its body.
@@ -411,4 +545,73 @@ mod tests {
assert_eq!(face["opacity"], 0.5);
assert!(!library(&mut a).is_empty());
}
#[test]
fn face_preselection_keeps_the_face_and_remove_survives_ok() {
let mut a = app();
let point = Vec3::new(5.0, 5.0, 10.0);
let selected = vec![Sel::Face { body: "Body1".into(), index: 0, point }];
a.session.selection = selected.clone();
a.start("appearance.assign");
let mut d = a.dialog.clone().unwrap();
assert_eq!(d.inputs[0].items, selected);
let Kind::Appearance(k) = &mut d.kind else { panic!("appearance dialog") };
assert!(k.faces);
k.color = [10, 20, 30];
k.opacity = 0.25;
for (id, p) in commands(k, &d.inputs) {
a.run(&id, p).unwrap();
}
assert_eq!(a.session.doc.appearances.faces.len(), 1);
assert!(a.session.doc.appearances.bodies.is_empty());
a.run("appearance.assign", clear_params(k, &d.inputs[0].items).unwrap()).unwrap();
k.remove_override = true;
assert!(commands(k, &d.inputs).is_empty());
assert!(a.session.doc.appearances.faces.is_empty());
a.run("edit.undo", json!({})).unwrap();
assert_eq!(a.session.doc.appearances.faces.len(), 1);
}
#[test]
fn reused_design_look_preserves_transparency_and_name() {
let mut a = app();
a.run("appearance.assign", json!({"bodies": ["Body1"], "color": "#abcdef", "appearance": "Tinted glass", "opacity": 0.25})).unwrap();
let used = in_design(&mut a);
let look = &used[0].0;
assert_eq!(look.opacity, 0.25);
assert_eq!(look.name, "Tinted glass");
a.session.selection = vec![Sel::Body { name: "Body1".into() }];
a.start("appearance.assign");
let Kind::Appearance(k) = &a.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") };
assert_eq!(k.opacity, 0.25);
assert_eq!(k.look.as_deref(), Some("Tinted glass"));
let p = apply_params(&[Sel::Body { name: "Body2".into() }], look.color, look.opacity, Some(&look.name)).unwrap();
a.run("appearance.assign", p).unwrap();
assert_eq!(a.session.doc.appearances.bodies.get("Body1"), a.session.doc.appearances.bodies.get("Body2"));
}
#[test]
fn immediate_apply_then_ok_has_one_undo_and_retains_precise_opacity() {
let mut a = app();
a.run("appearance.assign", json!({"bodies": ["Body1"], "color": "#abcdef", "opacity": 0.123456})).unwrap();
a.session.selection = vec![Sel::Body { name: "Body1".into() }];
a.start("appearance.assign");
let mut d = a.dialog.clone().unwrap();
let Kind::Appearance(k) = &mut d.kind else { panic!("appearance dialog") };
assert_eq!(commands(k, &d.inputs)[0].1["opacity"], json!(0.123456));
k.color = [1, 2, 3];
let p = selected_params(k, &d.inputs[0].items).unwrap();
a.run("appearance.assign", p.clone()).unwrap();
k.applied = Some(p);
assert!(commands(k, &d.inputs).is_empty());
a.run("edit.undo", json!({})).unwrap();
let restored = a.session.doc.appearances.bodies.get("Body1").unwrap();
assert_eq!(restored.color, [171, 205, 239]);
assert_eq!(restored.opacity, 0.123456);
k.opacity = 0.5;
assert_eq!(commands(k, &d.inputs).len(), 1, "changed look still applies on OK");
k.opacity = 0.123456;
d.inputs[0].items = vec![Sel::Body { name: "Body2".into() }];
assert_eq!(commands(k, &d.inputs).len(), 1, "changed target still applies on OK");
}
}
+198 -47
View File
@@ -597,56 +597,115 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
let run = |app: &mut SolveApp, error: &mut Option<String>, id: &str, p: Value| {
*error = app.run(id, p).err();
};
// Header.
ui.label(RichText::new("Configuration").strong());
ui.horizontal(|ui| {
for c in &tb.columns {
let (kind, n) = column_parts(c);
ui.add_sized(vec2(96.0, 18.0), egui::Label::new(RichText::new(n).strong()).truncate())
.on_hover_text(format!("{} {n}", if kind == "suppress" { "Suppress" } else { "Parameter" }));
if ui.small_button("×").on_hover_text("Remove the column").clicked() {
let key = if kind == "suppress" { "feature" } else { "param" };
run(app, error, "config.column", json!({ key: n, "delete": true }));
}
}
});
ui.end_row();
for (name, vals) in &tb.rows {
let active = tb.active.as_deref() == Some(name.as_str());
ui.horizontal(|ui| {
ui.label(if active { RichText::new(name).strong() } else { RichText::new(name) });
if active {
ui.label(RichText::new("Active").color(t.accent));
} else if ui.button("Activate").clicked() {
run(app, error, "config.activate", json!({ "row": name }));
}
});
ui.horizontal(|ui| {
for (c, val) in tb.columns.iter().zip(vals) {
let id = egui::Id::new(("cfg", name, c));
let mut text = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| val.clone());
let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(96.0));
if r.changed() {
ui.data_mut(|d| d.insert_temp(id, text.clone()));
egui::ScrollArea::horizontal().id_salt("configuration_columns").auto_shrink([false, true]).show(ui, |ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
egui::Grid::new("configuration_table").num_columns(2).min_col_width(210.0).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
// Header.
ui.label(RichText::new("Configuration").strong());
ui.horizontal(|ui| {
for c in &tb.columns {
ui.allocate_ui_with_layout(vec2(190.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
let (kind, n) = column_parts(c);
let label = match kind {
"material" => format!("Material: {n}"),
"appearance" => format!("Appearance: {n}"),
"suppress" => format!("Suppress: {n}"),
_ => n.to_string(),
};
ui.add_sized(vec2(156.0, 22.0), egui::Label::new(RichText::new(label).strong()).truncate()).on_hover_text(c);
if ui.small_button("×").on_hover_text("Remove the column").clicked() {
let key = if kind == "suppress" { "feature" } else { kind };
run(app, error, "config.column", json!({ key: n, "delete": true }));
}
});
}
if r.lost_focus() {
ui.data_mut(|d| d.remove::<String>(id));
if text != *val {
let (_, n) = column_parts(c);
run(app, error, "config.row", json!({"name": name, "values": { n: cell_value(c, &text) }}));
});
ui.end_row();
for (name, vals) in &tb.rows {
let active = tb.active.as_deref() == Some(name.as_str());
ui.horizontal(|ui| {
ui.add_sized(
vec2(100.0, 22.0),
egui::Label::new(if active { RichText::new(name).strong() } else { RichText::new(name) }).truncate(),
)
.on_hover_text(name);
if active {
ui.label(RichText::new("Active").color(t.accent));
if v.get("matches").and_then(Value::as_bool) == Some(false) && ui.button("Apply").clicked() {
run(app, error, "config.activate", json!({ "row": name }));
}
} else if ui.button("Activate").clicked() {
run(app, error, "config.activate", json!({ "row": name }));
}
}
ui.add_space(18.0);
}
if ui.small_button("×").on_hover_text("Delete the row").clicked() {
run(app, error, "config.row", json!({"name": name, "delete": true}));
});
ui.horizontal(|ui| {
for (c, val) in tb.columns.iter().zip(vals) {
ui.allocate_ui_with_layout(vec2(190.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
let (kind, _) = column_parts(c);
let shown = if kind == "appearance" {
solvecraft_engine::doc::config::appearance_cell(val)
.ok()
.flatten()
.filter(|look| solvecraft_engine::doc::appearance::library(&look.name).as_ref() == Some(look))
.map(|look| look.name)
.unwrap_or_else(|| val.clone())
} else {
val.clone()
};
let style = matches!(kind, "material" | "appearance");
if style {
let mut choice = None;
ui.menu_button("Library", |ui| {
if ui.button(if kind == "appearance" { "Follow material" } else { "Default material" }).clicked() {
choice = Some("Default".to_string());
ui.close();
}
if kind == "material" {
for (name, _) in solvecraft_engine::doc::MATERIALS.iter().filter(|(n, _)| *n != "Default") {
if ui.button(*name).clicked() {
choice = Some(name.to_string());
ui.close();
}
}
} else {
for (name, _, _) in solvecraft_engine::doc::appearance::LIBRARY {
if ui.button(name).clicked() {
choice = Some(name.to_string());
ui.close();
}
}
}
});
if let Some(choice) = choice {
run(app, error, "config.row", json!({"name": name, "values": { c: choice }}));
}
}
let id = egui::Id::new(("cfg", name, c));
let mut text = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone());
let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(if style { 106.0 } else { 164.0 }));
if r.changed() {
ui.data_mut(|d| d.insert_temp(id, text.clone()));
}
if r.lost_focus() {
ui.data_mut(|d| d.remove::<String>(id));
if text != shown {
run(app, error, "config.row", json!({"name": name, "values": { c: cell_value(c, &text) }}));
}
}
});
}
if ui.small_button("×").on_hover_text("Delete the row").clicked() {
run(app, error, "config.row", json!({"name": name, "delete": true}));
}
});
ui.end_row();
}
});
ui.end_row();
}
});
// New row (a copy of the active one) and new column.
ui.add(egui::TextEdit::singleline(new_row).hint_text("new configuration").desired_width(130.0));
ui.horizontal(|ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
ui.add(egui::TextEdit::singleline(new_row).hint_text("new configuration").desired_width(160.0));
if ui.button("Add Row").on_hover_text("A new configuration, a copy of the active one").clicked() {
let name = if new_row.trim().is_empty() {
(tb.rows.len() + 1..).map(|k| format!("Configuration{k}")).find(|n| !tb.rows.iter().any(|r| &r.0 == n)).unwrap_or_default()
@@ -672,6 +731,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
options.push((format!("Suppress {}", f.name), json!({ "feature": f.name })));
}
}
for body in &app.session.model.state().bodies {
for kind in ["material", "appearance"] {
if !tb.columns.contains(&format!("{kind}:{}", body.name)) {
options.push((format!("{} ({kind})", body.name), json!({kind: body.name})));
}
}
}
let mut pick: Option<Value> = None;
ui.menu_button("Add Column", |ui| {
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
@@ -688,9 +754,14 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
let (id, p) = if tb.columns.is_empty() {
(
"config.configure",
match p.get("param") {
Some(n) => json!({ "params": [n] }),
None => json!({ "features": [p.get("feature")] }),
if let Some(n) = p.get("param") {
json!({ "params": [n] })
} else if let Some(n) = p.get("feature") {
json!({ "features": [n] })
} else if let Some(n) = p.get("material") {
json!({ "materials": [n] })
} else {
json!({ "appearances": [p.get("appearance")] })
},
)
} else {
@@ -837,6 +908,86 @@ mod tests {
assert_eq!(cell_value("param:width", "90 mm"), json!("90 mm"));
}
#[test]
fn configuration_style_picker_applies_then_inherits_material() {
let steps: Value = serde_json::from_str(include_str!("../tests/scenarios/s80_configuration_styles.json")).unwrap();
let mut harness = crate::scenario::Harness::new();
for step in steps.as_array().unwrap() {
harness.step(step).unwrap();
match step.get("shot").and_then(Value::as_str) {
Some("configuration_red") => {
let look = harness.app.session.doc.appearances.bodies.get("Block").unwrap();
assert_eq!(look.color, [196, 40, 40]);
assert!(harness.app.session.doc.configuration_matches());
}
Some("configuration_default") => {
assert!(!harness.app.session.doc.appearances.bodies.contains_key("Block"));
let state = harness.app.session.model.state();
let body = state.body("Block").unwrap();
let look = harness.app.session.doc.body_look("Block", body.feature).unwrap();
assert_eq!(look.name, "Steel");
assert_eq!(look.color, [158, 164, 172]);
assert!(harness.app.session.doc.configuration_matches());
}
_ => {}
}
}
}
#[test]
fn configuration_table_actions_stay_on_one_line_at_normal_and_narrow_widths() {
for width in [1400, 900] {
let mut harness = crate::scenario::Harness::new();
harness.call("ui.resize", json!({"width": width, "height": 720}));
harness
.run(&json!([
{"call":"engine.execute","params":{"command":"solid.box","params":{"length":40,"width":20,"height":10,"body_name":"Block"}}},
{"start":"config.table"}
]))
.unwrap();
assert_single_line(&harness, "Configuration");
for name in ["span", "thickness", "depth", "clearance"] {
harness.call("engine.execute", json!({"command":"parameters.change","params":{"name":name,"expression":"10 mm"}}));
}
harness
.run(&json!([
{"call":"engine.execute","params":{"command":"config.configure","params":{
"params":["span","thickness","depth","clearance"],"materials":["Block"],"appearances":["Block"]
}}},
{"call":"engine.execute","params":{"command":"config.row","params":{"name":"Default","values":{"param:span":"20 mm"}}}}
]))
.unwrap();
harness.frames(4);
for label in ["Configuration", "Active", "Apply", "Add Row", "Add Column"] {
assert_single_line(&harness, label);
}
crate::scenario::check(&mut harness, &json!({"windows_fit":{"max_width":560}})).unwrap();
for name in ["span", "thickness", "depth", "clearance"] {
harness.call("engine.execute", json!({"command":"config.column","params":{"param":name,"delete":true}}));
}
harness.call("engine.execute", json!({"command":"config.column","params":{"appearance":"Block","delete":true}}));
assert_single_line(&harness, "Material: Block");
harness.call("engine.execute", json!({"command":"config.column","params":{"material":"Block","delete":true}}));
harness.call("engine.execute", json!({"command":"config.column","params":{"feature":"Box1"}}));
assert_single_line(&harness, "Suppress: Box1");
}
fn assert_single_line(harness: &crate::scenario::Harness, label: &str) {
let tree = harness.output.accesskit_update.as_ref().unwrap();
let bounds = tree
.nodes
.iter()
.filter(|(_, n)| n.label() == Some(label) || n.value() == Some(label))
.filter_map(|(_, n)| n.bounds())
.collect::<Vec<_>>();
assert!(
!bounds.is_empty(),
"missing {label}; labels/values: {:?}",
tree.nodes.iter().filter_map(|(_, n)| n.label().or(n.value())).collect::<Vec<_>>()
);
assert!(bounds.iter().all(|b| b.y1 - b.y0 <= 24.0), "{label} wrapped: {bounds:?}");
}
}
#[test]
fn study_keys_by_joint_id() {
let mut s = Session::default();
+11 -5
View File
@@ -360,7 +360,12 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
}
row_label(ui, "");
if ui.button("Export DXF…").clicked() {
*status = Some(export_dxf(app, v["body"].as_str().unwrap_or("")));
*status = Some(export_flat(app, v["body"].as_str().unwrap_or(""), "dxf"));
}
ui.end_row();
row_label(ui, "");
if ui.button("Export Bend Table…").clicked() {
*status = Some(export_flat(app, v["body"].as_str().unwrap_or(""), "csv"));
}
ui.end_row();
if let Some(s) = status {
@@ -381,12 +386,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
enter
}
fn export_dxf(app: &mut SolveApp, body: &str) -> String {
let name = format!("{}-{body}.dxf", app.session.doc.name);
let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&name, &["dxf"])) else {
fn export_flat(app: &mut SolveApp, body: &str, extension: &str) -> String {
let name = format!("{}-{body}.{extension}", app.session.doc.name);
let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[extension])) else {
return "Export cancelled".into();
};
match app.run("sheet.export_dxf", json!({"body": body, "path": path})) {
let command = if extension == "csv" { "sheet.export_bend_table" } else { "sheet.export_dxf" };
match app.run(command, json!({"body": body, "path": path})) {
Ok(_) => {
app.set_status(format!("Exported {path}"), false);
format!("Saved {path}")
+43
View File
@@ -106,3 +106,46 @@ fn hem_and_rules() {
run_all(&mut app, c);
assert!(app.session.doc.sheet.rules.iter().any(|r| r.name == "Alu"));
}
#[test]
fn flat_exports_use_the_save_picker() {
use std::sync::{Arc, Mutex};
let mut app = plate();
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
let picks = Arc::new(Mutex::new(Vec::new()));
let captured = picks.clone();
app.services.pick_save = Some(Box::new(move |name, extensions| {
captured.lock().unwrap().push((name.to_string(), extensions.iter().map(|x| x.to_string()).collect::<Vec<_>>()));
None
}));
assert_eq!(export_flat(&mut app, "Base", "csv"), "Export cancelled");
assert_eq!(export_flat(&mut app, "Base", "dxf"), "Export cancelled");
let picks = picks.lock().unwrap();
assert!(picks[0].0.ends_with("-Base.csv"));
assert_eq!(picks[0].1, vec!["csv"]);
assert!(picks[1].0.ends_with("-Base.dxf"));
assert_eq!(picks[1].1, vec!["dxf"]);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn bend_table_picker_writes_the_current_body() {
let mut app = plate();
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
app.run("sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})).unwrap();
let body = app.session.model.state().sheets[0].body.clone();
let path = std::env::temp_dir().join(format!("solvecraft-bend-table-{}.csv", std::process::id()));
let output = path.to_string_lossy().into_owned();
let chosen = output.clone();
app.services.pick_save = Some(Box::new(move |_, extensions| {
assert_eq!(extensions, &["csv"]);
Some(chosen.clone())
}));
let undo = app.session.undo.len();
assert_eq!(export_flat(&mut app, &body, "csv"), format!("Saved {output}"));
let contents = std::fs::read_to_string(&path).unwrap();
std::fs::remove_file(&path).unwrap();
assert_eq!(contents.lines().count(), 2);
assert!(contents.contains(",90.000000000,up,2.500000000,"), "{contents}");
assert_eq!(app.session.undo.len(), undo);
}
+45 -1
View File
@@ -45,6 +45,32 @@ fn empty_space_offers_view_items_and_greys_out_undo() {
assert!(!app.ui.show_origin);
}
#[test]
fn appearance_browser_action_targets_the_clicked_body() {
let mut app = sample_app();
let b = body(&app);
app.run("select.clear", json!({})).unwrap();
act(&mut app, &Target::Body { name: b.clone() }, "Appearance");
let dialog = app.dialog.as_ref().unwrap();
assert!(matches!(dialog.kind, crate::dialogs::Kind::Appearance(_)));
assert_eq!(dialog.inputs[0].items, vec![solvecraft_engine::Sel::Body { name: b }]);
}
#[test]
fn appearance_component_action_assigns_an_inherited_look() {
let mut app = sample_app();
let created = app.run("component.create", json!({"name": "Painted part"})).unwrap();
let id = created["component"].as_u64().unwrap();
act(&mut app, &Target::Component { id }, "Appearance");
let d = app.dialog.clone().unwrap();
let commands = crate::dialogs::apply_commands(&app, &d).unwrap();
assert_eq!(commands[0].1["components"], json!(["Painted part"]));
for (id, p) in commands {
app.run(&id, p).unwrap();
}
assert!(app.session.doc.appearances.components.contains_key(&id));
}
#[test]
fn face_menu_hides_and_finds_the_body() {
let mut app = sample_app();
@@ -54,7 +80,7 @@ fn face_menu_hides_and_finds_the_body() {
for l in ["Create Sketch", "Offset Plane", "Press Pull", "Extrude", "Measure", "Find in Browser", "Find in Timeline"] {
assert!(find(&items, l).enabled, "{l}");
}
assert!(!find(&items, "Appearance").enabled);
assert!(find(&items, "Appearance").enabled);
context_menu::run_item(&mut app, &find(&items, "Find in Timeline"), pos2(0.0, 0.0));
assert!(matches!(app.session.selection.as_slice(), [solvecraft_engine::Sel::Feature { .. }]));
context_menu::run_item(&mut app, &find(&items, "Show/Hide"), pos2(0.0, 0.0));
@@ -97,6 +123,24 @@ fn properties_measure_the_body() {
assert!(v["total"]["mass_g"].as_f64().unwrap() > 0.0);
}
#[test]
fn component_properties_measure_one_assembly_placement() {
let mut session = Session::default();
let component = session.execute("component.create", &json!({"name": "Subassembly"})).unwrap()["component"].as_u64().unwrap();
session.execute("component.create", &json!({"name": "Block"})).unwrap();
session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
session.execute("occurrence.copy", &json!({"component": component, "translate": [100, 0, 0]})).unwrap();
let mut app = SolveApp::new(session, Services::default());
act(&mut app, &Target::Component { id: component }, "Properties");
let (title, properties) = app.menu.props.as_ref().unwrap();
assert_eq!(title, "Subassembly");
assert_eq!(properties["body_count"], 1);
assert!((properties["total"]["volume_mm3"].as_f64().unwrap() - 1000.0).abs() < 1e-6);
assert!((properties["total"]["center_of_mass"][0].as_f64().unwrap() - 5.0).abs() < 1e-6);
act(&mut app, &Target::Component { id: 0 }, "Properties");
assert_eq!(app.menu.props.as_ref().unwrap().1["body_count"], 2);
}
#[test]
fn sketch_menu_hides_and_moves_to_another_plane() {
let mut app = sample_app();
@@ -0,0 +1,63 @@
use super::*;
use solvecraft_engine::sketch::CurveKind;
#[test]
fn clicked_origin_corner_fillets_and_chamfers_with_origin_visible() {
for command in ["sketch.fillet", "sketch.chamfer.equal_distance", "sketch.chamfer.distance_angle", "sketch.chamfer.two_distance"] {
let mut h = crate::scenario::Harness::new();
h.app.ui.show_origin = true;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,30]})).unwrap();
h.frames(2);
let sid = h.app.session.active_sketch.unwrap();
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap());
let cursor = proj.to_screen(Vec3::ZERO).unwrap();
// The ordinary snap still finds the fixed origin; the corner tool must instead
// reach the rectangle's overlapping endpoint.
assert_eq!(sketch_point_at(&h.app, &proj, cursor).unwrap().1.as_deref(), Some("origin"));
h.step(&json!({"start":command})).unwrap();
h.step(&json!({"move":{"world":[0,0,0]}})).unwrap();
let point = h.app.tool.as_ref().unwrap().hover.as_ref().unwrap().1.as_deref().unwrap();
assert_ne!(point, "origin", "the actual shared endpoint must be highlighted");
h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
let st = h.app.session.world_state();
let ss = st.sketch(sid).unwrap();
assert_eq!(ss.sketch.point(0), Some(Vec2::ZERO));
assert!(ss.sketch.points[0].fixed);
assert_eq!(ss.sketch.curves.len(), 5, "{command} must actually modify the geometry; status: {:?}", h.app.status);
if command == "sketch.fillet" {
assert!(ss.sketch.curves.iter().any(|c| matches!(c.kind, CurveKind::Arc { .. })));
} else {
assert!(ss.sketch.curves.iter().all(|c| matches!(c.kind, CurveKind::Line { .. })));
}
let expected = if command == "sketch.fillet" { 600.0 - 25.0 * (1.0 - std::f64::consts::PI / 4.0) } else { 587.5 };
assert_eq!(ss.profiles.len(), 1);
assert!((ss.profiles[0].area - expected).abs() < 1e-6, "{command}: {}", ss.profiles[0].area);
h.app.run("edit.undo", json!({})).unwrap();
let st = h.app.session.world_state();
let ss = st.sketch(sid).unwrap();
assert_eq!(ss.sketch.curves.len(), 4);
assert!((ss.profiles[0].area - 600.0).abs() < 1e-6);
}
}
#[test]
fn ordinary_drawing_keeps_origin_snap_and_real_origin_corners_work() {
let mut h = crate::scenario::Harness::new();
h.app.ui.show_origin = true;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.step(&json!({"start":"sketch.line"})).unwrap();
h.step(&json!({"move":{"world":[0,0,0]}})).unwrap();
assert_eq!(h.app.tool.as_ref().unwrap().hover.as_ref().unwrap().1.as_deref(), Some("origin"));
h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
assert_eq!(h.app.tool.as_ref().unwrap().pts[0].1.as_deref(), Some("origin"));
h.app.tool = None;
h.app.run("sketch.line", json!({"points":["origin",[20,0],[20,20],[0,20],"origin"]})).unwrap();
h.step(&json!({"start":"sketch.fillet"})).unwrap();
h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
let st = h.app.session.world_state();
let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap();
assert_eq!(ss.sketch.point(0), Some(Vec2::ZERO));
assert!(ss.sketch.curves.iter().any(|c| matches!(c.kind, CurveKind::Arc { .. })));
assert_eq!(ss.profiles.len(), 1);
}
+56 -3
View File
@@ -240,6 +240,30 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
let px = 2.0 * app.cam.half_height() / f64::from(proj.rect.height().max(1.0));
let lv = live(t, e, px);
let focus = std::mem::take(&mut t.dims_focus);
// Egui processes ordinary Tab navigation before these floating text edits are drawn.
// Remember the box that owned the keyboard and restore a focus within this tool after
// drawing, so the last dimension wraps to the first rather than reaching the panels.
let tab = ui.input(|input| {
input.raw.events.iter().find_map(|event| match event {
egui::Event::Key { key: egui::Key::Tab, pressed: true, modifiers, .. } if !modifiers.ctrl && !modifiers.alt && !modifiers.command => {
Some(modifiers.shift)
}
_ => None,
})
});
let source = tab.and_then(|_| {
ui.ctx().memory(|memory| {
(0..t.dims.len())
.find(|i| memory.had_focus_last_frame(egui::Id::new(("sc_dim", i))))
.or_else(|| (0..t.dims.len()).find(|i| memory.has_focus(egui::Id::new(("sc_dim", i)))))
})
});
if source.is_some() {
// Also cancel navigation on the first focused frame, before the text edit's focus
// filter has been installed (e.g. immediately repeated keyboard input).
ui.ctx().memory_mut(|memory| memory.move_focus(egui::FocusDirection::None));
}
let mut completed = false;
let mut any_focused = false;
for (i, b) in t.dims.iter_mut().enumerate() {
let Some((v, at)) = lv.get(i).copied() else { continue };
@@ -260,9 +284,19 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
egui::Area::new(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
let fill = if b.locked { tk.accent_soft } else { tk.panel };
egui::Frame::popup(ui.style()).fill(fill).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| {
let r = ui.add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(66.0)).on_hover_text(b.label);
crate::params_dialog::complete(ui, &r, &mut b.value);
if r.changed() {
let r = ui
.add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(66.0).event_filter(egui::EventFilter {
tab: true,
horizontal_arrows: true,
vertical_arrows: true,
..Default::default()
}))
.on_hover_text(b.label);
// A completion gets first use of forward Tab. Shift+Tab always moves back;
// the box reached by egui's generic traversal must not complete its text.
let changed = (source.is_none() || (source == Some(i) && tab != Some(true))) && crate::params_dialog::complete(ui, &r, &mut b.value);
completed |= changed && source == Some(i);
if r.changed() || changed {
b.locked = true;
}
if focus && i == 0 {
@@ -272,6 +306,21 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
});
});
}
if let Some(source) = source
&& !completed
{
let next = if tab == Some(true) { (source + t.dims.len() - 1) % t.dims.len() } else { (source + 1) % t.dims.len() };
let id = egui::Id::new(("sc_dim", next));
if let Some(dimension) = t.dims.get(next) {
let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(
egui::text::CCursor::new(0),
egui::text::CCursor::new(dimension.value.chars().count()),
)));
state.store(ui.ctx(), id);
}
ui.ctx().memory_mut(|memory| memory.request_focus(id));
}
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
let nothing = ui.ctx().memory(|m| m.focused().is_none());
(enter && (any_focused || nothing)).then_some(e)
@@ -281,3 +330,7 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
pub fn dimension_params(entities: &[String], ty: &str, value: &str) -> Value {
json!({"entities": entities, "type": ty, "value": value})
}
#[cfg(test)]
#[path = "sketch_dims_tests.rs"]
mod tests;
+128
View File
@@ -0,0 +1,128 @@
//! Real keyboard input must stay inside the dimensions of the shape being drawn.
use serde_json::json;
use crate::scenario::Harness;
fn drawing(command: &str) -> Harness {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
h.step(&json!({"start": command})).unwrap();
h.step(&json!({"click": {"world": [0, 0, 0]}})).unwrap();
h.step(&json!({"move": {"world": [20, 10, 0]}})).unwrap();
assert_focus(&h, 0);
h
}
fn assert_focus(h: &Harness, index: usize) {
let expected = egui::Id::new(("sc_dim", index));
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(expected), "focus must stay in inline dimension {index}");
}
fn tab(h: &mut Harness, backwards: bool) {
h.step(&json!({"key": "Tab", "shift": backwards})).unwrap();
}
fn type_value(h: &mut Harness, text: &str) {
h.step(&json!({"key": "A", "cmd": true})).unwrap();
h.step(&json!({"text": text})).unwrap();
}
#[test]
fn tabs_cycle_rectangle_dimensions_indefinitely_and_enter_commits_values() {
for command in ["sketch.rectangle.two_point", "sketch.rectangle.center"] {
let mut h = drawing(command);
type_value(&mut h, "30");
tab(&mut h, false);
assert_focus(&h, 1);
type_value(&mut h, "12");
for i in 0..8 {
tab(&mut h, false);
assert_focus(&h, i % 2);
}
for i in 0..8 {
tab(&mut h, true);
assert_focus(&h, i % 2);
}
// Revisited locked dimensions are selected, so another value replaces them directly.
h.step(&json!({"text": "14"})).unwrap();
tab(&mut h, true);
assert_focus(&h, 0);
h.step(&json!({"text": "32"})).unwrap();
let dims = &h.app.tool.as_ref().unwrap().dims;
assert_eq!(dims.iter().map(|d| d.value.as_str()).collect::<Vec<_>>(), ["32", "14"]);
assert!(dims.iter().all(|d| d.locked));
h.step(&json!({"key": "Enter"})).unwrap();
let state = h.app.session.world_state();
let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
assert_eq!(sketch.sketch.curves.iter().filter(|curve| !curve.construction).count(), 4);
assert_eq!(sketch.profiles.len(), 1);
assert!((sketch.profiles[0].area - 448.0).abs() < 1e-6, "{command}: {}", sketch.profiles[0].area);
}
}
#[test]
fn line_dimensions_wrap_both_directions_and_enter_uses_length_and_angle() {
let mut h = drawing("sketch.line");
type_value(&mut h, "10");
tab(&mut h, false);
type_value(&mut h, "45 deg");
for i in 0..6 {
tab(&mut h, false);
assert_focus(&h, i % 2);
}
tab(&mut h, true);
assert_focus(&h, 0);
h.step(&json!({"key": "Enter"})).unwrap();
let state = h.app.session.world_state();
let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
assert_eq!(sketch.sketch.curves.len(), 1);
let points: Vec<_> = sketch.sketch.points.iter().map(|point| point.pos).collect();
assert!(points.iter().any(|point| (point.x - 10.0 / 2f64.sqrt()).abs() < 1e-6 && (point.y - 10.0 / 2f64.sqrt()).abs() < 1e-6));
}
#[test]
fn single_dimension_keeps_focus_for_tab_and_shift_tab() {
let mut h = drawing("sketch.circle.center");
type_value(&mut h, "8");
for backwards in [false, false, true, true, false] {
tab(&mut h, backwards);
assert_focus(&h, 0);
}
assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "8");
h.step(&json!({"key": "Enter"})).unwrap();
let state = h.app.session.world_state();
let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
assert_eq!(sketch.profiles.len(), 1);
assert!((sketch.profiles[0].area - std::f64::consts::PI * 16.0).abs() < 1e-6);
}
#[test]
fn forward_tab_completes_expression_then_cycles_and_shift_tab_moves_back() {
let mut h = drawing("sketch.rectangle.two_point");
h.app.run("parameters.add", json!({"name": "width", "expression": "24 mm"})).unwrap();
h.frames(2);
type_value(&mut h, "wid");
tab(&mut h, false);
assert_focus(&h, 0);
assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "width");
tab(&mut h, false);
assert_focus(&h, 1);
type_value(&mut h, "5");
tab(&mut h, true);
assert_focus(&h, 0);
type_value(&mut h, "wid");
tab(&mut h, true);
assert_focus(&h, 1);
assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "wid", "Shift+Tab must navigate rather than accepting a completion");
tab(&mut h, true);
assert_focus(&h, 0);
tab(&mut h, false);
assert_focus(&h, 0);
assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "width");
h.step(&json!({"key": "Enter"})).unwrap();
let state = h.app.session.world_state();
let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
assert_eq!(sketch.profiles.len(), 1);
assert!((sketch.profiles[0].area - 120.0).abs() < 1e-6);
}
+45 -6
View File
@@ -458,6 +458,41 @@ fn sketch_entity(app: &SolveApp, hits: &[Hit]) -> Option<(String, bool)> {
})
}
/// Corner tools accept endpoints shared by two editable curves. Pick among those endpoints
/// directly so the origin, a circle centre or an overlapping loose point cannot hide a corner.
pub(super) fn corner_point(app: &SolveApp, proj: &Proj, pos: Pos2, cmd: &str) -> Option<(Vec2, String)> {
let st = app.session.world_state();
let ss = st.sketch(active_sketch(app)?)?;
let sk = &ss.sketch;
sk.points
.iter()
.enumerate()
.filter_map(|(pi, point)| {
let distance = proj.to_screen(ss.plane.to_world(point.pos))?.distance(pos);
if distance >= 8.0 {
return None;
}
let curves: Vec<_> = sk
.curves
.iter()
.enumerate()
.filter(|(ci, c)| !sk.is_linked_curve(*ci) && c.kind.ends().is_some_and(|(a, b)| a == pi || b == pi))
.take(3)
.collect();
if curves.len() != 2
|| curves.iter().any(|(_, c)| {
!matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. })
&& (cmd != "sketch.fillet" || !matches!(c.kind, solvecraft_engine::sketch::CurveKind::Arc { .. }))
})
{
return None;
}
Some((distance, point.pos, point.id.clone()))
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, at, id)| (at, id))
}
fn xy(p: Vec2) -> Value {
json!([p.x, p.y])
}
@@ -624,6 +659,12 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
}
}
Mode::Entities(n) => {
if cmd == "sketch.fillet" || cmd.starts_with("sketch.chamfer.") {
if let Some((_, id)) = corner_point(app, proj, pos, cmd) {
run_corner(app, cmd, &id);
}
return;
}
if cmd == "sketch.offset" && !tool.picks.is_empty() {
if let Some((p, _)) = at {
offset_side(app, tool, p);
@@ -640,12 +681,6 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
}
return;
}
if cmd == "sketch.fillet" || cmd.starts_with("sketch.chamfer.") {
if is_point {
run_corner(app, cmd, &id);
}
return;
}
if cmd == "sketch.centerline" {
let _ = app.run(cmd, json!({"curves": [id]}));
return;
@@ -1132,3 +1167,7 @@ fn text_entry(app: &mut SolveApp, ctx: &egui::Context) {
None => TEXT.with(|t| *t.borrow_mut() = Some((at, text))),
}
}
#[cfg(test)]
#[path = "sketch_corner_ui_tests.rs"]
mod corner_ui_tests;
+5 -1
View File
@@ -100,7 +100,11 @@ fn xy(p: &(Vec2, Option<String>)) -> Value {
pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
crate::sketch3d::on_hover(app, proj, pos);
let h = sketch_point_at(app, proj, pos);
let h = if let Some(tool) = app.tool.as_ref().filter(|t| t.cmd == "sketch.fillet" || t.cmd.starts_with("sketch.chamfer.")) {
crate::sketch_tools::corner_point(app, proj, pos, tool.cmd).map(|(at, id)| (at, Some(id)))
} else {
sketch_point_at(app, proj, pos)
};
if let Some(t) = app.tool.as_mut() {
t.hover = h;
}
@@ -110,7 +110,15 @@
"file": "Untitled-Body1.dxf"
}
},
{
"widget": "Export Bend Table\u2026"
},
{
"expect": {
"file": "Untitled-Body1.csv"
}
},
{
"shot": "s08_flat"
}
]
]
@@ -78,14 +78,10 @@
},
{
"expect": {
"sketch_world": {
"sketch": "Sketch1",
"point": "p1",
"at": [
140,
0,
20
]
"linked_world_range": {
"x": [100, 140],
"y": [0, 30],
"z": [20, 20]
}
}
},
@@ -100,4 +96,4 @@
"errors": 0
}
}
]
]
@@ -0,0 +1,17 @@
[
{"note": "Appearance from the Browser targets its body, filters library names, and removal survives OK and can be undone."},
{"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}},
{"call": "engine.execute", "params": {"command": "appearance.assign", "params": {"bodies": ["Body1"], "color": "#abcdef", "appearance": "Tinted glass", "opacity": 0.25}}},
{"call": "ui.menu", "params": {"x": 300, "y": 300, "target": {"type": "body", "name": "Body1"}}},
{"call": "ui.menuPick", "params": {"item": "Appearance"}},
{"expect": {"dialog": "Appearance", "shown": "Remove override"}},
{"click": {"handle": "appearance:search"}},
{"text": "no-such-appearance"},
{"expect": {"shown": "No matching appearances"}},
{"shot": "appearance_library_search"},
{"widget": "Remove override"},
{"widget": "OK"},
{"expect": {"dialog": null, "query": {"command": "appearance.list", "path": ["bodies"], "value": {}}}},
{"call": "engine.execute", "params": {"command": "edit.undo", "params": {}}},
{"expect": {"query": {"command": "appearance.list", "path": ["bodies", "Body1", "opacity"], "value": 0.25}}}
]
@@ -0,0 +1,21 @@
[
{"note":"Configure a body's appearance in the table, apply the edited active row, and render the result."},
{"call":"engine.execute","params":{"command":"solid.box","params":{"length":40,"width":20,"height":10,"body_name":"Block"}}},
{"call":"engine.execute","params":{"command":"material.assign","params":{"bodies":["Block"],"material":"Steel"}}},
{"start":"config.table"},
{"widget":"Add Column"},
{"widget":"Block (appearance)"},
{"expect":{"shown":"Appearance: Block"}},
{"widget":"Library"},
{"widget":"ABS - Red"},
{"widget":"Apply"},
{"shot":"configuration_red"},
{"expect":{"errors":0,"bodies":1,"query":{"command":"appearance.list","path":["bodies","Block","color"],"value":"#c42828"}}},
{"widget":"Library"},
{"widget":"Follow material"},
{"widget":"Apply"},
{"shot":"configuration_default"},
{"expect":{"errors":0,"bodies":1,"query":{"command":"appearance.list","path":["bodies","Block"],"value":null}}},
{"expect":{"query":{"command":"appearance.list","params":{"body":"Block"},"path":["body","appearance","name"],"value":"Steel"}}},
{"expect":{"query":{"command":"config.table","path":["matches"],"value":true}}}
]
+33
View File
@@ -0,0 +1,33 @@
# Assembly physical properties
The Browser's component **Properties** action measures the selected assembly placement,
including its nested components. Select the root design to measure every instance in the
assembly. The window shows material-aware mass, volume, surface area, center of mass and
the world bounding box. Pending occurrence moves are included without capturing them.
The same measurements are available through commands:
```json
{"command":"component.properties","params":{"component":"Bracket"}}
{"command":"component.properties","params":{"component":"root"}}
{"command":"component.properties","params":{"component":"Subassembly","descendants":false}}
{"command":"occurrence.properties","params":{"occurrence":"Bracket:2"}}
```
`component.properties` defaults to the active component and measures its first placement.
`occurrence.properties` measures the chosen instance. A nested occurrence can appear beneath
several instances of its parent; supply `path` (the complete list of occurrence ids from the
root) to choose one of those placements. An ambiguous occurrence without a path returns an
error. Both commands accept `descendants: false` to measure only directly owned bodies.
Results contain a `bodies` list and a `total` object. Each body's entry identifies the original
body name, owning component and occurrence path. Physical material density is resolved from
that original body, so copies keep their material and mass. Values use millimetres, square
millimetres, cubic millimetres and grams; coordinates use the world frame.
Mass and center of mass account for each body's density. Volume and area are summed over
placed bodies, including overlapping bodies and repeated instances. Measurements use the
kernel's tessellation-based body measurement. An empty component reports zero totals and
null center of mass and bounding box. A failed body measurement returns an error instead of
presenting an incomplete assembly total. These commands leave geometry, pending placements
and undo history unchanged.
+9
View File
@@ -20,3 +20,12 @@ couples two joints. Contact sets stop motion where bodies would pass through eac
volumes in red.
STEP export writes the component tree as an assembly.
Right-click a component in the Browser and choose **Properties** to inspect
material-aware mass, volume, surface area, centre of mass and world bounds. Descendants and
repeated instances contribute to assembly totals; pending moves are included. A component
uses its first placement; `occurrence.properties` selects a specific instance. Root Properties
measures the complete assembly. Volumes are summed, including overlapping bodies, rather than
measuring their union. The same workflow is available through `component.properties` and
`occurrence.properties`; see
[placement and measurement details](https://github.com/bherbruck/solvecraft/blob/main/docs/assembly-properties.md).
+5 -1
View File
@@ -18,4 +18,8 @@ existing geometry.
Parameters export to and import from CSV or JSON (`parameters.export`, `parameters.import`).
**Configurations** (`config.*`) keep a table of rows, each with its own parameter values and
suppressed features, switched with `config.activate`.
suppressed features, physical materials and body appearances, switched with `config.activate`.
The table's Library picker selects material or appearance values. Choose **Follow material**
to clear an appearance override. Custom looks retain their colour and opacity across
activation, undo and save/reopen. See
[configuration commands and examples](https://github.com/bherbruck/solvecraft/blob/main/docs/configurations.md).
+16 -1
View File
@@ -9,7 +9,22 @@ The SHEET METAL tab builds parts from a sheet of constant thickness under a **sh
- **Unfold / Refold** (`sheet.unfold`, `sheet.refold`) for working on the flat part.
- **Convert** (`sheet.convert`) turns a solid of constant thickness into sheet metal.
- **Create Flat Pattern** (`sheet.flat_pattern`) shows the flat part with its bend lines and a
bend table, and exports it as DXF.
bend table, and exports it as DXF or a bend-table CSV through the save picker.
Bends follow the rule's K-factor, so flat lengths match what a press brake will make. The
oracle checks these against parts built in Fusion.
For a shop drawing or spreadsheet, `sheet.export_bend_table` returns CSV by default; choose
`format: "json"` for structured data. It includes the sheet rule, thickness, bend angles,
radii, allowances and flat centre-line coordinates. Lengths use millimetres and angles use
degrees. Supply `path` to save it, or omit `path` to receive the content through the command
response. `body` defaults to the last sheet body. Exporting does not add a timeline entry or
consume an undo step.
```json
{"command":"sheet.export_bend_table","params":{"format":"csv","path":"bracket-bends.csv"}}
{"command":"sheet.export_dxf","params":{"path":"bracket-flat.dxf"}}
```
DXF cutout geometry preserves lines, circles and circular arcs; other curves are sampled
adaptively. The `OUTLINE`, `BEND_UP` and `BEND_DOWN` layers separate cuts from bend centres.
+43
View File
@@ -0,0 +1,43 @@
# Design configurations
The configuration table stores named design variants. Add parameter, feature suppression,
physical material, and body appearance columns with `config.configure` or `config.column`.
The desktop Configuration Table's **Add Column** menu offers the same targets. Material and
appearance cells have a **Library** picker; their text fields also accept exact saved values.
```json
{"command":"config.configure","params":{"params":["width"],"materials":["Box1"],"appearances":["Box1"]}}
{"command":"config.row","params":{"name":"Aluminum blue","values":{"param:width":"80 mm","material:Box1":"Aluminum","appearance:Box1":"Aluminum - Anodized Blue"}}}
{"command":"config.activate","params":{"row":"Aluminum blue"}}
```
Use the body's actual Browser name in place of `Box1`. `config.table` returns the column keys,
rows, active name, and `matches`: whether the current design still has the active row's values.
Changing an assignment by hand makes `matches` false. Activating another row is undoable.
Material values are names from `material.list` (case insensitive), with `Default` clearing the
body's physical material assignment. Appearance values are names from `appearance.library`,
an empty string or `Default` to clear the body's appearance, or a custom look object:
```json
{"command":"config.row","params":{"name":"Transparent blue","from":"Aluminum blue","values":{"appearance:Box1":{"name":"Custom blue","color":[40,90,186],"opacity":0.4}}}}
```
The table stores custom looks without losing colour or opacity. `color` contains three byte
values, names support up to 128 Unicode characters, and `opacity` must be finite and between
zero and one. Style values are validated when
editing a row and again when activating a saved row. An invalid value leaves the design unchanged.
Clearing a body appearance allows component appearance or material colour to show through;
face appearances keep their existing priority. Physical material changes affect measured mass.
`config.row` supports `from` to copy another row, `rename` to rename an existing row, and
`delete` to remove it. Names must be unique. To remove a style column, use
`config.column` with `material` or `appearance` set to the target body and `delete: true`.
Style columns target body names. Assignments remain stored when a row suppresses the feature
that produces that body, and become visible when it returns. Component and face appearance
columns and conditional configuration rules are not yet supported.
Designs saved with these columns use file format 7. Older designs upgrade on opening without
changing their existing configuration values. Older SolveCraft versions must be updated to
open newly saved designs.
+34
View File
@@ -0,0 +1,34 @@
# Contact sets and joint motion
Contact sets constrain **Drive Joints**, motion-study playback and exported motion samples.
Bodies belonging to different members of an enabled set are checked along the requested
joint motion. A collision between clear start and end poses can therefore stop the motion,
including slider travel through a blocker and revolute travel across one.
```json
{"command":"contact.create","params":{"occurrences":["Base:1","Carriage:1"]}}
{"command":"joint.drive","params":{"joint":"Rail","value":30}}
```
A stopped command returns `stopped_by_contact`, identifying the occurrence pair and the
fraction of the requested motion reached. The first detected collision interval is refined
to leave the bodies just short of penetration, within the tessellated contact tolerance.
Motion links and limits participate in the trajectory checks. Moving a body between
components preserves its placement for contact checks through its body offset.
The trajectory uses bounded samples of cached tessellated geometry. Spacing accounts for
effective joint travel, angular motion, distance from joint pivots and whole-body widths
along sampled face directions. This handles tilted thin plates while keeping curved bodies
practical to drive. It is not exact continuous collision detection: locally thin walls in
a larger body, unsampled face directions and very small geometric details remain limitations.
When a motion or assembly exceeds the sampling, geometry or collision-work budget, the
command returns an error and restores the document. Use smaller drive increments or a
smaller contact set. Mesh bodies in contact sets are currently rejected for swept driving.
An assembly already overlapping at the start retains the recovery behaviour that allows
it to move out of that invalid placement. Disable contact to move freely.
`motion.export` uses the same checks between its requested poses, including sparse exports,
and leaves the source document unchanged. Contact checks interpolate joint values between
those poses; they do not reconstruct intermediate motion-study keyframes omitted by the
requested sample steps.
+10 -10
View File
@@ -2,9 +2,9 @@
Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolveCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth.
**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 328 / 541 commands live (61%).** SOLID + SKETCH: 185 / 291 (64%). Registered commands: 300.
**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 331 / 541 commands live (61%).** SOLID + SKETCH: 186 / 291 (64%). Registered commands: 307.
Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 616 / 1175 (52%).
Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 623 / 1175 (53%).
| Tab | Panel | Live | Total | % |
|---|---|---:|---:|---:|
@@ -12,7 +12,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
| SOLID | MODIFY | 19 | 32 | 59% |
| SOLID | ASSEMBLE | 12 | 21 | 57% |
| SOLID | CONFIGURE | 2 | 3 | 67% |
| SOLID | CONSTRUCT | 18 | 20 | 90% |
| SOLID | CONSTRUCT | 19 | 20 | 95% |
| SOLID | INSPECT | 12 | 25 | 48% |
| SOLID | INSERT | 6 | 11 | 55% |
| SOLID | SELECT | 0 | 15 | 0% |
@@ -30,7 +30,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
| SHEET METAL | MODIFY | 12 | 21 | 57% |
| SHEET METAL | ASSEMBLE | 11 | 20 | 55% |
| SHEET METAL | CONFIGURE | 2 | 3 | 67% |
| SHEET METAL | CONSTRUCT | 18 | 20 | 90% |
| SHEET METAL | CONSTRUCT | 19 | 20 | 95% |
| SHEET METAL | INSPECT | 12 | 25 | 48% |
| SHEET METAL | INSERT | 6 | 11 | 55% |
| SHEET METAL | SELECT | 0 | 15 | 0% |
@@ -43,7 +43,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
| ASSEMBLY | MODIFY | 8 | 11 | 73% |
| ASSEMBLY | CONTACT | 2 | 2 | 100% |
| ASSEMBLY | CONFIGURE | 2 | 3 | 67% |
| ASSEMBLY | CONSTRUCT | 18 | 20 | 90% |
| ASSEMBLY | CONSTRUCT | 19 | 20 | 95% |
| ASSEMBLY | INSPECT | 12 | 25 | 48% |
| ASSEMBLY | SELECT | 0 | 15 | 0% |
| ASSEMBLY | POSITION | 2 | 2 | 100% |
@@ -57,7 +57,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
| SURFACE | MODIFY | 17 | 26 | 65% |
| SURFACE | ASSEMBLE | 11 | 20 | 55% |
| SURFACE | CONFIGURE | 2 | 3 | 67% |
| SURFACE | CONSTRUCT | 18 | 20 | 90% |
| SURFACE | CONSTRUCT | 19 | 20 | 95% |
| SURFACE | INSPECT | 12 | 25 | 48% |
| SURFACE | INSERT | 6 | 11 | 55% |
| SURFACE | SELECT | 0 | 15 | 0% |
@@ -67,7 +67,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
| MESH | MODIFY | 6 | 30 | 20% |
| MESH | ASSEMBLE | 12 | 21 | 57% |
| MESH | CONFIGURE | 2 | 3 | 67% |
| MESH | CONSTRUCT | 18 | 20 | 90% |
| MESH | CONSTRUCT | 19 | 20 | 95% |
| MESH | INSPECT | 12 | 25 | 48% |
| MESH | INSERT | 5 | 10 | 50% |
| MESH | SELECT | 0 | 10 | 0% |
@@ -77,7 +77,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
| FORM | MODIFY | 5 | 31 | 16% |
| FORM | SYMMETRY | 0 | 6 | 0% |
| FORM | UTILITIES | 0 | 5 | 0% |
| FORM | CONSTRUCT | 18 | 20 | 90% |
| FORM | CONSTRUCT | 19 | 20 | 95% |
| FORM | INSPECT | 12 | 25 | 48% |
| FORM | INSERT | 7 | 13 | 54% |
| FORM | ASSEMBLE | 0 | 1 | 0% |
@@ -88,7 +88,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
| PLASTIC | MODIFY | 19 | 32 | 59% |
| PLASTIC | ASSEMBLE | 11 | 20 | 55% |
| PLASTIC | CONFIGURE | 2 | 3 | 67% |
| PLASTIC | CONSTRUCT | 18 | 20 | 90% |
| PLASTIC | CONSTRUCT | 19 | 20 | 95% |
| PLASTIC | INSPECT | 12 | 26 | 46% |
| PLASTIC | INSERT | 6 | 11 | 55% |
| PLASTIC | SELECT | 0 | 15 | 0% |
@@ -170,6 +170,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
- SOLID › CONSTRUCT › Plane at Angle
- SOLID › CONSTRUCT › Tangent Plane
- SOLID › CONSTRUCT › Midplane
- SOLID › CONSTRUCT › Perpendicular Plane
- SOLID › CONSTRUCT › Plane Through Two Edges
- SOLID › CONSTRUCT › Plane Through Three Points
- SOLID › CONSTRUCT › Plane Along Path
@@ -333,7 +334,6 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
- SOLID › ASSEMBLE › As-Built Constraint
- SOLID › CONFIGURE › Create Configuration Rules
- SOLID › CONSTRUCT › User Coordinate System (UCS)
- SOLID › CONSTRUCT › Perpendicular Plane
- SOLID › INSPECT › Validate
- SOLID › INSPECT › Component Connection Analysis
- SOLID › INSPECT › DOF Analysis