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 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-14 13:48:39 -04:00
1 parent 37547f8eba
commit 6de8f6db95
11 files changed
+439 -21

No files matched your search

+239
View File
@@ -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<Value>,
}
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::<f64>().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<f64>, Vec<f64>) = (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::<f64>() / 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")));
}
}
+6 -1
View File
@@ -5,6 +5,7 @@
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect) measurements as JSON
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--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 <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
//! solidcraft-cli exec <command> [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 <design.solidcraft|script.json> [--out script.json]
(the design's history as a command script that eval and run replay)
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
solidcraft-cli exec <command> [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 <design.solidcraft | part.step> [--parse-only]")?;
let t = std::time::Instant::now();
let step = solidcraft_engine::io::is_step_path(path);
+3 -3
View File
@@ -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))))
+8 -2
View File
@@ -2358,8 +2358,14 @@ impl Document {
/// Evaluate an expression against the document's parameters.
pub fn eval(&self, e: &str, kind: Kind) -> Result<f64> {
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::collections::BTreeMap<String, expr::Value>> = 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<String, expr::Value>, e: &str, kind: Kind) -> Result<f64> {
+56 -2
View File
@@ -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<u64> {
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
}
+3 -2
View File
@@ -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;
+14 -2
View File
@@ -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<String, Vec<String>> = all.iter().map(|(n, e, _)| (n.clone(), expr::references(e))).collect();
let used_by = |n: &str| -> Vec<String> { 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<String>> = 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<String> { 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 {
+21 -8
View File
@@ -173,6 +173,8 @@ struct WorldCache {
active_component: u64,
active_occurrence_path: Option<Vec<u64>>,
world: Arc<solidcraft_doc::ModelState>,
/// The moved components' bodies as placed for `world`, reused by the next world state.
placed: Arc<std::sync::Mutex<solidcraft_doc::PlacedBodies>>,
}
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
@@ -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);
}
+6 -1
View File
@@ -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;
+45
View File
@@ -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