diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 31bf0bcda..91dc6db3c 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -68,10 +68,11 @@ variants append at enum end. get/set; read-only nested block-content traversal. ### Phase 4 — paper-space & viewports -- **Entities:** viewport entities. -- **Methods:** `create_viewport`, set-view (target, center, scale), layout - queries. -- **Blocked by:** a viewport query model (view target/center/scale DTOs). +- **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). ### Phase 5 — media & misc (read-mostly) - **Entities:** raster image, underlay, table, light. diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index e9ec290ab..d01a2aa43 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -105,6 +105,9 @@ 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_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index e30563518..7b8de56ed 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -252,6 +252,21 @@ name = "create_insert" args = [{ name = "block_name", ty = "&str" }, { name = "insert_point", ty = "[f64; 3]" }, { name = "scale", ty = "f64" }, { name = "rotation", ty = "f64" }] op = "CreateInsert" +# ============================================================================ +# PAPER-SPACE FAMILY (phase 4) — viewports +# ============================================================================ +[[family]] +name = "Viewport" +acadrust_variant = "Viewport" +handle = "Entity" +collection = "entities" +description = "Paper-space viewport: a width×height window at center looking at view_target with view_height zoom. bounds = center ± w/2,h/2; transform moves center." + +[[family.constructor]] +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" + # ============================================================================ # Cross-cutting ops (generic handles + bulk) — documented in build.rs # ============================================================================ diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 9be15e0ba..5fc03379e 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -39,6 +39,9 @@ pub trait DocApiBackend { /// the fresh `ObjectId`. `Validation` if the block is unknown. fn add_insert(&mut self, spec: &crate::ops::InsertSpec) -> ApiResult; + /// Add a paper-space `VIEWPORT`; returns the fresh `ObjectId`. + fn add_viewport(&mut self, spec: &crate::ops::ViewportSpec) -> ApiResult; + /// Remove any first-level entity by id. Returns `false` if absent; returns /// `Err` if the entity exists but cannot be removed (e.g. locked layer). fn remove_entity(&mut self, id: ObjectId) -> ApiResult; diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index 4fe69917f..5267229b3 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -183,6 +183,12 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + b.push_undo(name); + let id = b.add_viewport(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } }; Ok(Receipt { outcome: Some(outcome), diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index ea4f73d32..02bb6bbf0 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -464,6 +464,19 @@ pub struct EntityCollection { } impl EntityCollection { + /// Create a paper-space `VIEWPORT` (a `width`×`height` viewport at `center` + /// looking at `view_target` with `view_height` zoom). + pub fn create_viewport(&self, center: [f64; 3], width: f64, height: f64, view_target: [f64; 3], view_height: f64) -> ApiResult { + let receipt = self.session.apply_op(Operation::CreateViewport(crate::ops::ViewportSpec { + center, width, height, view_target, view_height, + }))?; + let id = receipt + .outcome + .and_then(|o| o.new_id()) + .ok_or_else(|| ApiError::Transport("create_viewport returned no id".into()))?; + Ok(Entity::new(self.session.clone(), id)) + } + /// Place a block reference (`INSERT`) for `block_name` (must exist). pub fn create_insert(&self, block_name: &str, insert_point: [f64; 3], scale: f64, rotation: f64) -> ApiResult { let receipt = self.session.apply_op(Operation::CreateInsert(crate::ops::InsertSpec { diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 201f9e5ac..0447dbbbb 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -95,6 +95,14 @@ Block reference. Place an existing BlockRecord by name; transform moves its inse - **create_insert**(`block_name: &str, insert_point: [f64; 3], scale: f64, rotation: f64`) -> `Entity` +## `Entity` (acadrust `Viewport`, collection `entities`) + +Paper-space viewport: a width×height window at center looking at view_target with view_height zoom. bounds = center ± w/2,h/2; transform moves center. + +### Constructors (`doc.entities()`) + +- **create_viewport**(`center: [f64; 3], width: f64, height: f64, view_target: [f64; 3], view_height: f64`) -> `Entity` + ## Generic methods (every handle) Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `Entity`) is a diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 611ca78ca..5999bc28a 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -194,6 +194,13 @@ "handle": "Entity", "methods": [], "name": "Insert" + }, + { + "acadrust_variant": "Viewport", + "collection": "entities", + "handle": "Entity", + "methods": [], + "name": "Viewport" } ], "layouts": { diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index 3e7883f3a..7657cb82c 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::id::ObjectId; -use crate::ops::{BoolOp, Curve2Spec, EntitySpec, InsertSpec, PlacementSpec, SolidPrimitive}; +use crate::ops::{BoolOp, Curve2Spec, EntitySpec, InsertSpec, PlacementSpec, SolidPrimitive, ViewportSpec}; /// A typed write operation (plan §5). Each variant is ONE atomic host call = one /// undo step. Append new variants at the END only. @@ -29,4 +29,6 @@ pub enum Operation { DeleteMany(Vec), // containers CreateInsert(InsertSpec), + // paper-space + CreateViewport(ViewportSpec), } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index f6ca50ecd..8d3566059 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -67,6 +67,18 @@ pub struct InsertSpec { pub rotation: f64, } +/// Construction spec for a paper-space `VIEWPORT`: a `width`×`height` viewport +/// centered at `center` (paper space), looking at `view_target` (model space, +/// WCS) with `view_height` (model-space height visible; the viewport's zoom). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ViewportSpec { + pub center: [f64; 3], + pub width: f64, + pub height: f64, + pub view_target: [f64; 3], + pub view_height: f64, +} + /// A rigid similarity (rotation + translation + optional uniform scale/reflection) /// as a plain-data mirror of `cadkernel::brep::transform`'s `Placement` /// (non-serde). Columns are the new basis axes; `origin` is the translation. @@ -115,6 +127,7 @@ impl crate::gen::Operation { TransformMany { .. } => "TransformMany", DeleteMany(_) => "DeleteMany", CreateInsert(_) => "CreateInsert", + CreateViewport(_) => "CreateViewport", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 5de13c856..695539c61 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -65,6 +65,9 @@ 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_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index e6faf7db2..7935c667e 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -125,6 +125,20 @@ impl DocApiBackend for HostSession<'_> { Ok(handle_to_obj(handle)) } + fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { + use acadrust::entities::Viewport; + use acadrust::types::Vector3; + let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); + let mut vp = Viewport::new(); + vp.center = v3(spec.center); + vp.width = spec.width; + vp.height = spec.height; + vp.view_target = v3(spec.view_target); + vp.view_height = spec.view_height; + let handle = self.scene_mut().add_entity(EntityType::Viewport(vp)); + Ok(handle_to_obj(handle)) + } + 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 { @@ -183,6 +197,7 @@ impl DocApiBackend for HostSession<'_> { | EntityType::Ray(_) | EntityType::XLine(_) | EntityType::Insert(_) + | EntityType::Viewport(_) | EntityType::Point(_) | EntityType::LwPolyline(_) ); @@ -626,6 +641,44 @@ mod tests { assert!((r.base_point.z - 5.0).abs() < 1e-6); } + // ── Phase 4: paper-space viewports ─────────────────────────────────────── + + #[test] + fn phase4_create_viewport_bounds_transform() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // A 40x30 paper-space viewport at (50,50,0) looking at model origin. + 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()); + let handle = obj_to_handle(vp); + let Some(EntityType::Viewport(v)) = host.document().get_entity(handle) else { panic!("viewport not found") }; + assert!((v.width - 40.0).abs() < 1e-9 && (v.view_height - 100.0).abs() < 1e-9); + + // Bounds = center ± width/2, height/2. + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetEntity { id: vp }, Query::GetBounds { id: vp }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(e) => assert_eq!(e.kind, "Viewport"), + other => panic!("expected entity, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Bounds(b) => { + assert!((b.min[0] - 30.0).abs() < 1e-6 && (b.max[0] - 70.0).abs() < 1e-6, "{b:?}"); + assert!((b.min[1] - 35.0).abs() < 1e-6 && (b.max[1] - 65.0).abs() < 1e-6, "{b:?}"); + } + other => panic!("expected bounds, got {other:?}"), + } + + // Transform moves the viewport's paper-space center. + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { + id: vp, placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]) })).unwrap(); + let Some(EntityType::Viewport(v)) = host.document().get_entity(handle) else { panic!("viewport not found") }; + assert!((v.center.x - 60.0).abs() < 1e-6); + } + // ── Phase 3: containers (block references) ─────────────────────────────── #[test] diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index 880507133..87891aa4c 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -129,6 +129,11 @@ pub fn transform_entity_geometry( ins.insert_point = apply(ins.insert_point); EntityType::Insert(ins) } + EntityType::Viewport(vp) => { + let mut vp = vp.clone(); + vp.center = apply(vp.center); + EntityType::Viewport(vp) + } EntityType::Point(pt) => { let mut pt = pt.clone(); pt.location = apply(pt.location); @@ -242,6 +247,7 @@ pub fn entity_kind_name(entity: &EntityType) -> &'static str { EntityType::Ray(_) => "Ray", EntityType::XLine(_) => "XLine", EntityType::Insert(_) => "Insert", + EntityType::Viewport(_) => "Viewport", EntityType::Text(_) => "Text", EntityType::MText(_) => "MText", _ => "Other", @@ -311,6 +317,18 @@ pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult Aabb { + min: [ + vp.center.x - vp.width / 2.0, + vp.center.y - vp.height / 2.0, + vp.center.z, + ], + max: [ + vp.center.x + vp.width / 2.0, + vp.center.y + vp.height / 2.0, + vp.center.z, + ], + }, // Ray/XLine are unbounded by definition — no finite bounds. _ => return Err(ApiError::Unsupported("bounds for this entity family is not yet implemented".into())), };