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 <noreply@anthropic.com>
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

+121 -4
View File
@@ -7,7 +7,7 @@
//! producing their normal wire, hatch, image, wipeout, or mesh model.
use acadrust::entities::Insert;
use acadrust::types::{Color, Transform, Vector3};
use acadrust::types::{Color, Matrix3, Matrix4, Transform, Vector3};
use acadrust::{CadDocument, EntityType, Handle};
use rustc_hash::{FxHashMap, FxHashSet};
@@ -1042,11 +1042,34 @@ pub fn document_block_uses(document: &CadDocument) -> Vec<BlockUse> {
uses
}
/// A MINSERT's row/column counts are parsed `u16`s whose unchecked product
/// (`Insert::instance_count`) would otherwise drive this function's
/// allocation and a full per-instance render-graph walk after it — a corrupt
/// or adversarial 65535x65535 pair requests over four billion instances (tens
/// of GiB) long before any GPU-side chunking limit could help. The primary
/// defense is the load-time filter in `io::is_entity_corrupt` (kept in sync
/// with this constant), which drops such an insert before it ever reaches a
/// `Scene`; this is the failsafe for an insert built or edited some other way
/// (API/scripting) that skips that path.
const MAX_MINSERT_INSTANCES: usize = 20_000;
pub fn array_offsets(insert: &Insert) -> Vec<[f64; 3]> {
if !insert.is_minsert() {
return vec![[0.0; 3]];
}
let mut offsets = Vec::with_capacity(insert.instance_count());
let instance_count = insert.instance_count();
if instance_count > MAX_MINSERT_INSTANCES {
eprintln!(
"MINSERT '{}' requests {} instances ({}x{}), exceeding the {} budget; rendering a single instance instead",
insert.block_name,
instance_count,
insert.row_count,
insert.column_count,
MAX_MINSERT_INSTANCES
);
return vec![[0.0; 3]];
}
let mut offsets = Vec::with_capacity(instance_count);
for row in 0..insert.row_count {
for column in 0..insert.column_count {
offsets.push([
@@ -1070,8 +1093,20 @@ pub fn insert_instance_transform(
if offset == [0.0; 3] {
transform
} else {
Transform::from_translation(Vector3::new(offset[0], offset[1], offset[2]))
.then(&transform)
// Row/column spacing is defined in the insert's rotated/OCS-oriented
// space but is NOT subject to the block's x/y/z scale — this matches
// the pinned CAD model's own `Insert::array_points`, which rotates
// the offset but never scales it. Composing the raw offset ahead of
// the full (scaled) transform, as this used to do, let the block
// scale multiply the spacing too (e.g. an x-scale-2 insert with
// column spacing 10 would render 20 units between columns). Instead,
// orient the offset by rotation + OCS only, then translate the
// already-composed (scaled) instance transform by that world-space
// vector, applied after — never scaled.
let orientation = Transform::from_matrix(Matrix4::rotation_z(insert.rotation))
.then(&Transform::from_matrix(Matrix4::from_matrix3(Matrix3::arbitrary_axis(insert.normal))));
let world_offset = orientation.apply_rotation(Vector3::new(offset[0], offset[1], offset[2]));
transform.then(&Transform::from_translation(world_offset))
}
}
@@ -1163,4 +1198,86 @@ mod tests {
assert!((from_insert.x - 12.0).abs() < 1.0e-9);
assert!((applied.x - 11.0).abs() < 1.0e-9);
}
#[test]
fn array_offsets_clamps_pathological_minsert_counts() {
// A corrupt or adversarial row/column pair (parsed as u16, so this is
// reachable from a malformed file) must not drive a multi-billion-
// element allocation; the primary defense is the load-time filter in
// `io::is_entity_corrupt`, this is the render-graph-side failsafe.
let mut insert = Insert::new("BLOCK", Vector3::ZERO);
insert.row_count = u16::MAX;
insert.column_count = u16::MAX;
insert.row_spacing = 1.0;
insert.column_spacing = 1.0;
let offsets = array_offsets(&insert);
assert_eq!(offsets, vec![[0.0; 3]], "pathological instance count must clamp to a single instance");
}
#[test]
fn array_offsets_is_unaffected_for_a_reasonable_minsert() {
let mut insert = Insert::new("BLOCK", Vector3::ZERO);
insert.row_count = 3;
insert.column_count = 4;
insert.row_spacing = 5.0;
insert.column_spacing = 2.0;
let offsets = array_offsets(&insert);
assert_eq!(offsets.len(), 12);
assert_eq!(offsets[0], [0.0, 0.0, 0.0]);
assert_eq!(offsets[1], [2.0, 0.0, 0.0]);
assert_eq!(offsets[4], [0.0, 5.0, 0.0]);
}
#[test]
fn insert_instance_transform_does_not_scale_the_row_column_spacing() {
// github discussion-derived report: an INSERT with x-scale 2 and
// column spacing 10 was rendering 20 drawing units between columns
// instead of 10, because the offset translation was composed ahead
// of (and thus multiplied by) the block scale.
let document = CadDocument::new();
let mut insert = Insert::new("BLOCK", Vector3::new(100.0, 0.0, 0.0));
insert.set_x_scale(2.0);
insert.row_count = 1;
insert.column_count = 2;
insert.column_spacing = 10.0;
let transform = insert_instance_transform(
&document,
&insert,
[10.0, 0.0, 0.0],
1.0,
BlockScalePolicy::Applied,
);
// The block's own origin (local point 0,0,0) placed at this instance
// must land 10 units from the insert point, not 20.
let world = transform.apply(Vector3::ZERO);
assert!((world.x - 110.0).abs() < 1.0e-9, "expected spacing 10, got offset {}", world.x - 100.0);
// Block-local geometry must still pick up the block scale normally.
let block_point = transform.apply(Vector3::new(1.0, 0.0, 0.0));
assert!((block_point.x - 112.0).abs() < 1.0e-9, "block-local geometry should still be scaled by x_scale 2");
}
#[test]
fn insert_instance_transform_rotates_the_spacing_with_the_insert() {
// Matches the pinned CAD model's own `Insert::array_points`: spacing
// is rotated with the insert but never scaled.
let document = CadDocument::new();
let mut insert = Insert::new("BLOCK", Vector3::ZERO);
insert.rotation = std::f64::consts::FRAC_PI_2;
insert.row_count = 1;
insert.column_count = 2;
insert.column_spacing = 10.0;
let transform = insert_instance_transform(
&document,
&insert,
[10.0, 0.0, 0.0],
1.0,
BlockScalePolicy::Applied,
);
let world = transform.apply(Vector3::ZERO);
assert!(world.x.abs() < 1.0e-9, "a 90deg rotation should turn column spacing into Y, got x={}", world.x);
assert!((world.y - 10.0).abs() < 1.0e-9, "expected the spacing rotated onto Y, got y={}", world.y);
}
}