Fix: paper limits, Cohen-Sutherland clipping, frozen layers UI, PDF improvements

Paper limits:
- Change A4 fallback from 12×9 to 297×210 mm in paper_limits()

Context menu:
- Translate layout context menu labels to English (Rename / Delete)

Viewport clipping:
- Replace AABB-only culling with proper Cohen-Sutherland line clipping
  in viewport_content_wires(); each projected segment is now clipped at
  the exact viewport boundary instead of being included or discarded whole
- Add cs_clip() and clip_polyline_to_rect() free functions in scene/mod.rs

Frozen layers per-viewport UI:
- Inject a "Frozen Layers" EditText property in the Properties panel for
  viewport entities (layer names resolved from handles at display time)
- Handle "frozen_layers" in PropGeomCommit: parse comma-separated names,
  resolve to layer handles, update vp.frozen_layers in-place

PDF export improvements:
- Add offset_x/offset_y params to export_pdf() for model-space normalization:
  coordinates are shifted so the drawing origin maps to the page origin
- Model space export computes extents via model_space_extents() (now public)
  and adds a 5 % margin; falls back to A4 landscape for empty drawings
- Skip __paper_boundary__ wire in PDF (white background covers it)
- Map yellow (active viewport border) → black and cyan (vp border) → dark
  blue for print-friendly output

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-03-31 08:18:29 +03:00
commit 845770938c
5 changed files with 263 additions and 68 deletions

View file

