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

+72 -3
View File
@@ -568,6 +568,20 @@ struct MeshSurfaceParams {
flags: [u32; 4],
}
/// Downscale a decoded RGBA buffer so neither dimension exceeds `limit`,
/// preserving aspect ratio (scaled by whichever dimension overshoots more).
/// `None` only for genuinely malformed input (`rgba`'s length doesn't match
/// `width * height * 4`) — the caller falls back to the 1x1 material color
/// in that case, same as it already does for a missing image entirely.
fn downscale_rgba_to_limit(width: u32, height: u32, rgba: &[u8], limit: u32) -> Option<(u32, u32, Vec<u8>)> {
let buffer = image::RgbaImage::from_raw(width, height, rgba.to_vec())?;
let scale = (limit as f64 / width.max(height) as f64).min(1.0);
let new_width = ((width as f64 * scale).round() as u32).clamp(1, limit);
let new_height = ((height as f64 * scale).round() as u32).clamp(1, limit);
let resized = image::imageops::resize(&buffer, new_width, new_height, image::imageops::FilterType::Triangle);
Some((new_width, new_height, resized.into_raw()))
}
fn upload_rgba_texture(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -576,9 +590,27 @@ fn upload_rgba_texture(
fallback: [u8; 4],
srgb: bool,
) -> wgpu::TextureView {
let (width, height, pixels) = image.map_or((1, 1, fallback.as_slice()), |image| {
(image.width, image.height, image.rgba.as_slice())
});
// A material map's decoded dimensions come straight from the source file
// (`material_model::load_map_image`), uncapped — an oversized image would
// otherwise fail `wgpu` texture-size validation and leave the material
// unusable (audit: raster-image rendering already tiles to this same
// limit in `image_gpu.rs`; a mesh material sampled by UV can't use that
// per-quad tiling trick, so downscale instead of tiling).
let limit = device.limits().max_texture_dimension_2d.max(1);
let resized;
let (width, height, pixels): (u32, u32, &[u8]) = match image {
Some(image) if image.width > limit || image.height > limit => {
match downscale_rgba_to_limit(image.width, image.height, &image.rgba, limit) {
Some((w, h, buf)) => {
resized = buf;
(w, h, resized.as_slice())
}
None => (1, 1, fallback.as_slice()),
}
}
Some(image) => (image.width, image.height, image.rgba.as_slice()),
None => (1, 1, fallback.as_slice()),
};
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
@@ -2426,3 +2458,40 @@ pub fn build_mesh_batch_filtered(
}
(chunks, total_tris)
}
#[cfg(test)]
mod texture_limit_tests {
use super::downscale_rgba_to_limit;
#[test]
fn downscale_keeps_both_dimensions_within_the_limit() {
// A wide-and-short image: the limit is only crossed by width, but
// both dimensions must come out <= limit and aspect ratio preserved.
let (w, h) = (500u32, 10u32);
let rgba = vec![0u8; (w * h * 4) as usize];
let (new_w, new_h, buf) = downscale_rgba_to_limit(w, h, &rgba, 200).expect("well-formed input must resize");
assert!(new_w <= 200 && new_h <= 200, "both dimensions must respect the limit, got {new_w}x{new_h}");
assert_eq!(buf.len(), (new_w * new_h * 4) as usize, "the returned buffer must match its reported dimensions");
// Aspect ratio 50:1 should be preserved within rounding.
let ratio = new_w as f64 / new_h as f64;
assert!((ratio - 50.0).abs() < 1.0, "expected aspect ratio near 50:1, got {ratio}");
}
#[test]
fn downscale_handles_a_square_image_over_the_limit() {
let (w, h) = (300u32, 300u32);
let rgba = vec![255u8; (w * h * 4) as usize];
let (new_w, new_h, buf) = downscale_rgba_to_limit(w, h, &rgba, 128).expect("well-formed input must resize");
assert_eq!(new_w, 128);
assert_eq!(new_h, 128);
assert_eq!(buf.len(), (128 * 128 * 4) as usize);
}
#[test]
fn downscale_rejects_a_buffer_whose_length_does_not_match_its_dimensions() {
// Corrupt/mismatched input (audit's "validate decoded byte length" ask):
// must fail cleanly, not panic or silently misread the buffer.
let rgba = vec![0u8; 10];
assert!(downscale_rgba_to_limit(1000, 1000, &rgba, 512).is_none());
}
}