Fix cubic input-name repair when loading large designs
This commit is contained in:
1 parent
feaed2e3f6
commit
e3ee1b359e
4 files changed
+180
-4
No files matched your search
@@ -3,6 +3,7 @@
|
||||
//! ```text
|
||||
//! solvecraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
|
||||
//! solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
|
||||
//! solvecraft-cli bench-open <design.solvecraft> [--parse-only] loading timings as JSON lines
|
||||
//! solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
||||
//! solvecraft-cli exec <command> [json-params] run one command on an empty design
|
||||
//! solvecraft-cli commands the command registry as JSON
|
||||
@@ -31,6 +32,7 @@ const USAGE: &str = "usage:
|
||||
solvecraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
|
||||
solvecraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
|
||||
solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
|
||||
solvecraft-cli bench-open <design.solvecraft> [--parse-only]
|
||||
solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
solvecraft-cli exec <command> [json-params]
|
||||
solvecraft-cli commands
|
||||
@@ -74,6 +76,7 @@ fn main() -> ExitCode {
|
||||
Some("save-stress") => cmd_save_stress(&args[1..]),
|
||||
// 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..]),
|
||||
Some("--version" | "-V") => {
|
||||
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
@@ -351,3 +354,34 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
|
||||
println!("{}", pretty(&json!(out)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
|
||||
fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
let path = args.first().ok_or("usage: solvecraft-cli bench-open <design.solvecraft> [--parse-only]")?;
|
||||
let t = std::time::Instant::now();
|
||||
let len = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
|
||||
if len > solvecraft_engine::io::MAX_DESIGN_BYTES as u64 {
|
||||
return Err(format!("{path}: file too large"));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("{}", json!({"step": "read", "ms": read_ms, "bytes": bytes.len()}));
|
||||
let t = std::time::Instant::now();
|
||||
let doc = solvecraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?;
|
||||
let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len()}));
|
||||
if args.iter().any(|a| a == "--parse-only") {
|
||||
return Ok(());
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let s = Session::new(doc);
|
||||
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0}));
|
||||
for r in &s.model.results {
|
||||
println!("{}", json!({"step": "feature", "id": r.id, "name": r.name, "ms": r.ms, "error": r.error}));
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let st = s.world_state();
|
||||
let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum();
|
||||
println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles}));
|
||||
Ok(())
|
||||
}
|
||||
@@ -197,10 +197,7 @@ fn rigid(m: &crate::Mat) -> bool {
|
||||
|
||||
/// Feature input names and component occurrences, which format 1 files may predate.
|
||||
fn normalise(d: &mut Document) -> Result<()> {
|
||||
let ids: Vec<u64> = d.features.iter().map(|f| f.id).collect();
|
||||
for id in ids {
|
||||
d.name_feature_inputs(id);
|
||||
}
|
||||
d.name_all_feature_inputs();
|
||||
let missing: Vec<(u64, u64)> =
|
||||
d.components.iter().filter(|c| !d.occurrences.iter().any(|o| o.component == c.id)).map(|c| (c.id, c.parent)).collect();
|
||||
for (c, p) in missing {
|
||||
@@ -213,6 +210,70 @@ fn normalise(d: &mut Document) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bulk_input_repair_preserves_existing_names_and_reuses_surplus_names() {
|
||||
let mut d = Document::new("input repair");
|
||||
d.set_param("d2", "1", None, None).unwrap();
|
||||
for i in 0..5 {
|
||||
d.add_feature(
|
||||
crate::FeatureKind::Box {
|
||||
corner: ["0".into(), "0".into(), "0".into()],
|
||||
length: "10".into(),
|
||||
width: "10".into(),
|
||||
height: "10".into(),
|
||||
operation: crate::Operation::NewBody,
|
||||
},
|
||||
Some(&format!("Box{i}")),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
d.features[0].param_names = vec!["".into(), "custom".into(), "".into(), "d1".into(), "d2".into()];
|
||||
d.features[1].param_names.clear();
|
||||
d.features[2].param_names = vec!["d7".into(), "".into(), "d9".into()];
|
||||
d.features[3].param_names = vec!["d7".into(), "d11".into(), "d12".into(), "d1".into()];
|
||||
d.features[4].param_names.clear();
|
||||
let mut expected = d.clone();
|
||||
for id in expected.features.iter().map(|f| f.id).collect::<Vec<_>>() {
|
||||
expected.name_feature_inputs(id);
|
||||
}
|
||||
d.name_all_feature_inputs();
|
||||
assert_eq!(d, expected);
|
||||
assert_eq!(read(&d.to_json()).unwrap(), d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_unnamed_timeline_loads_with_unique_inputs() {
|
||||
let mut d = Document::new("large input repair");
|
||||
let id = d
|
||||
.add_feature(
|
||||
crate::FeatureKind::Box {
|
||||
corner: ["0".into(), "0".into(), "0".into()],
|
||||
length: "10".into(),
|
||||
width: "10".into(),
|
||||
height: "10".into(),
|
||||
operation: crate::Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut f = d.feature(id).unwrap().clone();
|
||||
f.param_names.clear();
|
||||
d.features = (1..=5000)
|
||||
.map(|id| {
|
||||
let mut f = f.clone();
|
||||
f.id = id;
|
||||
f
|
||||
})
|
||||
.collect();
|
||||
d.next_id = 5001;
|
||||
let back = read(&d.to_json()).unwrap();
|
||||
let names: Vec<_> = back.features.iter().flat_map(|f| &f.param_names).collect();
|
||||
assert_eq!(names.len(), 15000);
|
||||
assert_eq!(names.first().unwrap().as_str(), "d1");
|
||||
assert_eq!(names.last().unwrap().as_str(), "d15000");
|
||||
assert_eq!(names.iter().collect::<std::collections::BTreeSet<_>>().len(), names.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions() {
|
||||
assert_eq!(version_of("solvecraft/1"), Some(1));
|
||||
|
||||
@@ -398,6 +398,55 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Repair a loaded timeline in one pass. Reserve even later features' names, just as
|
||||
/// `name_feature_inputs` does, without restarting a document scan for every `dN` candidate.
|
||||
pub(crate) fn name_all_feature_inputs(&mut self) {
|
||||
let mut taken: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for name in self.params.iter().map(|p| &p.name).chain(self.features.iter().flat_map(|f| &f.param_names)) {
|
||||
*taken.entry(name.clone()).or_default() += 1;
|
||||
}
|
||||
let mut next = 1usize;
|
||||
for f in &mut self.features {
|
||||
let n = f.kind.inputs().len();
|
||||
for k in 0..n {
|
||||
if f.param_names.get(k).is_some_and(|name| !name.is_empty()) {
|
||||
continue;
|
||||
}
|
||||
let name = loop {
|
||||
let name = format!("d{next}");
|
||||
next += 1;
|
||||
if !taken.contains_key(&name) {
|
||||
break name;
|
||||
}
|
||||
};
|
||||
if f.param_names.len() <= k {
|
||||
f.param_names.resize(k + 1, String::new());
|
||||
}
|
||||
// Empty slots cannot collide with a generated name.
|
||||
if let Some(slot) = f.param_names.get_mut(k) {
|
||||
*slot = name.clone();
|
||||
}
|
||||
taken.insert(name, 1);
|
||||
}
|
||||
// Match the single-feature repair: surplus names become available only after
|
||||
// this feature has been repaired. Counts preserve names shared with parameters.
|
||||
for name in f.param_names.drain(n..) {
|
||||
if let Some(count) = taken.get_mut(&name) {
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
taken.remove(&name);
|
||||
if let Some(i) = name.strip_prefix('d').and_then(|s| s.parse::<usize>().ok())
|
||||
&& name == format!("d{i}")
|
||||
&& i > 0
|
||||
{
|
||||
next = next.min(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Change a parameter: a stored one, or a feature input by its name.
|
||||
/// A typed length that is a bare number means the design's units: it gets that unit written
|
||||
/// in (feature inputs are stored in mm otherwise). Anything else is returned unchanged.
|
||||
|
||||
@@ -1,3 +1,35 @@
|
||||
# Design loading
|
||||
|
||||
`solvecraft-cli bench-open design.solvecraft` reports JSON lines for file reading,
|
||||
JSON decoding and migrations (including input-name repair), timeline evaluation, individual
|
||||
features, and world placement/display meshing. `--parse-only` stops before geometry evaluation.
|
||||
The parse timer includes document validation and repair, not just the JSON parser.
|
||||
|
||||
Windows x64, optimized `ci` profile, measured during other builds:
|
||||
|
||||
| Design | Parse/repair | Evaluate | Display meshes |
|
||||
|---|---:|---:|---:|
|
||||
| 19-feature enclosure, 34 KB | 1.2 ms | 14.9 s | 6 ms |
|
||||
| 25-feature assembly, 15.2 MB, 2,990 bodies | 21 ms | 12.5 s | 2.1 s |
|
||||
| Generated 1,000 independent boxes, 185 KB | 5 ms | 0.82 s | 0.15 s |
|
||||
| Generated 5,000 boxes in five grids, 10 features | 0.45 ms | 17.6 s | 0.81 s |
|
||||
|
||||
The enclosures spend most of evaluation in joins and cuts, not parsing. Designs save feature
|
||||
definitions; opening them rebuilds their geometry. Those times remain after the repair fix.
|
||||
|
||||
A separate generated timeline with missing input names reproduced minutes in the reader:
|
||||
|
||||
| Timeline (geometry rolled back) | Parse/repair before | After |
|
||||
|---|---:|---:|
|
||||
| 2,000 boxes, 6,000 unnamed inputs | 78.1 s | 10–20 ms |
|
||||
| 5,000 boxes, 15,000 unnamed inputs | interrupted after 131 s, still repairing | 26–46 ms |
|
||||
|
||||
Previously, each missing input restarted the `d1`, `d2`, … search, scanning the document for
|
||||
every candidate. This approaches cubic work as unnamed inputs accumulate. Loading now reserves
|
||||
names once and assigns missing names with an advancing cursor. Existing names, names on later
|
||||
features, parameter collisions and reuse of surplus names retain the previous behavior.
|
||||
Regression tests compare the old and bulk repairs and load a 5,000-feature unnamed timeline.
|
||||
|
||||
# Viewport performance
|
||||
|
||||
Measured with the control channel (`ui.inspect` `frame_ms`: CPU time of one UI frame, which
|
||||
|
||||
Reference in new issue
Block a user