forked from 1b-agent/opencadstudio-upstream
Break up the four giant files (~29k lines total) into focused submodule
directories, one concern per file. Pure mechanical moves — behavior is
unchanged.
- app/update.rs (9650) -> app/update/{mod,viewport,command,style,file,
dynamic,dialog,util}. The 384-arm update_inner match stays in mod.rs
with thin delegating arms; the 66 fattest arm bodies move to topic
methods. Arm set preserved exactly (384 -> 384).
- scene/mod.rs (9017) -> scene/{mod,entity,tess,hittest,layout,paper,
mspace,project,selection,modify,group_layer,preview}. The impl Scene
body is split across files via inherent-impl-per-file; struct, ctor,
fields and load-time helpers stay in the root. fn count 123 -> 123.
- app/commands.rs (6082) -> app/commands/* grouped by command family.
The 233-arm dispatch match becomes source-ordered per-family
dispatch_* handlers, preserving first-match precedence (233 -> 233).
- app/view.rs (4686) -> app/view/{mod,overlay,modal,viewcube,controls}.
Largest file drops from 9650 to 3343. Builds clean; app launches and
loads plugins with no panic.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
441 lines
18 KiB
Rust
441 lines
18 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();
|
||
|
||
// 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
|
||
}
|