From 0e5dec0948a0ff15bff8b3340c5cdc676c45df99 Mon Sep 17 00:00:00 2001 From: Hakan Date: Wed, 6 May 2026 17:50:46 +0300 Subject: [PATCH] feat: annotation scale system (CANNOSCALE, PSLTSCALE, MultiLeader, Leader) - Scale popup dropdown replaces cycle behavior; paper-space viewport scale editable when a viewport is active/selected - CANNOSCALE persisted via DXF $USERR1 (read on open, write on save) - PSLTSCALE: linetype dash lengths scaled by viewport anno_scale so dashes appear uniform physical size in paper space - MultiLeader: arrow size, dogleg length, and text height scale with anno_scale; leader line paths (world coords) stay fixed - Leader: arrow size and hookline length scale with anno_scale; also corrects world_offset subtraction that was previously deferred to the TruckObject::Lines path Co-Authored-By: Claude Sonnet 4.6 --- src/app/mod.rs | 10 ++ src/app/update.rs | 21 +++ src/app/view.rs | 56 +++++--- src/entities/multileader.rs | 2 +- src/scene/mod.rs | 96 +++++++++++-- src/scene/tessellate.rs | 273 +++++++++++++++++++----------------- src/ui/mod.rs | 1 + src/ui/scale_popup.rs | 126 +++++++++++++++++ src/ui/statusbar.rs | 48 ++++++- 9 files changed, 471 insertions(+), 162 deletions(-) create mode 100644 src/ui/scale_popup.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 26ccd5b0a..e613d41e9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -39,6 +39,7 @@ pub(super) struct H7CAD { vp_size: (f32, f32), snapper: Snapper, snap_popup_open: bool, + scale_popup_open: bool, /// Whether Tangent snap was enabled before a tangent-pick command started. pre_cmd_tangent: Option, /// Orthogonal drawing constraint (F8): constrains picks to 0°/90°/180°/270°. @@ -322,6 +323,14 @@ pub enum Message { TogglePolar, /// Set polar tracking angle increment (right-click POLAR button). SetPolarAngle(f32), + /// Set the model-space annotation scale (CANNOSCALE equivalent). + SetAnnotationScale(f32), + /// Set the active viewport's custom_scale (paper space). + SetViewportScale(f64), + /// Toggle the scale picker popup open/closed. + ToggleScalePopup, + /// Close the scale picker popup. + CloseScalePopup, /// Toggle dynamic input overlay (F12). ToggleDynInput, /// Toggle object snap tracking (F11). @@ -606,6 +615,7 @@ impl H7CAD { vp_size: (1280.0, 720.0), snapper: Snapper::default(), snap_popup_open: false, + scale_popup_open: false, pre_cmd_tangent: None, ortho_mode: false, polar_mode: false, diff --git a/src/app/update.rs b/src/app/update.rs index 7f337c5c7..8a77ad64e 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -60,6 +60,8 @@ impl H7CAD { self.tabs[i].current_path = Some(path.clone()); self.tabs[i].scene.document = doc; + let stored = self.tabs[i].scene.document.header.user_real1; + self.tabs[i].scene.annotation_scale = if stored > 1e-9 { stored as f32 } else { 1.0 }; // Auto-resolve XREFs relative to the opened file's directory. if let Some(base_dir) = path.parent() { @@ -407,6 +409,7 @@ impl H7CAD { let i = self.active_tab; if let Some(path) = &self.tabs[i].current_path { let path = path.clone(); + self.tabs[i].scene.document.header.user_real1 = self.tabs[i].scene.annotation_scale as f64; match crate::io::save(&self.tabs[i].scene.document, &path) { Ok(()) => { self.command_line @@ -468,6 +471,7 @@ impl H7CAD { let (_, version) = crate::io::parse_save_format(&self.save_dialog_format); let close = self.close_save_dialog_window(); let i = self.active_tab; + self.tabs[i].scene.document.header.user_real1 = self.tabs[i].scene.annotation_scale as f64; match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) { Ok(()) => { self.command_line.push_output(&format!("Saved: {}", path.display())); @@ -2019,6 +2023,23 @@ impl H7CAD { self.ortho_mode = false; Task::none() } + Message::SetAnnotationScale(scale) => { + self.scale_popup_open = false; + if let Some(tab) = self.tabs.get_mut(self.active_tab) { + tab.scene.annotation_scale = scale; + tab.scene.bump_geometry(); + } + Task::none() + } + Message::SetViewportScale(scale) => { + self.scale_popup_open = false; + if let Some(tab) = self.tabs.get_mut(self.active_tab) { + tab.scene.set_viewport_scale(scale); + } + Task::none() + } + Message::ToggleScalePopup => { self.scale_popup_open ^= true; Task::none() } + Message::CloseScalePopup => { self.scale_popup_open = false; Task::none() } Message::ToggleSnap(t) => { self.snapper.toggle(t); Task::none() } Message::ToggleSnapPopup => { self.snap_popup_open ^= true; Task::none() } Message::CloseSnapPopup => { self.snap_popup_open = false; Task::none() } diff --git a/src/app/view.rs b/src/app/view.rs index c57f27534..965314363 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -344,23 +344,32 @@ impl H7CAD { } col.push(center_stack) .push(self.command_line.view()) - .push(self.status_bar.view( - &self.snapper, - self.snap_popup_open, - self.ortho_mode, - self.polar_mode, - self.polar_increment_deg, - self.show_grid, - self.dyn_input, - self.snapper.otrack_enabled, - tab.scene.layout_names(), - tab.scene.current_layout.clone(), - self.layout_rename_state.as_ref(), - tab.scene.first_viewport_scale(), - tab.scene.viewport_count(), - tab.scene.active_viewport.is_some(), - self.show_layout_tabs, - )) + .push({ + let is_model = tab.scene.current_layout == "Model"; + let scale_pill_enabled = is_model + || tab.scene.active_viewport.is_some() + || tab.scene.has_selected_viewport(); + self.status_bar.view( + &self.snapper, + self.snap_popup_open, + self.ortho_mode, + self.polar_mode, + self.polar_increment_deg, + self.show_grid, + self.dyn_input, + self.snapper.otrack_enabled, + tab.scene.layout_names(), + tab.scene.current_layout.clone(), + self.layout_rename_state.as_ref(), + tab.scene.first_viewport_scale(), + tab.scene.viewport_count(), + tab.scene.active_viewport.is_some(), + self.show_layout_tabs, + tab.scene.annotation_scale, + self.scale_popup_open, + scale_pill_enabled, + ) + }) .width(Fill) .height(Fill) }) @@ -377,6 +386,17 @@ impl H7CAD { iced::widget::Space::new().width(0).height(0).into() }; + let scale_layer: Element<'_, Message> = if self.scale_popup_open { + let is_model = tab.scene.current_layout == "Model"; + crate::ui::scale_popup::scale_popup_overlay( + is_model, + tab.scene.annotation_scale, + tab.scene.first_viewport_scale(), + ) + } else { + iced::widget::Space::new().width(0).height(0).into() + }; + let dropdown_layer: Element<'_, Message> = self .ribbon .dropdown_overlay( @@ -406,7 +426,7 @@ impl H7CAD { } }; - stack![main_ui, self.app_menu.view(), snap_layer, dropdown_layer, layout_ctx_layer, viewport_ctx_layer].into() + stack![main_ui, self.app_menu.view(), snap_layer, scale_layer, dropdown_layer, layout_ctx_layer, viewport_ctx_layer].into() } pub fn subscription(&self) -> Subscription { diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index 5491e52e4..20f9d8304 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -11,7 +11,7 @@ use crate::scene::wire_model::{SnapHint, TangentGeom}; // ── TruckConvertible ──────────────────────────────────────────────────────── /// Catmull-Rom spline tessellation through `ctrl` points, `segs_per_span` segments each. -fn catmull_rom_pts(ctrl: &[[f32; 3]], segs_per_span: u32) -> Vec<[f32; 3]> { +pub(crate) fn catmull_rom_pts(ctrl: &[[f32; 3]], segs_per_span: u32) -> Vec<[f32; 3]> { let n = ctrl.len(); let mut out = Vec::new(); for i in 0..n.saturating_sub(1) { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index fa32079c3..3cebc024b 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -130,6 +130,10 @@ pub struct Scene { /// EXTMIN/EXTMAX (10× safety margin). Used by fit_all() to ignore garbage /// entity coordinates (origin-stuck entities, bad Ray/XLine direction vectors). pub local_extent_max: f32, + /// Current annotation scale (CANNOSCALE equivalent). + /// Multiplier applied to Text/MText/Dimension sizes during tessellation. + /// 1.0 = no scaling. 50.0 = "1:50" drawing scale. + pub annotation_scale: f32, } impl Scene { @@ -162,6 +166,7 @@ impl Scene { paper_bg_color: [1.0, 1.0, 1.0, 1.0], world_offset: [0.0; 3], local_extent_max: 1e9, + annotation_scale: 1.0, } } @@ -439,6 +444,57 @@ impl Scene { }).count() } + /// True if any currently selected entity is a Viewport. + /// Used to enable the scale picker when a viewport is selected in paper space. + pub fn has_selected_viewport(&self) -> bool { + self.selected.iter().any(|&h| { + matches!(self.document.get_entity(h), Some(EntityType::Viewport(_))) + }) + } + + /// First content viewport handle in the current layout, used as fallback target + /// when no viewport is active or explicitly selected. + fn first_viewport_handle(&self) -> Option { + if self.current_layout == "Model" { + return None; + } + let layout_block = self.current_layout_block_handle(); + if layout_block.is_null() { + return None; + } + self.document.entities().find_map(|e| { + if let EntityType::Viewport(vp) = e { + if Self::is_content_viewport(vp) && vp.common.owner_handle == layout_block { + return Some(vp.common.handle); + } + } + None + }) + } + + /// Set the scale of the active/selected viewport. + /// Priority: active_viewport → first selected viewport → first viewport in layout. + pub fn set_viewport_scale(&mut self, scale: f64) { + let target = self.active_viewport + .or_else(|| { + self.selected.iter().copied().find(|&h| { + matches!(self.document.get_entity(h), Some(EntityType::Viewport(_))) + }) + }) + .or_else(|| self.first_viewport_handle()); + + if let Some(handle) = target { + if let Some(EntityType::Viewport(vp)) = self.document.get_entity_mut(handle) { + if !vp.status.locked && scale > 1e-9 { + vp.custom_scale = scale; + vp.view_height = vp.height / scale; + } + } + self.viewport_wire_cache.borrow_mut().remove(&handle); + self.bump_geometry(); + } + } + /// Sorted list of layout names: "Model" first, then paper layouts by tab order. pub fn layout_names(&self) -> Vec { let mut names = vec!["Model".to_string()]; @@ -672,9 +728,10 @@ impl Scene { // coordinates, so world_offset must not be subtracted from them. let woff = if self.current_layout == "Model" { self.world_offset } else { [0.0; 3] }; let bg = if self.current_layout == "Model" { self.bg_color } else { self.paper_bg_color }; + let anno = if self.current_layout == "Model" { self.annotation_scale } else { 1.0 }; let mut wires: Vec = visible .into_par_iter() - .flat_map(|e| tessellate_entity(doc, sel, avp, woff, bg, e)) + .flat_map(|e| tessellate_entity(doc, sel, avp, woff, bg, anno, e)) .collect(); // Apply draw order via the cached index (O(1) block lookup). @@ -733,7 +790,8 @@ impl Scene { /// Full tessellation pipeline for one entity. fn tessellate_one(&self, e: &EntityType) -> Vec { let bg = if self.current_layout == "Model" { self.bg_color } else { self.paper_bg_color }; - tessellate_entity(&self.document, &self.selected, self.active_viewport, self.world_offset, bg, e) + let anno = if self.current_layout == "Model" { self.annotation_scale } else { 1.0 }; + tessellate_entity(&self.document, &self.selected, self.active_viewport, self.world_offset, bg, anno, e) } fn model_space_block_handle(&self) -> Handle { @@ -2941,9 +2999,20 @@ impl Scene { fn model_wires_for_viewport(&self, vp_handle: Handle) -> Vec { use std::collections::HashSet as HSet; - let frozen: HSet = match self.document.get_entity(vp_handle) { - Some(EntityType::Viewport(vp)) => vp.frozen_layers.iter().cloned().collect(), - _ => HSet::new(), + let (frozen, vp_anno_scale) = match self.document.get_entity(vp_handle) { + Some(EntityType::Viewport(vp)) => { + let f: HSet = vp.frozen_layers.iter().cloned().collect(); + let vp_scale = if vp.custom_scale.abs() > 1e-9 { + vp.custom_scale + } else if vp.view_height.abs() > 1e-9 { + vp.height / vp.view_height + } else { + 1.0 + }; + let anno = if vp_scale > 1e-9 { (1.0 / vp_scale) as f32 } else { 1.0_f32 }; + (f, anno) + } + _ => (HSet::new(), 1.0_f32), }; let model_block = self.model_space_block_handle(); @@ -2982,6 +3051,7 @@ impl Scene { self.active_viewport, self.world_offset, self.bg_color, + vp_anno_scale, e, )) .collect() @@ -3136,6 +3206,7 @@ fn tessellate_entity( active_viewport: Option, world_offset: [f64; 3], bg_color: [f32; 4], + anno_scale: f32, e: &EntityType, ) -> Vec { let h = e.common().handle; @@ -3164,7 +3235,7 @@ fn tessellate_entity( (0.0_f32, [0.0f32; 8]) }; let mut wire = tessellate::tessellate( - document, h, e, sel, color, pattern_length, pattern, 1.5, world_offset, + document, h, e, sel, color, pattern_length, pattern, 1.5, world_offset, 1.0, ); wire.aabb = entity_aabb(e, world_offset); return vec![wire]; @@ -3175,11 +3246,15 @@ fn tessellate_entity( let entity_color = render::adapt_to_bg(entity_color, bg_color); let lt_scale = document.header.linetype_scale as f32 * e.common().linetype_scale as f32; let lt_name = render::linetype_name_for(document, e); + // PSLTSCALE: scale linetype dashes by viewport anno_scale so they appear uniform in paper space. + let pslt_factor = if document.header.paper_space_linetype_scaling { anno_scale } else { 1.0 }; + let pattern_length = pattern_length * pslt_factor; + let pattern = pattern.map(|v| v * pslt_factor); if let EntityType::Dimension(dim) = e { let aabb = entity_aabb(e, world_offset); let mut wires = tessellate::tessellate_dimension( - document, h, dim, sel, entity_color, line_weight_px, world_offset, + document, h, dim, sel, entity_color, line_weight_px, world_offset, anno_scale, ); for w in &mut wires { w.aci = aci; @@ -3208,6 +3283,8 @@ fn tessellate_entity( ); let sub_color = render::adapt_to_bg(sub_color, bg_color); let sub_aabb = entity_aabb(&sub, world_offset); + let sub_pattern_length = sub_pattern_length * pslt_factor; + let sub_pattern = sub_pattern.map(|v| v * pslt_factor); let mut wire = tessellate::tessellate( document, h, @@ -3218,6 +3295,7 @@ fn tessellate_entity( sub_pattern, sub_line_weight_px, world_offset, + anno_scale, ); wire.name = h.value().to_string(); wire.aci = sub_aci; @@ -3253,7 +3331,7 @@ fn tessellate_entity( let aabb = entity_aabb(e, world_offset); let mut base = tessellate::tessellate( - document, h, e, sel, entity_color, pattern_length, pattern, line_weight_px, world_offset, + document, h, e, sel, entity_color, pattern_length, pattern, line_weight_px, world_offset, anno_scale, ); base.aci = aci; base.aabb = aabb; @@ -3263,7 +3341,7 @@ fn tessellate_entity( &base.name, &base.points, clt, - lt_scale.max(1e-4), + (lt_scale * pslt_factor).max(1e-4), entity_color, sel, base.line_weight_px, diff --git a/src/scene/tessellate.rs b/src/scene/tessellate.rs index 648e9a118..a7b7f064a 100644 --- a/src/scene/tessellate.rs +++ b/src/scene/tessellate.rs @@ -13,7 +13,8 @@ // Entities not handled by acad_to_truck (Viewport, Hatch, …) are tessellated // by the legacy geometry() path so nothing regresses. -use acadrust::entities::{Dimension, Leader, MultiLeader, MultiLeaderPathType, Text}; +use acadrust::entities::{Dimension, Leader, LeaderContentType, MultiLeader, MultiLeaderPathType, Text}; +use crate::entities::multileader::catmull_rom_pts; use acadrust::types::{Color as AcadColor, Vector3}; use acadrust::{CadDocument, EntityType, Handle}; use glam::Vec3; @@ -52,6 +53,7 @@ pub fn tessellate( pattern: [f32; 8], line_weight_px: f32, world_offset: [f64; 3], + anno_scale: f32, ) -> WireModel { let color = if selected { WireModel::SELECTED @@ -60,6 +62,18 @@ pub fn tessellate( }; let name = handle.value().to_string(); + // MultiLeader/Leader need anno_scale for arrow/dogleg/text — handle before generic path. + if let EntityType::MultiLeader(ml) = entity { + return tessellate_multileader_single( + document, handle, ml, selected, entity_color, line_weight_px, world_offset, anno_scale, + ); + } + if let EntityType::Leader(leader) = entity { + return tessellate_leader_single( + handle, leader, selected, entity_color, line_weight_px, world_offset, anno_scale, + ); + } + // ── Try the truck path first ─────────────────────────────────────────── if let Some(te) = convert(entity, document) { match te.object { @@ -73,9 +87,17 @@ pub fn tessellate( let mut points: Vec<[f32; 3]> = Vec::new(); let mut first = true; + // Annotation scale: scale glyph strokes relative to the text + // insertion point (first group's origin) so multi-line MText + // lines spread apart correctly as well as growing in size. + let ref_origin = stroke_groups.first().map(|g| g.origin).unwrap_or([0.0, 0.0]); + let ref_lx = (ref_origin[0] - ox) as f32; + let ref_ly = (ref_origin[1] - oy) as f32; for group in &stroke_groups { let lx = (group.origin[0] - ox) as f32; let ly = (group.origin[1] - oy) as f32; + let slx = (lx - ref_lx) * anno_scale + ref_lx; + let sly = (ly - ref_ly) * anno_scale + ref_ly; for stroke in &group.strokes { if stroke.len() < 2 { continue; @@ -85,7 +107,7 @@ pub fn tessellate( } first = false; for &[x, y] in stroke { - points.push([x + lx, y + ly, elev]); + points.push([x * anno_scale + slx, y * anno_scale + sly, elev]); } } } @@ -309,6 +331,7 @@ pub fn tessellate_dimension( entity_color: [f32; 4], line_weight_px: f32, world_offset: [f64; 3], + anno_scale: f32, ) -> Vec { let color = if selected { WireModel::SELECTED @@ -323,7 +346,7 @@ pub fn tessellate_dimension( .find(|s| s.name.eq_ignore_ascii_case(style_name) || (style_name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard"))) .map(|s| { - let scale = if s.dimscale > 1e-6 { s.dimscale } else { 1.0 }; + let scale = (if s.dimscale > 1e-6 { s.dimscale } else { 1.0 }) * anno_scale as f64; ( ((s.dimasz * scale) as f32).max(0.001), (s.dimexo * scale) as f32, @@ -368,6 +391,7 @@ pub fn tessellate_dimension( [0.0; 8], line_weight_px, world_offset, + anno_scale, ); wire.name = name; wires.push(wire); @@ -376,91 +400,81 @@ pub fn tessellate_dimension( wires } -/// Kept for backwards compatibility — geometry now lives in entities/leader.rs. -#[allow(dead_code)] -fn tessellate_leader( +fn tessellate_leader_single( handle: Handle, leader: &Leader, selected: bool, entity_color: [f32; 4], line_weight_px: f32, -) -> Vec { + world_offset: [f64; 3], + anno_scale: f32, +) -> WireModel { let color = if selected { WireModel::SELECTED } else { entity_color }; let name = handle.value().to_string(); + let [ox, oy, oz] = world_offset; + let p3 = |v: &Vector3| -> [f32; 3] { + [(v.x - ox) as f32, (v.y - oy) as f32, (v.z - oz) as f32] + }; + let nan = [f32::NAN; 3]; let verts = &leader.vertices; + if verts.len() < 2 { - return vec![WireModel { + return WireModel { name, points: vec![], color, selected, + aci: 0, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, snap_pts: vec![], tangent_geoms: vec![], - aci: 0, key_vertices: vec![], aabb: WireModel::UNBOUNDED_AABB, plinegen: true, vp_scissor: None, - }]; + }; } - let to_f32 = |v: &Vector3| -> [f32; 3] { [v.x as f32, v.y as f32, v.z as f32] }; - let nan = [f32::NAN; 3]; + let mut points: Vec<[f32; 3]> = verts.iter().map(|v| p3(v)).collect(); + let mut tangents: Vec = Vec::new(); + let key_vertices: Vec<[f32; 3]> = verts.iter().map(|v| p3(v)).collect(); - // Main path - let mut points: Vec<[f32; 3]> = verts.iter().map(to_f32).collect(); + for i in 0..verts.len().saturating_sub(1) { + tangents.push(TangentGeom::Line { p1: p3(&verts[i]), p2: p3(&verts[i + 1]) }); + } - // Arrowhead at vertex[0] — only when arrow_enabled if leader.arrow_enabled { - let tip = verts[0]; - let next = verts[1]; + let tip = &verts[0]; + let next = &verts[1]; let dx = (next.x - tip.x) as f32; let dy = (next.y - tip.y) as f32; let len = (dx * dx + dy * dy).sqrt().max(1e-9); let (dx, dy) = (dx / len, dy / len); - let arrow_size = (leader.text_height as f32).max(1.0) * 0.8; - let angle = std::f32::consts::PI / 6.0; - let (s, c) = angle.sin_cos(); - let wing1 = [ - tip.x as f32 + (dx * c - dy * s) * arrow_size, - tip.y as f32 + (dx * s + dy * c) * arrow_size, - tip.z as f32, - ]; - let wing2 = [ - tip.x as f32 + (dx * c + dy * s) * arrow_size, - tip.y as f32 + (-dx * s + dy * c) * arrow_size, - tip.z as f32, - ]; + let sz = (leader.text_height as f32).max(1.0) * 0.8 * anno_scale; + let a = std::f32::consts::PI / 6.0; + let (s, c) = a.sin_cos(); + let tip_f = p3(tip); points.push(nan); - points.push(wing1); - points.push(to_f32(&tip)); - points.push(wing2); + points.push([tip_f[0] + (dx*c - dy*s)*sz, tip_f[1] + (dx*s + dy*c)*sz, tip_f[2]]); + points.push(tip_f); + points.push([tip_f[0] + (dx*c + dy*s)*sz, tip_f[1] + (-dx*s + dy*c)*sz, tip_f[2]]); } - // Landing line at last vertex if leader.hookline_enabled { - let last = *verts.last().unwrap(); - let prev = verts[verts.len() - 2]; - let last_dir_x = (last.x - prev.x) as f32; - let sign = if last_dir_x >= 0.0 { 1.0_f32 } else { -1.0_f32 }; - let landing_len = leader.text_height as f32 * 1.5; - let landing_pt = [ - last.x as f32 + sign * landing_len, - last.y as f32, - last.z as f32, - ]; + let last = verts.last().unwrap(); + let prev = &verts[verts.len() - 2]; + let sign = if (last.x - prev.x) >= 0.0 { 1.0_f32 } else { -1.0_f32 }; + let land_len = leader.text_height as f32 * 1.5 * anno_scale; + let last_f = p3(last); points.push(nan); - points.push(to_f32(&last)); - points.push(landing_pt); + points.push(last_f); + points.push([last_f[0] + sign * land_len, last_f[1], last_f[2]]); } - let key_vertices: Vec<[f32; 3]> = verts.iter().map(to_f32).collect(); - - vec![WireModel { + WireModel { name, points, color, @@ -470,17 +484,15 @@ fn tessellate_leader( pattern: [0.0; 8], line_weight_px, snap_pts: vec![], - tangent_geoms: vec![], + tangent_geoms: tangents, key_vertices, aabb: WireModel::UNBOUNDED_AABB, - plinegen: true, - vp_scissor: None, - }] + plinegen: true, + vp_scissor: None, + } } -/// Kept for backwards compatibility — geometry now lives in entities/multileader.rs. -#[allow(dead_code)] -fn tessellate_multileader( +fn tessellate_multileader_single( document: &CadDocument, handle: Handle, ml: &MultiLeader, @@ -488,128 +500,125 @@ fn tessellate_multileader( entity_color: [f32; 4], line_weight_px: f32, world_offset: [f64; 3], -) -> Vec { + anno_scale: f32, +) -> WireModel { let color = if selected { WireModel::SELECTED } else { entity_color }; let name = handle.value().to_string(); let nan = [f32::NAN; 3]; - let [ox, oy, oz] = world_offset; - let to_f32 = |v: &acadrust::types::Vector3| -> [f32; 3] { + let p3 = |v: &acadrust::types::Vector3| -> [f32; 3] { [(v.x - ox) as f32, (v.y - oy) as f32, (v.z - oz) as f32] }; - let arrow_size = ml.arrowhead_size as f32; + let arrow_size = ml.arrowhead_size as f32 * anno_scale; let draw_arrow = arrow_size > 0.0; let invisible = ml.path_type == MultiLeaderPathType::Invisible; let mut points: Vec<[f32; 3]> = Vec::new(); let mut key_verts: Vec<[f32; 3]> = Vec::new(); - let mut first_segment = true; + let mut snap_pts: Vec<(Vec3, SnapHint)> = Vec::new(); + let mut tangents: Vec = Vec::new(); + let mut first = true; for root in &ml.context.leader_roots { let cp = &root.connection_point; - let cp_f = to_f32(cp); + let cp_f = p3(cp); + snap_pts.push((Vec3::from(cp_f), SnapHint::Node)); for line in &root.lines { if line.points.is_empty() { continue; } - // Leader line segments (hidden when path_type = Invisible) if !invisible { - if !first_segment { points.push(nan); } - first_segment = false; + if !first { points.push(nan); } + first = false; - for p in &line.points { - points.push(to_f32(p)); - key_verts.push(to_f32(p)); + let mut ctrl: Vec<[f32; 3]> = line.points.iter().map(|p| p3(p)).collect(); + let last_f = *ctrl.last().unwrap_or(&cp_f); + let dist = ((last_f[0]-cp_f[0]).powi(2) + (last_f[1]-cp_f[1]).powi(2)).sqrt(); + if dist > 1e-9 { ctrl.push(cp_f); } + for &c in &ctrl { + key_verts.push(c); + snap_pts.push((Vec3::from(c), SnapHint::Node)); } - // Closing segment: last bend point → connection_point - let last = line.points.last().unwrap(); - let last_f = to_f32(last); - let dist = ((last_f[0]-cp_f[0]).powi(2) + (last_f[1]-cp_f[1]).powi(2)).sqrt(); - if dist > 1e-9 { - points.push(cp_f); - key_verts.push(cp_f); + if ml.path_type == MultiLeaderPathType::Spline && ctrl.len() >= 2 { + for pt in catmull_rom_pts(&ctrl, 8) { points.push(pt); } + } else { + for &c in &ctrl { points.push(c); } + } + for i in 0..ctrl.len().saturating_sub(1) { + tangents.push(TangentGeom::Line { p1: ctrl[i], p2: ctrl[i + 1] }); } } - // Arrowhead — only when arrowhead_size > 0 if draw_arrow { - let tip = line.points[0]; - let tip_f = to_f32(&tip); - let next_dir = if line.points.len() >= 2 { line.points[1] } else { *cp }; - let dx = (next_dir.x - tip.x) as f32; - let dy = (next_dir.y - tip.y) as f32; - let dlen = (dx * dx + dy * dy).sqrt().max(1e-9); - let (dx, dy) = (dx / dlen, dy / dlen); - let angle = std::f32::consts::PI / 6.0; - let (s, c) = angle.sin_cos(); - let w1 = [tip_f[0] + (dx*c - dy*s)*arrow_size, - tip_f[1] + (dx*s + dy*c)*arrow_size, tip_f[2]]; - let w2 = [tip_f[0] + (dx*c + dy*s)*arrow_size, - tip_f[1] + (-dx*s + dy*c)*arrow_size, tip_f[2]]; + let tip = &line.points[0]; + let tip_f = p3(tip); + let next = if line.points.len() >= 2 { line.points[1] } else { *cp }; + let dx = (next.x - tip.x) as f32; + let dy = (next.y - tip.y) as f32; + let dl = (dx * dx + dy * dy).sqrt().max(1e-9); + let (dx, dy) = (dx / dl, dy / dl); + let a = std::f32::consts::PI / 6.0; + let (s, c) = a.sin_cos(); points.push(nan); - points.push(w1); + points.push([tip_f[0] + (dx*c - dy*s)*arrow_size, tip_f[1] + (dx*s + dy*c)*arrow_size, tip_f[2]]); points.push(tip_f); - points.push(w2); + points.push([tip_f[0] + (dx*c + dy*s)*arrow_size, tip_f[1] + (-dx*s + dy*c)*arrow_size, tip_f[2]]); } } - // Short landing shelf at connection_point — respects enable_landing and enable_dogleg if ml.enable_landing && ml.enable_dogleg && ml.dogleg_length > 0.0 { let dir = &root.direction; - let dlen = (dir.x * dir.x + dir.y * dir.y).sqrt().max(1e-9); - let dl = ml.dogleg_length; - let end = [ - (cp.x + dir.x / dlen * dl - ox) as f32, - (cp.y + dir.y / dlen * dl - oy) as f32, - (cp.z - oz) as f32, - ]; + let dl = (dir.x * dir.x + dir.y * dir.y).sqrt().max(1e-9); + let d = ml.dogleg_length * anno_scale as f64; points.push(nan); points.push(cp_f); - points.push(end); + points.push([(cp.x + dir.x/dl*d - ox) as f32, (cp.y + dir.y/dl*d - oy) as f32, cp_f[2]]); } } - let mut wires = vec![WireModel { - name: name.clone(), + // Text strokes — scale height, keep insertion point fixed. + if ml.content_type == LeaderContentType::MText && !ml.context.text_string.is_empty() { + let height = if ml.context.text_height > 0.0 { ml.context.text_height } else { ml.text_height }; + let ins = &ml.context.text_location; + let z = (ins.z - oz) as f32; + snap_pts.push((Vec3::new((ins.x - ox) as f32, (ins.y - oy) as f32, z), SnapHint::Node)); + let style = crate::entities::text_support::resolve_text_style("STANDARD", document); + let strokes = crate::scene::cxf::tessellate_text_ex( + [ins.x as f32, ins.y as f32], + (height as f32) * anno_scale, + 0.0, + style.width_factor.max(0.01), + style.oblique_angle, + &style.font_name, + &ml.context.text_string, + ); + for stroke in &strokes { + if stroke.len() < 2 { continue; } + points.push(nan); + for &[x, y] in stroke { + points.push([x - ox as f32, y - oy as f32, z]); + } + } + } + + WireModel { + name, points, color, selected, + aci: 0, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, - snap_pts: vec![], - tangent_geoms: vec![], - aci: 0, - key_vertices: key_verts, - aabb: WireModel::UNBOUNDED_AABB, - plinegen: true, - vp_scissor: None, - }]; - - // Render text content as MText wire - if ml.content_type == acadrust::entities::LeaderContentType::MText - && !ml.context.text_string.is_empty() - { - let mut mtext = acadrust::entities::MText::new(); - mtext.value = ml.context.text_string.clone(); - mtext.insertion_point = ml.context.text_location; - mtext.height = if ml.context.text_height > 0.0 { - ml.context.text_height - } else { - ml.text_height - }; - mtext.common.layer = ml.common.layer.clone(); - let mut w = tessellate( - document, handle, &EntityType::MText(mtext), - selected, entity_color, 0.0, [0.0; 8], line_weight_px, world_offset, - ); - w.name = name; - wires.push(w); + snap_pts, + tangent_geoms: tangents, + key_vertices: key_verts, + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + vp_scissor: None, } - - wires } /// Tessellate a Solid3D entity into a MeshModel (truck Shell/Solid path). diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8d823f698..4de26a00c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -15,6 +15,7 @@ pub mod plotstyle; pub mod properties; pub mod ribbon; pub mod shortcuts; +pub mod scale_popup; pub mod snap_popup; pub mod statusbar; pub mod tablestyle; diff --git a/src/ui/scale_popup.rs b/src/ui/scale_popup.rs new file mode 100644 index 000000000..98ba68e2d --- /dev/null +++ b/src/ui/scale_popup.rs @@ -0,0 +1,126 @@ +//! Scale picker dropdown — annotation scale (model space) or viewport scale (paper space). +//! +//! Rendered as a floating overlay above the status bar, same pattern as snap_popup. + +use iced::widget::{button, column, container, mouse_area, row, text}; +use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme}; + +use crate::app::Message; + +/// (label, annotation_scale_multiplier, viewport_scale_factor) +/// annotation_scale = multiplier for text/dim sizes (50.0 for "1:50") +/// vp_scale = custom_scale value on the Viewport entity (0.02 for "1:50") +const COMMON_SCALES: &[(&str, f32, f64)] = &[ + ("1:100", 100.0, 0.01), + ("1:50", 50.0, 0.02), + ("1:20", 20.0, 0.05), + ("1:10", 10.0, 0.10), + ("1:5", 5.0, 0.20), + ("1:2", 2.0, 0.50), + ("1:1", 1.0, 1.00), + ("2:1", 0.5, 2.00), + ("5:1", 0.2, 5.00), + ("10:1", 0.1, 10.00), +]; + +#[allow(dead_code)] +pub fn anno_scale_for_label(label: &str) -> f32 { + COMMON_SCALES + .iter() + .find(|&&(l, _, _)| l == label) + .map(|&(_, a, _)| a) + .unwrap_or(1.0) +} + +/// Full-screen overlay: transparent click-catcher + scale list panel pinned bottom-right. +/// +/// - `is_model`: true = model space (dispatches SetAnnotationScale), false = paper space (SetViewportScale). +/// - `current_anno_scale`: current annotation_scale from Scene (used to highlight active row in model space). +/// - `viewport_scale`: current vp.custom_scale (used to highlight in paper space). +pub fn scale_popup_overlay( + is_model: bool, + current_anno_scale: f32, + viewport_scale: Option, +) -> Element<'static, Message> { + let rows: Vec> = COMMON_SCALES + .iter() + .map(|&(label, anno_scale, vp_scale)| { + let active = if is_model { + (current_anno_scale - anno_scale).abs() < 0.001 * current_anno_scale.max(0.001) + } else { + viewport_scale + .map(|vs| (vs - vp_scale).abs() < 0.001 * vp_scale.max(0.001)) + .unwrap_or(false) + }; + let msg = if is_model { + Message::SetAnnotationScale(anno_scale) + } else { + Message::SetViewportScale(vp_scale) + }; + scale_row(label, active, msg) + }) + .collect(); + + let panel = container(column(rows)) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(PANEL_BG)), + border: Border { + color: PANEL_BORDER, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }) + .width(Length::Fixed(120.0)); + + let positioned = container(panel) + .align_right(Fill) + .align_bottom(Fill) + .padding(Padding { + bottom: 27.0, + right: 4.0, + top: 0.0, + left: 0.0, + }) + .width(Fill) + .height(Fill); + + mouse_area(positioned) + .on_press(Message::CloseScalePopup) + .into() +} + +fn scale_row(label: &'static str, active: bool, msg: Message) -> Element<'static, Message> { + let check = text(if active { "✓" } else { " " }) + .size(11) + .color(if active { CHECK_COLOR } else { Color::TRANSPARENT }) + .width(Length::Fixed(14.0)); + + let lbl = text(label) + .size(11) + .color(if active { LABEL_ON } else { LABEL_OFF }); + + let content = row![check, lbl].spacing(6).align_y(iced::Center); + + button(content) + .on_press(msg) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered => ROW_HOVER, + _ => Color::TRANSPARENT, + })), + ..Default::default() + }) + .width(Fill) + .padding([4, 10]) + .into() +} + +// ── Colours ─────────────────────────────────────────────────────────────── + +const PANEL_BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0 }; +const PANEL_BORDER: Color = Color { r: 0.32, g: 0.32, b: 0.32, a: 1.0 }; +const ROW_HOVER: Color = Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0 }; +const CHECK_COLOR: Color = Color { r: 0.35, g: 0.75, b: 1.00, a: 1.0 }; +const LABEL_ON: Color = Color { r: 0.92, g: 0.92, b: 0.92, a: 1.0 }; +const LABEL_OFF: Color = Color { r: 0.65, g: 0.65, b: 0.65, a: 1.0 }; diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index f0505f34d..bd8902125 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -42,6 +42,12 @@ impl StatusBar { in_mspace: bool, // Whether the layout tabs (Model/Paper) are visible (LAYOUTTAB). show_layout_tabs: bool, + // Current annotation scale for model space (1.0 = 1:1, 50.0 = 1:50, etc.). + annotation_scale: f32, + // True when the scale picker popup is open. + scale_popup_open: bool, + // True when the scale pill is interactive (always model space; paper space only when a viewport is active/selected). + scale_pill_enabled: bool, ) -> Element<'a, Message> { let menu_btn = button(text("≡").size(14).color(ICON_COLOR)) .on_press(Message::Command("MENU".into())) @@ -63,12 +69,24 @@ impl StatusBar { let osnap_active = snapper.is_active(); let snap_grid_on = snapper.is_on(crate::snap::SnapType::Grid) && snapper.snap_enabled; - let scale_label = format_scale(viewport_scale); let vp_label = if viewport_count > 0 { format!("{} VP", viewport_count) } else { String::new() }; + // Scale pill: opens the scale picker popup. + // Model space: always interactive, shows annotation scale. + // Paper space: interactive only when a viewport is active/selected. + let scale_label = if current_layout == "Model" { + format_scale(Some(1.0 / annotation_scale as f64)) + } else { + format_scale(viewport_scale) + }; + let scale_element: Element<'_, Message> = if scale_pill_enabled { + tip(scale_popup_btn(&scale_label, scale_popup_open), "Annotation / Viewport Scale\nClick to change") + } else { + status_pill(scale_label).into() + }; let mut right_status = row![ tip( toggle_pill("SNAP", snap_grid_on, Message::ToggleGridSnap), @@ -96,7 +114,7 @@ impl StatusBar { space_mode_btn(¤t_layout, in_mspace), "PAPER: double-click viewport to enter MSPACE\nMODEL: click to switch to Model Space", ), - status_pill(scale_label), + scale_element, ] .spacing(2); if !vp_label.is_empty() { @@ -609,6 +627,32 @@ const SNAP_OFF_HOVER: Color = Color { a: 1.0, }; +// ── Scale popup button ──────────────────────────────────────────────────── + +fn scale_popup_btn(label: &str, open: bool) -> Element<'static, Message> { + let label = label.to_string(); + button(text(label).size(10).color(if open { SNAP_BORDER_ON } else { OSNAP_OFF_TEXT })) + .on_press(Message::ToggleScalePopup) + .style(move |_: &Theme, status| button::Style { + background: Some(Background::Color(match (open, status) { + (true, button::Status::Hovered) => SNAP_ON_HOVER, + (true, _) => SNAP_ON_BG, + (false, button::Status::Hovered) => SNAP_OFF_HOVER, + (false, _) => SNAP_OFF_BG, + })), + border: Border { + color: if open { SNAP_BORDER_ON } else { BORDER_COLOR }, + width: 1.0, + radius: 2.0.into(), + }, + text_color: if open { SNAP_BORDER_ON } else { OSNAP_OFF_TEXT }, + shadow: iced::Shadow::default(), + snap: false, + }) + .padding([2, 6]) + .into() +} + // ── Scale display ───────────────────────────────────────────────────────── /// Formats a viewport scale factor as a human-readable ratio string.