diff --git a/src/app/properties.rs b/src/app/properties.rs index faaa0032..6907c4dd 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -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 = 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() { diff --git a/src/app/update.rs b/src/app/update.rs index f565525b..04f0f417 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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 = 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() diff --git a/src/app/view.rs b/src/app/view.rs index 83b6a6d7..1fb608b2 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -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), diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 7c930aea..f86134b8 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -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 { // ── PDF builder ─────────────────────────────────────────────────────────── -fn build_pdf(wires: &[WireModel], paper_w: f32, paper_h: f32) -> Vec { +fn build_pdf(wires: &[WireModel], paper_w: f32, paper_h: f32, ox: f32, oy: f32) -> Vec { let mut doc = PdfDocument::new("H7CAD Export"); let mut ops: Vec = 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 { 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 = 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 = 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 { segment.clear(); } else { segment.push(LinePoint { - p: Point::new(Mm(x), Mm(y)), + p: Point::new(Mm(x + ox), Mm(y + oy)), bezier: false, }); } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d20bdf84..3a8f1274 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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.