From 495b13ec4f02bf6b46a26b6c2702287d588b15ac Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 27 Jun 2026 11:50:11 +0300 Subject: [PATCH] refactor(viewport): migrate model-space tiling to iced pane_grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the custom model_tiles tiling with iced 0.14 pane_grid as the layout source of truth, while paper-space floating viewports are left untouched (they overlap/free-place, which pane_grid cannot model). Each Model pane is its own shader widget rendering its camera into the pane bounds; the renderer Primitive carries a base_slot so several shader widgets share the type-keyed MultiPipeline storage on disjoint slots. A second pane_grid of transparent mouse_areas (plus on_resize) layers above the crosshair overlay to receive input — the overlay's Hidden cursor interaction otherwise levitates the cursor and starves any layer beneath it. The active pane follows the cursor (hover-activate), and grid / crosshair / UCS icon render per pane while the ViewCube and render-mode picker stay on the active pane only. Split / close / VPORTS presets and the VPort table round-trip now drive pane_grid (guillotine reconstruction on load). Adds drag-to-swap via a controls bar handle with a ghost-card preview, visible pane dividers from the pane_grid split regions, a default model background of RGB(33,40,48), and removes all the superseded custom-tile code. Co-Authored-By: Claude Opus 4.8 --- src/app/commands/view.rs | 48 +- src/app/mod.rs | 42 +- src/app/update/command.rs | 8 +- src/app/update/dialog.rs | 4 +- src/app/update/dynamic.rs | 4 +- src/app/update/file.rs | 4 +- src/app/update/mod.rs | 86 +++- src/app/update/style.rs | 4 +- src/app/update/viewport.rs | 163 +++---- src/app/view/controls.rs | 51 +- src/app/view/mod.rs | 194 ++++++-- src/scene/camera_ops.rs | 3 + src/scene/layout.rs | 801 +++++++++++++++----------------- src/scene/mod.rs | 35 +- src/scene/pipeline/mod.rs | 8 +- src/scene/view/render.rs | 80 +++- src/scene/view/viewport_pane.rs | 31 +- src/ui/overlay.rs | 207 +++++---- 18 files changed, 1022 insertions(+), 751 deletions(-) diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 5e3a3be69..e38830aea 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -370,31 +370,33 @@ impl OpenCADStudio { .push_info("VPORTS Configuration [SIngle/2H/2V/4]:"); return Some(self.focus_cmd_input()); } - // Model space: split the tiled viewport layout. - use iced::Rectangle as R; - let full = R { x: 0.0, y: 0.0, width: 1.0, height: 1.0 }; - let rects: Option> = match sub.as_str() { - "SINGLE" | "SI" | "1" => Some(vec![full]), - "2H" | "2" => Some(vec![ - R { x: 0.0, y: 0.0, width: 1.0, height: 0.5 }, - R { x: 0.0, y: 0.5, width: 1.0, height: 0.5 }, - ]), - "2V" => Some(vec![ - R { x: 0.0, y: 0.0, width: 0.5, height: 1.0 }, - R { x: 0.5, y: 0.0, width: 0.5, height: 1.0 }, - ]), - "4" => Some(vec![ - R { x: 0.0, y: 0.0, width: 0.5, height: 0.5 }, - R { x: 0.5, y: 0.0, width: 0.5, height: 0.5 }, - R { x: 0.0, y: 0.5, width: 0.5, height: 0.5 }, - R { x: 0.5, y: 0.5, width: 0.5, height: 0.5 }, - ]), + // Model space: split the tiled viewport layout via pane_grid. + use iced::widget::pane_grid::{Axis, Configuration as C}; + let split = |axis, a, b| C::Split { + axis, + ratio: 0.5, + a: Box::new(a), + b: Box::new(b), + }; + let config: Option<(C, usize)> = match sub.as_str() { + "SINGLE" | "SI" | "1" => Some((C::Pane(0), 1)), + "2H" | "2" => { + Some((split(Axis::Horizontal, C::Pane(0), C::Pane(1)), 2)) + } + "2V" => Some((split(Axis::Vertical, C::Pane(0), C::Pane(1)), 2)), + "4" => Some(( + split( + Axis::Vertical, + split(Axis::Horizontal, C::Pane(0), C::Pane(2)), + split(Axis::Horizontal, C::Pane(1), C::Pane(3)), + ), + 4, + )), _ => None, }; - match rects { - Some(rects) => { - let n = rects.len(); - self.tabs[i].scene.set_model_tile_layout(rects); + match config { + Some((config, n)) => { + self.tabs[i].scene.set_model_panes(config); self.tabs[i].scene.camera_generation += 1; self.command_line .push_output(&format!("VPORTS: {n} viewport(s).")); diff --git a/src/app/mod.rs b/src/app/mod.rs index d72407375..0c9af8605 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -26,18 +26,7 @@ pub use style_ops::StyleKind; use document::DocumentTab; use crate::modules::ModuleEvent; -use crate::scene::{CubeRegion, TileEdgeOrient}; - -/// In-flight drag of a Model-tile inner divider. `last_applied` is the -/// most recent normalized coordinate handed to `move_model_tile_edge` — -/// the next `ViewportMove` uses it as the `old_coord` argument so the -/// drag follows the same edge across frames even after sub-pixel -/// adjustments from the clamp inside `move_model_tile_edge`. -#[derive(Clone, Debug)] -pub struct TileDrag { - pub orient: TileEdgeOrient, - pub last_applied: f32, -} +use crate::scene::CubeRegion; /// Which UCS-icon grip is being dragged. `Origin` slides the UCS origin within /// its own plane; `XAxis`/`YAxis` rotate the UCS so that axis points at the @@ -265,10 +254,6 @@ pub(super) struct OpenCADStudio { /// `true` after a bare `VPORTS` in model space — the next command-line /// entry is treated as the tiled-config option (SIngle/2H/2V/4). awaiting_vports: bool, - /// Active drag of a Model-tile divider. Set on press over an inner - /// edge, updated on move, cleared on release (which also runs the - /// collapse pass). - tile_drag: Option, /// Cursor is hovering over the UCS icon body — drives the hover highlight. ucs_icon_hover: bool, /// UCS icon is selected (clicked): its grips are shown and draggable. @@ -276,6 +261,9 @@ pub(super) struct OpenCADStudio { /// Active direct-drag of a UCS icon grip (origin slide or axis rotate). /// Set on press over a grip, updated on move, committed on release. ucs_grip_drag: Option, + /// Pane-move drag in progress: the source pane's tile index, armed by the + /// controls-bar drag handle. On release over another pane the two swap. + pane_move_from: Option, /// `true` once the user has reshaped the dynamic-input field set via /// the `,` separator during the current command iteration. Tells /// `sync_dyn_fields` to preserve the user's chosen shape instead of @@ -1143,6 +1131,26 @@ pub enum Message { ViewportMiddleRelease, ViewportScroll(mouse::ScrollDelta), ViewportExit, + // ── Per-pane Model viewport (pane_grid) ─────────────────────────────── + /// A pane_grid divider was dragged — resize the split. + PaneResized(iced::widget::pane_grid::ResizeEvent), + /// A pane body was clicked — focus that pane. + PaneClicked(iced::widget::pane_grid::Pane), + /// A pane was drag-and-dropped onto another — swap them. + PaneDragged(iced::widget::pane_grid::DragEvent), + /// Per-pane mouse events. `usize` = the pane's tile index; the `Point` is + /// pane-local (offset to canvas coords + focus in the handler). + PaneMove(usize, Point), + PanePress(usize), + PaneRelease(usize), + PaneRightPress(usize), + PaneRightRelease(usize), + PaneMiddlePress(usize), + PaneMiddleRelease(usize), + PaneScroll(usize, mouse::ScrollDelta), + /// Drag-handle pressed on the active pane's controls bar: arm a pane move — + /// the next pane released over is swapped with the active pane. + PaneMoveStart, ViewCubeSnap(CubeRegion), /// World-frame view snap from a compass cardinal (N/E/S/W), bypassing the /// UCS so the compass stays world-aligned. @@ -1806,10 +1814,10 @@ impl OpenCADStudio { default_bg_color: None, default_paper_bg_color: None, awaiting_vports: false, - tile_drag: None, ucs_icon_hover: false, ucs_icon_selected: false, ucs_grip_drag: None, + pane_move_from: None, dyn_user_reshaped: false, grip_hover: None, grip_popup: None, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index e5fd19c55..71acc4d64 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2,7 +2,7 @@ #![allow(unused_imports)] use super::util::*; -use super::{format_size, tile_min_norm, TILE_EDGE_HIT_PX, VIEWCUBE_HIT_SIZE}; +use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, @@ -12,7 +12,7 @@ use crate::modules::ModuleEvent; use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::model::object::GripApply; use crate::scene::{ - self, hover_id, CubeRegion, Scene, TileEdgeOrient, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, + self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; use crate::ui::PropertiesPanel; use acadrust::types::Color as AcadColor; @@ -402,6 +402,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.text_inline_cancel(); return self.post_editor_closed(false); } + // Esc cancels an armed pane move. + if self.pane_move_from.take().is_some() { + return Task::none(); + } // UCS icon: Esc ends any grip drag and clears the selection // (only when no command owns Escape). if self.tabs[self.active_tab].active_cmd.is_none() diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index fbd54f61f..84c967220 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -2,7 +2,7 @@ #![allow(unused_imports)] use super::util::*; -use super::{format_size, tile_min_norm, TILE_EDGE_HIT_PX, VIEWCUBE_HIT_SIZE}; +use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, @@ -12,7 +12,7 @@ use crate::modules::ModuleEvent; use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::model::object::GripApply; use crate::scene::{ - self, hover_id, CubeRegion, Scene, TileEdgeOrient, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, + self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; use crate::ui::PropertiesPanel; use acadrust::types::Color as AcadColor; diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index 35db501cb..5a4be7276 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -2,7 +2,7 @@ #![allow(unused_imports)] use super::util::*; -use super::{format_size, tile_min_norm, TILE_EDGE_HIT_PX, VIEWCUBE_HIT_SIZE}; +use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, @@ -12,7 +12,7 @@ use crate::modules::ModuleEvent; use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::model::object::GripApply; use crate::scene::{ - self, hover_id, CubeRegion, Scene, TileEdgeOrient, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, + self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; use crate::ui::PropertiesPanel; use acadrust::types::Color as AcadColor; diff --git a/src/app/update/file.rs b/src/app/update/file.rs index dde85a6c0..a6d42d556 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2,7 +2,7 @@ #![allow(unused_imports)] use super::util::*; -use super::{format_size, tile_min_norm, TILE_EDGE_HIT_PX, VIEWCUBE_HIT_SIZE}; +use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, @@ -12,7 +12,7 @@ use crate::modules::ModuleEvent; use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::model::object::GripApply; use crate::scene::{ - self, hover_id, CubeRegion, Scene, TileEdgeOrient, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, + self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; use crate::ui::PropertiesPanel; use acadrust::types::Color as AcadColor; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 5fcb3a893..5c66c9367 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1,7 +1,5 @@ use super::{Message, OpenCADStudio}; -use crate::scene::{ - VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, -}; +use crate::scene::VIEWCUBE_DRAW_PX; use crate::ui::PropertiesPanel; use acadrust::types::Color as AcadColor; use iced::time::Instant; @@ -42,19 +40,6 @@ fn is_modal_blocked_key_msg(msg: &Message) -> bool { } const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_DRAW_PX; -/// Pixel distance from a Model-tile inner divider that still registers as -/// a resize grip on the press. -const TILE_EDGE_HIT_PX: f32 = 4.0; -/// Normalized minimum tile size a divider drag clamps to. Sized to -/// comfortably contain the ViewCube + its padding so every tile always -/// has room to show the gizmo. -fn tile_min_norm(canvas_w: f32, canvas_h: f32) -> (f32, f32) { - let px = VIEWCUBE_DRAW_PX + 2.0 * VIEWCUBE_PAD + 16.0; - ( - (px / canvas_w.max(1.0)).min(0.95), - (px / canvas_h.max(1.0)).min(0.95), - ) -} fn format_size(bytes: u64) -> String { const KB: f64 = 1024.0; @@ -706,15 +691,17 @@ impl OpenCADStudio { Message::SplitModelViewport(horizontal) => { let i = self.active_tab; - self.tabs[i].scene.split_active_model_tile(horizontal); + self.tabs[i].scene.split_active_pane(horizontal); self.tabs[i].scene.camera_generation += 1; Task::none() } Message::CloseModelViewport => { let i = self.active_tab; - self.tabs[i].scene.close_active_model_tile(); + self.tabs[i].scene.close_active_pane(); self.tabs[i].scene.camera_generation += 1; + self.sync_render_mode_to_active_tile(i); + self.adopt_view_display(i); Task::none() } @@ -1021,6 +1008,69 @@ impl OpenCADStudio { Message::ViewportExit => self.on_viewport_exit(), + // ── Per-pane Model viewport ─────────────────────────────────── + Message::PaneResized(ev) => self.on_pane_resized(ev), + Message::PaneClicked(pane) => self.on_pane_clicked(pane), + Message::PaneDragged(ev) => self.on_pane_dragged(ev), + Message::PaneMove(idx, local) => { + let p = self.pane_canvas_point(idx, local); + // While dragging a pane, just track the cursor (no focus swap or + // snap) so the drop target reads cleanly. + if self.pane_move_from.is_some() { + self.tabs[self.active_tab] + .scene + .selection + .borrow_mut() + .last_move_pos = Some(p); + return Task::none(); + } + self.focus_model_pane(idx); + self.on_viewport_move(p) + } + Message::PaneMoveStart => { + let i = self.active_tab; + self.pane_move_from = Some(self.tabs[i].scene.active_model_tile.get()); + Task::none() + } + Message::PanePress(idx) => { + // A fresh press ends any stale (un-dropped) pane move. + self.pane_move_from = None; + self.focus_model_pane(idx); + self.on_viewport_left_press() + } + Message::PaneRelease(idx) => { + // Finishing a pane-move drag: swap the source pane with the one + // released over, instead of the normal release handling. + if let Some(from) = self.pane_move_from.take() { + let i = self.active_tab; + self.tabs[i].scene.swap_model_panes(from, idx); + self.tabs[i].scene.camera_generation += 1; + return Task::none(); + } + self.focus_model_pane(idx); + self.on_viewport_left_release() + } + Message::PaneRightPress(idx) => { + self.focus_model_pane(idx); + self.update(Message::ViewportRightPress) + } + Message::PaneRightRelease(idx) => { + self.focus_model_pane(idx); + self.update(Message::ViewportRightRelease) + } + Message::PaneMiddlePress(idx) => { + self.focus_model_pane(idx); + self.update(Message::ViewportMiddlePress) + } + Message::PaneMiddleRelease(idx) => { + self.focus_model_pane(idx); + self.update(Message::ViewportMiddleRelease) + } + Message::PaneScroll(idx, d) => { + self.focus_model_pane(idx); + self.update(Message::ViewportScroll(d)) + } + Message::ViewportLeftPress => self.on_viewport_left_press(), Message::ViewportLeftRelease => self.on_viewport_left_release(), diff --git a/src/app/update/style.rs b/src/app/update/style.rs index dad4f6504..050b0b898 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -2,7 +2,7 @@ #![allow(unused_imports)] use super::util::*; -use super::{format_size, tile_min_norm, TILE_EDGE_HIT_PX, VIEWCUBE_HIT_SIZE}; +use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, @@ -12,7 +12,7 @@ use crate::modules::ModuleEvent; use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::model::object::GripApply; use crate::scene::{ - self, hover_id, CubeRegion, Scene, TileEdgeOrient, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, + self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; use crate::ui::PropertiesPanel; use acadrust::types::Color as AcadColor; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 946d5ea68..ca890e4a5 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2,7 +2,7 @@ #![allow(unused_imports)] use super::util::*; -use super::{format_size, tile_min_norm, TILE_EDGE_HIT_PX, VIEWCUBE_HIT_SIZE}; +use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, @@ -12,7 +12,7 @@ use crate::modules::ModuleEvent; use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::model::object::GripApply; use crate::scene::{ - self, hover_id, CubeRegion, Scene, TileEdgeOrient, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, + self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; use crate::ui::PropertiesPanel; use acadrust::types::Color as AcadColor; @@ -480,34 +480,6 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { pub(super) fn on_viewport_move(&mut self, p: Point) -> Task { let i = self.active_tab; - // A Model-tile divider drag short-circuits the rest of - // the move handling — the cursor neither pans the camera - // nor updates snap state while the user is resizing - // panes. - if let Some(drag) = self.tile_drag.as_mut() { - let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; - if vw > 1.0 && vh > 1.0 { - let new_coord = match drag.orient { - TileEdgeOrient::Vertical => (p.x / vw).clamp(0.0, 1.0), - TileEdgeOrient::Horizontal => (p.y / vh).clamp(0.0, 1.0), - }; - let (min_w, min_h) = tile_min_norm(vw, vh); - let min_size = match drag.orient { - TileEdgeOrient::Vertical => min_w, - TileEdgeOrient::Horizontal => min_h, - }; - self.tabs[i].scene.move_model_tile_edge( - drag.orient, - drag.last_applied, - new_coord, - min_size, - ); - drag.last_applied = new_coord; - self.tabs[i].scene.camera_generation += 1; - } - return Task::none(); - } - // UCS icon grip drag: map the cursor onto the UCS plane and // slide the origin / rotate the axis. Short-circuits pan & snap. if let Some(kind) = self.ucs_grip_drag { @@ -669,21 +641,10 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { let vp_size = sel.vp_size; drop(sel); - // Hover (no button held): the tile under the cursor becomes - // active, so the camera + tile bounds used for picking below - // follow the pane the cursor is in. During a drag the active - // tile stays put so the operation finishes in its own pane. - if !dragging && vp_size.0 > 1.0 && vp_size.1 > 1.0 { - if self.tabs[i] - .scene - .set_active_model_tile_at(p.x / vp_size.0, p.y / vp_size.1) - { - self.tabs[i].scene.camera_generation += 1; - self.sync_render_mode_to_active_tile(i); - // Grid/snap follow the newly active viewport. - self.adopt_view_display(i); - } - } + // The active pane already follows the cursor (each pane's own + // mouse_area focuses it via `PaneMove` → `focus_model_pane`), so + // the camera + tile bounds used for picking below are already the + // pane the cursor is in. // Tile-relative picking: shadow `p` with the cursor mapped // into the active Model tile and `vp_size` with the tile's @@ -1398,6 +1359,65 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { self.tabs[i].scene.camera_generation += 1; } + // ── Per-pane Model viewport (pane_grid) ─────────────────────────────── + + /// Focus Model pane `idx` (cursor entered it): swap in its camera, sync the + /// render-mode / grid display. No-op if already active. + pub(super) fn focus_model_pane(&mut self, idx: usize) { + let i = self.active_tab; + if self.tabs[i].scene.set_active_model_tile(idx) { + self.tabs[i].scene.camera_generation += 1; + self.sync_render_mode_to_active_tile(i); + self.adopt_view_display(i); + } + } + + /// Convert a pane-local cursor point (from the pane's `mouse_area`) to the + /// canvas-relative point the viewport handlers expect. + pub(super) fn pane_canvas_point(&self, idx: usize, local: Point) -> Point { + let i = self.active_tab; + let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; + let o = self.tabs[i].scene.pane_origin_px(idx, vw, vh); + Point::new(o.x + local.x, o.y + local.y) + } + + pub(super) fn on_pane_resized( + &mut self, + ev: iced::widget::pane_grid::ResizeEvent, + ) -> Task { + let i = self.active_tab; + self.tabs[i].scene.model_panes.resize(ev.split, ev.ratio); + let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; + self.tabs[i].scene.sync_tiles_from_panes(vw, vh); + self.tabs[i].scene.camera_generation += 1; + Task::none() + } + + pub(super) fn on_pane_clicked( + &mut self, + pane: iced::widget::pane_grid::Pane, + ) -> Task { + let i = self.active_tab; + if let Some(&idx) = self.tabs[i].scene.model_panes.get(pane) { + self.focus_model_pane(idx); + } + Task::none() + } + + pub(super) fn on_pane_dragged( + &mut self, + ev: iced::widget::pane_grid::DragEvent, + ) -> Task { + if let iced::widget::pane_grid::DragEvent::Dropped { pane, target } = ev { + let i = self.active_tab; + self.tabs[i].scene.model_panes.drop(pane, target); + let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; + self.tabs[i].scene.sync_tiles_from_panes(vw, vh); + self.tabs[i].scene.camera_generation += 1; + } + Task::none() + } + pub(super) fn on_viewport_left_press(&mut self) -> Task { let i = self.active_tab; // A click in the viewport dismisses any open ribbon dropdown @@ -1497,48 +1517,9 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { self.ucs_icon_selected = false; } - // Tiled Model layout: an inner divider near the cursor - // becomes a resize grip. Start the drag and short-circuit - // (no pick / select / camera swap). Released on - // `ViewportLeftRelease`. - if self.tabs[i].active_cmd.is_none() - && self.tabs[i].scene.current_layout == "Model" - && vw > 1.0 - && vh > 1.0 - { - let canvas_bounds = iced::Rectangle { - x: 0.0, - y: 0.0, - width: vw, - height: vh, - }; - if let Some(edge) = - self.tabs[i] - .scene - .hit_model_tile_edge(p, canvas_bounds, TILE_EDGE_HIT_PX) - { - self.tile_drag = Some(crate::app::TileDrag { - orient: edge.orient, - last_applied: edge.coord, - }); - return Task::none(); - } - } - - // Tiled Model layout: clicking a non-active tile activates - // it (swapping in its camera) instead of selecting / drawing. - if self.tabs[i].active_cmd.is_none() && vw > 1.0 && vh > 1.0 { - if self.tabs[i] - .scene - .set_active_model_tile_at(p.x / vw, p.y / vh) - { - self.tabs[i].scene.camera_generation += 1; - self.sync_render_mode_to_active_tile(i); - // Grid/snap follow the newly active viewport. - self.adopt_view_display(i); - return Task::none(); - } - } + // Divider resize is handled natively by the input pane_grid + // (`on_resize`); the active pane already follows the cursor via + // `focus_model_pane`. So a press here goes straight to picking. // From here the click targets the active tile: map the // cursor into it and use the tile's size for picking, so @@ -1649,14 +1630,6 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { return Task::none(); } - // End an in-flight tile-divider drag. The move clamps - // each side to its minimum, so panes never collapse from - // dragging — use the close button to remove a pane. - if self.tile_drag.take().is_some() { - self.tabs[i].scene.camera_generation += 1; - return Task::none(); - } - // Commit a UCS icon grip drag: persist the new UCS so it // round-trips, and clear the lingering press state. if self.ucs_grip_drag.take().is_some() { diff --git a/src/app/view/controls.rs b/src/app/view/controls.rs index 0b3faa719..15c0ca376 100644 --- a/src/app/view/controls.rs +++ b/src/app/view/controls.rs @@ -2,7 +2,7 @@ use super::*; use super::super::document::{DynComponent, DynFieldEntry}; use super::super::Message; use iced::widget::{ - button, container, row, + button, container, mouse_area, row, }; use iced::{Background, Border, Color, Element, Theme}; @@ -25,6 +25,33 @@ pub(super) fn viewport_controls<'a>( ]; let light = Color { r: 0.85, g: 0.85, b: 0.85, a: 1.0 }; let accent = Color { r: 0.45, g: 0.70, b: 1.0, a: 1.0 }; + let green = Color { r: 0.36, g: 0.80, b: 0.45, a: 1.0 }; + let red = Color { r: 0.92, g: 0.38, b: 0.38, a: 1.0 }; + + // Fixed-colour icon button (close = red); colour stays on hover. + let tinted_btn = move |bytes: &'static [u8], color: Color, msg: Message| { + button(crate::ui::icons::tinted(bytes, 15.0, color)) + .on_press(msg) + .padding([4, 6]) + .style(move |_: &Theme, status| iced::widget::button::Style { + background: Some(Background::Color(match status { + iced::widget::button::Status::Hovered + | iced::widget::button::Status::Pressed => Color { + r: 0.25, + g: 0.25, + b: 0.25, + a: 0.9, + }, + _ => Color::TRANSPARENT, + })), + border: Border { + radius: 3.0.into(), + ..Default::default() + }, + text_color: color, + ..Default::default() + }) + }; // Borderless icon button; an `active` toggle gets an accent tint + fill. let icon_btn = move |bytes: &'static [u8], active: bool, msg: Message| { @@ -106,11 +133,29 @@ pub(super) fn viewport_controls<'a>( .push(icon_btn(crate::ui::icons::SPLIT_V, false, Message::SplitModelViewport(false))) .push(sep()) .push(icon_btn(crate::ui::icons::SPLIT_H, false, Message::SplitModelViewport(true))); - // Close button: only meaningful with more than one model tile. + // Drag handle + close: only meaningful with more than one model tile. + // The handle is a `mouse_area` (not a button) so it fires on press-DOWN, + // letting the drag continue onto the target pane to swap them (a button + // would only fire on release). Placed just left of Close. if tile_count > 1 { + let drag = mouse_area( + container(crate::ui::icons::tinted(crate::ui::icons::MOVE, 15.0, green)) + .padding([4, 6]) + .style(|_: &Theme| iced::widget::container::Style { + border: Border { + radius: 3.0.into(), + ..Default::default() + }, + ..Default::default() + }), + ) + .interaction(iced::mouse::Interaction::Grab) + .on_press(Message::PaneMoveStart); bar = bar .push(sep()) - .push(icon_btn(crate::ui::icons::CLOSE, false, Message::CloseModelViewport)); + .push(drag) + .push(sep()) + .push(tinted_btn(crate::ui::icons::CLOSE, red, Message::CloseModelViewport)); } } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 73022584d..3bcc531c5 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -7,7 +7,7 @@ use crate::scene::pick::grip::{grips_to_screen, grips_to_screen_paper, grips_to_ use crate::scene::view::viewport_pane::ViewportPane; use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX}; use iced::widget::{ - button, column, container, mouse_area, row, shader, stack, text, Row, + button, column, container, mouse_area, pane_grid, responsive, row, shader, stack, text, Row, Space, }; use iced::window; @@ -78,7 +78,7 @@ impl OpenCADStudio { // borders) and the floating content viewports blit on top. let viewport_3d: Element<'_, Message> = if tab.is_start { start_page_view() - } else { + } else if is_paper { shader(ViewportPane::model( &tab.scene, self.show_viewcube, @@ -87,6 +87,67 @@ impl OpenCADStudio { .width(Fill) .height(Fill) .into() + } else { + // Model space: a pane_grid of per-pane shader widgets (rendering + // only). The input mouse_areas live in a SECOND, identical pane_grid + // layered ABOVE the crosshair overlay (`model_input_layer` below) — + // they must sit above the selection overlay, whose `Hidden` cursor + // interaction otherwise "levitates" the cursor and starves any layer + // beneath it of mouse events. A separate eventless `responsive` + // Space captures the area size to keep `vp_size` / tile rects + // current (building the pane_grid inside `responsive` resets the + // mouse_areas' hover state and drops their move events). + let scene = &tab.scene; + let show_viewcube = self.show_viewcube; + let render_mode = tab.render_mode; + let size_probe: Element<'_, Message> = responsive(move |size| { + { + let mut sel = scene.selection.borrow_mut(); + sel.vp_size = (size.width, size.height); + } + scene.sync_tiles_from_panes(size.width, size.height); + Space::new().width(Fill).height(Fill).into() + }) + .into(); + let shaders = pane_grid::PaneGrid::new( + &scene.model_panes, + move |_pane, &idx, _maximized| { + pane_grid::Content::new( + shader(ViewportPane::for_pane( + scene, + show_viewcube, + render_mode, + idx, + )) + .width(Fill) + .height(Fill), + ) + }, + ) + .width(Fill) + .height(Fill) + .spacing(crate::scene::TILE_DIVIDER_PX); + stack![size_probe, shaders].width(Fill).height(Fill).into() + }; + + // Per-pane input layer: a pane_grid of transparent mouse_areas matching + // the shader pane_grid (same `model_panes` → identical layout). Layered + // above the crosshair overlay so it actually receives mouse events, and + // it owns the divider resize. Only built for the Model layout. + let model_input_layer: Option> = if is_paper || tab.is_start { + None + } else { + let scene = &tab.scene; + Some( + pane_grid::PaneGrid::new(&scene.model_panes, |_pane, &idx, _maximized| { + pane_grid::Content::new(pane_mouse_area(idx)) + }) + .width(Fill) + .height(Fill) + .spacing(crate::scene::TILE_DIVIDER_PX) + .on_resize(6.0, Message::PaneResized) + .into(), + ) }; let grid_overlay = { @@ -211,8 +272,8 @@ impl OpenCADStudio { // Rotation-only projection (view_proj_rte): the icon shows axis // DIRECTIONS only, so the full view_proj's huge UTM translation would // cancel catastrophically in f32 and make the tripod jitter. - let ucs_icon = if !self.show_ucs_icon { - None + let ucs_icons: Vec = if !self.show_ucs_icon { + vec![] } else if let Some((vp_cam, full)) = tab.scene.viewport_edit_frame((vw, vh)) { let (_, ux, uy, uz) = tab.ucs_xform().axes(); let origin_screen = self.ucs_icon_at_origin.then(|| { @@ -220,31 +281,51 @@ impl OpenCADStudio { .project(tab.ucs_origin_world(), full) .map(|p| iced::Point::new(full.x + p.x, full.y + p.y)) }).flatten(); - Some(crate::ui::overlay::UcsIconParams { + vec![crate::ui::overlay::UcsIconParams { view_proj: vp_cam.view_proj_rte(full), bounds: full, axes: (ux, uy, uz), origin_screen, hover: self.ucs_icon_hover, selected: self.ucs_icon_selected, - }) + }] } else if !is_paper { - let cam = tab.scene.camera.borrow(); + // One icon per Model pane — each at its own UCS origin, projected + // through that pane's camera at its pane rect. Only the active + // pane carries the interactive hover / selected grips. let (_, ux, uy, uz) = tab.ucs_xform().axes(); - let origin_screen = self.ucs_icon_at_origin.then(|| { - cam.project(tab.ucs_origin_world(), vp_bounds) - .map(|p| iced::Point::new(vp_bounds.x + p.x, vp_bounds.y + p.y)) - }).flatten(); - Some(crate::ui::overlay::UcsIconParams { - view_proj: cam.view_proj_rte(vp_bounds), - bounds: vp_bounds, - axes: (ux, uy, uz), - origin_screen, - hover: self.ucs_icon_hover, - selected: self.ucs_icon_selected, - }) + let origin_w = tab.ucs_origin_world(); + let active = tab.scene.active_model_tile.get(); + let live = tab.scene.camera.borrow().clone(); + tab.scene + .model_tiles + .borrow() + .iter() + .enumerate() + .map(|(i, t)| { + let b = 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), + }; + let cam = if i == active { live.clone() } else { t.camera.clone() }; + let origin_screen = self.ucs_icon_at_origin.then(|| { + cam.project(origin_w, b) + .map(|p| iced::Point::new(b.x + p.x, b.y + p.y)) + }).flatten(); + crate::ui::overlay::UcsIconParams { + view_proj: cam.view_proj_rte(b), + bounds: b, + axes: (ux, uy, uz), + origin_screen, + hover: i == active && self.ucs_icon_hover, + selected: i == active && self.ucs_icon_selected, + } + }) + .collect() } else { - None + vec![] }; // OST tracking points → screen positions, projected relative-to-eye @@ -272,22 +353,53 @@ impl OpenCADStudio { vec![] }; - // Model-space tile dividers (none in paper / single-tile layouts). - let tile_edges = if !is_paper { - tab.scene.model_tile_edges() + // Model-space pane dividers (none in paper / single-pane layouts). + let dividers = if !is_paper { + let (vw, vh) = tab.scene.selection.borrow().vp_size; + tab.scene.model_pane_dividers(vw, vh) } else { vec![] }; + // Pane move (drag-to-swap) visuals: source pane rect + the drop + // target pane under the cursor. + let (pane_move_rect, pane_drop_rect) = match self.pane_move_from { + Some(from) if !is_paper => { + let (vw, vh) = tab.scene.selection.borrow().vp_size; + let cursor = tab.scene.selection.borrow().last_move_pos; + let tiles = tab.scene.model_tiles.borrow(); + let px = |t: &crate::scene::ModelTile| iced::Rectangle { + x: t.rect.x * vw, + y: t.rect.y * vh, + width: t.rect.width * vw, + height: t.rect.height * vh, + }; + let src = tiles.get(from).map(px); + let drop = cursor.and_then(|c| { + tiles.iter().enumerate().find(|(i, t)| { + *i != from + && c.x >= t.rect.x * vw + && c.x < (t.rect.x + t.rect.width) * vw + && c.y >= t.rect.y * vh + && c.y < (t.rect.y + t.rect.height) * vh + }) + }); + (src, drop.map(|(_, t)| px(t))) + } + _ => (None, None), + }; + crate::ui::overlay::selection_overlay( sel, snap_info, grips, - ucs_icon, + ucs_icons, ost_points, tab.last_cursor_screen, !is_paper && self.show_viewcube, - tile_edges, + dividers, + pane_move_rect, + pane_drop_rect, tab.pan_mode, ) }; @@ -317,9 +429,10 @@ impl OpenCADStudio { tab.bg_color .map(|[r, g, b, a]| Color { r, g, b, a }) .unwrap_or(Color { - r: 0.11, - g: 0.11, - b: 0.11, + // Default model background: RGB (33, 40, 48). + r: 33.0 / 255.0, + g: 40.0 / 255.0, + b: 48.0 / 255.0, a: 1.0, }) }; @@ -445,12 +558,18 @@ impl OpenCADStudio { .height(Fill), viewport_3d, selection_overlay, - viewport_mouse, ] .width(Fill) .height(Fill) }; + // Per-pane input pane_grid goes ABOVE the crosshair overlay so it + // receives mouse events (the overlay's `Hidden` cursor would otherwise + // starve any layer beneath it). The controls bar is pushed on top of it. + if let Some(input) = model_input_layer { + viewport_stack = viewport_stack.push(input); + } + // Model-space render-mode picker, top-left. Sits ABOVE the // viewport mouse_area so clicks inside its bounds reach it // instead of the shader behind it; `opaque` stops them bubbling @@ -1675,6 +1794,23 @@ const BRAND_DARK: Color = Color { a: 1.0, }; +/// Transparent input layer for one Model pane: a `mouse_area` filling the pane +/// that emits pane-tagged viewport events (`idx` = the pane's tile index). The +/// handlers offset the pane-local point to canvas coords and focus the pane. +fn pane_mouse_area<'a>(idx: usize) -> Element<'a, Message> { + mouse_area(container(Space::new().width(Fill).height(Fill))) + .on_move(move |p| Message::PaneMove(idx, p)) + .on_press(Message::PanePress(idx)) + .on_release(Message::PaneRelease(idx)) + .on_right_press(Message::PaneRightPress(idx)) + .on_right_release(Message::PaneRightRelease(idx)) + .on_middle_press(Message::PaneMiddlePress(idx)) + .on_middle_release(Message::PaneMiddleRelease(idx)) + .on_scroll(move |d| Message::PaneScroll(idx, d)) + .on_exit(Message::ViewportExit) + .into() +} + pub(super) fn start_page_view<'a>() -> Element<'a, Message> { const TEXT: Color = Color { r: 0.94, diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 46ebe73cc..2c91adc48 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -348,6 +348,9 @@ impl Scene { *self.model_tiles.borrow_mut() = tiles; self.active_model_tile.set(0); *self.camera.borrow_mut() = active_cam; + // Rebuild the pane_grid layout from the restored tile rects so the panes + // match (the renderer / input iterate panes, not tiles). + self.rebuild_panes_from_tiles(); self.camera_generation += 1; true } diff --git a/src/scene/layout.rs b/src/scene/layout.rs index 19c5ef231..36068be47 100644 --- a/src/scene/layout.rs +++ b/src/scene/layout.rs @@ -121,420 +121,37 @@ impl Scene { } } } - /// Discover the inner divider edges between Model tiles. Each entry - /// is one draggable horizontal or vertical edge, with the span along - /// the perpendicular axis that the edge actually covers (the union - /// of touching tiles' extents). Coordinates are in normalized 0..1 - /// canvas space. Returns an empty list outside Model or for a - /// single-tile layout. - pub fn model_tile_edges(&self) -> Vec { - if self.current_layout != "Model" { - return vec![]; - } - let tiles = self.model_tiles.borrow(); - if tiles.len() < 2 { - return vec![]; - } - let mut out = Vec::new(); - // Collect candidate inner x's: any tile edge that's strictly - // inside (0, 1). Dedup by epsilon. - let mut xs: Vec = Vec::new(); - let mut ys: Vec = Vec::new(); - for t in tiles.iter() { - for x in [t.rect.x, t.rect.x + t.rect.width] { - if x > TILE_EPS && x < 1.0 - TILE_EPS { - xs.push(x); - } - } - for y in [t.rect.y, t.rect.y + t.rect.height] { - if y > TILE_EPS && y < 1.0 - TILE_EPS { - ys.push(y); - } - } - } - xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - xs.dedup_by(|a, b| (*a - *b).abs() < TILE_EPS); - ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - ys.dedup_by(|a, b| (*a - *b).abs() < TILE_EPS); - for x in xs { - let mut y0 = f32::INFINITY; - let mut y1 = f32::NEG_INFINITY; - let mut has_left = false; - let mut has_right = false; - for t in tiles.iter() { - if ((t.rect.x + t.rect.width) - x).abs() < TILE_EPS { - has_left = true; - y0 = y0.min(t.rect.y); - y1 = y1.max(t.rect.y + t.rect.height); - } - if (t.rect.x - x).abs() < TILE_EPS { - has_right = true; - y0 = y0.min(t.rect.y); - y1 = y1.max(t.rect.y + t.rect.height); - } - } - if has_left && has_right && y1 > y0 { - out.push(TileEdge { - orient: TileEdgeOrient::Vertical, - coord: x, - span: (y0, y1), - }); - } - } - for y in ys { - let mut x0 = f32::INFINITY; - let mut x1 = f32::NEG_INFINITY; - let mut has_top = false; - let mut has_bot = false; - for t in tiles.iter() { - if ((t.rect.y + t.rect.height) - y).abs() < TILE_EPS { - has_top = true; - x0 = x0.min(t.rect.x); - x1 = x1.max(t.rect.x + t.rect.width); - } - if (t.rect.y - y).abs() < TILE_EPS { - has_bot = true; - x0 = x0.min(t.rect.x); - x1 = x1.max(t.rect.x + t.rect.width); - } - } - if has_top && has_bot && x1 > x0 { - out.push(TileEdge { - orient: TileEdgeOrient::Horizontal, - coord: y, - span: (x0, x1), - }); - } - } - out - } - /// Hit-test the inner Model-tile dividers against a pixel cursor. - /// `bounds` is the canvas pixel rectangle (origin = canvas top-left). - /// Returns the closest edge within `tolerance_px` pixels of the cursor - /// along its perpendicular axis, also requiring the cursor to lie - /// within the edge's actual span. - pub fn hit_model_tile_edge( - &self, - cursor_px: iced::Point, - bounds: iced::Rectangle, - tolerance_px: f32, - ) -> Option { - if bounds.width <= 0.0 || bounds.height <= 0.0 { - return None; - } - let cx = cursor_px.x - bounds.x; - let cy = cursor_px.y - bounds.y; - let nx = cx / bounds.width; - let ny = cy / bounds.height; - let tol_nx = tolerance_px / bounds.width; - let tol_ny = tolerance_px / bounds.height; - let mut best: Option<(f32, TileEdge)> = None; - for e in self.model_tile_edges() { - let (dist, in_span) = match e.orient { - TileEdgeOrient::Vertical => ( - (e.coord - nx).abs() / tol_nx.max(1e-9), - ny >= e.span.0 && ny <= e.span.1, - ), - TileEdgeOrient::Horizontal => ( - (e.coord - ny).abs() / tol_ny.max(1e-9), - nx >= e.span.0 && nx <= e.span.1, - ), - }; - if in_span && dist <= 1.0 { - if best.as_ref().map_or(true, |(d, _)| dist < *d) { - best = Some((dist, e)); - } - } - } - best.map(|(_, e)| e) - } - - /// Move the inner divider edge from `old_coord` to `new_coord`, both - /// in normalized 0..1 space. Adjusts every tile that touches the - /// edge on either side. `min_size` clamps the new coordinate so no - /// tile on either side can shrink below the minimum — dragging a - /// divider to the screen edge stops at that minimum instead of - /// closing the pane (use the close button for that). - pub fn move_model_tile_edge( - &self, - orient: TileEdgeOrient, - old_coord: f32, - new_coord: f32, - min_size: f32, - ) { - let mut tiles = self.model_tiles.borrow_mut(); - // Clamp the new coordinate so no tile becomes ≤ 0 wide / tall. - // (Sub-`min_size` results are still allowed — the collapse pass - // handles those.) - let new_coord = match orient { - TileEdgeOrient::Vertical => { - let mut lo = 0.0_f32; - let mut hi = 1.0_f32; - for t in tiles.iter() { - if ((t.rect.x + t.rect.width) - old_coord).abs() < TILE_EPS { - lo = lo.max(t.rect.x + min_size); - } - if (t.rect.x - old_coord).abs() < TILE_EPS { - hi = hi.min(t.rect.x + t.rect.width - min_size); - } - } - new_coord.clamp(lo, hi.max(lo)) - } - TileEdgeOrient::Horizontal => { - let mut lo = 0.0_f32; - let mut hi = 1.0_f32; - for t in tiles.iter() { - if ((t.rect.y + t.rect.height) - old_coord).abs() < TILE_EPS { - lo = lo.max(t.rect.y + min_size); - } - if (t.rect.y - old_coord).abs() < TILE_EPS { - hi = hi.min(t.rect.y + t.rect.height - min_size); - } - } - new_coord.clamp(lo, hi.max(lo)) - } + /// 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, }; - for t in tiles.iter_mut() { - match orient { - TileEdgeOrient::Vertical => { - if ((t.rect.x + t.rect.width) - old_coord).abs() < TILE_EPS { - t.rect.width = (new_coord - t.rect.x).max(0.0); - } else if (t.rect.x - old_coord).abs() < TILE_EPS { - let old_right = t.rect.x + t.rect.width; - t.rect.x = new_coord; - t.rect.width = (old_right - new_coord).max(0.0); - } - } - TileEdgeOrient::Horizontal => { - if ((t.rect.y + t.rect.height) - old_coord).abs() < TILE_EPS { - t.rect.height = (new_coord - t.rect.y).max(0.0); - } else if (t.rect.y - old_coord).abs() < TILE_EPS { - let old_bottom = t.rect.y + t.rect.height; - t.rect.y = new_coord; - t.rect.height = (old_bottom - new_coord).max(0.0); - } - } - } - } - } - - /// Close the active Model tile, absorbing its area into the - /// neighbour that shares the longest contact edge and rebinding the - /// live camera to that neighbour. No-op with fewer than two tiles. - pub fn close_active_model_tile(&self) { - let mut tiles = self.model_tiles.borrow_mut(); - if tiles.len() < 2 { - return; - } - let idx = self.active_model_tile.get().min(tiles.len() - 1); - self.absorb_model_tile(&mut tiles, idx); - } - - /// Drop tile `idx`, growing the neighbour with the longest shared - /// contact edge to cover the vacated area. Fixes up - /// `active_model_tile` so the live camera stays bound to a real tile - /// (preferring the neighbour that absorbed it). Falls back to - /// stretching the first remaining tile to fill the canvas if the - /// tile has no axis-aligned neighbour. - fn absorb_model_tile(&self, tiles: &mut Vec, idx: usize) { - let removed = tiles[idx].rect; - // Find the neighbour with the longest shared contact edge. - let mut best: Option<(usize, f32, ContactSide)> = None; - for (j, t) in tiles.iter().enumerate() { - if j == idx { - continue; - } - let probes = [ - ( - ContactSide::Left, - ((t.rect.x + t.rect.width) - removed.x).abs() < TILE_EPS, - overlap_len( - (t.rect.y, t.rect.y + t.rect.height), - (removed.y, removed.y + removed.height), - ), - ), - ( - ContactSide::Right, - (t.rect.x - (removed.x + removed.width)).abs() < TILE_EPS, - overlap_len( - (t.rect.y, t.rect.y + t.rect.height), - (removed.y, removed.y + removed.height), - ), - ), - ( - ContactSide::Top, - ((t.rect.y + t.rect.height) - removed.y).abs() < TILE_EPS, - overlap_len( - (t.rect.x, t.rect.x + t.rect.width), - (removed.x, removed.x + removed.width), - ), - ), - ( - ContactSide::Bottom, - (t.rect.y - (removed.y + removed.height)).abs() < TILE_EPS, - overlap_len( - (t.rect.x, t.rect.x + t.rect.width), - (removed.x, removed.x + removed.width), - ), - ), - ]; - for (side, touches, c) in probes { - if touches && c > 0.0 { - if best.map_or(true, |(_, len, _)| c > len) { - best = Some((j, c, side)); - } - } - } - } - if let Some((nbr_idx, _, side)) = best { - match side { - ContactSide::Left => { - tiles[nbr_idx].rect.width = - (removed.x + removed.width) - tiles[nbr_idx].rect.x; - } - ContactSide::Right => { - let old_right = - tiles[nbr_idx].rect.x + tiles[nbr_idx].rect.width; - tiles[nbr_idx].rect.x = removed.x; - tiles[nbr_idx].rect.width = old_right - removed.x; - } - ContactSide::Top => { - tiles[nbr_idx].rect.height = - (removed.y + removed.height) - tiles[nbr_idx].rect.y; - } - ContactSide::Bottom => { - let old_bottom = - tiles[nbr_idx].rect.y + tiles[nbr_idx].rect.height; - tiles[nbr_idx].rect.y = removed.y; - tiles[nbr_idx].rect.height = old_bottom - removed.y; - } - } - let active = self.active_model_tile.get(); - let new_active = if active == idx { - if nbr_idx > idx { nbr_idx - 1 } else { nbr_idx } - } else if active > idx { - active - 1 - } else { - active - }; - tiles.remove(idx); - self.active_model_tile - .set(new_active.min(tiles.len().saturating_sub(1))); + let config = if items.is_empty() { + iced::widget::pane_grid::Configuration::Pane(0) } else { - // Isolated tile (shouldn't happen with axis-aligned - // splits) — drop it and stretch the first remaining - // tile to fill the canvas so we don't leave a hole. - tiles.remove(idx); - let active = self.active_model_tile.get(); - self.active_model_tile - .set(active.saturating_sub(if active > idx { 1 } else { 0 }).min(tiles.len().saturating_sub(1))); - if let Some(first) = tiles.first_mut() { - first.rect = iced::Rectangle { - x: 0.0, - y: 0.0, - width: 1.0, - height: 1.0, - }; - } - } + config_from_rects(&items, full) + }; + self.model_panes = iced::widget::pane_grid::State::with_configuration(config); } - /// Split the active Model tile in two. `horizontal` → a horizontal - /// divider (top / bottom halves); otherwise a vertical divider (left / - /// right). Both halves inherit the active tile's current camera; the - /// active tile stays the first half. No-op outside the Model layout. - pub fn split_active_model_tile(&self, horizontal: bool) { - if self.current_layout != "Model" { - return; - } - let cam_now = self.camera.borrow().clone(); - let mut tiles = self.model_tiles.borrow_mut(); - let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1)); - let r = tiles[active].rect; - let (a, b) = if horizontal { - ( - iced::Rectangle { height: r.height / 2.0, ..r }, - iced::Rectangle { - y: r.y + r.height / 2.0, - height: r.height / 2.0, - ..r - }, - ) - } else { - ( - iced::Rectangle { width: r.width / 2.0, ..r }, - iced::Rectangle { - x: r.x + r.width / 2.0, - width: r.width / 2.0, - ..r - }, - ) - }; - let mode = tiles[active].render_mode; - let (grid_on, snap_on) = (tiles[active].grid_on, tiles[active].snap_on); - tiles[active] = ModelTile { - rect: a, - camera: cam_now.clone(), - render_mode: mode, - grid_on, - snap_on, - }; - tiles.insert( - active + 1, - ModelTile { - rect: b, - camera: cam_now, - render_mode: mode, - grid_on, - snap_on, - }, - ); - } - - /// Make the Model tile containing normalized point `(nx, ny)` active, - /// swapping cameras so the live `Scene::camera` follows the new tile. - /// Returns `true` when the active tile changed. No-op outside Model. - pub fn set_active_model_tile_at(&self, nx: f32, ny: f32) -> bool { - if self.current_layout != "Model" { - return false; - } - let new = { - let tiles = self.model_tiles.borrow(); - tiles.iter().position(|t| { - nx >= t.rect.x - && nx < t.rect.x + t.rect.width - && ny >= t.rect.y - && ny < t.rect.y + t.rect.height - }) - }; - let Some(new) = new else { return false }; - let old = self.active_model_tile.get(); - if new == old { - return false; - } - // Stash the live camera into the outgoing tile, load the incoming. - 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(new).map(|t| t.camera.clone()) - }; - if let Some(cam) = incoming { - *self.camera.borrow_mut() = cam; - } - self.active_model_tile.set(new); - // Caller bumps camera_generation (it needs &mut Scene). - true - } - - /// 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`. - pub fn set_model_tile_layout(&self, rects: Vec) { - let cam_now = self.camera.borrow().clone(); - // Every new pane inherits the active tile's current visual style. + /// 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) { + 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)); @@ -547,35 +164,264 @@ impl Scene { false, )) }; - let tiles: Vec = rects - .into_iter() - .map(|rect| ModelTile { - rect, - camera: cam_now.clone(), - render_mode: mode, - grid_on, - snap_on, - }) - .collect(); - *self.model_tiles.borrow_mut() = if tiles.is_empty() { - vec![ModelTile { + self.model_panes = iced::widget::pane_grid::State::with_configuration(config); + let order: Vec = + 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_now, + camera: cam.clone(), render_mode: mode, grid_on, snap_on, - }] - } else { - tiles - }; + }); + } + *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 = + 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 { + 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 / @@ -609,14 +455,99 @@ impl Scene { } } -#[derive(Copy, Clone, Debug)] -enum ContactSide { - Left, - Right, - Top, - Bottom, -} -fn overlap_len(a: (f32, f32), b: (f32, f32)) -> f32 { - (a.1.min(b.1) - a.0.max(b.0)).max(0.0) +/// 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 { + 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 = 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 = 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)), + } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 7247e5883..0632fb9d7 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -527,27 +527,10 @@ pub(crate) struct ModelTile { pub(crate) snap_on: bool, } -/// Tolerance for matching two normalized tile coordinates as "the same" -/// edge — drag math leaves small floating-point residue. -const TILE_EPS: f32 = 1e-4; - -#[derive(Copy, Clone, Debug)] -pub enum TileEdgeOrient { - Vertical, - Horizontal, -} - -/// One inner divider between Model tiles, exposed by [`Scene::model_tile_edges`]. -/// `coord` is the fixed axis (x for vertical, y for horizontal) and `span` -/// is the perpendicular extent over which the divider actually separates -/// tiles. All values are normalized to the 0..1 canvas. -#[derive(Clone, Debug)] -pub struct TileEdge { - pub orient: TileEdgeOrient, - pub coord: f32, - pub span: (f32, f32), -} - +/// Gap (pixels) between Model panes — the `pane_grid` spacing and the visible +/// divider width. The renderer derives tile rects through this same spacing so +/// the drawn viewports line up exactly with the pane_grid layout. +pub const TILE_DIVIDER_PX: f32 = 2.0; /// Shift every vertex of a freshly tessellated `MeshLodSet` into the /// scene's local f32 space by subtracting `world_offset`. ACIS / SAT @@ -693,6 +676,14 @@ pub struct Scene { pub(crate) model_tiles: RefCell>, /// Index of the active model tile (camera input + overlays target it). pub(crate) active_model_tile: std::cell::Cell, + /// pane_grid layout tree for the Model tab — the source of truth for the + /// tile split layout, resize and focus. `model_tiles` (the renderer's + /// per-pane data: camera / render-mode / grid) is kept in lock-step with + /// it, and its rects are derived from the pane regions. Each pane's value + /// is the index of its backing `ModelTile`. Paper layout is unaffected. + /// Plain field (not a `RefCell`) so the view can borrow it for the + /// `PaneGrid` widget's lifetime; mutated through `&mut Scene` in update. + pub(crate) model_panes: iced::widget::pane_grid::State, pub selection: Rc>, /// The CAD document — single source of truth for all entities. pub document: CadDocument, @@ -921,6 +912,8 @@ impl Scene { snap_on: false, }]), active_model_tile: std::cell::Cell::new(0), + // One pane mapped to tile 0 — matches the single default tile above. + model_panes: iced::widget::pane_grid::State::new(0).0, selection: Rc::new(RefCell::new(SelectionState::default())), document: CadDocument::new(), selected: HashSet::default(), diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 60cc182dc..80497e381 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -2165,8 +2165,11 @@ pub struct MultiPipeline { } impl MultiPipeline { - /// Ensure exactly `n` (≥1) inner pipelines exist, creating any missing - /// ones. Extra pipelines beyond `n` are dropped. + /// Ensure at least `n` (≥1) inner pipelines exist, creating any missing + /// ones. Grow-only: extra pipelines are NOT dropped, because per-pane Model + /// shader widgets share this storage and each only touches its own slot — + /// truncating mid-frame would destroy another pane's slot. Stale inners (a + /// closed pane, or fewer paper viewports) are harmless idle GPU resources. pub(crate) fn ensure_len( &mut self, device: &wgpu::Device, @@ -2177,7 +2180,6 @@ impl MultiPipeline { while self.inners.len() < n { self.inners.push(Pipeline::new(device, queue, self.format)); } - self.inners.truncate(n); } } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index e57245bcd..342159c59 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -113,6 +113,13 @@ pub struct Primitive { pub(in crate::scene) viewports: Vec, /// Background color used to clear each viewport's MSAA buffer. pub(in crate::scene) bg_color: [f32; 4], + /// First `MultiPipeline` inner slot this primitive owns. Paper space (one + /// shader widget, many viewports) uses 0. Per-pane Model widgets each own a + /// distinct slot (= their tile index) so several shader widgets can share + /// the type-keyed pipeline storage without clobbering one another — all + /// `prepare` calls run before all `render` calls, so disjoint slots are + /// safe. + pub(in crate::scene) base_slot: usize, } /// Flags the render pipeline consumes, derived from @@ -201,10 +208,10 @@ impl shader::Primitive for Primitive { let phys = viewport.physical_size(); let full_size = Size::new(phys.width, phys.height); let scale = viewport.scale_factor() as f32; - pipeline.ensure_len(device, queue, self.viewports.len()); + pipeline.ensure_len(device, queue, self.base_slot + self.viewports.len()); for (i, vp) in self.viewports.iter().enumerate() { - let inner = &mut pipeline.inners[i]; + let inner = &mut pipeline.inners[self.base_slot + i]; // The MSAA / depth / resolve textures are always sized to the // FULL viewport rectangle (not the on-canvas-visible portion) // so the camera matrices render at consistent aspect / scale. @@ -347,7 +354,7 @@ impl shader::Primitive for Primitive { let clip_right = clip.x + clip.width; let clip_bottom = clip.y + clip.height; for (i, vp) in self.viewports.iter().enumerate() { - let Some(inner) = pipeline.inners.get(i) else { + let Some(inner) = pipeline.inners.get(self.base_slot + i) else { break; }; // Where the viewport would land on the surface in absolute @@ -628,7 +635,72 @@ impl Scene { .collect(); // Empty viewports → blit nothing; the container background (model bg // or the paper desk colour) stays visible. - Primitive { viewports, bg_color } + Primitive { + viewports, + bg_color, + base_slot: 0, + } + } + + /// Build a single-pane Model primitive: the viewport for tile `tile_idx`, + /// filling the shader widget's own `bounds` (= the pane rectangle the + /// `pane_grid` laid out). Each Model pane is its own shader widget, so the + /// camera matrices use the pane aspect for free and the primitive owns + /// pipeline slot `tile_idx`. The active tile renders the live camera / + /// render-mode; the rest use their stored snapshot. + pub(in crate::scene) fn build_viewport_for_pane( + &self, + bounds: Rectangle, + tile_idx: usize, + model_render_mode: acadrust::entities::ViewportRenderMode, + ) -> Primitive { + let hover_region = self.viewcube_hover.get(); + let canvas = (bounds.width.max(1.0), bounds.height.max(1.0)); + let bg_color = [0.0, 0.0, 0.0, 0.0]; + let tiles = self.model_tiles.borrow(); + let Some(tile) = tiles.get(tile_idx) else { + return Primitive { + viewports: vec![], + bg_color, + base_slot: tile_idx, + }; + }; + let active = self.active_model_tile.get(); + let is_active = tile_idx == active; + let camera = if is_active { + self.camera.borrow().clone() + } else { + tile.camera.clone() + }; + let inst = ViewportInstance { + handle: Handle::NULL, + tile_idx: Some(tile_idx), + // Fills the whole widget (= pane); normalized rect is (0,0,1,1). + screen_rect: Rectangle { + x: 0.0, + y: 0.0, + width: canvas.0, + height: canvas.1, + }, + camera, + render_mode: if is_active { + model_render_mode + } else { + tile.render_mode + }, + active: is_active, + grid_on: tile.grid_on, + paper_sheet: false, + }; + let viewports = self + .viewport_data_for(&inst, canvas, hover_region) + .into_iter() + .collect(); + Primitive { + viewports, + bg_color, + base_slot: tile_idx, + } } /// Build one `ViewportData` from a `ViewportInstance`: gathers the diff --git a/src/scene/view/viewport_pane.rs b/src/scene/view/viewport_pane.rs index 93f00e100..a50967022 100644 --- a/src/scene/view/viewport_pane.rs +++ b/src/scene/view/viewport_pane.rs @@ -19,6 +19,10 @@ pub struct ViewportPane<'a> { /// Render mode applied to the Model layout's tiles. Paper-space content /// viewports use the render mode stored on their own viewport entity. pub render_mode: acadrust::entities::ViewportRenderMode, + /// `Some(tile_idx)` → this widget renders a single Model pane (one shader + /// per `pane_grid` pane, filling its own bounds). `None` → the unified + /// full-canvas path (paper layout, or the whole-canvas Model fallback). + pub pane: Option, } impl<'a> ViewportPane<'a> { @@ -31,6 +35,23 @@ impl<'a> ViewportPane<'a> { scene, show_viewcube, render_mode, + pane: None, + } + } + + /// One Model `pane_grid` pane: renders just `tile_idx` into this widget's + /// own bounds (the pane rectangle). + pub fn for_pane( + scene: &'a Scene, + show_viewcube: bool, + render_mode: acadrust::entities::ViewportRenderMode, + tile_idx: usize, + ) -> Self { + Self { + scene, + show_viewcube, + render_mode, + pane: Some(tile_idx), } } } @@ -47,8 +68,14 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for ViewportPane<'a> _cursor: mouse::Cursor, bounds: Rectangle, ) -> Self::Primitive { - self.scene - .build_viewports(bounds, self.render_mode, state.hover_region) + match self.pane { + Some(idx) => self + .scene + .build_viewport_for_pane(bounds, idx, self.render_mode), + None => self + .scene + .build_viewports(bounds, self.render_mode, state.hover_region), + } } fn update( diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index bef7ad78d..324b13f5d 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -181,22 +181,26 @@ pub fn selection_overlay<'a>( selection: SelectionState, snap: Option<(Point, SnapType)>, grips: Vec, - ucs_icon: Option, + ucs_icons: Vec, ost_points: Vec, cursor_screen: Point, show_viewcube: bool, - tile_edges: Vec, + dividers: Vec, + pane_move_rect: Option, + pane_drop_rect: Option, pan_mode: bool, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, snap, grips, - ucs_icon, + ucs_icons, ost_points, cursor_screen, show_viewcube, - tile_edges, + dividers, + pane_move_rect, + pane_drop_rect, pan_mode, }) .width(Length::Fill) @@ -208,53 +212,41 @@ struct SelectionCanvas { selection: SelectionState, snap: Option<(Point, SnapType)>, grips: Vec, - ucs_icon: Option, + /// One UCS icon per Model pane (each viewport shows its own at its origin); + /// a single entry for paper / floating-viewport. Only the active pane's + /// entry carries hover/selected (grips). + ucs_icons: Vec, ost_points: Vec, cursor_screen: Point, show_viewcube: bool, - tile_edges: Vec, + /// Divider bars (pixel rects, canvas-relative) between Model panes — drawn + /// as filled lines and used to suppress the crosshair over a divider. + dividers: Vec, + /// When a pane move is armed (drag handle pressed), the source pane's rect + /// (px) — dimmed, with a ghost card dragged along under the cursor. + pane_move_rect: Option, + /// The pane under the cursor during a pane move (drop target), highlighted. + pane_drop_rect: Option, /// Interactive PAN mode: the crosshair is hidden and the cursor becomes a /// hand so the viewport reads as a draggable surface. pan_mode: bool, } impl SelectionCanvas { - /// Returns the orientation of the Model-tile divider the cursor sits - /// on (within a few pixels of perpendicular distance, inside the - /// edge's span). Used by both `mouse_interaction` (to pick a resize - /// cursor) and `draw` (to suppress the CAD crosshair). - fn tile_edge_under( - &self, - cursor: mouse::Cursor, - bounds: iced::Rectangle, - ) -> Option { - const TOL_PX: f32 = 4.0; - let pos = cursor.position_in(bounds)?; - for e in &self.tile_edges { - let (perp, edge_px, span_lo, span_hi, pos_along) = match e.orient { - crate::scene::TileEdgeOrient::Vertical => ( - pos.x, - e.coord * bounds.width, - e.span.0 * bounds.height, - e.span.1 * bounds.height, - pos.y, - ), - crate::scene::TileEdgeOrient::Horizontal => ( - pos.y, - e.coord * bounds.height, - e.span.0 * bounds.width, - e.span.1 * bounds.width, - pos.x, - ), - }; - if (perp - edge_px).abs() <= TOL_PX - && pos_along >= span_lo - && pos_along <= span_hi - { - return Some(e.orient); - } - } - None + /// True when the cursor sits on a Model-pane divider (within a few px), so + /// `draw` can suppress the CAD crosshair there. The resize cursor itself is + /// supplied by the input pane_grid layered above. + fn divider_under(&self, cursor: mouse::Cursor, bounds: iced::Rectangle) -> bool { + const TOL_PX: f32 = 3.0; + let Some(pos) = cursor.position_in(bounds) else { + return false; + }; + self.dividers.iter().any(|d| { + pos.x >= d.x - TOL_PX + && pos.x <= d.x + d.width + TOL_PX + && pos.y >= d.y - TOL_PX + && pos.y <= d.y + d.height + TOL_PX + }) } } @@ -290,20 +282,9 @@ impl canvas::Program for SelectionCanvas { } } } - // Hover over a Model-tile divider → resize cursor cue. The - // system cursor is intentionally shown here so the OS arrow gives - // its own resize affordance; the draw step suppresses the custom - // CAD crosshair while we're over a divider. - if let Some(orient) = self.tile_edge_under(cursor, bounds) { - return match orient { - crate::scene::TileEdgeOrient::Vertical => { - mouse::Interaction::ResizingHorizontally - } - crate::scene::TileEdgeOrient::Horizontal => { - mouse::Interaction::ResizingVertically - } - }; - } + // The resize cursor over a divider is supplied by the input pane_grid + // layered above this overlay; `draw` only suppresses the CAD crosshair + // there (see `divider_under`). // Over the viewport (no divider, no viewcube): hide the system // cursor entirely. `Interaction::None` would let the stack fall // through to a sibling — `Hidden` is the explicit "no cursor" @@ -325,43 +306,87 @@ impl canvas::Program for SelectionCanvas { ) -> Vec { let mut frame = canvas::Frame::new(renderer, bounds.size()); - // ── Tile dividers (Model-space tiled layout) ────────────────────── - // Drawn first so all other overlays sit on top — the line is just - // a visual cue for the resize-drag handles. - if !self.tile_edges.is_empty() { + // ── Pane dividers (Model-space tiled layout) ────────────────────── + // Filled bars in the pane_grid spacing gaps, so adjacent panes read as + // distinct viewports. Drawn first so all other overlays sit on top. + if !self.dividers.is_empty() { const DIVIDER: Color = Color { - r: 0.40, - g: 0.45, - b: 0.55, - a: 0.85, + r: 0.46, + g: 0.52, + b: 0.62, + a: 1.0, }; - for e in &self.tile_edges { - let (a, b) = match e.orient { - crate::scene::TileEdgeOrient::Vertical => { - let x = e.coord * bounds.width; - ( - Point::new(x, e.span.0 * bounds.height), - Point::new(x, e.span.1 * bounds.height), - ) - } - crate::scene::TileEdgeOrient::Horizontal => { - let y = e.coord * bounds.height; - ( - Point::new(e.span.0 * bounds.width, y), - Point::new(e.span.1 * bounds.width, y), - ) - } - }; - let path = canvas::Path::new(|p| { - p.move_to(a); - p.line_to(b); - }); + for d in &self.dividers { + let bar = canvas::Path::rectangle( + Point::new(d.x, d.y), + iced::Size::new(d.width.max(1.0), d.height.max(1.0)), + ); + frame.fill(&bar, DIVIDER); + } + } + + // ── Pane move (drag-to-swap) ────────────────────────────────────── + // While armed: dim the lifted source pane, highlight the drop target + // under the cursor, and drag a translucent ghost card along the cursor + // so the pane is visibly "moving". + if let Some(src) = self.pane_move_rect { + let accent = Color { + r: 0.30, + g: 0.62, + b: 1.0, + a: 1.0, + }; + // Source pane: dimmed + dashed-feel outline (it has been lifted). + let src_path = + canvas::Path::rectangle(Point::new(src.x, src.y), iced::Size::new(src.width, src.height)); + frame.fill( + &src_path, + Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.28, + }, + ); + frame.stroke( + &src_path, + canvas::Stroke { + width: 1.5, + style: canvas::Style::Solid(Color { a: 0.6, ..accent }), + ..Default::default() + }, + ); + // Drop target pane: bright fill + outline. + if let Some(dst) = self.pane_drop_rect { + let dst_path = canvas::Path::rectangle( + Point::new(dst.x, dst.y), + iced::Size::new(dst.width, dst.height), + ); + frame.fill(&dst_path, Color { a: 0.16, ..accent }); frame.stroke( - &path, + &dst_path, + canvas::Stroke { + width: 2.5, + style: canvas::Style::Solid(accent), + ..Default::default() + }, + ); + } + // Ghost card dragged under the cursor — a 0.32× preview of the + // source pane, centred on the cursor. + if let Some(c) = self.selection.last_move_pos { + let gw = (src.width * 0.32).clamp(60.0, 280.0); + let gh = (src.height * 0.32).clamp(40.0, 200.0); + let g = canvas::Path::rectangle( + Point::new(c.x - gw * 0.5, c.y - gh * 0.5), + iced::Size::new(gw, gh), + ); + frame.fill(&g, Color { a: 0.30, ..accent }); + frame.stroke( + &g, canvas::Stroke { width: 2.0, - style: canvas::Style::Solid(DIVIDER), - line_cap: canvas::LineCap::Butt, + style: canvas::Style::Solid(Color { a: 0.95, ..accent }), ..Default::default() }, ); @@ -799,7 +824,7 @@ impl canvas::Program for SelectionCanvas { // Over a Model-tile divider the OS cursor switches to a resize // arrow (see `mouse_interaction`); drawing the CAD crosshair on // top of it would double up the visual feedback. - let over_divider = self.tile_edge_under(cursor, bounds).is_some(); + let over_divider = self.divider_under(cursor, bounds); // PAN mode replaces the crosshair with a hand cursor. if !over_viewcube && !over_divider && !self.pan_mode { if let Some(cp) = self.selection.last_move_pos { @@ -849,8 +874,8 @@ impl canvas::Program for SelectionCanvas { } } // end !over_viewcube - // ── UCS icon ────────────────────────────────────────────────────── - if let Some(ref ucs) = self.ucs_icon { + // ── UCS icon (one per Model pane) ───────────────────────────────── + for ucs in &self.ucs_icons { draw_ucs_icon( &mut frame, ucs.view_proj,