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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-24 10:09:19 +03:00
1 parent ef55b49db5
commit dd1f2e49bb
7 files changed
+72 -46

No files matched your search

+5 -1
View File
@@ -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),
})
+12 -10
View File
@@ -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,
));
}
+10 -9
View File
@@ -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,
+7 -7
View File
@@ -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],
];
+5 -5
View File
@@ -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],
+7 -2
View File
@@ -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<TangentGeom>,
Vec<[f32; 3]>,
Vec<[f64; 3]>,
);
// ── Arc tessellation helpers ─────────────────────────────────────────────
+26 -12
View File
@@ -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
}