ocs_doc_api: fix review findings (scale/rotation/all-or-nothing/DoS/bounds/codegen)

CRITICAL:
- transform_entity_geometry: fix s^2 double-scale (axes already carry scale; drop
  the *s pre-multiply so 2D entities scale by s like solids), and handle rotation
  for direction/angle fields (apply_dir for Ray/XLine direction + Ellipse
  major_axis; offset Arc angles; compose Insert rotation).
- ensure_transformable: check layer-lock + resolve solid bodies (not just type),
  restoring TransformMany all-or-nothing; regression test with a locked layer.

WARNING:
- volume/centroid: memoize per (handle, geometry_epoch) + cap cold-cache
  tessellation budget (query-batch DoS).
- entity_to_profile_curves: fail Unsupported on polyline bulge (no silent
  chord-substituted sweep profile).
- Ellipse: validate minor_axis_ratio in (0,1] so bounds never under-bound.
- RasterImage bounds: bracket all four corners (rotated/flipped images).

SUGGESTION:
- binding_schema.json handles derived from spec families (includes ArcCurve/
  Ellipse/Spline/Ray/XLine).
- build.rs header: enums are hand-maintained (not 'GENERATED'); ops_gen/query_gen
  headers corrected to match.
- Dedupe divergence mesh_volume_centroid into the crate's geom module; host +
  both mocks delegate.

Tests: +3 regression tests (scale-once, rotation, locked-layer all-or-nothing)
-> 604 host lib green; 8 crate unit tests green.
This commit is contained in:
Sebastian committed 2026-09-06 08:36:06 +02:00
1 parent fabdeae8bf
commit c74504f30c
10 files changed
+271 -150

No files matched your search

+23 -4
View File
@@ -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<String> {
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).
+3 -20
View File
@@ -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)
}
@@ -265,7 +265,12 @@
"Line",
"Circle",
"Polyline",
"Point"
"Point",
"ArcCurve",
"Ellipse",
"Spline",
"Ray",
"XLine"
],
"root": "DocApi"
},
+5 -4
View File
@@ -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};
+4 -4
View File
@@ -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};
+25
View File
@@ -13,6 +13,31 @@ fn geom_err(kind: GeometryErrorKind, msg: impl Into<String>) -> 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> {
Plane::orthonormal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0])
+6 -51
View File
@@ -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<f64> {
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<ocs_doc_api::transport::InProcess<MockBackend>>) {
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");
}
+134 -47
View File
@@ -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<f64> {
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]
+59 -19
View File
@@ -30,8 +30,14 @@ pub fn entity_to_profile_curves(entity: &EntityType) -> ApiResult<Vec<cadkernel:
if pl.vertices.len() < 2 {
return Err(ApiError::validation("profile", "polyline profile needs >= 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<EntityType> {
// 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> {
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<Aab
],
},
EntityType::RasterImage(img) => {
// 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.
+6
View File
@@ -18,6 +18,10 @@ pub(crate) struct HostSession<'a> {
doc_store: Option<DocumentSnapshotStore<DocumentViewData>>,
/// In-flight DocApi v2 undo delta (between `push_undo` and `finalize_op`).
doc_api_pending: Option<super::history::PendingDelta>,
/// Memoized volume/centroid per (handle, geometry_epoch) for DocApi mass queries.
pub(crate) mass_cache: std::collections::HashMap<Handle, (u64, f64, [f64; 3])>,
/// 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,
}
}