diff --git a/crates/ocs_doc_api/build.rs b/crates/ocs_doc_api/build.rs index 008485158..084227843 100644 --- a/crates/ocs_doc_api/build.rs +++ b/crates/ocs_doc_api/build.rs @@ -1,7 +1,11 @@ //! Codegen driver (plan §10): reads `spec/entities.toml` and regenerates the -//! committed snapshots in `src/gen/` (ops/query enums, api_reference.md, -//! binding_schema.json). CI runs `cargo build` and fails on drift (the committed -//! files are the reviewable truth; build.rs rewrites them deterministically). +//! derived snapshots in `src/gen/` (`api_reference.md`, `binding_schema.json`). +//! CI runs `cargo build` and fails on drift for those two (the committed files +//! are the reviewable truth; build.rs rewrites them deterministically). +//! +//! NOTE: the `Operation`/`Query` wire enums (`ops_gen.rs`/`query_gen.rs`) are +//! HAND-MAINTAINED, append-only, and NOT derived here — the spec's `op`/`query` +//! names must map to enum variants (asserted by a test), never the reverse. //! //! The wire-layout tracing for `binding_schema.json` uses serde-reflection on the //! DTO payload types (same mechanism as `ocs_plugin_api`'s `type_registry.json`), @@ -264,6 +268,19 @@ fn api_reference_md(spec: &Spec) -> String { /// The merged, self-contained binding handover schema (§10.2). Object model + /// method signatures + op/query mapping + traced wire layouts inlined per node. +/// The handle types for the binding schema, derived from the spec's families +/// ("Entity" always first, then the deduped family handles in spec order). +fn handles_for_schema(spec: &Spec) -> Vec { + let mut out = vec!["Entity".to_string()]; + for fam in &spec.family { + let h = fam.handle.as_deref().unwrap_or(&fam.name); + if h != "Entity" && !out.iter().any(|x| x == h) { + out.push(h.to_string()); + } + } + out +} + fn binding_schema_json(spec: &Spec) -> String { // Trace the wire layouts of the DTO payload types via serde-reflection so // the inlined layouts are mechanically correct (mirrors the wire types). @@ -307,7 +324,9 @@ fn binding_schema_json(spec: &Spec) -> String { "root": "DocApi", "document": "Document", "collections": ["solids", "curves", "entities"], - "handles": ["Entity", "Solid", "Line", "Circle", "Polyline", "Point"], + // Derived from the spec's family handles (deduped, "Entity" first) so + // new handle families appear in the handover automatically. + "handles": handles_for_schema(spec), }, "families": families, // Traced wire layouts for every Operation/Query/payload DTO (inlined). diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index 8e129fdde..7156bd178 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -204,25 +204,8 @@ impl DocApiBackend for MockBackend { } } -/// Signed volume + centroid of a closed mesh via the divergence theorem. +/// Signed volume + centroid of a closed mesh — delegates to the crate's single +/// source of truth (divergence theorem) so the example mock can't drift. fn mesh_vc(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { - let mut vol = 0.0; - let mut c = [0.0; 3]; - for t in &mesh.triangles { - let (v0, v1, v2) = (mesh.positions[t[0]], mesh.positions[t[1]], mesh.positions[t[2]]); - let cross = [ - v1[1] * v2[2] - v1[2] * v2[1], - v1[2] * v2[0] - v1[0] * v2[2], - v1[0] * v2[1] - v1[1] * v2[0], - ]; - let tet = (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0; - vol += tet; - for i in 0..3 { - c[i] += (v0[i] + v1[i] + v2[i]) * tet; - } - } - if vol.abs() < 1e-12 { - return (0.0, [0.0; 3]); - } - (vol, [c[0] / (4.0 * vol), c[1] / (4.0 * vol), c[2] / (4.0 * vol)]) + ocs_doc_api::geom::mesh_volume_centroid(mesh) } diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 179c357d9..27cc042c9 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -265,7 +265,12 @@ "Line", "Circle", "Polyline", - "Point" + "Point", + "ArcCurve", + "Ellipse", + "Spline", + "Ray", + "XLine" ], "root": "DocApi" }, diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index 7657cb82c..685195f86 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -1,7 +1,8 @@ -// GENERATED by build.rs from spec/entities.toml — do NOT edit by hand. -// Committed snapshot for reviewability (plan §10); build.rs regenerates and CI -// fails on drift (`--check`). Append new variants at the END only (bincode -// discriminant stability, plan §7). +// HAND-MAINTAINED wire vocabulary (the Operation enum). This file is NOT derived +// from spec/entities.toml by build.rs — the enum is the canonical append-only +// wire vocabulary; the spec's `op` names must each map to a variant here +// (enforced by a test). Append new variants at the END only (bincode discriminant +// stability, plan §7). Do NOT reorder or remove variants. use serde::{Deserialize, Serialize}; diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs index 0d9832956..6341590b0 100644 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ b/crates/ocs_doc_api/src/gen/query_gen.rs @@ -1,7 +1,7 @@ -// GENERATED by build.rs from spec/entities.toml — do NOT edit by hand. -// Committed snapshot for reviewability (plan §10); build.rs regenerates and CI -// fails on drift (`--check`). Append new variants at the END only (bincode -// discriminant stability, plan §7). +// HAND-MAINTAINED wire vocabulary (the Query enum). NOT derived from the spec by +// build.rs — the enum is the canonical append-only wire vocabulary; the spec's +// `query` names must each map to a variant here (enforced by a test). Append new +// variants at the END only (bincode discriminant stability, plan §7). use serde::{Deserialize, Serialize}; diff --git a/crates/ocs_doc_api/src/geom.rs b/crates/ocs_doc_api/src/geom.rs index 0341d760b..92bac754d 100644 --- a/crates/ocs_doc_api/src/geom.rs +++ b/crates/ocs_doc_api/src/geom.rs @@ -13,6 +13,31 @@ fn geom_err(kind: GeometryErrorKind, msg: impl Into) -> ApiError { ApiError::geometry(kind, msg) } +/// Signed volume and centroid of a closed triangle mesh via the divergence +/// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source +/// of truth shared by the host backend and the test/example mock backends. +pub fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { + let mut vol = 0.0; + let mut c = [0.0; 3]; + for t in &mesh.triangles { + let (v0, v1, v2) = (mesh.positions[t[0]], mesh.positions[t[1]], mesh.positions[t[2]]); + let cross = [ + v1[1] * v2[2] - v1[2] * v2[1], + v1[2] * v2[0] - v1[0] * v2[2], + v1[0] * v2[1] - v1[1] * v2[0], + ]; + let tet = (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0; + vol += tet; + for i in 0..3 { + c[i] += (v0[i] + v1[i] + v2[i]) * tet; + } + } + if vol.abs() < 1e-12 { + return (0.0, [0.0; 3]); + } + (vol, [c[0] / (4.0 * vol), c[1] / (4.0 * vol), c[2] / (4.0 * vol)]) +} + /// The XY work plane (origin at 0, X=(1,0,0), normal=(0,0,1)). fn xy_plane() -> ApiResult { Plane::orthonormal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]) diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 99ad1b04a..3946add9f 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -1,5 +1,5 @@ -//! Unit tests over an in-memory mock `DocApiBackend` (plan §11.1): no host. -//! Covers the §9 examples: per-op atomicity, boolean erase_sources, OpGroup +//! Unit tests over an in-memory mock `DocApiBackend` (plan §11.1): no host. +//! Covers the §9 examples: per-op atomicity, boolean erase_sources, OpGroup //! compensation, bulk all-or-nothing, revision bumps, read-only query batching, //! and stale-handle `UnknownId` semantics. @@ -119,11 +119,11 @@ impl DocApiBackend for MockBackend { } fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { let body = self.body(id)?; - Ok(cadkernel::brep::mesh_body(body, 0.5, 1e-3).centroid_approx()) + Ok(ocs_doc_api::geom::mesh_volume_centroid(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).1) } fn volume(&mut self, id: ObjectId) -> ApiResult { let body = self.body(id)?; - Ok(cadkernel::brep::mesh_body(body, 0.5, 1e-3).volume_approx()) + Ok(ocs_doc_api::geom::mesh_volume_centroid(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).0) } fn entity_exists(&self, id: ObjectId) -> bool { self.kinds.contains_key(&id) @@ -138,51 +138,6 @@ impl DocApiBackend for MockBackend { } } -/// Approximate mesh volume/centroid for the mock (divergence over triangles). -trait MeshApprox { - fn volume_approx(&self) -> f64; - fn centroid_approx(&self) -> [f64; 3]; -} -impl MeshApprox for cadkernel::brep::Mesh { - fn volume_approx(&self) -> f64 { - let mut vol = 0.0; - for t in &self.triangles { - let v0 = self.positions[t[0]]; - let v1 = self.positions[t[1]]; - let v2 = self.positions[t[2]]; - let cross = [ - v1[1] * v2[2] - v1[2] * v2[1], - v1[2] * v2[0] - v1[0] * v2[2], - v1[0] * v2[1] - v1[1] * v2[0], - ]; - vol += (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0; - } - vol - } - fn centroid_approx(&self) -> [f64; 3] { - let mut vol = 0.0; - let mut c = [0.0; 3]; - for t in &self.triangles { - let v0 = self.positions[t[0]]; - let v1 = self.positions[t[1]]; - let v2 = self.positions[t[2]]; - let cross = [ - v1[1] * v2[2] - v1[2] * v2[1], - v1[2] * v2[0] - v1[0] * v2[2], - v1[0] * v2[1] - v1[1] * v2[0], - ]; - let tet = (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0; - vol += tet; - for i in 0..3 { - c[i] += (v0[i] + v1[i] + v2[i]) * tet; - } - } - if vol.abs() < 1e-12 { - return [0.0; 3]; - } - [c[0] / (4.0 * vol), c[1] / (4.0 * vol), c[2] / (4.0 * vol)] - } -} fn api() -> (DocApi, Arc>) { let tp = Arc::new(ocs_doc_api::transport::InProcess::new(MockBackend::default())); @@ -196,7 +151,7 @@ fn cuboid_intersect_cuboid_atomic_ops_and_revision() { let mut grp = ocs_doc_api::OpGroup::new(); let rev0 = doc.revision().unwrap(); - // Two overlapping boxes — the kernel's own boolean test case (boolean.rs pair()). + // Two overlapping boxes — the kernel's own boolean test case (boolean.rs pair()). let block = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0, 10.0, 10.0])).unwrap(); let other = grp.track(doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0, 10.0, 10.0])).unwrap(); assert_eq!(doc.revision().unwrap().as_u64(), rev0.as_u64() + 2); @@ -215,7 +170,7 @@ fn cuboid_intersect_cuboid_atomic_ops_and_revision() { .unwrap(); let bb = res.bounds(0).unwrap(); let vol = res.volume(1).unwrap(); - // Intersection of [0,10]³ ∩ [5,15]³ = [5,10]³ → bounds + volume 125. + // Intersection of [0,10]³ ∩ [5,15]³ = [5,10]³ → bounds + volume 125. assert!((bb.min[0] - 5.0).abs() < 1e-6 && (bb.max[0] - 10.0).abs() < 1e-6, "bounds {bb:?}"); assert!(vol > 0.0, "intersection volume must be positive"); } diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index bbb3ac1e1..bccae0bd6 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -180,11 +180,14 @@ impl DocApiBackend for HostSession<'_> { } fn ensure_transformable(&mut self, id: ObjectId) -> ApiResult<()> { - // Pre-validating this lets TransformMany be all-or-nothing. Solids and the - // supported 2D families are transformable; anything else errors up front. + // Pre-validating everything the apply loop can actually fail on is what makes + // TransformMany all-or-nothing (no mid-loop failure after earlier mutations). + // This must check more than the entity TYPE: layer-lock (update_entity returns + // false) and, for solids, that the body resolves. + let handle = obj_to_handle(id); let entity = self .document() - .get_entity(obj_to_handle(id)) + .get_entity(handle) .ok_or(ApiError::UnknownId(id))?; let ok = matches!( entity, @@ -201,11 +204,19 @@ impl DocApiBackend for HostSession<'_> { | EntityType::Point(_) | EntityType::LwPolyline(_) ); - if ok { - Ok(()) - } else { - Err(ApiError::Unsupported("transform is not supported for this entity family".into())) + if !ok { + return Err(ApiError::Unsupported("transform is not supported for this entity family".into())); } + // Locked layer -> update_entity would return false mid-loop. + if self.scene().is_layer_locked(handle) { + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); + } + // Solid: the body must resolve (kernel transform/display-prep can still fail + // at apply time, but resolution is the checkable precondition). + if matches!(entity, EntityType::Solid3D(_)) { + self.resolve_body(id)?; + } + Ok(()) } fn can_remove(&self, id: ObjectId) -> ApiResult<()> { @@ -283,30 +294,11 @@ impl DocApiBackend for HostSession<'_> { } fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { - let handle = obj_to_handle(id); - // Serve from the scene's render-mesh metrics cache when present (avoids a - // full body tessellation + B-rep clone per query, plan review); fall back - // to the kernel mesh when the cache is cold. - if let Some(metrics) = self.scene().meshes.get(&handle).map(|m| m.metrics) { - if metrics.volume.abs() > 0.0 { - return Ok(metrics.centroid); - } - } - let body = self.resolve_body(id)?; - let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3); - Ok(mesh_volume_centroid(&mesh).1) + Ok(self.mass_properties(id)?.1) } fn volume(&mut self, id: ObjectId) -> ApiResult { - let handle = obj_to_handle(id); - if let Some(metrics) = self.scene().meshes.get(&handle).map(|m| m.metrics) { - if metrics.volume.abs() > 0.0 { - return Ok(metrics.volume); - } - } - let body = self.resolve_body(id)?; - let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3); - Ok(mesh_volume_centroid(&mesh).0) + Ok(self.mass_properties(id)?.0) } fn entity_exists(&self, id: ObjectId) -> bool { @@ -331,6 +323,43 @@ impl DocApiBackend for HostSession<'_> { // ── helpers ────────────────────────────────────────────────────────────────── +impl HostSession<'_> { + /// Volume + centroid for a solid, memoized per (handle, geometry_epoch) so + /// repeated queries on the SAME solid are O(1) (the common query-batch DoS). + /// Served from the render-mesh metrics cache when present; otherwise tessellates + /// the body ONCE per epoch and caches it. A per-session cold-tessellation + /// budget bounds pathological batches of many distinct cold solids. + fn mass_properties(&mut self, id: ObjectId) -> ApiResult<(f64, [f64; 3])> { + let handle = obj_to_handle(id); + let epoch = self.scene().geometry_epoch; + if let Some(&(cached_epoch, v, c)) = self.mass_cache.get(&handle) { + if cached_epoch == epoch { + return Ok((v, c)); + } + } + // Render-mesh metrics cache (avoids re-tessellation + B-rep clone). + if let Some(metrics) = self.scene().meshes.get(&handle).map(|m| m.metrics) { + if metrics.volume.abs() > 0.0 { + self.mass_cache.insert(handle, (epoch, metrics.volume, metrics.centroid)); + return Ok((metrics.volume, metrics.centroid)); + } + } + // Cold-cache fallback: bounded full tessellation. + const COLD_TESS_BUDGET: usize = 256; + if self.cold_tess_used >= COLD_TESS_BUDGET { + return Err(ApiError::Unsupported( + "volume/centroid cold-tessellation budget exceeded for this session; regenerate the mesh or query fewer distinct solids".into(), + )); + } + self.cold_tess_used += 1; + let body = self.resolve_body(id)?; + let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3); + let (v, c) = mesh_volume_centroid(&mesh); + self.mass_cache.insert(handle, (epoch, v, c)); + Ok((v, c)) + } +} + fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement { cadkernel::brep::Placement { x_axis: p.x_axis, @@ -344,25 +373,8 @@ fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement { /// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source /// of truth for both (used by the cold-cache `volume`/`centroid` fallback). fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { - let mut vol = 0.0; - let mut c = [0.0; 3]; - for t in &mesh.triangles { - let (v0, v1, v2) = (mesh.positions[t[0]], mesh.positions[t[1]], mesh.positions[t[2]]); - let cross = [ - v1[1] * v2[2] - v1[2] * v2[1], - v1[2] * v2[0] - v1[0] * v2[2], - v1[0] * v2[1] - v1[1] * v2[0], - ]; - let tet = (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0; - vol += tet; - for i in 0..3 { - c[i] += (v0[i] + v1[i] + v2[i]) * tet; - } - } - if vol.abs() < 1e-12 { - return (0.0, [0.0; 3]); - } - (vol, [c[0] / (4.0 * vol), c[1] / (4.0 * vol), c[2] / (4.0 * vol)]) + // Delegate to the crate's single source of truth (divergence theorem). + ocs_doc_api::geom::mesh_volume_centroid(mesh) } #[cfg(test)] @@ -679,6 +691,81 @@ mod tests { assert!((v.center.x - 60.0).abs() < 1e-6); } + // ── Regression tests for review fixes (s^2 scale, rotation, all-or-nothing) ── + + #[test] + fn fix_uniform_scale_applied_once_not_squared() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let circle = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Circle { centre: [0.0, 0.0, 0.0], radius: 1.0 }))).unwrap()); + // Uniform scale by 2 (x_axis=(2,0,0) etc). Radius must become 2 (s), not 4 (s^2). + let placement = ocs_doc_api::PlacementSpec { + x_axis: [2.0, 0.0, 0.0], y_axis: [0.0, 2.0, 0.0], z_axis: [0.0, 0.0, 2.0], origin: [0.0; 3], + }; + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { id: circle, placement })).unwrap(); + let handle = obj_to_handle(circle); + let Some(EntityType::Circle(c)) = host.document().get_entity(handle) else { panic!("circle not found") }; + assert!((c.radius - 2.0).abs() < 1e-9, "radius {} (must be 2.0, not 4.0)", c.radius); + } + + #[test] + fn fix_rotation_rotates_ellipse_ray_and_insert() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // 90-degree Z-rotation: x_axis=(0,1,0), y_axis=(-1,0,0). + let rot90 = ocs_doc_api::PlacementSpec { + x_axis: [0.0, 1.0, 0.0], y_axis: [-1.0, 0.0, 0.0], z_axis: [0.0, 0.0, 1.0], origin: [0.0; 3], + }; + // Ray along +X must become along +Y after a 90° rotation. + let ray = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Ray { origin: [0.0, 0.0, 0.0], direction: [1.0, 0.0, 0.0] }))).unwrap()); + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { id: ray, placement: rot90 })).unwrap(); + let Some(EntityType::Ray(r)) = host.document().get_entity(obj_to_handle(ray)) else { panic!("ray not found") }; + assert!((r.direction.y - 1.0).abs() < 1e-6 && r.direction.x.abs() < 1e-6, "ray dir {:?}", r.direction); + + // Ellipse major_axis along +X must rotate to +Y (length preserved). + let ell = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Ellipse { centre: [0.0; 3], major_axis: [4.0, 0.0, 0.0], ratio: 0.5, start: 0.0, end: std::f64::consts::TAU }))).unwrap()); + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { id: ell, placement: rot90 })).unwrap(); + let Some(EntityType::Ellipse(e)) = host.document().get_entity(obj_to_handle(ell)) else { panic!("ellipse not found") }; + assert!((e.major_axis.y - 4.0).abs() < 1e-6 && e.major_axis.x.abs() < 1e-6, "ellipse axis {:?}", e.major_axis); + + // Arc angles offset by +90° (π/2). + let arc = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Arc { centre: [0.0; 3], radius: 2.0, start_angle: 0.0, end_angle: 1.0 }))).unwrap()); + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { id: arc, placement: rot90 })).unwrap(); + let Some(EntityType::Arc(a)) = host.document().get_entity(obj_to_handle(arc)) else { panic!("arc not found") }; + let half_pi = std::f64::consts::FRAC_PI_2; + assert!((a.start_angle - half_pi).abs() < 1e-6, "arc start {} (expected {})", a.start_angle, half_pi); + } + + #[test] + fn fix_transform_many_locked_layer_fails_all_or_nothing() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let a = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Point { position: [0.0; 3] }))).unwrap()); + let b = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Point { position: [1.0; 3] }))).unwrap()); + // Lock the layer holding `b`. + let bh = obj_to_handle(b); + let layer = host.document().get_entity(bh).unwrap().common().layer.clone(); + host.document_mut().layers.get_mut(&layer).unwrap().lock(); + let rev_before = host.scene().geometry_epoch; + + // TransformMany [a, b] must fail all-or-nothing (b is locked) BEFORE mutating a. + let err = dispatch(&mut host, DocApiEnvelope::op(Operation::TransformMany { + ids: vec![a, b], + placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]), + })).unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. } | ApiError::Unsupported(_) | ApiError::UnknownId(_)), "{err:?}"); + // `a` was NOT transformed (all-or-nothing): its location is unchanged. + let Some(EntityType::Point(pa)) = host.document().get_entity(obj_to_handle(a)) else { panic!("point a not found") }; + assert!((pa.location.x - 0.0).abs() < 1e-9, "a moved despite locked batch member"); + assert_eq!(host.scene().geometry_epoch, rev_before, "no mutation on rejected TransformMany"); + } + // ── Phase 5: media & misc (read-mostly) ────────────────────────────────── #[test] diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index 38bea725c..ac5f6a084 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -30,8 +30,14 @@ pub fn entity_to_profile_curves(entity: &EntityType) -> ApiResult= 2 vertices")); } - // Build a straight-segment polyline chain (bulge arcs land in a later - // phase; straight segments cover the common rectangular profile case). + // Fail explicitly on bulge arcs rather than silently producing a + // chord-substituted (wrong-shape) profile for Extrude/Revolve. + if pl.vertices.iter().any(|v| v.bulge != 0.0) { + return Err(ApiError::Unsupported( + "polyline profiles with bulge (arc) segments are not yet supported for sweep ops".into(), + )); + } + // Build a straight-segment polyline chain. let pts: Vec<[f64; 2]> = pl.vertices.iter().map(|v| [v.location.x, v.location.y]).collect(); let mut segs = Vec::with_capacity(pts.len()); for i in 0..pts.len() { @@ -64,16 +70,26 @@ pub fn transform_entity_geometry( entity: &EntityType, p: &ocs_doc_api::PlacementSpec, ) -> ApiResult { - // Affine point transform: out = R * (s * v) + t. Uniform scale s = |x_axis|. + // Placement = R·S·v + t where the axis COLUMNS carry rotation AND uniform + // scale (|x_axis| = s). A point maps v -> axes·v + origin (axes already embed + // s — do NOT pre-multiply by s or scale is applied twice). A direction maps + // v -> axes·v (no origin). Scalar fields (radius) scale by s = |x_axis|. let s = (p.x_axis[0] * p.x_axis[0] + p.x_axis[1] * p.x_axis[1] + p.x_axis[2] * p.x_axis[2]).sqrt(); + // Z-rotation of the placement in the XY plane (for arc angles / insert rotation). + let rot_z = p.x_axis[1].atan2(p.x_axis[0]); let apply = |v: Vector3| -> Vector3 { - let x = v.x * s; - let y = v.y * s; - let z = v.z * s; Vector3::new( - p.x_axis[0] * x + p.y_axis[0] * y + p.z_axis[0] * z + p.origin[0], - p.x_axis[1] * x + p.y_axis[1] * y + p.z_axis[1] * z + p.origin[1], - p.x_axis[2] * x + p.y_axis[2] * y + p.z_axis[2] * z + p.origin[2], + p.x_axis[0] * v.x + p.y_axis[0] * v.y + p.z_axis[0] * v.z + p.origin[0], + p.x_axis[1] * v.x + p.y_axis[1] * v.y + p.z_axis[1] * v.z + p.origin[1], + p.x_axis[2] * v.x + p.y_axis[2] * v.y + p.z_axis[2] * v.z + p.origin[2], + ) + }; + // Direction/vector transform: rotation+scale only, NO translation. + let apply_dir = |v: Vector3| -> Vector3 { + Vector3::new( + p.x_axis[0] * v.x + p.y_axis[0] * v.y + p.z_axis[0] * v.z, + p.x_axis[1] * v.x + p.y_axis[1] * v.y + p.z_axis[1] * v.z, + p.x_axis[2] * v.x + p.y_axis[2] * v.y + p.z_axis[2] * v.z, ) }; let entity = match entity { @@ -93,15 +109,16 @@ pub fn transform_entity_geometry( let mut a = a.clone(); a.center = apply(a.center); a.radius *= s; + // A Z-rotation shifts both angles by the placement's XY rotation. + a.start_angle += rot_z; + a.end_angle += rot_z; EntityType::Arc(a) } EntityType::Ellipse(e) => { let mut e = e.clone(); e.center = apply(e.center); - // Scale the major-axis vector by the uniform scale (rigid similarity). - e.major_axis = Vector3::new( - e.major_axis.x * s, e.major_axis.y * s, e.major_axis.z * s, - ); + // The major-axis is a direction+length vector: rotate+scale it (no origin). + e.major_axis = apply_dir(e.major_axis); EntityType::Ellipse(e) } EntityType::Spline(sp) => { @@ -117,16 +134,20 @@ pub fn transform_entity_geometry( EntityType::Ray(r) => { let mut r = r.clone(); r.base_point = apply(r.base_point); + r.direction = apply_dir(r.direction); EntityType::Ray(r) } EntityType::XLine(x) => { let mut x = x.clone(); x.base_point = apply(x.base_point); + x.direction = apply_dir(x.direction); EntityType::XLine(x) } EntityType::Insert(ins) => { let mut ins = ins.clone(); ins.insert_point = apply(ins.insert_point); + // Compose the block rotation with the placement's Z-rotation. + ins.rotation += rot_z; EntityType::Insert(ins) } EntityType::Viewport(vp) => { @@ -206,6 +227,14 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { EntityType::Arc(e) } Curve2Spec::Ellipse { centre, major_axis, ratio, start, end } => { + // Validate the minor/major ratio so the (major-axis) bounds never + // under-bound: ratio must be in (0, 1]. + if !(*ratio > 0.0 && *ratio <= 1.0) { + return Err(ApiError::validation( + "CreateCurve", + format!("ellipse minor_axis_ratio must be in (0, 1], got {ratio}"), + )); + } let mut e = Ellipse::new(); e.center = v3(*centre); e.major_axis = v3(*major_axis); @@ -344,13 +373,24 @@ pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { - // Coarse image rect: origin + u*width + v*height (in world units). - let w = img.u_vector.x * img.size.x + img.v_vector.x * img.size.y; - let h = img.u_vector.y * img.size.x + img.v_vector.y * img.size.y; - let (x0, y0) = (img.insertion_point.x, img.insertion_point.y); + // Bracket ALL FOUR image corners (origin, +u*W, +v*H, +u*W+v*H) per + // axis so rotated/flipped images are not under-bounded. + let (w, h) = (img.size.x, img.size.y); + let ux = img.u_vector.x * w; + let uy = img.u_vector.y * w; + let vx = img.v_vector.x * h; + let vy = img.v_vector.y * h; + let x0 = img.insertion_point.x; + let y0 = img.insertion_point.y; + let xs = [x0, x0 + ux, x0 + vx, x0 + ux + vx]; + let ys = [y0, y0 + uy, y0 + vy, y0 + uy + vy]; + let min_x = xs.iter().cloned().fold(f64::INFINITY, f64::min); + let max_x = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let min_y = ys.iter().cloned().fold(f64::INFINITY, f64::min); + let max_y = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max); Aabb { - min: [x0.min(x0 + w), y0.min(y0 + h), img.insertion_point.z], - max: [x0.max(x0 + w), y0.max(y0 + h), img.insertion_point.z], + min: [min_x, min_y, img.insertion_point.z], + max: [max_x, max_y, img.insertion_point.z], } } // Ray/XLine are unbounded by definition — no finite bounds. diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 3e5977de2..dca04abb2 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -18,6 +18,10 @@ pub(crate) struct HostSession<'a> { doc_store: Option>, /// In-flight DocApi v2 undo delta (between `push_undo` and `finalize_op`). doc_api_pending: Option, + /// Memoized volume/centroid per (handle, geometry_epoch) for DocApi mass queries. + pub(crate) mass_cache: std::collections::HashMap, + /// Per-session count of cold-cache mass tessellations (budget for query DoS). + pub(crate) cold_tess_used: usize, } impl<'a> HostSession<'a> { @@ -27,6 +31,8 @@ impl<'a> HostSession<'a> { tab, doc_store: None, doc_api_pending: None, + mass_cache: std::collections::HashMap::new(), + cold_tess_used: 0, } }