forked from 1b-agent/opencadstudio-upstream
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:
1 parent
63a74b19a9
commit
44deee2ac9
11 files changed
+953
-41
No files matched your search
+45
-7
@@ -330,6 +330,15 @@ pub struct UndoRecording {
|
||||
/// by the command. `None` denotes a newly-created object.
|
||||
object_before: HashMap<Handle, Option<ObjectType>>,
|
||||
object_order: Vec<Handle>,
|
||||
/// First-touch before-images of every sketch-constraint scope a command
|
||||
/// changed (e.g. ERASE removing an entity's constraints along with it via
|
||||
/// `refresh_sketch_constraints`'s deletion policy). `sketch_constraints`
|
||||
/// lives on `Scene`, not in `document`/`document.objects`, so neither
|
||||
/// directory above ever sees this change — it needs its own directory.
|
||||
/// Unlike the two above, there is no "didn't exist before" case: a
|
||||
/// scope's `Vec` entry, once created, is never removed.
|
||||
sketch_constraints_before: HashMap<sketch_constraints::SketchScope, sketch_constraints::SketchConstraintSet>,
|
||||
sketch_constraints_order: Vec<sketch_constraints::SketchScope>,
|
||||
poisoned: bool,
|
||||
}
|
||||
|
||||
@@ -339,18 +348,22 @@ impl UndoRecording {
|
||||
self.poisoned
|
||||
}
|
||||
|
||||
/// No entity or object entry was recorded (nothing to undo).
|
||||
/// No entity, object, or sketch-constraint entry was recorded (nothing to undo).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.order.is_empty() && self.object_order.is_empty()
|
||||
self.order.is_empty() && self.object_order.is_empty() && self.sketch_constraints_order.is_empty()
|
||||
}
|
||||
|
||||
/// Consume both recording directories in deterministic first-touch order.
|
||||
/// A `None` image means the entity/object was added by the command.
|
||||
/// Consume all three recording directories in deterministic first-touch
|
||||
/// order. A `None` image means the entity/object was added by the
|
||||
/// command; every sketch-constraint scope's before-image is a real
|
||||
/// `SketchConstraintSet` (its `Vec` entry, once created, is never
|
||||
/// removed, so there is no "didn't exist before" case there).
|
||||
pub fn into_recorded_images(
|
||||
mut self,
|
||||
) -> (
|
||||
Vec<(Handle, Option<Arc<EntityType>>)>,
|
||||
Vec<(Handle, Option<ObjectType>)>,
|
||||
Vec<(sketch_constraints::SketchScope, sketch_constraints::SketchConstraintSet)>,
|
||||
) {
|
||||
let entities = self
|
||||
.order
|
||||
@@ -362,7 +375,12 @@ impl UndoRecording {
|
||||
.drain(..)
|
||||
.map(|h| (h, self.object_before.remove(&h).flatten()))
|
||||
.collect();
|
||||
(entities, objects)
|
||||
let sketch_constraints = self
|
||||
.sketch_constraints_order
|
||||
.drain(..)
|
||||
.filter_map(|scope| self.sketch_constraints_before.remove(&scope).map(|before| (scope, before)))
|
||||
.collect();
|
||||
(entities, objects, sketch_constraints)
|
||||
}
|
||||
|
||||
/// Entity-only convenience used by the focused Scene delta tests.
|
||||
@@ -2538,6 +2556,26 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one sketch-constraint scope's whole-set image before its first
|
||||
/// mutation within the open recording (first touch wins) — e.g. ERASE
|
||||
/// about to remove some of a scope's constraints via
|
||||
/// `refresh_sketch_constraints`'s deletion policy. `sketch_constraints`
|
||||
/// lives on `Scene`, outside `document`/`document.objects`, so neither
|
||||
/// `record_undo_before` nor `record_undo_object_before` ever sees this
|
||||
/// change on their own.
|
||||
pub(crate) fn record_undo_sketch_constraints_before(
|
||||
&mut self,
|
||||
scope: sketch_constraints::SketchScope,
|
||||
before: sketch_constraints::SketchConstraintSet,
|
||||
) {
|
||||
if let Some(rec) = self.undo_recording.as_mut() {
|
||||
if !rec.sketch_constraints_before.contains_key(&scope) {
|
||||
rec.sketch_constraints_order.push(scope);
|
||||
rec.sketch_constraints_before.insert(scope, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flag the open recording as touching non-entity state (a new layer, a
|
||||
/// group edit, a `*D` block record) that a pure-entity delta cannot
|
||||
/// restore. The app's per-command predicate keeps this from firing.
|
||||
@@ -11007,7 +11045,7 @@ mod delta_undo_tests {
|
||||
let handle = scene.add_entity(EntityType::RasterImage(image));
|
||||
let rec = scene.take_undo_recording().unwrap();
|
||||
assert!(!rec.is_poisoned());
|
||||
let (entities, objects) = rec.into_recorded_images();
|
||||
let (entities, objects, _sketch_constraints) = rec.into_recorded_images();
|
||||
assert_eq!(entities.len(), 1);
|
||||
assert_eq!(entities[0].0, handle);
|
||||
assert_eq!(objects.len(), 1);
|
||||
@@ -11031,7 +11069,7 @@ mod delta_undo_tests {
|
||||
scene.erase_entities(&[h1]);
|
||||
let rec = scene.take_undo_recording().unwrap();
|
||||
assert!(!rec.is_poisoned());
|
||||
let (entities, objects) = rec.into_recorded_images();
|
||||
let (entities, objects, _sketch_constraints) = rec.into_recorded_images();
|
||||
assert_eq!(entities.len(), 1);
|
||||
assert_eq!(entities[0].0, h1);
|
||||
assert_eq!(objects.len(), 1);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+121
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ pub type ConstraintId = u32;
|
||||
/// One persisted constraint record: a friendly [`ConstraintKind`], the
|
||||
/// entities/points it relates, and — for a dimensional kind — the value
|
||||
/// driving it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SketchConstraint {
|
||||
pub id: ConstraintId,
|
||||
pub kind: ConstraintKind,
|
||||
|
||||
@@ -17,9 +17,31 @@
|
||||
|
||||
use super::sketch_constraints::SketchConstraintSet;
|
||||
use super::Scene;
|
||||
use acadrust::{CadDocument, Handle};
|
||||
|
||||
const XRECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS";
|
||||
|
||||
/// Removes a named XRecord from `owner`'s extension dictionary, if present.
|
||||
/// The dictionary itself is left in place even if now empty — other code may
|
||||
/// already reference it via `xdictionary_handle`, and an empty extension
|
||||
/// dictionary is harmless bookkeeping, unlike a stale XRecord that would
|
||||
/// resurrect deleted data on the next load.
|
||||
fn remove_xrecord(document: &mut CadDocument, owner: Handle, key: &str) {
|
||||
let Some(dictionary_handle) = document.extension_dictionary_handle(owner) else {
|
||||
return;
|
||||
};
|
||||
let Some(acadrust::objects::ObjectType::Dictionary(dictionary)) =
|
||||
document.objects.get_mut(&dictionary_handle)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(index) = dictionary.entries.iter().position(|(name, _)| name.eq_ignore_ascii_case(key)) else {
|
||||
return;
|
||||
};
|
||||
let (_, record_handle) = dictionary.entries.remove(index);
|
||||
document.objects.remove(&record_handle);
|
||||
}
|
||||
|
||||
/// Prefixed onto every serialized blob so a future schema change can be
|
||||
/// detected and gracefully skipped (dropping just that one scope's
|
||||
/// constraints, not the whole load) rather than silently misreading bytes
|
||||
@@ -53,13 +75,20 @@ impl Scene {
|
||||
/// edits ride the ordinary object-delta undo path per §5.1(b), but this
|
||||
/// lazy model was the doc's own recommended first step).
|
||||
///
|
||||
/// A scope with an empty constraint set is skipped rather than writing
|
||||
/// an empty XRecord — an unconstrained drawing (the common case) should
|
||||
/// not gain persisted-but-empty bookkeeping on every save.
|
||||
/// A scope with an empty constraint set writes no XRecord — an
|
||||
/// unconstrained drawing (the common case) should not gain
|
||||
/// persisted-but-empty bookkeeping on every save. If a *previous* save
|
||||
/// already materialized a non-empty XRecord for that scope, the now-empty
|
||||
/// set actively removes it instead: leaving the stale blob in place would
|
||||
/// resurrect the deleted constraints the next time the document loads.
|
||||
pub(crate) fn materialize_sketch_constraints_for_save(&mut self) {
|
||||
for index in 0..self.sketch_constraints.len() {
|
||||
let set = &self.sketch_constraints[index];
|
||||
if set.constraints.is_empty() {
|
||||
let owner = set.scope.owner_handle(&self.document);
|
||||
if !owner.is_null() {
|
||||
remove_xrecord(&mut self.document, owner, XRECORD_KEY);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let owner = set.scope.owner_handle(&self.document);
|
||||
@@ -239,6 +268,38 @@ mod tests {
|
||||
assert!(scene.document.xrecord(owner, XRECORD_KEY).is_none(), "an empty constraint set should not create an XRecord");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_previously_materialized_set_removes_its_stale_xrecord() {
|
||||
// Regression for the audit-flagged case `an_empty_constraint_set_is_not_materialized`
|
||||
// doesn't cover: a scope that already had a *non-empty* XRecord from a
|
||||
// prior save must have that XRecord actively removed once its
|
||||
// in-memory set goes back to empty — otherwise the stale blob
|
||||
// survives the save and resurrects the deleted constraints on the
|
||||
// next load.
|
||||
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),
|
||||
)));
|
||||
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None);
|
||||
scene.materialize_sketch_constraints_for_save();
|
||||
|
||||
let owner = scene.document.header.model_space_block_handle;
|
||||
assert!(scene.document.xrecord(owner, XRECORD_KEY).is_some(), "sanity: the first save should have materialized an XRecord");
|
||||
|
||||
// Clear every constraint (mirrors deleting the constrained entity, or
|
||||
// removing the last constraint by hand) and save again.
|
||||
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).constraints.clear();
|
||||
scene.materialize_sketch_constraints_for_save();
|
||||
|
||||
assert!(scene.document.xrecord(owner, XRECORD_KEY).is_none(), "the stale XRecord from the earlier non-empty save must be removed, not left behind");
|
||||
|
||||
// And the load path must agree: nothing resurrects on reload.
|
||||
scene.sketch_constraints.clear();
|
||||
scene.load_sketch_constraints_from_document();
|
||||
assert!(scene.sketch_constraint_set(SketchScope::ModelSpace).is_none(), "no constraints should come back after the set was cleared and re-saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_a_mismatched_format_version() {
|
||||
let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user