diff --git a/src/app/view.rs b/src/app/view.rs index a0688dd6..68b5e4fd 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -165,6 +165,7 @@ impl H7CAD { tab.scene.layout_names(), tab.scene.current_layout.clone(), self.layout_rename_state.as_ref(), + tab.scene.first_viewport_scale(), ) ] .width(Fill) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 9e9ac272..579de63e 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -91,6 +91,58 @@ impl Scene { .unwrap_or(Handle::NULL) } + /// Returns `(min, max)` paper-space limits for the current layout, or `None` + /// when in Model space. Falls back to `(0,0)-(12,9)` if the layout has + /// zero-size limits (common in freshly-created layouts). + pub fn paper_limits(&self) -> Option<((f64, f64), (f64, f64))> { + if self.current_layout == "Model" { + return None; + } + self.document.objects.values().find_map(|obj| { + if let ObjectType::Layout(l) = obj { + if l.name == self.current_layout { + let (min, max) = (l.min_limits, l.max_limits); + // Guard against degenerate limits. + let w = (max.0 - min.0).abs(); + let h = (max.1 - min.1).abs(); + if w < 1e-6 || h < 1e-6 { + return Some(((0.0, 0.0), (12.0, 9.0))); + } + return Some((min, max)); + } + } + None + }) + } + + /// Scale of the first user viewport (id > 1) in the current paper layout, + /// used for the status-bar display. Returns `None` in Model space or if + /// no user viewport exists. + pub fn first_viewport_scale(&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 vp.id > 1 && vp.common.owner_handle == layout_block { + let 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 + }; + return Some(scale); + } + } + None + }) + } + /// 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()]; @@ -134,6 +186,10 @@ impl Scene { let layout_block = self.current_layout_block_handle(); let mut wires: Vec = self.wires_for_block(layout_block); if self.current_layout != "Model" { + // Draw the paper boundary rectangle first (rendered beneath everything else). + if let Some(((x0, y0), (x1, y1))) = self.paper_limits() { + wires.insert(0, paper_boundary_wire(x0 as f32, y0 as f32, x1 as f32, y1 as f32)); + } wires.extend(self.viewport_content_wires(layout_block)); } wires @@ -1121,3 +1177,30 @@ impl Default for Scene { Self::new() } } + +// ── Paper boundary wire ──────────────────────────────────────────────────── + +/// A thin white rectangle wire that represents the printable-area boundary +/// of the active paper layout. Rendered beneath all other paper-space +/// geometry so it acts as a visual "page" backdrop. +fn paper_boundary_wire(x0: f32, y0: f32, x1: f32, y1: f32) -> WireModel { + WireModel { + name: "__paper_boundary__".to_string(), + points: vec![ + [x0, y0, 0.0], + [x1, y0, 0.0], + [x1, y1, 0.0], + [x0, y1, 0.0], + [x0, y0, 0.0], + ], + // Near-white so it stands out against the dark paper-space background. + color: [0.95, 0.95, 0.95, 1.0], + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.5, + snap_pts: vec![], + tangent_geoms: vec![], + key_vertices: vec![], + } +} diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index 27c69e7b..a8246d6e 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -31,6 +31,8 @@ impl StatusBar { current_layout: String, // If `Some((original, edit_value))`, the named tab shows a text input. rename_state: Option<&'a (String, String)>, + // Scale of the first user viewport in the active paper layout. + viewport_scale: Option, ) -> Element<'a, Message> { let menu_btn = button(text("≡").size(14).color(ICON_COLOR)) .on_press(Message::Command("MENU".into())) @@ -57,6 +59,7 @@ impl StatusBar { } else { "LAYOUT" }; + let scale_label = format_scale(viewport_scale); let right_status = row![ tip( toggle_pill("SNAP", snap_grid_on, Message::ToggleGridSnap), @@ -76,7 +79,7 @@ impl StatusBar { ), osnap_btn(osnap_active, snapper.snap_enabled, popup_open), status_pill(space_label), - status_pill("1:1"), + status_pill(scale_label), ] .spacing(2); @@ -353,8 +356,8 @@ fn space_tab<'a>(label: String, is_active: bool, rename_edit: Option<&'a str>) - } } -fn status_pill(label: &str) -> Element<'_, Message> { - container(text(label).size(10).color(Color { +fn status_pill(label: impl Into) -> Element<'static, Message> { + container(text(label.into()).size(10).color(Color { r: 0.65, g: 0.65, b: 0.65, @@ -460,3 +463,40 @@ const SNAP_OFF_HOVER: Color = Color { b: 0.22, a: 1.0, }; + +// ── Scale display ───────────────────────────────────────────────────────── + +/// Formats a viewport scale factor as a human-readable ratio string. +/// +/// - `None` → "1:1" (model space or no viewport yet) +/// - `1.0` → "1:1" +/// - `0.02` → "1:50" +/// - `2.0` → "2:1" +fn format_scale(scale: Option) -> String { + let s = match scale { + None => return "1:1".to_string(), + Some(v) if v <= 0.0 => return "1:1".to_string(), + Some(v) => v, + }; + + // Try to express as a clean integer ratio. + if s >= 1.0 { + let n = s.round() as u32; + if (s - n as f64).abs() < 0.01 * s { + return if n == 1 { + "1:1".to_string() + } else { + format!("{}:1", n) + }; + } + } else { + let inv = (1.0 / s).round() as u32; + if (s - 1.0 / inv as f64).abs() < 0.01 * s { + return format!("1:{}", inv); + } + } + + // Fall back to a decimal string. + format!("{:.4}", s).trim_end_matches('0').trim_end_matches('.').to_string() +} +