forked from 1b-agent/opencadstudio-upstream
Use kernel-produced band outlines when FILLMODE is off. Preserve centerline linetype stations through inserts, clipping, projection, and PDF export.
1104 lines
43 KiB
Rust
1104 lines
43 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 (_, _, viewport_handles) = self.paper_viewport_handles();
|
||
let viewports: Vec<&Viewport> = viewport_handles
|
||
.iter()
|
||
.filter_map(|handle| match self.document.get_entity(*handle) {
|
||
Some(EntityType::Viewport(vp)) => Some(vp),
|
||
_ => None,
|
||
})
|
||
.filter(|vp| {
|
||
vp.common.owner_handle == 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 marker_view_height = wire.point_marker.map_or(view_height_eff, |marker| {
|
||
if !use_perspective {
|
||
return view_height_eff;
|
||
}
|
||
let relative = marker.origin
|
||
- glam::DVec3::new(
|
||
display_center_x,
|
||
display_center_y,
|
||
display_center_z,
|
||
);
|
||
let depth = relative.x * view_fwd_d.0
|
||
+ relative.y * view_fwd_d.1
|
||
+ relative.z * view_fwd_d.2;
|
||
view_height_eff * (camera_dist_d - depth).max(0.001)
|
||
/ camera_dist_d.max(0.001)
|
||
});
|
||
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];
|
||
}
|
||
let point = wire.point_world(pi, marker_view_height);
|
||
proj_abs(point.x, point.y, point.z)
|
||
})
|
||
.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 stationed = wire.pattern_stations.len() > wire.points.len();
|
||
let source_length = stationed.then(|| wire.pattern_stations[wire.points.len()]);
|
||
let (clipped, mut clipped_stations) = clip_polyline_to_rect(
|
||
&projected_pts,
|
||
stationed.then_some(&wire.pattern_stations[..wire.points.len()]),
|
||
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;
|
||
if let Some(source_length) = source_length {
|
||
for station in &mut clipped_stations {
|
||
*station *= scale;
|
||
}
|
||
clipped_stations.push(source_length * scale);
|
||
out.pattern_stations = clipped_stations;
|
||
} else {
|
||
out.pattern_stations.clear();
|
||
}
|
||
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.point_marker = None;
|
||
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();
|
||
// Same for thickness walls: `points` above were projected and
|
||
// clipped into paper coords, and the clone's wall triangles are
|
||
// still model-space. Left in place they would hit-test at a
|
||
// model-scale offset — a stray catch somewhere out on the sheet.
|
||
// Drop them: an extruded entity inside a floating viewport stays
|
||
// selectable by its edges, which do get projected.
|
||
out.pick_tris = Vec::new();
|
||
out.pick_tris_low = 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 patterns stay in their base model units. Projection
|
||
// normally scales them with the geometry; PSLTSCALE instead
|
||
// keeps dash sizes constant in paper units (the on-screen path
|
||
// applies the same factor through its viewport uniform).
|
||
let dash_scale = if self.document.header.paper_space_linetype_scaling {
|
||
1.0
|
||
} else {
|
||
scale
|
||
};
|
||
out.pattern_length = wire.pattern_length * dash_scale;
|
||
out.pattern = wire.pattern.map(|v| v * dash_scale);
|
||
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
|
||
}
|
||
|
||
/// Model-space fills projected into every active paper viewport for vector
|
||
/// plotting. The screen renderer projects these on the GPU; PDF generation
|
||
/// needs the equivalent paper-space geometry explicitly.
|
||
pub fn viewport_plot_fills(
|
||
&self,
|
||
) -> (Vec<(WireModel, f32)>, Vec<HatchModel>, Vec<HatchModel>) {
|
||
use acadrust::entities::Viewport;
|
||
use model::hatch_model::HatchPattern;
|
||
|
||
if self.current_layout == "Model" {
|
||
return (Vec::new(), Vec::new(), Vec::new());
|
||
}
|
||
let paper_block = self.current_layout_block_handle();
|
||
let model_block = self.model_space_block_handle();
|
||
let (_, _, viewport_handles) = self.paper_viewport_handles();
|
||
let viewports: Vec<&Viewport> = viewport_handles
|
||
.iter()
|
||
.filter_map(|handle| match self.document.get_entity(*handle) {
|
||
Some(EntityType::Viewport(viewport))
|
||
if viewport.common.owner_handle == paper_block && viewport.status.is_on =>
|
||
{
|
||
Some(viewport)
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
|
||
let mut pattern_wires = Vec::new();
|
||
let mut projected_hatches = Vec::new();
|
||
let mut projected_wipeouts = Vec::new();
|
||
|
||
for viewport in viewports {
|
||
let Some(camera) = self.camera_for_viewport(viewport.common.handle) else {
|
||
continue;
|
||
};
|
||
let view_right = camera.rotation * glam::Vec3::X;
|
||
let view_up = camera.rotation * glam::Vec3::Y;
|
||
let view_forward = camera.rotation * glam::Vec3::Z;
|
||
let view_height = (camera.ortho_size() * 2.0) as f64;
|
||
let viewport_scale = if view_height > 1e-9 {
|
||
viewport.height / view_height
|
||
} else {
|
||
1.0
|
||
};
|
||
let center_x = viewport.center.x;
|
||
let center_y = viewport.center.y;
|
||
let half_w = viewport.width * 0.5;
|
||
let half_h = viewport.height * 0.5;
|
||
let (xmin, ymin, xmax, ymax) = (
|
||
(center_x - half_w) as f32,
|
||
(center_y - half_h) as f32,
|
||
(center_x + half_w) as f32,
|
||
(center_y + half_h) as f32,
|
||
);
|
||
let perspective = viewport.status.perspective && viewport.lens_length > 1.0;
|
||
let camera_distance = if perspective {
|
||
(viewport.view_height * viewport.lens_length / 24.0).max(0.001)
|
||
} else {
|
||
0.0
|
||
};
|
||
let project_3d = |point: [f64; 3]| -> Option<[f32; 2]> {
|
||
let delta = glam::DVec3::from(point) - camera.target;
|
||
let u = delta.dot(view_right.as_dvec3());
|
||
let v = delta.dot(view_up.as_dvec3());
|
||
let factor = if perspective {
|
||
let depth = camera_distance - delta.dot(view_forward.as_dvec3());
|
||
if depth <= 0.001 {
|
||
return None;
|
||
}
|
||
camera_distance / depth
|
||
} else {
|
||
1.0
|
||
};
|
||
Some([
|
||
(center_x + u * factor * viewport_scale) as f32,
|
||
(center_y + v * factor * viewport_scale) as f32,
|
||
])
|
||
};
|
||
let project = |x: f64, y: f64| project_3d([x, y, 0.0]);
|
||
|
||
let frozen: rustc_hash::FxHashSet<Handle> =
|
||
viewport.frozen_layers.iter().copied().collect();
|
||
let hatches = self.plot_hatches_for_block(
|
||
model_block,
|
||
Some(&frozen),
|
||
self.viewport_scale_handle(viewport.common.handle),
|
||
self.annotation_all_visible(),
|
||
true,
|
||
);
|
||
for hatch in hatches {
|
||
if matches!(&hatch.pattern, HatchPattern::Pattern(_)) {
|
||
let mut points = Vec::new();
|
||
let mut aabb = [
|
||
f32::INFINITY,
|
||
f32::INFINITY,
|
||
f32::NEG_INFINITY,
|
||
f32::NEG_INFINITY,
|
||
];
|
||
for [a, b] in hatch.pattern_segments_for_plot() {
|
||
let (Some(a), Some(b)) =
|
||
(project(a[0], a[1]), project(b[0], b[1]))
|
||
else {
|
||
continue;
|
||
};
|
||
let Some((ax, ay, bx, by)) =
|
||
cs_clip(a[0], a[1], b[0], b[1], xmin, ymin, xmax, ymax)
|
||
else {
|
||
continue;
|
||
};
|
||
if !points.is_empty() {
|
||
points.push([f32::NAN, f32::NAN, f32::NAN]);
|
||
}
|
||
points.push([ax, ay, viewport.center.z as f32]);
|
||
points.push([bx, by, viewport.center.z as f32]);
|
||
aabb[0] = aabb[0].min(ax).min(bx);
|
||
aabb[1] = aabb[1].min(ay).min(by);
|
||
aabb[2] = aabb[2].max(ax).max(bx);
|
||
aabb[3] = aabb[3].max(ay).max(by);
|
||
}
|
||
if !points.is_empty() {
|
||
let mut wire = WireModel::solid(
|
||
"viewport_hatch_pattern".into(),
|
||
points,
|
||
hatch.color,
|
||
false,
|
||
);
|
||
wire.aci = hatch.aci;
|
||
wire.line_weight_px = hatch.line_weight_px;
|
||
wire.aabb = aabb;
|
||
pattern_wires.push((wire, hatch.draw_depth));
|
||
}
|
||
continue;
|
||
}
|
||
if let Some(hatch) =
|
||
project_plot_fill(hatch, &project_3d, xmin, ymin, xmax, ymax)
|
||
{
|
||
projected_hatches.push(hatch);
|
||
}
|
||
}
|
||
|
||
for wipeout in self.plot_wipeouts_for_block(
|
||
model_block,
|
||
Some(&frozen),
|
||
self.viewport_scale_handle(viewport.common.handle),
|
||
self.annotation_all_visible(),
|
||
false,
|
||
) {
|
||
if let Some(wipeout) =
|
||
project_plot_fill(wipeout, &project_3d, xmin, ymin, xmax, ymax)
|
||
{
|
||
projected_wipeouts.push(wipeout);
|
||
}
|
||
}
|
||
}
|
||
|
||
(pattern_wires, projected_hatches, projected_wipeouts)
|
||
}
|
||
|
||
/// A content viewport's clip boundary, projected into that viewport's
|
||
/// render-target normalized device coords. Every content viewport goes
|
||
/// through the same stencil path: a rectangular viewport contributes its
|
||
/// own four corners (a full-target mask, since the render rectangle already
|
||
/// clips the edges), a viewport with a `clip_boundary_handle` contributes
|
||
/// that entity's tessellated outline. Returns empty only when the viewport
|
||
/// is degenerate.
|
||
///
|
||
/// `uo/vo/us/vs` are the visible-sub-rect crop the content camera applies
|
||
/// (see `crop_view_proj`); the same crop is applied here so the boundary
|
||
/// lines up with the content even when the viewport hangs off-canvas.
|
||
pub(super) fn viewport_clip_boundary_ndc(
|
||
&self,
|
||
vp_handle: Handle,
|
||
uo: f32,
|
||
vo: f32,
|
||
us: f32,
|
||
vs: f32,
|
||
) -> Vec<[f32; 2]> {
|
||
use acadrust::entities::Viewport;
|
||
let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) else {
|
||
return vec![];
|
||
};
|
||
let vp: &Viewport = vp;
|
||
let pcx = vp.center.x as f32;
|
||
let pcy = vp.center.y as f32;
|
||
let z = vp.center.z as f32;
|
||
let hw = (vp.width / 2.0) as f32;
|
||
let hh = (vp.height / 2.0) as f32;
|
||
if hw.abs() < 1e-6 || hh.abs() < 1e-6 {
|
||
return vec![];
|
||
}
|
||
let poly = if vp.clip_boundary_handle.is_null() {
|
||
// Rectangular viewport → its own four corners (paper coords). These
|
||
// map to full-rect NDC [-1, 1], a no-op mask over a render target
|
||
// that already clips to the rectangle, but it keeps rect and
|
||
// non-rect viewports on one uniform stencil path.
|
||
vec![
|
||
[pcx - hw, pcy - hh, z],
|
||
[pcx + hw, pcy - hh, z],
|
||
[pcx + hw, pcy + hh, z],
|
||
[pcx - hw, pcy + hh, z],
|
||
]
|
||
} else {
|
||
self.clip_boundary_polygon(vp.clip_boundary_handle, z)
|
||
};
|
||
if poly.len() < 3 {
|
||
return vec![];
|
||
}
|
||
// Paper point → full-rectangle NDC ([-1, 1] across the viewport rect,
|
||
// Y up, matching the content camera + `viewport_screen_rect`), then the
|
||
// same crop the content camera uses to map its visible sub-rect back to
|
||
// NDC [-1, 1].
|
||
let us = us.max(1e-6);
|
||
let vs = vs.max(1e-6);
|
||
let sx = 1.0 / us;
|
||
let sy = 1.0 / vs;
|
||
let tx = (1.0 - 2.0 * uo - us) / us;
|
||
let ty = -(1.0 - 2.0 * vo - vs) / vs;
|
||
poly.iter()
|
||
.map(|p| {
|
||
let nx = (p[0] - pcx) / hw;
|
||
let ny = (p[1] - pcy) / hh;
|
||
[sx * nx + tx, sy * ny + ty]
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Tessellated outline of a non-rectangular viewport / XCLIP clip-boundary
|
||
/// entity, in paper coordinates. Closed polylines are sampled directly
|
||
/// because their normal tessellation is a `Contour`, not `Lines`.
|
||
pub(crate) fn clip_boundary_polygon(&self, handle: Handle, z: f32) -> Vec<[f32; 3]> {
|
||
clip_boundary_polygon_for_document(&self.document, handle, z)
|
||
}
|
||
}
|
||
|
||
pub(crate) fn clip_boundary_polygon_for_document(
|
||
document: &CadDocument,
|
||
handle: Handle,
|
||
z: f32,
|
||
) -> Vec<[f32; 3]> {
|
||
use std::f64::consts::TAU;
|
||
let Some(entity) = document
|
||
.entities()
|
||
.find(|e| e.common().handle == handle)
|
||
else {
|
||
return vec![];
|
||
};
|
||
// Circles and ellipses tessellate directly — their `to_render` returns a
|
||
// parametric RenderObject (not `Lines`), so extracting a polygon there
|
||
// would come back empty. Everything else (splines, polylines, …) reuses
|
||
// the entity's own `Lines` tessellation.
|
||
const N: usize = 64;
|
||
match entity {
|
||
EntityType::Circle(c) => (0..N)
|
||
.map(|i| {
|
||
let a = i as f64 * TAU / N as f64;
|
||
[
|
||
(c.center.x + a.cos() * c.radius) as f32,
|
||
(c.center.y + a.sin() * c.radius) as f32,
|
||
z,
|
||
]
|
||
})
|
||
.collect(),
|
||
EntityType::Ellipse(el) => {
|
||
// major_axis = center → major endpoint; minor = perp(major) × ratio.
|
||
let (mx, my) = (el.major_axis.x, el.major_axis.y);
|
||
let r = el.minor_axis_ratio;
|
||
(0..N)
|
||
.map(|i| {
|
||
let t = i as f64 * TAU / N as f64;
|
||
let px = mx * t.cos() - my * r * t.sin();
|
||
let py = my * t.cos() + mx * r * t.sin();
|
||
[(el.center.x + px) as f32, (el.center.y + py) as f32, z]
|
||
})
|
||
.collect()
|
||
}
|
||
EntityType::LwPolyline(polyline) if polyline.is_closed => {
|
||
let vertices: Vec<([f64; 2], f64)> = polyline
|
||
.vertices
|
||
.iter()
|
||
.map(|vertex| {
|
||
(
|
||
[vertex.location.x, vertex.location.y],
|
||
vertex.bulge,
|
||
)
|
||
})
|
||
.collect();
|
||
sample_polyline_clip_boundary(
|
||
&vertices,
|
||
polyline.elevation,
|
||
(
|
||
polyline.normal.x,
|
||
polyline.normal.y,
|
||
polyline.normal.z,
|
||
),
|
||
z,
|
||
)
|
||
}
|
||
EntityType::Polyline2D(polyline) if polyline.is_closed() => {
|
||
let vertices: Vec<([f64; 2], f64)> = polyline
|
||
.vertices
|
||
.iter()
|
||
.map(|vertex| {
|
||
(
|
||
[vertex.location.x, vertex.location.y],
|
||
vertex.bulge,
|
||
)
|
||
})
|
||
.collect();
|
||
sample_polyline_clip_boundary(
|
||
&vertices,
|
||
polyline.elevation,
|
||
(
|
||
polyline.normal.x,
|
||
polyline.normal.y,
|
||
polyline.normal.z,
|
||
),
|
||
z,
|
||
)
|
||
}
|
||
EntityType::Polyline(polyline) if polyline.is_closed() => polyline
|
||
.vertices
|
||
.iter()
|
||
.map(|vertex| {
|
||
[
|
||
vertex.location.x as f32,
|
||
vertex.location.y as f32,
|
||
z,
|
||
]
|
||
})
|
||
.collect(),
|
||
EntityType::Polyline3D(polyline) if polyline.flags.closed => polyline
|
||
.vertices
|
||
.iter()
|
||
.map(|vertex| {
|
||
[
|
||
vertex.position.x as f32,
|
||
vertex.position.y as f32,
|
||
z,
|
||
]
|
||
})
|
||
.collect(),
|
||
// Anything else that draws as a plane curve — a circle, an ellipse, a
|
||
// bulged polyline, a spline — is sampled through its own geometry,
|
||
// which is where every entity's curve is defined once.
|
||
_ => crate::entities::curve::entity_curve(entity)
|
||
.map(|planar| crate::entities::curve::curve_points(&planar))
|
||
.unwrap_or_default()
|
||
.into_iter()
|
||
.filter(|point| point[0].is_finite() && point[1].is_finite())
|
||
.map(|point| [point[0] as f32, point[1] as f32, z])
|
||
.collect(),
|
||
}
|
||
}
|
||
|
||
fn sample_polyline_clip_boundary(
|
||
vertices: &[([f64; 2], f64)],
|
||
elevation: f64,
|
||
normal: (f64, f64, f64),
|
||
z: f32,
|
||
) -> Vec<[f32; 3]> {
|
||
if vertices.len() < 3 {
|
||
return Vec::new();
|
||
}
|
||
let to_wcs = |point: [f64; 2]| {
|
||
crate::scene::view::transform::ocs_point_to_wcs(
|
||
(point[0], point[1], elevation),
|
||
normal,
|
||
)
|
||
};
|
||
let mut output = Vec::new();
|
||
let first = to_wcs(vertices[0].0);
|
||
output.push([first.0 as f32, first.1 as f32, z]);
|
||
for index in 0..vertices.len() {
|
||
let (start, bulge) = vertices[index];
|
||
let end_index = (index + 1) % vertices.len();
|
||
let end = vertices[end_index].0;
|
||
if let Some(arc) =
|
||
crate::entities::common::BulgeArc::from_bulge(start, end, bulge)
|
||
{
|
||
let steps = ((arc.sweep.abs() / std::f64::consts::TAU * 64.0).ceil()
|
||
as usize)
|
||
.clamp(4, 64);
|
||
for step in 1..=steps {
|
||
if end_index == 0 && step == steps {
|
||
break;
|
||
}
|
||
let point = to_wcs(arc.sample(step as f64 / steps as f64));
|
||
output.push([point.0 as f32, point.1 as f32, z]);
|
||
}
|
||
} else if end_index != 0 {
|
||
let point = to_wcs(end);
|
||
output.push([point.0 as f32, point.1 as f32, z]);
|
||
}
|
||
}
|
||
output
|
||
}
|
||
|
||
fn project_plot_fill<F>(
|
||
mut fill: HatchModel,
|
||
project: &F,
|
||
xmin: f32,
|
||
ymin: f32,
|
||
xmax: f32,
|
||
ymax: f32,
|
||
) -> Option<HatchModel>
|
||
where
|
||
F: Fn([f64; 3]) -> Option<[f32; 2]>,
|
||
{
|
||
let mut output = Vec::new();
|
||
let mut ring = Vec::new();
|
||
let flush_ring = |ring: &mut Vec<[f32; 2]>, output: &mut Vec<[f32; 2]>| {
|
||
if ring.len() < 3 {
|
||
ring.clear();
|
||
return;
|
||
}
|
||
if ring.first() == ring.last() {
|
||
ring.pop();
|
||
}
|
||
let mut clipped = clip_polygon_to_rect(ring, xmin, ymin, xmax, ymax);
|
||
ring.clear();
|
||
if clipped.len() < 3 {
|
||
return;
|
||
}
|
||
if clipped.first() != clipped.last() {
|
||
clipped.push(clipped[0]);
|
||
}
|
||
if !output.is_empty() {
|
||
output.push([f32::NAN, f32::NAN]);
|
||
}
|
||
output.extend(clipped);
|
||
};
|
||
if let (Some(plane), Some(boundary)) = (fill.fill_plane, fill.fill_plane_boundary.as_deref()) {
|
||
let plane = cadkernel::space::Plane::from_axes(
|
||
plane.origin,
|
||
plane.x_axis,
|
||
plane.y_axis,
|
||
);
|
||
for &[x, y] in boundary {
|
||
if x.is_nan() || y.is_nan() {
|
||
flush_ring(&mut ring, &mut output);
|
||
continue;
|
||
}
|
||
let point = plane.point_at([x as f64, y as f64]);
|
||
if let Some(point) = project(point) {
|
||
ring.push(point);
|
||
}
|
||
}
|
||
} else {
|
||
for &[x, y] in fill.boundary.iter() {
|
||
if x.is_nan() || y.is_nan() {
|
||
flush_ring(&mut ring, &mut output);
|
||
continue;
|
||
}
|
||
if let Some(point) = project([
|
||
fill.world_origin[0] + x as f64,
|
||
fill.world_origin[1] + y as f64,
|
||
0.0,
|
||
]) {
|
||
ring.push(point);
|
||
}
|
||
}
|
||
}
|
||
flush_ring(&mut ring, &mut output);
|
||
if output.is_empty() {
|
||
return None;
|
||
}
|
||
fill.world_origin = [0.0, 0.0];
|
||
fill.boundary = std::sync::Arc::new(output);
|
||
fill.boundary_wcs = None;
|
||
fill.fill_plane = None;
|
||
fill.fill_plane_boundary = None;
|
||
Some(fill)
|
||
}
|
||
|
||
fn clip_polygon_to_rect(
|
||
polygon: &[[f32; 2]],
|
||
xmin: f32,
|
||
ymin: f32,
|
||
xmax: f32,
|
||
ymax: f32,
|
||
) -> Vec<[f32; 2]> {
|
||
let mut output = polygon.to_vec();
|
||
for (edge, value) in [(0u8, xmin), (1, xmax), (2, ymin), (3, ymax)] {
|
||
if output.is_empty() {
|
||
break;
|
||
}
|
||
let input = std::mem::take(&mut output);
|
||
let mut previous = *input.last().unwrap();
|
||
let mut previous_inside = polygon_edge_inside(previous, edge, value);
|
||
for current in input {
|
||
let current_inside = polygon_edge_inside(current, edge, value);
|
||
if current_inside != previous_inside {
|
||
output.push(polygon_edge_intersection(previous, current, edge, value));
|
||
}
|
||
if current_inside {
|
||
output.push(current);
|
||
}
|
||
previous = current;
|
||
previous_inside = current_inside;
|
||
}
|
||
}
|
||
output
|
||
}
|
||
|
||
fn polygon_edge_inside(point: [f32; 2], edge: u8, value: f32) -> bool {
|
||
match edge {
|
||
0 => point[0] >= value,
|
||
1 => point[0] <= value,
|
||
2 => point[1] >= value,
|
||
_ => point[1] <= value,
|
||
}
|
||
}
|
||
|
||
fn polygon_edge_intersection(
|
||
start: [f32; 2],
|
||
end: [f32; 2],
|
||
edge: u8,
|
||
value: f32,
|
||
) -> [f32; 2] {
|
||
if edge <= 1 {
|
||
let dx = end[0] - start[0];
|
||
let t = if dx.abs() > 1e-12 {
|
||
(value - start[0]) / dx
|
||
} else {
|
||
0.0
|
||
};
|
||
[value, start[1] + (end[1] - start[1]) * t]
|
||
} else {
|
||
let dy = end[1] - start[1];
|
||
let t = if dy.abs() > 1e-12 {
|
||
(value - start[1]) / dy
|
||
} else {
|
||
0.0
|
||
};
|
||
[start[0] + (end[0] - start[0]) * t, value]
|
||
}
|
||
}
|
||
|
||
// ── 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]],
|
||
stations: Option<&[f32]>,
|
||
xmin: f32,
|
||
ymin: f32,
|
||
xmax: f32,
|
||
ymax: f32,
|
||
z: f32,
|
||
) -> (Vec<[f32; 3]>, Vec<f32>) {
|
||
const NAN3: [f32; 3] = [f32::NAN, f32::NAN, f32::NAN];
|
||
let mut result: Vec<[f32; 3]> = Vec::new();
|
||
let mut result_stations = 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)) => {
|
||
let parameter = |x: f32, y: f32| {
|
||
let dx = x1 - x0;
|
||
let dy = y1 - y0;
|
||
if dx.abs() >= dy.abs() && dx.abs() > 1e-12 {
|
||
(x - x0) / dx
|
||
} else if dy.abs() > 1e-12 {
|
||
(y - y0) / dy
|
||
} else {
|
||
0.0
|
||
}
|
||
};
|
||
let station = |t: f32| {
|
||
stations.map_or(0.0, |values| {
|
||
let start_station = values[start + j];
|
||
start_station
|
||
+ (values[start + j + 1] - start_station) * t
|
||
})
|
||
};
|
||
if !pen_down {
|
||
if !result.is_empty() {
|
||
result.push(NAN3);
|
||
if stations.is_some() {
|
||
result_stations.push(0.0);
|
||
}
|
||
}
|
||
result.push([cx0, cy0, z]);
|
||
if stations.is_some() {
|
||
result_stations.push(station(parameter(cx0, cy0)));
|
||
}
|
||
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]);
|
||
if stations.is_some() {
|
||
result_stations.push(0.0);
|
||
result_stations.push(station(parameter(cx0, cy0)));
|
||
}
|
||
}
|
||
}
|
||
result.push([cx1, cy1, z]);
|
||
if stations.is_some() {
|
||
result_stations.push(station(parameter(cx1, cy1)));
|
||
}
|
||
// 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();
|
||
if stations.is_some() {
|
||
result_stations.pop();
|
||
}
|
||
}
|
||
(result, result_stations)
|
||
}
|