refactor(viewport): migrate model-space tiling to iced pane_grid

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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-27 11:50:11 +03:00
commit 495b13ec4f
18 changed files with 1004 additions and 733 deletions

View file

@ -370,31 +370,33 @@ impl OpenCADStudio {
.push_info("VPORTS Configuration [SIngle/2H/2V/4]:"); .push_info("VPORTS Configuration [SIngle/2H/2V/4]:");
return Some(self.focus_cmd_input()); return Some(self.focus_cmd_input());
} }
// Model space: split the tiled viewport layout. // Model space: split the tiled viewport layout via pane_grid.
use iced::Rectangle as R; use iced::widget::pane_grid::{Axis, Configuration as C};
let full = R { x: 0.0, y: 0.0, width: 1.0, height: 1.0 }; let split = |axis, a, b| C::Split {
let rects: Option<Vec<R>> = match sub.as_str() { axis,
"SINGLE" | "SI" | "1" => Some(vec![full]), ratio: 0.5,
"2H" | "2" => Some(vec![ a: Box::new(a),
R { x: 0.0, y: 0.0, width: 1.0, height: 0.5 }, b: Box::new(b),
R { x: 0.0, y: 0.5, width: 1.0, height: 0.5 }, };
]), let config: Option<(C<usize>, usize)> = match sub.as_str() {
"2V" => Some(vec![ "SINGLE" | "SI" | "1" => Some((C::Pane(0), 1)),
R { x: 0.0, y: 0.0, width: 0.5, height: 1.0 }, "2H" | "2" => {
R { x: 0.5, y: 0.0, width: 0.5, height: 1.0 }, Some((split(Axis::Horizontal, C::Pane(0), C::Pane(1)), 2))
]), }
"4" => Some(vec![ "2V" => Some((split(Axis::Vertical, C::Pane(0), C::Pane(1)), 2)),
R { x: 0.0, y: 0.0, width: 0.5, height: 0.5 }, "4" => Some((
R { x: 0.5, y: 0.0, width: 0.5, height: 0.5 }, split(
R { x: 0.0, y: 0.5, width: 0.5, height: 0.5 }, Axis::Vertical,
R { x: 0.5, y: 0.5, width: 0.5, height: 0.5 }, split(Axis::Horizontal, C::Pane(0), C::Pane(2)),
]), split(Axis::Horizontal, C::Pane(1), C::Pane(3)),
),
4,
)),
_ => None, _ => None,
}; };
match rects { match config {
Some(rects) => { Some((config, n)) => {
let n = rects.len(); self.tabs[i].scene.set_model_panes(config);
self.tabs[i].scene.set_model_tile_layout(rects);
self.tabs[i].scene.camera_generation += 1; self.tabs[i].scene.camera_generation += 1;
self.command_line self.command_line
.push_output(&format!("VPORTS: {n} viewport(s).")); .push_output(&format!("VPORTS: {n} viewport(s)."));

View file

@ -26,18 +26,7 @@ pub use style_ops::StyleKind;
use document::DocumentTab; use document::DocumentTab;
use crate::modules::ModuleEvent; use crate::modules::ModuleEvent;
use crate::scene::{CubeRegion, TileEdgeOrient}; use crate::scene::CubeRegion;
/// 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,
}
/// Which UCS-icon grip is being dragged. `Origin` slides the UCS origin within /// 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 /// 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 /// `true` after a bare `VPORTS` in model space — the next command-line
/// entry is treated as the tiled-config option (SIngle/2H/2V/4). /// entry is treated as the tiled-config option (SIngle/2H/2V/4).
awaiting_vports: bool, 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<TileDrag>,
/// Cursor is hovering over the UCS icon body — drives the hover highlight. /// Cursor is hovering over the UCS icon body — drives the hover highlight.
ucs_icon_hover: bool, ucs_icon_hover: bool,
/// UCS icon is selected (clicked): its grips are shown and draggable. /// 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). /// 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. /// Set on press over a grip, updated on move, committed on release.
ucs_grip_drag: Option<UcsGripKind>, ucs_grip_drag: Option<UcsGripKind>,
/// 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<usize>,
/// `true` once the user has reshaped the dynamic-input field set via /// `true` once the user has reshaped the dynamic-input field set via
/// the `,` separator during the current command iteration. Tells /// the `,` separator during the current command iteration. Tells
/// `sync_dyn_fields` to preserve the user's chosen shape instead of /// `sync_dyn_fields` to preserve the user's chosen shape instead of
@ -1143,6 +1131,26 @@ pub enum Message {
ViewportMiddleRelease, ViewportMiddleRelease,
ViewportScroll(mouse::ScrollDelta), ViewportScroll(mouse::ScrollDelta),
ViewportExit, 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), ViewCubeSnap(CubeRegion),
/// World-frame view snap from a compass cardinal (N/E/S/W), bypassing the /// World-frame view snap from a compass cardinal (N/E/S/W), bypassing the
/// UCS so the compass stays world-aligned. /// UCS so the compass stays world-aligned.
@ -1806,10 +1814,10 @@ impl OpenCADStudio {
default_bg_color: None, default_bg_color: None,
default_paper_bg_color: None, default_paper_bg_color: None,
awaiting_vports: false, awaiting_vports: false,
tile_drag: None,
ucs_icon_hover: false, ucs_icon_hover: false,
ucs_icon_selected: false, ucs_icon_selected: false,
ucs_grip_drag: None, ucs_grip_drag: None,
pane_move_from: None,
dyn_user_reshaped: false, dyn_user_reshaped: false,
grip_hover: None, grip_hover: None,
grip_popup: None, grip_popup: None,

View file

@ -2,7 +2,7 @@
#![allow(unused_imports)] #![allow(unused_imports)]
use super::util::*; 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::{ use crate::app::helpers::{
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
CoordKind, 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::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
use crate::scene::model::object::GripApply; use crate::scene::model::object::GripApply;
use crate::scene::{ 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 crate::ui::PropertiesPanel;
use acadrust::types::Color as AcadColor; use acadrust::types::Color as AcadColor;
@ -402,6 +402,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
self.text_inline_cancel(); self.text_inline_cancel();
return self.post_editor_closed(false); 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 // UCS icon: Esc ends any grip drag and clears the selection
// (only when no command owns Escape). // (only when no command owns Escape).
if self.tabs[self.active_tab].active_cmd.is_none() if self.tabs[self.active_tab].active_cmd.is_none()

View file

@ -2,7 +2,7 @@
#![allow(unused_imports)] #![allow(unused_imports)]
use super::util::*; 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::{ use crate::app::helpers::{
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
CoordKind, 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::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
use crate::scene::model::object::GripApply; use crate::scene::model::object::GripApply;
use crate::scene::{ 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 crate::ui::PropertiesPanel;
use acadrust::types::Color as AcadColor; use acadrust::types::Color as AcadColor;

View file

@ -2,7 +2,7 @@
#![allow(unused_imports)] #![allow(unused_imports)]
use super::util::*; 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::{ use crate::app::helpers::{
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
CoordKind, 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::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
use crate::scene::model::object::GripApply; use crate::scene::model::object::GripApply;
use crate::scene::{ 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 crate::ui::PropertiesPanel;
use acadrust::types::Color as AcadColor; use acadrust::types::Color as AcadColor;

View file

@ -2,7 +2,7 @@
#![allow(unused_imports)] #![allow(unused_imports)]
use super::util::*; 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::{ use crate::app::helpers::{
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
CoordKind, 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::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
use crate::scene::model::object::GripApply; use crate::scene::model::object::GripApply;
use crate::scene::{ 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 crate::ui::PropertiesPanel;
use acadrust::types::Color as AcadColor; use acadrust::types::Color as AcadColor;

View file

@ -1,7 +1,5 @@
use super::{Message, OpenCADStudio}; use super::{Message, OpenCADStudio};
use crate::scene::{ use crate::scene::VIEWCUBE_DRAW_PX;
VIEWCUBE_DRAW_PX, VIEWCUBE_PAD,
};
use crate::ui::PropertiesPanel; use crate::ui::PropertiesPanel;
use acadrust::types::Color as AcadColor; use acadrust::types::Color as AcadColor;
use iced::time::Instant; 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; 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 { fn format_size(bytes: u64) -> String {
const KB: f64 = 1024.0; const KB: f64 = 1024.0;
@ -706,15 +691,17 @@ impl OpenCADStudio {
Message::SplitModelViewport(horizontal) => { Message::SplitModelViewport(horizontal) => {
let i = self.active_tab; 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; self.tabs[i].scene.camera_generation += 1;
Task::none() Task::none()
} }
Message::CloseModelViewport => { Message::CloseModelViewport => {
let i = self.active_tab; 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.tabs[i].scene.camera_generation += 1;
self.sync_render_mode_to_active_tile(i);
self.adopt_view_display(i);
Task::none() Task::none()
} }
@ -1021,6 +1008,69 @@ impl OpenCADStudio {
Message::ViewportExit => self.on_viewport_exit(), 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::ViewportLeftPress => self.on_viewport_left_press(),
Message::ViewportLeftRelease => self.on_viewport_left_release(), Message::ViewportLeftRelease => self.on_viewport_left_release(),

View file

@ -2,7 +2,7 @@
#![allow(unused_imports)] #![allow(unused_imports)]
use super::util::*; 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::{ use crate::app::helpers::{
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
CoordKind, 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::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
use crate::scene::model::object::GripApply; use crate::scene::model::object::GripApply;
use crate::scene::{ 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 crate::ui::PropertiesPanel;
use acadrust::types::Color as AcadColor; use acadrust::types::Color as AcadColor;

View file

@ -2,7 +2,7 @@
#![allow(unused_imports)] #![allow(unused_imports)]
use super::util::*; 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::{ use crate::app::helpers::{
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
CoordKind, 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::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
use crate::scene::model::object::GripApply; use crate::scene::model::object::GripApply;
use crate::scene::{ 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 crate::ui::PropertiesPanel;
use acadrust::types::Color as AcadColor; use acadrust::types::Color as AcadColor;
@ -480,34 +480,6 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
pub(super) fn on_viewport_move(&mut self, p: Point) -> Task<Message> { pub(super) fn on_viewport_move(&mut self, p: Point) -> Task<Message> {
let i = self.active_tab; 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 // UCS icon grip drag: map the cursor onto the UCS plane and
// slide the origin / rotate the axis. Short-circuits pan & snap. // slide the origin / rotate the axis. Short-circuits pan & snap.
if let Some(kind) = self.ucs_grip_drag { if let Some(kind) = self.ucs_grip_drag {
@ -669,21 +641,10 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
let vp_size = sel.vp_size; let vp_size = sel.vp_size;
drop(sel); drop(sel);
// Hover (no button held): the tile under the cursor becomes // The active pane already follows the cursor (each pane's own
// active, so the camera + tile bounds used for picking below // mouse_area focuses it via `PaneMove` → `focus_model_pane`), so
// follow the pane the cursor is in. During a drag the active // the camera + tile bounds used for picking below are already the
// tile stays put so the operation finishes in its own pane. // pane the cursor is in.
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);
}
}
// Tile-relative picking: shadow `p` with the cursor mapped // Tile-relative picking: shadow `p` with the cursor mapped
// into the active Model tile and `vp_size` with the tile's // 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<Message> {
self.tabs[i].scene.camera_generation += 1; 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<Message> {
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<Message> {
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<Message> {
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<Message> { pub(super) fn on_viewport_left_press(&mut self) -> Task<Message> {
let i = self.active_tab; let i = self.active_tab;
// A click in the viewport dismisses any open ribbon dropdown // A click in the viewport dismisses any open ribbon dropdown
@ -1497,48 +1517,9 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
self.ucs_icon_selected = false; self.ucs_icon_selected = false;
} }
// Tiled Model layout: an inner divider near the cursor // Divider resize is handled natively by the input pane_grid
// becomes a resize grip. Start the drag and short-circuit // (`on_resize`); the active pane already follows the cursor via
// (no pick / select / camera swap). Released on // `focus_model_pane`. So a press here goes straight to picking.
// `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();
}
}
// From here the click targets the active tile: map the // From here the click targets the active tile: map the
// cursor into it and use the tile's size for picking, so // 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<Message> {
return Task::none(); 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 // Commit a UCS icon grip drag: persist the new UCS so it
// round-trips, and clear the lingering press state. // round-trips, and clear the lingering press state.
if self.ucs_grip_drag.take().is_some() { if self.ucs_grip_drag.take().is_some() {

View file

@ -2,7 +2,7 @@ use super::*;
use super::super::document::{DynComponent, DynFieldEntry}; use super::super::document::{DynComponent, DynFieldEntry};
use super::super::Message; use super::super::Message;
use iced::widget::{ use iced::widget::{
button, container, row, button, container, mouse_area, row,
}; };
use iced::{Background, Border, Color, Element, Theme}; 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 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 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. // Borderless icon button; an `active` toggle gets an accent tint + fill.
let icon_btn = move |bytes: &'static [u8], active: bool, msg: Message| { 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(icon_btn(crate::ui::icons::SPLIT_V, false, Message::SplitModelViewport(false)))
.push(sep()) .push(sep())
.push(icon_btn(crate::ui::icons::SPLIT_H, false, Message::SplitModelViewport(true))); .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 { 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 bar = bar
.push(sep()) .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));
} }
} }

View file

@ -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::view::viewport_pane::ViewportPane;
use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX}; use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX};
use iced::widget::{ 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, Space,
}; };
use iced::window; use iced::window;
@ -78,7 +78,7 @@ impl OpenCADStudio {
// borders) and the floating content viewports blit on top. // borders) and the floating content viewports blit on top.
let viewport_3d: Element<'_, Message> = if tab.is_start { let viewport_3d: Element<'_, Message> = if tab.is_start {
start_page_view() start_page_view()
} else { } else if is_paper {
shader(ViewportPane::model( shader(ViewportPane::model(
&tab.scene, &tab.scene,
self.show_viewcube, self.show_viewcube,
@ -87,6 +87,67 @@ impl OpenCADStudio {
.width(Fill) .width(Fill)
.height(Fill) .height(Fill)
.into() .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<Element<'_, Message>> = 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 = { let grid_overlay = {
@ -211,8 +272,8 @@ impl OpenCADStudio {
// Rotation-only projection (view_proj_rte): the icon shows axis // Rotation-only projection (view_proj_rte): the icon shows axis
// DIRECTIONS only, so the full view_proj's huge UTM translation would // DIRECTIONS only, so the full view_proj's huge UTM translation would
// cancel catastrophically in f32 and make the tripod jitter. // cancel catastrophically in f32 and make the tripod jitter.
let ucs_icon = if !self.show_ucs_icon { let ucs_icons: Vec<crate::ui::overlay::UcsIconParams> = if !self.show_ucs_icon {
None vec![]
} else if let Some((vp_cam, full)) = tab.scene.viewport_edit_frame((vw, vh)) { } else if let Some((vp_cam, full)) = tab.scene.viewport_edit_frame((vw, vh)) {
let (_, ux, uy, uz) = tab.ucs_xform().axes(); let (_, ux, uy, uz) = tab.ucs_xform().axes();
let origin_screen = self.ucs_icon_at_origin.then(|| { let origin_screen = self.ucs_icon_at_origin.then(|| {
@ -220,31 +281,51 @@ impl OpenCADStudio {
.project(tab.ucs_origin_world(), full) .project(tab.ucs_origin_world(), full)
.map(|p| iced::Point::new(full.x + p.x, full.y + p.y)) .map(|p| iced::Point::new(full.x + p.x, full.y + p.y))
}).flatten(); }).flatten();
Some(crate::ui::overlay::UcsIconParams { vec![crate::ui::overlay::UcsIconParams {
view_proj: vp_cam.view_proj_rte(full), view_proj: vp_cam.view_proj_rte(full),
bounds: full, bounds: full,
axes: (ux, uy, uz), axes: (ux, uy, uz),
origin_screen, origin_screen,
hover: self.ucs_icon_hover, hover: self.ucs_icon_hover,
selected: self.ucs_icon_selected, selected: self.ucs_icon_selected,
}) }]
} else if !is_paper { } 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 (_, ux, uy, uz) = tab.ucs_xform().axes();
let origin_screen = self.ucs_icon_at_origin.then(|| { let origin_w = tab.ucs_origin_world();
cam.project(tab.ucs_origin_world(), vp_bounds) let active = tab.scene.active_model_tile.get();
.map(|p| iced::Point::new(vp_bounds.x + p.x, vp_bounds.y + p.y)) let live = tab.scene.camera.borrow().clone();
}).flatten(); tab.scene
Some(crate::ui::overlay::UcsIconParams { .model_tiles
view_proj: cam.view_proj_rte(vp_bounds), .borrow()
bounds: vp_bounds, .iter()
axes: (ux, uy, uz), .enumerate()
origin_screen, .map(|(i, t)| {
hover: self.ucs_icon_hover, let b = iced::Rectangle {
selected: self.ucs_icon_selected, 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 { } else {
None vec![]
}; };
// OST tracking points → screen positions, projected relative-to-eye // OST tracking points → screen positions, projected relative-to-eye
@ -272,22 +353,53 @@ impl OpenCADStudio {
vec![] vec![]
}; };
// Model-space tile dividers (none in paper / single-tile layouts). // Model-space pane dividers (none in paper / single-pane layouts).
let tile_edges = if !is_paper { let dividers = if !is_paper {
tab.scene.model_tile_edges() let (vw, vh) = tab.scene.selection.borrow().vp_size;
tab.scene.model_pane_dividers(vw, vh)
} else { } else {
vec![] 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( crate::ui::overlay::selection_overlay(
sel, sel,
snap_info, snap_info,
grips, grips,
ucs_icon, ucs_icons,
ost_points, ost_points,
tab.last_cursor_screen, tab.last_cursor_screen,
!is_paper && self.show_viewcube, !is_paper && self.show_viewcube,
tile_edges, dividers,
pane_move_rect,
pane_drop_rect,
tab.pan_mode, tab.pan_mode,
) )
}; };
@ -317,9 +429,10 @@ impl OpenCADStudio {
tab.bg_color tab.bg_color
.map(|[r, g, b, a]| Color { r, g, b, a }) .map(|[r, g, b, a]| Color { r, g, b, a })
.unwrap_or(Color { .unwrap_or(Color {
r: 0.11, // Default model background: RGB (33, 40, 48).
g: 0.11, r: 33.0 / 255.0,
b: 0.11, g: 40.0 / 255.0,
b: 48.0 / 255.0,
a: 1.0, a: 1.0,
}) })
}; };
@ -445,12 +558,18 @@ impl OpenCADStudio {
.height(Fill), .height(Fill),
viewport_3d, viewport_3d,
selection_overlay, selection_overlay,
viewport_mouse,
] ]
.width(Fill) .width(Fill)
.height(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 // Model-space render-mode picker, top-left. Sits ABOVE the
// viewport mouse_area so clicks inside its bounds reach it // viewport mouse_area so clicks inside its bounds reach it
// instead of the shader behind it; `opaque` stops them bubbling // instead of the shader behind it; `opaque` stops them bubbling
@ -1675,6 +1794,23 @@ const BRAND_DARK: Color = Color {
a: 1.0, 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> { pub(super) fn start_page_view<'a>() -> Element<'a, Message> {
const TEXT: Color = Color { const TEXT: Color = Color {
r: 0.94, r: 0.94,

View file

@ -348,6 +348,9 @@ impl Scene {
*self.model_tiles.borrow_mut() = tiles; *self.model_tiles.borrow_mut() = tiles;
self.active_model_tile.set(0); self.active_model_tile.set(0);
*self.camera.borrow_mut() = active_cam; *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; self.camera_generation += 1;
true true
} }

View file

@ -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<TileEdge> {
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<f32> = Vec::new();
let mut ys: Vec<f32> = 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. /// Rebuild the `pane_grid` layout from the current `model_tiles` rects
/// `bounds` is the canvas pixel rectangle (origin = canvas top-left). /// (each pane value = its tile index) — used after loading a tiled config
/// Returns the closest edge within `tolerance_px` pixels of the cursor /// from the VPort table so the panes match the restored tiles.
/// along its perpendicular axis, also requiring the cursor to lie pub fn rebuild_panes_from_tiles(&mut self) {
/// within the edge's actual span. let items: Vec<(usize, iced::Rectangle)> = self
pub fn hit_model_tile_edge( .model_tiles
&self, .borrow()
cursor_px: iced::Point, .iter()
bounds: iced::Rectangle, .enumerate()
tolerance_px: f32, .map(|(i, t)| (i, t.rect))
) -> Option<TileEdge> { .collect();
if bounds.width <= 0.0 || bounds.height <= 0.0 { let full = iced::Rectangle {
return None; x: 0.0,
} y: 0.0,
let cx = cursor_px.x - bounds.x; width: 1.0,
let cy = cursor_px.y - bounds.y; height: 1.0,
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))
}
}; };
for t in tiles.iter_mut() { let config = if items.is_empty() {
match orient { iced::widget::pane_grid::Configuration::Pane(0)
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<ModelTile>, 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)));
} else { } else {
// Isolated tile (shouldn't happen with axis-aligned config_from_rects(&items, full)
// splits) — drop it and stretch the first remaining };
// tile to fill the canvas so we don't leave a hole. self.model_panes = iced::widget::pane_grid::State::with_configuration(config);
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,
};
}
}
} }
/// Split the active Model tile in two. `horizontal` → a horizontal /// Replace the Model pane layout with a `pane_grid` configuration (VPORTS
/// divider (top / bottom halves); otherwise a vertical divider (left / /// presets). Each resulting pane inherits the active tile's camera / style;
/// right). Both halves inherit the active tile's current camera; the /// pane values are compacted to `0..N` and `model_tiles` rebuilt to match.
/// active tile stays the first half. No-op outside the Model layout. pub fn set_model_panes(&mut self, config: iced::widget::pane_grid::Configuration<usize>) {
pub fn split_active_model_tile(&self, horizontal: bool) { let cam = self.camera.borrow().clone();
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<iced::Rectangle>) {
let cam_now = self.camera.borrow().clone();
// Every new pane inherits the active tile's current visual style.
let (mode, grid_on, snap_on) = { let (mode, grid_on, snap_on) = {
let tiles = self.model_tiles.borrow(); let tiles = self.model_tiles.borrow();
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1)); let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
@ -547,35 +164,264 @@ impl Scene {
false, false,
)) ))
}; };
let tiles: Vec<ModelTile> = rects self.model_panes = iced::widget::pane_grid::State::with_configuration(config);
.into_iter() let order: Vec<iced::widget::pane_grid::Pane> =
.map(|rect| ModelTile { self.model_panes.iter().map(|(p, _)| *p).collect();
rect, let mut tiles = Vec::with_capacity(order.len());
camera: cam_now.clone(), for (i, pane) in order.iter().enumerate() {
render_mode: mode, if let Some(v) = self.model_panes.get_mut(*pane) {
grid_on, *v = i;
snap_on, }
}) tiles.push(ModelTile {
.collect();
*self.model_tiles.borrow_mut() = if tiles.is_empty() {
vec![ModelTile {
rect: iced::Rectangle { rect: iced::Rectangle {
x: 0.0, x: 0.0,
y: 0.0, y: 0.0,
width: 1.0, width: 1.0,
height: 1.0, height: 1.0,
}, },
camera: cam_now, camera: cam.clone(),
render_mode: mode, render_mode: mode,
grid_on, grid_on,
snap_on, snap_on,
}] });
} else { }
tiles *self.model_tiles.borrow_mut() = tiles;
};
self.active_model_tile.set(0); 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 /// Screen-pixel rectangle of the active Model tile within a canvas of
/// `(vw, vh)`. Full canvas outside the Model layout or for a single /// `(vw, vh)`. Full canvas outside the Model layout or for a single
/// tile. Used to map cursor coordinates into the active tile so pick / /// 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 { /// Reconstruct a `pane_grid` configuration from a set of (tile-index, rect)
(a.1.min(b.1) - a.0.max(b.0)).max(0.0) /// 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)),
}
} }

View file

@ -527,27 +527,10 @@ pub(crate) struct ModelTile {
pub(crate) snap_on: bool, pub(crate) snap_on: bool,
} }
/// Tolerance for matching two normalized tile coordinates as "the same" /// Gap (pixels) between Model panes — the `pane_grid` spacing and the visible
/// edge — drag math leaves small floating-point residue. /// divider width. The renderer derives tile rects through this same spacing so
const TILE_EPS: f32 = 1e-4; /// the drawn viewports line up exactly with the pane_grid layout.
pub const TILE_DIVIDER_PX: f32 = 2.0;
#[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),
}
/// Shift every vertex of a freshly tessellated `MeshLodSet` into the /// Shift every vertex of a freshly tessellated `MeshLodSet` into the
/// scene's local f32 space by subtracting `world_offset`. ACIS / SAT /// scene's local f32 space by subtracting `world_offset`. ACIS / SAT
@ -693,6 +676,14 @@ pub struct Scene {
pub(crate) model_tiles: RefCell<Vec<ModelTile>>, pub(crate) model_tiles: RefCell<Vec<ModelTile>>,
/// Index of the active model tile (camera input + overlays target it). /// Index of the active model tile (camera input + overlays target it).
pub(crate) active_model_tile: std::cell::Cell<usize>, pub(crate) active_model_tile: std::cell::Cell<usize>,
/// 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<usize>,
pub selection: Rc<RefCell<SelectionState>>, pub selection: Rc<RefCell<SelectionState>>,
/// The CAD document — single source of truth for all entities. /// The CAD document — single source of truth for all entities.
pub document: CadDocument, pub document: CadDocument,
@ -921,6 +912,8 @@ impl Scene {
snap_on: false, snap_on: false,
}]), }]),
active_model_tile: std::cell::Cell::new(0), 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())), selection: Rc::new(RefCell::new(SelectionState::default())),
document: CadDocument::new(), document: CadDocument::new(),
selected: HashSet::default(), selected: HashSet::default(),

View file

@ -2165,8 +2165,11 @@ pub struct MultiPipeline {
} }
impl MultiPipeline { impl MultiPipeline {
/// Ensure exactly `n` (≥1) inner pipelines exist, creating any missing /// Ensure at least `n` (≥1) inner pipelines exist, creating any missing
/// ones. Extra pipelines beyond `n` are dropped. /// 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( pub(crate) fn ensure_len(
&mut self, &mut self,
device: &wgpu::Device, device: &wgpu::Device,
@ -2177,7 +2180,6 @@ impl MultiPipeline {
while self.inners.len() < n { while self.inners.len() < n {
self.inners.push(Pipeline::new(device, queue, self.format)); self.inners.push(Pipeline::new(device, queue, self.format));
} }
self.inners.truncate(n);
} }
} }

View file

@ -113,6 +113,13 @@ pub struct Primitive {
pub(in crate::scene) viewports: Vec<ViewportData>, pub(in crate::scene) viewports: Vec<ViewportData>,
/// Background color used to clear each viewport's MSAA buffer. /// Background color used to clear each viewport's MSAA buffer.
pub(in crate::scene) bg_color: [f32; 4], 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 /// Flags the render pipeline consumes, derived from
@ -201,10 +208,10 @@ impl shader::Primitive for Primitive {
let phys = viewport.physical_size(); let phys = viewport.physical_size();
let full_size = Size::new(phys.width, phys.height); let full_size = Size::new(phys.width, phys.height);
let scale = viewport.scale_factor() as f32; 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() { 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 // The MSAA / depth / resolve textures are always sized to the
// FULL viewport rectangle (not the on-canvas-visible portion) // FULL viewport rectangle (not the on-canvas-visible portion)
// so the camera matrices render at consistent aspect / scale. // 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_right = clip.x + clip.width;
let clip_bottom = clip.y + clip.height; let clip_bottom = clip.y + clip.height;
for (i, vp) in self.viewports.iter().enumerate() { 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; break;
}; };
// Where the viewport would land on the surface in absolute // Where the viewport would land on the surface in absolute
@ -628,7 +635,72 @@ impl Scene {
.collect(); .collect();
// Empty viewports → blit nothing; the container background (model bg // Empty viewports → blit nothing; the container background (model bg
// or the paper desk colour) stays visible. // 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 /// Build one `ViewportData` from a `ViewportInstance`: gathers the

View file

@ -19,6 +19,10 @@ pub struct ViewportPane<'a> {
/// Render mode applied to the Model layout's tiles. Paper-space content /// Render mode applied to the Model layout's tiles. Paper-space content
/// viewports use the render mode stored on their own viewport entity. /// viewports use the render mode stored on their own viewport entity.
pub render_mode: acadrust::entities::ViewportRenderMode, 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<usize>,
} }
impl<'a> ViewportPane<'a> { impl<'a> ViewportPane<'a> {
@ -31,6 +35,23 @@ impl<'a> ViewportPane<'a> {
scene, scene,
show_viewcube, show_viewcube,
render_mode, 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<Msg> for ViewportPane<'a>
_cursor: mouse::Cursor, _cursor: mouse::Cursor,
bounds: Rectangle, bounds: Rectangle,
) -> Self::Primitive { ) -> Self::Primitive {
self.scene match self.pane {
.build_viewports(bounds, self.render_mode, state.hover_region) 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( fn update(

View file

@ -181,22 +181,26 @@ pub fn selection_overlay<'a>(
selection: SelectionState, selection: SelectionState,
snap: Option<(Point, SnapType)>, snap: Option<(Point, SnapType)>,
grips: Vec<GripMarker>, grips: Vec<GripMarker>,
ucs_icon: Option<UcsIconParams>, ucs_icons: Vec<UcsIconParams>,
ost_points: Vec<OstTrackPoint>, ost_points: Vec<OstTrackPoint>,
cursor_screen: Point, cursor_screen: Point,
show_viewcube: bool, show_viewcube: bool,
tile_edges: Vec<crate::scene::TileEdge>, dividers: Vec<iced::Rectangle>,
pane_move_rect: Option<iced::Rectangle>,
pane_drop_rect: Option<iced::Rectangle>,
pan_mode: bool, pan_mode: bool,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
canvas(SelectionCanvas { canvas(SelectionCanvas {
selection, selection,
snap, snap,
grips, grips,
ucs_icon, ucs_icons,
ost_points, ost_points,
cursor_screen, cursor_screen,
show_viewcube, show_viewcube,
tile_edges, dividers,
pane_move_rect,
pane_drop_rect,
pan_mode, pan_mode,
}) })
.width(Length::Fill) .width(Length::Fill)
@ -208,53 +212,41 @@ struct SelectionCanvas {
selection: SelectionState, selection: SelectionState,
snap: Option<(Point, SnapType)>, snap: Option<(Point, SnapType)>,
grips: Vec<GripMarker>, grips: Vec<GripMarker>,
ucs_icon: Option<UcsIconParams>, /// 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<UcsIconParams>,
ost_points: Vec<OstTrackPoint>, ost_points: Vec<OstTrackPoint>,
cursor_screen: Point, cursor_screen: Point,
show_viewcube: bool, show_viewcube: bool,
tile_edges: Vec<crate::scene::TileEdge>, /// Divider bars (pixel rects, canvas-relative) between Model panes — drawn
/// as filled lines and used to suppress the crosshair over a divider.
dividers: Vec<iced::Rectangle>,
/// 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<iced::Rectangle>,
/// The pane under the cursor during a pane move (drop target), highlighted.
pane_drop_rect: Option<iced::Rectangle>,
/// Interactive PAN mode: the crosshair is hidden and the cursor becomes a /// Interactive PAN mode: the crosshair is hidden and the cursor becomes a
/// hand so the viewport reads as a draggable surface. /// hand so the viewport reads as a draggable surface.
pan_mode: bool, pan_mode: bool,
} }
impl SelectionCanvas { impl SelectionCanvas {
/// Returns the orientation of the Model-tile divider the cursor sits /// True when the cursor sits on a Model-pane divider (within a few px), so
/// on (within a few pixels of perpendicular distance, inside the /// `draw` can suppress the CAD crosshair there. The resize cursor itself is
/// edge's span). Used by both `mouse_interaction` (to pick a resize /// supplied by the input pane_grid layered above.
/// cursor) and `draw` (to suppress the CAD crosshair). fn divider_under(&self, cursor: mouse::Cursor, bounds: iced::Rectangle) -> bool {
fn tile_edge_under( const TOL_PX: f32 = 3.0;
&self, let Some(pos) = cursor.position_in(bounds) else {
cursor: mouse::Cursor, return false;
bounds: iced::Rectangle, };
) -> Option<crate::scene::TileEdgeOrient> { self.dividers.iter().any(|d| {
const TOL_PX: f32 = 4.0; pos.x >= d.x - TOL_PX
let pos = cursor.position_in(bounds)?; && pos.x <= d.x + d.width + TOL_PX
for e in &self.tile_edges { && pos.y >= d.y - TOL_PX
let (perp, edge_px, span_lo, span_hi, pos_along) = match e.orient { && pos.y <= d.y + d.height + TOL_PX
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
} }
} }
@ -290,20 +282,9 @@ impl canvas::Program<Message> for SelectionCanvas {
} }
} }
} }
// Hover over a Model-tile divider → resize cursor cue. The // The resize cursor over a divider is supplied by the input pane_grid
// system cursor is intentionally shown here so the OS arrow gives // layered above this overlay; `draw` only suppresses the CAD crosshair
// its own resize affordance; the draw step suppresses the custom // there (see `divider_under`).
// 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
}
};
}
// Over the viewport (no divider, no viewcube): hide the system // Over the viewport (no divider, no viewcube): hide the system
// cursor entirely. `Interaction::None` would let the stack fall // cursor entirely. `Interaction::None` would let the stack fall
// through to a sibling — `Hidden` is the explicit "no cursor" // through to a sibling — `Hidden` is the explicit "no cursor"
@ -325,43 +306,87 @@ impl canvas::Program<Message> for SelectionCanvas {
) -> Vec<canvas::Geometry> { ) -> Vec<canvas::Geometry> {
let mut frame = canvas::Frame::new(renderer, bounds.size()); let mut frame = canvas::Frame::new(renderer, bounds.size());
// ── Tile dividers (Model-space tiled layout) ────────────────────── // ── Pane dividers (Model-space tiled layout) ──────────────────────
// Drawn first so all other overlays sit on top — the line is just // Filled bars in the pane_grid spacing gaps, so adjacent panes read as
// a visual cue for the resize-drag handles. // distinct viewports. Drawn first so all other overlays sit on top.
if !self.tile_edges.is_empty() { if !self.dividers.is_empty() {
const DIVIDER: Color = Color { const DIVIDER: Color = Color {
r: 0.40, r: 0.46,
g: 0.45, g: 0.52,
b: 0.55, b: 0.62,
a: 0.85, a: 1.0,
}; };
for e in &self.tile_edges { for d in &self.dividers {
let (a, b) = match e.orient { let bar = canvas::Path::rectangle(
crate::scene::TileEdgeOrient::Vertical => { Point::new(d.x, d.y),
let x = e.coord * bounds.width; iced::Size::new(d.width.max(1.0), d.height.max(1.0)),
( );
Point::new(x, e.span.0 * bounds.height), frame.fill(&bar, DIVIDER);
Point::new(x, e.span.1 * bounds.height), }
) }
}
crate::scene::TileEdgeOrient::Horizontal => { // ── Pane move (drag-to-swap) ──────────────────────────────────────
let y = e.coord * bounds.height; // While armed: dim the lifted source pane, highlight the drop target
( // under the cursor, and drag a translucent ghost card along the cursor
Point::new(e.span.0 * bounds.width, y), // so the pane is visibly "moving".
Point::new(e.span.1 * bounds.width, y), if let Some(src) = self.pane_move_rect {
) let accent = Color {
} r: 0.30,
}; g: 0.62,
let path = canvas::Path::new(|p| { b: 1.0,
p.move_to(a); a: 1.0,
p.line_to(b); };
}); // 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( 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 { canvas::Stroke {
width: 2.0, width: 2.0,
style: canvas::Style::Solid(DIVIDER), style: canvas::Style::Solid(Color { a: 0.95, ..accent }),
line_cap: canvas::LineCap::Butt,
..Default::default() ..Default::default()
}, },
); );
@ -799,7 +824,7 @@ impl canvas::Program<Message> for SelectionCanvas {
// Over a Model-tile divider the OS cursor switches to a resize // Over a Model-tile divider the OS cursor switches to a resize
// arrow (see `mouse_interaction`); drawing the CAD crosshair on // arrow (see `mouse_interaction`); drawing the CAD crosshair on
// top of it would double up the visual feedback. // 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. // PAN mode replaces the crosshair with a hand cursor.
if !over_viewcube && !over_divider && !self.pan_mode { if !over_viewcube && !over_divider && !self.pan_mode {
if let Some(cp) = self.selection.last_move_pos { if let Some(cp) = self.selection.last_move_pos {
@ -849,8 +874,8 @@ impl canvas::Program<Message> for SelectionCanvas {
} }
} // end !over_viewcube } // end !over_viewcube
// ── UCS icon ────────────────────────────────────────────────────── // ── UCS icon (one per Model pane) ─────────────────────────────────
if let Some(ref ucs) = self.ucs_icon { for ucs in &self.ucs_icons {
draw_ucs_icon( draw_ucs_icon(
&mut frame, &mut frame,
ucs.view_proj, ucs.view_proj,