From e6c132e0ebb5c533a7f8570cf990822b33170551 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:16:27 +0200 Subject: [PATCH] ocs_doc_api: Phase 4 set_view + viewport-view query - SetViewportView op + GetViewportView query (append-only). - backend set_viewport_view (update Viewport.view_target/view_height) + viewport_view. - facade Entity::set_view/viewport_view (Viewport returns Entity). - spec Viewport methods; api_reference + binding_schema regenerated; mocks + baseline. - Test phase4_set_view_and_view_query -> 609 host lib + 3 consistency green. - Phase 4 complete. --- crates/ocs_doc_api/IMPLEMENTATION.md | 6 +- crates/ocs_doc_api/examples/common/mod.rs | 18 +++++- crates/ocs_doc_api/spec/entities.toml | 13 +++++ crates/ocs_doc_api/src/backend.rs | 6 ++ crates/ocs_doc_api/src/executor.rs | 14 +++++ crates/ocs_doc_api/src/facade.rs | 12 ++++ crates/ocs_doc_api/src/gen/api_reference.md | 5 ++ .../ocs_doc_api/src/gen/binding_schema.json | 34 ++++++++++- crates/ocs_doc_api/src/gen/ops_gen.rs | 2 + crates/ocs_doc_api/src/gen/query_gen.rs | 2 + crates/ocs_doc_api/src/ops.rs | 1 + crates/ocs_doc_api/src/query.rs | 3 + crates/ocs_doc_api/tests/mock_backend.rs | 18 +++++- .../tests/spec_wire_consistency.rs | 4 ++ src/app/doc_api.rs | 56 +++++++++++++++++++ 15 files changed, 187 insertions(+), 7 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index a330db132..653ca79b0 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -91,8 +91,10 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal - **Done:** `create_viewport(center, width, height, view_target, view_height)` → `Operation::CreateViewport`; `bounds()` = center ± width/2,height/2; `transform` moves the paper-space center. Covered by `phase4_*` test. -- **Remaining:** `set_view` (retarget/re-zoom an existing viewport) and a - viewport-view query (view target/center/scale DTOs). +- **Done (set_view + view query):** `set_view(view_target, view_height)` retargets/ + re-zooms in place (`SetViewportView` op), `viewport_view()` reads the view back + (`GetViewportView` query → target + zoom height). Covered by + `phase4_set_view_and_view_query` test. ### Phase 5 — media & misc (read-mostly) - **Done:** media/misc families report a **real `kind`** via `GetEntity` diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index d6a5c7900..da2614520 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -29,6 +29,8 @@ pub struct MockBackend { dimension_measurements: HashMap, /// Stored insert attributes. attributes_store: HashMap>, + /// Stored viewport views (target, height). + viewport_views: HashMap, } impl MockBackend { @@ -138,8 +140,10 @@ impl DocApiBackend for MockBackend { } Ok(self.alloc("Insert")) } - fn add_viewport(&mut self, _spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { - Ok(self.alloc("Viewport")) + fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { + let id = self.alloc("Viewport"); + self.viewport_views.insert(id, (spec.view_target, spec.view_height)); + Ok(id) } fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { let id = self.alloc("Text"); @@ -200,6 +204,16 @@ impl DocApiBackend for MockBackend { fn block_entities(&self, _block_name: &str) -> ApiResult> { Ok(Vec::new()) } + fn set_viewport_view(&mut self, id: ObjectId, view_target: [f64; 3], view_height: f64) -> ApiResult<()> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + self.viewport_views.insert(id, (view_target, view_height)); + Ok(()) + } + fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> { + self.viewport_views.get(&id).copied().ok_or(ApiError::UnknownId(id)) + } } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index 174f5a253..f28d8df80 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -375,6 +375,19 @@ name = "create_viewport" args = [{ name = "center", ty = "[f64; 3]" }, { name = "width", ty = "f64" }, { name = "height", ty = "f64" }, { name = "view_target", ty = "[f64; 3]" }, { name = "view_height", ty = "f64" }] op = "CreateViewport" +[[family.method]] +name = "viewport_view" +kind = "query" +query = "GetViewportView" +returns = "([f64; 3], f64)" + +[[family.method]] +name = "set_view" +kind = "op" +op = "SetViewportView" +args = [{ name = "view_target", ty = "[f64; 3]" }, { name = "view_height", ty = "f64" }] +returns = "()" + # ============================================================================ # MEDIA & MISC (phase 5, read-mostly) — read-only EntityView, no typed create. # These families report a real `kind` via GetEntity and coarse bounds where diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 13d09461b..edca3215f 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -75,6 +75,12 @@ pub trait DocApiBackend { /// The entities inside a block definition (read-only traversal), as views. fn block_entities(&self, block_name: &str) -> ApiResult>; + /// Set a viewport's view target + zoom height in place. Viewport-only. + fn set_viewport_view(&mut self, id: ObjectId, view_target: [f64; 3], view_height: f64) -> ApiResult<()>; + + /// A viewport's view (target WCS + zoom height). Viewport-only. + fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)>; + /// Can `id` be modified in place right now (exists, is the expected family, /// not on a locked layer)? Read-only pre-check used before mutations. /// Default: existence + not-locked (backends narrow the family check). diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index f8cfa8962..319339b6e 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -237,6 +237,16 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + b.can_modify(*id).map_err(|e| match e { + ApiError::UnknownId(_) => ApiError::validation(name, format!("unknown ObjectId {id:?}")), + other => other, + })?; + b.push_undo(name); + b.set_viewport_view(*id, *view_target, *view_height)?; + b.finalize_op(); + OpOutcome::Updated(*id) + } }; Ok(Receipt { outcome: Some(outcome), @@ -279,6 +289,10 @@ pub fn apply_queries(b: &mut B, queries: Vec) -> ApiRes Query::GetDimensionMeasurement { id } => QueryResult::DimensionMeasurement(b.dimension_measurement(*id)?), Query::GetAttributes { id } => QueryResult::Attributes(b.attributes(*id)?), Query::GetBlockEntities { block_name } => QueryResult::BlockEntities(b.block_entities(block_name)?), + Query::GetViewportView { id } => { + let (target, height) = b.viewport_view(*id)?; + QueryResult::ViewportView { target, height } + } }; results.push(r); } diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index e0e7648eb..2912d5905 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -309,6 +309,18 @@ impl Entity { self.session.apply_op(Operation::SetAttribute { id: self.id, tag: tag.to_string(), value: value.to_string() })?; Ok(()) } + /// This viewport's view (target WCS + zoom height). Viewport-only. + pub fn viewport_view(&self) -> ApiResult<([f64; 3], f64)> { + match self.session.one_query(Query::GetViewportView { id: self.id })? { + QueryResult::ViewportView { target, height } => Ok((target, height)), + _ => Err(ApiError::Transport("unexpected viewport-view result".into())), + } + } + /// Retarget / re-zoom this viewport (one undo step). Viewport-only. + pub fn set_view(&self, view_target: [f64; 3], view_height: f64) -> ApiResult<()> { + self.session.apply_op(Operation::SetViewportView { id: self.id, view_target, view_height })?; + Ok(()) + } pub fn as_solid(&self) -> Option { // Typed downcast is validated by the view's kind. matches!(self.view().ok()?.kind.as_str(), "Solid3D").then(|| Solid::new(self.session.clone(), self.id)) diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 5a0dbdb06..25e7cad9a 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -158,6 +158,11 @@ Paper-space viewport: a width×height window at center looking at view_target wi - **create_viewport**(`center: [f64; 3], width: f64, height: f64, view_target: [f64; 3], view_height: f64`) -> `Entity` +### Methods + +- **viewport_view**(``) -> `([f64; 3], f64)` — query `GetViewportView` +- **set_view**(`view_target: [f64; 3], view_height: f64`) -> `()` — op `SetViewportView` + ## `Entity` (acadrust `RasterImage`, collection `entities`) Raster image (read-mostly): GetEntity kind + coarse bounds (insertion + u*width + v*height); generic delete. No typed create in v1. diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index cb9d1e307..a405f67cb 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -339,7 +339,39 @@ "acadrust_variant": "Viewport", "collection": "entities", "handle": "Entity", - "methods": [], + "methods": [ + { + "args": [], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "viewport_view", + "op": null, + "query": "GetViewportView", + "read_only": true, + "returns": "([f64; 3], f64)" + }, + { + "args": [ + { + "name": "view_target", + "ty": "[f64; 3]" + }, + { + "name": "view_height", + "ty": "f64" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "set_view", + "op": "SetViewportView", + "query": null, + "read_only": false, + "returns": "()" + } + ], "name": "Viewport" }, { diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index f0ddb86d9..682db65ab 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -42,4 +42,6 @@ pub enum Operation { CreateDimensionLinear(DimensionSpec), // attributes SetAttribute { id: ObjectId, tag: String, value: String }, + // viewport view + SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }, } diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs index 531febeed..ffeefb046 100644 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ b/crates/ocs_doc_api/src/gen/query_gen.rs @@ -30,4 +30,6 @@ pub enum Query { GetAttributes { id: ObjectId }, /// The entity ids + kinds inside a block definition (read-only traversal). GetBlockEntities { block_name: String }, + /// A viewport's view (target + zoom height). + GetViewportView { id: ObjectId }, } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index eef7025ea..3e139ce2e 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -170,6 +170,7 @@ impl crate::gen::Operation { CreateHatch(_) => "CreateHatch", CreateDimensionLinear(_) => "CreateDimensionLinear", SetAttribute { .. } => "SetAttribute", + SetViewportView { .. } => "SetViewportView", } } } diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs index b2103b025..4b9f82076 100644 --- a/crates/ocs_doc_api/src/query.rs +++ b/crates/ocs_doc_api/src/query.rs @@ -53,6 +53,8 @@ pub enum QueryResult { Attributes(Vec<(String, String)>), /// The entities inside a block definition (read-only traversal). BlockEntities(Vec), + /// A viewport's view: target point (WCS) + zoom height. + ViewportView { target: [f64; 3], height: f64 }, } /// Convenience: the query name for diagnostics. @@ -71,6 +73,7 @@ impl crate::gen::Query { GetDimensionMeasurement { .. } => "GetDimensionMeasurement", GetAttributes { .. } => "GetAttributes", GetBlockEntities { .. } => "GetBlockEntities", + GetViewportView { .. } => "GetViewportView", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 743d4e9dd..e6f6c36a6 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -29,6 +29,8 @@ struct MockBackend { dimension_measurements: HashMap, /// Stored insert attributes. attributes_store: HashMap>, + /// Stored viewport views (target, height). + viewport_views: HashMap, } impl MockBackend { @@ -78,8 +80,10 @@ impl DocApiBackend for MockBackend { } Ok(self.alloc("Insert")) } - fn add_viewport(&mut self, _spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { - Ok(self.alloc("Viewport")) + fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { + let id = self.alloc("Viewport"); + self.viewport_views.insert(id, (spec.view_target, spec.view_height)); + Ok(id) } fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { let id = self.alloc("Text"); @@ -140,6 +144,16 @@ impl DocApiBackend for MockBackend { fn block_entities(&self, _block_name: &str) -> ApiResult> { Ok(Vec::new()) } + fn set_viewport_view(&mut self, id: ObjectId, view_target: [f64; 3], view_height: f64) -> ApiResult<()> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + self.viewport_views.insert(id, (view_target, view_height)); + Ok(()) + } + fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> { + self.viewport_views.get(&id).copied().ok_or(ApiError::UnknownId(id)) + } } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index 2abb5422c..1309a00bd 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -59,6 +59,7 @@ fn operation_variants() -> Vec { Operation::CreateHatch(ocs_doc_api::ops::HatchSpec { boundary: vec![], solid: true }), Operation::CreateDimensionLinear(ocs_doc_api::ops::DimensionSpec { first_point: [0.0; 3], second_point: [0.0; 3], definition_point: [0.0; 3] }), Operation::SetAttribute { id: ObjectId::from_u64(0), tag: String::new(), value: String::new() }, + Operation::SetViewportView { id: ObjectId::from_u64(0), view_target: [0.0; 3], view_height: 0.0 }, ]; // Derive the variant names by serializing a probe of each and reading the // bincode variant index -> name map is not available; instead match by @@ -88,6 +89,7 @@ fn query_variants() -> Vec { Query::GetDimensionMeasurement { id: ObjectId::from_u64(0) }, Query::GetAttributes { id: ObjectId::from_u64(0) }, Query::GetBlockEntities { block_name: String::new() }, + Query::GetViewportView { id: ObjectId::from_u64(0) }, ]; marker .iter() @@ -144,6 +146,7 @@ const OPERATION_BASELINE: &[&str] = &[ "CreateHatch", "CreateDimensionLinear", "SetAttribute", + "SetViewportView", ]; /// Recorded baseline of the `Query` variant order. @@ -159,6 +162,7 @@ const QUERY_BASELINE: &[&str] = &[ "GetDimensionMeasurement", "GetAttributes", "GetBlockEntities", + "GetViewportView", ]; #[test] diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index 32f7d257b..8575cad69 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -311,6 +311,28 @@ impl DocApiBackend for HostSession<'_> { Ok(out) } + fn set_viewport_view(&mut self, id: ObjectId, view_target: [f64; 3], view_height: f64) -> ApiResult<()> { + let handle = obj_to_handle(id); + let entity = self.document().get_entity(handle).cloned().ok_or(ApiError::UnknownId(id))?; + let EntityType::Viewport(mut vp) = entity else { + return Err(ApiError::Unsupported("SetViewportView is only for Viewport".into())); + }; + vp.view_target = acadrust::types::Vector3::new(view_target[0], view_target[1], view_target[2]); + vp.view_height = view_height; + if !self.scene_mut().update_entity(EntityType::Viewport(vp)) { + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); + } + Ok(()) + } + + fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> { + let entity = self.document().get_entity(obj_to_handle(id)).ok_or(ApiError::UnknownId(id))?; + let EntityType::Viewport(vp) = entity else { + return Err(ApiError::Unsupported("GetViewportView is only for Viewport".into())); + }; + Ok(([vp.view_target.x, vp.view_target.y, vp.view_target.z], vp.view_height)) + } + fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> { let handle = obj_to_handle(id); let Some(entity) = self.document().get_entity(handle).cloned() else { @@ -978,6 +1000,40 @@ mod tests { assert!((t.insertion_point.x - 15.0).abs() < 1e-6); } + // ── Phase 4 remaining: set_view + viewport-view query ──────────────────── + + #[test] + fn phase4_set_view_and_view_query() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let vp = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateViewport( + ocs_doc_api::ops::ViewportSpec { + center: [50.0, 50.0, 0.0], width: 40.0, height: 30.0, + view_target: [0.0, 0.0, 0.0], view_height: 100.0, + }))).unwrap()); + + // set_view retargets + re-zooms in place. + dispatch(&mut host, DocApiEnvelope::op(Operation::SetViewportView { + id: vp, view_target: [20.0, 30.0, 0.0], view_height: 50.0 })).unwrap(); + let Some(EntityType::Viewport(v)) = host.document().get_entity(obj_to_handle(vp)) else { panic!("viewport not found") }; + assert!((v.view_target.x - 20.0).abs() < 1e-6 && (v.view_height - 50.0).abs() < 1e-6); + + // viewport_view query reads it back. + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetViewportView { id: vp }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::ViewportView { target, height } => { + assert!((target[0] - 20.0).abs() < 1e-6 && (*height - 50.0).abs() < 1e-6); + } + other => panic!("expected viewport view, got {other:?}"), + } + + // set_view on a non-viewport is Unsupported. + let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Line { start: [0.0; 3], end: [1.0; 3] }))).unwrap()); + assert!(dispatch(&mut host, DocApiEnvelope::op(Operation::SetViewportView { + id: line, view_target: [0.0; 3], view_height: 1.0 })).is_err()); + } + // ── Phase 3 remaining: attributes + nested block traversal ────────────── #[test]