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

+73 -3
View File
@@ -383,12 +383,24 @@ impl Scene {
// and unconditionally over every scope (not just ones a `touched`
// check would catch), so a scope left with zero constraints after
// this doesn't attempt a pointless resolve below.
//
// Recorded into the *same* undo transaction as the entity removal
// that caused it (audit finding: ERASE undo silently lost constraint
// state, since `sketch_constraints` lives outside `document`/
// `document.objects` and neither of those directories ever saw this
// mutation) — one undo press now restores both the entity and its
// constraints together.
for (handle, kind) in changes {
if *kind != ChangeKind::Removed {
continue;
}
for set in &mut self.sketch_constraints {
set.remove_all_touching(*handle);
for i in 0..self.sketch_constraints.len() {
if self.sketch_constraints[i].constraints_touching(*handle).next().is_some() {
let scope = self.sketch_constraints[i].scope;
let before = self.sketch_constraints[i].clone();
self.record_undo_sketch_constraints_before(scope, before);
self.sketch_constraints[i].remove_all_touching(*handle);
}
}
}
@@ -504,12 +516,70 @@ mod tests {
scene.bump_entities(&[(a, super::ChangeKind::Modified)]);
let recording = scene.take_undo_recording().expect("an undo recording should still be open");
let (entities, _objects) = recording.into_recorded_images();
let (entities, _objects, _sketch_constraints) = recording.into_recorded_images();
let touched: std::collections::HashSet<_> = entities.iter().map(|(h, _)| *h).collect();
assert!(touched.contains(&a), "the directly-edited line must be in the undo delta");
assert!(touched.contains(&b), "the constraint-solved neighbor must ride the same undo delta");
}
/// Audit-flagged gap: ERASE removing an entity takes its constraints with
/// it (design doc's deletion policy, above), but `sketch_constraints`
/// lives on `Scene`, outside `document`/`document.objects`, so nothing
/// used to capture that mutation for undo at all — the erased geometry
/// came back on undo, but its constraints didn't. This proves the fix at
/// the level it actually lives: `refresh_sketch_constraints`'s deletion
/// pass must record the scope's whole before-image into the same
/// `UndoRecording` the entity removal itself rides in, so one recovered
/// image is enough to restore both together (the app-level wiring that
/// turns this into one committed, one-undo-press `DeltaSnapshot` is
/// `OpenCADStudio::commit_undo_delta`/`apply_delta_state`, exercised by
/// the app, not `Scene`, so it's out of this test's reach — this covers
/// the root cause, not that outer plumbing).
#[test]
fn erasing_a_constrained_entity_records_its_scope_for_undo() {
let mut scene = Scene::new();
let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
)));
let b = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(10.0, 0.0, 0.0),
Vector3::new(20.0, 5.0, 0.0),
)));
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
ConstraintKind::Coincident,
vec![SketchRef::point(a, 1), SketchRef::point(b, 0)],
None,
);
assert_eq!(scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(), 1);
scene.begin_undo_recording();
scene.erase_entities(&[a]);
// The live scope must already have lost the constraint (design doc's
// deletion policy) — this ensures the test actually exercises restore,
// not a no-op.
assert_eq!(
scene.sketch_constraint_set(SketchScope::ModelSpace).map_or(0, |s| s.constraints.len()),
0,
"the constraint touching the erased line should be gone from the live set"
);
let recording = scene.take_undo_recording().expect("an undo recording should still be open");
let (_entities, _objects, sketch_constraints) = recording.into_recorded_images();
assert_eq!(sketch_constraints.len(), 1, "the touched scope's before-image must be captured");
let (scope, before) = &sketch_constraints[0];
assert_eq!(*scope, SketchScope::ModelSpace);
assert_eq!(before.constraints.len(), 1, "the before-image must still hold the constraint as it was before the erase");
assert_eq!(before.constraints[0].kind, ConstraintKind::Coincident);
assert_eq!(before.constraints[0].refs, vec![SketchRef::point(a, 1), SketchRef::point(b, 0)]);
// What `apply_delta_state` does with this on undo: install the
// before-image back as the live scope state.
*scene.sketch_constraint_set_mut(*scope) = before.clone();
assert_eq!(scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(), 1, "restoring the before-image must bring the constraint back");
}
/// Design doc §7 open question 6 (live-drag re-solve):
/// `solve_sketch_constraints_preview` must report the constrained
/// neighbor's solved position without writing anything into the