Review follow-ups to #390, which fixed PDF/print text for model space but left the layout-plot path — the one issue #385 was filed from — broken. viewport_content_wires rewrote points/snap_pts/key_vertices/aabb and then cloned text_verts through untouched, so paper-space viewport text stayed at model (UTM) coordinates: a dimension at model (25000, 12000) plotted its lines onto the sheet and its glyphs kilometres off the page. A text-only wire fared worse — TEXT/MTEXT carry no stroke points since text went SDF-only, so it was dropped outright by the empty-clip and AABB rejections. Project the glyphs through the same proj_abs the snap points use (via the existing map_text_verts helper), cull per glyph on the quad centroid, count them towards the paper AABB, and dim them like the wire colour. Also in emit_text, which diverged from the wire pass it mirrors: - divide the pen by `scale`, or a Fit/windowed plot prints text as near-invisible hairlines while its own lines stay correct; - apply the CTB plot style, or a monochrome.ctb plot renders the lines black and leaves the text on its screen colour; - reset the dash pattern, which is persistent PDF state the wire pass leaves set — a HIDDEN-linetype last wire printed glyph outlines dashed. export_table's doc claimed it honoured TEXTFILL and widened the bold pen itself; it did neither. Gate fill_tris on textfill() so hollow-on-screen text exports hollow, carry the bold flag and widen the pen by the 1.7x the bake uses, and resolve Face once per family instead of once per glyph (the atlas mutex is held for that whole walk). uv_key identifies a glyph by its tile's uv_min, but grow_height rescales every entry's V and reset rewinds the packer, leaving already-built quads addressing the wrong tile — garbage on screen and a silent miss in the export table, i.e. #385 again under #347's grow-the-atlas conditions. Bump an atlas generation on both and fold it into the tessellation memo guard so the text re-lays-out instead. Quads built earlier in the same pass that grows are still stale for that pass; the guard heals them on the next one. Tests: the integration test's "some wire has text_verts" assert passed on pre-fix code too (the exporter ignored them; the scene always carried them), and its dimension half was satisfied by the TEXT alone — assert per entity handle instead, and stop colliding on fixed names in the shared temp root. The unit test's bbox came from the same quads the mapping reads, so it held by construction; pin the run's own world box instead. That still cannot catch a mirrored corner assignment — verified by mirroring it — so say so rather than claim coverage the assert doesn't have. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
489 lines
21 KiB
Rust
489 lines
21 KiB
Rust
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||
use super::*;
|
||
|
||
impl Scene {
|
||
/// Collect model-space wires projected into paper space for all (or one specific)
|
||
/// user viewports. `only_vp = Some(h)` restricts output to that viewport.
|
||
pub(super) fn viewport_content_wires(
|
||
&self,
|
||
paper_block: Handle,
|
||
only_vp: Option<Handle>,
|
||
exclude_vp: Option<Handle>,
|
||
) -> Vec<WireModel> {
|
||
use acadrust::entities::Viewport;
|
||
|
||
let viewports: Vec<&Viewport> = self
|
||
.document
|
||
.entities()
|
||
.filter_map(|e| {
|
||
if let EntityType::Viewport(vp) = e {
|
||
Some(vp)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.filter(|vp| {
|
||
self.is_content_viewport_in_layout(vp, paper_block)
|
||
&& vp.status.is_on
|
||
&& only_vp.map_or(true, |h| vp.common.handle == h)
|
||
&& exclude_vp.map_or(true, |h| vp.common.handle != h)
|
||
})
|
||
.collect();
|
||
|
||
if viewports.is_empty() {
|
||
return vec![];
|
||
}
|
||
|
||
let mut result = Vec::new();
|
||
|
||
for vp in viewports {
|
||
let vp_handle = vp.common.handle;
|
||
|
||
// ── Fast path: return cached projected wires ──────────────────
|
||
{
|
||
let cache = self.paper_projected_cache.borrow();
|
||
if let Some((cached_epoch, ref wires)) = cache.get(&vp_handle) {
|
||
if *cached_epoch == self.geometry_epoch {
|
||
result.extend_from_slice(wires);
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Cache miss: compute projection ────────────────────────────
|
||
|
||
// Use camera_for_viewport so the axes match the GPU renderer exactly.
|
||
let cam_frame = match self.camera_for_viewport(vp_handle) {
|
||
Some(c) => c,
|
||
None => continue,
|
||
};
|
||
let view_right = cam_frame.rotation * glam::Vec3::X;
|
||
let view_up = cam_frame.rotation * glam::Vec3::Y;
|
||
|
||
// Scale (paper units per model unit) comes straight from the camera
|
||
// the GPU uses: the model height shown is `2 * ortho_size`, mapped
|
||
// onto `vp.height` of paper. `camera_for_viewport` already made the
|
||
// saved-view-vs-auto-fit decision (with the twist-correct overlap
|
||
// test), so deriving scale from it keeps the CPU projection (used
|
||
// for hit-test / snap / fit) locked to the GPU render — no second,
|
||
// independently-computed scale that could disagree under a twist.
|
||
let view_height_eff = (cam_frame.ortho_size() * 2.0) as f64;
|
||
let scale = if view_height_eff > 1e-9 {
|
||
(vp.height / view_height_eff) as f32
|
||
} else {
|
||
1.0
|
||
};
|
||
|
||
let pcx = vp.center.x as f32;
|
||
let pcy = vp.center.y as f32;
|
||
let pcz = vp.center.z as f32;
|
||
let hw = (vp.width / 2.0) as f32;
|
||
let hh = (vp.height / 2.0) as f32;
|
||
|
||
// ── Use cached tessellation (model_wires_for_viewport_arc) ────
|
||
// This eliminates the per-frame tessellate_one() loop that was here
|
||
// previously; tessellation is now O(1) on navigation frames.
|
||
// Pass 0.0 for screen height — the CPU-projection / hit-test
|
||
// path wants the full-fidelity (no-LOD-stub) wire list,
|
||
// regardless of paper zoom.
|
||
let model_wires = self.model_wires_for_viewport_arc(vp_handle, 0.0);
|
||
|
||
// ── 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;
|
||
|
||
// camera_dist: how far the camera is from the target plane.
|
||
let use_perspective = vp.status.perspective && vp.lens_length > 1.0;
|
||
let camera_dist = if use_perspective {
|
||
(vp.view_height as f32 * vp.lens_length as f32 / 24.0).max(0.001)
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
let mut projected: Vec<WireModel> = Vec::new();
|
||
|
||
// Precompute precision-stable WCS-space projection inputs in
|
||
// f64. The previous f32 inner loop suffered catastrophic
|
||
// cancellation on UTM-scale drawings: `(wire_offset_rel -
|
||
// target_offset_rel).dot(view_right) - view_center` is a
|
||
// small paper offset computed by subtracting two values at
|
||
// ~5e6 magnitude — f32 ULP there is ~0.5 m, so paper output
|
||
// jittered by cm even when the actual model was clean.
|
||
//
|
||
// Do everything WCS-relative in f64; cast to f32 only at the
|
||
// final paper position.
|
||
// Display centre = the camera's target, in WCS. `camera_for_viewport`
|
||
// already folded view_center through the (twisted) view basis and
|
||
// applied the empty-WCS auto-fit, so taking its target keeps the CPU
|
||
// projection identical to the GPU renderer under any twist.
|
||
let display_center_x = cam_frame.target.x as f64 + [0.0_f64; 3][0];
|
||
let display_center_y = cam_frame.target.y as f64 + [0.0_f64; 3][1];
|
||
let display_center_z = cam_frame.target.z as f64 + [0.0_f64; 3][2];
|
||
let view_right_d = (
|
||
view_right.x as f64,
|
||
view_right.y as f64,
|
||
view_right.z as f64,
|
||
);
|
||
let view_up_d = (view_up.x as f64, view_up.y as f64, view_up.z as f64);
|
||
let view_fwd = cam_frame.rotation * glam::Vec3::Z;
|
||
let view_fwd_d = (view_fwd.x as f64, view_fwd.y as f64, view_fwd.z as f64);
|
||
let camera_dist_d = camera_dist as f64;
|
||
let scale_d = scale as f64;
|
||
let pcx_d = pcx as f64;
|
||
let pcy_d = pcy as f64;
|
||
// Project one ABSOLUTE-WCS model point (f64) onto the paper sheet.
|
||
// Shared by the polyline points, snap points and key vertices so the
|
||
// hit-test / snap geometry lands in the same paper frame the wire is
|
||
// drawn in — otherwise snaps and the click-AABB stay in model (UTM)
|
||
// space and the cursor never reaches them.
|
||
let proj_abs = |ax: f64, ay: f64, az: f64| -> [f32; 3] {
|
||
let mp_x = ax - display_center_x;
|
||
let mp_y = ay - display_center_y;
|
||
let mp_z = az - display_center_z;
|
||
let u = mp_x * view_right_d.0 + mp_y * view_right_d.1 + mp_z * view_right_d.2;
|
||
let v = mp_x * view_up_d.0 + mp_y * view_up_d.1 + mp_z * view_up_d.2;
|
||
if use_perspective {
|
||
let d_vd = mp_x * view_fwd_d.0 + mp_y * view_fwd_d.1 + mp_z * view_fwd_d.2;
|
||
let fwd = camera_dist_d - d_vd;
|
||
if fwd <= 0.001 {
|
||
return [f32::NAN; 3];
|
||
}
|
||
let factor = camera_dist_d / fwd;
|
||
[
|
||
(pcx_d + u * factor * scale_d) as f32,
|
||
(pcy_d + v * factor * scale_d) as f32,
|
||
pcz,
|
||
]
|
||
} else {
|
||
[(pcx_d + u * scale_d) as f32, (pcy_d + v * scale_d) as f32, pcz]
|
||
}
|
||
};
|
||
let in_vp = |x: f32, y: f32| x >= vp_x0 && x <= vp_x1 && y >= vp_y0 && y <= vp_y1;
|
||
|
||
for wire in model_wires.iter() {
|
||
let projected_pts: Vec<[f32; 3]> = wire
|
||
.points
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(pi, &[mx, my, mz])| {
|
||
if mx.is_nan() || my.is_nan() || mz.is_nan() {
|
||
return [f32::NAN; 3];
|
||
}
|
||
// Reconstruct absolute WCS from the double-single high
|
||
// (`points`) + low (`points_low`) pair — the high f32
|
||
// alone is ~0.5 m off at UTM scale.
|
||
let lo = wire.points_low.get(pi).copied().unwrap_or([0.0; 3]);
|
||
proj_abs(mx as f64 + lo[0] as f64, my as f64 + lo[1] as f64, mz as f64 + lo[2] as f64)
|
||
})
|
||
.collect();
|
||
|
||
// SDF glyph quads ride `text_verts` in absolute WCS, so they need
|
||
// the same projection the points get — a bare clone leaves the
|
||
// text at model (UTM) coordinates while its own dimension lines
|
||
// move to the sheet, putting the glyphs kilometres off the page
|
||
// (issue #385 for layout plots). Cull per glyph on the quad's
|
||
// centroid: the text analogue of `clip_polyline_to_rect`, since a
|
||
// glyph can't be split at the viewport border.
|
||
let mut projected_text = if wire.text_verts.is_empty() {
|
||
Vec::new()
|
||
} else {
|
||
model::wire_model::map_text_verts(&wire.text_verts, |x, y, z| {
|
||
let p = proj_abs(x, y, z);
|
||
(p[0] as f64, p[1] as f64, p[2] as f64)
|
||
})
|
||
.chunks_exact(6)
|
||
.filter(|quad| {
|
||
let (sx, sy) = quad.iter().fold((0.0f32, 0.0f32), |(ax, ay), v| {
|
||
(ax + v.pos[0] + v.pos_low[0], ay + v.pos[1] + v.pos_low[1])
|
||
});
|
||
let (cx, cy) = (sx / 6.0, sy / 6.0);
|
||
cx.is_finite() && cy.is_finite() && in_vp(cx, cy)
|
||
})
|
||
.flatten()
|
||
.copied()
|
||
.collect::<Vec<_>>()
|
||
};
|
||
|
||
// Fast AABB pre-reject.
|
||
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
|
||
});
|
||
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;
|
||
// A text-only wire (TEXT / MTEXT carry no stroke `points` since
|
||
// text went SDF-only) has nothing to pre-reject on, so gate both
|
||
// rejections on the glyphs too or it never reaches the sheet.
|
||
if !any_near && !aabb_hits && projected_text.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
let clipped =
|
||
clip_polyline_to_rect(&projected_pts, vp_x0, vp_y0, vp_x1, vp_y1, pcz);
|
||
if clipped.is_empty() && projected_text.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
// Paper-space AABB of the clipped polyline — the cloned model
|
||
// (UTM) AABB would make click_hit's screen-projected pre-reject
|
||
// discard the wire (box selection has no pre-reject, which is why
|
||
// it kept working while picking didn't).
|
||
let mut pmnx = f32::INFINITY;
|
||
let mut pmny = f32::INFINITY;
|
||
let mut pmxx = f32::NEG_INFINITY;
|
||
let mut pmxy = f32::NEG_INFINITY;
|
||
for &[x, y, _] in clipped.iter().filter(|p| p[0].is_finite()) {
|
||
pmnx = pmnx.min(x);
|
||
pmny = pmny.min(y);
|
||
pmxx = pmxx.max(x);
|
||
pmxy = pmxy.max(y);
|
||
}
|
||
// Glyphs count towards the paper AABB as well — a text-only wire
|
||
// would otherwise stay UNBOUNDED and never pick.
|
||
for v in &projected_text {
|
||
let (x, y) = (v.pos[0] + v.pos_low[0], v.pos[1] + v.pos_low[1]);
|
||
if x.is_finite() && y.is_finite() {
|
||
pmnx = pmnx.min(x);
|
||
pmny = pmny.min(y);
|
||
pmxx = pmxx.max(x);
|
||
pmxy = pmxy.max(y);
|
||
}
|
||
}
|
||
|
||
// Project snap points + key vertices into the same paper frame,
|
||
// keeping only those inside the viewport rect, so endpoint /
|
||
// midpoint / centre snaps land on the visible sheet geometry
|
||
// instead of the model's UTM coordinates.
|
||
let snap_pts: Vec<(glam::DVec3, model::wire_model::SnapHint)> = wire
|
||
.snap_pts
|
||
.iter()
|
||
.filter_map(|(w, h)| {
|
||
let p = proj_abs(w.x, w.y, w.z);
|
||
(p[0].is_finite() && in_vp(p[0], p[1]))
|
||
.then(|| (glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), *h))
|
||
})
|
||
.collect();
|
||
let key_vertices: Vec<[f64; 3]> = wire
|
||
.key_vertices
|
||
.iter()
|
||
.filter_map(|&[kx, ky, kz]| {
|
||
let p = proj_abs(kx, ky, kz);
|
||
(p[0].is_finite() && in_vp(p[0], p[1]))
|
||
.then(|| [p[0] as f64, p[1] as f64, p[2] as f64])
|
||
})
|
||
.collect();
|
||
|
||
let adapted = view::render::adapt_to_bg(wire.color, self.paper_bg_color);
|
||
let [r, g, b, a] = adapted;
|
||
// Glyphs carry their own per-vertex colour, so dim them through
|
||
// the same adapt + 0.80/0.85 the wire colour below gets, or the
|
||
// text reads brighter than its own dimension lines.
|
||
for v in &mut projected_text {
|
||
let [tr, tg, tb, ta] = view::render::adapt_to_bg(v.color, self.paper_bg_color);
|
||
v.color = [tr * 0.80, tg * 0.80, tb * 0.80, ta * 0.85];
|
||
}
|
||
let mut out = wire.clone();
|
||
out.points = clipped;
|
||
out.text_verts = projected_text;
|
||
// Paper coordinates are small sheet units — no relative-to-eye
|
||
// residual is needed, and keeping the model wire's points_low
|
||
// here would add a model-scale offset to the paper points.
|
||
out.points_low = Vec::new();
|
||
out.snap_pts = snap_pts;
|
||
out.key_vertices = key_vertices;
|
||
// Tangent geometry is in model space and can't be trivially
|
||
// re-expressed in paper coords — drop it (no tangent snap on
|
||
// projected viewport content) rather than snap to UTM.
|
||
out.tangent_geoms = Vec::new();
|
||
out.aabb = if pmnx.is_finite() {
|
||
[pmnx, pmny, pmxx, pmxy]
|
||
} else {
|
||
WireModel::UNBOUNDED_AABB
|
||
};
|
||
out.color = [r * 0.80, g * 0.80, b * 0.80, a * 0.85];
|
||
out.line_weight_px = wire.line_weight_px;
|
||
// Wire's pattern was sized for model-space coords during
|
||
// tessellation; we just projected points into paper coords
|
||
// (× scale), so rescale the dash pattern by the same factor
|
||
// to keep dimensional consistency in the GPU shader.
|
||
out.pattern_length = wire.pattern_length * scale;
|
||
out.pattern = wire.pattern.map(|v| v * scale);
|
||
out.vp_scissor = Some([vp_x0, vp_y0, vp_x1, vp_y1]);
|
||
projected.push(out);
|
||
}
|
||
|
||
// Store in cache, then extend result.
|
||
self.paper_projected_cache
|
||
.borrow_mut()
|
||
.insert(vp_handle, (self.geometry_epoch, projected.clone()));
|
||
result.extend(projected);
|
||
}
|
||
|
||
result
|
||
}
|
||
}
|
||
|
||
// ── 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
|
||
}
|