Files
OneCAD-Draft/src/scene/pick/xclip.rs
T
Karim Jerbi 02c470aac7 feat(pdf): searchable text layer with per-style subset font embedding
Real BT/Tj text objects ride alongside the vector outlines (Tr 3,
zero visual change): runs preserved at layout time, TTF styles embedded
as allsorts subsets with ToUnicode, width-matched Tm, clip-culled.
WireModel literals moved to ..Default::default() tails.
2026-10-01 23:30:35 +01:00

1203 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 codec::entities::Insert;
use codec::objects::{ObjectType, SpatialFilter};
use codec::types::{Handle, Transform, Vector3};
use codec::CadDocument;
use kernel::geom2d::{contains, Curve, Line, Tolerance};
use crate::scene::model::wire_model::{
decode_pattern_station_map, encode_pattern_stations, pattern_station_values, WireModel,
};
const NAN3: [f32; 3] = [f32::NAN, f32::NAN, f32::NAN];
/// Resolve the extension dictionary handle for an object or entity, checking the entity's
/// field, document lookup tables, and owned Dictionary / DictionaryWithDefault objects.
fn resolve_extension_dictionary(doc: &CadDocument, owner: Handle) -> Option<Handle> {
if let Some(entity) = doc.get_entity(owner) {
if let Some(h) = entity.common().xdictionary_handle {
if !h.is_null() {
return Some(h);
}
}
}
if let Some(h) = doc.extension_dictionary_handle(owner) {
if !h.is_null() {
return Some(h);
}
}
doc.objects.iter().find_map(|(handle, object)| match object {
ObjectType::Dictionary(d) if d.owner == owner && !handle.is_null() => Some(*handle),
ObjectType::DictionaryWithDefault(d) if d.owner == owner && !handle.is_null() => {
Some(*handle)
}
_ => None,
})
}
/// The INSERT's extension dictionary through its own link or the document's
/// index — both O(1). Render walks ask this for every INSERT, so it must not
/// fall back to [`resolve_extension_dictionary`]'s scan of every object.
fn linked_extension_dictionary(doc: &CadDocument, ins: &Insert) -> Option<Handle> {
ins.common
.xdictionary_handle
.filter(|h| !h.is_null())
.or_else(|| doc.extension_dictionary_handle(ins.common.handle))
.filter(|h| !h.is_null())
}
/// 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 = linked_extension_dictionary(doc, ins)?;
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,
}
}
// ── Editing (XCLIP) ─────────────────────────────────────────────────────────
/// The filter's handle, when the insert has one (enabled or not).
pub fn filter_handle(doc: &CadDocument, insert: Handle) -> Option<Handle> {
let Some(codec::EntityType::Insert(ins)) = doc.get_entity(insert) else {
return None;
};
let xdict = linked_extension_dictionary(doc, ins)?;
let acad_filter = dict_entry(doc, xdict, "ACAD_FILTER")?;
let spatial = dict_entry(doc, acad_filter, "SPATIAL")?;
matches!(doc.objects.get(&spatial), Some(ObjectType::SpatialFilter(_))).then_some(spatial)
}
const ROUNDTRIP: &str = "ACAD_XREC_ROUNDTRIP";
const INVERTED: &str = "ACAD_INVERTEDCLIP_ROUNDTRIP";
const INVERTED_COMPARE: &str = "ACAD_INVERTEDCLIP_ROUNDTRIP_COMPARE";
/// The boundary an inverted clip was drawn with (WCS), kept beside the
/// filter's ring in an ACAD_XREC_ROUNDTRIP record as the reference keeps it.
pub fn inverted_boundary(doc: &CadDocument, filter: &SpatialFilter) -> Option<Vec<[f64; 2]>> {
let x = doc.xrecord(filter.handle, ROUNDTRIP)?;
let mut points = Vec::new();
let mut inside = false;
for entry in &x.entries {
match &entry.value {
codec::objects::XRecordValue::String(s) if entry.code == 102 => {
inside = s == INVERTED;
}
codec::objects::XRecordValue::Point3D(px, py, _) if inside && entry.code == 10 => {
points.push([*px, *py]);
}
_ => {}
}
}
(points.len() >= 2).then_some(points)
}
/// The inverted boundary drawn as the XCLIP frame and generated polyline,
/// else the stored one; rectangles as four corners (WCS through the stored
/// transforms).
pub fn clip_outline_world(doc: &CadDocument, filter: &SpatialFilter, xform: &Transform) -> Vec<[f64; 2]> {
match inverted_boundary(doc, filter) {
Some(points) => {
let mut outline = filter.clone();
outline.boundary_points = points
.iter()
.map(|p| codec::types::Vector2::new(p[0], p[1]))
.collect();
world_clip_polygon_for_transform(&outline, xform)
}
None => world_clip_polygon_for_transform(filter, xform),
}
}
/// The ring that shows only what lies outside `inner` (WCS, drawn order):
/// the block's extents grown by 5 % on each side, joined to the boundary by
/// a hairline slit — the polygon the reference stores for an inverted clip.
// ponytail: the slit runs left from the boundary's last corner; a concave
// boundary it would cross clips wrongly there.
pub fn inverted_ring(inner: &[[f64; 2]], extents: ([f64; 2], [f64; 2])) -> Vec<[f64; 2]> {
let (lo, hi) = extents;
let size = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1e-9);
let pad = size * 0.05;
let (x0, y0, x1, y1) = (
lo[0].min(inner.iter().map(|p| p[0]).fold(f64::MAX, f64::min)) - pad,
lo[1].min(inner.iter().map(|p| p[1]).fold(f64::MAX, f64::min)) - pad,
hi[0].max(inner.iter().map(|p| p[0]).fold(f64::MIN, f64::max)) + pad,
hi[1].max(inner.iter().map(|p| p[1]).fold(f64::MIN, f64::max)) + pad,
);
let eps = size * 4e-6;
let last = *inner.last().expect("a boundary has points");
let mut ring = inner.to_vec();
ring.extend([
[x0, last[1]],
[x0, y1],
[x1, y1],
[x1, y0],
[x0, y0],
[x0, last[1] - eps],
[last[0], last[1] - eps],
]);
ring
}
/// A rectangle's two corners as its four, in drawing order.
pub fn rectangle_corners(a: [f64; 2], b: [f64; 2]) -> Vec<[f64; 2]> {
vec![a, [b[0], a[1]], b, [a[0], b[1]]]
}
/// Remove an insert's clip (the filter and what it owns).
pub fn remove_insert_clip(doc: &mut CadDocument, insert: Handle) -> bool {
let Some(spatial) = filter_handle(doc, insert) else {
return false;
};
if let Some(xdict) = doc.extension_dictionary_handle(spatial) {
let children: Vec<Handle> = match doc.objects.get(&xdict) {
Some(ObjectType::Dictionary(d)) => d.entries.iter().map(|(_, h)| *h).collect(),
Some(ObjectType::DictionaryWithDefault(d)) => d.entries.iter().map(|(_, h)| *h).collect(),
_ => Vec::new(),
};
for child in children {
doc.objects.remove(&child);
}
doc.objects.remove(&xdict);
}
doc.objects.remove(&spatial);
for object in doc.objects.values_mut() {
match object {
ObjectType::Dictionary(d) => d.entries.retain(|(_, h)| *h != spatial),
ObjectType::DictionaryWithDefault(d) => d.entries.retain(|(_, h)| *h != spatial),
_ => {}
}
}
true
}
/// Turn an insert's clip display on or off; false when it has none.
pub fn set_insert_clip_enabled(doc: &mut CadDocument, insert: Handle, on: bool) -> bool {
let Some(spatial) = filter_handle(doc, insert) else {
return false;
};
if let Some(ObjectType::SpatialFilter(f)) = doc.objects.get_mut(&spatial) {
f.display_enabled = on;
}
true
}
/// Front and back clipping planes (None = off) of an insert's clip.
pub fn set_insert_clip_depth(
doc: &mut CadDocument,
insert: Handle,
front: Option<Option<f64>>,
back: Option<Option<f64>>,
) -> bool {
let Some(spatial) = filter_handle(doc, insert) else {
return false;
};
if let Some(ObjectType::SpatialFilter(f)) = doc.objects.get_mut(&spatial) {
if let Some(front) = front {
f.front_clip = front;
}
if let Some(back) = back {
f.back_clip = back;
}
}
true
}
/// A new clip boundary on an insert, replacing any old one. `boundary` is in
/// WCS (two points = rectangle corners); `inverted` hides what is inside,
/// stored as the reference stores it (the ring plus the drawn boundary).
pub fn set_insert_clip(
doc: &mut CadDocument,
insert: Handle,
boundary: &[[f64; 2]],
inverted: bool,
extents: ([f64; 2], [f64; 2]),
) -> bool {
use codec::objects::{Dictionary, XRecord, XRecordEntry, XRecordValue};
use codec::types::{Matrix4, Vector2};
let Some(codec::EntityType::Insert(ins)) = doc.get_entity(insert) else {
return false;
};
let t = ins.get_transform().matrix.m;
let forward = glam::DMat4::from_cols_array_2d(&[
[t[0][0], t[1][0], t[2][0], t[3][0]],
[t[0][1], t[1][1], t[2][1], t[3][1]],
[t[0][2], t[1][2], t[2][2], t[3][2]],
[t[0][3], t[1][3], t[2][3], t[3][3]],
]);
let inv = forward.inverse().to_cols_array_2d();
let inverse = Matrix4 {
m: [
[inv[0][0], inv[1][0], inv[2][0], inv[3][0]],
[inv[0][1], inv[1][1], inv[2][1], inv[3][1]],
[inv[0][2], inv[1][2], inv[2][2], inv[3][2]],
[inv[0][3], inv[1][3], inv[2][3], inv[3][3]],
],
};
let existing_xdict = resolve_extension_dictionary(doc, insert);
remove_insert_clip(doc, insert);
// insert → extension dictionary → ACAD_FILTER → SPATIAL
let xdict = match existing_xdict.filter(|h| {
matches!(
doc.objects.get(h),
Some(ObjectType::Dictionary(_) | ObjectType::DictionaryWithDefault(_))
)
}) {
Some(h) => h,
None => {
let h = doc.allocate_handle();
let mut d = Dictionary::new();
(d.handle, d.owner, d.hard_owner) = (h, insert, true);
doc.objects.insert(h, ObjectType::Dictionary(d));
if let Some(entity) = doc.get_entity_mut(insert) {
entity.common_mut().xdictionary_handle = Some(h);
}
h
}
};
let acad_filter = match dict_entry(doc, xdict, "ACAD_FILTER") {
Some(h) => h,
None => {
let h = doc.allocate_handle();
let mut d = Dictionary::new();
(d.handle, d.owner, d.hard_owner) = (h, xdict, true);
doc.objects.insert(h, ObjectType::Dictionary(d));
match doc.objects.get_mut(&xdict) {
Some(ObjectType::Dictionary(parent)) => parent.add_entry("ACAD_FILTER", h),
Some(ObjectType::DictionaryWithDefault(parent)) => {
parent.entries.push(("ACAD_FILTER".to_string(), h))
}
_ => {}
}
h
}
};
let stored: Vec<[f64; 2]> = if inverted {
let inner = if boundary.len() == 2 {
rectangle_corners(boundary[0], boundary[1])
} else {
boundary.to_vec()
};
inverted_ring(&inner, extents)
} else {
boundary.to_vec()
};
let spatial = doc.allocate_handle();
let mut filter = SpatialFilter::new();
filter.handle = spatial;
filter.owner = acad_filter;
filter.boundary_points = stored.iter().map(|p| Vector2::new(p[0], p[1])).collect();
filter.inverse_block_transform = inverse;
filter.clip_bound_transform = Matrix4::identity();
doc.objects.insert(spatial, ObjectType::SpatialFilter(filter));
match doc.objects.get_mut(&acad_filter) {
Some(ObjectType::Dictionary(parent)) => parent.add_entry("SPATIAL", spatial),
Some(ObjectType::DictionaryWithDefault(parent)) => {
parent.entries.push(("SPATIAL".to_string(), spatial))
}
_ => {}
}
if inverted {
let fdict = doc.ensure_extension_dictionary(spatial);
let record = doc.allocate_handle();
match doc.objects.get_mut(&fdict) {
Some(ObjectType::Dictionary(d)) => d.add_entry(ROUNDTRIP, record),
Some(ObjectType::DictionaryWithDefault(d)) => {
d.entries.push((ROUNDTRIP.to_string(), record))
}
_ => {}
}
let mut x = XRecord::new();
(x.handle, x.owner) = (record, fdict);
x.cloning_flags = codec::objects::DictionaryCloningFlags::KeepExisting;
let point = |p: &[f64; 2]| XRecordEntry::new(10, XRecordValue::Point3D(p[0], p[1], 0.0));
x.entries.push(XRecordEntry::string(102, INVERTED));
x.entries.extend(boundary.iter().map(point));
x.entries.push(XRecordEntry::string(102, INVERTED_COMPARE));
x.entries.extend(stored.iter().map(point));
doc.objects.insert(record, ObjectType::XRecord(x));
}
true
}
fn dict_entry(doc: &CadDocument, dict: Handle, key: &str) -> Option<Handle> {
let entries = match doc.objects.get(&dict)? {
ObjectType::Dictionary(d) => &d.entries,
ObjectType::DictionaryWithDefault(d) => &d.entries,
_ => return None,
};
entries
.iter()
.find(|(k, _)| k.trim().eq_ignore_ascii_case(key))
.map(|(_, h)| *h)
}
/// 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.
/// Build the clip boundary in absolute f64 world coordinates so clipping at
/// large coordinate values remains precise.
#[cfg(test)]
fn world_clip_polygon_f64(
sf: &SpatialFilter,
ins: &Insert,
) -> Vec<[f64; 2]> {
world_clip_polygon_for_transform(sf, &ins.get_transform())
}
/// Build the clip boundary with the exact transform used by the corresponding
/// block instance. Callers that resolve block base points or parent instances
/// pass their composed transform here so geometry and clipping share one space.
///
/// The filter's `inverse_block_transform` is used as stored: the codec decodes
/// the on-disk column-major 4x3 layout into row-major `Matrix4`.
pub fn world_clip_polygon_for_transform(
sf: &SpatialFilter,
xform: &Transform,
) -> Vec<[f64; 2]> {
let inv_block = &sf.inverse_block_transform;
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, w.y]
})
.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: &[[f64; 2]]) {
if poly.len() < 3 {
return;
}
// Clip in a frame relative to the boundary's first vertex: the wire points
// arrive as absolute coordinates (double-single high+low), which at UTM
// scale are ~5.7e6 and lose ~0.5 m in f32. Subtracting the f64 reference
// makes every coordinate small, so the f32 Sutherland–Hodgman math is exact;
// the reference is added back afterwards and re-split into the high/low pair
// the relative-to-eye renderer expects.
let (rx, ry) = (poly[0][0], poly[0][1]);
let lpoly: Vec<[f32; 2]> = poly
.iter()
.map(|&[x, y]| [(x - rx) as f32, (y - ry) as f32])
.collect();
let boundary: Vec<Curve> = poly
.iter()
.zip(poly.iter().cycle().skip(1))
.take(poly.len())
.map(|(&start, &end)| Curve::Line(Line { start, end }))
.collect();
// Reconstruct an absolute-f64 wire point from its high/low pair, NaN-safe.
let abs = |hi: [f32; 3], lo: [f32; 3]| -> [f64; 3] {
[
hi[0] as f64 + lo[0] as f64,
hi[1] as f64 + lo[1] as f64,
hi[2] as f64 + lo[2] as f64,
]
};
for w in wires.iter_mut() {
let keep_marker = w.point_marker.map(|marker| {
contains(
&boundary,
[marker.origin.x, marker.origin.y],
Tolerance::default(),
)
});
if keep_marker == Some(false) {
w.points.clear();
w.points_low.clear();
w.pattern_stations.clear();
w.point_marker = None;
} else if keep_marker.is_none() && !w.points.is_empty() {
// Absolute → local f32 (NaN separators preserved).
let local: Vec<[f32; 3]> = (0..w.points.len())
.map(|i| {
let hi = w.points[i];
if !hi[0].is_finite() || !hi[1].is_finite() {
return NAN3;
}
let lo = w.points_low.get(i).copied().unwrap_or([0.0; 3]);
let a = abs(hi, lo);
[(a[0] - rx) as f32, (a[1] - ry) as f32, a[2] as f32]
})
.collect();
let station_data = pattern_station_values(&w.pattern_stations, w.points.len());
let source_length = station_data.map(|(_, source_length)| source_length);
let station_map = decode_pattern_station_map(&w.pattern_stations, w.points.len());
let (clipped, clipped_stations, clipped_segments) = clip_polyline_stationed(
&local,
&lpoly,
station_data.map(|(stations, _)| stations),
station_map.as_ref().map(|map| map.point_segments.as_slice()),
);
// Local → absolute, re-split into double-single high/low.
let mut hi = Vec::with_capacity(clipped.len());
let mut lo = Vec::with_capacity(clipped.len());
for p in clipped {
if !p[0].is_finite() || !p[1].is_finite() {
hi.push(NAN3);
lo.push([0.0; 3]);
continue;
}
let (hx, lx) = split_ds(p[0] as f64 + rx);
let (hy, ly) = split_ds(p[1] as f64 + ry);
let (hz, lz) = split_ds(p[2] as f64);
hi.push([hx, hy, hz]);
lo.push([lx, ly, lz]);
}
w.points = hi;
w.points_low = lo;
if let Some(source_length) = source_length {
w.pattern_stations = encode_pattern_stations(
clipped_stations,
source_length,
&clipped_segments,
station_map.as_ref().map_or(&[], |map| map.pieces.as_slice()),
);
} else {
w.pattern_stations.clear();
}
if !w.tangent_geoms.is_empty() {
w.tangent_geoms.clear();
}
}
if !w.fill_tris.is_empty() {
let local: Vec<[f32; 3]> = (0..w.fill_tris.len())
.map(|i| {
let hi = w.fill_tris[i];
let lo = w.fill_tris_low.get(i).copied().unwrap_or([0.0; 3]);
let a = abs(hi, lo);
[(a[0] - rx) as f32, (a[1] - ry) as f32, a[2] as f32]
})
.collect();
let clipped = clip_triangles(&local, &lpoly);
let mut hi = Vec::with_capacity(clipped.len());
let mut lo = Vec::with_capacity(clipped.len());
for p in clipped {
let (hx, lx) = split_ds(p[0] as f64 + rx);
let (hy, ly) = split_ds(p[1] as f64 + ry);
let (hz, lz) = split_ds(p[2] as f64);
hi.push([hx, hy, hz]);
lo.push([lx, ly, lz]);
}
w.fill_tris = hi;
w.fill_tris_low = lo;
}
// Clip SDF text: keep each glyph quad (6 verts = 2 triangles) whose
// centroid is inside the boundary. Whole-glyph granularity so block-
// instance text is bounded by the xclip region instead of bleeding past
// it — the stroke path clips per-segment, but per-glyph reads cleanly.
if !w.text_verts.is_empty() {
let mut kept = Vec::with_capacity(w.text_verts.len());
for quad in w.text_verts.chunks(6) {
let n = quad.len() as f64;
let (mut cx, mut cy) = (0.0f64, 0.0f64);
for v in quad {
cx += v.pos[0] as f64 + v.pos_low[0] as f64;
cy += v.pos[1] as f64 + v.pos_low[1] as f64;
}
if point_in_poly((cx / n - rx) as f32, (cy / n - ry) as f32, &lpoly) {
kept.extend_from_slice(quad);
}
}
w.text_verts = kept;
}
w.key_vertices
.retain(|v| point_in_poly((v[0] - rx) as f32, (v[1] - ry) as f32, &lpoly));
w.snap_pts
.retain(|(p, _)| point_in_poly((p.x - rx) as f32, (p.y - ry) as f32, &lpoly));
// AABB from surviving points + fills, then fold in the surviving glyph
// quads so a text-only wire keeps a tight (non-degenerate) pick box.
let mut bb = recompute_aabb(&w.points, &w.fill_tris);
if !w.text_verts.is_empty() {
let (mut nx, mut ny, mut xx, mut xy) = if bb == [0.0; 4] {
(f32::MAX, f32::MAX, f32::MIN, f32::MIN)
} else {
(bb[0], bb[1], bb[2], bb[3])
};
for v in &w.text_verts {
nx = nx.min(v.pos[0]);
ny = ny.min(v.pos[1]);
xx = xx.max(v.pos[0]);
xy = xy.max(v.pos[1]);
}
if nx <= xx {
bb = [nx, ny, xx, xy];
}
}
w.aabb = bb;
}
wires.retain(|w| {
!w.points.is_empty() || !w.fill_tris.is_empty() || !w.text_verts.is_empty()
});
}
/// Double-single split of an f64 into (high, low) f32 — mirrors
/// `WireModel::split_ds` so clipped points match the renderer's reconstruction.
fn split_ds(v: f64) -> (f32, f32) {
let high = v as f32;
(high, (v - high as f64) as f32)
}
/// Build the visible clip-boundary frame (XCLIPFRAME 1/2) as a closed ring
/// over the world-space boundary polygon. The frame is display chrome for the
/// clipped INSERT: it rides the insert's resolved colour / line weight and is
/// deliberately NOT clipped — it *is* the boundary.
pub fn frame_wire(
poly: &[[f64; 2]],
name: String,
color: [f32; 4],
selected: bool,
line_weight_px: f32,
) -> WireModel {
let mut hi = Vec::with_capacity(poly.len() + 1);
let mut lo = Vec::with_capacity(poly.len() + 1);
for &[x, y] in poly.iter().chain(std::iter::once(&poly[0])) {
let (hx, lx) = split_ds(x);
let (hy, ly) = split_ds(y);
hi.push([hx, hy, 0.0]);
lo.push([lx, ly, 0.0]);
}
WireModel {
bg_adapt: None,
point_marker: None,
taper_widths: Vec::new(),
pattern_stations: Vec::new(),
world_width: 0.0,
depth_override: None,
display_visible: true,
snap_only: false,
plot_visible: true,
fill_is_3d: false,
fill_is_2d_solid: false,
render_instance: None,
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name,
points: hi,
points_low: lo,
color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
..Default::default()
}
}
/// 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]);
}
// Lower back into the loop's `world_origin`-relative space so the
// result keeps the same f32-precision anchor the renderer adds back.
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.
#[cfg(test)]
fn clip_polyline(pts: &[[f32; 3]], poly: &[[f32; 2]]) -> Vec<[f32; 3]> {
clip_polyline_stationed(pts, poly, None, None).0
}
fn clip_polyline_stationed(
pts: &[[f32; 3]],
poly: &[[f32; 2]],
stations: Option<&[f32]>,
point_segments: Option<&[i32]>,
) -> (Vec<[f32; 3]>, Vec<f32>, Vec<i32>) {
let mut out: Vec<[f32; 3]> = Vec::new();
let mut out_stations = Vec::new();
let mut out_segments = 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 (ta, tb) in clip_segment_params(seg[j], seg[j + 1], poly) {
let lerp_point = |t: f32| {
[
seg[j][0] + (seg[j + 1][0] - seg[j][0]) * t,
seg[j][1] + (seg[j + 1][1] - seg[j][1]) * t,
seg[j][2] + (seg[j + 1][2] - seg[j][2]) * t,
]
};
let a = lerp_point(ta);
let b = lerp_point(tb);
let station = |t: f32| {
stations.map_or(0.0, |values| {
let a = values[start + j];
a + (values[start + j + 1] - a) * t
})
};
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);
if stations.is_some() {
out_stations.push(0.0);
}
if point_segments.is_some() {
out_segments.push(-1);
}
}
out.push(a);
if stations.is_some() {
out_stations.push(station(ta));
}
if let Some(segments) = point_segments {
out_segments.push(segments[start + j]);
}
}
out.push(b);
if stations.is_some() {
out_stations.push(station(tb));
}
if let Some(segments) = point_segments {
out_segments.push(segments[start + j]);
}
last = Some(b);
}
}
}
(out, out_stations, out_segments)
}
/// 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.
#[cfg(test)]
fn clip_segment(
p0: [f32; 3],
p1: [f32; 3],
poly: &[[f32; 2]],
) -> Vec<([f32; 3], [f32; 3])> {
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,
]
};
clip_segment_params(p0, p1, poly)
.into_iter()
.map(|(a, b)| (lerp(a), lerp(b)))
.collect()
}
fn clip_segment_params(p0: [f32; 3], p1: [f32; 3], poly: &[[f32; 2]]) -> Vec<(f32, f32)> {
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 point = |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 = point(0.5 * (w[0] + w[1]));
if point_in_poly(mid[0], mid[1], poly) {
out.push((w[0], 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 codec::objects::Dictionary;
use codec::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_f64(resolved, &ins);
assert_eq!(poly.len(), 4);
// A polyline half inside, half outside the 0..10 square.
let mut wires = vec![WireModel {
text_verts: Vec::new(),
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 codec::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_f64(&sf, &ins);
// vert (580,4528) → ×1000 → (580000,4528000) → ×0.1 + insert →
// (639668, 4516955).
let xs: Vec<f64> = poly.iter().map(|p| p[0]).collect();
let ys: Vec<f64> = poly.iter().map(|p| p[1]).collect();
let minx = xs.iter().cloned().fold(f64::MAX, f64::min);
let miny = ys.iter().cloned().fold(f64::MAX, f64::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));
}
}