Files saved while a paper layout was active reopened in Model space because the loader hardcoded the current layout to "Model" and never consulted the space recorded in the file. The active space is a standard part of the format and acadrust already round-trips it: $TILEMODE (header.show_model_space) for model-vs-paper, and the CTAB current-tab variable for the exact layout name. - Read: on open, pick the current layout from CTAB (when it names a layout that exists), else $TILEMODE (Model, or the first paper layout). - Write: Scene::sync_active_space_to_document() mirrors the active layout back into the document (show_model_space + CTAB). It runs from set_current_layout, so every active-layout change — the status-bar tab, the Layout Manager's Set Current, create / rename / delete, and undo/redo — keeps the document in sync for the next save. $TILEMODE is the guaranteed mechanism (read and written for both DXF and DWG); CTAB refines the exact tab when the variable is present. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
554 lines
21 KiB
Rust
554 lines
21 KiB
Rust
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
|
use super::*;
|
|
|
|
impl Scene {
|
|
// ── Layout management ─────────────────────────────────────────────────
|
|
|
|
/// Rename a paper-space layout. Updates the Layout object name in the document.
|
|
pub fn rename_layout(&mut self, old_name: &str, new_name: &str) {
|
|
for obj in self.document.objects.values_mut() {
|
|
if let ObjectType::Layout(l) = obj {
|
|
if l.name == old_name {
|
|
l.name = new_name.to_string();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Delete a paper-space layout and all entities owned by it.
|
|
/// Returns `false` if the layout was not found or is "Model".
|
|
pub fn delete_layout(&mut self, name: &str) -> bool {
|
|
if name == "Model" {
|
|
return false;
|
|
}
|
|
|
|
let layout_info = self.document.objects.values().find_map(|obj| {
|
|
if let ObjectType::Layout(l) = obj {
|
|
if l.name == name {
|
|
return Some((l.handle, l.block_record));
|
|
}
|
|
}
|
|
None
|
|
});
|
|
|
|
let (layout_handle, block_handle) = match layout_info {
|
|
Some(info) => info,
|
|
None => return false,
|
|
};
|
|
|
|
// Remove all entities that belong to this layout's block record.
|
|
let to_remove: Vec<Handle> = self
|
|
.document
|
|
.entities()
|
|
.filter(|e| e.common().owner_handle == block_handle)
|
|
.map(|e| e.common().handle)
|
|
.collect();
|
|
for h in &to_remove {
|
|
self.hatches.remove(h);
|
|
self.meshes.remove(h);
|
|
self.solid_models.remove(h);
|
|
self.document.remove_entity(*h);
|
|
}
|
|
|
|
// Remove the Layout object itself.
|
|
self.document.objects.remove(&layout_handle);
|
|
|
|
// Drop the layout's entry from the ACAD_LAYOUT dictionary so it does not
|
|
// dangle (and so AutoCAD doesn't try to recover a now-missing layout).
|
|
let dict_handle = self.document.header.acad_layout_dict_handle;
|
|
if let Some(ObjectType::Dictionary(d)) = self.document.objects.get_mut(&dict_handle) {
|
|
d.entries.retain(|(k, _)| k != name);
|
|
}
|
|
|
|
// Remove the now-empty paper-space block record.
|
|
let block_name = self
|
|
.document
|
|
.block_records
|
|
.iter()
|
|
.find(|b| b.handle == block_handle)
|
|
.map(|b| b.name.clone());
|
|
if let Some(bn) = block_name {
|
|
self.document.block_records.remove(&bn);
|
|
}
|
|
|
|
// Drop any standalone PlotSettings page setup tied to this layout.
|
|
let ps_handles: Vec<Handle> = self
|
|
.document
|
|
.objects
|
|
.iter()
|
|
.filter_map(|(h, o)| match o {
|
|
ObjectType::PlotSettings(ps) if ps.page_name == name => Some(*h),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
for h in ps_handles {
|
|
self.document.objects.remove(&h);
|
|
}
|
|
|
|
// If the deleted layout was active, fall back to Model space.
|
|
if self.current_layout == name {
|
|
self.current_layout = "Model".to_string();
|
|
self.sync_active_space_to_document();
|
|
}
|
|
|
|
self.bump_geometry();
|
|
true
|
|
}
|
|
|
|
/// Swap the `tab_order` of two paper layouts so they appear in swapped order.
|
|
pub fn swap_layout_order(&mut self, name_a: &str, name_b: &str) {
|
|
let mut order_a: Option<i16> = None;
|
|
let mut order_b: Option<i16> = None;
|
|
for obj in self.document.objects.values() {
|
|
if let ObjectType::Layout(l) = obj {
|
|
if l.name == name_a {
|
|
order_a = Some(l.tab_order);
|
|
}
|
|
if l.name == name_b {
|
|
order_b = Some(l.tab_order);
|
|
}
|
|
}
|
|
}
|
|
if let (Some(oa), Some(ob)) = (order_a, order_b) {
|
|
for obj in self.document.objects.values_mut() {
|
|
if let ObjectType::Layout(l) = obj {
|
|
if l.name == name_a {
|
|
l.tab_order = ob;
|
|
} else if l.name == name_b {
|
|
l.tab_order = oa;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rebuild the `pane_grid` layout from the current `model_tiles` rects
|
|
/// (each pane value = its tile index) — used after loading a tiled config
|
|
/// from the VPort table so the panes match the restored tiles.
|
|
pub fn rebuild_panes_from_tiles(&mut self) {
|
|
let items: Vec<(usize, iced::Rectangle)> = self
|
|
.model_tiles
|
|
.borrow()
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, t)| (i, t.rect))
|
|
.collect();
|
|
let full = iced::Rectangle {
|
|
x: 0.0,
|
|
y: 0.0,
|
|
width: 1.0,
|
|
height: 1.0,
|
|
};
|
|
let config = if items.is_empty() {
|
|
iced::widget::pane_grid::Configuration::Pane(0)
|
|
} else {
|
|
config_from_rects(&items, full)
|
|
};
|
|
self.model_panes = iced::widget::pane_grid::State::with_configuration(config);
|
|
}
|
|
|
|
/// Replace the Model pane layout with a `pane_grid` configuration (VPORTS
|
|
/// presets). Each resulting pane inherits the active tile's camera / style;
|
|
/// pane values are compacted to `0..N` and `model_tiles` rebuilt to match.
|
|
pub fn set_model_panes(&mut self, config: iced::widget::pane_grid::Configuration<usize>) {
|
|
let cam = self.camera.borrow().clone();
|
|
let (mode, grid_on, snap_on) = {
|
|
let tiles = self.model_tiles.borrow();
|
|
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
|
tiles
|
|
.get(active)
|
|
.map(|t| (t.render_mode, t.grid_on, t.snap_on))
|
|
.unwrap_or((
|
|
acadrust::entities::ViewportRenderMode::Wireframe2D,
|
|
false,
|
|
false,
|
|
))
|
|
};
|
|
self.model_panes = iced::widget::pane_grid::State::with_configuration(config);
|
|
let order: Vec<iced::widget::pane_grid::Pane> =
|
|
self.model_panes.iter().map(|(p, _)| *p).collect();
|
|
let mut tiles = Vec::with_capacity(order.len());
|
|
for (i, pane) in order.iter().enumerate() {
|
|
if let Some(v) = self.model_panes.get_mut(*pane) {
|
|
*v = i;
|
|
}
|
|
tiles.push(ModelTile {
|
|
rect: iced::Rectangle {
|
|
x: 0.0,
|
|
y: 0.0,
|
|
width: 1.0,
|
|
height: 1.0,
|
|
},
|
|
camera: cam.clone(),
|
|
render_mode: mode,
|
|
grid_on,
|
|
snap_on,
|
|
});
|
|
}
|
|
*self.model_tiles.borrow_mut() = tiles;
|
|
self.active_model_tile.set(0);
|
|
}
|
|
|
|
/// Split the active Model pane through `pane_grid`, adding a new pane that
|
|
/// inherits the active pane's camera / render-mode / grid. `horizontal`
|
|
/// true → a horizontal divider (panes stacked); false → vertical (panes
|
|
/// side by side). The active pane stays active.
|
|
pub fn split_active_pane(&mut self, horizontal: bool) {
|
|
use iced::widget::pane_grid::Axis;
|
|
let active_idx = self.active_model_tile.get();
|
|
// Keep the active tile's stored camera current before cloning it.
|
|
if let Some(t) = self.model_tiles.borrow_mut().get_mut(active_idx) {
|
|
t.camera = self.camera.borrow().clone();
|
|
}
|
|
let Some(active_pane) = self
|
|
.model_panes
|
|
.iter()
|
|
.find(|(_, &v)| v == active_idx)
|
|
.map(|(p, _)| *p)
|
|
else {
|
|
return;
|
|
};
|
|
let Some(clone) = self.model_tiles.borrow().get(active_idx).cloned() else {
|
|
return;
|
|
};
|
|
let new_val = self.model_tiles.borrow().len();
|
|
self.model_tiles.borrow_mut().push(clone);
|
|
let axis = if horizontal {
|
|
Axis::Horizontal
|
|
} else {
|
|
Axis::Vertical
|
|
};
|
|
if self.model_panes.split(axis, active_pane, new_val).is_none() {
|
|
self.model_tiles.borrow_mut().pop();
|
|
return;
|
|
}
|
|
self.compact_panes(active_pane);
|
|
}
|
|
|
|
/// Close the active Model pane (no-op on the last pane); the sibling that
|
|
/// absorbs the space becomes active and its camera goes live.
|
|
pub fn close_active_pane(&mut self) {
|
|
if self.model_panes.len() <= 1 {
|
|
return;
|
|
}
|
|
let active_idx = self.active_model_tile.get();
|
|
if let Some(t) = self.model_tiles.borrow_mut().get_mut(active_idx) {
|
|
t.camera = self.camera.borrow().clone();
|
|
}
|
|
let Some(active_pane) = self
|
|
.model_panes
|
|
.iter()
|
|
.find(|(_, &v)| v == active_idx)
|
|
.map(|(p, _)| *p)
|
|
else {
|
|
return;
|
|
};
|
|
if let Some((_, sibling)) = self.model_panes.close(active_pane) {
|
|
self.compact_panes(sibling);
|
|
let cam = self
|
|
.model_tiles
|
|
.borrow()
|
|
.get(self.active_model_tile.get())
|
|
.map(|t| t.camera.clone());
|
|
if let Some(c) = cam {
|
|
*self.camera.borrow_mut() = c;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Swap two Model panes (by tile index) in the `pane_grid` layout — each
|
|
/// viewport moves to the other's position, keeping its own camera / style.
|
|
pub fn swap_model_panes(&mut self, a: usize, b: usize) {
|
|
if a == b {
|
|
return;
|
|
}
|
|
let pa = self.model_panes.iter().find(|(_, &v)| v == a).map(|(p, _)| *p);
|
|
let pb = self.model_panes.iter().find(|(_, &v)| v == b).map(|(p, _)| *p);
|
|
if let (Some(pa), Some(pb)) = (pa, pb) {
|
|
self.model_panes.swap(pa, pb);
|
|
}
|
|
}
|
|
|
|
/// Renumber pane values to a compact `0..N` in layout order and rebuild
|
|
/// `model_tiles` to match (preserving each pane's data), so the pane value
|
|
/// is always a valid `model_tiles` index. `active_pane` becomes the new
|
|
/// active tile.
|
|
fn compact_panes(&mut self, active_pane: iced::widget::pane_grid::Pane) {
|
|
let old = self.model_tiles.borrow().clone();
|
|
let order: Vec<iced::widget::pane_grid::Pane> =
|
|
self.model_panes.iter().map(|(p, _)| *p).collect();
|
|
let fallback = old
|
|
.first()
|
|
.cloned()
|
|
.unwrap_or_else(|| ModelTile {
|
|
rect: iced::Rectangle {
|
|
x: 0.0,
|
|
y: 0.0,
|
|
width: 1.0,
|
|
height: 1.0,
|
|
},
|
|
camera: self.camera.borrow().clone(),
|
|
render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D,
|
|
grid_on: false,
|
|
snap_on: false,
|
|
});
|
|
let mut new_tiles = Vec::with_capacity(order.len());
|
|
let mut new_active = 0usize;
|
|
for (new_idx, pane) in order.iter().enumerate() {
|
|
let old_idx = *self.model_panes.get(*pane).unwrap_or(&0);
|
|
new_tiles.push(old.get(old_idx).cloned().unwrap_or_else(|| fallback.clone()));
|
|
if let Some(v) = self.model_panes.get_mut(*pane) {
|
|
*v = new_idx;
|
|
}
|
|
if *pane == active_pane {
|
|
new_active = new_idx;
|
|
}
|
|
}
|
|
*self.model_tiles.borrow_mut() = new_tiles;
|
|
self.active_model_tile.set(new_active);
|
|
}
|
|
|
|
/// Make Model tile `idx` active, stashing the live camera into the outgoing
|
|
/// tile and loading the incoming one. Returns `true` if the active tile
|
|
/// changed. The caller bumps `camera_generation` and syncs the display.
|
|
pub fn set_active_model_tile(&self, idx: usize) -> bool {
|
|
if self.current_layout != "Model" {
|
|
return false;
|
|
}
|
|
let old = self.active_model_tile.get();
|
|
if idx == old || idx >= self.model_tiles.borrow().len() {
|
|
return false;
|
|
}
|
|
let incoming = {
|
|
let mut tiles = self.model_tiles.borrow_mut();
|
|
if let Some(t) = tiles.get_mut(old) {
|
|
t.camera = self.camera.borrow().clone();
|
|
}
|
|
tiles.get(idx).map(|t| t.camera.clone())
|
|
};
|
|
if let Some(cam) = incoming {
|
|
*self.camera.borrow_mut() = cam;
|
|
}
|
|
self.active_model_tile.set(idx);
|
|
true
|
|
}
|
|
|
|
/// Divider bars between Model panes, as pixel rectangles within a
|
|
/// `(vw, vh)` canvas — derived from the `pane_grid` split regions so they
|
|
/// land exactly in the spacing gaps the renderer leaves. Each bar is
|
|
/// `TILE_DIVIDER_PX` thick. Empty outside Model / for a single pane.
|
|
pub fn model_pane_dividers(&self, vw: f32, vh: f32) -> Vec<iced::Rectangle> {
|
|
use iced::widget::pane_grid::Axis;
|
|
if self.current_layout != "Model" || vw < 1.0 || vh < 1.0 {
|
|
return vec![];
|
|
}
|
|
let half = TILE_DIVIDER_PX * 0.5;
|
|
self.model_panes
|
|
.layout()
|
|
.split_regions(TILE_DIVIDER_PX, 0.0, iced::Size::new(vw, vh))
|
|
.values()
|
|
.map(|(axis, region, ratio)| match axis {
|
|
Axis::Vertical => {
|
|
let x = region.x + ratio * region.width;
|
|
iced::Rectangle {
|
|
x: x - half,
|
|
y: region.y,
|
|
width: TILE_DIVIDER_PX,
|
|
height: region.height,
|
|
}
|
|
}
|
|
Axis::Horizontal => {
|
|
let y = region.y + ratio * region.height;
|
|
iced::Rectangle {
|
|
x: region.x,
|
|
y: y - half,
|
|
width: region.width,
|
|
height: TILE_DIVIDER_PX,
|
|
}
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Top-left pixel corner of Model pane `idx` within a `(vw, vh)` canvas,
|
|
/// derived from the `pane_grid` layout (so it matches the rendered pane).
|
|
pub fn pane_origin_px(&self, idx: usize, vw: f32, vh: f32) -> iced::Point {
|
|
if vw < 1.0 || vh < 1.0 {
|
|
return iced::Point::ORIGIN;
|
|
}
|
|
let regions = self.model_panes.layout().pane_regions(
|
|
TILE_DIVIDER_PX,
|
|
0.0,
|
|
iced::Size::new(vw, vh),
|
|
);
|
|
self.model_panes
|
|
.iter()
|
|
.find(|(_, &v)| v == idx)
|
|
.and_then(|(pane, _)| regions.get(pane))
|
|
.map(|r| iced::Point::new(r.x, r.y))
|
|
.unwrap_or(iced::Point::ORIGIN)
|
|
}
|
|
|
|
/// Replace the Model tiled layout with the given normalized rectangles
|
|
/// (each in 0..1). Every tile inherits the current camera; the first
|
|
/// tile becomes active. Used by VPORTS presets and `reset_model_tiles`.
|
|
/// Derive `model_tiles` rects from the `pane_grid` layout (the layout
|
|
/// source of truth) for a canvas of `(canvas_w, canvas_h)` pixels — each
|
|
/// pane's value is its `model_tiles` index. Called before the renderer /
|
|
/// hit-test read tile rects so they track the live pane_grid layout and any
|
|
/// in-flight resize, with the divider gap applied exactly as pane_grid draws
|
|
/// it. No-op outside the Model layout.
|
|
#[allow(dead_code)]
|
|
pub fn sync_tiles_from_panes(&self, canvas_w: f32, canvas_h: f32) {
|
|
if self.current_layout != "Model" || canvas_w < 1.0 || canvas_h < 1.0 {
|
|
return;
|
|
}
|
|
let regions = self.model_panes.layout().pane_regions(
|
|
TILE_DIVIDER_PX,
|
|
0.0,
|
|
iced::Size::new(canvas_w, canvas_h),
|
|
);
|
|
let mut tiles = self.model_tiles.borrow_mut();
|
|
for (pane, &idx) in self.model_panes.iter() {
|
|
if let (Some(r), Some(tile)) = (regions.get(pane), tiles.get_mut(idx)) {
|
|
tile.rect = iced::Rectangle {
|
|
x: r.x / canvas_w,
|
|
y: r.y / canvas_h,
|
|
width: r.width / canvas_w,
|
|
height: r.height / canvas_h,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// Screen-pixel rectangle of the active Model tile within a canvas of
|
|
/// `(vw, vh)`. Full canvas outside the Model layout or for a single
|
|
/// tile. Used to map cursor coordinates into the active tile so pick /
|
|
/// pan / ViewCube work per-pane in a tiled layout.
|
|
/// Canvas bounds + camera for every Model tile whose grid display is on.
|
|
/// Each pane renders its own grid independently of which tile is active or
|
|
/// hovered, so the grid never flickers as the cursor crosses panes. The
|
|
/// active tile uses the live camera (mid-orbit/pan); others use their
|
|
/// stored camera. (#121)
|
|
/// Screen rect + camera for every grid-on sub-view in the current layout —
|
|
/// model tiles in model space, the sheet plus each floating viewport
|
|
/// (clipped to its rectangle) in paper space. Derived from the same
|
|
/// `active_viewports` enumeration the renderer uses, so the grid overlay can
|
|
/// never drift from the views actually on screen (issue #121). The grid
|
|
|
|
pub fn active_model_tile_bounds(&self, vw: f32, vh: f32) -> iced::Rectangle {
|
|
if self.current_layout != "Model" {
|
|
return iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh };
|
|
}
|
|
let tiles = self.model_tiles.borrow();
|
|
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
|
match tiles.get(active) {
|
|
Some(t) => iced::Rectangle {
|
|
x: t.rect.x * vw,
|
|
y: t.rect.y * vh,
|
|
width: (t.rect.width * vw).max(1.0),
|
|
height: (t.rect.height * vh).max(1.0),
|
|
},
|
|
None => iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh },
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// Reconstruct a `pane_grid` configuration from a set of (tile-index, rect)
|
|
/// items covering `region`, by recursively finding a full vertical or
|
|
/// horizontal guillotine cut. AutoCAD tiled configs (and anything pane_grid
|
|
/// produces) are guillotine layouts, so this round-trips them. A non-guillotine
|
|
/// set falls back to chaining panes so nothing is lost.
|
|
fn config_from_rects(
|
|
items: &[(usize, iced::Rectangle)],
|
|
region: iced::Rectangle,
|
|
) -> iced::widget::pane_grid::Configuration<usize> {
|
|
use iced::widget::pane_grid::{Axis, Configuration as C};
|
|
if items.len() == 1 {
|
|
return C::Pane(items[0].0);
|
|
}
|
|
let eps = 1e-3;
|
|
// Try a vertical cut: a boundary x where every rect is wholly left or right.
|
|
let mut cuts: Vec<f32> = items.iter().map(|(_, r)| r.x + r.width).collect();
|
|
cuts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
|
for &x in &cuts {
|
|
if x <= region.x + eps || x >= region.x + region.width - eps {
|
|
continue;
|
|
}
|
|
let split_ok = items
|
|
.iter()
|
|
.all(|(_, r)| r.x + r.width <= x + eps || r.x >= x - eps);
|
|
if !split_ok {
|
|
continue;
|
|
}
|
|
let (a, b): (Vec<_>, Vec<_>) = items
|
|
.iter()
|
|
.cloned()
|
|
.partition(|(_, r)| r.x + r.width <= x + eps);
|
|
if a.is_empty() || b.is_empty() {
|
|
continue;
|
|
}
|
|
let ra = iced::Rectangle {
|
|
width: x - region.x,
|
|
..region
|
|
};
|
|
let rb = iced::Rectangle {
|
|
x,
|
|
width: region.x + region.width - x,
|
|
..region
|
|
};
|
|
return C::Split {
|
|
axis: Axis::Vertical,
|
|
ratio: ((x - region.x) / region.width).clamp(0.05, 0.95),
|
|
a: Box::new(config_from_rects(&a, ra)),
|
|
b: Box::new(config_from_rects(&b, rb)),
|
|
};
|
|
}
|
|
// Try a horizontal cut: a boundary y where every rect is wholly above/below.
|
|
let mut cuts: Vec<f32> = items.iter().map(|(_, r)| r.y + r.height).collect();
|
|
cuts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
|
for &y in &cuts {
|
|
if y <= region.y + eps || y >= region.y + region.height - eps {
|
|
continue;
|
|
}
|
|
let split_ok = items
|
|
.iter()
|
|
.all(|(_, r)| r.y + r.height <= y + eps || r.y >= y - eps);
|
|
if !split_ok {
|
|
continue;
|
|
}
|
|
let (a, b): (Vec<_>, Vec<_>) = items
|
|
.iter()
|
|
.cloned()
|
|
.partition(|(_, r)| r.y + r.height <= y + eps);
|
|
if a.is_empty() || b.is_empty() {
|
|
continue;
|
|
}
|
|
let ra = iced::Rectangle {
|
|
height: y - region.y,
|
|
..region
|
|
};
|
|
let rb = iced::Rectangle {
|
|
y,
|
|
height: region.y + region.height - y,
|
|
..region
|
|
};
|
|
return C::Split {
|
|
axis: Axis::Horizontal,
|
|
ratio: ((y - region.y) / region.height).clamp(0.05, 0.95),
|
|
a: Box::new(config_from_rects(&a, ra)),
|
|
b: Box::new(config_from_rects(&b, rb)),
|
|
};
|
|
}
|
|
// Non-guillotine fallback: peel the first pane off the rest.
|
|
let (first, rest) = items.split_first().unwrap();
|
|
C::Split {
|
|
axis: Axis::Vertical,
|
|
ratio: 0.5,
|
|
a: Box::new(C::Pane(first.0)),
|
|
b: Box::new(config_from_rects(rest, region)),
|
|
}
|
|
}
|