// 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, exclude_vp: Option, ) -> Vec { 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 = 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(); // 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; if !any_near && !aabb_hits { continue; } let clipped = clip_polyline_to_rect(&projected_pts, vp_x0, vp_y0, vp_x1, vp_y1, pcz); if clipped.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); } // 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; let mut out = wire.clone(); out.points = clipped; // 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 }