From 09e7e90aaeb645d34e8a0d3f529be403ed6bed06 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 30 May 2026 21:29:01 +0300 Subject: [PATCH] feat(draworder): cross-type draw order + context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DRAWORDER now affects rendering immediately (bump geometry epoch so the sort cache rebuilds) and front/back anchor to the block's full effective sort range instead of only existing table entries, so bring-to-front actually lands on top. The render fallback sorts table-absent entities by their own handle. Draw order now applies across entity types, not just within a type. A per-entity draw-order rank (SortEntitiesTable override or own handle, ranked per block) is encoded as a small signed clip-z bias in the wire, hatch, image, and 3DFACE pipelines. The shared LessEqual depth buffer then orders fills, lines, rasters and 2D solids against each other regardless of pass order — without touching model coordinates. 3D meshes keep real geometric depth; wipeouts stay always-on-top. Adds a Draw Order submenu to the viewport context menu (Bring to Front, Send to Back, Bring Above/Send Under Object). The above/under items run an interactive reference-object pick, then reorder the captured selection. Co-Authored-By: Claude Opus 4.8 --- src/app/commands.rs | 135 +++++++++++++++++++++++- src/app/mod.rs | 6 ++ src/app/update.rs | 24 +++++ src/app/view.rs | 60 ++++++++++- src/modules/home/draw/hatch.rs | 3 + src/scene/hatch_model.rs | 4 + src/scene/image_model.rs | 5 + src/scene/mod.rs | 107 ++++++++++++++++++- src/scene/pipeline/face3d_gpu.rs | 24 +++++ src/scene/pipeline/hatch_batched_gpu.rs | 11 +- src/scene/pipeline/image_gpu.rs | 8 +- src/scene/pipeline/mod.rs | 30 ++++-- src/scene/pipeline/wire_gpu.rs | 39 +++++-- src/scene/render.rs | 9 +- src/scene/selection.rs | 2 + src/shaders/face3d.wgsl | 10 +- src/shaders/hatch_batched.wgsl | 8 +- src/shaders/image.wgsl | 12 ++- src/shaders/wire.wgsl | 8 ++ 19 files changed, 469 insertions(+), 36 deletions(-) diff --git a/src/app/commands.rs b/src/app/commands.rs index 6f3328ca7..0d80e399a 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -3026,6 +3026,60 @@ impl OpenCADStudio { if relative_target.is_some() || to_front_opt.is_some() { self.push_undo_snapshot(i, "DRAWORDER"); let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); + + // For FRONT/BACK, anchor the new sort handle to the + // block's current effective draw-order range so the moved + // entities land strictly above/below every sibling — + // including ones not yet in the table, which sort by + // their own handle. (min_eff, max_eff) over siblings. + let fb_baseline: Option<(u64, u64)> = if to_front_opt.is_some() { + let selected_set: std::collections::HashSet = + selected.iter().map(|h| h.value()).collect(); + let doc_ref = &self.tabs[i].scene.document; + let overrides: std::collections::HashMap = doc_ref + .objects + .values() + .find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some( + t.entries() + .map(|e| { + ( + e.entity_handle.value(), + e.sort_handle.value(), + ) + }) + .collect(), + ); + } + } + None + }) + .unwrap_or_default(); + let mut max_eff = 0u64; + let mut min_eff = u64::MAX; + for e in doc_ref.entities() { + let c = e.common(); + let hv = c.handle.value(); + if selected_set.contains(&hv) { + continue; + } + if c.owner_handle != block_handle && !c.owner_handle.is_null() { + continue; + } + let eff = overrides.get(&hv).copied().unwrap_or(hv); + max_eff = max_eff.max(eff); + min_eff = min_eff.min(eff); + } + if min_eff == u64::MAX { + min_eff = 1; + } + Some((min_eff, max_eff)) + } else { + None + }; + let doc = &mut self.tabs[i].scene.document; let table_handle = doc.objects.iter().find_map(|(h, obj)| { if let ObjectType::SortEntitiesTable(t) = obj { @@ -3071,6 +3125,14 @@ impl OpenCADStudio { if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&th) { if let Some((above, target)) = relative_target { + // move_above/move_below read the target's sort + // handle from the table and no-op when it is + // absent. A reference object that was never + // reordered isn't in the table yet, so seed it + // with its own handle as the implicit sort key. + if !table.contains(target) { + table.add_entry(target, target); + } for h in &selected { if above { table.move_above(*h, target); @@ -3086,12 +3148,14 @@ impl OpenCADStudio { target.value() )); } else if let Some(to_front) = to_front_opt { - for h in &selected { - if to_front { - table.bring_to_front(*h); + let (min_eff, max_eff) = fb_baseline.unwrap_or((1, 0)); + for (k, h) in selected.iter().enumerate() { + let sort = if to_front { + max_eff.saturating_add(1 + k as u64) } else { - table.send_to_back(*h); - } + min_eff.saturating_sub(1 + k as u64).max(1) + }; + table.add_entry(*h, acadrust::Handle::new(sort)); } let dir = if to_front { "front" } else { "back" }; self.command_line.push_info(&format!( @@ -3101,6 +3165,11 @@ impl OpenCADStudio { )); } } + // Sort order lives in SortEntitiesTable, which the + // render-side `sort_cache` rebuilds per geometry epoch. + // Bump it so the new draw order shows immediately + // instead of waiting for an unrelated geometry change. + self.tabs[i].scene.bump_geometry(); self.tabs[i].dirty = true; } else { self.command_line.push_info( @@ -5494,6 +5563,62 @@ fn entity_extra_info(entity: &acadrust::EntityType) -> String { } } +// ── Draw Order: interactive reference-object pick ────────────────────────── + +/// Moves a captured selection above or below a reference object the user +/// picks in the viewport. On pick it relaunches `DRAWORDER A|U ` +/// with the captured handles reinstalled as the selection, so the existing +/// command path performs the actual reorder. +pub(crate) struct DrawOrderRefCommand { + to_move: Vec, + above: bool, +} + +impl DrawOrderRefCommand { + pub(crate) fn new(to_move: Vec, above: bool) -> Self { + Self { to_move, above } + } +} + +impl CadCommand for DrawOrderRefCommand { + fn name(&self) -> &'static str { + "DRAWORDER" + } + + fn prompt(&self) -> String { + if self.above { + "DRAWORDER Select reference object (move selection above):".into() + } else { + "DRAWORDER Select reference object (move selection under):".into() + } + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn on_entity_pick( + &mut self, + handle: acadrust::Handle, + _pt: glam::Vec3, + ) -> crate::command::CmdResult { + if handle.is_null() { + return crate::command::CmdResult::NeedPoint; + } + let opt = if self.above { "A" } else { "U" }; + let cmd = format!("DRAWORDER {} {:x}", opt, handle.value()); + crate::command::CmdResult::Relaunch(cmd, std::mem::take(&mut self.to_move)) + } + + fn on_point(&mut self, _pt: glam::Vec3) -> crate::command::CmdResult { + crate::command::CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> crate::command::CmdResult { + crate::command::CmdResult::Cancel + } +} + /// Escape a string for a CSV field (wrap in quotes if it contains comma/quote/newline). fn csv_escape(s: &str) -> String { if s.contains(',') || s.contains('"') || s.contains('\n') { diff --git a/src/app/mod.rs b/src/app/mod.rs index dceb55b45..04568babf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -702,6 +702,12 @@ pub enum Message { /// Extend the current selection with every entity in the active /// layout that matches a selected entity by (type, layer). SelectSimilar, + // ── Draw Order context menu ───────────────────────────────────────── + /// Toggle the Draw Order sub-items in the viewport context menu. + DrawOrderSubmenuToggle, + /// Begin an interactive reference-object pick to move the current + /// selection above (`true`) or below (`false`) the picked object. + DrawOrderPickRef(bool), /// Open the Quick Select panel. Initialises filters from the current /// selection's first entity (type + layer) when one is selected. QSelectOpen, diff --git a/src/app/update.rs b/src/app/update.rs index 6f6fb3c4e..79a31a92d 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -3006,6 +3006,7 @@ impl OpenCADStudio { if sel.right_down { if !sel.right_dragging { sel.context_menu = sel.last_move_pos; + sel.draworder_submenu = false; } sel.right_down = false; sel.right_press_pos = None; @@ -3379,6 +3380,29 @@ impl OpenCADStudio { Task::none() } + Message::DrawOrderSubmenuToggle => { + let i = self.active_tab; + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + sel.draworder_submenu = !sel.draworder_submenu; + Task::none() + } + + Message::DrawOrderPickRef(above) => { + let i = self.active_tab; + self.tabs[i].scene.selection.borrow_mut().context_menu = None; + let to_move: Vec<_> = self.tabs[i].scene.selected.iter().cloned().collect(); + if to_move.is_empty() { + self.command_line + .push_error("DRAWORDER: select entities first."); + } else { + use crate::command::CadCommand; + let cmd = super::commands::DrawOrderRefCommand::new(to_move, above); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + } + Task::none() + } + Message::SelectSimilar => { let i = self.active_tab; self.tabs[i].scene.selection.borrow_mut().context_menu = None; diff --git a/src/app/view.rs b/src/app/view.rs index d6affd20d..3c9a1824c 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -769,7 +769,10 @@ impl OpenCADStudio { // the cursor position (canvas-relative) anchors the menu under // the cursor instead of drifting into window-relative space. if !tab.is_start { - let ctx_pos = tab.scene.selection.borrow().context_menu; + let (ctx_pos, draworder_open) = { + let sel = tab.scene.selection.borrow(); + (sel.context_menu, sel.draworder_submenu) + }; if let Some(p) = ctx_pos { let has_cmd = tab.active_cmd.is_some(); let has_selection = !tab.scene.selected.is_empty(); @@ -781,8 +784,13 @@ impl OpenCADStudio { .take(3) .cloned() .collect(); - viewport_stack = viewport_stack - .push(viewport_context_menu_overlay(p, has_cmd, has_selection, last_cmds)); + viewport_stack = viewport_stack.push(viewport_context_menu_overlay( + p, + has_cmd, + has_selection, + last_cmds, + draworder_open, + )); } } @@ -1302,6 +1310,7 @@ fn viewport_context_menu_overlay( has_cmd: bool, has_selection: bool, last_cmds: Vec, + draworder_open: bool, ) -> Element<'static, Message> { const MENU_BG: Color = Color { r: 0.17, @@ -1364,6 +1373,30 @@ fn viewport_context_menu_overlay( .into() }; + // Indented variant for sub-menu rows (e.g. Draw Order children). + let subitem = |label: String, msg: Message| -> Element<'static, Message> { + button(text(label).size(12).color(TEXT_COL)) + .on_press(msg) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => ITEM_HOVER, + _ => Color::TRANSPARENT, + })), + text_color: TEXT_COL, + border: Border::default(), + shadow: iced::Shadow::default(), + snap: false, + }) + .padding(iced::Padding { + top: 4.0, + right: 12.0, + bottom: 4.0, + left: 26.0, + }) + .width(Fill) + .into() + }; + let mut items: Vec> = Vec::new(); if has_cmd { @@ -1395,6 +1428,27 @@ fn viewport_context_menu_overlay( Message::Command("COPY".to_string()), )); items.push(sep()); + let arrow = if draworder_open { "Draw Order \u{25be}" } else { "Draw Order \u{25b8}" }; + items.push(item(arrow.to_string(), Message::DrawOrderSubmenuToggle)); + if draworder_open { + items.push(subitem( + "Bring to Front".to_string(), + Message::Command("DRAWORDER F".to_string()), + )); + items.push(subitem( + "Send to Back".to_string(), + Message::Command("DRAWORDER B".to_string()), + )); + items.push(subitem( + "Bring Above Object".to_string(), + Message::DrawOrderPickRef(true), + )); + items.push(subitem( + "Send Under Object".to_string(), + Message::DrawOrderPickRef(false), + )); + } + items.push(sep()); items.push(item("Select Similar".to_string(), Message::SelectSimilar)); } items.push(item( diff --git a/src/modules/home/draw/hatch.rs b/src/modules/home/draw/hatch.rs index 15bd290ef..3e74dec6b 100644 --- a/src/modules/home/draw/hatch.rs +++ b/src/modules/home/draw/hatch.rs @@ -121,6 +121,7 @@ impl HatchCommand { scale: 1.0, world_origin: [0.0; 2], vp_scissor: None, + draw_depth: 0.0, } } } @@ -258,6 +259,7 @@ impl GradientCommand { scale: 1.0, world_origin: [0.0; 2], vp_scissor: None, + draw_depth: 0.0, } } } @@ -408,6 +410,7 @@ impl CadCommand for BoundaryCommand { scale: 1.0, world_origin: [0.0; 2], vp_scissor: None, + draw_depth: 0.0, }; return CmdResult::CommitHatch(model); } diff --git a/src/scene/hatch_model.rs b/src/scene/hatch_model.rs index 53572965d..666cc9ac1 100644 --- a/src/scene/hatch_model.rs +++ b/src/scene/hatch_model.rs @@ -78,6 +78,10 @@ pub struct HatchModel { /// preventing viewport-projected content from bleeding past the /// viewport frame. Mirrors `WireModel.vp_scissor`. pub vp_scissor: Option<[f32; 4]>, + /// Normalized draw-order depth in (0,1); higher draws on top. Fed to the + /// hatch pipeline as a small clip-z bias so this fill orders correctly + /// against other entity types. 0.0 for transient/preview hatches. + pub draw_depth: f32, } impl HatchModel { diff --git a/src/scene/image_model.rs b/src/scene/image_model.rs index c84d36124..ea36e2b0c 100644 --- a/src/scene/image_model.rs +++ b/src/scene/image_model.rs @@ -27,6 +27,10 @@ pub struct ImageModel { /// viewport clipping. Mirrors `WireModel.vp_scissor` / /// `HatchModel.vp_scissor`. pub vp_scissor: Option<[f32; 4]>, + /// Normalized draw-order depth in (0,1); higher draws on top. Fed to the + /// image pipeline as a small clip-z bias so the raster orders correctly + /// against other entity types. + pub draw_depth: f32, } impl ImageModel { @@ -61,6 +65,7 @@ impl ImageModel { opacity, corners, vp_scissor: None, + draw_depth: 0.0, }) } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 8377c0cab..60220212b 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -570,6 +570,14 @@ pub struct Scene { /// Maps block_handle → (entity_handle.value() → sort_handle.value()). /// Replaces the O(objects) linear scan inside `wires_for_block()` with an O(1) lookup. sort_cache: RefCell>)>>, + /// Per-entity normalized draw-order depth in (0,1), keyed by + /// entity_handle.value(). Higher = drawn on top. Built once per + /// geometry epoch by ranking every entity within its owning block by + /// effective sort key (SortEntitiesTable override or own handle), then + /// fed to the 2D pipelines as a small clip-z bias so entities of + /// *different* types order correctly against each other. 3D meshes are + /// excluded (they keep real geometric depth). + draw_depth_cache: RefCell>)>>, /// Cached hatch fill models, keyed by geometry_epoch. View culling /// is handled at draw time via `hatch_skip_flags` in the pipeline, /// not at build time — that lets the GPU buffer stay stable across @@ -683,6 +691,7 @@ impl Scene { wire_cache: RefCell::new(None), model_tile_wire_cache: RefCell::new(HashMap::new()), sort_cache: RefCell::new(None), + draw_depth_cache: RefCell::new(None), hatch_cache: RefCell::new(None), wipeout_cache: RefCell::new(None), image_cache: RefCell::new(None), @@ -1328,6 +1337,79 @@ impl Scene { (*self.entity_wires_arc()).clone() } + /// Per-entity normalized draw-order depth, keyed by entity handle value. + /// Built (and cached per geometry epoch) by ranking every entity within + /// its owning block by effective sort key (SortEntitiesTable override or + /// own handle). The result feeds the 2D pipelines as a clip-z bias so + /// entities of different types order correctly against each other. + pub(super) fn draw_depth_map(&self) -> Arc> { + { + let cache = self.draw_depth_cache.borrow(); + if let Some((epoch, ref arc)) = *cache { + if epoch == self.geometry_epoch { + return Arc::clone(arc); + } + } + } + use acadrust::objects::ObjectType; + // Per-block SortEntitiesTable overrides: block -> (entity_val -> sort_val). + let mut overrides: HashMap> = HashMap::new(); + for obj in self.document.objects.values() { + if let ObjectType::SortEntitiesTable(t) = obj { + if !t.is_empty() { + overrides.insert( + t.block_owner_handle, + t.entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(), + ); + } + } + } + let ms = self.model_space_block_handle(); + // Group entities by owning block, carrying each entity's effective key. + let mut by_block: HashMap> = HashMap::new(); + for e in self.document.entities() { + let c = e.common(); + // 3D meshes keep real geometric depth — exclude them from + // draw-order biasing so 3D occlusion is never flattened. + if matches!( + e, + EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) + ) { + continue; + } + let block = if c.owner_handle.is_null() { + ms + } else { + c.owner_handle + }; + let hv = c.handle.value(); + let eff = overrides + .get(&block) + .and_then(|m| m.get(&hv)) + .copied() + .unwrap_or(hv); + by_block.entry(block).or_default().push((hv, eff)); + } + let mut depth_map: HashMap = HashMap::new(); + for (_block, mut v) in by_block { + v.sort_by_key(|(_, eff)| *eff); + let denom = (v.len() as f32) + 1.0; + for (rank, (hv, _)) in v.into_iter().enumerate() { + // Signed (-1,1): back ranks → negative, front → positive, + // mid → ~0. The shader applies `z -= draw_depth * BIAS`, so a + // default/unranked 0.0 means "no bias" (neutral) — which keeps + // 3D mesh faces and transient wires at their real depth. + let norm = (rank as f32 + 1.0) / denom; // (0,1) + depth_map.insert(hv, (norm - 0.5) * 2.0); + } + } + let arc = Arc::new(depth_map); + *self.draw_depth_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); + arc + } + pub(super) fn hatch_models_arc(&self) -> Arc> { { let cache = self.hatch_cache.borrow(); @@ -1365,7 +1447,17 @@ impl Scene { } } } - let arc = Arc::new(self.images.values().cloned().collect()); + let depth_map = self.draw_depth_map(); + let arc = Arc::new( + self.images + .iter() + .map(|(handle, model)| { + let mut m = model.clone(); + m.draw_depth = depth_map.get(&handle.value()).copied().unwrap_or(0.0); + m + }) + .collect(), + ); *self.image_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); arc } @@ -1688,7 +1780,11 @@ impl Scene { let key = Self::handle_from_wire_name(&w.name) .map(|h| h.value()) .unwrap_or(u64::MAX); - sort_map.get(&key).copied().unwrap_or(u64::MAX / 2) + // Entities absent from the table sort by their own + // handle — the same key space the table's sort handles + // live in — so reordered and untouched entities interleave + // correctly instead of all collapsing to one constant. + sort_map.get(&key).copied().unwrap_or(key) }); } } @@ -2861,6 +2957,7 @@ impl Scene { // paper), so projecting them through a camera built for the // wrong block lands them outside the frustum and the per-vp // scissor / LOD culls them out — no double-rendering. + let depth_map = self.draw_depth_map(); let mut models: Vec = self .hatches .iter() @@ -2909,6 +3006,7 @@ impl Scene { if self.selected.contains(&handle) { m.color = [0.15, 0.55, 1.00, m.color[3]]; } + m.draw_depth = depth_map.get(&handle.value()).copied().unwrap_or(0.0); m }) .collect(); @@ -2992,6 +3090,7 @@ impl Scene { scale: 1.0, world_origin: [0.0; 2], vp_scissor: None, + draw_depth: depth_map.get(&common.handle.value()).copied().unwrap_or(0.0), }); } } @@ -3055,6 +3154,7 @@ impl Scene { scale: 1.0, world_origin: [0.0; 2], vp_scissor: None, + draw_depth: 0.0, }); } } @@ -3453,6 +3553,7 @@ impl Scene { scale: dxf.pattern_scale as f32, world_origin, vp_scissor: None, + draw_depth: 0.0, }) } @@ -3602,6 +3703,7 @@ impl Scene { scale: 1.0, world_origin: [0.0; 2], vp_scissor: None, + draw_depth: 0.0, } } @@ -5655,6 +5757,7 @@ impl Scene { scale: 1.0, world_origin: [0.0; 2], vp_scissor: None, + draw_depth: 0.0, }); } Arc::new(models) diff --git a/src/scene/pipeline/face3d_gpu.rs b/src/scene/pipeline/face3d_gpu.rs index 13f657237..dfb965405 100644 --- a/src/scene/pipeline/face3d_gpu.rs +++ b/src/scene/pipeline/face3d_gpu.rs @@ -29,6 +29,10 @@ use iced::wgpu::util::DeviceExt; pub struct Face3DVertex { pub position: [f32; 3], pub color: [f32; 4], + /// Normalized draw-order depth in (0,1) for 2D fills / 3DFACE quads; + /// applied as a small clip-z bias in the shader. 0.0 for true 3D mesh + /// faces (PolyfaceMesh / PolygonMesh) so their real depth is preserved. + pub draw_depth: f32, } impl Face3DVertex { @@ -47,6 +51,11 @@ impl Face3DVertex { shader_location: 1, format: wgpu::VertexFormat::Float32x4, }, + wgpu::VertexAttribute { + offset: 28, + shader_location: 2, + format: wgpu::VertexFormat::Float32, + }, ], } } @@ -84,7 +93,15 @@ impl Face3DGpu { face3d_wires: &[WireModel], all_wires: &[WireModel], keep_3d_mesh_fills: bool, + depth_map: &std::collections::HashMap, ) -> Self { + let depth_of = |w: &WireModel| -> f32 { + w.name + .parse::() + .ok() + .and_then(|h| depth_map.get(&h).copied()) + .unwrap_or(0.0) + }; let mut verts_3d: Vec = Vec::with_capacity(face3d_wires.len() * 6); let mut verts_2d: Vec = Vec::new(); @@ -97,10 +114,12 @@ impl Face3DGpu { } let [r, g, b, a] = wire.color; let fill_color = [r * 0.45, g * 0.45, b * 0.45, a]; + let depth = depth_of(wire); let p = &wire.key_vertices; let v = |i: usize| Face3DVertex { position: p[i], color: fill_color, + draw_depth: depth, }; verts_3d.push(v(0)); verts_3d.push(v(1)); @@ -132,18 +151,23 @@ impl Face3DGpu { continue; } let fill_color = [r * 0.45, g * 0.45, b * 0.45, a]; + // True 3D surface: keep real depth (no draw-order bias) so + // hidden-surface shading is preserved. for &position in &wire.fill_tris { verts_3d.push(Face3DVertex { position, color: fill_color, + draw_depth: 0.0, }); } } else { let fill_color = [r, g, b, a]; + let depth = depth_of(wire); for &position in &wire.fill_tris { verts_2d.push(Face3DVertex { position, color: fill_color, + draw_depth: depth, }); } } diff --git a/src/scene/pipeline/hatch_batched_gpu.rs b/src/scene/pipeline/hatch_batched_gpu.rs index 1ff61baab..1f5c94a51 100644 --- a/src/scene/pipeline/hatch_batched_gpu.rs +++ b/src/scene/pipeline/hatch_batched_gpu.rs @@ -64,7 +64,10 @@ pub struct HatchInstance { pub boundary_count: u32, // 92 pub family_offset: u32, // 96 pub family_count: u32, // 100 - pub _pad0: [u32; 2], // 104 (pad to 112 = 16-byte stride) + /// Signed draw-order depth (-1,1); 0.0 = neutral. Applied as a clip-z + /// bias in the vertex shader so this fill orders against other types. + pub draw_depth: f32, // 104 + pub _pad0: u32, // 108 (pad to 112 = 16-byte stride) } const _: () = assert!(std::mem::size_of::() == 112); @@ -274,7 +277,8 @@ impl HatchBatchedGpu { boundary_count, family_offset, family_count, - _pad0: [0; 2], + draw_depth: h.draw_depth, + _pad0: 0, }); continue; } @@ -312,7 +316,8 @@ impl HatchBatchedGpu { boundary_count, family_offset, family_count, - _pad0: [0; 2], + draw_depth: h.draw_depth, + _pad0: 0, }); } diff --git a/src/scene/pipeline/image_gpu.rs b/src/scene/pipeline/image_gpu.rs index 1fb28dc06..62af027ce 100644 --- a/src/scene/pipeline/image_gpu.rs +++ b/src/scene/pipeline/image_gpu.rs @@ -45,7 +45,10 @@ impl ImageVertex { #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] struct ImageParams { opacity: f32, - _pad: [f32; 3], + /// Signed draw-order depth (-1,1); applied as a clip-z bias in the shader + /// so the raster orders against other entity types. 0.0 = neutral. + draw_depth: f32, + _pad: [f32; 2], } // 16 bytes // ── Per-image GPU handle ────────────────────────────────────────────────── @@ -118,7 +121,8 @@ impl ImageGpu { // ── Opacity uniform ─────────────────────────────────────────────── let params = ImageParams { opacity: model.opacity.clamp(0.0, 1.0), - _pad: [0.0; 3], + draw_depth: model.draw_depth, + _pad: [0.0; 2], }; let _params_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("image.params"), diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 4d8cb3251..99ea420ec 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -646,10 +646,11 @@ impl Pipeline { ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, - // binding 2: ImageParams uniform + // binding 2: ImageParams uniform (read in vertex for the + // draw-order z bias and in fragment for opacity). wgpu::BindGroupLayoutEntry { binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, @@ -886,15 +887,30 @@ impl Pipeline { } } - pub fn upload_wires(&mut self, device: &wgpu::Device, wires: &[WireModel]) { - self.gpu_wires = wires.iter().map(|w| WireGpu::new(device, w)).collect(); + pub fn upload_wires( + &mut self, + device: &wgpu::Device, + wires: &[WireModel], + depth_map: &std::collections::HashMap, + ) { + let depth_of = |w: &WireModel| -> f32 { + w.name + .parse::() + .ok() + .and_then(|h| depth_map.get(&h).copied()) + .unwrap_or(0.0) + }; + self.gpu_wires = wires + .iter() + .map(|w| WireGpu::new(device, w, depth_of(w))) + .collect(); // Full-brightness copies of selected wires, drawn on top of everything // in the selection overlay pass so they're always visible. self.gpu_selected_wires = wires .iter() .filter(|w| w.selected) - .map(|w| WireGpu::new(device, w)) + .map(|w| WireGpu::new(device, w, depth_of(w))) .collect(); } @@ -971,10 +987,11 @@ impl Pipeline { face3d_wires: &[WireModel], all_wires: &[WireModel], wireframe_only: bool, + depth_map: &std::collections::HashMap, ) { // Edge buffer is always built from `face3d_wires`, so 3DFACE // outlines stay on the screen regardless of mode. - self.gpu_face3d_edges = WireGpu::from_batch(device, face3d_wires); + self.gpu_face3d_edges = WireGpu::from_batch(device, face3d_wires, depth_map); // Fill buffer split: 3D quads + PolyfaceMesh / PolygonMesh face // tris go to `vertex_buffer_3d` (gated by `keep_3d_mesh_fills`); // 2D fills (text-LOD greek, MultiLeader background) go to @@ -996,6 +1013,7 @@ impl Pipeline { face3d_wires, all_wires, keep_3d_mesh_fills, + depth_map, )); } } diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index d2a6d4a8b..bfc4d0fc9 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -71,6 +71,9 @@ pub struct WireInstance { pub pattern_length: f32, pub pat0: [f32; 4], pub pat1: [f32; 4], + /// Normalized draw-order depth in (0,1); applied as a small clip-z bias + /// in the shader so this wire orders against other entity types. + pub draw_depth: f32, } impl WireInstance { @@ -124,6 +127,11 @@ impl WireInstance { shader_location: 8, format: wgpu::VertexFormat::Float32x4, }, // pat1 + wgpu::VertexAttribute { + offset: 76, + shader_location: 9, + format: wgpu::VertexFormat::Float32, + }, // draw_depth ], } } @@ -158,7 +166,7 @@ fn pack_color(color: [f32; 4]) -> [u8; 4] { ] } -fn emit_wire_instances(wire: &WireModel, color: [f32; 4]) -> Vec { +fn emit_wire_instances(wire: &WireModel, color: [f32; 4], draw_depth: f32) -> Vec { let color_u8 = pack_color(color); let pat0 = [ wire.pattern[0], @@ -222,14 +230,27 @@ fn emit_wire_instances(wire: &WireModel, color: [f32; 4]) -> Vec { pattern_length: wire.pattern_length, pat0, pat1, + draw_depth, }); } instances } +/// Looks up a wire's draw-order depth from the per-entity map using the +/// handle encoded in its `name`. Falls back to 0.0 (transient / preview +/// wires that carry no document handle). +fn wire_draw_depth(wire: &WireModel, depth_map: &std::collections::HashMap) -> f32 { + wire + .name + .parse::() + .ok() + .and_then(|h| depth_map.get(&h).copied()) + .unwrap_or(0.0) +} + impl WireGpu { - pub fn new(device: &wgpu::Device, wire: &WireModel) -> Self { - let mut g = Self::build(device, wire, wire.color); + pub fn new(device: &wgpu::Device, wire: &WireModel, draw_depth: f32) -> Self { + let mut g = Self::build(device, wire, wire.color, draw_depth); g.vp_scissor = wire.vp_scissor; g.is_3d_mesh_edge = !wire.fill_tris.is_empty(); g @@ -238,7 +259,11 @@ impl WireGpu { /// Merge multiple WireModels into GPU instance buffers, chunked to fit the /// 256 MB GPU limit. Each wire keeps its own color and pattern — they live /// per-instance. - pub fn from_batch(device: &wgpu::Device, wires: &[WireModel]) -> Vec { + pub fn from_batch( + device: &wgpu::Device, + wires: &[WireModel], + depth_map: &std::collections::HashMap, + ) -> Vec { let total_segs: usize = wires.iter().map(|w| w.points.len().saturating_sub(1)).sum(); if total_segs == 0 { return vec![]; @@ -251,7 +276,7 @@ impl WireGpu { // across cores. let instances: Vec = wires .par_iter() - .map(|wire| emit_wire_instances(wire, wire.color)) + .map(|wire| emit_wire_instances(wire, wire.color, wire_draw_depth(wire, depth_map))) .reduce(Vec::new, |mut acc, mut chunk| { if acc.is_empty() { chunk @@ -286,8 +311,8 @@ impl WireGpu { .collect() } - fn build(device: &wgpu::Device, wire: &WireModel, color: [f32; 4]) -> Self { - let instances = emit_wire_instances(wire, color); + fn build(device: &wgpu::Device, wire: &WireModel, color: [f32; 4], draw_depth: f32) -> Self { + let instances = emit_wire_instances(wire, color, draw_depth); let label = format!("wire.ibuf.{}", wire.name); let instance_buffer = instance_buffer_mapped(device, &label, &instances); diff --git a/src/scene/render.rs b/src/scene/render.rs index c994dffad..9b0257801 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -35,6 +35,11 @@ pub struct ViewportData { /// 3DFACE entity wires — separated so they are uploaded to the dedicated /// face3d pipeline (fill + batched edges) instead of N individual WireGpu. pub(super) face3d_wires: Arc>, + /// Per-entity normalized draw-order depth (handle.value() → (0,1)), used + /// by the wire / face3d pipelines as a clip-z bias. WireModels carry no + /// depth field (84 construction sites); the bias is looked up by handle + /// at GPU-upload time from this map instead. + pub(super) draw_depths: Arc>, pub(super) hatches: Arc>, /// Wipeout fills — rendered in a separate pass AFTER wires. pub(super) wipeout_hatches: Arc>, @@ -224,12 +229,13 @@ impl shader::Primitive for Primitive { } // Wires re-upload on every camera change because the visible // subset shifts under frustum culling. - inner.upload_wires(device, &vp.wires[..]); + inner.upload_wires(device, &vp.wires[..], &vp.draw_depths); inner.upload_face3d( device, &vp.face3d_wires[..], &vp.wires[..], !face3d_fill_active, + &vp.draw_depths, ); inner.cached_epoch = cur_key; } @@ -645,6 +651,7 @@ impl Scene { Some(ViewportData { wires: all_wires, face3d_wires: Arc::new(face3d_wires), + draw_depths: self.draw_depth_map(), hatches: self.hatch_models_arc(), wipeout_hatches: self.wipeout_models_arc(), images: self.images_arc(), diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 32cb9be66..9261e7c63 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -15,6 +15,8 @@ pub struct SelectionState { pub poly_crossing: bool, pub poly_last_crossing: bool, pub context_menu: Option, + /// True while the context menu's Draw Order sub-items are expanded. + pub draworder_submenu: bool, pub last_move_pos: Option, pub left_down: bool, pub left_press_pos: Option, diff --git a/src/shaders/face3d.wgsl b/src/shaders/face3d.wgsl index 074d17b0e..10441dc39 100644 --- a/src/shaders/face3d.wgsl +++ b/src/shaders/face3d.wgsl @@ -14,8 +14,9 @@ struct Uniforms { var u: Uniforms; struct VertexIn { - @location(0) position: vec3, - @location(1) color: vec4, + @location(0) position: vec3, + @location(1) color: vec4, + @location(2) draw_depth: f32, }; struct VertexOut { @@ -23,10 +24,15 @@ struct VertexOut { @location(0) color: vec4, }; +// Draw-order depth bias (see wire.wgsl). Signed draw_depth; 0.0 (3D mesh +// faces) leaves real depth untouched; 2D fills / 3DFACE quads order by rank. +const DRAW_ORDER_BIAS: f32 = 0.001; + @vertex fn vs_main(v: VertexIn) -> VertexOut { var out: VertexOut; out.clip_pos = u.view_proj * vec4(v.position, 1.0); + out.clip_pos.z = out.clip_pos.z - v.draw_depth * DRAW_ORDER_BIAS * out.clip_pos.w; out.color = v.color; return out; } diff --git a/src/shaders/hatch_batched.wgsl b/src/shaders/hatch_batched.wgsl index f7933ab54..f53ce6982 100644 --- a/src/shaders/hatch_batched.wgsl +++ b/src/shaders/hatch_batched.wgsl @@ -45,9 +45,14 @@ struct HatchInstance { boundary_count: u32, family_offset: u32, family_count: u32, - _pad0: vec2, + draw_depth: f32, // signed (-1,1) draw-order bias; 0 = neutral + _pad0: u32, } +// Draw-order depth bias (see wire.wgsl). Higher draw_depth → smaller z → +// drawn on top, ordering this fill against other entity types. +const DRAW_ORDER_BIAS: f32 = 0.001; + struct LineFamily { cos_a: f32, sin_a: f32, @@ -122,6 +127,7 @@ fn corner_xy(c: u32, aabb: vec4) -> vec2 { local.y + inst.world_origin.y, 0.0); o.clip = u.view_proj * vec4(world, 1.0); + o.clip.z = o.clip.z - inst.draw_depth * DRAW_ORDER_BIAS * o.clip.w; o.xz = local; o.instance_index = v.instance_index; return o; diff --git a/src/shaders/image.wgsl b/src/shaders/image.wgsl index bc74abaf9..e41e7d0c5 100644 --- a/src/shaders/image.wgsl +++ b/src/shaders/image.wgsl @@ -19,13 +19,16 @@ struct Uniforms { // Per-image params (fade, clip flag, etc.) struct ImageParams { - opacity: f32, - _pad0: f32, - _pad1: f32, - _pad2: f32, + opacity: f32, + draw_depth: f32, // signed (-1,1) draw-order bias; 0 = neutral + _pad1: f32, + _pad2: f32, }; @group(1) @binding(2) var img_params: ImageParams; +// Draw-order depth bias (see wire.wgsl). +const DRAW_ORDER_BIAS: f32 = 0.001; + // ── Vertex stage ────────────────────────────────────────────────────────────── struct VertIn { @location(0) pos: vec3, @@ -41,6 +44,7 @@ struct VertOut { fn vs_main(in: VertIn) -> VertOut { var out: VertOut; out.clip_pos = u.mvp * vec4(in.pos, 1.0); + out.clip_pos.z = out.clip_pos.z - img_params.draw_depth * DRAW_ORDER_BIAS * out.clip_pos.w; out.uv = in.uv; return out; } diff --git a/src/shaders/wire.wgsl b/src/shaders/wire.wgsl index 84e7b6482..112292b9e 100644 --- a/src/shaders/wire.wgsl +++ b/src/shaders/wire.wgsl @@ -35,8 +35,15 @@ struct InstanceIn { @location(6) pattern_length: f32, @location(7) pat0: vec4, @location(8) pat1: vec4, + @location(9) draw_depth: f32, } +// Draw-order depth bias: shifts clip-space z so 2D entities of different +// types order against each other through the shared LessEqual depth test. +// draw_depth is signed (-1,1): front → positive → smaller z → drawn on top; +// 0.0 = neutral (real depth). Depth32Float gives ample precision. +const DRAW_ORDER_BIAS: f32 = 0.001; + struct VertexOut { @builtin(position) clip_pos: vec4, @location(0) color: vec4, @@ -111,6 +118,7 @@ struct VertexOut { var out: VertexOut; out.clip_pos = final_clip; + out.clip_pos.z = out.clip_pos.z - in.draw_depth * DRAW_ORDER_BIAS * out.clip_pos.w; out.color = in.color; out.distance = mix(in.distance_a, in.distance_b, which_end); out.pattern_length = in.pattern_length;