From c45146bbdac9796133b711631d09a661e930ed47 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 27 May 2026 22:33:36 +0300 Subject: [PATCH] =?UTF-8?q?feat(viewport):=20model-space=20tiled=20split?= =?UTF-8?q?=20(=E2=96=A4=20/=20=E2=96=A5=20buttons)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model layout artık tiled viewport destekliyor. Scene.model_tiles normalized ekran rect'i + kamera tutan tile listesi; active_viewports model layout'ta her tile için bir ViewportInstance üretiyor (aktif tile canlı scene.camera'yı, inaktif tile'lar saklı snapshot'ı kullanıyor). Render-mode picker'ın yanına iki buton eklendi: ▤ aktif tile'ı yatay (üst/alt), ▥ dikey (sol/sağ) ikiye böler. split_active_model_tile bölmeyi yapıyor; her iki yarı aktif kameranın kopyasını alıyor. set_active_model_tile_at (cursor → aktif tile, kamera swap) ve reset_model_tiles eklendi ama henüz bağlanmadı — aktif tile cursor seçimi + pick yönlendirme sonraki adımda. Co-Authored-By: Claude Opus 4.7 --- src/app/mod.rs | 3 + src/app/update.rs | 7 ++ src/app/view.rs | 48 +++++++++++++- src/scene/mod.rs | 160 ++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 205 insertions(+), 13 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 318477211..cea1ae7dc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -355,6 +355,9 @@ pub enum Message { CommandBackspace, /// TAB pressed: move focus to the next dynamic-input field (wraps). DynTabNext, + /// Split the active Model viewport in two. `true` → horizontal divider + /// (top / bottom); `false` → vertical divider (left / right). + SplitModelViewport(bool), /// Recall previous command in history (↑ arrow key). CommandHistoryPrev, /// Recall next command in history (↓ arrow key). diff --git a/src/app/update.rs b/src/app/update.rs index 3feb04e33..1bce54e86 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -916,6 +916,13 @@ impl OpenCADStudio { self.focus_cmd_input() } + Message::SplitModelViewport(horizontal) => { + let i = self.active_tab; + self.tabs[i].scene.split_active_model_tile(horizontal); + self.tabs[i].scene.camera_generation += 1; + Task::none() + } + Message::CommandHistoryPrev => { // While autocomplete is showing suggestions, ↑ walks up // that list. Otherwise it falls back to recall history. diff --git a/src/app/view.rs b/src/app/view.rs index 53f7d7306..1e7ebb76b 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -483,8 +483,54 @@ impl OpenCADStudio { // viewport drawing / selection is unaffected. In a paper layout // the active viewport gets its own picker (below) instead. if !is_paper && !tab.is_start { + // Render-mode picker + viewport-split buttons (horizontal / + // vertical divider of the active Model tile). + let split_btn = |glyph: &'static str, horizontal: bool| { + button(text(glyph).size(13).color(Color { + r: 0.85, + g: 0.85, + b: 0.85, + a: 1.0, + })) + .on_press(Message::SplitModelViewport(horizontal)) + .padding([4, 8]) + .style(|_: &Theme, status| iced::widget::button::Style { + background: Some(Background::Color(match status { + iced::widget::button::Status::Hovered => Color { + r: 0.20, + g: 0.20, + b: 0.20, + a: 0.85, + }, + _ => Color { + r: 0.10, + g: 0.10, + b: 0.10, + a: 0.75, + }, + })), + border: Border { + color: Color { + r: 0.35, + g: 0.35, + b: 0.35, + a: 1.0, + }, + width: 1.0, + radius: 4.0.into(), + }, + text_color: Color { + r: 0.85, + g: 0.85, + b: 0.85, + a: 1.0, + }, + ..Default::default() + }) + }; + let bar = row![info, split_btn("▤", true), split_btn("▥", false)].spacing(4); viewport_stack = viewport_stack.push( - container(iced::widget::opaque(info)) + container(iced::widget::opaque(bar)) .width(Fill) .height(Fill) .align_x(iced::alignment::Horizontal::Left) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 301cb71af..6e7d9cc2f 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -427,8 +427,23 @@ pub struct ViewportInstance { pub active: bool, } +/// One pane of the Model-space tiled viewport layout: the normalized screen +/// rectangle it fills and the camera it last had. The active tile uses the +/// live `Scene::camera` (so orbit/pan/zoom drive it); inactive tiles keep a +/// snapshot here, swapped in when they become active. +#[derive(Clone)] +pub(crate) struct ModelTile { + pub(crate) rect: iced::Rectangle, + pub(crate) camera: Camera, +} + pub struct Scene { pub camera: Rc>, + /// Model-space tiled viewport layout. One full-window tile by default; + /// the split buttons / VPORTS subdivide the active tile. + pub(crate) model_tiles: RefCell>, + /// Index of the active model tile (camera input + overlays target it). + pub(crate) active_model_tile: std::cell::Cell, pub selection: Rc>, /// The CAD document — single source of truth for all entities. pub document: CadDocument, @@ -539,6 +554,16 @@ impl Scene { pub fn new() -> Self { Self { camera: Rc::new(RefCell::new(Camera::default())), + model_tiles: RefCell::new(vec![ModelTile { + rect: iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1.0, + height: 1.0, + }, + camera: Camera::default(), + }]), + active_model_tile: std::cell::Cell::new(0), selection: Rc::new(RefCell::new(SelectionState::default())), document: CadDocument::new(), selected: HashSet::new(), @@ -4256,6 +4281,102 @@ impl Scene { // ── Paper-space coordinate helpers ─────────────────────────────────── + /// 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 + }, + ) + }; + tiles[active] = ModelTile { + rect: a, + camera: cam_now.clone(), + }; + tiles.insert( + active + 1, + ModelTile { + rect: b, + camera: cam_now, + }, + ); + } + + /// 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 + } + + /// Reset the Model layout to a single full-window tile. + pub fn reset_model_tiles(&self) { + let cam_now = self.camera.borrow().clone(); + *self.model_tiles.borrow_mut() = vec![ModelTile { + rect: iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1.0, + height: 1.0, + }, + camera: cam_now, + }]; + self.active_model_tile.set(0); + } + /// The viewports to render this frame, one entry per scissor pass. /// /// - **Model layout**: a single full-canvas instance driven by the @@ -4271,18 +4392,33 @@ impl Scene { model_mode: acadrust::entities::ViewportRenderMode, ) -> Vec { if self.current_layout == "Model" { - return vec![ViewportInstance { - handle: Handle::NULL, - screen_rect: iced::Rectangle { - x: 0.0, - y: 0.0, - width: canvas_w, - height: canvas_h, - }, - camera: self.camera.borrow().clone(), - render_mode: model_mode, - active: true, - }]; + let tiles = self.model_tiles.borrow(); + let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1)); + return tiles + .iter() + .enumerate() + .map(|(i, tile)| { + // The active tile renders the live camera (orbit/pan act + // on it); inactive tiles use their stored snapshot. + let camera = if i == active { + self.camera.borrow().clone() + } else { + tile.camera.clone() + }; + ViewportInstance { + handle: Handle::NULL, + screen_rect: iced::Rectangle { + x: tile.rect.x * canvas_w, + y: tile.rect.y * canvas_h, + width: tile.rect.width * canvas_w, + height: tile.rect.height * canvas_h, + }, + camera, + render_mode: model_mode, + active: i == active, + } + }) + .collect(); } let layout_block = self.current_layout_block_handle(); let mut out: Vec = Vec::new();