fix(viewport): UTM auto-fit + mesh world_offset

Two world_offset bugs in viewport rendering.

(1) Solid3D / Region / Body meshes were tessellated straight out of
the ACIS SAT data in WCS, while the wire / hatch / face3d pipelines
all run in `(WCS - world_offset)` local space. At UTM scale the
result was triangles drifting millions of units off-camera every
frame. `offset_mesh_lod_set` post-processes the freshly built
`MeshLodSet`, subtracting `world_offset` from every vertex and
recomputing `world_aabb` so the LOD / cull math agrees with the
shared camera space. Applied at the three `tessellate_volume`
call sites — load, `add_entity`, and `populate_meshes_from_document`.

(2) Paper-space content viewports rendered blank on UTM drawings.
The DWG / DXF saved `view_target = (0, 0, 0)` while the model sat
around `world_offset`; the CPU projection in `viewport_content_wires`
already had a "saved view doesn't overlap content cluster →
auto-fit to (world_offset ± local_extent_max)" fallback, but the
GPU path read `vp.view_target` literally and culled every entity
against the empty WCS rect. `camera_for_viewport` now applies the
same overlap test + auto-fit, and `model_wires_for_viewport`
derives its frustum AABB / wpp from the resulting camera so both
the camera and the cull agree on which area to show.

