From 6de8f6db9515e4ab01cf6d2bca72bc4f1aaea68b Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 14 Oct 2026 13:48:39 -0400 Subject: [PATCH] Agent-built designs: stop redoing whole-design work on every command (#1148) A design built over MCP is many small commands, and each one did work over the whole design, so building or showing a 1,300-body design took minutes: - moved components were transformed and meshed again after every change; world_state now reuses each placed body whose definition and placement are unchanged - next_d_name searched the design once per candidate number - Document::eval evaluated every parameter for every expression; now only when the expression names one - param_rows' used_by was quadratic; the desktop no longer gathers completions on every change while no text box has focus solidcraft-cli bench-agent builds a generated ~1,300-body bicycle command by command and times it (241 s -> 16 s here). Eval output is unchanged apart from timings. Co-authored-by: Claude Opus 5.5 --- apps/solidcraft-cli/src/bench_agent.rs | 239 ++++++++++++++++++ apps/solidcraft-cli/src/main.rs | 7 +- crates/doc/src/appearance.rs | 6 +- crates/doc/src/document.rs | 10 +- crates/doc/src/eval.rs | 58 ++++- crates/doc/src/lib.rs | 5 +- crates/doc/src/params.rs | 16 +- crates/engine/src/lib.rs | 29 ++- .../engine/src/world_placement_cache_tests.rs | 38 +++ crates/ui-egui/src/params_dialog.rs | 7 +- docs/perf.md | 45 ++++ 11 files changed, 439 insertions(+), 21 deletions(-) create mode 100644 apps/solidcraft-cli/src/bench_agent.rs diff --git a/apps/solidcraft-cli/src/bench_agent.rs b/apps/solidcraft-cli/src/bench_agent.rs new file mode 100644 index 00000000..a5cb4bc1 --- /dev/null +++ b/apps/solidcraft-cli/src/bench_agent.rs @@ -0,0 +1,239 @@ +//! `bench-agent`: a large design built the way an agent builds one over MCP, one small command +//! at a time, timed as it grows and then opened again. +//! +//! The design is a bicycle of about 1,300 bodies: frame tubes joined at root and then moved into +//! a component, two wheels whose spokes and nipples are placed one by one, a chainring with +//! patterned teeth, a cassette, a chain of individual links, cockpit, seat and a few hundred +//! fasteners. Nothing is read from a file; the script is generated here, so the benchmark needs +//! no fixture. +//! +//! ```text +//! solidcraft-cli bench-agent [--scale F] [--script-out script.json] [--save design.solidcraft] +//! ``` +//! prints one JSON line per hundred commands (mean and max milliseconds, bodies so far), then the +//! build total, then the phases `bench-open` prints for the saved design. + +use serde_json::{Value, json}; +use solidcraft_engine::Session; + +struct Script { + commands: Vec, +} + +impl Script { + fn c(&mut self, command: &str, params: Value) { + self.commands.push(json!({"command": command, "params": params})); + } + + /// A round tube from `a` to `b` (an agent's frame tube, spoke or post). + fn tube(&mut self, a: [f64; 3], b: [f64; 3], radius: f64, body: &str) { + let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); + let axis = [d[0] / len, d[1] / len, d[2] / len]; + self.c( + "solid.cylinder", + json!({"radius": radius, "height": round3(len), "base": a.map(round3), "axis": axis.map(round6), "body_name": body}), + ); + } + + fn component(&mut self, name: &str) { + self.c("component.create", json!({"name": name})); + } + + fn root(&mut self) { + self.c("component.activate", json!({"component": "root"})); + } + + /// A wheel built at the origin in its own component, then placed where it goes (the + /// component's occurrence moved), as an agent builds one part and positions it. + fn wheel(&mut self, label: &str, at: f64, spokes: usize) { + let cx = 0.0; + self.component(&format!("{label} wheel")); + self.c("solid.torus", json!({"major": 311, "minor": 12, "center": [cx, 0, 340], "body_name": format!("{label} rim")})); + self.c("solid.torus", json!({"major": 330, "minor": 18, "center": [cx, 0, 340], "body_name": format!("{label} tyre")})); + self.c("solid.cylinder", json!({"radius": 18, "height": 110, "base": [cx, -55, 340], "axis": "Y", "body_name": format!("{label} hub")})); + for k in 0..spokes { + let a = std::f64::consts::TAU * k as f64 / spokes as f64; + let side = if k % 2 == 1 { 1.0 } else { -1.0 }; + let hub = [cx + 30.0 * a.cos(), side * 40.0, 340.0 + 30.0 * a.sin()]; + let (ca, sa) = ((a + 0.12).cos(), (a + 0.12).sin()); + let rim = [cx + 300.0 * ca, side * 4.0, 340.0 + 300.0 * sa]; + self.tube(hub, rim, 1.0, &format!("{label} spoke {k}")); + let nipple = json!({"radius": 2, "height": 12, "base": rim.map(round3), "axis": [round6(ca), 0, round6(sa)], "body_name": format!("{label} nipple {k}")}); + self.c("solid.cylinder", nipple); + } + self.root(); + self.c("occurrence.move", json!({"occurrence": format!("{label} wheel:1"), "translate": [at, 0, 0]})); + } +} + +fn round3(v: f64) -> f64 { + (v * 1e3).round() / 1e3 +} + +fn round6(v: f64) -> f64 { + (v * 1e6).round() / 1e6 +} + +/// The bicycle's command script. `scale` multiplies the counts of repeated parts (spokes, +/// chain links, fasteners); 1.0 gives about 1,300 bodies. +pub fn bicycle(scale: f64) -> Value { + let n = |k: f64| ((k * scale).round() as usize).max(1); + let mut s = Script { commands: Vec::new() }; + // Frame: tubes at root, the main triangle joined, then everything moved into its component. + let (bb, head_top, head_bot, seat_top) = ([0.0, 0.0, 300.0], [600.0, 0.0, 860.0], [580.0, 0.0, 740.0], [-150.0, 0.0, 820.0]); + s.tube(bb, head_bot, 19.0, "Down tube"); + s.tube(seat_top, head_top, 16.0, "Top tube"); + s.tube(bb, seat_top, 16.0, "Seat tube"); + s.tube(head_bot, head_top, 22.0, "Head tube"); + for (side, y) in [("L", 60.0), ("R", -60.0)] { + s.tube([0.0, y * 0.3, 300.0], [-420.0, y, 340.0], 10.0, &format!("Chainstay {side}")); + s.tube([-140.0, y * 0.3, 800.0], [-420.0, y, 340.0], 9.0, &format!("Seatstay {side}")); + s.c("solid.box", json!({"length": 20, "width": 6, "height": 40, "corner": [-430.0, y - 3.0, 320], "body_name": format!("Dropout {side}")})); + } + s.c("solid.cylinder", json!({"radius": 24, "height": 80, "base": [0, -40, 300], "axis": "Y", "body_name": "BB shell"})); + s.c("solid.combine", json!({"target": "Down tube", "tools": ["Top tube", "Seat tube", "Head tube", "BB shell"]})); + s.component("Frame"); + s.root(); + let frame = ["Down tube", "Chainstay L", "Seatstay L", "Dropout L", "Chainstay R", "Seatstay R", "Dropout R"]; + s.c("component.move_bodies", json!({"bodies": frame, "component": "Frame"})); + // Wheels: every spoke and nipple a body of its own. + s.wheel("Front", 640.0, n(36.0)); + s.wheel("Rear", -420.0, n(36.0)); + // Drivetrain: patterned chainring teeth, a cassette and a chain of separate links. + s.component("Drivetrain"); + s.c("solid.cylinder", json!({"radius": 95, "height": 4, "base": [0, 50, 300], "axis": "Y", "body_name": "Chainring"})); + s.c("solid.box", json!({"length": 6, "width": 4, "height": 8, "corner": [-3, 50, 395], "name": "Tooth", "body_name": "Tooth"})); + s.c("solid.pattern.circular", json!({"bodies": ["Tooth"], "axis": {"origin": [0, 50, 300], "dir": [0, 1, 0]}, "count": 44})); + for g in 0..11 { + let r = 22.0 + 3.5 * g as f64; + s.c( + "solid.cylinder", + json!({"radius": r, "height": 2, "base": [-420.0, round3(30.0 + 2.6 * g as f64), 340.0], "axis": "Y", "body_name": format!("Cog {g}")}), + ); + } + let links = n(112.0); + for k in 0..links { + let a = std::f64::consts::TAU * k as f64 / links as f64; + let (x, z) = (round3(-210.0 + 230.0 * a.cos()), round3(320.0 + 80.0 * a.sin())); + for (side, y) in [("in", 46), ("out", 56)] { + s.c("solid.box", json!({"length": 12, "width": 1, "height": 8, "corner": [round3(x - 6.0), y, round3(z - 4.0)], "body_name": format!("Link {k} {side}")})); + } + s.c("solid.cylinder", json!({"radius": 3.5, "height": 10, "base": [x, 46, z], "axis": "Y", "body_name": format!("Roller {k}")})); + } + for (side, y) in [("L", -90.0), ("R", 90.0)] { + let x0 = if side == "L" { -15.0 } else { 0.0 }; + s.c("solid.box", json!({"length": 170, "width": 12, "height": 30, "corner": [x0, y - 6.0, 285], "body_name": format!("Crank {side}")})); + let py = if y > 0.0 { y + 8.0 } else { y - 68.0 }; + s.c("solid.box", json!({"length": 90, "width": 60, "height": 15, "corner": [150.0, py, 280], "body_name": format!("Pedal {side}")})); + } + s.root(); + // Cockpit and seat. + s.component("Cockpit"); + s.tube([600.0, 0.0, 860.0], [620.0, 0.0, 940.0], 14.0, "Steerer"); + s.tube([620.0, 0.0, 940.0], [700.0, 0.0, 950.0], 16.0, "Stem"); + s.tube([700.0, -380.0, 950.0], [700.0, 380.0, 950.0], 11.0, "Handlebar"); + s.c("solid.cylinder", json!({"radius": 16, "height": 120, "base": [700, 260, 950], "axis": "Y", "body_name": "Grip R"})); + s.c("solid.mirror", json!({"bodies": ["Grip R"], "plane": "XZ"})); + s.root(); + s.component("Seat"); + s.tube([-150.0, 0.0, 820.0], [-170.0, 0.0, 960.0], 13.0, "Seatpost"); + s.c("solid.box", json!({"length": 260, "width": 140, "height": 40, "center": [-180, 0, 980], "body_name": "Saddle"})); + s.root(); + // Fasteners: head, shank and washer, each its own body. + s.component("Hardware"); + let bolts = n(240.0); + for k in 0..bolts { + let a = std::f64::consts::TAU * k as f64 / bolts as f64; + let p = [round3(-300.0 + 2.5 * k as f64), round3(120.0 + 10.0 * a.sin()), round3(400.0 + 10.0 * a.cos())]; + s.c("solid.cylinder", json!({"radius": 5, "height": 4, "base": p, "axis": "Z", "body_name": format!("Bolt {k} head")})); + s.c( + "solid.cylinder", + json!({"radius": 2.5, "height": 16, "base": [p[0], p[1], round3(p[2] - 16.0)], "axis": "Z", "body_name": format!("Bolt {k} shank")}), + ); + s.c( + "solid.cylinder", + json!({"radius": 4.5, "height": 1, "base": [p[0], p[1], round3(p[2] - 1.0)], "axis": "Z", "body_name": format!("Bolt {k} washer")}), + ); + } + s.root(); + json!({"commands": s.commands}) +} + +pub fn run(args: &[String]) -> Result<(), String> { + let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)); + let scale = match flag("--scale") { + Some(v) => v.parse::().ok().filter(|v| v.is_finite() && *v > 0.0 && *v <= 20.0).ok_or("--scale takes a number from 0 to 20")?, + None => 1.0, + }; + let script = bicycle(scale); + if let Some(path) = flag("--script-out") { + let text = serde_json::to_string_pretty(&script).map_err(|e| e.to_string())?; + return solidcraft_engine::io::vfs::write(path, text.as_bytes()).map_err(|e| format!("{path}: {e}")); + } + let commands = script["commands"].as_array().cloned().unwrap_or_default(); + let mut s = Session::default(); + let began = std::time::Instant::now(); + // Per command: the command itself, then what a window needs to draw its result (the + // bodies placed in the world and their display meshes). + let (mut run, mut shown): (Vec, Vec) = (Vec::new(), Vec::new()); + for (i, c) in commands.iter().enumerate() { + let id = c["command"].as_str().unwrap_or_default(); + let t = std::time::Instant::now(); + s.execute(id, &c["params"]).map_err(|e| format!("command {i} ({id}): {e}"))?; + run.push(t.elapsed().as_secs_f64() * 1000.0); + let t = std::time::Instant::now(); + s.world_state().warm_meshes(|| true); + shown.push(t.elapsed().as_secs_f64() * 1000.0); + if run.len() == 100 || i + 1 == commands.len() { + let mean = |v: &[f64]| v.iter().sum::() / v.len().max(1) as f64; + let bodies = s.model.state().bodies.len(); + println!( + "{}", + json!({"step": "build", "commands": i + 1, "bodies": bodies, "command_ms": mean(&run), "display_ms": mean(&shown), + "max_ms": run.iter().zip(&shown).map(|(a, b)| a + b).fold(0.0, f64::max)}) + ); + run.clear(); + shown.clear(); + } + } + let world = s.world_state(); + println!( + "{}", + json!({"step": "built", "ms": began.elapsed().as_secs_f64() * 1000.0, "commands": commands.len(), "features": s.doc.features.len(), "bodies": world.bodies.len()}) + ); + let path = match flag("--save") { + Some(p) => p.clone(), + None => std::env::temp_dir().join(format!("bench-agent-{}.solidcraft", std::process::id())).to_string_lossy().into_owned(), + }; + s.execute("file.save", &json!({"path": path})).map_err(|e| e.to_string())?; + let r = crate::cmd_bench_open(std::slice::from_ref(&path)); + if flag("--save").is_none() { + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(format!("{path}.bak")); + } + r +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The generated commands stay valid as the command set changes: a small bicycle builds + /// without a failing command or feature. + #[test] + fn a_small_bicycle_builds() { + let script = bicycle(0.03); + let mut s = Session::default(); + for c in script["commands"].as_array().into_iter().flatten() { + let id = c["command"].as_str().unwrap_or_default(); + if let Err(e) = s.execute(id, &c["params"]) { + panic!("{id}: {e}"); + } + } + assert!(s.model.results.iter().all(|r| r.error.is_none())); + let world = s.world_state(); + assert!(world.bodies.len() > 40, "{} bodies", world.bodies.len()); + assert!(world.bodies.iter().any(|b| b.name.contains("spoke"))); + } +} diff --git a/apps/solidcraft-cli/src/main.rs b/apps/solidcraft-cli/src/main.rs index 75b6ffdc..91616255 100644 --- a/apps/solidcraft-cli/src/main.rs +++ b/apps/solidcraft-cli/src/main.rs @@ -5,6 +5,7 @@ //! solidcraft-cli eval (alias: inspect) measurements as JSON //! solidcraft-cli script [--out script.json] the design's history as a command script //! solidcraft-cli bench-open [--parse-only] loading timings as JSON lines +//! solidcraft-cli bench-agent [--scale F] [--save F] [--script-out F] a ~1,300-body design built command by command, timed //! solidcraft-cli snapshot --out shot.png [--width W] [--height H] [--view iso|front|top|…] //! solidcraft-cli exec [json-params] run one command on an empty design //! solidcraft-cli commands the command registry as JSON @@ -18,6 +19,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod bench_agent; mod crashes; mod fuzz; mod oracle; @@ -41,6 +43,7 @@ const USAGE: &str = "usage: solidcraft-cli script [--out script.json] (the design's history as a command script that eval and run replay) solidcraft-cli bench-open [--parse-only] + solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json] solidcraft-cli snapshot --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right] solidcraft-cli exec [json-params] solidcraft-cli commands @@ -95,6 +98,8 @@ fn main() -> ExitCode { // Hidden: time recomputes after parameter edits (docs/perf.md). Some("bench-edit") => cmd_bench_edit(&args[1..]), Some("bench-open") => cmd_bench_open(&args[1..]), + // A ~1,300-body design built command by command, as an agent builds one over MCP. + Some("bench-agent") => bench_agent::run(&args[1..]), Some("--version" | "-V") => { println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION")); Ok(()) @@ -422,7 +427,7 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> { } /// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing. -fn cmd_bench_open(args: &[String]) -> Result<(), String> { +pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> { let path = args.first().ok_or("usage: solidcraft-cli bench-open [--parse-only]")?; let t = std::time::Instant::now(); let step = solidcraft_engine::io::is_step_path(path); diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index e7ef3917..34f7e3f8 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -162,12 +162,12 @@ impl Document { pub fn face_colors(&self, b: &crate::ModelBody) -> Vec<(usize, Look)> { // Face colours the body was imported with, under the design's own. let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; - let names = crate::naming::face_names(b); let mut out: Vec<(usize, Look)> = match b.body.paint() { Some(p) => p.faces.iter().map(|f| (f.face, Look::custom(f.color.map(to8), f.opacity as f64))).collect(), - // Carried from an earlier version of the body: by face name. + // Carried from an earlier version of the body: by face name (worked out only here: + // the viewport asks every body for its face colours on every change). None => match &b.imported { - Some(imp) => names + Some(imp) => crate::naming::face_names(b) .iter() .enumerate() .filter_map(|(i, n)| imp.faces.get(crate::naming::strip_piece(n)).map(|(c, o)| (i, Look::custom(c.map(to8), *o as f64)))) diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index be8a1181..0b805d75 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -2358,8 +2358,14 @@ impl Document { /// Evaluate an expression against the document's parameters. pub fn eval(&self, e: &str, kind: Kind) -> Result { - let (vals, _) = self.param_values(); - Self::eval_in(&vals, e, kind) + // The parameters are evaluated only when the expression names one: most expressions an + // agent sends are plain values, and evaluating every parameter of a large design for + // each of them made every new feature cost more than the last. + let vals: std::cell::OnceCell> = std::cell::OnceCell::new(); + expr::eval_with(e, &|n| { + vals.get_or_init(|| self.param_values().0).get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))) + })? + .to_kind(kind) } pub fn eval_in(vals: &std::collections::BTreeMap, e: &str, kind: Kind) -> Result { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 7738b9ca..c2ab5221 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -4753,9 +4753,61 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram /// occurrence of it (bodies of the first placement keep their names; further instances get the /// occurrence path in brackets). Without components this is the model itself. pub fn world_state(doc: &Document, st: &ModelState) -> ModelState { + world_state_reusing(doc, st, &mut PlacedBodies::default()) +} + +/// Bodies a [`world_state_reusing`] call placed away from their definition (a moved +/// component's bodies), by their definition and placement: the next call reuses each one whose +/// definition and placement are unchanged instead of transforming it again, and so keeps its +/// display mesh. Without this every change anywhere in a design transformed and meshed every +/// body of every moved component again. +#[derive(Default)] +pub struct PlacedBodies { + by_source: std::collections::HashMap<(usize, [u64; 16]), (ModelBody, ModelBody)>, +} + +impl PlacedBodies { + /// The body `b` placed by `m`, from an earlier call when `b` is the same body as then. + fn placed(&self, b: &ModelBody, m: &crate::Mat, name: String) -> ModelBody { + let reused = self.by_source.get(&placed_key(b, m)).filter(|(source, _)| { + Arc::ptr_eq(&source.mesh, &b.mesh) + && source.body.same(&b.body) + && source.body.color() == b.body.color() + && source.body.paint() == b.body.paint() + && match (source.body.split_lines(), b.body.split_lines()) { + (Some(x), Some(y)) => Arc::ptr_eq(x, y), + (None, None) => true, + _ => false, + } + }); + match reused { + Some((_, placed)) => { + let mut fresh = ModelBody::new(name, placed.body.clone(), b.feature); + fresh.mesh = placed.mesh.clone(); + fresh.plan = placed.plan.clone(); + fresh.plan_mesh = placed.plan_mesh.clone(); + fresh + } + None => ModelBody::new(name, kernel::transform_matrix(&b.body, *m).unwrap_or_else(|_| b.body.clone()), b.feature), + } + } +} + +fn placed_key(b: &ModelBody, m: &crate::Mat) -> (usize, [u64; 16]) { + let mut bits = [0u64; 16]; + for (slot, v) in bits.iter_mut().zip(m.iter().flatten()) { + *slot = v.to_bits(); + } + (Arc::as_ptr(&b.mesh) as usize, bits) +} + +/// [`world_state`], reusing the bodies `placed` holds from an earlier call and leaving it holding +/// this call's. +pub fn world_state_reusing(doc: &Document, st: &ModelState, placed_before: &mut PlacedBodies) -> ModelState { if doc.occurrences.is_empty() { return st.clone(); } + let mut placed_now = PlacedBodies::default(); let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0); let first_path = |comp: u64| -> Vec { let mut path = Vec::new(); @@ -4785,8 +4837,9 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState { 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) + let placed = placed_before.placed(b, &bm, name); + placed_now.by_source.insert(placed_key(b, &bm), (b.clone(), placed.clone())); + placed }; // World states keep their previous display-only evaluation-key semantics. placed.key = 0; @@ -4834,6 +4887,7 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState { } } } + *placed_before = placed_now; out } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 5524607e..7387263a 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -49,9 +49,10 @@ pub use assembly::{ }; pub use document::*; pub use eval::{ - EdgeRef, FeatureResult, Model, ModelBody, ModelState, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for, edge_refs, - face_point_names, face_point_state, hole_direction_at, object_height, parse_metric_thread, pattern_matrices, revolve_extent, + EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for, + edge_refs, face_point_names, face_point_state, hole_direction_at, object_height, parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, state_in_frame, state_in_frame_at, through_all_distance, world_state, + world_state_reusing, }; pub use params::{ParamRow, ParamUser, cycles}; pub use solidcraft_kernel as kernel; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 90756f31..a3f5510e 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -467,10 +467,14 @@ impl Document { /// The next free `dN` name. pub fn next_d_name(&self) -> String { + // Every taken name at once: asking `name_taken` for d1, d2, … searched the whole + // design once per number, quadratic in the number of named inputs. + let taken: std::collections::HashSet<&str> = + self.params.iter().map(|p| p.name.as_str()).chain(self.features.iter().flat_map(|f| f.param_names.iter().map(String::as_str))).collect(); let mut n = 1; loop { let c = format!("d{n}"); - if !self.name_taken(&c) { + if !taken.contains(c.as_str()) { return c; } n += 1; @@ -657,7 +661,15 @@ impl Document { let (vals, errs) = self.param_values(); let all = self.all_param_exprs(); let refs: BTreeMap> = all.iter().map(|(n, e, _)| (n.clone(), expr::references(e))).collect(); - let used_by = |n: &str| -> Vec { refs.iter().filter(|(_, r)| r.iter().any(|x| x == n)).map(|(k, _)| k.clone()).collect() }; + // Who uses each name, in name order: one pass over the references (a design an agent + // built names every feature input, so a search per row was quadratic). + let mut users: BTreeMap<&str, Vec> = BTreeMap::new(); + for (k, r) in &refs { + for x in r { + users.entry(x.as_str()).or_default().push(k.clone()); + } + } + let used_by = |n: &str| -> Vec { users.get(n).cloned().unwrap_or_default() }; let value = |n: &str, k: Kind| vals.get(n).and_then(|v| v.to_kind(k).ok()).map(|x| if k == Kind::Angle { x.to_degrees() } else { x }); let mut rows = Vec::new(); for p in &self.params { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 14cbfddc..9ed2571f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -173,6 +173,8 @@ struct WorldCache { active_component: u64, active_occurrence_path: Option>, world: Arc, + /// The moved components' bodies as placed for `world`, reused by the next world state. + placed: Arc>, } impl WorldCache { @@ -395,17 +397,27 @@ impl Session { { return cached.world.clone(); } - let mut w = if self.pending_moves.is_empty() { - solidcraft_doc::world_state(&self.doc, &st) - } else { - let mut d = (*self.doc).clone(); - for o in &mut d.occurrences { - if let Some(m) = self.pending_moves.get(&o.id) { - o.transform = *m; + // The bodies the last world state placed: unchanged ones are not transformed and meshed + // again (a design built command by command changed one body at a time). + let placed = self.world_cache.lock().ok().and_then(|c| c.as_ref().map(|c| c.placed.clone())).unwrap_or_default(); + // Taken out while placing, so a scratch copy of the session sharing it never waits. + let mut reuse = placed.lock().map(|mut p| std::mem::take(&mut *p)).unwrap_or_default(); + let mut w = { + if self.pending_moves.is_empty() { + solidcraft_doc::world_state_reusing(&self.doc, &st, &mut reuse) + } else { + let mut d = (*self.doc).clone(); + for o in &mut d.occurrences { + if let Some(m) = self.pending_moves.get(&o.id) { + o.transform = *m; + } } + solidcraft_doc::world_state_reusing(&d, &st, &mut reuse) } - solidcraft_doc::world_state(&d, &st) }; + if let Ok(mut p) = placed.lock() { + *p = reuse; + } // Sketch definitions are displayed once, at the placement currently being authored. if self.active_occurrence_path.is_some() && self.active_component != 0 { let first = self.occurrence_placement(&self.doc.first_path(self.active_component)).map(|p| p.world).unwrap_or(solidcraft_doc::IDENTITY); @@ -440,6 +452,7 @@ impl Session { active_component: self.active_component, active_occurrence_path: self.active_occurrence_path.clone(), world: w.clone(), + placed, }); } w diff --git a/crates/engine/src/world_placement_cache_tests.rs b/crates/engine/src/world_placement_cache_tests.rs index 94f9d0a0..7d1c4431 100644 --- a/crates/engine/src/world_placement_cache_tests.rs +++ b/crates/engine/src/world_placement_cache_tests.rs @@ -175,3 +175,41 @@ fn adopted_scratch_keeps_its_warm_geometry_and_history_stages_invalidate_it() { run(&mut session, "timeline.suppress", json!({"feature":feature,"suppressed":false})); assert!(session.world_state().body("Draft").is_some()); } + +/// A moved component's bodies keep their placed geometry and display mesh while the design +/// changes elsewhere: each new body used to transform and mesh every body of every moved +/// component again. A body that changes, or a placement that moves, is placed afresh. +#[test] +fn moved_component_bodies_are_not_placed_again_when_something_else_changes() { + let mut session = Session::default(); + let created = run(&mut session, "component.create", json!({"name":"Wheel"})); + let occurrence = created["occurrence"].as_u64().unwrap(); + run(&mut session, "solid.box", json!({"length":60,"width":60,"height":5,"body_name":"Rim"})); + run(&mut session, "component.activate", json!({"component":"root"})); + run(&mut session, "occurrence.move", json!({"occurrence":occurrence,"translate":[100,0,0]})); + let placed = session.world_state(); + let rim = placed.bodies.iter().find(|b| b.source_name.as_deref() == Some("Rim")).unwrap().clone(); + let mesh = rim.mesh(); + assert!(mesh.bounds().min.dist(Vec3::new(100., 0., 0.)) < 1e-6); + // A body added elsewhere: the rim is the same placed body, already meshed. + run(&mut session, "solid.box", json!({"length":5,"width":6,"height":7,"body_name":"Frame"})); + let after = session.world_state(); + let again = after.body(&rim.name).unwrap(); + assert!(Arc::ptr_eq(&mesh, &again.mesh()), "an unchanged placed body keeps its mesh"); + assert!(again.body.same(&rim.body)); + // The placement moves: placed and meshed again where it now is. + run(&mut session, "occurrence.move", json!({"occurrence":occurrence,"translate":[0,50,0]})); + let moved = session.world_state(); + let moved_rim = moved.bodies.iter().find(|b| b.source_name.as_deref() == Some("Rim")).unwrap(); + assert!(!Arc::ptr_eq(&mesh, &moved_rim.mesh())); + assert!(moved_rim.mesh().bounds().min.dist(Vec3::new(100., 50., 0.)) < 1e-6); + // The body itself changes: placed again too, and the old snapshot is untouched. + run(&mut session, "body.rename", json!({"body":"Frame","name":"Frame tube"})); + let source = session.model.state().body("Rim").unwrap().clone(); + let world = session.world_state(); + let kept = world.bodies.iter().find(|b| b.source_name.as_deref() == Some("Rim")).unwrap(); + assert!(Arc::ptr_eq(&moved_rim.mesh(), &kept.mesh())); + assert_eq!(kept.source_transform, moved_rim.source_transform); + assert!(source.mesh().bounds().min.dist(Vec3::ZERO) < 1e-6); + assert!(mesh.bounds().min.dist(Vec3::new(100., 0., 0.)) < 1e-6); +} diff --git a/crates/ui-egui/src/params_dialog.rs b/crates/ui-egui/src/params_dialog.rs index ef1c8867..a8abf159 100644 --- a/crates/ui-egui/src/params_dialog.rs +++ b/crates/ui-egui/src/params_dialog.rs @@ -120,7 +120,12 @@ pub fn open(app: &mut SolveApp) { /// Draw the dialog when it is open. Called once per frame. pub fn show(app: &mut SolveApp, ctx: &egui::Context) { - publish(app, ctx); + // Completions are offered only under a focused text box; gathering them evaluates every + // parameter, so a design changing command after command (an agent building over MCP) + // does not pay for it on every change while nobody is typing. + if app.params.is_some() || ctx.memory(|m| m.focused().is_some()) { + publish(app, ctx); + } let Some(mut d) = app.params.take() else { return }; let t = Tokens::get(); let mut open = true; diff --git a/docs/perf.md b/docs/perf.md index eeb6d436..b8348e71 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -1,5 +1,50 @@ # Design loading +## Designs built by agents: work per command that grew with the design (2026-10-14) + +A design an agent builds over MCP is made of many small commands: a cylinder per spoke, a box +per chain plate, one part built at the origin and its component moved into place. Each command +did work over the whole design, so the 1,000th command cost far more than the first and a +1,300-body design took minutes to build and to show. `solidcraft-cli bench-agent` builds such a +design (a generated bicycle: about 1,260 commands, 1,280 bodies, 1,250 features, wheels built at +the origin and moved), timing each command and then what a window needs to draw its result (the +world state and its display meshes), and finally opens the saved design like `bench-open`. The +script is generated, so it needs no fixture; `--scale` changes the counts and `--script-out` +writes the commands for replaying elsewhere (`engine.execute` one by one over the control +channel reproduces the desktop case). + +What grew with the design, per command: + +- **Moved components were placed again after every change.** `world_state` transformed every + body of a component whose occurrence is moved and gave it a new, empty display mesh, so every + command meshed all those bodies again (the two wheels: 150 bodies and two tori, 130-190 ms per + command). It now keeps the bodies it placed and reuses each one whose definition (same body, + same display cell) and placement are unchanged. +- **A free `dN` name searched the design once per number** (`next_d_name`), quadratic in the + number of named inputs, for every new feature input. +- **Every expression evaluated every parameter** (`Document::eval`), several times per + feature command (each coordinate, each size). Parameters are now evaluated only when the + expression names one. +- **The parameter table's `used_by` searched all parameters for each one** (quadratic); it is + one pass now. The desktop also stops gathering expression completions (eleven parameter + tables) on every change while no text box has focus. + +`solidcraft-cli bench-agent`, release build, 16-core Linux shared with other builds (load +average 15-20), one run each: + +| per command, by design size | before: command | before: display | after: command | after: display | +|---|---:|---:|---:|---:| +| 200 commands (227 bodies) | 6.0 ms | 129 ms | 1.8 ms | 2.7 ms | +| 600 commands (621 bodies) | 19.9 ms | 169 ms | 4.9 ms | 2.4 ms | +| 1,000 commands (1,021 bodies) | 45.1 ms | 187 ms | 9.8 ms | 4.5 ms | +| 1,200 commands (1,221 bodies) | 56.8 ms | 194 ms | 12.5 ms | 5.6 ms | +| whole build (1,260 commands) | 241 s | | 16.3 s | | + +The command column still grows (the timeline's fingerprints and parameter values are walked +once per command), but slowly. `solidcraft-cli eval` output for the design corpus, the examples, +the regression scripts, the generated bicycle and a 3,300-feature agent-built design is +unchanged apart from the timings. + ## STEP import: shells and item solids without copies (2026-10-13) A file that lists one product definition many times (a bolt used 70 times, a panel in several