Port 6 audit-found bugs from the upstream OSNAP fix branch

Ports the fixes from fix/osnap-circle-arc-ellipse-intersection (and
this session's audit follow-up) onto this branch. All are independent
correctness/reliability fixes, unrelated to the parametric-constraint-
system work also in progress here.

1. OSNAP Intersection snap used a tessellated approximation instead of
   exact geometry (GitHub discussion #1052): a circle/arc always
   tessellates at a fixed 48 segments in the resident wire set
   regardless of radius or zoom, so every circle/arc intersection snap
   was off by construction. Added exact_circle_intersections, a
   closed-form, frame-independent circle/arc-vs-circle/arc solver,
   wired into both Intersection-snap code paths ahead of the
   tessellated sweep. This branch's TangentGeom predates the
   PlanarEllipse variant added upstream, so the ellipse/line
   generalization from the upstream fix isn't ported here — this
   covers the literal reported bug (a circle-circle case) using only
   variants this branch already has.

2. Unbounded MINSERT allocation: array_offsets() reserved one Vec
   element per array instance with row/column counts parsed as
   unchecked u16s — a corrupt 65535x65535 pair requests ~96 GiB.
   Fixed with a documented instance budget (20,000), enforced at both
   the load-time corrupt-entity filter and a render-graph failsafe.

3. MINSERT spacing was scaled by the block's own scale:
   insert_instance_transform composed the offset translation before
   the instance transform, so x/y block scale multiplied the row/
   column spacing too. Fixed by orienting the offset with rotation +
   OCS only, then translating the already-scaled instance transform.

4. Automation "open" skipped the finalization (block-origin
   normalization, raster-path resolution, source_path) and corrupt-
   entity purge a UI open always runs. Added
   io::load_bytes_finalized(path, bytes), used by automation's "open"
   handler, which now also reports the purge count.

5. Material maps were uploaded without checking
   device.limits().max_texture_dimension_2d, unlike raster-image
   rendering (which already tiles to that limit). A mesh material is
   sampled by UV, so it can't use the same tiling trick — instead,
   upload_rgba_texture now downscales an oversized image before
   upload, falling back to the 1x1 material color if malformed.

6. Sketch-constraint persistence/undo, audit-found while reviewing
   this branch's own constraint system:
   - Clearing every constraint in a scope after a prior non-empty save
     left the stale XRecord in place, resurrecting the deleted
     constraints on the next load. materialize_sketch_constraints_for_
     save now actively removes the XRecord when a scope goes back to
     empty.
   - ERASE removing an entity took its constraints with it (design
     doc's deletion policy), but sketch_constraints lives on Scene,
     outside document/document.objects, so nothing captured that
     mutation for undo — the erased geometry came back on undo, but
     its constraints didn't. refresh_sketch_constraints's deletion
     pass now records the touched scope's before-image into the same
     UndoRecording the entity removal rides in, threaded through a new
     sketch_constraints channel on DeltaSnapshot, so one undo restores
     both together. HistorySnapshot::Delta is now boxed (clippy
     flagged the resulting size skew across undo/redo stack variants).

31 new/adapted tests across all six fixes. Full workspace suite passes
except the one pre-existing, unrelated font-test failure also present
on main.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-09 10:46:28 +02:00
1 parent 63a74b19a9
commit 44deee2ac9
11 files changed
+953 -41

No files matched your search

+63 -9
View File
@@ -282,16 +282,19 @@ impl OpenCADStudio {
let Some(path) = req["path"].as_str() else {
return err("open: missing \"path\"");
};
let bytes = match self.read_drawing(std::path::Path::new(path)) {
let path_buf = PathBuf::from(path);
let bytes = match self.read_drawing(&path_buf) {
Ok(b) => b,
Err(e) => return err(format!("open: {e}")),
};
let name = PathBuf::from(path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string());
match crate::io::load_bytes(&name, bytes) {
Ok(doc) => {
// Runs the same finalization (block-origin normalization,
// raster-path resolution, source_path) and corrupt-entity
// purge the UI open path always gets, using the real path —
// not just its basename — so an automation open behaves the
// same as opening the identical file from the UI instead of
// silently skipping that safety/normalization net.
match crate::io::load_bytes_finalized(&path_buf, bytes) {
Ok((doc, dropped)) => {
let i = self.active_tab;
self.tabs[i].scene.document = doc;
self.tabs[i].scene.load_sketch_constraints_from_document();
@@ -300,10 +303,16 @@ impl OpenCADStudio {
&mut self.tabs[i].scene.document,
);
self.tabs[i].adopt_active_ucs_from_header();
self.tabs[i].current_path = Some(PathBuf::from(path));
self.tabs[i].current_path = Some(path_buf);
self.tabs[i].is_start = false;
self.tabs[i].scene.bump_geometry();
self.entity_summary()
let mut summary = self.entity_summary();
if dropped > 0 {
if let Some(obj) = summary.as_object_mut() {
obj.insert("purged".to_string(), json!(dropped));
}
}
summary
}
Err(e) => err(format!("open: {e}")),
}
@@ -1267,4 +1276,49 @@ mod tests {
let _ = std::fs::remove_file(sidecar);
let _ = std::fs::remove_file(&path);
}
/// Audit finding: automation's `"open"` used to call `io::load_bytes`
/// directly, skipping the finalization and corrupt-entity purge every UI
/// open runs — so the same file opened two ways behaved differently.
/// Proves both halves of the fix: `source_path` (only ever set by
/// finalization, never by `load_bytes` alone) is populated, and a
/// zero-radius circle — `io::is_entity_corrupt`'s own rejection case —
/// is purged and reported, exactly like a UI open of the same file.
#[test]
fn open_finalizes_and_purges_like_the_ui_open_path() {
let mut app = OpenCADStudio::new_for_test();
let path = std::env::temp_dir().join(format!(
"ocs_automation_finalize_test_{}.dxf",
std::process::id()
));
let _ = std::fs::remove_file(&path);
let mut doc = acadrust::CadDocument::new();
let mut good = acadrust::entities::Circle::new();
good.center = acadrust::types::Vector3::new(5.0, 5.0, 0.0);
good.radius = 2.0;
doc.add_entity(acadrust::EntityType::Circle(good)).unwrap();
let mut corrupt = acadrust::entities::Circle::new();
corrupt.center = acadrust::types::Vector3::new(1.0, 1.0, 0.0);
corrupt.radius = 0.0; // io::is_entity_corrupt rejects a zero-radius circle
doc.add_entity(acadrust::EntityType::Circle(corrupt)).unwrap();
let bytes = crate::io::save_to_bytes(&doc, "dxf", doc.version)
.expect("save a document containing a corrupt entity");
std::fs::write(&path, bytes).unwrap();
let p = path.to_string_lossy().replace('\\', "\\\\");
let result = app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#));
assert_eq!(result["ok"], true, "{}", result["error"]);
assert_eq!(result["total"], 1, "the corrupt circle must not survive the open");
assert_eq!(result["purged"], 1, "the purge count must be reported, matching the UI open path's diagnostics");
let i = app.active_tab;
assert!(
app.tabs[i].scene.document.source_path.is_some(),
"automation open must run the same finalization as a path-based open, which sets source_path (load_bytes alone never does)"
);
drop(app);
let _ = std::fs::remove_file(&path);
}
}