forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
8bdf5228d2
commit
495b13ec4f
18 files changed
+1022
-751
No files matched your search
+25
-23
@@ -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<Vec<R>> = 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>, 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)."));
|
||||
|
||||
+25
-17
@@ -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<TileDrag>,
|
||||
/// 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<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
|
||||
/// 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,
|
||||
|
||||
@@ -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<Message> {
|
||||
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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+68
-18
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
+68
-95
@@ -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<Message> {
|
||||
pub(super) fn on_viewport_move(&mut self, p: Point) -> Task<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+165
-29
@@ -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<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 = {
|
||||
@@ -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<crate::ui::overlay::UcsIconParams> = 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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+366
-435
@@ -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.
|
||||
/// `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<TileEdge> {
|
||||
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<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)));
|
||||
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<iced::Rectangle>) {
|
||||
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<usize>) {
|
||||
let cam = self.camera.borrow().clone();
|
||||
let (mode, grid_on, snap_on) = {
|
||||
let tiles = self.model_tiles.borrow();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
@@ -547,35 +164,264 @@ impl Scene {
|
||||
false,
|
||||
))
|
||||
};
|
||||
let tiles: Vec<ModelTile> = 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<iced::widget::pane_grid::Pane> =
|
||||
self.model_panes.iter().map(|(p, _)| *p).collect();
|
||||
let mut tiles = Vec::with_capacity(order.len());
|
||||
for (i, pane) in order.iter().enumerate() {
|
||||
if let Some(v) = self.model_panes.get_mut(*pane) {
|
||||
*v = i;
|
||||
}
|
||||
tiles.push(ModelTile {
|
||||
rect: iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
},
|
||||
camera: cam_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<iced::widget::pane_grid::Pane> =
|
||||
self.model_panes.iter().map(|(p, _)| *p).collect();
|
||||
let fallback = old
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ModelTile {
|
||||
rect: iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
},
|
||||
camera: self.camera.borrow().clone(),
|
||||
render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D,
|
||||
grid_on: false,
|
||||
snap_on: false,
|
||||
});
|
||||
let mut new_tiles = Vec::with_capacity(order.len());
|
||||
let mut new_active = 0usize;
|
||||
for (new_idx, pane) in order.iter().enumerate() {
|
||||
let old_idx = *self.model_panes.get(*pane).unwrap_or(&0);
|
||||
new_tiles.push(old.get(old_idx).cloned().unwrap_or_else(|| fallback.clone()));
|
||||
if let Some(v) = self.model_panes.get_mut(*pane) {
|
||||
*v = new_idx;
|
||||
}
|
||||
if *pane == active_pane {
|
||||
new_active = new_idx;
|
||||
}
|
||||
}
|
||||
*self.model_tiles.borrow_mut() = new_tiles;
|
||||
self.active_model_tile.set(new_active);
|
||||
}
|
||||
|
||||
/// Make Model tile `idx` active, stashing the live camera into the outgoing
|
||||
/// tile and loading the incoming one. Returns `true` if the active tile
|
||||
/// changed. The caller bumps `camera_generation` and syncs the display.
|
||||
pub fn set_active_model_tile(&self, idx: usize) -> bool {
|
||||
if self.current_layout != "Model" {
|
||||
return false;
|
||||
}
|
||||
let old = self.active_model_tile.get();
|
||||
if idx == old || idx >= self.model_tiles.borrow().len() {
|
||||
return false;
|
||||
}
|
||||
let incoming = {
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
if let Some(t) = tiles.get_mut(old) {
|
||||
t.camera = self.camera.borrow().clone();
|
||||
}
|
||||
tiles.get(idx).map(|t| t.camera.clone())
|
||||
};
|
||||
if let Some(cam) = incoming {
|
||||
*self.camera.borrow_mut() = cam;
|
||||
}
|
||||
self.active_model_tile.set(idx);
|
||||
true
|
||||
}
|
||||
|
||||
/// Divider bars between Model panes, as pixel rectangles within a
|
||||
/// `(vw, vh)` canvas — derived from the `pane_grid` split regions so they
|
||||
/// land exactly in the spacing gaps the renderer leaves. Each bar is
|
||||
/// `TILE_DIVIDER_PX` thick. Empty outside Model / for a single pane.
|
||||
pub fn model_pane_dividers(&self, vw: f32, vh: f32) -> Vec<iced::Rectangle> {
|
||||
use iced::widget::pane_grid::Axis;
|
||||
if self.current_layout != "Model" || vw < 1.0 || vh < 1.0 {
|
||||
return vec![];
|
||||
}
|
||||
let half = TILE_DIVIDER_PX * 0.5;
|
||||
self.model_panes
|
||||
.layout()
|
||||
.split_regions(TILE_DIVIDER_PX, 0.0, iced::Size::new(vw, vh))
|
||||
.values()
|
||||
.map(|(axis, region, ratio)| match axis {
|
||||
Axis::Vertical => {
|
||||
let x = region.x + ratio * region.width;
|
||||
iced::Rectangle {
|
||||
x: x - half,
|
||||
y: region.y,
|
||||
width: TILE_DIVIDER_PX,
|
||||
height: region.height,
|
||||
}
|
||||
}
|
||||
Axis::Horizontal => {
|
||||
let y = region.y + ratio * region.height;
|
||||
iced::Rectangle {
|
||||
x: region.x,
|
||||
y: y - half,
|
||||
width: region.width,
|
||||
height: TILE_DIVIDER_PX,
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Top-left pixel corner of Model pane `idx` within a `(vw, vh)` canvas,
|
||||
/// derived from the `pane_grid` layout (so it matches the rendered pane).
|
||||
pub fn pane_origin_px(&self, idx: usize, vw: f32, vh: f32) -> iced::Point {
|
||||
if vw < 1.0 || vh < 1.0 {
|
||||
return iced::Point::ORIGIN;
|
||||
}
|
||||
let regions = self.model_panes.layout().pane_regions(
|
||||
TILE_DIVIDER_PX,
|
||||
0.0,
|
||||
iced::Size::new(vw, vh),
|
||||
);
|
||||
self.model_panes
|
||||
.iter()
|
||||
.find(|(_, &v)| v == idx)
|
||||
.and_then(|(pane, _)| regions.get(pane))
|
||||
.map(|r| iced::Point::new(r.x, r.y))
|
||||
.unwrap_or(iced::Point::ORIGIN)
|
||||
}
|
||||
|
||||
/// Replace the Model tiled layout with the given normalized rectangles
|
||||
/// (each in 0..1). Every tile inherits the current camera; the first
|
||||
/// tile becomes active. Used by VPORTS presets and `reset_model_tiles`.
|
||||
/// Derive `model_tiles` rects from the `pane_grid` layout (the layout
|
||||
/// source of truth) for a canvas of `(canvas_w, canvas_h)` pixels — each
|
||||
/// pane's value is its `model_tiles` index. Called before the renderer /
|
||||
/// hit-test read tile rects so they track the live pane_grid layout and any
|
||||
/// in-flight resize, with the divider gap applied exactly as pane_grid draws
|
||||
/// it. No-op outside the Model layout.
|
||||
#[allow(dead_code)]
|
||||
pub fn sync_tiles_from_panes(&self, canvas_w: f32, canvas_h: f32) {
|
||||
if self.current_layout != "Model" || canvas_w < 1.0 || canvas_h < 1.0 {
|
||||
return;
|
||||
}
|
||||
let regions = self.model_panes.layout().pane_regions(
|
||||
TILE_DIVIDER_PX,
|
||||
0.0,
|
||||
iced::Size::new(canvas_w, canvas_h),
|
||||
);
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
for (pane, &idx) in self.model_panes.iter() {
|
||||
if let (Some(r), Some(tile)) = (regions.get(pane), tiles.get_mut(idx)) {
|
||||
tile.rect = iced::Rectangle {
|
||||
x: r.x / canvas_w,
|
||||
y: r.y / canvas_h,
|
||||
width: r.width / canvas_w,
|
||||
height: r.height / canvas_h,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Screen-pixel rectangle of the active Model tile within a canvas of
|
||||
/// `(vw, vh)`. Full canvas outside the Model layout or for a single
|
||||
/// tile. Used to map cursor coordinates into the active tile so pick /
|
||||
@@ -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<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)),
|
||||
}
|
||||
}
|
||||
+14
-21
@@ -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<Vec<ModelTile>>,
|
||||
/// Index of the active model tile (camera input + overlays target it).
|
||||
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>>,
|
||||
/// 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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,13 @@ pub struct Primitive {
|
||||
pub(in crate::scene) viewports: Vec<ViewportData>,
|
||||
/// 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
|
||||
|
||||
@@ -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<usize>,
|
||||
}
|
||||
|
||||
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<Msg> 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(
|
||||
|
||||
+116
-91
@@ -181,22 +181,26 @@ pub fn selection_overlay<'a>(
|
||||
selection: SelectionState,
|
||||
snap: Option<(Point, SnapType)>,
|
||||
grips: Vec<GripMarker>,
|
||||
ucs_icon: Option<UcsIconParams>,
|
||||
ucs_icons: Vec<UcsIconParams>,
|
||||
ost_points: Vec<OstTrackPoint>,
|
||||
cursor_screen: Point,
|
||||
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,
|
||||
) -> 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<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>,
|
||||
cursor_screen: Point,
|
||||
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
|
||||
/// 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<crate::scene::TileEdgeOrient> {
|
||||
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<Message> 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<Message> for SelectionCanvas {
|
||||
) -> Vec<canvas::Geometry> {
|
||||
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<Message> 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<Message> 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,
|
||||
|
||||
Reference in new issue
Block a user