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<Handle> 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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-03-31 13:07:25 +03:00
commit 9cee12d1c7
2 changed files with 73 additions and 17 deletions

View file

@ -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<Handle> = 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<Handle> = 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<Handle> = 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<acadrust::Handle> = {
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)

View file

@ -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<WireModel> {
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<WireModel> {
self.document
@ -534,7 +558,9 @@ impl Scene {
vp.view_height = vp.height / fit_scale;
}
fn viewport_content_wires(&self, paper_block: Handle) -> Vec<WireModel> {
/// 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<Handle>) -> Vec<WireModel> {
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);