From ece559837210f799f60c625d6e81908695e9093f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 24 Jul 2026 19:26:10 +0300 Subject: [PATCH] fix: correct viewport stretch and SHX plots Anchor the opposite viewport corner during grip and STRETCH resizing. Match plotted stroke-font pen widths to the SDF atlas unless CTB overrides. Closes #435 --- src/app/command_driver.rs | 4 ++ src/entities/viewport.rs | 98 ++++++++++++++++++++++++++++++++----- src/io/pdf_export.rs | 33 ++++++++++--- src/scene/text/sdf_atlas.rs | 18 +++++-- 4 files changed, 127 insertions(+), 26 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 7bebaf5f8..589d838b0 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1677,6 +1677,10 @@ impl OpenCADStudio { stretched = true; } } + acadrust::EntityType::Viewport(vp) => { + stretched = + crate::entities::viewport::stretch(vp, win_min, win_max, delta); + } acadrust::EntityType::Dimension(dim) => { use acadrust::entities::Dimension; // Move every definition point that falls inside the diff --git a/src/entities/viewport.rs b/src/entities/viewport.rs index a0873ba1d..5baf8d364 100644 --- a/src/entities/viewport.rs +++ b/src/entities/viewport.rs @@ -351,24 +351,96 @@ fn apply_grip(vp: &mut Viewport, grip_id: usize, apply: GripApply) { vp.center.z = p.z as f64; } (1..=4, GripApply::Absolute(p)) => { - let new_hw = (p.x as f64 - vp.center.x).abs(); - let new_hh = (p.y as f64 - vp.center.y).abs(); - if new_hw > 0.01 { - vp.width = new_hw * 2.0; - } - if new_hh > 0.01 { - let new_h = new_hh * 2.0; - // Keep scale constant: view_height must scale with the viewport height. - if vp.height > 1e-9 && vp.view_height.abs() > 1e-9 { - vp.view_height = vp.view_height * (new_h / vp.height); - } - vp.height = new_h; - } + let hw = vp.width * 0.5; + let hh = vp.height * 0.5; + let opposite = match grip_id { + 1 => (vp.center.x - hw, vp.center.y - hh), + 2 => (vp.center.x + hw, vp.center.y - hh), + 3 => (vp.center.x + hw, vp.center.y + hh), + 4 => (vp.center.x - hw, vp.center.y + hh), + _ => unreachable!(), + }; + resize_from_corners(vp, opposite, (p.x as f64, p.y as f64)); } _ => {} } } +/// Stretch a rectangular paper-space viewport by the corners captured by a +/// crossing window. Bounds that have no captured corner stay anchored. +pub fn stretch( + vp: &mut Viewport, + win_min: glam::DVec3, + win_max: glam::DVec3, + delta: glam::DVec3, +) -> bool { + let hw = vp.width * 0.5; + let hh = vp.height * 0.5; + let (left, right) = (vp.center.x - hw, vp.center.x + hw); + let (bottom, top) = (vp.center.y - hh, vp.center.y + hh); + let inside = + |x: f64, y: f64| x >= win_min.x && x <= win_max.x && y >= win_min.y && y <= win_max.y; + + let bottom_left = inside(left, bottom); + let bottom_right = inside(right, bottom); + let top_left = inside(left, top); + let top_right = inside(right, top); + if !(bottom_left || bottom_right || top_left || top_right) { + return false; + } + + let moved_left = if bottom_left || top_left { + left + delta.x + } else { + left + }; + let moved_right = if bottom_right || top_right { + right + delta.x + } else { + right + }; + let moved_bottom = if bottom_left || bottom_right { + bottom + delta.y + } else { + bottom + }; + let moved_top = if top_left || top_right { + top + delta.y + } else { + top + }; + resize_from_corners(vp, (moved_left, moved_bottom), (moved_right, moved_top)) +} + +/// Rebuild center and size from two opposite corners while preserving viewport +/// scale as its paper-space height changes. +fn resize_from_corners(vp: &mut Viewport, a: (f64, f64), b: (f64, f64)) -> bool { + let new_width = (b.0 - a.0).abs(); + let new_height = (b.1 - a.1).abs(); + let mut changed = false; + + if new_width > 0.01 { + let new_center_x = (a.0 + b.0) * 0.5; + changed |= + (vp.center.x - new_center_x).abs() > 1e-12 || (vp.width - new_width).abs() > 1e-12; + vp.center.x = new_center_x; + vp.width = new_width; + } + if new_height > 0.01 { + let old_height = vp.height; + let new_center_y = (a.1 + b.1) * 0.5; + changed |= + (vp.center.y - new_center_y).abs() > 1e-12 || (old_height - new_height).abs() > 1e-12; + vp.center.y = new_center_y; + if old_height > 1e-9 && vp.view_height.abs() > 1e-9 { + vp.view_height *= new_height / old_height; + } + vp.height = new_height; + } + + changed +} + fn apply_transform(vp: &mut Viewport, t: &EntityTransform) { crate::scene::view::transform::apply_standard_entity_transform(vp, t, |entity, p1, p2| { crate::scene::view::transform::reflect_xy_point( diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 52a3bad4f..6aa9a0e82 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -635,9 +635,6 @@ fn emit_text( .map(|mm| (mm * MM_TO_PT).max(0.1) / scale.max(1e-6)); } } - let lw_pt = lw_override - .unwrap_or_else(|| (wire.line_weight_px * LW_PX_TO_PT).max(0.1) / scale.max(1e-6)); - let mut gi = 0; while gi + 6 <= verts.len() { let quad = &verts[gi..gi + 6]; @@ -703,13 +700,33 @@ fn emit_text( }); } } else { - // Stroke (LFF pen) font or hollow glyph: polylines. Bold bakes - // at a 1.7× pen over the same centrelines, so widen to match or - // a bold run prints at regular weight. + // Stroke (LFF/SHX pen) font or hollow glyph: polylines. + // Match the SDF atlas' nominal glyph-space pen instead of + // borrowing the entity lineweight: Roman Duplex and similar + // multi-stroke faces rely on that band to close the narrow + // gaps between parallel centrelines. An explicit CTB + // lineweight still wins and stays absolute under the plot CTM. ops.push(Op::SetOutlineColor { - col: Color::Rgb(Rgb { r, g, b, icc_profile: None }), + col: Color::Rgb(Rgb { + r, + g, + b, + icc_profile: None, + }), }); - let pen = if ge.bold { lw_pt * 1.7 } else { lw_pt }; + let pen = if let Some(ctb_pen) = lw_override { + if ge.bold { + ctb_pen * 1.7 + } else { + ctb_pen + } + } else { + let glyph_unit_mm = (((tl[0] - bl[0]).powi(2) + (tl[1] - bl[1]).powi(2)) + .sqrt() + / sy.abs() as f64) as f32; + (2.0 * sdf_atlas::stroke_pen_half_units(ge.bold) * glyph_unit_mm * MM_TO_PT) + .max(0.1) + }; ops.push(Op::SetOutlineThickness { pt: Pt(pen) }); for stroke in &ge.strokes { if stroke.len() < 2 { diff --git a/src/scene/text/sdf_atlas.rs b/src/scene/text/sdf_atlas.rs index 0e967b50b..11e8a97ba 100644 --- a/src/scene/text/sdf_atlas.rs +++ b/src/scene/text/sdf_atlas.rs @@ -105,6 +105,18 @@ const SPREAD_UNITS: f32 = 1.5; /// built around. Scales with text height like the strokes themselves. const PEN_HALF_UNITS: f32 = 0.35; +/// Nominal half-width of the SDF pen for a stroke glyph. +/// +/// CPU exporters cannot sample the atlas, so they use this to stroke the same +/// LFF/SHX centrelines at the thickness shown on screen. +pub(crate) fn stroke_pen_half_units(bold: bool) -> f32 { + if bold { + PEN_HALF_UNITS * 1.7 + } else { + PEN_HALF_UNITS + } +} + /// Upper bound on a single glyph tile's dimension (texels), a guard against a /// pathologically wide/tall glyph blowing up the atlas. const MAX_TILE_PX: u32 = 512; @@ -264,11 +276,7 @@ impl GlyphAtlas { return *cached; } // Bold stroke glyphs bake with a wider pen band (same shapes, thicker). - let pen_half = if bold { - PEN_HALF_UNITS * 1.7 - } else { - PEN_HALF_UNITS - }; + let pen_half = stroke_pen_half_units(bold); let face = Face::resolve(family); let entry = face .glyph(ch)