fix: hold the sketch caps at design load and bound the solver per component

Nothing on the load path checked a sketch's size, and every sketch in the
timeline is solved when a design opens. The solver sizes its dense matrices by
the connected component, so a 30 MB file with 200,000 chained points asked the
allocator for terabytes and the process aborted, which no panic guard can
intercept.

Reject an oversized sketch while the design is still raw JSON, so the typed
arrays are never built, and refuse a connected component with more variables
than fits in a bounded matrix: that component now reports a failed solve and
leaves its geometry alone. 10,000 chained points went from 9.6 GB and 25
seconds to a failed solve in well under a second.

Fixes #145

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 04abbf95c26f68c7d0a126ed9e2827049f593361)
This commit is contained in:
Matt committed 2026-10-11 08:22:11 -07:00
1 parent 0b5e15e2b9
commit 78f80143d5
5 files changed
+115 -2

No files matched your search

+55
View File
@@ -153,6 +153,7 @@ pub fn read(s: &str) -> Result<Document> {
validate_versioned_geometry(&v)?;
validate_emboss_targets(&v)?;
validate_freeform_offsets(&v)?;
validate_sketch_sizes(&v)?;
crate::format_offsets::validate_raw(&v)?;
let legacy_wires = validate_wires(&v, version)?;
validate_body_transfers(&v)?;
@@ -213,6 +214,29 @@ pub fn read(s: &str) -> Result<Document> {
Ok(d)
}
/// The caps interactive editing enforces, applied to the raw JSON so an oversized sketch is
/// never deserialised. Every sketch in the timeline is solved when the design opens, and the
/// solver sizes its dense matrices by the sketch, so a file with hundreds of thousands of
/// points would ask the allocator for terabytes and abort before anything could report it.
fn validate_sketch_sizes(value: &Value) -> Result<()> {
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) };
let invalid = || DocError::Invalid("damaged design: sketch too large".into());
for feature in features {
if feature.get("type").and_then(Value::as_str) != Some("sketch") {
continue;
}
let Some(sketch) = feature.get("sketch") else { continue };
let len = |key: &str| sketch.get(key).and_then(Value::as_array).map_or(0, Vec::len);
if len("points") > solvecraft_sketch::MAX_POINTS
|| len("curves") > solvecraft_sketch::MAX_CURVES
|| len("constraints") > solvecraft_sketch::MAX_CONSTRAINTS
{
return Err(invalid());
}
}
Ok(())
}
/// Bound arrays before typed allocation; only old files with absent markers need migration.
fn validate_wires(value: &Value, version: u32) -> Result<Vec<(usize, usize)>> {
let invalid = || DocError::Invalid("damaged design: 3D curve arrays, ids or coordinate spaces are invalid".into());
@@ -1199,4 +1223,35 @@ mod tests {
assert!(read(&v.to_string()).is_err(), "out-of-range numbers anywhere");
assert!(read("{\"format\": 2}").is_err());
}
/// Every sketch in the timeline is solved when the design opens, and the solver sizes its
/// dense matrices by the sketch. The caps interactive editing enforces have to hold for a
/// file too, before the arrays are deserialised.
#[test]
fn oversized_sketches_are_refused_before_typed_decoding() {
use solvecraft_sketch::{MAX_CONSTRAINTS, MAX_CURVES, MAX_POINTS, Sketch};
// A sketch right at the point cap, chained by coincident constraints.
let mut sketch = Sketch::new();
let mut previous = 0;
while sketch.points.len() < MAX_POINTS {
let i = sketch.add_point(solvecraft_geom::Vec2::new(0.001 * sketch.points.len() as f64, 0.0), None).unwrap();
sketch.add_constraint(solvecraft_sketch::ConstraintKind::Coincident { p: previous, q: i }, None).unwrap();
previous = i;
}
let mut doc = Document::new("big sketch");
doc.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
let at_cap: Value = serde_json::from_str(&doc.to_json()).unwrap();
assert!(read(&at_cap.to_string()).is_ok(), "a sketch at the caps must still load");
// One past each cap, grown at the JSON level: no typed array is ever built.
for (key, cap) in [("points", MAX_POINTS), ("constraints", MAX_CONSTRAINTS), ("curves", MAX_CURVES)] {
let mut value = at_cap.clone();
let slot = &mut value["features"][0]["sketch"][key];
let first = slot.as_array().and_then(|a| a.first()).cloned().unwrap_or(Value::Null);
*slot = Value::Array(vec![first; cap + 1]);
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("sketch too large"), "{key}: {error}");
}
}
}