From dd1f2e49bbab1aaaf1ff2ab76cfb0f8cfe0ad565 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 24 Jun 2026 10:09:19 +0300 Subject: [PATCH] fix(render): UTM-precise UCS icon + fallback outlines (hatch etc.) Two UTM-scale rendering defects. - UCS icon jittered: it projects only axis DIRECTIONS but went through the full view_proj, whose ~5.7e6 translation cancels catastrophically in f32. Feed it the rotation-only view_proj_rte instead (translation dropped). - Fallback outline geometry (hatch boundary, viewport/insert/ole2frame frames, Solid3D edge wires) built absolute f32 points with an empty points_low, so at UTM the outline sat ~0.5 m off its own fill (the hatch fill renders precisely via the world_origin-relative HatchModel pipeline). FallbackGeometry now carries absolute f64 points + key vertices; the tessellate dispatcher splits them into the double-single high/low pair the relative-to-eye wire pass needs, so outlines land exactly on their fills. Co-Authored-By: Claude Opus 4.8 --- src/app/view.rs | 6 +++++- src/entities/hatch.rs | 22 ++++++++++--------- src/entities/insert.rs | 19 +++++++++-------- src/entities/ole2frame.rs | 14 ++++++------ src/entities/viewport.rs | 10 ++++----- src/scene/convert/tess_util.rs | 9 ++++++-- src/scene/convert/tessellate.rs | 38 ++++++++++++++++++++++----------- 7 files changed, 72 insertions(+), 46 deletions(-) diff --git a/src/app/view.rs b/src/app/view.rs index e556c22a6..32eab6a7e 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -167,7 +167,11 @@ impl OpenCADStudio { let cam = tab.scene.camera.borrow(); let (_, ux, uy, uz) = tab.ucs_xform().axes(); Some(overlay::UcsIconParams { - view_proj: cam.view_proj(vp_bounds), + // Rotation-only projection: the icon shows axis DIRECTIONS + // only, so the full view_proj's huge UTM translation would + // just cancel catastrophically in f32 and make the tripod + // jitter. view_proj_rte drops the translation. (#utm-ucs) + view_proj: cam.view_proj_rte(vp_bounds), bounds: vp_bounds, axes: (ux, uy, uz), }) diff --git a/src/entities/hatch.rs b/src/entities/hatch.rs index 00c787d62..e39b7f6d4 100644 --- a/src/entities/hatch.rs +++ b/src/entities/hatch.rs @@ -348,13 +348,15 @@ impl FallbackTess for Hatch { let [ox, oy, oz] = world_offset; let normal = (self.normal.x, self.normal.y, self.normal.z); // Convert a 2D OCS hatch boundary point to WCS, then subtract world_offset. - let to_wcs = |x: f64, y: f64| -> [f32; 3] { + let to_wcs = |x: f64, y: f64| -> [f64; 3] { let (wx, wy, wz) = crate::scene::view::transform::ocs_point_to_wcs((x, y, self.elevation), normal); - [(wx - ox) as f32, (wy - oy) as f32, (wz - oz) as f32] + [wx - ox, wy - oy, wz - oz] }; - let mut pts: Vec<[f32; 3]> = Vec::new(); - let mut key_verts: Vec<[f32; 3]> = Vec::new(); + // Snap point at a world (f64) location, cast to the f32 snap buffer. + let snap_at = |w: [f64; 3]| Vec3::new(w[0] as f32, w[1] as f32, w[2] as f32); + let mut pts: Vec<[f64; 3]> = Vec::new(); + let mut key_verts: Vec<[f64; 3]> = Vec::new(); let mut snap_pts: Vec<(Vec3, SnapHint)> = Vec::new(); for path in &self.paths { for edge in &path.edges { @@ -376,7 +378,7 @@ impl FallbackTess for Hatch { // shows up as a stray boundary line between hatch // regions. if !pts.is_empty() { - pts.push([f32::NAN; 3]); + pts.push([f64::NAN; 3]); } let start_idx = pts.len(); let seg_count = if poly.is_closed { @@ -435,7 +437,7 @@ impl FallbackTess for Hatch { let p0 = to_wcs(ln.start.x, ln.start.y); let p1 = to_wcs(ln.end.x, ln.end.y); if !pts.is_empty() { - pts.push([f32::NAN; 3]); + pts.push([f64::NAN; 3]); } pts.push(p0); pts.push(p1); @@ -447,7 +449,7 @@ impl FallbackTess for Hatch { arc_signed_span(arc.start_angle, arc.end_angle, arc.counter_clockwise); let segs = arc_segments(arc.radius, span.abs(), wire_chord_tol(arc.radius)); if !pts.is_empty() { - pts.push([f32::NAN; 3]); + pts.push([f64::NAN; 3]); } for i in 0..=segs { let t = sa + span * (i as f64 / segs as f64); @@ -461,7 +463,7 @@ impl FallbackTess for Hatch { } } snap_pts.push(( - Vec3::from(to_wcs(arc.center.x, arc.center.y)), + snap_at(to_wcs(arc.center.x, arc.center.y)), SnapHint::Center, )); } @@ -475,7 +477,7 @@ impl FallbackTess for Hatch { arc_signed_span(ell.start_angle, ell.end_angle, ell.counter_clockwise); let segs = arc_segments(r_maj, span.abs(), wire_chord_tol(r_maj)); if !pts.is_empty() { - pts.push([f32::NAN; 3]); + pts.push([f64::NAN; 3]); } let (cr, sr) = (rot.cos(), rot.sin()); for i in 0..=segs { @@ -492,7 +494,7 @@ impl FallbackTess for Hatch { } } snap_pts.push(( - Vec3::from(to_wcs(ell.center.x, ell.center.y)), + snap_at(to_wcs(ell.center.x, ell.center.y)), SnapHint::Center, )); } diff --git a/src/entities/insert.rs b/src/entities/insert.rs index 62b539d4d..794314245 100644 --- a/src/entities/insert.rs +++ b/src/entities/insert.rs @@ -104,17 +104,18 @@ impl crate::entities::traits::FallbackTess for Insert { world_offset: [f64; 3], ) -> crate::scene::convert::tess_util::FallbackGeometry { let [ox, oy, oz] = world_offset; - let ip = Vec3::new( - (self.insert_point.x - ox) as f32, - (self.insert_point.y - oy) as f32, - (self.insert_point.z - oz) as f32, + let (ipx, ipy, ipz) = ( + self.insert_point.x - ox, + self.insert_point.y - oy, + self.insert_point.z - oz, ); - let s = 0.1_f32; + let ip = Vec3::new(ipx as f32, ipy as f32, ipz as f32); + let s = 0.1_f64; let pts = vec![ - [ip.x - s, ip.y, ip.z], - [ip.x + s, ip.y, ip.z], - [ip.x, ip.y - s, ip.z], - [ip.x, ip.y + s, ip.z], + [ipx - s, ipy, ipz], + [ipx + s, ipy, ipz], + [ipx, ipy - s, ipz], + [ipx, ipy + s, ipz], ]; ( pts, diff --git a/src/entities/ole2frame.rs b/src/entities/ole2frame.rs index 3af21b67d..8b116ca78 100644 --- a/src/entities/ole2frame.rs +++ b/src/entities/ole2frame.rs @@ -187,14 +187,14 @@ impl crate::entities::traits::FallbackTess for Ole2Frame { // OLE objects carry a bounding rectangle in model space. // Render a simple X-through-rectangle placeholder. let [ox, oy, oz] = world_offset; - let x0 = (self.upper_left_corner.x - ox) as f32; - let y0 = (self.lower_right_corner.y - oy) as f32; - let x1 = (self.lower_right_corner.x - ox) as f32; - let y1 = (self.upper_left_corner.y - oy) as f32; - let z = (self.upper_left_corner.z - oz) as f32; + let x0 = self.upper_left_corner.x - ox; + let y0 = self.lower_right_corner.y - oy; + let x1 = self.lower_right_corner.x - ox; + let y1 = self.upper_left_corner.y - oy; + let z = self.upper_left_corner.z - oz; if (x1 - x0).abs() < 1e-6 && (y1 - y0).abs() < 1e-6 { // Degenerate / unknown size — show a small cross. - let s = 0.5_f32; + let s = 0.5_f64; return (vec![[-s, 0.0, 0.0], [s, 0.0, 0.0]], vec![], vec![], vec![]); } let pts = vec![ @@ -210,7 +210,7 @@ impl crate::entities::traits::FallbackTess for Ole2Frame { // Diagonal X [x0, y0, z], [x1, y1, z], - [f32::NAN, f32::NAN, f32::NAN], + [f64::NAN, f64::NAN, f64::NAN], [x1, y0, z], [x0, y1, z], ]; diff --git a/src/entities/viewport.rs b/src/entities/viewport.rs index b072dea43..b4070c869 100644 --- a/src/entities/viewport.rs +++ b/src/entities/viewport.rs @@ -551,11 +551,11 @@ impl crate::entities::traits::FallbackTess for Viewport { world_offset: [f64; 3], ) -> crate::scene::convert::tess_util::FallbackGeometry { let [ox, oy, oz] = world_offset; - let cx = (self.center.x - ox) as f32; - let cy = (self.center.y - oy) as f32; - let cz = (self.center.z - oz) as f32; - let hw = (self.width / 2.0) as f32; - let hh = (self.height / 2.0) as f32; + let cx = self.center.x - ox; + let cy = self.center.y - oy; + let cz = self.center.z - oz; + let hw = self.width / 2.0; + let hh = self.height / 2.0; let pts = vec![ [cx - hw, cy - hh, cz], [cx + hw, cy - hh, cz], diff --git a/src/scene/convert/tess_util.rs b/src/scene/convert/tess_util.rs index d4ae0b8d5..3ada053ff 100644 --- a/src/scene/convert/tess_util.rs +++ b/src/scene/convert/tess_util.rs @@ -16,11 +16,16 @@ use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; /// callsite when the dispatcher wraps these into a WireModel. /// /// Layout: `(points, snap_pts, tangent_geoms, key_vertices)`. +/// +/// `points` are ABSOLUTE world coordinates in f64 — the dispatcher splits them +/// into the double-single high/low pair the relative-to-eye renderer needs, so +/// fallback outlines (hatch boundary, viewport/insert/ole2frame frames) stay +/// glued to their fills at UTM scale instead of quantizing ~0.5 m in f32. pub type FallbackGeometry = ( - Vec<[f32; 3]>, + Vec<[f64; 3]>, Vec<(Vec3, SnapHint)>, Vec, - Vec<[f32; 3]>, + Vec<[f64; 3]>, ); // ── Arc tessellation helpers ───────────────────────────────────────────── diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index d5064d566..8f27f8e0d 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -471,7 +471,7 @@ pub fn tessellate( // SOLVIEW output or when the SAT kernel cannot parse the // ACIS data). let wire_pts = solid_wire_fallback(entity, world_offset); - let mut wm = WireModel::solid(name, wire_pts, color, selected); + let mut wm = WireModel::solid_f64(name, wire_pts, color, selected); // Add insertion snap at point_of_reference. let [ox, oy, oz] = world_offset; if let Some(p) = crate::entities::solid3d::point_of_reference(entity) { @@ -484,19 +484,33 @@ pub fn tessellate( } // ── Fallback for Viewport / Insert / Hatch / Ole2Frame ──────────────── - let (points, snap_pts, tangent_geoms, key_vertices) = fallback_geometry(entity, world_offset); + let (points_f64, snap_pts, tangent_geoms, key_vertices) = + fallback_geometry(entity, world_offset); + // `points_f64` are absolute world coords; split into the double-single + // high/low pair so the outline reconstructs to f64 precision at UTM scale + // (a NaN separator stays NaN in both buffers). + let mut points: Vec<[f32; 3]> = Vec::with_capacity(points_f64.len()); + let mut points_low: Vec<[f32; 3]> = Vec::with_capacity(points_f64.len()); + for [x, y, z] in &points_f64 { + if !x.is_finite() || !y.is_finite() { + points.push([f32::NAN, f32::NAN, f32::NAN]); + points_low.push([0.0; 3]); + continue; + } + let (hx, lx) = split_ds(*x); + let (hy, ly) = split_ds(*y); + let (hz, lz) = split_ds(*z); + points.push([hx, hy, hz]); + points_low.push([lx, ly, lz]); + } // fallback_geometry still emits offset-relative f32 snap points; widen to // f64 for the WireModel's double-single-era snap buffer. let snap_pts: Vec<(glam::DVec3, SnapHint)> = snap_pts.into_iter().map(|(p, h)| (p.as_dvec3(), h)).collect(); - let key_vertices: Vec<[f64; 3]> = key_vertices - .into_iter() - .map(|[x, y, z]| [x as f64, y as f64, z as f64]) - .collect(); vec![WireModel { name, points, - points_low: Vec::new(), + points_low, color, selected, aci: 0, @@ -704,7 +718,7 @@ fn fallback_geometry(entity: &EntityType, world_offset: [f64; 3]) -> Geometry { (pts, snap, vec![], vec![]) } _ => { - let s = 0.5_f32; + let s = 0.5_f64; (vec![[-s, 0.0, 0.0], [s, 0.0, 0.0]], vec![], vec![], vec![]) } } @@ -715,7 +729,7 @@ fn fallback_geometry(entity: &EntityType, world_offset: [f64; 3]) -> Geometry { /// AutoCAD stores explicit wire geometry (from SOLVIEW / 3DPLOT) alongside the /// ACIS data. We use this as a visible fallback when the SAT tessellator /// produces no mesh (e.g. binary SAB data or unsupported geometry). -fn solid_wire_fallback(entity: &EntityType, world_offset: [f64; 3]) -> Vec<[f32; 3]> { +fn solid_wire_fallback(entity: &EntityType, world_offset: [f64; 3]) -> Vec<[f64; 3]> { let [ox, oy, oz] = world_offset; let Some(wires) = crate::entities::solid3d::fallback_wires(entity) else { return vec![]; @@ -724,16 +738,16 @@ fn solid_wire_fallback(entity: &EntityType, world_offset: [f64; 3]) -> Vec<[f32; return vec![]; } - let mut pts: Vec<[f32; 3]> = Vec::new(); + let mut pts: Vec<[f64; 3]> = Vec::new(); for wire in wires { if wire.points.len() < 2 { continue; } for v in &wire.points { - pts.push([(v.x - ox) as f32, (v.y - oy) as f32, (v.z - oz) as f32]); + pts.push([v.x - ox, v.y - oy, v.z - oz]); } // NaN sentinel separates distinct wire segments. - pts.push([f32::NAN, f32::NAN, f32::NAN]); + pts.push([f64::NAN, f64::NAN, f64::NAN]); } pts }