Folding `view_center` directly into `effective_target_wcs` matches
the CPU path's `display_center_x = view_target + view_center`,
removing the prior `+ view_right * view_center.x` shift that would
double-count once auto-fit had overwritten the target.
This commit is contained in:
Hakan Seven committed 2026-05-28 20:51:16 +03:00
1 parent 1eb54ee8a3
commit 74a685d0e8
1 file changed
+93 -29
+93 -29
View File
@@ -216,7 +216,7 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
let e = doc.get_entity(handle)?;
let color = tess_util::aci_to_rgba(&e.common().color);
crate::entities::solid3d::tessellate_volume(e, color, facet_res)
.map(|m| (handle, m))
.map(|m| (handle, offset_mesh_lod_set(m, world_offset)))
})
.collect();
@@ -477,6 +477,37 @@ fn overlap_len(a: (f32, f32), b: (f32, f32)) -> f32 {
(a.1.min(b.1) - a.0.max(b.0)).max(0.0)
}
/// Shift every vertex of a freshly tessellated `MeshLodSet` into the
/// scene's local f32 space by subtracting `world_offset`. ACIS / SAT
/// tessellation hands us WCS coordinates; the wire / hatch / face3d
/// paths run in `(WCS - world_offset)` so meshes at large UTM-scale
/// origins would otherwise float far away from the rest of the
/// geometry. Also recomputes `world_aabb` so per-frame LOD / cull math
/// uses the same space.
fn offset_mesh_lod_set(mut set: MeshLodSet, world_offset: [f64; 3]) -> MeshLodSet {
let [ox, oy, oz] = world_offset;
let (fx, fy, fz) = (ox as f32, oy as f32, oz as f32);
let mut min_x = f32::INFINITY;
let mut min_y = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut max_y = f32::NEG_INFINITY;
for lod in &mut set.lods {
for v in &mut lod.verts {
v[0] -= fx;
v[1] -= fy;
v[2] -= fz;
if v[0] < min_x { min_x = v[0]; }
if v[1] < min_y { min_y = v[1]; }
if v[0] > max_x { max_x = v[0]; }
if v[1] > max_y { max_y = v[1]; }
}
}
if min_x.is_finite() {
set.world_aabb = [min_x, min_y, max_x, max_y];
}
set
}
/// Stable hash of a `Camera`'s pose for use as a per-tile cache key.
/// Two cameras with bit-identical target / rotation / distance hash the
/// same; any orbit / pan / zoom on a tile bumps it.
@@ -2624,7 +2655,9 @@ impl Scene {
EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_)
) {
let color = self.render_style(&entity).0;
let woff = self.world_offset;
crate::entities::solid3d::tessellate_volume(&entity, color, facet_res)
.map(|m| offset_mesh_lod_set(m, woff))
} else {
None
};
@@ -3463,11 +3496,12 @@ impl Scene {
use rayon::prelude::*;
let facet_res = self.document.header.facet_resolution;
let woff = self.world_offset;
self.meshes = entries
.into_par_iter()
.filter_map(|(handle, entity, color)| {
crate::entities::solid3d::tessellate_volume(&entity, color, facet_res)
.map(|m| (handle, m))
.map(|m| (handle, offset_mesh_lod_set(m, woff)))
})
.collect();
@@ -5275,23 +5309,55 @@ impl Scene {
};
let rotation = camera::yaw_pitch_to_quat(yaw, pitch, 0.0);
let view_right = rotation * glam::Vec3::X;
let view_up = rotation * glam::Vec3::Y;
// view_target is in raw model/WCS coords; the GPU renderer works in
// wire-space (model - world_offset), so subtract world_offset here.
// view_center is a 2-D DCS offset of the display centre from view_target.
//
// UTM / coordinate-shifted drawings often arrive with
// `view_target = (0, 0, 0)` and a stale `view_center` from before
// the file was geo-referenced; the saved view points at empty
// WCS while the actual model sits ~`world_offset` away. The CPU
// projection path in `viewport_content_wires` already auto-fits
// to the content cluster in that case — apply the same fallback
// here so the GPU-rendered viewport shows the model instead of
// an empty rectangle.
let mut effective_target_wcs = (
vp.view_target.x + vp.view_center.x,
vp.view_target.y + vp.view_center.y,
);
let mut effective_view_height = vp.view_height.abs().max(1e-9);
let aspect_d = (vp.width / vp.height.max(1.0)).max(1e-9);
let cluster_half = self.local_extent_max.max(1.0) as f64;
let saved_half_h = effective_view_height * 0.5;
let saved_half_w = saved_half_h * aspect_d;
let cluster_min_x = self.world_offset[0] - cluster_half;
let cluster_max_x = self.world_offset[0] + cluster_half;
let cluster_min_y = self.world_offset[1] - cluster_half;
let cluster_max_y = self.world_offset[1] + cluster_half;
let saved_overlaps = effective_target_wcs.0 + saved_half_w >= cluster_min_x
&& effective_target_wcs.0 - saved_half_w <= cluster_max_x
&& effective_target_wcs.1 + saved_half_h >= cluster_min_y
&& effective_target_wcs.1 - saved_half_h <= cluster_max_y;
if !saved_overlaps {
let margin = 1.05_f64;
effective_view_height = cluster_half * 2.0 * margin;
effective_target_wcs = (self.world_offset[0], self.world_offset[1]);
}
let base_target = glam::Vec3::new(
(vp.view_target.x - self.world_offset[0]) as f32,
(vp.view_target.y - self.world_offset[1]) as f32,
(effective_target_wcs.0 - self.world_offset[0]) as f32,
(effective_target_wcs.1 - self.world_offset[1]) as f32,
(vp.view_target.z - self.world_offset[2]) as f32,
);
let target =
base_target + view_right * vp.view_center.x as f32 + view_up * vp.view_center.y as f32;
// `effective_target_wcs` already folded `view_center` in above
// (CPU path does the same via `display_center_*`), so no extra
// `view_right * view_center` shift here — that would double-count.
let target = base_target;
let fov_y = 45.0_f32.to_radians();
let view_height = if vp.view_height.abs() > 1e-9 {
vp.view_height as f32
let view_height = if effective_view_height > 1e-9 {
effective_view_height as f32
} else {
vp.height as f32
};
@@ -5320,10 +5386,7 @@ impl Scene {
) -> Vec<WireModel> {
use std::collections::HashSet as HSet;
let (frozen, vp_anno_scale, vp_aspect, vp_target, vp_ortho_h) = match self
.document
.get_entity(vp_handle)
{
let (frozen, vp_anno_scale, vp_aspect) = match self.document.get_entity(vp_handle) {
Some(EntityType::Viewport(vp)) => {
let f: HSet<Handle> = vp.frozen_layers.iter().cloned().collect();
let vp_scale =
@@ -5338,27 +5401,28 @@ impl Scene {
} else {
1.0_f32
};
let half_h = (vp.view_height as f32) * 0.5;
let target = glam::Vec3::new(
(vp.view_target.x - self.world_offset[0]) as f32,
(vp.view_target.y - self.world_offset[1]) as f32,
(vp.view_target.z - self.world_offset[2]) as f32,
);
(f, anno, aspect, target, half_h)
(f, anno, aspect)
}
_ => (HSet::new(), 1.0_f32, 1.0_f32, glam::Vec3::ZERO, 1.0_f32),
_ => (HSet::new(), 1.0_f32, 1.0_f32),
};
// Per-viewport frustum AABB in the same f32 wire space (already
// `world_offset`-subtracted). Matches the margin used by
// `view_world_aabb` so pan inertia doesn't pop edges.
// Drive the per-viewport view_aabb / wpp from the *effective* camera
// `camera_for_viewport` produces — it folds in the auto-fit
// fallback for UTM-style files whose saved `view_target` sits at
// empty WCS. Without that, the GPU pass would frustum-cull every
// entity (saved-view rect doesn't overlap the offset-subtracted
// model cluster) and the viewport would render blank.
let Some(cam) = self.camera_for_viewport(vp_handle) else {
return Vec::new();
};
let vp_ortho_h = cam.ortho_size();
let half_w = vp_ortho_h * vp_aspect.max(0.01);
let margin = 1.25_f32;
let view_aabb = Some([
vp_target.x - half_w * margin,
vp_target.y - vp_ortho_h * margin,
vp_target.x + half_w * margin,
vp_target.y + vp_ortho_h * margin,
cam.target.x - half_w * margin,
cam.target.y - vp_ortho_h * margin,
cam.target.x + half_w * margin,
cam.target.y + vp_ortho_h * margin,
]);
// World units per on-screen pixel for LOD substitution + curve
// tolerance. Tracks the paper-zoom-driven pixel height the