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 committed 2026-10-11 00:37:26 -04:00
1 parent 22672585c9
commit d71bdb78ec
54 files changed
+4179 -316

No files matched your search

+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}}}
]