Aşama 2: Paper space görsel düzenleme

- Kağıt sınırı dikdörtgeni: paper layout'ta Layout.min/max_limits'ten
  beyaz çerçeve wire'ı oluşturulur, entity_wires()'a eklenir
- first_viewport_scale(): paper layout'taki ilk kullanıcı viewport'un ölçeğini döner
- paper_limits(): mevcut layout'un kağıt sınırlarını döner, sıfır boyutlu
  layout için varsayılan 12×9 kullanılır
- Status bar scale göstergesi: hardcoded "1:1" yerine aktif viewport ölçeği
  dinamik olarak "1:50", "2:1" vb. formatında gösterilir
- Overall viewport (ID=1) zaten gri renkte render ediliyor (tessellate_one)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-03-31 07:17:45 +03:00
commit 474b38f4fb
3 changed files with 127 additions and 3 deletions

View file

@ -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)

View file

@ -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<f64> {
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<String> {
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<WireModel> = 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![],
}
}

View file

@ -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<f64>,
) -> 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<String>) -> 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<f64>) -> 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()
}