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

@ -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);