From 9cee12d1c774c20cb182a11051ca8b83a5175247 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 31 Mar 2026 13:07:25 +0300 Subject: [PATCH] Feat: PSPACE/MSPACE interaction isolation + paper-space drawing Selection, snapping and editing are now mode-aware: PSPACE (paper layout, no active viewport) - Only paper-space entities (viewport borders, title blocks, etc.) are hit-testable and selectable. Viewport content wires are invisible to the interaction layer. - Drawing commands add entities to the paper-space layout block so annotations/title blocks land on the sheet, not in model space. MSPACE (active viewport entered) - Only model-space entities visible through the active viewport are hit-testable. Other viewports' content and paper entities are ignored. - Drawing commands add entities to model space (correct DXF block). - Snap results from projected viewport-content wires are converted from paper-space back to model-space via paper_to_model so that placed coordinates and command previews are consistent. Entering/exiting MSPACE clears the current selection so no stale cross-space selection is carried over. Implementation: - Scene::hit_test_wires() returns mode-filtered wires for all hit-tests, snaps, and selections (entity_wires() still returns everything for rendering and PDF export). - viewport_content_wires() gains an Option filter to render only the active viewport's content for MSPACE hit-testing. - Scene::add_entity() routes to the paper-layout block or model-space block depending on current_layout / active_viewport. Co-Authored-By: Claude Sonnet 4.6 --- src/app/update.rs | 40 +++++++++++++++++++++++++------------ src/scene/mod.rs | 50 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/src/app/update.rs b/src/app/update.rs index 231f22981..b969de771 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -706,7 +706,7 @@ impl H7CAD { let raw = self.tabs[i].scene.paper_to_model(raw_paper); let edited_name = grip.handle.value().to_string(); - let all_wires = self.tabs[i].scene.entity_wires(); + let all_wires = self.tabs[i].scene.hit_test_wires(); let snap_wires: Vec<_> = all_wires .iter() .filter(|w| w.name != edited_name) @@ -749,7 +749,7 @@ impl H7CAD { // command previews and snapping work in the correct coordinate space. let cursor_world = self.tabs[i].scene.paper_to_model(cursor_paper); - let all_wires = self.tabs[i].scene.entity_wires(); + let all_wires = self.tabs[i].scene.hit_test_wires(); let needs_tan = self.tabs[i] .active_cmd.as_ref().map(|c| c.needs_tangent_pick()).unwrap_or(false); self.tabs[i].snap_result = if needs_tan { @@ -758,7 +758,11 @@ impl H7CAD { self.snapper.snap(cursor_world, p, &all_wires, view_proj, bounds) }; let effective = { - let mut pt = self.tabs[i].snap_result.map(|s| s.world).unwrap_or(cursor_world); + // snap.world is paper-space for viewport-projected wires; convert + // to model-space so previews use consistent coordinates. + let mut pt = self.tabs[i].snap_result + .map(|s| self.tabs[i].scene.paper_to_model(s.world)) + .unwrap_or(cursor_world); if self.tabs[i].active_cmd.is_some() { pt.z = 0.0; } if let Some(base) = self.last_point { if self.ortho_mode { @@ -895,7 +899,7 @@ impl H7CAD { // Convert paper-space → model-space when inside a viewport. let raw = self.tabs[i].scene.paper_to_model(raw_paper); let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); - let all_wires = self.tabs[i].scene.entity_wires(); + let all_wires = self.tabs[i].scene.hit_test_wires(); let needs_tan = self.tabs[i].active_cmd.as_ref() .map(|c| c.needs_tangent_pick()).unwrap_or(false); let snap_hit = if needs_tan { @@ -903,7 +907,11 @@ impl H7CAD { } else { self.snapper.snap(raw, p, &all_wires, vp_mat, bounds) }; - let mut pt = snap_hit.map(|s| s.world).unwrap_or(raw); + // snap.world is in paper-space (projected wire coords in MSPACE); + // convert to model-space so commands receive consistent coordinates. + let mut pt = snap_hit + .map(|s| self.tabs[i].scene.paper_to_model(s.world)) + .unwrap_or(raw); pt.z = 0.0; if let Some(base) = self.last_point { if self.ortho_mode { @@ -919,7 +927,7 @@ impl H7CAD { .map(|c| c.needs_entity_pick()).unwrap_or(false) { let vp_mat2 = self.tabs[i].scene.camera.borrow().view_proj(bounds); - let all_wires2 = self.tabs[i].scene.entity_wires(); + let all_wires2 = self.tabs[i].scene.hit_test_wires(); let hit = scene::hit_test::click_hit(p, &all_wires2, vp_mat2, bounds) .and_then(|s| Scene::handle_from_wire_name(s)); if let Some(handle) = hit { @@ -976,7 +984,7 @@ impl H7CAD { if elapsed_ms < POLY_START_DELAY_MS { if let Some(a) = box_anchor { let crossing = box_crossing; - let all_wires = self.tabs[i].scene.entity_wires(); + let all_wires = self.tabs[i].scene.hit_test_wires(); let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); let mut handles: Vec = scene::hit_test::box_hit( a, p, crossing, &all_wires, vp_mat, bounds, @@ -996,7 +1004,7 @@ impl H7CAD { (sel.poly_points.clone(), sel.poly_crossing) }; self.tabs[i].scene.selection.borrow_mut().poly_last_crossing = crossing; - let all_wires = self.tabs[i].scene.entity_wires(); + let all_wires = self.tabs[i].scene.hit_test_wires(); let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); let mut handles: Vec = scene::hit_test::poly_hit( &poly_pts, crossing, &all_wires, vp_mat, bounds, @@ -1018,7 +1026,7 @@ impl H7CAD { sel.box_current = None; } else { if box_anchor.is_none() { - let all_wires = self.tabs[i].scene.entity_wires(); + let all_wires = self.tabs[i].scene.hit_test_wires(); let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); let hit = scene::hit_test::click_hit(p, &all_wires, vp_mat, bounds) .and_then(|s| Scene::handle_from_wire_name(s)) @@ -1041,7 +1049,7 @@ impl H7CAD { } else { let a = box_anchor.unwrap(); let crossing = box_crossing; - let all_wires = self.tabs[i].scene.entity_wires(); + let all_wires = self.tabs[i].scene.hit_test_wires(); let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); let mut handles: Vec = scene::hit_test::box_hit( a, p, crossing, &all_wires, vp_mat, bounds, @@ -1107,7 +1115,7 @@ impl H7CAD { // 1) Try direct wire hit — works when the border is clicked. let hit_vp: Option = { let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); - let all_wires = self.tabs[i].scene.entity_wires(); + let all_wires = self.tabs[i].scene.hit_test_wires(); scene::hit_test::click_hit(p, &all_wires, vp_mat, bounds) .and_then(|s| Scene::handle_from_wire_name(s)) .and_then(|h| { @@ -1679,13 +1687,19 @@ impl H7CAD { Message::EnterViewport(handle) => { let i = self.active_tab; + // Clear paper-space selection before entering model space. + self.tabs[i].scene.deselect_all(); self.tabs[i].scene.active_viewport = Some(handle); - self.command_line.push_output("MSPACE — viewport entered. Middle-drag/scroll to navigate, double-click outside to exit."); + self.refresh_properties(); + self.command_line.push_output("MSPACE"); Task::none() } Message::ExitViewport => { let i = self.active_tab; + // Clear model-space selection before returning to paper space. + self.tabs[i].scene.deselect_all(); + self.refresh_properties(); self.tabs[i].scene.active_viewport = None; self.command_line.push_output("PSPACE"); Task::none() @@ -1801,7 +1815,7 @@ impl H7CAD { Message::PlotExportPath(None) => Task::none(), Message::PlotExportPath(Some(path)) => { let i = self.active_tab; - let wires = self.tabs[i].scene.entity_wires(); + let wires = self.tabs[i].scene.entity_wires(); // full scene for PDF let (paper_w, paper_h, offset_x, offset_y) = if let Some(((x0, y0), (x1, y1))) = self.tabs[i].scene.paper_limits() { (x1 - x0, y1 - y0, -x0, -y0) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 3c72915d2..964415cab 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -288,11 +288,35 @@ impl Scene { if let Some(((x0, y0), (x1, y1))) = self.paper_limits() { wires.insert(0, paper_boundary_wire(x0 as f32, y0 as f32, x1 as f32, y1 as f32)); } - wires.extend(self.viewport_content_wires(layout_block)); + wires.extend(self.viewport_content_wires(layout_block, None)); } wires } + /// Wires that should participate in hit-testing, snapping, and selection. + /// + /// - Model layout: all entity wires (same as entity_wires). + /// - PSPACE (paper layout, no active viewport): paper-space entities only — + /// viewport content is NOT interactive. + /// - MSPACE (active viewport set): model-space content of the active viewport + /// only — paper-space entities are NOT interactive. + pub fn hit_test_wires(&self) -> Vec { + if self.current_layout == "Model" { + return self.entity_wires(); + } + let layout_block = self.current_layout_block_handle(); + match self.active_viewport { + None => { + // PSPACE: only paper-space entities (viewport borders, title blocks…) + self.wires_for_block(layout_block) + } + Some(vp_handle) => { + // MSPACE: only model content visible through the active viewport + self.viewport_content_wires(layout_block, Some(vp_handle)) + } + } + } + /// Tessellate all non-invisible entities owned by `block_handle`. fn wires_for_block(&self, block_handle: Handle) -> Vec { self.document @@ -534,7 +558,9 @@ impl Scene { vp.view_height = vp.height / fit_scale; } - fn viewport_content_wires(&self, paper_block: Handle) -> Vec { + /// Collect model-space wires projected into paper space for all (or one specific) + /// user viewports. `only_vp = Some(h)` restricts output to that viewport. + fn viewport_content_wires(&self, paper_block: Handle, only_vp: Option) -> Vec { use acadrust::entities::Viewport; use std::collections::HashSet as HSet; @@ -544,7 +570,12 @@ impl Scene { .filter_map(|e| { if let EntityType::Viewport(vp) = e { Some(vp) } else { None } }) - .filter(|vp| vp.id > 1 && vp.common.owner_handle == paper_block && vp.status.is_on) + .filter(|vp| { + vp.id > 1 + && vp.common.owner_handle == paper_block + && vp.status.is_on + && only_vp.map_or(true, |h| vp.common.handle == h) + }) .collect(); if viewports.is_empty() { @@ -911,7 +942,18 @@ impl Scene { None }; - let handle = self.document.add_entity(entity).unwrap_or(Handle::NULL); + // Route to the correct block based on current editing mode: + // - PSPACE (paper layout, no active viewport): paper-space layout block. + // - MSPACE or model layout: model space (document default). + let handle = if self.current_layout != "Model" && self.active_viewport.is_none() { + let layout_name = self.current_layout.clone(); + self.document + .add_entity_to_layout(entity, &layout_name) + .unwrap_or(Handle::NULL) + } else { + self.document.add_entity(entity).unwrap_or(Handle::NULL) + }; + if !handle.is_null() { if let Some(model) = hatch_seed { self.hatches.insert(handle, model);