@ -55,6 +55,29 @@ impl H7CAD {
let group_names = self.tabs[i].scene.group_names_for_entity(handle);
let mut sections =
dispatch::properties_sectioned(handle, entity, &text_style_names);
// Inject frozen-layer property for viewports (requires doc access).
if let acadrust::EntityType::Viewport(vp) = entity {
let frozen_names: Vec<String> = vp
.frozen_layers
.iter()
.filter_map(|&h| {
self.tabs[i].scene.document.layers.iter()
.find(|l| l.handle == h)
.map(|l| l.name.clone())
})
.collect();
if let Some(geom) = sections.last_mut() {
geom.props.push(crate::scene::object::Property {
label: "Frozen Layers".to_string(),
field: "frozen_layers",
value: crate::scene::object::PropValue::EditText(
frozen_names.join(", "),
),
});
}
}
if !group_names.is_empty() {
let label = group_names.join(", ");
if let Some(general) = sections.first_mut() {

View file

@ -1457,14 +1457,35 @@ impl H7CAD {
if !handles.is_empty() {
if let Some(val) = self.tabs[i].properties.edit_buf.remove(field) {
self.push_undo_snapshot(i, "CHPROP");
for handle in handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
match field {
"linetype_scale" | "transparency" => {
crate::scene::dispatch::apply_common_prop(entity, field, &val);
}
_ => {
crate::scene::dispatch::apply_geom_prop(entity, field, &val);
if field == "frozen_layers" {
// Resolve layer names → handles, then apply to viewports.
let layer_handles: Vec<acadrust::Handle> = val
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.filter_map(|name| {
self.tabs[i].scene.document.layers.iter()
.find(|l| l.name.eq_ignore_ascii_case(name))
.map(|l| l.handle)
})
.collect();
for handle in handles {
if let Some(acadrust::EntityType::Viewport(vp)) =
self.tabs[i].scene.document.get_entity_mut(handle)
{
vp.frozen_layers = layer_handles.clone();
}
}
} else {
for handle in handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
match field {
"linetype_scale" | "transparency" => {
crate::scene::dispatch::apply_common_prop(entity, field, &val);
}
_ => {
crate::scene::dispatch::apply_geom_prop(entity, field, &val);
}
}
}
}
@ -1725,15 +1746,23 @@ impl H7CAD {
Message::PlotExportPath(Some(path)) => {
let i = self.active_tab;
let wires = self.tabs[i].scene.entity_wires();
let (paper_w, paper_h) = if let Some(((_, _), (w, h))) =
self.tabs[i].scene.paper_limits()
{
(w, h)
} else {
// Model space: use extents or default A4 landscape.
(297.0, 210.0)
};
match crate::io::pdf_export::export_pdf(&wires, paper_w, paper_h, &path) {
let (paper_w, paper_h, offset_x, offset_y) =
if let Some(((x0, y0), (x1, y1))) = self.tabs[i].scene.paper_limits() {
(x1 - x0, y1 - y0, -x0, -y0)
} else {
// Model space: fit wires to computed extents with 5 % margin.
let margin = 1.05_f64;
if let Some((mn, mx)) = self.tabs[i].scene.model_space_extents() {
let w = ((mx.x - mn.x) as f64 * margin).max(1.0);
let h = ((mx.y - mn.y) as f64 * margin).max(1.0);
let pad_x = (w - (mx.x - mn.x) as f64) * 0.5;
let pad_y = (h - (mx.y - mn.y) as f64) * 0.5;
(w, h, -(mn.x as f64) + pad_x, -(mn.y as f64) + pad_y)
} else {
(297.0, 210.0, 0.0, 0.0)
}
};
match crate::io::pdf_export::export_pdf(&wires, paper_w, paper_h, offset_x as f32, offset_y as f32, &path) {
Ok(()) => self.command_line.push_info(&format!(
"Exported: {}",
path.file_name().unwrap_or_default().to_string_lossy()

View file

@ -415,8 +415,8 @@ fn layout_context_menu_overlay(name: &str) -> Element<'_, Message> {
let menu = container(
column![
item("Yeniden Adlandır", Message::LayoutRenameStart(rename_name)),
item("Sil", Message::LayoutDelete(delete_name)),
item("Rename", Message::LayoutRenameStart(rename_name)),
item("Delete", Message::LayoutDelete(delete_name)),
]
.spacing(0)
.width(160),

View file

@ -5,7 +5,8 @@
//
// Coordinate system: CAD uses mm units with origin at bottom-left and Y up.
// printpdf's Point::new(Mm, Mm) also has origin at bottom-left, so no Y-flip
// is needed — we just pass the coordinates through directly.
// is needed — we shift the coordinates by (offset_x, offset_y) to place the
// drawing origin at the paper origin.
use crate::scene::WireModel;
use printpdf::{Color, Line, LineCapStyle, LineJoinStyle, LinePoint, Mm, Op, PdfDocument,
@ -17,14 +18,20 @@ use std::path::Path;
/// Export `wires` to a PDF file.
///
/// `paper_w` / `paper_h` are in millimetres (drawing units assumed = mm).
/// - `paper_w` / `paper_h`: page dimensions in mm.
/// - `offset_x` / `offset_y`: added to every wire coordinate so the drawing
/// origin maps to the bottom-left corner of the page. Pass 0/0 for paper
/// space (coordinates are already relative to page origin); pass a computed
/// shift for model space to normalise the extents.
pub fn export_pdf(
wires: &[WireModel],
paper_w: f64,
paper_h: f64,
offset_x: f32,
offset_y: f32,
path: &Path,
) -> Result<(), String> {
let bytes = build_pdf(wires, paper_w as f32, paper_h as f32);
let bytes = build_pdf(wires, paper_w as f32, paper_h as f32, offset_x, offset_y);
let mut file = std::fs::File::create(path).map_err(|e| e.to_string())?;
file.write_all(&bytes).map_err(|e| e.to_string())
}
@ -43,11 +50,11 @@ pub async fn pick_pdf_path_owned(stem: String) -> Option<std::path::PathBuf> {
// ── PDF builder ───────────────────────────────────────────────────────────
fn build_pdf(wires: &[WireModel], paper_w: f32, paper_h: f32) -> Vec<u8> {
fn build_pdf(wires: &[WireModel], paper_w: f32, paper_h: f32, ox: f32, oy: f32) -> Vec<u8> {
let mut doc = PdfDocument::new("H7CAD Export");
let mut ops: Vec<Op> = Vec::new();
// White page background rectangle.
// White page background.
ops.push(Op::SetFillColor {
col: Color::Rgb(Rgb { r: 1.0, g: 1.0, b: 1.0, icc_profile: None }),
});
@ -55,43 +62,50 @@ fn build_pdf(wires: &[WireModel], paper_w: f32, paper_h: f32) -> Vec<u8> {
rectangle: printpdf::Rect::from_wh(Mm(paper_w).into(), Mm(paper_h).into()),
});
// Round line caps for CAD aesthetics.
// Round line caps/joins for CAD aesthetics.
ops.push(Op::SetLineCapStyle { cap: LineCapStyle::Round });
ops.push(Op::SetLineJoinStyle { join: LineJoinStyle::Round });
let mut last_color: Option<[f32; 4]> = None;
let mut last_color: Option<[f32; 3]> = None;
let mut last_lw: Option<f32> = None;
for wire in wires {
// Set stroke color (white → black for print; skip fully transparent).
let [mut r, mut g, mut b, a] = wire.color;
if a < 0.01 {
continue;
}
// Invert near-white to black so it prints on white paper.
if r > 0.85 && g > 0.85 && b > 0.85 {
r = 0.0;
g = 0.0;
b = 0.0;
// Skip the paper-boundary wire — the white PDF background already provides it.
if wire.name == "__paper_boundary__" {
continue;
}
let color_changed = last_color.map(|c| {
(c[0] - r).abs() > 0.01 || (c[1] - g).abs() > 0.01 || (c[2] - b).abs() > 0.01
}).unwrap_or(true);
if color_changed {
// Near-white and near-yellow (viewport active border) → dark grey for print.
let is_light = r > 0.80 && g > 0.80 && b > 0.80;
let is_yellow = r > 0.80 && g > 0.70 && b < 0.30;
let is_cyan = r < 0.30 && g > 0.70 && b > 0.70;
if is_light || is_yellow {
r = 0.0; g = 0.0; b = 0.0;
} else if is_cyan {
// Viewport border: print as dark blue.
r = 0.0; g = 0.15; b = 0.50;
}
if last_color.map(|c| {
(c[0]-r).abs() > 0.01 || (c[1]-g).abs() > 0.01 || (c[2]-b).abs() > 0.01
}).unwrap_or(true) {
ops.push(Op::SetOutlineColor {
col: Color::Rgb(Rgb { r, g, b, icc_profile: None }),
});
last_color = Some([r, g, b, a]);
last_color = Some([r, g, b]);
}
// Set line width (in points; 1 mm = 2.8346 pt).
let lw_pt = (wire.line_weight_px as f32 * 0.35278).max(0.1);
// Line weight: screen px → points (approximate 1 px ≈ 0.35 pt).
let lw_pt = (wire.line_weight_px * 0.35278_f32).max(0.1);
if last_lw.map(|l| (l - lw_pt).abs() > 0.01).unwrap_or(true) {
ops.push(Op::SetOutlineThickness { pt: Pt(lw_pt) });
last_lw = Some(lw_pt);
}
// Collect segments (split at NaN).
// Emit segments (NaN = pen-up).
let mut segment: Vec<LinePoint> = Vec::new();
for &[x, y, _z] in &wire.points {
if x.is_nan() || y.is_nan() {
@ -99,7 +113,7 @@ fn build_pdf(wires: &[WireModel], paper_w: f32, paper_h: f32) -> Vec<u8> {
segment.clear();
} else {
segment.push(LinePoint {
p: Point::new(Mm(x), Mm(y)),
p: Point::new(Mm(x + ox), Mm(y + oy)),
bezier: false,
});
}

View file

@ -118,7 +118,8 @@ impl Scene {
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)));
// Default to A4 landscape (mm).
return Some(((0.0, 0.0), (297.0, 210.0)));
}
return Some((min, max));
}
@ -379,7 +380,7 @@ impl Scene {
/// Compute the axis-aligned bounding box of all model-space entities by
/// collecting their `key_vertices`. Returns `None` when there are no
/// vertices (empty drawing).
fn model_space_extents(&self) -> Option<(glam::Vec3, glam::Vec3)> {
pub fn model_space_extents(&self) -> Option<(glam::Vec3, glam::Vec3)> {
let model_block = self.model_space_block_handle();
if model_block.is_null() {
return None;
@ -527,43 +528,60 @@ impl Scene {
.collect();
// ── Project and clip wires into viewport ──────────────────────
let vp_x0 = pcx - hw;
let vp_x1 = pcx + hw;
let vp_y0 = pcy - hh;
let vp_y1 = pcy + hh;
for wire in &model_wires {
// Project 3-D model points onto view plane → paper space.
let pts: Vec<[f32; 3]> = wire.points.iter().map(|&[mx, my, mz]| {
let projected_pts: Vec<[f32; 3]> = wire.points.iter().map(|&[mx, my, mz]| {
if mx.is_nan() || my.is_nan() || mz.is_nan() {
return [f32::NAN; 3];
}
let mp = glam::Vec3::new(mx, my, mz) - target;
let u = mp.dot(view_right); // horizontal in view
let v = mp.dot(view_up); // vertical in view
let u = mp.dot(view_right);
let v = mp.dot(view_up);
[pcx + u * scale, pcy + v * scale, pcz]
}).collect();
// Proper AABB test: discard if the wire's bounding box has
// zero overlap with the viewport rectangle.
if pts.is_empty() {
// Fast AABB pre-reject: skip entirely if no finite point is
// anywhere near the viewport.
let any_near = projected_pts.iter().any(|&[x, y, _]| {
x.is_finite() && y.is_finite()
&& x >= vp_x0 - 1.0 && x <= vp_x1 + 1.0
&& y >= vp_y0 - 1.0 && y <= vp_y1 + 1.0
});
// Also keep wires whose AABB overlaps the viewport (partial overlap).
let (min_x, max_x, min_y, max_y) = projected_pts.iter()
.filter(|p| p[0].is_finite())
.fold(
(f32::INFINITY, f32::NEG_INFINITY, f32::INFINITY, f32::NEG_INFINITY),
|(mnx, mxx, mny, mxy), &[x, y, _]| {
(mnx.min(x), mxx.max(x), mny.min(y), mxy.max(y))
},
);
let aabb_hits = max_x >= vp_x0 && min_x <= vp_x1
&& max_y >= vp_y0 && min_y <= vp_y1;
if !any_near && !aabb_hits {
continue;
}
let min_x = pts.iter().map(|p| p[0]).fold(f32::INFINITY, f32::min);
let max_x = pts.iter().map(|p| p[0]).fold(f32::NEG_INFINITY, f32::max);
let min_y = pts.iter().map(|p| p[1]).fold(f32::INFINITY, f32::min);
let max_y = pts.iter().map(|p| p[1]).fold(f32::NEG_INFINITY, f32::max);
let vp_x0 = pcx - hw;
let vp_x1 = pcx + hw;
let vp_y0 = pcy - hh;
let vp_y1 = pcy + hh;
// AABB overlap check (no tolerance — exact viewport boundary).
if max_x < vp_x0 || min_x > vp_x1 || max_y < vp_y0 || min_y > vp_y1 {
// Cohen-Sutherland clipping: clip every segment to the viewport.
let clipped = clip_polyline_to_rect(
&projected_pts, vp_x0, vp_y0, vp_x1, vp_y1, pcz,
);
if clipped.is_empty() {
continue;
}
let [r, g, b, a] = wire.color;
let mut projected = wire.clone();
projected.points = pts;
projected.color = [r * 0.80, g * 0.80, b * 0.80, a * 0.85];
// Scale line weight proportionally so annotation lines (dimensions,
// text outlines) appear at the correct visual thickness in paper space.
projected.line_weight_px = wire.line_weight_px * scale;
result.push(projected);
let mut out = wire.clone();
out.points = clipped;
out.color = [r * 0.80, g * 0.80, b * 0.80, a * 0.85];
// Scale line weight proportionally for correct visual thickness.
out.line_weight_px = wire.line_weight_px * scale;
result.push(out);
}
}
@ -1396,6 +1414,117 @@ impl Default for Scene {
// ── Paper boundary wire ────────────────────────────────────────────────────
// ── Cohen-Sutherland line clipping ───────────────────────────────────────
/// Clip a single segment (x0,y0)→(x1,y1) against the axis-aligned rectangle
/// [xmin,xmax]×[ymin,ymax]. Returns the clipped endpoints or `None` if the
/// segment is entirely outside.
fn cs_clip(
mut x0: f32, mut y0: f32,
mut x1: f32, mut y1: f32,
xmin: f32, ymin: f32, xmax: f32, ymax: f32,
) -> Option<(f32, f32, f32, f32)> {
const LEFT: u8 = 1;
const RIGHT: u8 = 2;
const BOTTOM: u8 = 4;
const TOP: u8 = 8;
let code = |x: f32, y: f32| -> u8 {
let mut c = 0u8;
if x < xmin { c |= LEFT; }
else if x > xmax { c |= RIGHT; }
if y < ymin { c |= BOTTOM; }
else if y > ymax { c |= TOP; }
c
};
let mut c0 = code(x0, y0);
let mut c1 = code(x1, y1);
loop {
if c0 | c1 == 0 { return Some((x0, y0, x1, y1)); }
if c0 & c1 != 0 { return None; }
let cout = if c0 != 0 { c0 } else { c1 };
let (x, y);
if cout & TOP != 0 {
x = x0 + (x1 - x0) * (ymax - y0) / (y1 - y0);
y = ymax;
} else if cout & BOTTOM != 0 {
x = x0 + (x1 - x0) * (ymin - y0) / (y1 - y0);
y = ymin;
} else if cout & RIGHT != 0 {
y = y0 + (y1 - y0) * (xmax - x0) / (x1 - x0);
x = xmax;
} else {
y = y0 + (y1 - y0) * (xmin - x0) / (x1 - x0);
x = xmin;
}
if cout == c0 { x0 = x; y0 = y; c0 = code(x0, y0); }
else { x1 = x; y1 = y; c1 = code(x1, y1); }
}
}
/// Clip a projected polyline (NaN-separated segments) to the viewport rectangle.
/// Returns a new points vec with proper NaN separators at clip boundaries.
fn clip_polyline_to_rect(
pts: &[[f32; 3]],
xmin: f32, ymin: f32, xmax: f32, ymax: f32,
z: f32,
) -> Vec<[f32; 3]> {
const NAN3: [f32; 3] = [f32::NAN, f32::NAN, f32::NAN];
let mut result: Vec<[f32; 3]> = Vec::new();
let mut i = 0;
while i < pts.len() {
// Skip NaN separators.
if pts[i][0].is_nan() || pts[i][1].is_nan() {
i += 1;
continue;
}
// Gather contiguous run of finite points.
let start = i;
while i < pts.len() && pts[i][0].is_finite() && pts[i][1].is_finite() {
i += 1;
}
let seg = &pts[start..i];
if seg.len() < 2 {
continue;
}
// Clip each edge and track pen state to insert NaN on lift.
let mut pen_down = false;
for j in 0..seg.len() - 1 {
let [x0, y0, _] = seg[j];
let [x1, y1, _] = seg[j + 1];
match cs_clip(x0, y0, x1, y1, xmin, ymin, xmax, ymax) {
None => { pen_down = false; }
Some((cx0, cy0, cx1, cy1)) => {
if !pen_down {
if !result.is_empty() { result.push(NAN3); }
result.push([cx0, cy0, z]);
pen_down = true;
} else if let Some(&[lx, ly, _]) = result.last() {
if (lx - cx0).abs() > 1e-4 || (ly - cy0).abs() > 1e-4 {
result.push(NAN3);
result.push([cx0, cy0, z]);
}
}
result.push([cx1, cy1, z]);
// If the exit point was clipped, lift pen.
if (cx1 - x1).abs() > 1e-4 || (cy1 - y1).abs() > 1e-4 {
pen_down = false;
}
}
}
}
}
// Remove trailing NaN.
while result.last().map(|p: &[f32; 3]| p[0].is_nan()).unwrap_or(false) {
result.pop();
}
result
}
/// 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.