Compare commits

...
7 Commits
25 changed files with 1213 additions and 135 deletions

No files matched your search

Generated
+13 -13
View File
@@ -3290,7 +3290,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft" name = "solvecraft"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"eframe", "eframe",
"egui", "egui",
@@ -3306,7 +3306,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-cli" name = "solvecraft-cli"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"serde_json", "serde_json",
"solvecraft-engine", "solvecraft-engine",
@@ -3315,7 +3315,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-doc" name = "solvecraft-doc"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -3330,7 +3330,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-engine" name = "solvecraft-engine"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -3346,7 +3346,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-geom" name = "solvecraft-geom"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -3354,7 +3354,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-io" name = "solvecraft-io"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"quick-xml 0.38.4", "quick-xml 0.38.4",
"solvecraft-doc", "solvecraft-doc",
@@ -3366,7 +3366,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-kernel" name = "solvecraft-kernel"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -3381,7 +3381,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-mcp" name = "solvecraft-mcp"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"serde_json", "serde_json",
"solvecraft-engine", "solvecraft-engine",
@@ -3389,7 +3389,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-render" name = "solvecraft-render"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"image", "image",
"serde", "serde",
@@ -3399,7 +3399,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-sketch" name = "solvecraft-sketch"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -3410,7 +3410,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-ui-egui" name = "solvecraft-ui-egui"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"egui", "egui",
"egui-wgpu", "egui-wgpu",
@@ -3425,7 +3425,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-web" name = "solvecraft-web"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"eframe", "eframe",
"egui", "egui",
@@ -4848,7 +4848,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]] [[package]]
name = "xtask" name = "xtask"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"serde_json", "serde_json",
] ]
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"] exclude = ["vendor"]
[workspace.package] [workspace.package]
version = "0.3.1" version = "0.3.2"
edition = "2024" edition = "2024"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
rust-version = "1.95" rust-version = "1.95"
+19
View File
@@ -289,7 +289,26 @@ headline counts in-scope tabs only and lists the deferred ones separately.
continuous collision detection; local thin walls and small details remain limitations. continuous collision detection; local thin walls and small details remain limitations.
See [contact-set limits and recovery](docs/contact-sets.md). See [contact-set limits and recovery](docs/contact-sets.md).
## Document-opening performance
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
benchmark runs reduce median rebuild time by 26% on the public sensor enclosure and 22%
on a local case assembly; shape measurements and topology match the original build.
See [performance measurements](docs/perf.md). Further cold Boolean and import costs remain.
- Prepared assembly geometry survives selection and view changes while real geometry and
placement changes invalidate it. A Pi assembly edge-selection probe drops from 9.24 s to
0.57 s; initial opening is unchanged. Actual Windows desktop profiling measures 15.87 s
median opening, with 82% spent rebuilding geometry; halving full opening remains open.
## Kernel ## Kernel
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
support and shared topology, preserving real steps and intentional boundaries. The local
reported case returns from 86 to 49 faces with unchanged material and bounds; regressions
cover stepped stock, STEP round trips and saved projections from removed pieces.
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README. retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README.
+121
View File
@@ -457,3 +457,124 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
} }
} }
} }
#[test]
fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
use solvecraft_kernel::{BoolOp, FacePaint, Paint};
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
let mut doc = Document::new("public stepped tab case");
doc.set_param("length", "40", Some("mm"), None).unwrap();
doc.set_param("wall", "2", Some("mm"), None).unwrap();
for (name, kind) in [
("Case", json!({"type":"box","corner":[0,0,0],"length":"length","width":"30","height":"20"})),
("Tab", json!({"type":"box","corner":["length",10,12],"length":"8","width":"10","height":"8"})),
] {
let id = doc.add_feature(serde_json::from_value(kind).unwrap(), Some(name)).unwrap();
doc.feature_mut(id).unwrap().body_names = vec![name.into()];
}
doc.add_feature(
FeatureKind::Combine { target: "Case".into(), tools: vec!["Tab".into()], operation: crate::Operation::Join, keep_tools: false },
None,
)
.unwrap();
let tool = doc
.add_feature(
serde_json::from_value(
json!({"type":"box","corner":["wall","wall","wall"],"length":"length-2*wall","width":"30-2*wall","height":"22-wall"}),
)
.unwrap(),
Some("Pocket tool"),
)
.unwrap();
doc.feature_mut(tool).unwrap().body_names = vec!["Pocket tool".into()];
let cut = doc
.add_feature(
FeatureKind::Combine { target: "Case".into(), tools: vec!["Pocket tool".into()], operation: crate::Operation::Cut, keep_tools: false },
Some("Pocket"),
)
.unwrap();
let mut model = Model::new();
model.evaluate(&doc);
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results);
let before = model.state_before(cut);
// A real per-face paint control preserves the former slab construction;
// no historical names or geometry are fabricated.
let old_stock = before
.body("Case")
.unwrap()
.body
.clone()
.with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
let old_cut = solvecraft_kernel::boolean(&old_stock, &before.body("Pocket tool").unwrap().body, BoolOp::Cut).unwrap().unwrap();
let mut old_body = ModelBody::new("Case".into(), old_cut, cut);
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
feature: doc.feature(cut).unwrap().clone(),
before,
depth: 1,
mats: vec![],
})));
let at = Vec3::new(0., 15., 1.);
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
assert_ne!(former, crate::naming::strip_piece(&former), "actual former bounded wall patch has a full piece name");
let index = crate::naming::face_on_body(&old_body, &former, at).unwrap().0;
let mut old_state = crate::ModelState::default();
old_state.bodies.push(old_body);
let plane = solvecraft_geom::Plane::YZ;
let resolved = crate::project::resolve_face_indices(&old_state, &plane, LinkKind::Project, "Case", &[index]).unwrap();
let current_state = model.state();
let current = current_state.body("Case").unwrap();
let stem = crate::naming::strip_piece(&former);
let current_names = crate::naming::face_names(current);
let merged = current_names.iter().position(|name| name == stem).unwrap();
assert!(current.body.face_was_coalesced(merged), "new exact generated-wall construction supplies the loss-only witness");
assert!(!current_names.contains(&former));
let mut sketch = Sketch::new();
sketch.add_link(LinkKind::Project, LinkSource::Face { body: "Case".into(), at }, &resolved.geom).unwrap();
sketch.links[0].face_name = Some(former);
let original = sketch.clone();
let captured = original.points.iter().filter(|p| p.link.is_some()).map(|p| p.pos).collect::<Vec<_>>();
assert_eq!(captured.len(), 4);
assert!(captured.iter().all(|p| (p.x == 10. || p.x == 20.) && (p.y == 0. || p.y == 20.)));
let sk = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch }, Some("Saved wall patch")).unwrap();
let material = doc
.add_feature(
FeatureKind::Extrude {
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
operation: crate::Operation::NewBody,
targets: vec![],
},
Some("Saved band stock"),
)
.unwrap();
for (length, wall) in [(40., 2.), (50., 3.)] {
doc.set_param("length", &length.to_string(), Some("mm"), None).unwrap();
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
model.evaluate(&doc);
let reopened = crate::format::read(&doc.to_json()).unwrap();
let mut cold = Model::new();
cold.evaluate(&reopened);
for current in [&model, &cold] {
let result = current.results.iter().find(|r| r.id == sk).unwrap();
assert!(result.error.is_none(), "{result:?}");
assert!(result.warning.as_deref().is_some_and(|w| w.contains("no longer exists; last geometry retained")), "{result:?}");
let state = current.state();
let saved = state.sketch(sk).unwrap();
assert!(saved.sketch.links[0].lost);
assert_eq!(saved.sketch.points, original.points);
assert_eq!(saved.sketch.curves, original.curves);
let stock = state.bodies.iter().find(|b| b.feature == material).unwrap();
assert!(
(solvecraft_kernel::measure(&stock.body).unwrap().volume - 200.).abs() < 1e-7,
"saved10x20 patch must not become30x20 wall; actualvolume={}, bounds={:?}, originalpoints={:?}",
solvecraft_kernel::measure(&stock.body).unwrap().volume,
stock.mesh().bounds(),
original.points
);
let case = state.body("Case").unwrap();
let expected = length * 30. * 20. + 8. * 10. * 8. - (length - 2. * wall) * (30. - 2. * wall) * (20. - wall);
assert!((solvecraft_kernel::measure(&case.body).unwrap().volume - expected).abs() < 1e-6);
}
}
}
+93 -3
View File
@@ -3436,9 +3436,18 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
let name = doc.world_body_name(st, &b.name, comp, &path); let name = doc.world_body_name(st, &b.name, comp, &path);
// A body moved in from another component keeps its place (its offset). // A body moved in from another component keeps its place (its offset).
let bm = doc.model_body_offset(b).map(|o| crate::mat_mul(&m, o)).unwrap_or(m); let bm = doc.model_body_offset(b).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
let body = // Identical geometry also shares its display mesh cell with the definition.
if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) }; // A changed placement keeps a distinct mesh, at that placement's coordinates.
let mut placed = ModelBody::new(name, body, b.feature); let mut placed = if crate::is_identity(&bm) {
let mut placed = b.clone();
placed.name = name;
placed
} else {
let body = kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone());
ModelBody::new(name, body, b.feature)
};
// World states keep their previous display-only evaluation-key semantics.
placed.key = 0;
placed.source_name = Some(b.definition_name().to_string()); placed.source_name = Some(b.definition_name().to_string());
placed.source_transform = crate::mat_mul(&bm, &b.source_transform); placed.source_transform = crate::mat_mul(&bm, &b.source_transform);
placed.component = comp; placed.component = comp;
@@ -3481,6 +3490,87 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
out out
} }
#[cfg(test)]
mod world_placement_cache_tests {
use super::*;
#[test]
fn placed_real_feature_retains_names_from_its_original_feature_context() {
let mut doc = Document::new("authored names");
let component = doc.add_component(Some("Part"), 0).unwrap();
let feature = doc
.add_feature(
FeatureKind::Box {
corner: crate::point_expr(Vec3::ZERO),
length: "2 mm".into(),
width: "3 mm".into(),
height: "4 mm".into(),
operation: crate::Operation::NewBody,
},
Some("AuthoredBox"),
)
.unwrap();
doc.feature_mut(feature).unwrap().component = component;
let matrix = crate::rigid(Vec3::new(15., -2., 8.), Vec3::ZERO, Vec3::X, 0.7);
doc.occurrences.iter_mut().find(|o| o.component == component).unwrap().transform = matrix;
let mut model = Model::default();
model.evaluate(&doc);
let state = model.state();
let source = &state.bodies[0];
assert!(source.names.origin.is_some());
assert!(source.mesh.get().is_none());
let world = world_state(&doc, &state);
assert!(source.mesh.get().is_some(), "source naming is prepared before adoption, as before");
let names = crate::naming::face_names(&world.bodies[0]);
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(source)));
assert!(names.iter().all(|name| name.starts_with(&format!("F{feature}:"))));
let local = source.mesh();
let placed = world.bodies[0].mesh();
assert_eq!(local.tri_face, placed.tri_face);
let mapped: Vec<_> = local.positions.iter().map(|p| crate::apply_point(&matrix, *p)).collect();
assert!(mapped.iter().all(|p| placed.positions.iter().any(|q| p.dist(*q) < 1e-8)));
assert!(placed.positions.iter().all(|p| mapped.iter().any(|q| p.dist(*q) < 1e-8)));
}
#[test]
fn identity_shares_mesh_but_rotated_instances_keep_distinct_physical_coordinates_and_source_names() {
let mut doc = Document::new("placements");
let component = doc.add_component(Some("Part"), 0).unwrap();
doc.body_components.insert("Block".into(), component);
let matrix = crate::rigid(Vec3::new(10., 20., 0.), Vec3::ZERO, Vec3::Z, std::f64::consts::FRAC_PI_2);
let second = doc.add_occurrence(component, 0, matrix).unwrap();
let mut state = ModelState::default();
state.bodies.push(ModelBody::new("Block".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(2., 3., 4.)).unwrap(), 1));
state.bodies[0].key = 1234;
let source = &state.bodies[0];
let world = world_state(&doc, &state);
assert_eq!(world.bodies.len(), 2);
let first = world.body("Block").unwrap();
let second_name = doc.world_body_name(&state, "Block", component, &[second]);
let placed = world.body(&second_name).unwrap();
assert!(source.mesh.get().is_some(), "persistent source names stay prepared before adoption");
assert!(Arc::ptr_eq(&source.mesh, &first.mesh), "unchanged geometry must retain the shared display mesh cell");
assert_eq!(first.key, 0);
assert_eq!(placed.key, 0);
assert!(!Arc::ptr_eq(&source.mesh, &placed.mesh), "a transformed mesh cannot be reused without mapping coordinates");
assert_eq!(first.source_name.as_deref(), Some("Block"));
assert_eq!(placed.source_name.as_deref(), Some("Block"));
assert_eq!(placed.source_transform, matrix);
assert_eq!(doc.model_body_component(placed), component);
let bounds = placed.mesh().bounds();
assert!(bounds.min.dist(Vec3::new(7., 20., 0.)) < 1e-9);
assert!(bounds.max.dist(Vec3::new(10., 22., 4.)) < 1e-9);
let names = crate::naming::face_names(source);
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(first)));
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(placed)));
let original_mesh = source.mesh();
assert!(Arc::ptr_eq(&original_mesh, &first.mesh()));
let again = world_state(&doc, &state);
assert!(Arc::ptr_eq(&original_mesh, &again.body("Block").unwrap().mesh()));
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(again.body(&second_name).unwrap())));
}
}
#[cfg(test)] #[cfg(test)]
mod chain_tests { mod chain_tests {
use super::*; use super::*;
+37 -12
View File
@@ -150,6 +150,28 @@ pub struct Preview {
pub after: Arc<ModelState>, pub after: Arc<ModelState>,
} }
/// Prepared world geometry depends on design/model identity and placement context, not
/// selection or the view revision. Retained Arcs prevent pointer reuse after a design closes.
#[derive(Clone)]
struct WorldCache {
doc: Arc<Document>,
model: Arc<solvecraft_doc::ModelState>,
pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
active_component: u64,
active_occurrence_path: Option<Vec<u64>>,
world: Arc<solvecraft_doc::ModelState>,
}
impl WorldCache {
fn matches(&self, session: &Session, model: &Arc<solvecraft_doc::ModelState>) -> bool {
Arc::ptr_eq(&self.doc, &session.doc)
&& Arc::ptr_eq(&self.model, model)
&& self.pending_moves == session.pending_moves
&& self.active_component == session.active_component
&& self.active_occurrence_path == session.active_occurrence_path
}
}
/// An editing session on one design. /// An editing session on one design.
pub struct Session { pub struct Session {
pub doc: Arc<Document>, pub doc: Arc<Document>,
@@ -173,7 +195,7 @@ pub struct Session {
pub active_occurrence_path: Option<Vec<u64>>, pub active_occurrence_path: Option<Vec<u64>>,
/// Occurrence moves not captured yet (Capture Position keeps them, a recompute drops them). /// Occurrence moves not captured yet (Capture Position keeps them, a recompute drops them).
pub pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>, pub pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
world_cache: std::sync::Mutex<Option<(u64, Arc<solvecraft_doc::ModelState>)>>, world_cache: std::sync::Mutex<Option<WorldCache>>,
occurrence_visibility_cache: std::sync::Mutex<Option<occurrences::VisibilityCache>>, occurrence_visibility_cache: std::sync::Mutex<Option<occurrences::VisibilityCache>>,
/// Features copied with `timeline.copy` (pasted by `timeline.paste`). /// Features copied with `timeline.copy` (pasted by `timeline.paste`).
pub clipboard: Vec<solvecraft_doc::Feature>, pub clipboard: Vec<solvecraft_doc::Feature>,
@@ -308,10 +330,10 @@ impl Session {
return st; return st;
} }
if let Ok(c) = self.world_cache.lock() if let Ok(c) = self.world_cache.lock()
&& let Some((rev, w)) = c.as_ref() && let Some(cached) = c.as_ref()
&& *rev == self.revision && cached.matches(self, &st)
{ {
return w.clone(); return cached.world.clone();
} }
let mut w = if self.pending_moves.is_empty() { let mut w = if self.pending_moves.is_empty() {
solvecraft_doc::world_state(&self.doc, &st) solvecraft_doc::world_state(&self.doc, &st)
@@ -350,7 +372,14 @@ impl Session {
} }
let w = Arc::new(w); let w = Arc::new(w);
if let Ok(mut c) = self.world_cache.lock() { if let Ok(mut c) = self.world_cache.lock() {
*c = Some((self.revision, w.clone())); *c = Some(WorldCache {
doc: self.doc.clone(),
model: st,
pending_moves: self.pending_moves.clone(),
active_component: self.active_component,
active_occurrence_path: self.active_occurrence_path.clone(),
world: w.clone(),
});
} }
w w
} }
@@ -503,7 +532,7 @@ impl Session {
active_component: self.active_component, active_component: self.active_component,
active_occurrence_path: self.active_occurrence_path.clone(), active_occurrence_path: self.active_occurrence_path.clone(),
pending_moves: self.pending_moves.clone(), pending_moves: self.pending_moves.clone(),
world_cache: Default::default(), world_cache: std::sync::Mutex::new(self.world_cache.lock().ok().and_then(|cache| cache.clone())),
occurrence_visibility_cache: Default::default(), occurrence_visibility_cache: Default::default(),
clipboard: self.clipboard.clone(), clipboard: self.clipboard.clone(),
section: None, section: None,
@@ -519,12 +548,6 @@ impl Session {
pub fn invalidate_view(&mut self, previous_revision: u64) { pub fn invalidate_view(&mut self, previous_revision: u64) {
let old = self.revision; let old = self.revision;
self.revision = old.max(previous_revision) + 1; self.revision = old.max(previous_revision) + 1;
if let Ok(mut cache) = self.world_cache.lock()
&& let Some((revision, _)) = cache.as_mut()
&& *revision == old
{
*revision = self.revision;
}
} }
/// Commit commands evaluated on a scratch copy without losing this session's file, /// Commit commands evaluated on a scratch copy without losing this session's file,
@@ -651,3 +674,5 @@ mod split_face_tests;
mod tests; mod tests;
#[cfg(test)] #[cfg(test)]
mod thread_tests; mod thread_tests;
#[cfg(test)]
mod world_placement_cache_tests;
@@ -0,0 +1,177 @@
//! Placement-cache changes must retain physical picking, pending positions and named joints.
use crate::{Session, geom::Vec3};
use serde_json::{Value, json};
use std::sync::Arc;
fn run(session: &mut Session, command: &str, params: Value) -> Value {
session.execute(command, &params).unwrap_or_else(|error| panic!("{command}: {error}"))
}
#[test]
fn world_placement_cache_preserves_pending_picks_materials_scratch_and_named_joint_frames() {
let mut session = Session::default();
let created = run(&mut session, "component.create", json!({"name":"Part"}));
let component = created["component"].as_u64().unwrap();
let first = created["occurrence"].as_u64().unwrap();
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
run(&mut session, "material.assign", json!({"bodies":["Block"],"material":"Brass"}));
let second = run(&mut session, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[10,20,0],"axis":[0,0,1],"angle":"90 deg","capture":false}));
let pending = session.pending_moves.clone();
let path = vec![second];
let placement = session.occurrence_placement(&path).unwrap();
let name = placement.bodies[0].world.clone();
let world = session.world_state();
assert!(Arc::ptr_eq(&world, &session.world_state()));
let source_state = session.model.state();
let source = source_state.body("Block").unwrap();
let placed = world.body(&name).unwrap();
assert_eq!(placed.source_name.as_deref(), Some("Block"));
assert_eq!(placed.component, component);
let bounds = placed.mesh().bounds();
assert!(bounds.min.dist(Vec3::new(7., 20., 0.)) < 1e-9);
assert!(bounds.max.dist(Vec3::new(10., 22., 4.)) < 1e-9);
assert_eq!(session.doc.model_body_look(placed), session.doc.model_body_look(source));
let source_names = crate::doc::naming::face_names(source);
assert_eq!(source_names, crate::doc::naming::face_names(placed));
let pick = Vec3::new(8.5, 21., 4.);
let top = placed.body.faces(0.001).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|n| n.z > 0.99)).unwrap().index;
run(&mut session, "select.set", json!({"items":[{"type":"face","body":name,"index":top,"point":[pick.x,pick.y,pick.z]}]}));
assert!(matches!(&session.selection[0], crate::Sel::Face { body, index, .. } if body == &name && *index == top));
assert!(Arc::ptr_eq(&world, &session.world_state()), "selection changes must not discard prepared placements");
let scratch = session.scratch();
assert_eq!(scratch.pending_moves, pending);
let scratch_world = scratch.world_state();
assert!(Arc::ptr_eq(&world, &scratch_world));
let scratch_body = scratch_world.body(&name).unwrap();
assert!(scratch_body.mesh().bounds().min.dist(bounds.min) < 1e-9);
assert_eq!(crate::doc::naming::face_names(scratch_body), source_names);
run(&mut session, "component.capture_position", json!({}));
run(&mut session, "joint.origin", json!({"name":"Top joint","occurrence":second,"face":[pick.x,pick.y,pick.z]}));
let joint = session.doc.assembly.origins[0].origin.clone();
let local_frame = crate::doc::joints::resolve_origin(&session.doc, &session.model.state(), &joint).unwrap();
let before_joint = crate::doc::mat_mul(&crate::doc::joints::occurrence_world(&session.doc, second), &local_frame);
assert!(crate::doc::apply_point(&before_joint, Vec3::ZERO).dist(pick) < 1e-9);
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":true}));
let moved = session.world_state();
assert!(!Arc::ptr_eq(&world, &moved), "a changed transform must invalidate placed meshes");
assert!(moved.body(&name).unwrap().mesh().bounds().min.dist(bounds.min + Vec3::new(5., 0., 0.)) < 1e-9);
assert!(world.body(&name).unwrap().mesh().bounds().min.dist(bounds.min) < 1e-9, "prior snapshots must remain immutable");
assert_eq!(crate::doc::naming::face_names(moved.body(&name).unwrap()), source_names);
let local_frame = crate::doc::joints::resolve_origin(&session.doc, &session.model.state(), &joint).unwrap();
let after_joint = crate::doc::mat_mul(&crate::doc::joints::occurrence_world(&session.doc, second), &local_frame);
assert!(crate::doc::apply_point(&after_joint, Vec3::ZERO).dist(pick + Vec3::new(5., 0., 0.)) < 1e-9);
let reopened = Session::new(crate::doc::Document::from_json(&session.doc.to_json()).unwrap());
assert!(reopened.world_state().body(&name).unwrap().mesh().bounds().min.dist(bounds.min + Vec3::new(5., 0., 0.)) < 1e-9);
assert_eq!(reopened.model.results.iter().filter(|r| r.error.is_some()).count(), 0);
}
#[test]
fn selection_visibility_section_and_view_revisions_reuse_world_geometry() {
let mut session = Session::default();
run(&mut session, "component.create", json!({"name":"Part"}));
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
let world = session.world_state();
let revision = session.revision;
run(&mut session, "select.set", json!({"items":[{"type":"body","name":"Block"}]}));
assert!(session.revision > revision);
assert!(Arc::ptr_eq(&world, &session.world_state()));
run(&mut session, "select.clear", json!({}));
assert!(Arc::ptr_eq(&world, &session.world_state()));
run(&mut session, "browser.visibility", json!({"items":["Block"],"visible":false}));
assert!(!session.body_visible("Block"));
assert!(Arc::ptr_eq(&world, &session.world_state()));
run(&mut session, "inspect.section", json!({"plane":{"origin":[0,0,2],"normal":[0,0,1]}}));
assert!(session.section.is_some());
assert!(Arc::ptr_eq(&world, &session.world_state()));
session.invalidate_view(session.revision + 10);
assert!(Arc::ptr_eq(&world, &session.world_state()));
assert!(Arc::ptr_eq(&world, &session.scratch().world_state()));
}
#[test]
fn world_cache_requires_exact_design_model_pending_and_author_context() {
let mut session = Session::default();
let created = run(&mut session, "component.create", json!({"name":"Part"}));
let component = created["component"].as_u64().unwrap();
let first = created["occurrence"].as_u64().unwrap();
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
let sketch = run(&mut session, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
run(&mut session, "sketch.finish", json!({}));
let second = run(&mut session, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
let initial = session.world_state();
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[10,20,0],"capture":false}));
let pending = session.world_state();
assert!(!Arc::ptr_eq(&initial, &pending));
let name = session.occurrence_placement(&[second]).unwrap().bodies[0].world.clone();
assert!(pending.body(&name).unwrap().mesh().bounds().min.dist(Vec3::new(10., 20., 0.)) < 1e-9);
run(&mut session, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
let authored = session.world_state();
assert!(!Arc::ptr_eq(&pending, &authored));
assert!(authored.sketch(sketch).unwrap().plane.origin.dist(Vec3::new(10., 20., 0.)) < 1e-9);
let mut scratch = session.scratch();
assert!(Arc::ptr_eq(&authored, &scratch.world_state()));
scratch.active_occurrence_path = Some(vec![first]);
let scratch_world = scratch.world_state();
assert!(!Arc::ptr_eq(&authored, &scratch_world));
assert!(scratch_world.sketch(sketch).unwrap().plane.origin.dist(Vec3::ZERO) < 1e-9);
assert!(Arc::ptr_eq(&authored, &session.world_state()), "scratch context must not replace the source cache");
run(&mut session, "appearance.assign", json!({"bodies":["Block"],"color":"#ff0000"}));
let painted = session.world_state();
assert!(!Arc::ptr_eq(&authored, &painted));
assert_ne!(session.doc.model_body_look(painted.body("Block").unwrap()), None);
run(&mut session, "edit.undo", json!({}));
let undone = session.world_state();
assert!(!Arc::ptr_eq(&painted, &undone));
run(&mut session, "edit.redo", json!({}));
let redone = session.world_state();
assert!(!Arc::ptr_eq(&undone, &redone));
assert!(session.doc.model_body_look(redone.body("Block").unwrap()).is_some());
let before_edit = session.world_state();
run(&mut session, "solid.box", json!({"length":5,"width":6,"height":7,"body_name":"Other","operation":"new"}));
let edited = session.world_state();
assert!(!Arc::ptr_eq(&before_edit, &edited));
assert!(edited.body("Other").is_some());
session.doc = Arc::new(crate::doc::Document::from_json(&session.doc.to_json()).unwrap());
let replaced = session.world_state();
assert!(!Arc::ptr_eq(&edited, &replaced), "equal contents in a different document cannot share the cache key");
session.model = crate::doc::Model::default();
session.model.evaluate(&session.doc);
assert!(!Arc::ptr_eq(&replaced, &session.world_state()), "a new evaluated model must invalidate even when the document Arc is unchanged");
}
#[test]
fn adopted_scratch_keeps_its_warm_geometry_and_history_stages_invalidate_it() {
let mut session = Session::default();
run(&mut session, "component.create", json!({"name":"Part"}));
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
let source = session.world_state();
let source_json = session.doc.to_json();
let mut scratch = session.scratch();
run(&mut scratch, "solid.box", json!({"length":5,"width":6,"height":7,"body_name":"Draft","operation":"new"}));
let warmed = scratch.world_state();
assert!(!Arc::ptr_eq(&source, &warmed));
assert_eq!(session.doc.to_json(), source_json);
assert!(Arc::ptr_eq(&source, &session.world_state()));
assert!(source.body("Draft").is_none());
let feature = warmed.body("Draft").unwrap().feature;
session.accept_computed(scratch);
assert!(Arc::ptr_eq(&warmed, &session.world_state()), "adoption/view revision must retain the worker's prepared geometry");
session.invalidate_view(session.revision + 20);
assert!(Arc::ptr_eq(&warmed, &session.world_state()));
run(&mut session, "timeline.rollback", json!({"position":1}));
let rolled = session.world_state();
assert!(!Arc::ptr_eq(&warmed, &rolled));
assert!(rolled.body("Block").is_some());
assert!(rolled.body("Draft").is_none());
run(&mut session, "timeline.rollback", json!({}));
let restored = session.world_state();
assert!(!Arc::ptr_eq(&rolled, &restored));
assert!(restored.body("Draft").is_some());
run(&mut session, "timeline.suppress", json!({"feature":feature,"suppressed":true}));
let suppressed = session.world_state();
assert!(!Arc::ptr_eq(&restored, &suppressed));
assert!(suppressed.body("Draft").is_none());
run(&mut session, "timeline.suppress", json!({"feature":feature,"suppressed":false}));
assert!(session.world_state().body("Draft").is_some());
}
+87 -1
View File
@@ -244,8 +244,24 @@ impl Seg2 {
} }
} }
} }
/// Parameter of the point of the segment closest to `p` (sampling, then Newton). /// Parameter of the closest point: exact projection for lines,
/// sampling followed by Newton for the remaining curves.
pub fn closest_param(&self, p: Vec2) -> f64 { pub fn closest_param(&self, p: Vec2) -> f64 {
let finite = |p: Vec2| p.x.is_finite() && p.y.is_finite();
match *self {
Seg2::Line { a, b } if finite(a) && finite(b) && finite(p) => {
let d = b - a;
if a == b {
return 0.0;
}
let denominator = d.len2();
let t = (p - a).dot(d) / denominator;
if denominator > 0.0 && denominator.is_finite() && t.is_finite() {
return t.clamp(0.0, 1.0);
}
}
_ => {}
}
let n = 64; let n = 64;
let mut t = let mut t =
(0..=n).map(|i| i as f64 / n as f64).min_by(|x, y| self.point_at(*x).dist(p).total_cmp(&self.point_at(*y).dist(p))).unwrap_or(0.0); (0..=n).map(|i| i as f64 / n as f64).min_by(|x, y| self.point_at(*x).dist(p).total_cmp(&self.point_at(*y).dist(p))).unwrap_or(0.0);
@@ -540,6 +556,76 @@ impl Region2 {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn closest_line_projection_and_degeneracy() {
let line = Seg2::Line { a: Vec2::new(-3.0, 4.0), b: Vec2::new(7.0, 4.0) };
for (p, t) in [(Vec2::new(-9.0, 20.0), 0.0), (Vec2::new(2.0, -5.0), 0.5), (Vec2::new(15.0, 4.0), 1.0)] {
assert_eq!(line.closest_param(p), t);
assert_eq!(line.reversed().closest_param(p), 1.0 - t);
}
let a = Vec2::new(1e100, -1e100);
let b = a + Vec2::new(1e90, 2e90);
let large = Seg2::Line { a, b };
assert!((large.closest_param(a.lerp(b, 0.5)) - 0.5).abs() < 1e-6);
assert_eq!(Seg2::Line { a, b: a }.closest_param(Vec2::ZERO), 0.0);
let tiny = Seg2::Line { a: Vec2::ZERO, b: Vec2::new(1e-12, 0.0) };
assert_eq!(tiny.closest_param(Vec2::new(0.5e-12, 1.0)), 0.5);
for line in [line, line.reversed(), tiny] {
for k in 0..53 {
let p = Vec2::new((k * 17 % 37) as f64 - 18.0, (k * 31 % 29) as f64 - 14.0);
let closest = line.point_at(line.closest_param(p)).dist(p);
let dense = (0..=8192).map(|i| line.point_at(i as f64 / 8192.0).dist(p)).fold(f64::INFINITY, f64::min);
assert!(closest <= dense + 1e-12);
}
}
}
fn sampled_closest(s: &Seg2, p: Vec2) -> f64 {
let mut t = (0..=64).map(|i| i as f64 / 64.0).min_by(|x, y| s.point_at(*x).dist(p).total_cmp(&s.point_at(*y).dist(p))).unwrap_or(0.0);
for _ in 0..30 {
let (q, d) = (s.point_at(t), s.derivative(t));
let (d2, h) = ((s.derivative((t + 1e-6).min(1.0)) - s.derivative((t - 1e-6).max(0.0))) / 2e-6, q - p);
let df = d.dot(d) + h.dot(d2);
if df.abs() < 1e-300 {
break;
}
let nt = (t - h.dot(d) / df).clamp(0.0, 1.0);
if (nt - t).abs() < 1e-14 {
t = nt;
break;
}
t = nt;
}
t
}
#[test]
fn closest_projection_preserves_clearance_tolerance_and_generic_fallback() {
use std::f64::consts::TAU;
let curves = [
Seg2::Line { a: Vec2::new(-4.0, 7.0), b: Vec2::new(3.0, -2.0) },
Seg2::Arc { center: Vec2::new(3.0, -2.0), radius: 6.0, start: 5.9, sweep: 1.3 },
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.2, sweep: -TAU },
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: TAU },
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 0.0 },
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 1e-12 },
Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(2.0, 7.0), p2: Vec2::new(6.0, -3.0), p3: Vec2::new(8.0, 4.0) },
];
for (i, s) in curves.iter().enumerate() {
for k in 0..101 {
let p = Vec2::new((k * 17 % 37) as f64 - 18.0, (k * 31 % 29) as f64 - 14.0);
let old = sampled_closest(s, p);
let new = s.closest_param(p);
if i > 0 {
assert_eq!(new.to_bits(), old.to_bits());
}
assert!(s.point_at(new).dist(p) <= s.point_at(old).dist(p) + 1e-10);
let on_curve = s.point_at(k as f64 / 100.0);
assert!(s.point_at(s.closest_param(on_curve)).dist(on_curve) < 1e-9);
}
}
}
#[test] #[test]
fn triangulated_area() { fn triangulated_area() {
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(5.0, 3.0), Vec2::new(0.0, 8.0)]); let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(5.0, 3.0), Vec2::new(0.0, 8.0)]);
+94 -31
View File
@@ -33,6 +33,8 @@ pub struct Body {
pub(crate) splits: Option<std::sync::Arc<Vec<Vec<Vec3>>>>, pub(crate) splits: Option<std::sync::Arc<Vec<Vec<Vec3>>>>,
/// Exact construction/import source identities, in this body's face order. /// Exact construction/import source identities, in this body's face order.
pub(crate) face_sources: Option<std::sync::Arc<Vec<Option<FaceSource>>>>, pub(crate) face_sources: Option<std::sync::Arc<Vec<Option<FaceSource>>>>,
/// Sticky transient veto: authored face boundaries must survive later rebuilds.
pub(crate) regularization_protected: bool,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@@ -120,6 +122,11 @@ fn mesh_shells(solid: &Solid, tol: f64) -> Vec<MeshedShell> {
/// `None` when nothing needs splitting or the parameters don't match the surface. /// `None` when nothing needs splitting or the parameters don't match the surface.
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3, Vec3); 3]>> { fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3, Vec3); 3]>> {
refined_with_cache(pm, surf, tol, true)
}
#[allow(clippy::type_complexity)]
fn refined_with_cache(pm: &PolygonMesh, surf: &mt::Surface, tol: f64, memoize: bool) -> Option<Vec<[(Vec3, Vec3); 3]>> {
use mt::{ParametricSurface, ParametricSurface3D}; use mt::{ParametricSurface, ParametricSurface3D};
let (pos, uvs, nor) = (pm.positions(), pm.uv_coords(), pm.normals()); let (pos, uvs, nor) = (pm.positions(), pm.uv_coords(), pm.normals());
if uvs.is_empty() { if uvs.is_empty() {
@@ -148,6 +155,15 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3,
// Edges on the face's boundary are shared with the next face: never split them. // Edges on the face's boundary are shared with the next face: never split them.
let k = |x: &V| ((x.1.0 * 1e9).round() as i64, (x.1.1 * 1e9).round() as i64); let k = |x: &V| ((x.1.0 * 1e9).round() as i64, (x.1.1 * 1e9).round() as i64);
let ek = |a: &V, b: &V| if k(a) < k(b) { (k(a), k(b)) } else { (k(b), k(a)) }; let ek = |a: &V, b: &V| if k(a) < k(b) { (k(a), k(b)) } else { (k(b), k(a)) };
// Only exact finite values identify reusable evaluations. In particular,
// the rounded UV keys used for boundary incidence cannot identify a point
// whose derivatives or midpoint may be reused.
let vertex_key = |v: &V| {
let values = [v.0.x, v.0.y, v.0.z, v.1.0, v.1.1, v.2.x, v.2.y, v.2.z];
values.iter().all(|x| x.is_finite()).then(|| values.map(f64::to_bits))
};
let cache_limit = cap * 3;
let mut singular_cache = std::collections::HashMap::<(u64, u64), bool>::new();
let mut changed = false; let mut changed = false;
for _ in 0..4 { for _ in 0..4 {
let mut uses: std::collections::HashMap<((i64, i64), (i64, i64)), u32> = std::collections::HashMap::new(); let mut uses: std::collections::HashMap<((i64, i64), (i64, i64)), u32> = std::collections::HashMap::new();
@@ -156,34 +172,60 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3,
*uses.entry(e).or_default() += 1; *uses.entry(e).or_default() += 1;
} }
} }
let at = |a: &V, b: &V| -> Option<V> { let mut edge_cache = std::collections::HashMap::<([u64; 8], [u64; 8]), Option<V>>::new();
if uses.get(&ek(a, b)).copied().unwrap_or(0) < 2 { let mut at = |a: &V, b: &V| -> Option<V> {
return None; let key = if memoize { vertex_key(a).zip(vertex_key(b)).map(|(a, b)| if a < b { (a, b) } else { (b, a) }) } else { None };
if let Some(key) = key
&& let Some(value) = edge_cache.get(&key)
{
return *value;
} }
// Not at a degenerate point (a cone's apex: its parameters there are arbitrary). let mut calculate = || -> Option<V> {
let singular = |x: &V| { if uses.get(&ek(a, b)).copied().unwrap_or(0) < 2 {
let (du, dv) = (surf.uder(x.1.0, x.1.1), surf.vder(x.1.0, x.1.1)); return None;
let c = du.cross(dv); }
c.x * c.x + c.y * c.y + c.z * c.z // Not at a degenerate point (a cone's apex: its parameters there are arbitrary).
<= 1e-12 * (du.x * du.x + du.y * du.y + du.z * du.z + dv.x * dv.x + dv.y * dv.y + dv.z * dv.z).powi(2) + 1e-300 let mut singular = |x: &V| {
let key = (x.1.0.to_bits(), x.1.1.to_bits());
let cacheable = memoize && x.1.0.is_finite() && x.1.1.is_finite();
if cacheable && let Some(value) = singular_cache.get(&key) {
return *value;
}
let (du, dv) = (surf.uder(x.1.0, x.1.1), surf.vder(x.1.0, x.1.1));
let c = du.cross(dv);
let value = c.x * c.x + c.y * c.y + c.z * c.z
<= 1e-12 * (du.x * du.x + du.y * du.y + du.z * du.z + dv.x * dv.x + dv.y * dv.y + dv.z * dv.z).powi(2) + 1e-300;
if cacheable && singular_cache.len() < cache_limit {
singular_cache.insert(key, value);
}
value
};
if singular(a) || singular(b) {
return None;
}
let (u, v) = ((a.1.0 + b.1.0) / 2.0, (a.1.1 + b.1.1) / 2.0);
let q = from_p3(surf.subs(u, v));
// Within tolerance, or so far off that the parameters wrapped (a seam).
let sag = q.dist((a.0 + b.0) * 0.5);
if sag <= tol || sag >= 0.5 * a.0.dist(b.0) {
return None;
}
let n = surf.normal(u, v);
let mut n = Vec3::new(n.x, n.y, n.z);
// Keep the mesh's normal side.
if n.dot(a.2 + b.2) < 0.0 {
n = -n;
}
Some((q, (u, v), n))
}; };
if singular(a) || singular(b) { let value = calculate();
return None; if let Some(key) = key
&& edge_cache.len() < cache_limit
&& value.is_none_or(|v| vertex_key(&v).is_some())
{
edge_cache.insert(key, value);
} }
let (u, v) = ((a.1.0 + b.1.0) / 2.0, (a.1.1 + b.1.1) / 2.0); value
let q = from_p3(surf.subs(u, v));
// Within tolerance, or so far off that the parameters wrapped (a seam).
let sag = q.dist((a.0 + b.0) * 0.5);
if sag <= tol || sag >= 0.5 * a.0.dist(b.0) {
return None;
}
let n = surf.normal(u, v);
let mut n = Vec3::new(n.x, n.y, n.z);
// Keep the mesh's normal side.
if n.dot(a.2 + b.2) < 0.0 {
n = -n;
}
Some((q, (u, v), n))
}; };
let mut next = Vec::with_capacity(tris.len()); let mut next = Vec::with_capacity(tris.len());
let mut any = false; let mut any = false;
@@ -320,6 +362,7 @@ impl Body {
&& self.paint == other.paint && self.paint == other.paint
&& self.splits == other.splits && self.splits == other.splits
&& self.face_sources == other.face_sources && self.face_sources == other.face_sources
&& self.regularization_protected == other.regularization_protected
} }
pub(crate) fn new(solid: Solid) -> Result<Body> { pub(crate) fn new(solid: Solid) -> Result<Body> {
@@ -358,9 +401,15 @@ impl Body {
paint: None, paint: None,
splits: None, splits: None,
face_sources: None, face_sources: None,
regularization_protected: false,
}) })
} }
pub(crate) fn inherit_regularization_protection(mut self, input: &Body) -> Self {
self.regularization_protected |= input.regularization_protected;
self
}
pub fn face_sources(&self) -> Option<&[Option<FaceSource>]> { pub fn face_sources(&self) -> Option<&[Option<FaceSource>]> {
self.face_sources.as_deref().map(Vec::as_slice) self.face_sources.as_deref().map(Vec::as_slice)
} }
@@ -588,6 +637,7 @@ impl Body {
/// The same body with the lines its faces were split along on purpose. /// The same body with the lines its faces were split along on purpose.
pub fn with_split_lines(mut self, s: Option<std::sync::Arc<Vec<Vec<Vec3>>>>) -> Body { pub fn with_split_lines(mut self, s: Option<std::sync::Arc<Vec<Vec<Vec3>>>>) -> Body {
self.splits = s.filter(|l| !l.is_empty()); self.splits = s.filter(|l| !l.is_empty());
self.regularization_protected |= self.splits.is_some();
self self
} }
@@ -612,6 +662,7 @@ impl Body {
}) })
.filter(|p| p.opacity < 1.0 || !p.faces.is_empty()) .filter(|p| p.opacity < 1.0 || !p.faces.is_empty())
.map(std::sync::Arc::new); .map(std::sync::Arc::new);
self.regularization_protected |= self.paint().is_some_and(|paint| !paint.faces.is_empty());
self self
} }
@@ -658,11 +709,15 @@ impl Body {
let flip = !face.orientation(); let flip = !face.orientation();
let base = u32::try_from(out.positions.len()).unwrap_or(u32::MAX); let base = u32::try_from(out.positions.len()).unwrap_or(u32::MAX);
// Curved faces: split triangles that cut through the surface. // Curved faces: split triangles that cut through the surface.
if refine let refinement = if refine {
&& let Some(sf) = solid_faces.get(fi) solid_faces.get(fi).and_then(|sf| {
&& !matches!(sf.surface(), mt::Surface::Plane(_)) let surface = sf.surface();
&& let Some(tris) = refined(&pm, &sf.surface(), tol) if matches!(surface, mt::Surface::Plane(_)) { None } else { refined(&pm, &surface, tol) }
{ })
} else {
None
};
if let Some(tris) = refinement {
let fid = u32::try_from(fi).unwrap_or(u32::MAX); let fid = u32::try_from(fi).unwrap_or(u32::MAX);
for t in tris { for t in tris {
let mut idx = [0u32; 3]; let mut idx = [0u32; 3];
@@ -1019,7 +1074,7 @@ impl Body {
}) })
.collect() .collect()
}); });
Ok(result.with_face_sources(sources.map(std::sync::Arc::new))) Ok(result.with_face_sources(sources.map(std::sync::Arc::new)).inherit_regularization_protection(self))
}) })
.collect() .collect()
}) })
@@ -1029,3 +1084,11 @@ impl Body {
#[cfg(test)] #[cfg(test)]
#[path = "body_lump_tests.rs"] #[path = "body_lump_tests.rs"]
mod lump_tests; mod lump_tests;
#[cfg(test)]
#[path = "refine_cache_tests.rs"]
mod refine_cache_tests;
#[cfg(test)]
#[path = "regularization_protection_tests.rs"]
mod regularization_protection_tests;
+2
View File
@@ -109,6 +109,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
paint: None, paint: None,
splits: None, splits: None,
face_sources: None, face_sources: None,
regularization_protected: false,
}) })
} }
@@ -201,6 +202,7 @@ impl Body {
paint: self.paint.clone(), paint: self.paint.clone(),
splits: self.splits.clone(), splits: self.splits.clone(),
face_sources: None, face_sources: None,
regularization_protected: self.regularization_protected,
}) })
} }
} }
+9 -3
View File
@@ -221,6 +221,7 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
)), )),
}; };
} }
r = r.map(|result| result.map(|body| body.inherit_regularization_protection(a).inherit_regularization_protection(b)));
if matches!(op, BoolOp::Cut | BoolOp::Union) { if matches!(op, BoolOp::Cut | BoolOp::Union) {
r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, op == BoolOp::Cut)).transpose()); r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, op == BoolOp::Cut)).transpose());
} }
@@ -538,7 +539,11 @@ pub(crate) fn join_pieces(pieces: Vec<Body>) -> Result<Option<Body>> {
_ => { _ => {
let shells: Vec<mt::Shell> = pieces.iter().flat_map(|p| p.solid.boundaries().clone()).collect(); let shells: Vec<mt::Shell> = pieces.iter().flat_map(|p| p.solid.boundaries().clone()).collect();
let solid = guard("pieces", || Solid::try_new(shells).map_err(|e| KernelError::Failed(e.to_string())))?; let solid = guard("pieces", || Solid::try_new(shells).map_err(|e| KernelError::Failed(e.to_string())))?;
Body::new(solid).map(Some) let protected = pieces.iter().any(|piece| piece.regularization_protected);
Body::new(solid).map(|mut body| {
body.regularization_protected = protected;
Some(body)
})
} }
} }
} }
@@ -558,8 +563,9 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
); );
// An affine placement retains face order and appearance metadata. // An affine placement retains face order and appearance metadata.
let sources = body.transformed_sources(m)?; let sources = body.transformed_sources(m)?;
Body::new(placed_topology(&body.solid, mat)?) Body::new(placed_topology(&body.solid, mat)?).map(|placed| {
.map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources)) placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources).inherit_regularization_protection(body)
})
}) })
} }
+49 -3
View File
@@ -117,6 +117,36 @@ fn merged_face(faces: &[&mt::Face], members: &[usize], plane: Plane, eligible: &
impl Body { impl Body {
pub(crate) fn regularize_certified_planar_faces(self) -> Result<Self> { pub(crate) fn regularize_certified_planar_faces(self) -> Result<Self> {
self.regularize_planar_faces(false)
}
/// Only newly produced Cut walls call this path. The temporary planes carry
/// construction support, never persistent ancestor/source-role authority.
pub(crate) fn regularize_generated_planar_faces(self) -> Result<Self> {
let body = self.regularize_planar_faces(true)?;
let sources = body.face_sources().map(|sources| {
sources
.iter()
.map(|source| {
let mut source = source.clone();
if let Some(super::FaceSource::ExtrudeSide { plane, coalesced, .. }) = &mut source {
if plane.is_some() && !*coalesced {
source = None;
} else {
*plane = None;
}
}
source
})
.collect()
});
Ok(body.with_face_sources(sources.map(Arc::new)))
}
fn regularize_planar_faces(self, generated: bool) -> Result<Self> {
if self.regularization_protected {
return Ok(self);
}
let Some(sources) = self.face_sources() else { return Ok(self) }; let Some(sources) = self.face_sources() else { return Ok(self) };
if sources.len() > 16_384 || self.paint().is_some_and(|p| !p.faces.is_empty()) { if sources.len() > 16_384 || self.paint().is_some_and(|p| !p.faces.is_empty()) {
return Ok(self); return Ok(self);
@@ -148,7 +178,16 @@ impl Body {
.zip(sources) .zip(sources)
.map(|(f, s)| { .map(|(f, s)| {
s.as_ref() s.as_ref()
.and_then(super::planar_sources::certified_plane) .and_then(|source| {
if generated {
match source {
super::FaceSource::ExtrudeSide { segment: solvecraft_geom::Seg2::Line { .. }, plane: Some(p), .. } => Some(*p),
_ => None,
}
} else {
super::planar_sources::certified_plane(source)
}
})
.filter(|p| super::planar_sources::actual_plane(f).is_some_and(|actual| super::planar_sources::same_support(p, &actual))) .filter(|p| super::planar_sources::actual_plane(f).is_some_and(|actual| super::planar_sources::same_support(p, &actual)))
}) })
.collect(); .collect();
@@ -179,7 +218,7 @@ impl Body {
}; };
let Some(left) = identity.checked_sub(cost) else { return Ok(None) }; let Some(left) = identity.checked_sub(cost) else { return Ok(None) };
identity = left; identity = left;
if sa != sb { if !generated && sa != sb {
continue; continue;
} }
let Some(cost) = split_points.checked_mul(6).and_then(|n| n.checked_add(1)) else { return Ok(None) }; let Some(cost) = split_points.checked_mul(6).and_then(|n| n.checked_add(1)) else { return Ok(None) };
@@ -235,7 +274,14 @@ impl Body {
let Some(left) = metadata.checked_sub(cost) else { return Ok(None) }; let Some(left) = metadata.checked_sub(cost) else { return Ok(None) };
metadata = left; metadata = left;
} }
output_sources.push(source.cloned()); let mut source = source.cloned();
if generated
&& replacements.contains_key(&leader)
&& let Some(super::FaceSource::ExtrudeSide { coalesced, .. }) = &mut source
{
*coalesced = true;
}
output_sources.push(source);
offset += 1; offset += 1;
} }
shells.push(mt::Shell::from(output)); shells.push(mt::Shell::from(output));
+4 -2
View File
@@ -114,13 +114,15 @@ impl Body {
/// Retain exact shell roles, and construction roles for Cut, only on unchanged /// Retain exact shell roles, and construction roles for Cut, only on unchanged
/// supports without a competing coplanar tool. No imported or fitted role is inferred. /// supports without a competing coplanar tool. No imported or fitted role is inferred.
pub(crate) fn with_preserved_boolean_planar_sources(self, input: &Body, tool: &Body, constructed: bool) -> Result<Self> { pub(crate) fn with_preserved_boolean_planar_sources(self, input: &Body, tool: &Body, constructed: bool) -> Result<Self> {
let self_ = self.inherit_regularization_protection(input).inherit_regularization_protection(tool);
if !input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, constructed))) { if !input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, constructed))) {
return Ok(self); return Ok(self_);
} }
self.map_planar_sources(input, None, Some(tool), &[], constructed) self_.map_planar_sources(input, None, Some(tool), &[], constructed)
} }
fn map_planar_sources(mut self, input: &Body, shell: Option<f64>, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result<Self> { fn map_planar_sources(mut self, input: &Body, shell: Option<f64>, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result<Self> {
self.regularization_protected |= input.regularization_protected;
let Some(sources) = input.face_sources() else { return Ok(self) }; let Some(sources) = input.face_sources() else { return Ok(self) };
if input.solid.face_iter().count() > 16_384 if input.solid.face_iter().count() > 16_384
|| self.solid.face_iter().count() > 16_384 || self.solid.face_iter().count() > 16_384
+9 -1
View File
@@ -342,7 +342,15 @@ pub fn round_all_edges(b: &Body, r: f64) -> Result<Body> {
/// by the thickness (open faces outward, past the body) keeping the body's topology, which /// by the thickness (open faces outward, past the body) keeping the body's topology, which
/// gives the cavity; the shell is the body minus the cavity. /// gives the cavity; the shell is the body minus the cavity.
fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> { fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?; // Face paint and authored splits are construction boundaries. Retain their
// eligibility veto through the temporary body used for the cavity Cut.
// Paint indices stay in source order by avoiding healing in that case.
let solid = if b.paint().is_some_and(|paint| !paint.faces.is_empty()) {
b.deep_copy()
} else {
crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep())
};
let healed = Body::new(solid)?.with_paint(b.paint().cloned()).with_split_lines(b.split_lines().cloned()).inherit_regularization_protection(b);
let size = healed.size(); let size = healed.size();
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?; let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
// Faces to open: the face of the triangle nearest each point. // Faces to open: the face of the triangle nearest each point.
+95 -2
View File
@@ -848,6 +848,16 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
} }
let mut faces: Vec<FaceSpec> = Vec::new(); let mut faces: Vec<FaceSpec> = Vec::new();
let mut sources = Vec::new(); let mut sources = Vec::new();
let frame_is_orthonormal = plane.origin.is_finite()
&& plane.x.is_finite()
&& plane.y.is_finite()
&& d.is_finite()
&& (plane.x.len2() - 1.).abs() <= 64. * f64::EPSILON
&& (plane.y.len2() - 1.).abs() <= 64. * f64::EPSILON
&& (d.len2() - 1.).abs() <= 64. * f64::EPSILON
&& plane.x.dot(plane.y).abs() <= 64. * f64::EPSILON
&& plane.x.dot(d).abs() <= 64. * f64::EPSILON
&& plane.y.dot(d).abs() <= 64. * f64::EPSILON;
for (first, end, sg) in runs { for (first, end, sg) in runs {
let (z0, z1) = (levels[first], levels[end]); let (z0, z1) = (levels[first], levels[end]);
let (a, b) = (sg.start(), sg.end()); let (a, b) = (sg.start(), sg.end());
@@ -868,7 +878,28 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
_ => SurfSpec::Plane, _ => SurfSpec::Plane,
}; };
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) }); faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
sources.push((end > first + 1).then_some(crate::FaceSource::ExtrudeSide { segment: sg, plane: None, coalesced: true })); // Axis-aligned generated lines have a common oriented support frame
// independent of axial stations or along-line endpoint interpolation.
let generated_plane = if coalesce
&& frame_is_orthonormal
&& let Seg2::Line { a, b } = sg
{
let (origin, x) = if a.y == b.y && a.x != b.x {
(at(Vec2::new(0., a.y), 0.), plane.x * (b.x - a.x).signum())
} else if a.x == b.x && a.y != b.y {
(at(Vec2::new(a.x, 0.), 0.), plane.y * (b.y - a.y).signum())
} else {
(Vec3::ZERO, Vec3::ZERO)
};
(origin.is_finite() && x.len2() > 0.).then_some(Plane { origin, x, y: d })
} else {
None
};
sources.push((generated_plane.is_some() || end > first + 1).then_some(crate::FaceSource::ExtrudeSide {
segment: sg,
plane: generated_plane,
coalesced: end > first + 1,
}));
} }
for (z, up, rs) in &caps { for (z, up, rs) in &caps {
for r in rs { for r in rs {
@@ -885,7 +916,11 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
sources.push(None); sources.push(None);
} }
} }
Some(crate::sew::sew_touching_with_sources(&faces, &sources)) Some(crate::sew::sew_touching_with_sources(&faces, &sources).and_then(
|body| {
if coalesce { body.regularize_generated_planar_faces() } else { Ok(body) }
},
))
} }
fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> { fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
@@ -1109,6 +1144,8 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>>
&slabs, &slabs,
tol, tol,
op == BoolOp::Cut op == BoolOp::Cut
&& !a.regularization_protected
&& !b.regularization_protected
&& a.split_keep().is_empty() && a.split_keep().is_empty()
&& b.split_keep().is_empty() && b.split_keep().is_empty()
&& a.paint().is_none_or(|p| p.faces.is_empty()) && a.paint().is_none_or(|p| p.faces.is_empty())
@@ -1216,6 +1253,62 @@ mod planar_tests {
assert_eq!(crate::step_export(&[&tool], "tool").unwrap(), before_tool); assert_eq!(crate::step_export(&[&tool], "tool").unwrap(), before_tool);
assert_eq!(crate::step_export(&[&roof], "roof").unwrap(), before_roof); assert_eq!(crate::step_export(&[&roof], "roof").unwrap(), before_roof);
} }
fn stepped_tab_stock() -> Body {
let stock = crate::box_solid(Vec3::new(-20., -15., 0.), Vec3::new(20., 15., 24.)).unwrap();
let tab = crate::box_solid(Vec3::new(-12., -25., 8.), Vec3::new(-4., -15., 16.)).unwrap();
crate::boolean(&stock, &tab, BoolOp::Union).unwrap().unwrap()
}
fn coplanar_joins(body: &Body) -> usize {
let faces: Vec<_> = body.solid.face_iter().collect();
let mut adjacency = std::collections::HashMap::<_, Vec<usize>>::new();
for (i, face) in faces.iter().enumerate() {
for edge in face.edge_iter() {
adjacency.entry(edge.id()).or_default().push(i);
}
}
adjacency
.values()
.filter(|faces| {
let [a, b] = faces.as_slice() else { return false };
let (Some(pa), Some(pb)) = (body.planar_face(*a), body.planar_face(*b)) else { return false };
let (na, nb) = (pa.normal(), pb.normal());
na.dot(nb) > 1. - 1e-12 && (pb.origin - pa.origin).dot(na).abs() < 1e-10
})
.count()
}
#[test]
fn shell_of_joined_stepped_tabs_has_no_artificial_planar_stations() {
let stock = stepped_tab_stock();
let snapshot = crate::step_export(&[&stock], "unchanged stock").unwrap();
assert_eq!(coplanar_joins(&stock), 0);
let shell = crate::shell(&stock, &[Vec3::new(0., 0., 24.)], 1.).unwrap();
assert_eq!(coplanar_joins(&shell), 0, "Shell must not add internal edges across continuous planar walls");
assert!(shell.validity().is_empty());
let mesh = shell.tessellate(1e-5).unwrap();
let expected = 40. * 30. * 24. + 8. * 10. * 8. - (38. * 28. * 23. + 6. * 10. * 6.);
assert!((mesh.measure().volume - expected).abs() < 1e-6);
for (point, material) in [
(Vec3::new(0., 0., 0.5), true),
(Vec3::new(0., 0., 10.), false),
(Vec3::new(0., -14.5, 10.), true),
(Vec3::new(19.5, 0., 10.), true),
(Vec3::new(-8., -24.5, 12.), true),
(Vec3::new(-8., -20., 12.), false),
(Vec3::new(0., -18., 9.), false),
] {
assert_eq!(mesh.contains(point), material, "wrong material at {point:?}");
}
let step = crate::step_export(&[&shell], "stepped tab shell").unwrap();
let reopened = crate::step_import(&step).unwrap();
let body = &reopened.bodies[0].body;
assert_eq!(body.face_count(), shell.face_count());
assert_eq!(coplanar_joins(body), 0);
assert!((body.tessellate(1e-5).unwrap().measure().volume - expected).abs() < 1e-6);
assert_eq!(crate::step_export(&[&stock], "unchanged stock").unwrap(), snapshot);
}
#[test] #[test]
fn contiguous_analytic_walls_remove_faces_but_retain_vertical_stations() { fn contiguous_analytic_walls_remove_faces_but_retain_vertical_stations() {
for curved in [false, true] { for curved in [false, true] {
+53
View File
@@ -0,0 +1,53 @@
use super::*;
fn bits(v: Vec3) -> [u64; 3] {
[v.x.to_bits(), v.y.to_bits(), v.z.to_bits()]
}
fn check_body(body: &Body) -> usize {
let meshes = mesh_shells(&body.solid, 0.5);
let mut changed = 0;
for (face, meshed) in body.solid.face_iter().zip(meshes.iter().flat_map(|shell| shell.face_iter())) {
let Some(mesh) = meshed.surface() else { continue };
let surface = face.surface();
let cached = refined_with_cache(&mesh, &surface, 0.002, true);
let original = refined_with_cache(&mesh, &surface, 0.002, false);
match (cached, original) {
(None, None) => {}
(Some(cached), Some(original)) => {
changed += 1;
assert_eq!(cached.len(), original.len(), "triangle count/winding must not change");
for (cached, original) in cached.into_iter().zip(original) {
for ((point, normal), (old_point, old_normal)) in cached.into_iter().zip(original) {
assert_eq!(bits(point), bits(old_point), "every refined coordinate must remain bit-identical");
assert_eq!(bits(normal), bits(old_normal), "every refined normal must remain bit-identical");
}
}
}
_ => panic!("memoization changed the refine/no-refine decision"),
}
}
changed
}
#[test]
fn exact_refinement_cache_preserves_periodic_seams_and_normals() {
let cylinder = crate::cylinder(Vec3::ZERO, Vec3::Z, 15., 8.).unwrap();
let torus = crate::torus(Vec3::ZERO, 18., 4.).unwrap();
assert!(check_body(&cylinder) > 0, "the fixture must actually refine");
assert!(check_body(&torus) > 0, "the periodic fixture must actually refine");
}
#[test]
fn exact_refinement_cache_preserves_singular_poles_and_placed_surfaces() {
let sphere = crate::sphere(Vec3::ZERO, 12.).unwrap();
assert!(check_body(&sphere) > 0, "the polar fixture must actually refine");
let placed = crate::transform(&sphere, Vec3::new(120., -85., 40.), Vec3::ZERO, Vec3::new(1., 2., 3.), 0.371).unwrap();
assert!(check_body(&placed) > 0);
}
#[test]
fn exact_refinement_cache_keeps_unnecessary_planar_refinement_absent() {
let body = crate::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
assert_eq!(check_body(&body), 0);
}
@@ -0,0 +1,65 @@
use super::*;
use solvecraft_geom::Plane;
fn stamped_stock() -> Body {
crate::box_solid(Vec3::new(-20., -15., 0.), Vec3::new(20., 15., 24.)).unwrap().with_box_certificate(7).unwrap()
}
fn cup(stock: &Body) -> Body {
crate::shell(stock, &[Vec3::new(0., 0., 24.)], 1.).unwrap().with_shell_certificate(9).unwrap()
}
#[test]
fn stamped_shell_keeps_authored_painted_and_split_boundaries() {
let stock = stamped_stock();
let ordinary = cup(&stock);
assert!(!ordinary.regularization_protected);
let painted = stock.clone().with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
let side = stock
.solid
.face_iter()
.enumerate()
.find(|(i, _)| stock.planar_face(*i).is_some_and(|p| p.normal().dot(Vec3::X) > 0.999 && p.origin.x == 20.))
.unwrap()
.0;
let split = crate::split_faces(&stock, &[side], &crate::SplitTool::Plane(Plane { origin: Vec3::new(0., 0., 8.), ..Plane::XY })).unwrap();
assert!(split.split_lines().is_some());
for protected in [painted, split] {
let result = cup(&protected);
assert!(result.regularization_protected);
assert!(result.face_count() > ordinary.face_count(), "authored boundaries must survive both Shell regularizers");
assert!(result.validity().is_empty());
let expected = 40. * 30. * 24. - 38. * 28. * 23.;
let mesh = result.tessellate(1e-5).unwrap();
assert!((mesh.measure().volume - expected).abs() < 1e-6);
assert!(mesh.contains(Vec3::new(19.5, 0., 12.)));
assert!(!mesh.contains(Vec3::new(0., 0., 12.)));
let count = result.face_count();
assert_eq!(result.clone().regularize_certified_planar_faces().unwrap().face_count(), count);
assert_eq!(result.clone().regularize_generated_planar_faces().unwrap().face_count(), count);
let placed = crate::transform(&result, Vec3::new(30., -40., 7.), Vec3::ZERO, Vec3::Z, 0.3).unwrap();
assert!(placed.regularization_protected);
for lump in placed.lumps().unwrap() {
assert!(lump.regularization_protected);
}
let cleared = result.clone().with_paint(None).with_split_lines(None);
assert!(cleared.regularization_protected, "cleared appearance must not erase inherited boundary protection");
assert_eq!(cleared.regularize_certified_planar_faces().unwrap().face_count(), count);
}
}
#[test]
fn protection_is_identity_and_survives_tool_operand_and_compounds() {
let a = stamped_stock();
let protected = a.clone().with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [0., 1., 0.], opacity: 1. }] }));
let cleared = protected.with_paint(None);
assert!(!a.same(&cleared));
let tool = crate::box_solid(Vec3::new(-5., -5., 23.), Vec3::new(5., 5., 30.)).unwrap().inherit_regularization_protection(&cleared);
let cut = crate::boolean(&a, &tool, crate::BoolOp::Cut).unwrap().unwrap();
assert!(cut.regularization_protected);
let remote = crate::box_solid(Vec3::new(100., 0., 0.), Vec3::new(110., 10., 10.)).unwrap();
let joined = crate::ops::join_pieces(vec![cleared, remote]).unwrap().unwrap();
assert!(joined.regularization_protected);
assert_eq!(joined.lumps().unwrap().len(), 2);
assert!(joined.lumps().unwrap().iter().all(|body| body.regularization_protected));
}
+1
View File
@@ -886,6 +886,7 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
paint: None, paint: None,
splits: body.splits.clone(), splits: body.splits.clone(),
face_sources: None, face_sources: None,
regularization_protected: true,
}, },
imp, imp,
)) ))
+2
View File
@@ -30,6 +30,7 @@ fn surface_body(faces: Vec<mt::Face>) -> Body {
paint: None, paint: None,
splits: None, splits: None,
face_sources: None, face_sources: None,
regularization_protected: false,
} }
} }
@@ -47,6 +48,7 @@ fn body_of(faces: Vec<mt::Face>) -> Result<Body> {
paint: None, paint: None,
splits: None, splits: None,
face_sources: None, face_sources: None,
regularization_protected: false,
}) })
} }
} }
+18 -1
View File
@@ -102,6 +102,8 @@ fn start(app: &mut SolveApp) {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, cancelled: &AtomicBool) -> Job { fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, cancelled: &AtomicBool) -> Job {
let profile = std::env::var_os("SOLVECRAFT_PROFILE_OPEN").is_some();
let began = std::time::Instant::now();
if cancelled.load(Ordering::Relaxed) { if cancelled.load(Ordering::Relaxed) {
return Err("file operation cancelled".into()); return Err("file operation cancelled".into());
} }
@@ -113,16 +115,31 @@ fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, can
"file.insert_step" "file.insert_step"
}; };
let result = session.execute(command, &json!({"path": path})).map_err(|e| e.to_string())?; let result = session.execute(command, &json!({"path": path})).map_err(|e| e.to_string())?;
let geometry_ms = began.elapsed().as_secs_f64() * 1000.0;
let view_began = std::time::Instant::now();
phase.store(1, Ordering::Relaxed); phase.store(1, Ordering::Relaxed);
let world = session.world_state();
let placement_ms = view_began.elapsed().as_secs_f64() * 1000.0;
let mesh_began = std::time::Instant::now();
// Fit, picking and the first viewport frame must not trigger cold tessellation on the UI. // Fit, picking and the first viewport frame must not trigger cold tessellation on the UI.
for b in &session.world_state().bodies { for b in &world.bodies {
if cancelled.load(Ordering::Relaxed) { if cancelled.load(Ordering::Relaxed) {
return Err("file operation cancelled".into()); return Err("file operation cancelled".into());
} }
let _ = b.mesh(); let _ = b.mesh();
} }
let tessellation_ms = mesh_began.elapsed().as_secs_f64() * 1000.0;
phase.store(2, Ordering::Relaxed); phase.store(2, Ordering::Relaxed);
let view_ms = view_began.elapsed().as_secs_f64() * 1000.0;
let thumbnail_began = std::time::Instant::now();
let image = if insert { None } else { crate::home::remember(&session) }; let image = if insert { None } else { crate::home::remember(&session) };
if profile {
eprintln!(
"SolidCraft open phases: geometry_ms={geometry_ms:.3} placement_ms={placement_ms:.3} tessellation_ms={tessellation_ms:.3} display_warmup_ms={view_ms:.3} thumbnail_ms={:.3} total_ms={:.3}",
thumbnail_began.elapsed().as_secs_f64() * 1000.0,
began.elapsed().as_secs_f64() * 1000.0
);
}
Ok(Made { session, result, image }) Ok(Made { session, result, image })
} }
+28
View File
@@ -58,6 +58,8 @@ pub struct Prefs {
pub ui_scale: f32, pub ui_scale: f32,
/// The wheel zooms toward the point under the cursor (else toward the view's centre). /// The wheel zooms toward the point under the cursor (else toward the view's centre).
pub zoom_to_cursor: bool, pub zoom_to_cursor: bool,
/// Spread discrete wheel input across frames (off: apply each event immediately).
pub smooth_wheel_zoom: bool,
/// View changes (axis view, Look At, Home) animate; `look_at_speed` scales how fast. /// View changes (axis view, Look At, Home) animate; `look_at_speed` scales how fast.
pub look_at_anim: bool, pub look_at_anim: bool,
pub look_at_speed: f32, pub look_at_speed: f32,
@@ -84,6 +86,7 @@ impl Default for Prefs {
language: "en".into(), language: "en".into(),
ui_scale: 1.0, ui_scale: 1.0,
zoom_to_cursor: true, zoom_to_cursor: true,
smooth_wheel_zoom: true,
look_at_anim: true, look_at_anim: true,
look_at_speed: 1.0, look_at_speed: 1.0,
up_axis: "z".into(), up_axis: "z".into(),
@@ -184,6 +187,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
p.radial_context_menu = d.radial_context_menu; p.radial_context_menu = d.radial_context_menu;
p.zoom_reverse = d.zoom_reverse; p.zoom_reverse = d.zoom_reverse;
p.zoom_to_cursor = d.zoom_to_cursor; p.zoom_to_cursor = d.zoom_to_cursor;
p.smooth_wheel_zoom = d.smooth_wheel_zoom;
p.orbit_cursor = d.orbit_cursor; p.orbit_cursor = d.orbit_cursor;
p.space_mouse_enabled = d.space_mouse_enabled; p.space_mouse_enabled = d.space_mouse_enabled;
p.space_mouse_speed = d.space_mouse_speed; p.space_mouse_speed = d.space_mouse_speed;
@@ -577,6 +581,10 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
ui.checkbox(&mut app.preferences.zoom_to_cursor, "") ui.checkbox(&mut app.preferences.zoom_to_cursor, "")
.on_hover_text("The wheel zooms toward the point under the cursor (off: toward the view's centre)"); .on_hover_text("The wheel zooms toward the point under the cursor (off: toward the view's centre)");
ui.end_row(); ui.end_row();
ui.label("Smooth wheel zoom");
ui.checkbox(&mut app.preferences.smooth_wheel_zoom, "")
.on_hover_text("Turn off to apply each wheel event immediately, without a smoothing tail");
ui.end_row();
ui.label("Orbit around"); ui.label("Orbit around");
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre"); ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre");
@@ -782,6 +790,26 @@ mod tests {
assert!(h.app.preferences.radial_context_menu, "Cancel restores the last Apply"); assert!(h.app.preferences.radial_context_menu, "Cancel restores the last Apply");
} }
#[test]
fn wheel_smoothing_persists_and_follows_dialog_buttons() {
assert!(serde_json::from_value::<Prefs>(json!({})).unwrap().smooth_wheel_zoom);
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
control(&mut app, &json!({"open":"Navigation", "smooth_wheel_zoom":false})).unwrap();
control(&mut app, &json!({"button":"cancel"})).unwrap();
assert!(app.preferences.smooth_wheel_zoom);
control(&mut app, &json!({"open":"Navigation", "smooth_wheel_zoom":false})).unwrap();
control(&mut app, &json!({"button":"apply"})).unwrap();
control(&mut app, &json!({"smooth_wheel_zoom":true, "button":"cancel"})).unwrap();
assert!(!app.preferences.smooth_wheel_zoom);
let mut other = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
other.load_prefs(&app.prefs());
assert!(!other.preferences.smooth_wheel_zoom);
control(&mut app, &json!({"open":"Navigation", "button":"defaults"})).unwrap();
assert!(app.preferences.smooth_wheel_zoom);
control(&mut app, &json!({"button":"cancel"})).unwrap();
assert!(!app.preferences.smooth_wheel_zoom);
}
#[test] #[test]
fn presets_map_the_mouse_and_prefs_round_trip() { fn presets_map_the_mouse_and_prefs_round_trip() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default()); let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
+175 -61
View File
@@ -62,6 +62,8 @@ pub struct ViewportState {
pub orbit_pivot: Option<Vec3>, pub orbit_pivot: Option<Vec3>,
/// Continuous orbit: the view turns about the vertical until the next click or key. /// Continuous orbit: the view turns about the vertical until the next click or key.
pub spin: bool, pub spin: bool,
/// After immediate zoom, ignore egui's outstanding smoothing until the gesture ends.
wheel_zoom_raw_until_idle: bool,
/// A primary press on the axis gizmo: the pointer position used by its last orbit step. /// A primary press on the axis gizmo: the pointer position used by its last orbit step.
pub axis_drag: Option<Pos2>, pub axis_drag: Option<Pos2>,
/// Unsnapped camera while Shift-orbit previews a standard or diagonal view. /// Unsnapped camera while Shift-orbit previews a standard or diagonal view.
@@ -1471,6 +1473,34 @@ pub fn delete_selection(app: &mut SolveApp) {
crate::delete::delete_selection(app); crate::delete::delete_selection(app);
} }
/// Match egui's wheel units and modifier routing, without its temporal smoothing.
fn immediate_wheel_delta(input: &egui::InputState, options: egui::InputOptions, page_height: f32) -> f32 {
input
.events
.iter()
.filter_map(|event| {
let egui::Event::MouseWheel { unit, delta, modifiers, phase: egui::TouchPhase::Move } = event else { return None };
if modifiers.matches_any(options.zoom_modifier) {
return None;
}
let delta = match unit {
egui::MouseWheelUnit::Point => *delta,
egui::MouseWheelUnit::Line => *delta * options.line_scroll_speed,
egui::MouseWheelUnit::Page => *delta * page_height,
};
let horizontal = modifiers.matches_any(options.horizontal_scroll_modifier);
let vertical = modifiers.matches_any(options.vertical_scroll_modifier);
Some(if horizontal && !vertical {
0.0
} else if vertical && !horizontal {
delta.x + delta.y
} else {
delta.y
})
})
.sum()
}
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get(); let t = Tokens::get();
let rect = ui.max_rect(); let rect = ui.max_rect();
@@ -1499,6 +1529,18 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command); crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command);
// ---- navigation ---- // ---- navigation ----
if !app.preferences.smooth_wheel_zoom {
app.viewport.wheel_zoom_raw_until_idle = true;
} else if !ui.input(|i| i.is_scrolling()) {
app.viewport.wheel_zoom_raw_until_idle = false;
}
let scroll = if app.viewport.wheel_zoom_raw_until_idle {
let options = ui.ctx().options(|o| o.input_options);
let page_height = ui.ctx().viewport_rect().height();
ui.input(|i| immediate_wheel_delta(i, options, page_height))
} else {
scroll
};
let h = rect.height().max(1.0) as f64; let h = rect.height().max(1.0) as f64;
if inside && scroll != 0.0 { if inside && scroll != 0.0 {
app.viewport.orbit_snap = None; app.viewport.orbit_snap = None;
@@ -3113,72 +3155,144 @@ mod tests {
} }
} }
#[test]
fn immediate_wheel_zoom_applies_normalized_input_once() {
let mut h = crate::scenario::Harness::new();
h.app.preferences.smooth_wheel_zoom = false;
h.app.preferences.zoom_to_cursor = false;
h.call("ui.move", json!({"x":800,"y":500}));
let before = h.app.cam;
let line = h.ctx.options(|o| o.input_options.line_scroll_speed);
for (unit, amount, points) in
[(egui::MouseWheelUnit::Point, 120.0, 120.0), (egui::MouseWheelUnit::Line, 1.0, line), (egui::MouseWheelUnit::Page, 0.1, 100.0)]
{
let height = h.app.cam.half_height();
h.app.synthetic.push(egui::Event::MouseWheel {
unit,
delta: vec2(0.0, amount),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
h.frame();
let expected = height * (-f64::from(points) * 0.0015).exp();
assert!((h.app.cam.half_height() - expected).abs() < 1e-6);
let after = h.app.cam;
h.frames(30);
assert_eq!(h.app.cam, after, "idle frames must not replay wheel input");
}
assert_eq!(h.app.cam.target, before.target);
h.app.preferences.zoom_reverse = true;
let height = h.app.cam.half_height();
h.call("ui.scroll", json!({"x":800,"y":500,"delta":120}));
assert!((h.app.cam.half_height() - height * 0.18_f64.exp()).abs() < 1e-6);
}
#[test]
fn wheel_zoom_setting_changes_do_not_replay_smoothing() {
let mut h = crate::scenario::Harness::new();
h.app.preferences.zoom_to_cursor = false;
h.call("ui.move", json!({"x":800,"y":500}));
h.app.synthetic.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 120.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
h.frame();
assert!(h.ctx.input(|i| i.is_scrolling()));
h.app.preferences.smooth_wheel_zoom = false;
let stopped = h.app.cam;
h.frame();
assert_eq!(h.app.cam, stopped);
h.app.preferences.smooth_wheel_zoom = true;
h.frames(90);
assert_eq!(h.app.cam, stopped, "re-enabling must not replay the old smoothing tail");
assert!(!h.app.viewport.wheel_zoom_raw_until_idle);
h.app.preferences.smooth_wheel_zoom = false;
for _ in 0..3 {
h.app.synthetic.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 120.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
h.frame();
}
assert!((h.app.cam.half_height() - stopped.half_height() * (-0.54_f64).exp()).abs() < 1e-6);
let after = h.app.cam;
h.frames(90);
assert_eq!(h.app.cam, after);
}
#[test] #[test]
fn shortcuts_window_wheel_does_not_zoom_the_viewport() { fn shortcuts_window_wheel_does_not_zoom_the_viewport() {
let ctx = egui::Context::default(); for smooth in [true, false] {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default()); let ctx = egui::Context::default();
app.viewport.no_pixels = true; let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
crate::help::run(&mut app, &ctx, "shortcuts"); app.preferences.smooth_wheel_zoom = smooth;
let mut time = 0.0; app.viewport.no_pixels = true;
let mut frame = |app: &mut SolveApp, pointer: Option<Pos2>, wheel: f32| { crate::help::run(&mut app, &ctx, "shortcuts");
time += 1.0 / 60.0; let mut time = 0.0;
let mut events = Vec::new(); let mut frame = |app: &mut SolveApp, pointer: Option<Pos2>, wheel: f32| {
if let Some(p) = pointer { time += 1.0 / 60.0;
events.push(egui::Event::PointerMoved(p)); let mut events = Vec::new();
if let Some(p) = pointer {
events.push(egui::Event::PointerMoved(p));
}
if wheel != 0.0 {
events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, wheel),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
}
let mut output = ctx.run_ui(
egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))),
time: Some(time),
events,
..Default::default()
},
|ui| {
egui::CentralPanel::default().show(ui, |ui| show(app, ui));
crate::prefs::show(app, ui.ctx());
},
);
output.textures_delta.clear();
};
// Let egui establish the floating window's size and hit-test widgets.
for _ in 0..4 {
frame(&mut app, None, 0.0);
} }
if wheel != 0.0 { let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
events.push(egui::Event::MouseWheel { let list = window.center() + vec2(140.0, 0.0);
unit: egui::MouseWheelUnit::Point, let height = app.cam.half_height();
delta: vec2(0.0, wheel), let target = app.cam.target;
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
}
let mut output = ctx.run_ui(
egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))),
time: Some(time),
events,
..Default::default()
},
|ui| {
egui::CentralPanel::default().show(ui, |ui| show(app, ui));
crate::prefs::show(app, ui.ctx());
},
);
output.textures_delta.clear();
};
// Let egui establish the floating window's size and hit-test widgets.
for _ in 0..4 {
frame(&mut app, None, 0.0);
}
let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
let list = window.center() + vec2(140.0, 0.0);
let height = app.cam.half_height();
let target = app.cam.target;
frame(&mut app, Some(list), 0.0);
for n in 0..12 {
frame(&mut app, Some(list), if n == 0 { -120.0 } else { 0.0 });
assert_eq!(app.cam.half_height(), height, "wheel input over shortcuts must stay in the popup");
assert_eq!(app.cam.target, target);
}
// The window's non-scrolling sidebar must also shield the model.
frame(&mut app, Some(window.left_center() + vec2(30.0, 0.0)), -120.0);
assert_eq!(app.cam.half_height(), height);
// An exposed part of the viewport still accepts wheel navigation with the popup open.
let exposed = pos2(40.0, 700.0);
assert!(!window.contains(exposed));
frame(&mut app, Some(exposed), 0.0);
frame(&mut app, Some(exposed), -120.0);
assert_ne!(app.cam.half_height(), height);
// Closing the popup returns its former area to the viewport.
app.prefs_window.open = false;
for _ in 0..12 {
frame(&mut app, Some(list), 0.0); frame(&mut app, Some(list), 0.0);
for n in 0..12 {
frame(&mut app, Some(list), if n == 0 { -120.0 } else { 0.0 });
assert_eq!(app.cam.half_height(), height, "wheel input over shortcuts must stay in the popup");
assert_eq!(app.cam.target, target);
}
// The window's non-scrolling sidebar must also shield the model.
frame(&mut app, Some(window.left_center() + vec2(30.0, 0.0)), -120.0);
assert_eq!(app.cam.half_height(), height);
// An exposed part of the viewport still accepts wheel navigation with the popup open.
let exposed = pos2(40.0, 700.0);
assert!(!window.contains(exposed));
frame(&mut app, Some(exposed), 0.0);
frame(&mut app, Some(exposed), -120.0);
assert_ne!(app.cam.half_height(), height);
// Closing the popup returns its former area to the viewport.
app.prefs_window.open = false;
for _ in 0..12 {
frame(&mut app, Some(list), 0.0);
}
let height = app.cam.half_height();
frame(&mut app, Some(list), -120.0);
assert_ne!(app.cam.half_height(), height);
} }
let height = app.cam.half_height();
frame(&mut app, Some(list), -120.0);
assert_ne!(app.cam.half_height(), height);
} }
#[test] #[test]
+5
View File
@@ -39,5 +39,10 @@ Preferences (File › Preferences) hold the theme, the units of new designs, the
visual style and autosave. Help › Keyboard Shortcuts lists every key, and File › Keyboard visual style and autosave. Help › Keyboard Shortcuts lists every key, and File › Keyboard
Shortcuts changes them. Shortcuts changes them.
Navigation › **Smooth wheel zoom** is on by default. Turn it off to apply each wheel or
trackpad event immediately, with no smoothing tail. It is independent of **Animate view
changes**, which controls axis views, Look At, Home and Fit. Re-enabling smoothing during
a wheel gesture takes effect after that gesture ends, avoiding leftover zoom being replayed.
Shift-orbit snapping includes 45-degree edge views and isometric corner views, with a Shift-orbit snapping includes 45-degree edge views and isometric corner views, with a
lightly damped spring transition and a small boundary margin to prevent flicker. lightly damped spring transition and a small boundary margin to prevent flicker.
+1 -1
View File
@@ -70,7 +70,7 @@ rolls the numbering back with the design), so they can go straight into later co
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) | | `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text | | `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`), `zoom_reverse`, `zoom_to_cursor`, `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | | `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it | | `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
+55
View File
@@ -1,5 +1,60 @@
# Design loading # Design loading
## Cold geometry rebuilds after 0.3.1
Three alternating cold-process comparisons on the same Windows machine and optimized
`ci` profile, with no concurrent build, compare the unchanged 0.3.1 source with the
straight-line projection and exact surface-refinement reuse changes:
| Saved design | Before, median evaluation | After | Reduction |
|---|---:|---:|---:|
| Public sensor enclosure, 21 features, 52 KB | 11.96 s | 8.85 s | 26.0% |
| Local Pi case assembly, 25 features, 2,990 bodies | 14.75 s | 11.57 s | 21.6% |
These are geometry rebuild times, not complete desktop opening times. The public fixture
can be generated with `solvecraft-cli run examples/acceptance/sensor_enclosure.json
--save sensor.solvecraft --quiet`, then measured with `solvecraft-cli bench-open
sensor.solvecraft`. Each benchmark starts a new process; this does not measure a warm
timeline-cache hit. The local assembly is private and is not part of the repository.
Straight segments now use guarded algebraic closest-point projection instead of sampling
65 points and running Newton iterations. Other curve types retain their existing behavior.
Curved-face refinement reuses derivative and midpoint evaluations with exact finite input
keys and bounded, local caches. It keeps the original tolerance, seam and singularity
checks, refinement passes, and triangle limits.
Identity assembly placements retain the definition body's shared display-mesh cache.
Changed placements continue to build separate meshes in their own coordinates. Face-name
preparation remains eager during placement: deferring it merely shifted expensive work to
thumbnails and the first face query, so that approach was excluded.
Both saved fixtures retain exactly the same body names, face/edge/vertex/shell counts,
bounds, volumes, and surface areas. Focused tests compare cached and uncached refinement
coordinates and normals bit for bit on periodic, singular and transformed surfaces.
For desktop phase profiling, set `SOLVECRAFT_PROFILE_OPEN=1` before launching the app.
Interactive Open reports geometry, placement, tessellation, thumbnail and total worker
times to stderr without logging the source path. Thumbnails were about 0.1 seconds of
the slow assembly's open and were not the main bottleneck. Further cold Boolean and
import costs remain; these changes do not make all documents open instantly.
### Prepared assembly geometry and selection
Prepared world geometry is now keyed by the document, evaluated model and exact placement
context instead of the session revision. Selecting a face or edge, changing visibility,
or moving the view no longer discards already prepared assembly bodies and meshes.
Geometry edits, pending occurrence moves, authoring context changes, undo/redo and timeline
rollback still invalidate the cache. Scratch sessions share prepared geometry while their
inputs match, and adopting a computed session retains its warm cache.
On the private 2,990-body Pi assembly, a matched Linux desktop control probe reduced an
edge-selection round trip from 9.24 s to 0.57 s. Initial opening stayed essentially unchanged
(33.54 s to 33.32 s); this fixes a separate selection stall, not cold geometry evaluation.
Three fresh Windows desktop runs with the corrected cache measured a median 15.87 s to
ready: 13.04 s geometry, 2.20 s placement, 0.53 s meshing and 0.07 s thumbnails. These used
the optimized `ci` profile and actual Vulkan rendering, with isolated settings and caches.
Linux and Windows timings are separate measurements and must not be compared as a speedup.
`solvecraft-cli bench-open design.solvecraft` reports JSON lines for file reading, `solvecraft-cli bench-open design.solvecraft` reports JSON lines for file reading,
JSON decoding and migrations (including input-name repair), timeline evaluation, individual JSON decoding and migrations (including input-name repair), timeline evaluation, individual
features, and world placement/display meshing. `--parse-only` stops before geometry evaluation. features, and world placement/display meshing. `--parse-only` stops before geometry evaluation.