refactor(scene): group modules into role-based subfolders

Move the 30 flat modules under src/scene/ into six folders by role:
convert (entity→geometry), text (fonts/shaping), model (render models),
pick (hit-test/selection/grips/xclip), view (camera/transform/render),
cache (block + property caches). pipeline/ is unchanged.

Full path migration — all `scene::X` references become
`scene::<group>::X`, no compatibility re-exports. render.rs items that
were `pub(super)` (parent was scene) become `pub(in crate::scene)` to
keep their original scene-wide visibility.

No behavior change; build and tests are green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-16 11:33:46 +03:00
1 parent cd048165a9
commit 7fe7e0230d
160 files changed
+732 -715

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
//! OpenCADStudio-style grip editing.
use acadrust::Handle;
use glam::{Mat4, Vec3};
use iced::{Point, Rectangle};
use crate::scene::model::object::{GripDef, GripShape};
/// Pixel radius for grip hit-detection.
pub const GRIP_THRESHOLD_PX: f32 = 8.0;
/// Half-size of the rendered grip square / diamond in pixels.
pub const GRIP_HALF_PX: f32 = 5.0;
// ── Active drag state ─────────────────────────────────────────────────────
/// Stored on `OpenCADStudio` while a grip is being dragged.
#[derive(Clone, Debug)]
pub struct GripEdit {
/// Handle of the entity being edited.
pub handle: Handle,
/// Index into the entity's grip list.
pub grip_id: usize,
/// `true` → midpoint / translate grip; `false` → endpoint / absolute grip.
pub is_translate: bool,
/// World-space position of the grip when the drag started (ortho/polar base).
pub origin_world: Vec3,
/// Last world-space cursor position (needed for incremental delta on translate drags).
pub last_world: Vec3,
}
// ── Screen-space helpers ───────────────────────────────────────────────────
/// Project a slice of `GripDef`s to screen space.
/// Returns `(grip_id, screen_pos, is_midpoint, shape)` for each grip.
pub fn grips_to_screen(
grips: &[GripDef],
view_proj: Mat4,
bounds: Rectangle,
) -> Vec<(usize, Point, bool, GripShape, Option<[f32; 2]>)> {
grips
.iter()
.map(|g| {
let ndc = view_proj.project_point3(g.world.as_vec3());
let screen = Point::new(
bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width,
bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height,
);
(g.id, screen, g.is_midpoint, g.shape, g.dir)
})
.collect()
}
/// Paper-space variant: project grips using the 2-D linear `to_px` transform.
/// Parameters match the `to_px` closure in `paper_canvas.rs`.
pub fn grips_to_screen_paper(
grips: &[GripDef],
tx: f32,
ty: f32,
half_w: f32,
half_h: f32,
bounds: Rectangle,
) -> Vec<(usize, Point, bool, GripShape, Option<[f32; 2]>)> {
grips
.iter()
.map(|g| {
let screen = Point::new(
(g.world.x as f32 - tx + half_w) / (2.0 * half_w) * bounds.width,
(ty + half_h - g.world.y as f32) / (2.0 * half_h) * bounds.height,
);
(g.id, screen, g.is_midpoint, g.shape, g.dir)
})
.collect()
}
/// Paper-space hit-test variant (mirrors `find_hit_grip` but uses 2-D projection).
pub fn find_hit_grip_paper(
cursor: Point,
grips: &[GripDef],
tx: f32,
ty: f32,
half_w: f32,
half_h: f32,
bounds: Rectangle,
) -> Option<(usize, bool, Vec3)> {
let mut best_dist = GRIP_THRESHOLD_PX;
let mut best: Option<(usize, bool, Vec3)> = None;
for g in grips {
let screen = Point::new(
(g.world.x as f32 - tx + half_w) / (2.0 * half_w) * bounds.width,
(ty + half_h - g.world.y as f32) / (2.0 * half_h) * bounds.height,
);
let dx = screen.x - cursor.x;
let dy = screen.y - cursor.y;
let d = (dx * dx + dy * dy).sqrt();
if d < best_dist {
best_dist = d;
best = Some((g.id, g.is_midpoint, g.world.as_vec3()));
}
}
best
}
/// Find the closest grip within `GRIP_THRESHOLD_PX` pixels of `cursor`.
/// Returns `(grip_id, is_translate, world_pos)` if found, else `None`.
pub fn find_hit_grip(
cursor: Point,
grips: &[GripDef],
view_proj: Mat4,
bounds: Rectangle,
) -> Option<(usize, bool, Vec3)> {
let mut best_dist = GRIP_THRESHOLD_PX;
let mut best: Option<(usize, bool, Vec3)> = None;
for g in grips {
let ndc = view_proj.project_point3(g.world.as_vec3());
let screen = Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
);
let dx = screen.x - cursor.x;
let dy = screen.y - cursor.y;
let d = (dx * dx + dy * dy).sqrt();
if d < best_dist {
best_dist = d;
best = Some((g.id, g.is_midpoint, g.world.as_vec3()));
}
}
best
}
+653
View File
@@ -0,0 +1,653 @@
//! CPU-side hit-testing for wire geometry.
//!
//! All tests are performed in **screen space** — wire vertices are projected
//! to 2-D pixel coordinates, then compared against the cursor or selection box.
//! This matches the visual result the user sees.
use rustc_hash::FxHashMap as HashMap;
use acadrust::Handle;
use glam::{Mat4, Vec3};
use iced::{Point, Rectangle};
use crate::scene::model::hatch_model::HatchModel;
use crate::scene::model::mesh_model::MeshModel;
use crate::scene::model::wire_model::WireModel;
/// Pixel radius used for single-click wire detection.
const CLICK_THRESHOLD_PX: f32 = 8.0;
// ── Single-click hit test ─────────────────────────────────────────────────
/// Return the `name` of the closest wire whose screen-space segments pass
/// within `CLICK_THRESHOLD_PX` pixels of `cursor`.
///
/// Returns `None` when no wire is close enough.
pub fn click_hit<'a>(
cursor: Point,
wires: &'a [WireModel],
view_proj: Mat4,
bounds: Rectangle,
) -> Option<&'a str> {
let mut best_dist = CLICK_THRESHOLD_PX;
let mut best: Option<&str> = None;
// World z only shifts the *screen* x/y when the view is tilted (orbit /
// perspective). In the flat top-down ortho view — the case where hover lag
// on large drawings actually bites — a wire's screen position depends only
// on its world x/y, so its world-space AABB projects exactly and we can
// reject wires nowhere near the cursor without projecting any of their
// points (the dominant per-move cost on 100 k-wire drawings).
let z_flat = view_proj.z_axis.x.abs() < 1e-9 && view_proj.z_axis.y.abs() < 1e-9;
// Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain.
for wire in wires {
// Cheap AABB pre-reject (flat view only; never for the unbounded
// sentinel used by previews / greeked text).
if z_flat && wire.aabb != WireModel::UNBOUNDED_AABB {
let [minx, miny, maxx, maxy] = wire.aabb;
// Project all four corners — a plan view can be rotated about Z, so
// the screen footprint isn't axis-aligned and the two diagonal
// corners alone wouldn't bound it.
let mut sx0 = f32::MAX;
let mut sy0 = f32::MAX;
let mut sx1 = f32::MIN;
let mut sy1 = f32::MIN;
for (cx, cy) in [(minx, miny), (maxx, miny), (maxx, maxy), (minx, maxy)] {
let s = world_to_screen(Vec3::new(cx, cy, 0.0), view_proj, bounds);
sx0 = sx0.min(s.x);
sx1 = sx1.max(s.x);
sy0 = sy0.min(s.y);
sy1 = sy1.max(s.y);
}
let t = CLICK_THRESHOLD_PX;
if cursor.x < sx0 - t || cursor.x > sx1 + t || cursor.y < sy0 - t || cursor.y > sy1 + t
{
continue;
}
}
let mut prev: Option<Point> = None;
for &[px, py, pz] in &wire.points {
if px.is_nan() {
prev = None;
continue;
}
let cur = world_to_screen(Vec3::new(px, py, pz), view_proj, bounds);
if let Some(p0) = prev {
let d = dist_point_to_segment(cursor, p0, cur);
if d < best_dist {
best_dist = d;
best = Some(&wire.name);
}
}
prev = Some(cur);
}
}
best
}
/// Like `click_hit` but returns every wire within the click threshold,
/// nearest first. Used by selection cycling to step through overlapping
/// objects under the cursor.
pub fn click_hits_all<'a>(
cursor: Point,
wires: &'a [WireModel],
view_proj: Mat4,
bounds: Rectangle,
) -> Vec<&'a str> {
let mut hits: Vec<(f32, &str)> = Vec::new();
for wire in wires {
let mut prev: Option<Point> = None;
let mut best_for_wire = CLICK_THRESHOLD_PX;
let mut hit = false;
for &[px, py, pz] in &wire.points {
if px.is_nan() {
prev = None;
continue;
}
let cur = world_to_screen(Vec3::new(px, py, pz), view_proj, bounds);
if let Some(p0) = prev {
let d = dist_point_to_segment(cursor, p0, cur);
if d < best_for_wire {
best_for_wire = d;
hit = true;
}
}
prev = Some(cur);
}
if hit {
hits.push((best_for_wire, &wire.name));
}
}
hits.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
hits.into_iter().map(|(_, name)| name).collect()
}
/// Pick a 3D solid by clicking anywhere on its shaded body: project each
/// mesh triangle to screen space and test whether the cursor lands inside it.
/// Returns the front-most hit (smallest projected depth). Lets meshed solids
/// (whose only wire geometry is thin edges) be selected on their faces.
pub fn mesh_click_hit<'a>(
cursor: Point,
meshes: impl Iterator<Item = (Handle, &'a MeshModel)>,
view_proj: Mat4,
bounds: Rectangle,
) -> Option<Handle> {
let mut best: Option<(f32, Handle)> = None;
for (handle, mesh) in meshes {
let v = &mesh.verts;
let idx = &mesh.indices;
let mut t = 0;
while t + 2 < idx.len() {
let tri = [
v[idx[t] as usize],
v[idx[t + 1] as usize],
v[idx[t + 2] as usize],
];
t += 3;
let mut sp = [Point::ORIGIN; 3];
let mut depth = 0.0f32;
for (j, w) in tri.iter().enumerate() {
let ndc = view_proj.project_point3(Vec3::new(w[0], w[1], w[2]));
sp[j] = Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
);
depth += ndc.z;
}
if point_in_polygon(cursor, &sp) {
let d = depth / 3.0;
if best.map_or(true, |(bd, _)| d < bd) {
best = Some((d, handle));
}
break; // one hit per mesh is enough
}
}
}
best.map(|(_, h)| h)
}
// ── Box / window selection ────────────────────────────────────────────────
/// Return the names of wires selected by a completed rectangular selection box.
///
/// - **Window mode** (`crossing = false`, left→right drag):
/// ALL projected points must lie inside the box.
/// - **Crossing mode** (`crossing = true`, right→left drag):
/// ANY projected point inside the box, OR any wire segment crosses the box
/// boundary (so large entities like viewport frames are caught even when
/// no corner falls inside the selection rectangle).
pub fn box_hit<'a>(
corner_a: Point,
corner_b: Point,
crossing: bool,
wires: &'a [WireModel],
view_proj: Mat4,
bounds: Rectangle,
) -> Vec<&'a str> {
let min_x = corner_a.x.min(corner_b.x);
let max_x = corner_a.x.max(corner_b.x);
let min_y = corner_a.y.min(corner_b.y);
let max_y = corner_a.y.max(corner_b.y);
// Ignore zero-area boxes.
if (max_x - min_x) < 1.0 || (max_y - min_y) < 1.0 {
return vec![];
}
let inside = |sp: Point| sp.x >= min_x && sp.x <= max_x && sp.y >= min_y && sp.y <= max_y;
// Box corners for segment-intersection tests (crossing mode only).
let box_tl = Point { x: min_x, y: min_y };
let box_tr = Point { x: max_x, y: min_y };
let box_bl = Point { x: min_x, y: max_y };
let box_br = Point { x: max_x, y: max_y };
// Q: lazy projection — accumulate screen points without allocating per-wire Vec.
wires
.iter()
.filter_map(|wire| {
// Fallback: when wire has no line geometry (e.g. greek text emits
// only fill_tris) treat the AABB rectangle as the hit-test shape
// so low-LOD text stays selectable. See #19.
let aabb_pts: Vec<[f32; 3]>;
let pts: &[[f32; 3]] = if !wire.points.is_empty() {
&wire.points
} else if wire.aabb != WireModel::UNBOUNDED_AABB {
let [ax, ay, bx, by] = wire.aabb;
aabb_pts = vec![
[ax, ay, 0.0],
[bx, ay, 0.0],
[bx, by, 0.0],
[ax, by, 0.0],
[ax, ay, 0.0],
];
&aabb_pts
} else {
return None;
};
let mut hit = false;
let mut all_inside = true;
let mut prev: Option<Point> = None;
for &[px, py, pz] in pts {
if px.is_nan() {
prev = None;
continue;
}
let sp = world_to_screen(Vec3::new(px, py, pz), view_proj, bounds);
if crossing {
if inside(sp) {
hit = true;
}
if let Some(p0) = prev {
if !hit {
hit = segments_intersect(p0, sp, box_tl, box_tr)
|| segments_intersect(p0, sp, box_tr, box_br)
|| segments_intersect(p0, sp, box_br, box_bl)
|| segments_intersect(p0, sp, box_bl, box_tl);
}
}
} else {
if !inside(sp) {
all_inside = false;
}
}
prev = Some(sp);
}
let result = if crossing {
hit
} else {
all_inside && prev.is_some()
};
if result {
Some(wire.name.as_str())
} else {
None
}
})
.collect()
}
// ── Polygon / lasso selection ─────────────────────────────────────────────
/// Return the names of wires selected by a freehand polygon lasso.
///
/// - **Window mode** (`crossing = false`): ALL projected points inside polygon.
/// - **Crossing mode** (`crossing = true`): ANY point inside OR any wire
/// segment crosses a polygon edge.
pub fn poly_hit<'a>(
poly: &[Point],
crossing: bool,
wires: &'a [WireModel],
view_proj: Mat4,
bounds: Rectangle,
) -> Vec<&'a str> {
if poly.len() < 3 {
return vec![];
}
// Q: lazy projection — no Vec allocation per wire.
wires
.iter()
.filter_map(|wire| {
// Same AABB fallback as `box_hit`: when a wire has no line
// geometry (e.g. greek-LOD text emits only fill_tris) treat the
// AABB rectangle as the hit-test shape so low-LOD text stays
// selectable. See #19.
let aabb_pts: Vec<[f32; 3]>;
let pts: &[[f32; 3]] = if !wire.points.is_empty() {
&wire.points
} else if wire.aabb != WireModel::UNBOUNDED_AABB {
let [ax, ay, bx, by] = wire.aabb;
aabb_pts = vec![
[ax, ay, 0.0],
[bx, ay, 0.0],
[bx, by, 0.0],
[ax, by, 0.0],
[ax, ay, 0.0],
];
&aabb_pts
} else {
return None;
};
let mut hit = false;
let mut all_inside = true;
let mut prev: Option<Point> = None;
for &[px, py, pz] in pts {
if px.is_nan() {
prev = None;
continue;
}
let sp = world_to_screen(Vec3::new(px, py, pz), view_proj, bounds);
if crossing {
if point_in_polygon(sp, poly) {
hit = true;
}
if !hit {
if let Some(p0) = prev {
if segment_crosses_polygon(p0, sp, poly) {
hit = true;
}
}
}
} else {
if !point_in_polygon(sp, poly) {
all_inside = false;
}
}
prev = Some(sp);
}
let result = if crossing {
hit
} else {
all_inside && prev.is_some()
};
if result {
Some(wire.name.as_str())
} else {
None
}
})
.collect()
}
// ── Helpers ───────────────────────────────────────────────────────────────
fn world_to_screen(world: Vec3, view_proj: Mat4, bounds: Rectangle) -> Point {
let ndc = view_proj.project_point3(world);
Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
)
}
/// Even-odd ray-casting test: is `p` inside the polygon?
///
/// Handles multi-path boundaries: NaN points (used as path separators by
/// hatches with islands / holes) reset the previous-vertex tracking so
/// that the ray-cast doesn't draw a spurious closing edge between the
/// end of one sub-path and the start of the next. Each sub-path with at
/// least 2 finite vertices contributes its segments to the parity flip.
fn point_in_polygon(p: Point, poly: &[Point]) -> bool {
// Ray-cast crossing test for a single edge a→b.
fn cross(p: Point, a: Point, b: Point, inside: &mut bool) {
if (a.y > p.y) != (b.y > p.y)
&& p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x
{
*inside = !*inside;
}
}
let mut inside = false;
let mut prev: Option<Point> = None;
let mut path_start: Option<Point> = None;
// Vertices in the current sub-path. A boundary can be encoded either as a
// ring (`[v0,v1,v2,v3]`, needs an implicit closing edge) or as an explicit
// edge list (`[v0,v1, NaN, v1,v2, NaN, …]`, already closed). Only close a
// sub-path that is a real ring (≥3 verts); closing a 2-point explicit edge
// would add a degenerate back-edge that cancels its own crossing.
let mut count = 0usize;
let close = |prev: Option<Point>, path_start: Option<Point>, count: usize, inside: &mut bool| {
if count >= 3 {
if let (Some(pv), Some(sv)) = (prev, path_start) {
cross(p, pv, sv, inside);
}
}
};
for &pt in poly {
if !pt.x.is_finite() || !pt.y.is_finite() {
close(prev, path_start, count, &mut inside);
prev = None;
path_start = None;
count = 0;
continue;
}
if let Some(prev_v) = prev {
cross(p, prev_v, pt, &mut inside);
} else {
path_start = Some(pt);
}
prev = Some(pt);
count += 1;
}
close(prev, path_start, count, &mut inside);
inside
}
/// Does segment `[a, b]` cross any edge of the polygon?
fn segment_crosses_polygon(a: Point, b: Point, poly: &[Point]) -> bool {
let n = poly.len();
for i in 0..n {
let c = poly[i];
let d = poly[(i + 1) % n];
if segments_intersect(a, b, c, d) {
return true;
}
}
false
}
/// Do segments `[a,b]` and `[c,d]` intersect?
fn segments_intersect(a: Point, b: Point, c: Point, d: Point) -> bool {
let cross = |o: Point, p: Point, q: Point| -> f32 {
(p.x - o.x) * (q.y - o.y) - (p.y - o.y) * (q.x - o.x)
};
let d1 = cross(c, d, a);
let d2 = cross(c, d, b);
let d3 = cross(a, b, c);
let d4 = cross(a, b, d);
if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0))
&& ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0))
{
return true;
}
false
}
// ── Hatch hit-testing ─────────────────────────────────────────────────────
/// Return the Handle of the first hatch whose screen-space boundary polygon
/// contains `cursor`.
pub fn click_hit_hatch(
cursor: Point,
hatches: &HashMap<Handle, HatchModel>,
view_proj: Mat4,
bounds: Rectangle,
) -> Option<Handle> {
for (&handle, hatch) in hatches {
if hatch_contains_screen_point(hatch, cursor, view_proj, bounds) {
return Some(handle);
}
}
None
}
/// Same as `click_hit_hatch` but iterates `(Handle, HatchModel)` pairs
/// where the Handle is the parent Insert handle (block-internal
/// hatches). The first matching pair returns its Insert handle so
/// clicking a sub-hatch of a block selects the Insert, matching
/// AutoCAD's behaviour for block sub-entities.
pub fn click_hit_insert_hatch(
cursor: Point,
insert_hatches: &[(Handle, HatchModel)],
view_proj: Mat4,
bounds: Rectangle,
) -> Option<Handle> {
for (handle, hatch) in insert_hatches {
if hatch_contains_screen_point(hatch, cursor, view_proj, bounds) {
return Some(*handle);
}
}
None
}
fn hatch_contains_screen_point(
hatch: &HatchModel,
cursor: Point,
view_proj: Mat4,
bounds: Rectangle,
) -> bool {
// boundary verts are stored as small f32 offsets from
// `world_origin` (f64). Reconstruct offset-rel WCS before
// projecting to screen.
let ox = hatch.world_origin[0] as f32;
let oy = hatch.world_origin[1] as f32;
let screen: Vec<Point> = hatch
.boundary
.iter()
.map(|&[x, y]| {
if x.is_finite() && y.is_finite() {
world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_proj, bounds)
} else {
// Preserve path separators for the NaN-aware
// point_in_polygon ray-cast.
Point::new(f32::NAN, f32::NAN)
}
})
.collect();
screen.len() >= 3 && point_in_polygon(cursor, &screen)
}
/// Return Handles of hatches selected by a completed rectangular selection box.
pub fn box_hit_hatch(
corner_a: Point,
corner_b: Point,
crossing: bool,
hatches: &HashMap<Handle, HatchModel>,
view_proj: Mat4,
bounds: Rectangle,
) -> Vec<Handle> {
let min_x = corner_a.x.min(corner_b.x);
let max_x = corner_a.x.max(corner_b.x);
let min_y = corner_a.y.min(corner_b.y);
let max_y = corner_a.y.max(corner_b.y);
if (max_x - min_x) < 1.0 || (max_y - min_y) < 1.0 {
return vec![];
}
let inside = |sp: Point| sp.x >= min_x && sp.x <= max_x && sp.y >= min_y && sp.y <= max_y;
hatches
.iter()
.filter_map(|(&handle, hatch)| {
if hatch.boundary.is_empty() {
return None;
}
let ox = hatch.world_origin[0] as f32;
let oy = hatch.world_origin[1] as f32;
let screen: Vec<Point> = hatch
.boundary
.iter()
.map(|&[x, y]| world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_proj, bounds))
.collect();
let hit = if crossing {
screen.iter().any(|&sp| inside(sp))
} else {
screen.iter().all(|&sp| inside(sp))
};
if hit {
Some(handle)
} else {
None
}
})
.collect()
}
/// Return Handles of hatches selected by a freehand polygon lasso.
pub fn poly_hit_hatch(
poly: &[Point],
crossing: bool,
hatches: &HashMap<Handle, HatchModel>,
view_proj: Mat4,
bounds: Rectangle,
) -> Vec<Handle> {
if poly.len() < 3 {
return vec![];
}
hatches
.iter()
.filter_map(|(&handle, hatch)| {
if hatch.boundary.is_empty() {
return None;
}
let ox = hatch.world_origin[0] as f32;
let oy = hatch.world_origin[1] as f32;
let screen: Vec<Point> = hatch
.boundary
.iter()
.map(|&[x, y]| world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_proj, bounds))
.collect();
let hit = if crossing {
screen.iter().any(|&sp| point_in_polygon(sp, poly))
|| screen
.windows(2)
.any(|seg| segment_crosses_polygon(seg[0], seg[1], poly))
} else {
screen.iter().all(|&sp| point_in_polygon(sp, poly))
};
if hit {
Some(handle)
} else {
None
}
})
.collect()
}
/// Minimum distance from point `p` to line segment `[a, b]` in 2-D.
fn dist_point_to_segment(p: Point, a: Point, b: Point) -> f32 {
let abx = b.x - a.x;
let aby = b.y - a.y;
let len2 = abx * abx + aby * aby;
let t = if len2 < 1e-6 {
0.0
} else {
let apx = p.x - a.x;
let apy = p.y - a.y;
((apx * abx + apy * aby) / len2).clamp(0.0, 1.0)
};
let cx = a.x + t * abx;
let cy = a.y + t * aby;
let dx = p.x - cx;
let dy = p.y - cy;
(dx * dx + dy * dy).sqrt()
}
#[cfg(test)]
mod aabb_reject_tests {
use super::*;
fn wire(name: &str, pts: Vec<[f32; 3]>, aabb: [f32; 4]) -> WireModel {
let mut w = WireModel::solid(name.to_string(), pts, [1.0; 4], false);
w.aabb = aabb;
w
}
// Identity ortho view: world (x,y) → screen ((x+1)*100, (1-y)*100) for a
// 200×200 viewport. The view is flat (z_axis.xy == 0) so the AABB pre-reject
// is active — these tests guard it against false negatives.
#[test]
fn aabb_reject_keeps_near_wire_drops_far() {
let vp = Mat4::IDENTITY;
let bounds = Rectangle { x: 0.0, y: 0.0, width: 200.0, height: 200.0 };
let cursor = Point::new(100.0, 100.0); // world origin
let near = wire("5", vec![[-0.02, 0.0, 0.0], [0.02, 0.0, 0.0]], [-0.02, 0.0, 0.02, 0.0]);
let far = wire("9", vec![[0.9, 0.9, 0.0], [0.95, 0.9, 0.0]], [0.9, 0.9, 0.95, 0.9]);
assert_eq!(click_hit(cursor, std::slice::from_ref(&near), vp, bounds), Some("5"));
assert_eq!(click_hit(cursor, std::slice::from_ref(&far), vp, bounds), None);
// The far wire must be rejected without hiding the near one.
assert_eq!(click_hit(cursor, &[far, near], vp, bounds), Some("5"));
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod hit_test;
pub mod selection;
pub mod grip;
pub mod quadtree;
pub mod xclip;
+394
View File
@@ -0,0 +1,394 @@
// Spatial index for 2D entities. Each leaf holds up to `LEAF_CAPACITY`
// items; on overflow the node splits into 4 children and re-distributes.
//
// Items are keyed by `acadrust::Handle`. AABBs are stored in WCS f64
// (NOT world_offset-subtracted) so changing `world_offset` doesn't
// invalidate the index.
//
// API:
// - `QuadTree::new(world_bounds)` — build an empty tree spanning a
// fixed root rect. Items outside the root are clamped: they go into
// the root's overflow list and surface on every query.
// - `insert(handle, aabb)`, `remove(handle)`, `update(handle, aabb)`
// - `query_rect(aabb) -> Vec<Handle>` — returns every handle whose
// stored AABB intersects `aabb`, plus all overflow handles.
// - `len()` / `is_empty()` for sanity.
//
// This is a flat-vector (Vec-of-nodes) implementation: node indices
// are `usize`, child links are `Option<u32>` to keep nodes compact and
// cache-friendly. No rebalancing — once split, nodes don't merge back
// (rare in CAD workflows where most edits are local and few entities
// move long distances).
use acadrust::Handle;
pub type Aabb = [f64; 4]; // [xmin, ymin, xmax, ymax]
/// Items per leaf before it splits. Tuned: too small → deep tree, lots
/// of nodes; too large → poor culling per leaf. 32 is a typical sweet
/// spot for CAD-style content (many small entities in clusters).
const LEAF_CAPACITY: usize = 32;
/// Hard cap on tree depth — prevents pathological recursion when many
/// items pile up at the same point.
const MAX_DEPTH: u8 = 16;
#[derive(Debug, Clone, Copy)]
struct Item {
handle: Handle,
aabb: Aabb,
}
#[derive(Debug)]
struct Node {
bounds: Aabb,
/// Items stored at this node. Only populated when the node is a
/// leaf, OR when a child split would put a straddling item at the
/// parent level (straddlers stay at the current node — they don't
/// fit cleanly into one quadrant).
items: Vec<Item>,
/// 4 children: NW, NE, SW, SE. `None` until the node splits.
children: Option<[u32; 4]>,
depth: u8,
}
#[derive(Debug)]
pub struct QuadTree {
nodes: Vec<Node>,
/// `handle → (node_idx, item_idx_within_node)` so removal/update
/// is O(1) without walking the tree.
locator: rustc_hash::FxHashMap<Handle, (u32, u32)>,
/// Items whose AABB falls outside the root bounds. Surfaced on
/// every query — small set in practice (typically empty for
/// well-bounded drawings).
overflow: Vec<Item>,
}
impl QuadTree {
pub fn new(world_bounds: Aabb) -> Self {
let root = Node {
bounds: world_bounds,
items: Vec::new(),
children: None,
depth: 0,
};
Self {
nodes: vec![root],
locator: rustc_hash::FxHashMap::default(),
overflow: Vec::new(),
}
}
// `len` / `is_empty` / `remove` / `update` are part of the public
// quadtree API and exercised by the unit tests, but production
// callers haven't landed yet (Scene::add/erase/transform paths
// still trigger an epoch-based full rebuild rather than mutating
// the index in place). The `#[allow(dead_code)]` is intentional —
// dropping them would shrink the public surface only to put it
// back once incremental updates ship.
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.locator.len() + self.overflow.len()
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn insert(&mut self, handle: Handle, aabb: Aabb) {
if !aabb_contained(self.nodes[0].bounds, aabb) {
self.overflow.push(Item { handle, aabb });
return;
}
let node_idx = self.descend_to_leaf(0, aabb);
let item_idx = self.nodes[node_idx as usize].items.len() as u32;
self.nodes[node_idx as usize]
.items
.push(Item { handle, aabb });
self.locator.insert(handle, (node_idx, item_idx));
// Split only LEAF nodes. Internal nodes accumulate straddlers
// (items whose AABB crosses a child boundary); calling split on
// an internal node would overwrite its `children` pointer and
// orphan the entire existing subtree. Straddlers can't fit
// smaller children anyway, so splitting is futile here.
if self.nodes[node_idx as usize].items.len() > LEAF_CAPACITY
&& self.nodes[node_idx as usize].depth < MAX_DEPTH
&& self.nodes[node_idx as usize].children.is_none()
{
self.split(node_idx);
}
}
#[allow(dead_code)]
pub fn remove(&mut self, handle: Handle) -> bool {
if let Some((node_idx, item_idx)) = self.locator.remove(&handle) {
let node = &mut self.nodes[node_idx as usize];
// swap_remove: re-locate whoever took the moved item's slot.
node.items.swap_remove(item_idx as usize);
if let Some(moved) = node.items.get(item_idx as usize) {
self.locator.insert(moved.handle, (node_idx, item_idx));
}
return true;
}
// Try overflow.
if let Some(pos) = self.overflow.iter().position(|i| i.handle == handle) {
self.overflow.swap_remove(pos);
return true;
}
false
}
#[allow(dead_code)]
pub fn update(&mut self, handle: Handle, aabb: Aabb) {
self.remove(handle);
self.insert(handle, aabb);
}
pub fn query_rect(&self, query: Aabb) -> Vec<Handle> {
let mut out = Vec::new();
// Overflow always surfaces — these items have no usable spatial
// info (out-of-root) so we can't cull them spatially.
for i in &self.overflow {
if aabb_intersects(i.aabb, query) {
out.push(i.handle);
}
}
self.query_node(0, query, &mut out);
out
}
// ── internal helpers ────────────────────────────────────────────
fn descend_to_leaf(&self, mut node_idx: u32, aabb: Aabb) -> u32 {
loop {
let node = &self.nodes[node_idx as usize];
let Some(children) = node.children else {
return node_idx;
};
// Pick the child that fully contains `aabb`; if `aabb`
// straddles a split boundary, stash it at the current node.
let mut fits_child: Option<u32> = None;
for &c in &children {
if aabb_contained(self.nodes[c as usize].bounds, aabb) {
fits_child = Some(c);
break;
}
}
match fits_child {
Some(c) => node_idx = c,
None => return node_idx,
}
}
}
fn split(&mut self, node_idx: u32) {
let bounds = self.nodes[node_idx as usize].bounds;
let depth = self.nodes[node_idx as usize].depth + 1;
let [xmin, ymin, xmax, ymax] = bounds;
let mx = (xmin + xmax) * 0.5;
let my = (ymin + ymax) * 0.5;
// NW, NE, SW, SE
let child_bounds = [
[xmin, my, mx, ymax],
[mx, my, xmax, ymax],
[xmin, ymin, mx, my],
[mx, ymin, xmax, my],
];
let first_child = self.nodes.len() as u32;
for &b in &child_bounds {
self.nodes.push(Node {
bounds: b,
items: Vec::new(),
children: None,
depth,
});
}
let children = [
first_child,
first_child + 1,
first_child + 2,
first_child + 3,
];
self.nodes[node_idx as usize].children = Some(children);
// Redistribute existing items. Straddlers stay at the parent.
let existing = std::mem::take(&mut self.nodes[node_idx as usize].items);
let mut keep_at_parent = Vec::new();
for item in existing {
let mut placed = false;
for &c in &children {
if aabb_contained(self.nodes[c as usize].bounds, item.aabb) {
let idx = self.nodes[c as usize].items.len() as u32;
self.nodes[c as usize].items.push(item);
self.locator.insert(item.handle, (c, idx));
placed = true;
break;
}
}
if !placed {
let idx = keep_at_parent.len() as u32;
self.locator.insert(item.handle, (node_idx, idx));
keep_at_parent.push(item);
}
}
self.nodes[node_idx as usize].items = keep_at_parent;
}
fn query_node(&self, node_idx: u32, query: Aabb, out: &mut Vec<Handle>) {
let node = &self.nodes[node_idx as usize];
if !aabb_intersects(node.bounds, query) {
return;
}
for i in &node.items {
if aabb_intersects(i.aabb, query) {
out.push(i.handle);
}
}
if let Some(children) = node.children {
for c in children {
self.query_node(c, query, out);
}
}
}
}
// ── AABB helpers ────────────────────────────────────────────────────
fn aabb_contained(outer: Aabb, inner: Aabb) -> bool {
inner[0] >= outer[0]
&& inner[1] >= outer[1]
&& inner[2] <= outer[2]
&& inner[3] <= outer[3]
}
fn aabb_intersects(a: Aabb, b: Aabb) -> bool {
a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1]
}
#[cfg(test)]
mod tests {
use super::*;
fn h(v: u64) -> Handle {
Handle::from(v)
}
#[test]
fn empty_tree_returns_nothing() {
let t = QuadTree::new([0.0, 0.0, 100.0, 100.0]);
assert!(t.is_empty());
assert!(t.query_rect([0.0, 0.0, 100.0, 100.0]).is_empty());
}
#[test]
fn insert_and_query_hit() {
let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]);
t.insert(h(1), [10.0, 10.0, 20.0, 20.0]);
let hits = t.query_rect([0.0, 0.0, 50.0, 50.0]);
assert_eq!(hits, vec![h(1)]);
}
#[test]
fn query_miss() {
let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]);
t.insert(h(1), [10.0, 10.0, 20.0, 20.0]);
let hits = t.query_rect([50.0, 50.0, 90.0, 90.0]);
assert!(hits.is_empty());
}
#[test]
fn split_on_overflow() {
let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]);
// Push beyond LEAF_CAPACITY (=32) into different quadrants
// so the split actually distributes them.
for i in 0..40u64 {
let x = (i as f64) * 0.5;
let y = (i as f64) * 0.5;
t.insert(h(i), [x, y, x + 0.1, y + 0.1]);
}
assert_eq!(t.len(), 40);
// The root should have split — there are children now.
assert!(t.nodes.len() > 1);
// All items recoverable.
let hits = t.query_rect([0.0, 0.0, 100.0, 100.0]);
assert_eq!(hits.len(), 40);
}
#[test]
fn remove_works() {
let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]);
t.insert(h(1), [10.0, 10.0, 20.0, 20.0]);
t.insert(h(2), [30.0, 30.0, 40.0, 40.0]);
assert!(t.remove(h(1)));
assert!(!t.remove(h(1))); // already gone
let hits = t.query_rect([0.0, 0.0, 100.0, 100.0]);
assert_eq!(hits, vec![h(2)]);
}
#[test]
fn update_moves_item() {
let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]);
t.insert(h(1), [10.0, 10.0, 20.0, 20.0]);
t.update(h(1), [70.0, 70.0, 80.0, 80.0]);
let lo = t.query_rect([0.0, 0.0, 50.0, 50.0]);
let hi = t.query_rect([60.0, 60.0, 90.0, 90.0]);
assert!(lo.is_empty());
assert_eq!(hi, vec![h(1)]);
}
#[test]
fn out_of_bounds_goes_to_overflow() {
let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]);
// Way outside root.
t.insert(h(1), [1000.0, 1000.0, 1010.0, 1010.0]);
// Query a region that doesn't touch the overflow item — but
// overflow always surfaces only if its AABB intersects.
let near = t.query_rect([0.0, 0.0, 50.0, 50.0]);
assert!(near.is_empty());
// Query that does intersect the overflow item.
let far = t.query_rect([900.0, 900.0, 1100.0, 1100.0]);
assert_eq!(far, vec![h(1)]);
}
#[test]
fn many_straddlers_dont_orphan_subtree() {
// Regression: previously, when straddlers piled up at an
// internal node and exceeded LEAF_CAPACITY, split() was called
// again — overwriting the node's `children` and orphaning every
// descendant. Symptom: thousands of items inserted, only ~5%
// reachable from root via a huge_query.
let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]);
// First, fill one leaf so it splits.
for i in 0..40u64 {
t.insert(h(i), [1.0, 1.0, 2.0, 2.0]);
}
// Now flood the root with straddlers (AABBs that cross center).
for i in 100..200u64 {
t.insert(h(i), [10.0, 10.0, 90.0, 90.0]);
}
// After the regression-trigger inserts, every handle must still
// come back from a huge query.
let huge = [-1e9, -1e9, 1e9, 1e9];
let hits = t.query_rect(huge);
let expected = 40 + 100;
assert_eq!(hits.len(), expected, "lost items: tree.len={} hits.len={}", t.len(), hits.len());
}
#[test]
fn straddler_stays_at_parent() {
let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]);
// Force a split via 40 small items in NW quadrant.
for i in 0..40u64 {
t.insert(h(i + 100), [1.0, 1.0, 2.0, 2.0]);
}
// A straddler crossing center.
t.insert(h(1), [40.0, 40.0, 60.0, 60.0]);
// Querying any quadrant should still find the straddler.
let nw_hits = t.query_rect([0.0, 50.0, 50.0, 100.0]);
assert!(nw_hits.contains(&h(1)));
let se_hits = t.query_rect([50.0, 0.0, 100.0, 50.0]);
assert!(se_hits.contains(&h(1)));
}
}
+32
View File
@@ -0,0 +1,32 @@
use iced::time::Instant;
use iced::Point;
/// Mouse / selection interaction state for the viewport.
#[derive(Clone, Default)]
pub struct SelectionState {
pub vp_size: (f32, f32),
pub box_anchor: Option<Point>,
pub box_current: Option<Point>,
pub box_last: Option<(Point, Point)>,
pub box_crossing: bool,
pub box_last_crossing: bool,
pub poly_active: bool,
pub poly_points: Vec<Point>,
pub poly_crossing: bool,
pub poly_last_crossing: bool,
pub context_menu: Option<Point>,
/// True while the context menu's Draw Order sub-items are expanded.
pub draworder_submenu: bool,
pub last_move_pos: Option<Point>,
pub left_down: bool,
pub left_press_pos: Option<Point>,
pub left_press_time: Option<Instant>,
pub left_dragging: bool,
pub right_down: bool,
pub right_press_pos: Option<Point>,
pub right_dragging: bool,
pub right_last_pos: Option<Point>,
pub middle_down: bool,
pub middle_last_pos: Option<Point>,
pub middle_last_press_time: Option<Instant>,
}
+605
View File
@@ -0,0 +1,605 @@
//! XCLIP clip-boundary application for block references.
//!
//! A block reference (INSERT) may carry an `AcDbSpatialFilter` under its
//! extension dictionary (`ACAD_FILTER` → `SPATIAL`). When present and enabled,
//! only the portion of the block's geometry inside the boundary polygon is
//! drawn. This module resolves the filter, builds the world-space boundary
//! polygon, and clips the expanded block [`WireModel`]s to it.
//!
//! Geometry is assumed planar in the boundary's +Z plane — the standard XCLIP
//! case. The clip is performed in 2D (XY) after the INSERT transform has been
//! applied, matching the space the block wires are already emitted in.
use acadrust::entities::Insert;
use acadrust::objects::{ObjectType, SpatialFilter};
use acadrust::types::{Handle, Vector3};
use acadrust::CadDocument;
use crate::scene::model::wire_model::WireModel;
const NAN3: [f32; 3] = [f32::NAN, f32::NAN, f32::NAN];
/// Resolve the enabled XCLIP spatial filter for `ins`, if any.
///
/// Walks the INSERT's extension dictionary: `xdictionary → ACAD_FILTER
/// (dictionary) → SPATIAL (SpatialFilter)`. Returns `None` when there is no
/// filter, it is disabled (code 71 = 0), or it has fewer than two boundary
/// points.
pub fn insert_spatial_filter<'a>(
doc: &'a CadDocument,
ins: &Insert,
) -> Option<&'a SpatialFilter> {
let xdict = ins.common.xdictionary_handle?;
let acad_filter = dict_entry(doc, xdict, "ACAD_FILTER")?;
let spatial = dict_entry(doc, acad_filter, "SPATIAL")?;
match doc.objects.get(&spatial)? {
ObjectType::SpatialFilter(sf)
if sf.display_enabled && sf.boundary_points.len() >= 2 =>
{
Some(sf)
}
_ => None,
}
}
fn dict_entry(doc: &CadDocument, dict: Handle, key: &str) -> Option<Handle> {
match doc.objects.get(&dict)? {
ObjectType::Dictionary(d) => {
d.entries.iter().find(|(k, _)| k == key).map(|(_, h)| *h)
}
_ => None,
}
}
/// Build the clip boundary as a closed world-space ring in the same f32 XY
/// space as the emitted wires (i.e. with `world_offset` already subtracted).
///
/// Boundary points are stored in the clip-definition coordinate system. The
/// `inverse_block_transform` maps them into the block's coordinate system, then
/// the INSERT transform maps that to world — `world = T_insert · (M⁻¹ · vert)`.
/// (For a clip made against the current insert, `M⁻¹` is the insert's own
/// inverse and the two transforms cancel, leaving the vertices in WCS; when the
/// insert was later rescaled the stored `M⁻¹` still places the clip correctly.)
/// Two points describe a rectangle (opposite corners); three or more an
/// explicit polygon.
pub fn world_clip_polygon(
sf: &SpatialFilter,
ins: &Insert,
world_offset: [f64; 3],
) -> Vec<[f32; 2]> {
let xform = ins.get_transform();
let inv_block = &sf.inverse_block_transform;
let [ox, oy, _] = world_offset;
let local: Vec<[f64; 2]> = if sf.boundary_points.len() == 2 {
let a = sf.boundary_points[0];
let b = sf.boundary_points[1];
vec![[a.x, a.y], [b.x, a.y], [b.x, b.y], [a.x, b.y]]
} else {
sf.boundary_points.iter().map(|p| [p.x, p.y]).collect()
};
local
.into_iter()
.map(|[x, y]| {
let block = inv_block.transform_point(Vector3::new(x, y, 0.0));
let w = xform.apply(block);
[(w.x - ox) as f32, (w.y - oy) as f32]
})
.collect()
}
/// Clip every wire in `wires` to the boundary `poly` (a closed ring in f32 XY,
/// world_offset-subtracted). Polylines are split into NaN-separated inside
/// runs; fill triangles are clipped against the polygon; snap / key vertices
/// outside the boundary are dropped. Wires left with no geometry are removed.
pub fn clip_wires(wires: &mut Vec<WireModel>, poly: &[[f32; 2]]) {
if poly.len() < 3 {
return;
}
for w in wires.iter_mut() {
if !w.points.is_empty() {
w.points = clip_polyline(&w.points, poly);
}
if !w.fill_tris.is_empty() {
w.fill_tris = clip_triangles(&w.fill_tris, poly);
}
w.key_vertices
.retain(|v| point_in_poly(v[0], v[1], poly));
w.snap_pts.retain(|(p, _)| point_in_poly(p.x, p.y, poly));
w.aabb = recompute_aabb(&w.points, &w.fill_tris);
}
wires.retain(|w| !w.points.is_empty() || !w.fill_tris.is_empty());
}
/// Clip a hatch fill boundary to `poly`.
///
/// `boundary` is a hatch's NaN-separated loops in f32 offsets from
/// `world_origin` (the [`HatchModel`](crate::scene::model::hatch_model::HatchModel)
/// representation); `poly` is the clip ring in the same world space the hatch
/// occupies. Each loop is intersected with the clip polygon independently —
/// the even-odd island structure is preserved because intersecting every loop
/// with the same region distributes over the even-odd fill. Returns the
/// clipped loops in offsets from `world_origin`; empty if nothing survives.
pub fn clip_hatch_boundary(
boundary: &[[f32; 2]],
world_origin: [f64; 2],
poly: &[[f32; 2]],
) -> Vec<[f32; 2]> {
if poly.len() < 3 {
return boundary.to_vec();
}
let (ox, oy) = (world_origin[0] as f32, world_origin[1] as f32);
let mut out: Vec<[f32; 2]> = Vec::new();
let mut i = 0;
while i < boundary.len() {
if !boundary[i][0].is_finite() || !boundary[i][1].is_finite() {
i += 1;
continue;
}
let start = i;
while i < boundary.len()
&& boundary[i][0].is_finite()
&& boundary[i][1].is_finite()
{
i += 1;
}
// Lift the loop into the clip polygon's world space.
let loop_abs: Vec<[f32; 2]> = boundary[start..i]
.iter()
.map(|p| [p[0] + ox, p[1] + oy])
.collect();
let clipped = clip_polygon_2d(&loop_abs, poly);
if clipped.len() >= 3 {
if !out.is_empty() {
out.push([f32::NAN, f32::NAN]);
}
for p in clipped {
out.push([p[0] - ox, p[1] - oy]);
}
}
}
out
}
/// Sutherland–Hodgman clip of a closed 2D polygon `subject` against the convex
/// polygon `clip`.
fn clip_polygon_2d(subject: &[[f32; 2]], clip: &[[f32; 2]]) -> Vec<[f32; 2]> {
let n = clip.len();
let mut area2 = 0.0f32;
let mut j = n - 1;
for i in 0..n {
area2 += clip[j][0] * clip[i][1] - clip[i][0] * clip[j][1];
j = i;
}
let ccw = area2 > 0.0;
let mut output: Vec<[f32; 2]> = subject.to_vec();
let mut j = n - 1;
for i in 0..n {
if output.is_empty() {
break;
}
let (a, b) = (clip[j], clip[i]);
j = i;
let inside = |p: &[f32; 2]| {
let cr = (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
if ccw {
cr >= 0.0
} else {
cr <= 0.0
}
};
let input = std::mem::take(&mut output);
let len = input.len();
for k in 0..len {
let cur = input[k];
let prev = input[(k + len - 1) % len];
let cur_in = inside(&cur);
let prev_in = inside(&prev);
if cur_in {
if !prev_in {
output.push(line_cross_2d(prev, cur, a, b));
}
output.push(cur);
} else if prev_in {
output.push(line_cross_2d(prev, cur, a, b));
}
}
}
output
}
fn line_cross_2d(p0: [f32; 2], p1: [f32; 2], a: [f32; 2], b: [f32; 2]) -> [f32; 2] {
let r = (p1[0] - p0[0], p1[1] - p0[1]);
let s = (b[0] - a[0], b[1] - a[1]);
let denom = r.0 * s.1 - r.1 * s.0;
let t = if denom.abs() < 1e-12 {
0.0
} else {
((a[0] - p0[0]) * s.1 - (a[1] - p0[1]) * s.0) / denom
};
[p0[0] + r.0 * t, p0[1] + r.1 * t]
}
/// Ray-cast point-in-polygon test for a closed ring.
fn point_in_poly(x: f32, y: f32, poly: &[[f32; 2]]) -> bool {
let mut inside = false;
let n = poly.len();
let mut j = n - 1;
for i in 0..n {
let (xi, yi) = (poly[i][0], poly[i][1]);
let (xj, yj) = (poly[j][0], poly[j][1]);
if (yi > y) != (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi {
inside = !inside;
}
j = i;
}
inside
}
/// Clip a NaN-separated polyline to `poly`, returning a NaN-separated polyline
/// of only the inside portions.
fn clip_polyline(pts: &[[f32; 3]], poly: &[[f32; 2]]) -> Vec<[f32; 3]> {
let mut out: Vec<[f32; 3]> = Vec::new();
let mut i = 0;
while i < pts.len() {
if !pts[i][0].is_finite() || !pts[i][1].is_finite() {
i += 1;
continue;
}
let start = i;
while i < pts.len() && pts[i][0].is_finite() && pts[i][1].is_finite() {
i += 1;
}
let seg = &pts[start..i];
let mut last: Option<[f32; 3]> = None;
for j in 0..seg.len().saturating_sub(1) {
for (a, b) in clip_segment(seg[j], seg[j + 1], poly) {
let contiguous = last.is_some_and(|l| {
(l[0] - a[0]).abs() <= 1e-4 && (l[1] - a[1]).abs() <= 1e-4
});
if !contiguous {
if !out.is_empty() {
out.push(NAN3);
}
out.push(a);
}
out.push(b);
last = Some(b);
}
}
}
out
}
/// Return the inside-the-polygon sub-segments of `p0`→`p1` as endpoint pairs.
/// Handles convex and concave boundaries by testing the midpoint of every
/// interval between consecutive boundary crossings.
fn clip_segment(
p0: [f32; 3],
p1: [f32; 3],
poly: &[[f32; 2]],
) -> Vec<([f32; 3], [f32; 3])> {
let mut ts: Vec<f32> = vec![0.0, 1.0];
let n = poly.len();
let mut j = n - 1;
for i in 0..n {
if let Some(t) = seg_cross_t(p0, p1, poly[j], poly[i]) {
if t > 0.0 && t < 1.0 {
ts.push(t);
}
}
j = i;
}
ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-7);
let lerp = |t: f32| {
[
p0[0] + (p1[0] - p0[0]) * t,
p0[1] + (p1[1] - p0[1]) * t,
p0[2] + (p1[2] - p0[2]) * t,
]
};
let mut out = Vec::new();
for w in ts.windows(2) {
let mid = lerp(0.5 * (w[0] + w[1]));
if point_in_poly(mid[0], mid[1], poly) {
out.push((lerp(w[0]), lerp(w[1])));
}
}
out
}
/// Parameter `t` along segment `p0`→`p1` where it crosses the boundary edge
/// `a`→`b`, or `None` if they do not cross within the edge's extent.
fn seg_cross_t(p0: [f32; 3], p1: [f32; 3], a: [f32; 2], b: [f32; 2]) -> Option<f32> {
let r = (p1[0] - p0[0], p1[1] - p0[1]);
let s = (b[0] - a[0], b[1] - a[1]);
let denom = r.0 * s.1 - r.1 * s.0;
if denom.abs() < 1e-12 {
return None;
}
let qp = (a[0] - p0[0], a[1] - p0[1]);
let t = (qp.0 * s.1 - qp.1 * s.0) / denom;
let u = (qp.0 * r.1 - qp.1 * r.0) / denom;
if (0.0..=1.0).contains(&u) {
Some(t)
} else {
None
}
}
/// Clip a flat triangle list against `poly` (Sutherland–Hodgman per triangle,
/// fan-triangulating the clipped convex result). Exact for convex boundaries;
/// approximate for concave ones.
fn clip_triangles(tris: &[[f32; 3]], poly: &[[f32; 2]]) -> Vec<[f32; 3]> {
let mut out = Vec::new();
for tri in tris.chunks_exact(3) {
let clipped = sutherland_hodgman(tri, poly);
for k in 1..clipped.len().saturating_sub(1) {
out.push(clipped[0]);
out.push(clipped[k]);
out.push(clipped[k + 1]);
}
}
out
}
fn sutherland_hodgman(tri: &[[f32; 3]], poly: &[[f32; 2]]) -> Vec<[f32; 3]> {
// Boundary orientation decides which half-plane is "inside".
let mut area2 = 0.0f32;
let n = poly.len();
let mut j = n - 1;
for i in 0..n {
area2 += poly[j][0] * poly[i][1] - poly[i][0] * poly[j][1];
j = i;
}
let ccw = area2 > 0.0;
let mut output: Vec<[f32; 3]> = tri.to_vec();
let mut j = n - 1;
for i in 0..n {
if output.is_empty() {
break;
}
let (a, b) = (poly[j], poly[i]);
j = i;
let inside = |p: &[f32; 3]| {
let cr = (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
if ccw {
cr >= 0.0
} else {
cr <= 0.0
}
};
let input = std::mem::take(&mut output);
let len = input.len();
for k in 0..len {
let cur = input[k];
let prev = input[(k + len - 1) % len];
let cur_in = inside(&cur);
let prev_in = inside(&prev);
if cur_in {
if !prev_in {
output.push(line_cross(prev, cur, a, b));
}
output.push(cur);
} else if prev_in {
output.push(line_cross(prev, cur, a, b));
}
}
}
output
}
/// Intersection of segment `p0`→`p1` with the infinite line through `a`→`b`,
/// interpolating Z.
fn line_cross(p0: [f32; 3], p1: [f32; 3], a: [f32; 2], b: [f32; 2]) -> [f32; 3] {
let r = (p1[0] - p0[0], p1[1] - p0[1]);
let s = (b[0] - a[0], b[1] - a[1]);
let denom = r.0 * s.1 - r.1 * s.0;
let t = if denom.abs() < 1e-12 {
0.0
} else {
((a[0] - p0[0]) * s.1 - (a[1] - p0[1]) * s.0) / denom
};
[
p0[0] + r.0 * t,
p0[1] + r.1 * t,
p0[2] + (p1[2] - p0[2]) * t,
]
}
fn recompute_aabb(points: &[[f32; 3]], tris: &[[f32; 3]]) -> [f32; 4] {
let mut bb = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
let mut any = false;
for p in points.iter().chain(tris.iter()) {
if p[0].is_finite() && p[1].is_finite() {
bb[0] = bb[0].min(p[0]);
bb[1] = bb[1].min(p[1]);
bb[2] = bb[2].max(p[0]);
bb[3] = bb[3].max(p[1]);
any = true;
}
}
if any {
bb
} else {
[0.0; 4]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn square() -> Vec<[f32; 2]> {
vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]
}
#[test]
fn point_in_poly_basic() {
let p = square();
assert!(point_in_poly(5.0, 5.0, &p));
assert!(!point_in_poly(15.0, 5.0, &p));
assert!(!point_in_poly(-1.0, 5.0, &p));
}
#[test]
fn segment_clipped_to_boundary() {
// Horizontal line crossing the square from outside to outside.
let segs = clip_segment([-5.0, 5.0, 0.0], [15.0, 5.0, 0.0], &square());
assert_eq!(segs.len(), 1);
let (a, b) = segs[0];
assert!((a[0] - 0.0).abs() < 1e-3);
assert!((b[0] - 10.0).abs() < 1e-3);
}
#[test]
fn segment_fully_outside_dropped() {
let segs = clip_segment([20.0, 5.0, 0.0], [30.0, 5.0, 0.0], &square());
assert!(segs.is_empty());
}
#[test]
fn polyline_keeps_inside_run() {
// Polyline that dips outside then comes back: expect a NaN break.
let pts = vec![
[5.0, 5.0, 0.0],
[15.0, 5.0, 0.0],
[15.0, 8.0, 0.0],
[5.0, 8.0, 0.0],
];
let out = clip_polyline(&pts, &square());
assert!(out.iter().any(|p| p[0].is_nan()));
assert!(out.iter().all(|p| p[0].is_nan() || p[0] <= 10.0 + 1e-3));
}
#[test]
fn resolves_filter_and_clips_block_geometry() {
use acadrust::objects::Dictionary;
use acadrust::types::Vector2;
// Handles: insert, xdict, acad_filter dict, spatial filter.
let (h_ins, h_xdict, h_filter, h_spatial) = (
Handle::new(0x10),
Handle::new(0x11),
Handle::new(0x12),
Handle::new(0x13),
);
let mut doc = CadDocument::new();
// xdictionary → ACAD_FILTER → SPATIAL chain.
let mut xdict = Dictionary::new();
xdict.handle = h_xdict;
xdict.add_entry("ACAD_FILTER", h_filter);
doc.objects.insert(h_xdict, ObjectType::Dictionary(xdict));
let mut filter_dict = Dictionary::new();
filter_dict.handle = h_filter;
filter_dict.add_entry("SPATIAL", h_spatial);
doc.objects
.insert(h_filter, ObjectType::Dictionary(filter_dict));
let mut sf = SpatialFilter::new();
sf.handle = h_spatial;
sf.display_enabled = true;
sf.boundary_points = vec![Vector2::new(0.0, 0.0), Vector2::new(10.0, 10.0)];
doc.objects.insert(h_spatial, ObjectType::SpatialFilter(sf));
// Identity-transform insert (origin, unit scale, no rotation).
let mut ins = Insert::new("BLK", Vector3::new(0.0, 0.0, 0.0));
ins.common.handle = h_ins;
ins.common.xdictionary_handle = Some(h_xdict);
let resolved = insert_spatial_filter(&doc, &ins).expect("filter resolves");
let poly = world_clip_polygon(resolved, &ins, [0.0, 0.0, 0.0]);
assert_eq!(poly.len(), 4);
// A polyline half inside, half outside the 0..10 square.
let mut wires = vec![WireModel {
points: vec![[5.0, 5.0, 0.0], [15.0, 5.0, 0.0]],
..Default::default()
}];
clip_wires(&mut wires, &poly);
assert_eq!(wires.len(), 1);
let pts = &wires[0].points;
assert!(pts.iter().all(|p| p[0].is_nan() || p[0] <= 10.0 + 1e-3));
assert!(pts.iter().any(|p| (p[0] - 10.0).abs() < 1e-3));
}
#[test]
fn hatch_boundary_clipped_kept_and_dropped() {
let clip = square(); // 0..10
// Hatch loop straddling the right edge → clipped to x<=10.
let straddle = [[5.0, 5.0], [15.0, 5.0], [15.0, 8.0], [5.0, 8.0]];
let out = clip_hatch_boundary(&straddle, [0.0, 0.0], &clip);
assert!(!out.is_empty());
assert!(out.iter().all(|p| p[0].is_nan() || p[0] <= 10.0 + 1e-3));
// Hatch fully inside → unchanged vertex count.
let inside = [[1.0, 1.0], [4.0, 1.0], [4.0, 4.0], [1.0, 4.0]];
let out_in = clip_hatch_boundary(&inside, [0.0, 0.0], &clip);
assert_eq!(out_in.len(), 4);
// Hatch fully outside → dropped.
let outside = [[20.0, 20.0], [25.0, 20.0], [25.0, 25.0]];
let out_out = clip_hatch_boundary(&outside, [0.0, 0.0], &clip);
assert!(out_out.is_empty());
}
#[test]
fn hatch_boundary_respects_world_origin() {
// Same geometry as the straddle case but expressed as offsets from a
// large world_origin — clipping must account for the origin shift.
let clip = vec![[1000.0, 1000.0], [1010.0, 1000.0], [1010.0, 1010.0], [1000.0, 1010.0]];
let origin = [1000.0, 1000.0];
let loop_off = [[5.0, 5.0], [15.0, 5.0], [15.0, 8.0], [5.0, 8.0]];
let out = clip_hatch_boundary(&loop_off, origin, &clip);
assert!(!out.is_empty());
// Offsets must stay <= 10 (i.e. world x <= 1010).
assert!(out.iter().all(|p| p[0].is_nan() || p[0] <= 10.0 + 1e-3));
}
#[test]
fn world_polygon_applies_inverse_block_then_insert() {
use acadrust::types::{Matrix4, Vector2};
// Clip stored against a normalized space: inverse_block_transform scales
// the small boundary points up by 1000 into block space, then the insert
// (scale 0.1 + translation) maps them to world.
let mut sf = SpatialFilter::new();
sf.boundary_points = vec![Vector2::new(580.0, 4528.0), Vector2::new(581.0, 4529.0)];
sf.inverse_block_transform = Matrix4 {
m: [
[1000.0, 0.0, 0.0, 0.0],
[0.0, 1000.0, 0.0, 0.0],
[0.0, 0.0, 1000.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
],
};
let mut ins = Insert::new("BLK", Vector3::new(581668.0, 4064155.0, 0.0));
ins.set_x_scale(0.1);
ins.set_y_scale(0.1);
let poly = world_clip_polygon(&sf, &ins, [0.0, 0.0, 0.0]);
// vert (580,4528) → ×1000 → (580000,4528000) → ×0.1 + insert →
// (639668, 4516955).
let xs: Vec<f32> = poly.iter().map(|p| p[0]).collect();
let ys: Vec<f32> = poly.iter().map(|p| p[1]).collect();
let minx = xs.iter().cloned().fold(f32::MAX, f32::min);
let miny = ys.iter().cloned().fold(f32::MAX, f32::min);
assert!((minx - 639668.0).abs() < 1.0, "minx={minx}");
assert!((miny - 4516955.0).abs() < 1.0, "miny={miny}");
}
#[test]
fn triangle_clipped_to_square() {
// Triangle straddling the right edge → clipped, area reduced.
let tri = [[5.0, 5.0, 0.0], [15.0, 5.0, 0.0], [5.0, 9.0, 0.0]];
let out = clip_triangles(&tri, &square());
assert!(!out.is_empty());
assert!(out.iter().all(|p| p[0] <= 10.0 + 1e-3));
}
}