Files
OneCAD-Draft/src/scene/model/wire_model.rs
T

1657 lines
64 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.
/// Tag for pre-baked snap candidates stored inside a WireModel.
/// Kept separate from `snap::SnapType` to avoid circular module dependencies.
#[derive(Clone, Copy, Debug)]
pub enum SnapHint {
/// Geometric center of a circle, arc, or ellipse.
Center,
/// Centre of one arc segment of a chain (a polyline). Snaps like
/// `Center`, but does not make the wire count as round.
ArcCenter,
/// Area centroid of a closed polyline.
GeometricCenter,
/// Point entity location.
Node,
/// 0 / 90 / 180 / 270 ° point on a circle/arc (within arc span).
Quadrant,
/// Insertion point of text or block.
Insertion,
/// Midpoint of a curve that has one well-defined midpoint (an arc's
/// arc-length centre, a spline's `t = 0.5`). Lines / polylines do
/// not use this — their midpoints are derived from `key_vertices`.
Midpoint,
/// An end of a curve that is not a chain of straight segments — an arc,
/// an elliptical arc, a spline.
///
/// Such a curve cannot put its ends in `key_vertices`: consecutive
/// entries there are taken to be joined by a straight segment, so an
/// arc's two ends would also produce a midpoint snap out on the chord,
/// which is not on the geometry.
Endpoint,
/// B-rep corner of a 3D solid. Maps to the separate 3D snap system
/// (`SnapType::Vertex`), never to the 2D modes.
Vertex,
/// Centre of a 3D solid B-rep edge. Maps to the separate 3D snap system
/// (`SnapType::EdgeMidpoint`), never to the 2D modes.
EdgeMidpoint,
/// Centre of a 3D solid B-rep face. Maps to the separate 3D snap system
/// (`SnapType::FaceCenter`), never to the 2D modes.
FaceCenter,
/// NURBS knot location on a spline entity. Maps to the separate 3D snap
/// system (`SnapType::Knot`), never to the 2D modes.
Knot,
}
/// Geometric primitive used by the tangent-snap engine.
#[derive(Clone, Debug)]
pub enum TangentGeom {
/// Infinite line through these two world-space points.
Line { p1: [f32; 3], p2: [f32; 3] },
/// Complete circle.
Circle { center: [f32; 3], radius: f32 },
/// Complete circle in an arbitrary world-space plane.
PlanarCircle {
center: [f64; 3],
axis_x: [f64; 3],
axis_y: [f64; 3],
radius: f64,
},
/// Bounded circular arc in its world-space plane.
Arc {
center: [f64; 3],
axis_x: [f64; 3],
axis_y: [f64; 3],
radius: f64,
start_angle: f64,
end_angle: f64,
},
/// Ellipse or elliptical arc in an arbitrary world-space plane.
PlanarEllipse {
center: [f64; 3],
major_axis: [f64; 3],
normal: [f64; 3],
minor_axis_ratio: f64,
start_param: f64,
end_param: f64,
},
}
impl TangentGeom {
pub fn translated(&self, delta: glam::DVec3) -> Self {
match self {
Self::Line { p1, p2 } => Self::Line {
p1: [
p1[0] + delta.x as f32,
p1[1] + delta.y as f32,
p1[2] + delta.z as f32,
],
p2: [
p2[0] + delta.x as f32,
p2[1] + delta.y as f32,
p2[2] + delta.z as f32,
],
},
Self::Circle { center, radius } => Self::Circle {
center: [
center[0] + delta.x as f32,
center[1] + delta.y as f32,
center[2] + delta.z as f32,
],
radius: *radius,
},
Self::PlanarCircle {
center,
axis_x,
axis_y,
radius,
} => Self::PlanarCircle {
center: [
center[0] + delta.x,
center[1] + delta.y,
center[2] + delta.z,
],
axis_x: *axis_x,
axis_y: *axis_y,
radius: *radius,
},
Self::Arc {
center,
axis_x,
axis_y,
radius,
start_angle,
end_angle,
} => Self::Arc {
center: [
center[0] + delta.x,
center[1] + delta.y,
center[2] + delta.z,
],
axis_x: *axis_x,
axis_y: *axis_y,
radius: *radius,
start_angle: *start_angle,
end_angle: *end_angle,
},
Self::PlanarEllipse {
center,
major_axis,
normal,
minor_axis_ratio,
start_param,
end_param,
} => Self::PlanarEllipse {
center: [
center[0] + delta.x,
center[1] + delta.y,
center[2] + delta.z,
],
major_axis: *major_axis,
normal: *normal,
minor_axis_ratio: *minor_axis_ratio,
start_param: *start_param,
end_param: *end_param,
},
}
}
pub fn rotated(&self, center: glam::DVec3, axis: glam::DVec3, angle_rad: f64) -> Self {
let quat = glam::DQuat::from_axis_angle(axis.normalize_or(glam::DVec3::Z), angle_rad);
let rot_pt =
|p: [f64; 3]| (center + quat * (glam::DVec3::from_array(p) - center)).to_array();
let rot_vec = |v: [f64; 3]| (quat * glam::DVec3::from_array(v)).to_array();
match self {
Self::Line { p1, p2 } => {
let p1_d = [p1[0] as f64, p1[1] as f64, p1[2] as f64];
let p2_d = [p2[0] as f64, p2[1] as f64, p2[2] as f64];
let r1 = rot_pt(p1_d);
let r2 = rot_pt(p2_d);
Self::Line {
p1: [r1[0] as f32, r1[1] as f32, r1[2] as f32],
p2: [r2[0] as f32, r2[1] as f32, r2[2] as f32],
}
}
Self::Circle { center: c, radius } => {
let c_d = [c[0] as f64, c[1] as f64, c[2] as f64];
let rc = rot_pt(c_d);
Self::Circle {
center: [rc[0] as f32, rc[1] as f32, rc[2] as f32],
radius: *radius,
}
}
Self::PlanarCircle {
center: c,
axis_x,
axis_y,
radius,
} => Self::PlanarCircle {
center: rot_pt(*c),
axis_x: rot_vec(*axis_x),
axis_y: rot_vec(*axis_y),
radius: *radius,
},
Self::Arc {
center: c,
axis_x,
axis_y,
radius,
start_angle,
end_angle,
} => Self::Arc {
center: rot_pt(*c),
axis_x: rot_vec(*axis_x),
axis_y: rot_vec(*axis_y),
radius: *radius,
start_angle: *start_angle,
end_angle: *end_angle,
},
Self::PlanarEllipse {
center: c,
major_axis,
normal,
minor_axis_ratio,
start_param,
end_param,
} => Self::PlanarEllipse {
center: rot_pt(*c),
major_axis: rot_vec(*major_axis),
normal: rot_vec(*normal),
minor_axis_ratio: *minor_axis_ratio,
start_param: *start_param,
end_param: *end_param,
},
}
}
pub fn scaled(&self, center: glam::DVec3, factor: f64) -> Self {
let scale_pt =
|p: [f64; 3]| (center + (glam::DVec3::from_array(p) - center) * factor).to_array();
match self {
Self::Line { p1, p2 } => {
let p1_d = [p1[0] as f64, p1[1] as f64, p1[2] as f64];
let p2_d = [p2[0] as f64, p2[1] as f64, p2[2] as f64];
let s1 = scale_pt(p1_d);
let s2 = scale_pt(p2_d);
Self::Line {
p1: [s1[0] as f32, s1[1] as f32, s1[2] as f32],
p2: [s2[0] as f32, s2[1] as f32, s2[2] as f32],
}
}
Self::Circle { center: c, radius } => {
let c_d = [c[0] as f64, c[1] as f64, c[2] as f64];
let sc = scale_pt(c_d);
Self::Circle {
center: [sc[0] as f32, sc[1] as f32, sc[2] as f32],
radius: *radius * factor.abs() as f32,
}
}
Self::PlanarCircle {
center: c,
axis_x,
axis_y,
radius,
} => Self::PlanarCircle {
center: scale_pt(*c),
axis_x: *axis_x,
axis_y: *axis_y,
radius: *radius * factor.abs(),
},
Self::Arc {
center: c,
axis_x,
axis_y,
radius,
start_angle,
end_angle,
} => Self::Arc {
center: scale_pt(*c),
axis_x: *axis_x,
axis_y: *axis_y,
radius: *radius * factor.abs(),
start_angle: *start_angle,
end_angle: *end_angle,
},
Self::PlanarEllipse {
center: c,
major_axis,
normal,
minor_axis_ratio,
start_param,
end_param,
} => Self::PlanarEllipse {
center: scale_pt(*c),
major_axis: (glam::DVec3::from_array(*major_axis) * factor.abs()).to_array(),
normal: *normal,
minor_axis_ratio: *minor_axis_ratio,
start_param: *start_param,
end_param: *end_param,
},
}
}
pub fn mirrored(&self, p1: glam::DVec3, normal: glam::DVec3) -> Self {
let refl_pt = |p: [f64; 3]| {
let pt = glam::DVec3::from_array(p);
(pt - 2.0 * normal.dot(pt - p1) * normal).to_array()
};
let refl_vec = |v: [f64; 3]| {
let vec = glam::DVec3::from_array(v);
(vec - 2.0 * normal.dot(vec) * normal).to_array()
};
match self {
Self::Line { p1: lp1, p2: lp2 } => {
let p1_d = [lp1[0] as f64, lp1[1] as f64, lp1[2] as f64];
let p2_d = [lp2[0] as f64, lp2[1] as f64, lp2[2] as f64];
let m1 = refl_pt(p1_d);
let m2 = refl_pt(p2_d);
Self::Line {
p1: [m1[0] as f32, m1[1] as f32, m1[2] as f32],
p2: [m2[0] as f32, m2[1] as f32, m2[2] as f32],
}
}
Self::Circle { center: c, radius } => {
let c_d = [c[0] as f64, c[1] as f64, c[2] as f64];
let mc = refl_pt(c_d);
Self::Circle {
center: [mc[0] as f32, mc[1] as f32, mc[2] as f32],
radius: *radius,
}
}
Self::PlanarCircle {
center: c,
axis_x,
axis_y,
radius,
} => {
let ax = refl_vec(*axis_x);
let ay = refl_vec(*axis_y);
Self::PlanarCircle {
center: refl_pt(*c),
axis_x: ax,
axis_y: [-ay[0], -ay[1], -ay[2]],
radius: *radius,
}
}
Self::Arc {
center: c,
axis_x,
axis_y,
radius,
start_angle,
end_angle,
} => {
let ax = refl_vec(*axis_x);
let ay = refl_vec(*axis_y);
Self::Arc {
center: refl_pt(*c),
axis_x: ax,
axis_y: [-ay[0], -ay[1], -ay[2]],
radius: *radius,
start_angle: -end_angle,
end_angle: -start_angle,
}
}
Self::PlanarEllipse {
center: c,
major_axis,
normal: n,
minor_axis_ratio,
start_param,
end_param,
} => {
let norm = refl_vec(*n);
Self::PlanarEllipse {
center: refl_pt(*c),
major_axis: refl_vec(*major_axis),
normal: [-norm[0], -norm[1], -norm[2]],
minor_axis_ratio: *minor_axis_ratio,
start_param: *start_param,
end_param: *end_param,
}
}
}
}
}
/// Viewport-relative display transform for a point marker.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PointMarker {
pub origin: glam::DVec3,
pub normal: glam::DVec3,
pub axis_x: glam::DVec3,
pub axis_y: glam::DVec3,
pub viewport_percent: f32,
}
#[derive(Clone, Copy, Debug)]
pub struct PatternStationPiece {
pub source_segment: u32,
pub source_distances: [f32; 2],
pub segment_distances: [f32; 2],
pub vector: [f32; 3],
}
#[derive(Clone, Debug)]
pub(crate) struct PatternStationMap {
pub point_segments: Vec<i32>,
pub pieces: Vec<PatternStationPiece>,
}
pub(crate) fn pattern_station_values(data: &[f32], point_count: usize) -> Option<(&[f32], f32)> {
(data.len() >= point_count + 1).then(|| (&data[..point_count], data[point_count]))
}
pub(crate) fn encode_pattern_stations(
mut stations: Vec<f32>,
source_length: f32,
point_segments: &[i32],
pieces: &[PatternStationPiece],
) -> Vec<f32> {
let point_count = stations.len();
stations.push(source_length);
if point_segments.len() != point_count || pieces.is_empty() {
return stations;
}
stations.extend(point_segments.iter().map(|segment| *segment as f32));
for piece in pieces {
stations.push(piece.source_segment as f32);
stations.extend(piece.source_distances);
stations.extend(piece.segment_distances);
stations.extend(piece.vector);
}
stations
}
pub(crate) fn decode_pattern_station_map(
data: &[f32],
point_count: usize,
) -> Option<PatternStationMap> {
let metadata_start = point_count.checked_mul(2)?.checked_add(1)?;
if data.len() < metadata_start || (data.len() - metadata_start) % 8 != 0 {
return None;
}
let point_segments = data[point_count + 1..metadata_start]
.iter()
.map(|value| *value as i32)
.collect();
let pieces = data[metadata_start..]
.chunks_exact(8)
.map(|values| PatternStationPiece {
source_segment: values[0] as u32,
source_distances: [values[1], values[2]],
segment_distances: [values[3], values[4]],
vector: [values[5], values[6], values[7]],
})
.collect();
Some(PatternStationMap {
point_segments,
pieces,
})
}
impl PointMarker {
pub fn resolve(self, point: glam::DVec3, view_height: f64) -> glam::DVec3 {
let normal = self.normal.normalize_or(glam::DVec3::Z);
let delta = point - self.origin;
let axial = normal * delta.dot(normal);
let scale = self.viewport_percent.max(0.0) as f64 * 0.01 * view_height.max(0.0);
self.origin + axial + (delta - axial) * scale
}
}
/// A 1-D entity (line, arc, polyline) represented as an ordered set of
/// world-space points rendered as a quad strip (TriangleList).
///
/// Linetype is encoded as a GPU-side dash pattern so the CPU never needs to
/// split wires into per-dash segments. `pattern_length = 0.0` means solid.
/// Everything needed to resolve a wire's display colour against a background,
/// kept so the resolution can be redone for another background without
/// re-tessellating.
///
/// `None` — the default — means the wire did not record its inputs and a
/// resolution pass must leave it alone. Only `Batches::finalize` fills it in.
pub type BgAdapt = Option<Box<BgAdaptInputs>>;
/// One laid-out text run preserved for searchable PDF export.
///
/// `origin` is the final world-space baseline start (drawing units, f64 —
/// annotation scale and block placement already applied), `height` is the
/// world cap height (run.height × anno), `rotation` the run angle in radians.
/// `text` holds the visible characters only (DXF controls stripped, `%%`
/// specials decoded, `\P` → space — multi-line MTEXT arrives as one run per
/// visual line from the layout stage, so no re-wrapping is needed).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SearchableTextRun {
pub text: String,
pub origin: [f64; 3],
pub height: f32,
pub rotation: f32,
pub color: [f32; 4],
pub bold: bool,
/// Style font name as laid out (`GlyphRun.font`: TTF family, LFF/SHX
/// name, …). Lets the PDF exporter resolve the run's *actual* font and
/// embed a subset instead of always falling back to Helvetica.
pub font: String,
/// Laid-out advance width in drawing units (pen advance incl. spacing,
/// annotation scale applied). Drives `Tz` width-matching and run-level
/// culling at export; scaled alongside `height` by every transform.
pub adv_width: f32,
}
impl SearchableTextRun {
/// World-space origin translated by `delta` (preview drags, block moves).
pub fn translated(&self, delta: [f64; 3]) -> Self {
let mut out = self.clone();
out.origin[0] += delta[0];
out.origin[1] += delta[1];
out.origin[2] += delta[2];
out
}
}
/// Clean a laid-out run's raw string into visible searchable characters.
///
/// The input is the exact string passed to `layout_glyph_quads` for the run,
/// so the extracted text cannot diverge from the laid-out glyph sequence —
/// cleaning only strips controls. Traps paid for at the format level:
/// - `\P` is a real newline → space here (runs are already per-line).
/// - Decoration toggles (`\L…\l`, `\O…\o`, `\K…\k`) and other backslash
/// controls are stripped, not searched.
/// - `\Snum^den;`/stacking → `num/den`; `\U+XXXX` → the char; `{…}` grouping
/// braces are dropped; `\M+…` paragraph controls are stripped.
/// - DXF `%%d`/`%%p`/`%%c` decode to `°`/`±`/`Ø`; `%%` + 3 digits → the char.
/// - Literal `\\` → `\`, `\~` → non-breaking space.
pub fn clean_searchable_text(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let bytes = raw.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && i + 1 < bytes.len() {
let n = bytes[i + 1];
match n {
b'P' | b'p' => {
out.push(' ');
i += 2;
continue;
}
b'\\' => {
out.push('\\');
i += 2;
continue;
}
b'~' => {
out.push('\u{a0}');
i += 2;
continue;
}
// Stacking: `\Snum^den;` → `num/den` (searchable fraction).
b'S' | b's' => {
if let Some(semi) = raw[i + 2..].find(';') {
let body = &raw[i + 2..i + 2 + semi];
if let Some(caret) = body.find('^') {
out.push_str(&body[..caret]);
out.push('/');
out.push_str(&body[caret + 1..]);
} else {
out.push_str(body);
}
i += 2 + semi + 1;
} else {
i += 2;
}
continue;
}
// Unicode escape `\U+XXXX` (4–6 hex digits) → the char.
// Anything else (bad length, surrogate, > U+10FFFF) leaves
// the digits to be copied literally below — visible
// garbage-in beats silently dropped text.
b'U' | b'u' => {
let rest = &raw[i + 2..];
let after_plus = rest.strip_prefix('+').unwrap_or(rest);
let digits: String = after_plus
.chars()
.take_while(|c| c.is_ascii_hexdigit())
.collect();
let mut pushed = false;
if (4..=6).contains(&digits.len()) {
if let Ok(cp) = u32::from_str_radix(&digits, 16) {
if let Some(ch) = char::from_u32(cp) {
out.push(ch);
pushed = true;
}
}
}
i +=
2 + (rest.len() - after_plus.len()) + if pushed { digits.len() } else { 0 };
continue;
}
// Decoration / format toggles: drop both the opener and closer.
b'L' | b'l' | b'O' | b'o' | b'K' | b'k' | b'C' | b'c' | b'F' | b'f' | b'H'
| b'h' | b'Q' | b'q' | b'T' | b't' | b'W' | b'w' | b'A' | b'a' | b'M' | b'm' => {
// `\C1;`, `\Farial;` etc. run to the next `;` — skip it.
if let Some(semi) = raw[i + 2..].find(';') {
i += 2 + semi + 1;
} else {
i += 2;
}
continue;
}
_ => {
i += 1;
continue;
}
}
} else if b == b'%' && i + 2 < bytes.len() && bytes[i + 1] == b'%' {
let c = bytes[i + 2];
match c {
b'd' | b'D' => out.push('°'),
b'p' | b'P' => out.push('±'),
b'c' | b'C' => out.push('Ø'),
b'%' => out.push('%'),
_ if c.is_ascii_digit()
&& i + 4 < bytes.len()
&& bytes[i + 3].is_ascii_digit()
&& bytes[i + 4].is_ascii_digit() =>
{
let digits = std::str::from_utf8(&bytes[i + 2..i + 5]).unwrap_or("");
if let Ok(code) = digits.parse::<u32>() {
out.push(char::from_u32(code).unwrap_or('?'));
} else {
out.push_str("%%");
out.push_str(digits);
}
i += 5;
continue;
}
_ => {
out.push('%');
out.push('%');
out.push(c as char);
}
}
i += 3;
continue;
} else if b == b'{' || b == b'}' {
// MTEXT grouping braces: structure only, not content.
i += 1;
continue;
} else {
// Copy one UTF-8 char.
if let Some(ch) = raw[i..].chars().next() {
out.push(ch);
i += ch.len_utf8();
} else {
break;
}
}
}
// Collapse whitespace (MTEXT padding) but keep single spaces.
let mut collapsed = String::with_capacity(out.len());
let mut prev_space = true; // trim leading
for ch in out.chars() {
if ch.is_whitespace() {
if !prev_space {
collapsed.push(' ');
prev_space = true;
}
} else {
collapsed.push(ch);
prev_space = false;
}
}
if collapsed.ends_with(' ') {
collapsed.pop();
}
collapsed
}
/// The inputs `Batches::finalize` consumes when it resolves a colour.
///
/// The flags are independent — a wire can paint the background itself while
/// its glyphs still adapt — so this mirrors that code's branches rather than
/// collapsing them into one enum.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BgAdaptInputs {
/// Colour before adaptation. Resolution always recomputes from here rather
/// than from `WireModel::color`, so it is idempotent and safe over a set
/// mixing memo hits with freshly tessellated wires. `adapt_to_bg` is
/// **not** re-appliable: a near-white colour snaps to pure black on a
/// light background, and pure black comes back pure *white* on a dark one,
/// losing the tint.
pub raw_color: [f32; 4],
/// Glyph colours before adaptation, parallel to `WireModel::text_verts`.
/// Empty when the wire has no glyphs or their colour never adapts.
pub text_raw_colors: Vec<[f32; 4]>,
/// Adapt against this instead of the live background: an MTEXT carrying
/// its own background fill.
pub contrast_bg: Option<[f32; 4]>,
/// The wire paints the background; its colour *is* the live background.
pub canvas_color: bool,
/// Authored colour. The background must not touch it, glyphs included.
pub preserve_color: bool,
}
#[derive(Clone, Debug)]
pub struct WireModel {
/// Unique identifier — the handle value as a decimal string.
pub name: String,
/// Ordered world-space positions forming a strip of quads. Each entry is
/// the "high" half of a double-single f32 pair; [`points_low`] carries the
/// matching residual so the shader can reconstruct the f64 source.
pub points: Vec<[f32; 3]>,
/// Low-bit residual paired index-for-index with [`points`]. Empty means
/// "all-zero residual" (interactive draw / preview wires whose coordinates
/// don't need sub-f32 precision). Tessellation from CAD f64 fills it.
pub points_low: Vec<[f32; 3]>,
/// Point-marker scaling shared by the GPU, picking, and plotting paths.
pub point_marker: Option<PointMarker>,
/// RGBA colour in [0, 1].
pub color: [f32; 4],
/// How `color` and `text_verts` follow the background, or `None` when this
/// wire's colour must not be re-resolved.
pub bg_adapt: BgAdapt,
/// Whether this wire is currently selected.
#[allow(dead_code)]
pub selected: bool,
/// Total length of one pattern repeat (world units). 0 = solid line.
pub pattern_length: f32,
/// Up to 8 pattern elements: positive = dash length, negative = gap length.
/// Unused slots must be 0.0 (acts as end-of-pattern sentinel in shader).
pub pattern: [f32; 8],
/// Rendered line width in screen pixels (half-width = line_weight_px / 2).
pub line_weight_px: f32,
/// World-space band width (drawing units). Positive values create a wide
/// polyline band that scales with zoom. Zero uses `line_weight_px` and the
/// drawing's lineweight display setting. A negative value encodes a fixed
/// screen-pixel width whose absolute value bypasses that display setting.
pub world_width: f32,
/// Per-point full band width (drawing units), aligned index-for-index with
/// [`points`], for a polyline whose width VARIES (a taper). Empty = a
/// constant band of `world_width`. The wire shader reads the two endpoint
/// widths of each segment and interpolates, so the band tapers smoothly.
pub taper_widths: Vec<f32>,
/// Centreline stations and optional source-piece transform metadata.
pub pattern_stations: Vec<f32>,
/// ACI color index (1-255). 0 means true-color or unknown (no CTB lookup).
pub aci: u8,
/// Pre-baked snap candidates (Center, Node, Quadrant, Insertion).
pub snap_pts: Vec<(glam::DVec3, SnapHint)>,
/// Per-segment tangent geometry for Tangent snap.
/// Line/Arc entities: 1 entry. LwPolyline: 1 entry per segment.
pub tangent_geoms: Vec<TangentGeom>,
/// True polyline vertices used for Endpoint/Midpoint snap.
/// Non-empty only for entities with distinct vertex positions (Line, LwPolyline).
/// Empty for tessellated curves (Circle, Arc, Ellipse) which use snap_pts instead.
pub key_vertices: Vec<[f64; 3]>,
/// World-space 2-D bounding box [min_x, min_y, max_x, max_y].
/// Set from opencadcodec `bounding_box()` in `tessellate_entity()`.
/// Preview / interim wires use `UNBOUNDED_AABB` so they are never pre-rejected
/// by the snap world-space filter.
pub aabb: [f32; 4],
/// When false the linetype pattern restarts at each NaN-separated segment
/// (DXF PLINEGEN=0). When true the pattern runs continuously (PLINEGEN=1).
pub plinegen: bool,
/// DGN line-style marker. When false (every standard linetype) the dash
/// pattern uses the normal phase: A-type end alignment for dash-first
/// patterns, else centred. When true (DGN pipe walls) the pattern is drawn
/// from the START vertex with continuous phase and no A-type end forcing —
/// DGN line styles are not end-aligned.
pub dash_from_start: bool,
/// Shared "A"-type end-dash length for MLINE elements. `Some(len)` makes the
/// dash shader use `len` as the begin/end solid-dash length for EVERY
/// parallel element (derived once from the multiline's centre-line length),
/// while `align_total` stays each element's own length — so all elements
/// share one interior phase (perpendicular dashes line up) yet each still
/// ends on a dash. `None` (the default) = per-wire A-type / from-start.
pub dash_align_end: Option<f32>,
/// Pre-triangulated solid fill: flat vertex list, 3 per triangle (world-offset applied).
/// Non-empty only for PolyfaceMesh / PolygonMesh entities.
pub fill_tris: Vec<[f32; 3]>,
/// Low residual paired with [`fill_tris`] (double-single). Empty = all-zero;
/// tessellation from CAD f64 fills it so fills stay precise at UTM scale.
pub fill_tris_low: Vec<[f32; 3]>,
/// Pre-triangulated pick-only geometry: flat vertex list, 3 per triangle.
/// Hit-testing treats these as solid; this wire's own draw never uses them.
///
/// Carries the interior of things whose surface the cursor would otherwise
/// fall through, because [`points`] only bounds them:
///
/// - An entity extruded by a DXF thickness (code 39) — the swept wall
/// between each base segment and its extruded copy. Drawn as its four
/// wireframe edges, with nothing in between to pick.
/// - A wide polyline (codes 43 / 40 / 41) — the solid band. It *is* drawn,
/// but by the hatch pipeline off `wide_fills`, so the wire that carries
/// its centreline has no fill of its own to hit-test.
///
/// Separate from [`fill_tris`] because that channel reaches the GPU: a
/// thickness wall put there would render shaded and change how every such
/// drawing looks, and a polyline band would be drawn a second time on top
/// of the hatch that already draws it.
pub pick_tris: Vec<[f32; 3]>,
/// Low residual paired with [`pick_tris`] (double-single). Empty = all-zero.
pub pick_tris_low: Vec<[f32; 3]>,
/// SDF glyph quads for this entity's text (TEXT / MTEXT / dimension text /
/// block-internal text). Non-empty only when SDF text is enabled and this
/// wire carries a text run. Rides with the wire so it is cached by the
/// tess memo, cloned on hit, and transformed by the block-expand loop
/// exactly like `points` — no separate collector pass. The renderer
/// gathers these across all wires into the text vertex buffer.
pub text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex>,
/// Searchable text runs for PDF export (real text, not outlines).
///
/// The SDF `text_verts` above carry only tessellated quads — the source
/// string is lost by the time the PDF exporter runs, so searchable text
/// was impossible without re-layout. Each entry preserves one laid-out
/// run (already positioned: MTEXT attachment/width/wrap, rotation and
/// block placement are baked into `origin`/`rotation`/`height`), letting
/// `io::pdf_export` emit `BT … Tj ET` with an embedded font instead of
/// vector outlines. Empty on non-text wires; transformed alongside
/// `text_verts` by the block-expand loop and preview clones.
pub searchable_text: Vec<SearchableTextRun>,
/// Block-local composed draw-order offset in (-1, 1) for a wire that must
/// order against its *siblings inside the block* — currently wide-polyline
/// bands, whose solid area would otherwise cover later-drawn siblings.
/// `None` (all other wires) = the whole-insert depth resolved from `name`.
/// `Some(local)` composes at draw time: `insert_depth + local * insert_half`,
/// mirroring how exploded block fills seed their depth, so bands and fills
/// from the same block interleave by the block's internal draw order.
pub depth_override: Option<f32>,
/// Whether this wire is drawn in the normal viewport pass.
pub display_visible: bool,
/// Geometry that is never picked: the content of an underlay. Hidden
/// (`display_visible` false) it is what object snaps find; drawn, it is
/// display only and snaps skip it too ([`Self::is_display_only`]).
pub snap_only: bool,
/// Whether this wire is included in plotted output.
pub plot_visible: bool,
/// `true` when [`fill_tris`] is a real 3-D surface (PolyfaceMesh /
/// PolygonMesh face) that must render with hidden-surface depth and only in
/// shaded modes. `false` for a flat 2-D overlay fill (SOLID arrowhead,
/// dimension / MultiLeader text background, greek-LOD text) that draws in
/// every view mode. The render pass can't infer this from `fill_tris_low`
/// alone — a 2-D fill at UTM scale carries a low residual too.
pub fill_is_3d: bool,
/// `true` only for a planar SOLID entity's interior. Wireframe 3D omits
/// this fill while preserving its perimeter and every other 2-D overlay.
pub fill_is_2d_solid: bool,
/// Shared block-render source and this placement's translation.
pub render_instance: Option<super::instance_model::RenderInstance>,
}
impl WireModel {
pub const WHITE: [f32; 4] = [1.00, 1.00, 1.00, 1.0];
pub const CYAN: [f32; 4] = [0.25, 0.85, 1.00, 1.0];
pub const SELECTED: [f32; 4] = [0.15, 0.55, 1.00, 1.0];
/// Rollover (hover) highlight — orange, distinct from the blue selection.
pub const HOVER: [f32; 4] = [0.95, 0.55, 0.10, 1.0];
/// Drawn underlay content: shown and plotted, never picked or snapped.
pub fn is_display_only(&self) -> bool {
self.snap_only && self.display_visible
}
/// A POINT's wire: the Node is its only snap; the glyph strokes are drawn
/// but offer no endpoints or segments to snap to.
pub fn is_node_marker(&self) -> bool {
self.key_vertices.is_empty() && matches!(self.snap_pts.as_slice(), [(_, SnapHint::Node)])
}
/// Sentinel AABB that never rejects any snap query.
pub const UNBOUNDED_AABB: [f32; 4] = [
f32::NEG_INFINITY,
f32::NEG_INFINITY,
f32::INFINITY,
f32::INFINITY,
];
/// Double-single split: `high + low ≈ v` to ~f64 precision in two f32s.
/// Matches the renderer's relative-to-eye reconstruction.
#[inline]
pub fn split_ds(v: f64) -> (f32, f32) {
let high = v as f32;
(high, (v - high as f64) as f32)
}
/// Create a solid preview wire from f64 points, filling the double-single
/// `points_low` buffer so the line stays precise at UTM-scale coordinates.
/// Rubber-band previews built straight from f32 absolute points jitter
/// ~0.5 m at UTM because the wire pass is relative-to-eye and expects the
/// low residual; this keeps the preview glued to the cursor.
pub fn solid_f64(name: String, points: Vec<[f64; 3]>, color: [f32; 4], selected: bool) -> Self {
let mut hi = Vec::with_capacity(points.len());
let mut lo = Vec::with_capacity(points.len());
for [x, y, z] in points {
let (hx, lx) = Self::split_ds(x);
let (hy, ly) = Self::split_ds(y);
let (hz, lz) = Self::split_ds(z);
hi.push([hx, hy, hz]);
lo.push([lx, ly, lz]);
}
let mut w = Self::solid(name, hi, color, selected);
w.points_low = lo;
w
}
pub fn set_fixed_screen_width(&mut self, width_px: f32) {
let width_px = width_px.max(1.0);
self.line_weight_px = width_px;
self.world_width = -width_px;
}
/// Create a solid wire (no dash pattern, 1px weight).
pub fn solid(name: String, points: Vec<[f32; 3]>, color: [f32; 4], selected: bool) -> Self {
Self {
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(),
text_verts: Vec::new(),
searchable_text: Vec::new(),
name,
points,
points_low: Vec::new(),
color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: Self::UNBOUNDED_AABB,
plinegen: true,
dash_from_start: false,
dash_align_end: None,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
}
/// Return a clone with every point translated by `delta`.
pub fn translated(&self, delta: glam::Vec3) -> Self {
let mut out = self.clone();
out.name = format!("preview_{}", self.name);
out.color = Self::CYAN;
out.selected = false;
map_points(&mut out.points, &mut out.points_low, |point| {
point + delta.as_dvec3()
});
if let Some(marker) = &mut out.point_marker {
marker.origin += delta.as_dvec3();
}
{
let (dx, dy, dz) = (delta.x as f64, delta.y as f64, delta.z as f64);
out.map_text_layout(&|p| [p[0] + dx, p[1] + dy, p[2] + dz], 1.0, 0.0);
}
out.tangent_geoms = self
.tangent_geoms
.iter()
.map(|tg| tg.translated(delta.as_dvec3()))
.collect();
out
}
/// Return a preview clone with every point mapped through `map` (an
/// INSERT-style placement: per-axis scale, rotation, translation).
/// Tangent geometry is dropped; a preview is not snapped to.
pub fn mapped(&self, map: impl Fn(glam::DVec3) -> glam::DVec3) -> Self {
let mut out = self.clone();
out.name = format!("preview_{}", self.name);
out.color = Self::CYAN;
out.selected = false;
map_points(&mut out.points, &mut out.points_low, &map);
if let Some(marker) = &mut out.point_marker {
marker.origin = map(marker.origin);
}
if !out.text_verts.is_empty() {
out.text_verts = map_text_verts(&self.text_verts, |x, y, z| {
let p = map(glam::DVec3::new(x, y, z));
(p.x, p.y, p.z)
});
}
// Preview-only clone: searchable export runs are rebuilt from source
// geometry, never carried on a rubber-band preview.
out.searchable_text.clear();
out.tangent_geoms.clear();
out
}
/// Return a clone with every point rotated around `center` by `angle_rad`.
pub fn rotated(&self, center: glam::Vec3, angle_rad: f32) -> Self {
self.rotated_about_axis(center, glam::Vec3::Z, angle_rad)
}
/// Return a clone rotated about an arbitrary world-space axis.
pub fn rotated_about_axis(&self, center: glam::Vec3, axis: glam::Vec3, angle_rad: f32) -> Self {
let rotation = glam::Quat::from_axis_angle(axis.normalize_or_zero(), angle_rad);
let (s, c) = angle_rad.sin_cos();
let mut out = self.clone();
out.name = format!("preview_{}", self.name);
out.color = Self::CYAN;
out.selected = false;
for p in &mut out.points {
let mapped = center + rotation * (glam::Vec3::from_array(*p) - center);
*p = mapped.to_array();
}
for p in &mut out.points_low {
*p = (rotation * glam::Vec3::from_array(*p)).to_array();
}
if let Some(marker) = &mut out.point_marker {
let center = center.as_dvec3();
let rotation = glam::DQuat::from_axis_angle(
axis.as_dvec3().normalize_or(glam::DVec3::Z),
angle_rad as f64,
);
marker.origin = center + rotation * (marker.origin - center);
marker.normal = (rotation * marker.normal).normalize_or(glam::DVec3::Z);
marker.axis_x = (rotation * marker.axis_x).normalize_or(glam::DVec3::X);
marker.axis_y = (rotation * marker.axis_y).normalize_or(glam::DVec3::Y);
}
if !out.text_verts.is_empty() {
let center = center.as_dvec3();
let axis = axis.as_dvec3().normalize_or_zero();
let (s, c) = (s as f64, c as f64);
out.text_verts = map_text_verts(&self.text_verts, |x, y, z| {
let v = glam::DVec3::new(x, y, z) - center;
let mapped = center + v * c + axis.cross(v) * s + axis * axis.dot(v) * (1.0 - c);
(mapped.x, mapped.y, mapped.z)
});
}
out.tangent_geoms = self
.tangent_geoms
.iter()
.map(|tg| tg.rotated(center.as_dvec3(), axis.as_dvec3(), angle_rad as f64))
.collect();
out.searchable_text.clear();
out
}
/// Return a clone with every point uniformly scaled from `center` by `factor`.
pub fn scaled(&self, center: glam::Vec3, factor: f32) -> Self {
let mut out = self.clone();
out.name = format!("preview_{}", self.name);
out.color = Self::CYAN;
out.selected = false;
if let Some(marker) = out.point_marker {
let center = center.as_dvec3();
let normal = marker.normal.normalize_or(glam::DVec3::Z);
let target_origin = center + (marker.origin - center) * factor as f64;
map_points(&mut out.points, &mut out.points_low, |point| {
let delta = point - marker.origin;
let axial = normal * delta.dot(normal);
target_origin + (delta - axial) + axial * factor as f64
});
if let Some(target) = &mut out.point_marker {
target.origin = target_origin;
}
} else {
let center = center.as_dvec3();
map_points(&mut out.points, &mut out.points_low, |point| {
center + (point - center) * factor as f64
});
}
if !out.text_verts.is_empty() {
let (cx, cy, cz) = (center.x as f64, center.y as f64, center.z as f64);
let f = factor as f64;
out.text_verts = map_text_verts(&self.text_verts, |x, y, z| {
(cx + (x - cx) * f, cy + (y - cy) * f, cz + (z - cz) * f)
});
}
out.tangent_geoms = self
.tangent_geoms
.iter()
.map(|tg| tg.scaled(center.as_dvec3(), factor as f64))
.collect();
out.searchable_text.clear();
out
}
/// Return a clone for a stretch preview: every point whose XY lies inside
/// the crossing window `[win_min, win_max]` is translated by `delta`; points
/// outside stay put. Exact for line/polyline vertices (the primary stretch
/// targets); curve tessellation points may deform where a window edge cuts
/// through them, matching the per-vertex nature of the operation.
pub fn stretched(&self, win_min: glam::Vec3, win_max: glam::Vec3, delta: glam::Vec3) -> Self {
self.stretched_windows(&[(win_min, win_max)], delta)
}
/// Return a clone for a multi-window STRETCH preview. A point moves exactly
/// once when it lies inside any of the crossing windows.
pub fn stretched_windows(
&self,
windows: &[(glam::Vec3, glam::Vec3)],
delta: glam::Vec3,
) -> Self {
let mut out = self.clone();
out.name = format!("preview_{}", self.name);
out.color = Self::CYAN;
out.selected = false;
let inside = |x: f32, y: f32| {
windows.iter().any(|(win_min, win_max)| {
x >= win_min.x && x <= win_max.x && y >= win_min.y && y <= win_max.y
})
};
if let Some(marker) = out.point_marker {
if inside(marker.origin.x as f32, marker.origin.y as f32) {
map_points(&mut out.points, &mut out.points_low, |point| {
point + delta.as_dvec3()
});
if let Some(target) = &mut out.point_marker {
target.origin += delta.as_dvec3();
}
}
} else {
for p in &mut out.points {
if inside(p[0], p[1]) {
p[0] += delta.x;
p[1] += delta.y;
p[2] += delta.z;
}
}
}
if !out.text_verts.is_empty() {
let (dx, dy, dz) = (delta.x as f64, delta.y as f64, delta.z as f64);
out.text_verts = map_text_verts(&self.text_verts, |x, y, z| {
let inside = windows.iter().any(|(win_min, win_max)| {
x >= win_min.x as f64
&& x <= win_max.x as f64
&& y >= win_min.y as f64
&& y <= win_max.y as f64
});
if inside {
(x + dx, y + dy, z + dz)
} else {
(x, y, z)
}
});
}
out.searchable_text.clear();
out
}
/// Return a clone mirrored across the line through `p1`→`p2`.
pub fn mirrored(&self, p1: glam::Vec3, p2: glam::Vec3) -> Self {
self.mirrored_in_plane(p1, p2, glam::Vec3::Z)
}
/// Return a clone reflected through the plane containing the picked line
/// and the supplied working-plane normal.
pub fn mirrored_in_plane(
&self,
p1: glam::Vec3,
p2: glam::Vec3,
working_normal: glam::Vec3,
) -> Self {
let plane_normal = (p2 - p1)
.cross(working_normal.normalize_or_zero())
.normalize_or_zero();
let mut out = self.clone();
out.name = format!("preview_{}", self.name);
out.color = Self::CYAN;
out.selected = false;
if plane_normal.length_squared() < 1e-12 {
return out;
}
for p in &mut out.points {
let point = glam::Vec3::from_array(*p);
*p = (point - 2.0 * plane_normal.dot(point - p1) * plane_normal).to_array();
}
// World position is the double-single sum `points + points_low` (text /
// UTM wires split it), so the residual must reflect too — as a direction
// (linear reflection about the axis, no `p1` offset).
for p in &mut out.points_low {
let point = glam::Vec3::from_array(*p);
*p = (point - 2.0 * plane_normal.dot(point) * plane_normal).to_array();
}
if let Some(marker) = &mut out.point_marker {
let p1 = p1.as_dvec3();
let normal = plane_normal.as_dvec3();
marker.origin -= 2.0 * normal.dot(marker.origin - p1) * normal;
marker.normal = (marker.normal - 2.0 * normal.dot(marker.normal) * normal)
.normalize_or(glam::DVec3::Z);
marker.axis_x = (marker.axis_x - 2.0 * normal.dot(marker.axis_x) * normal)
.normalize_or(glam::DVec3::X);
marker.axis_y = (marker.axis_y - 2.0 * normal.dot(marker.axis_y) * normal)
.normalize_or(glam::DVec3::Y);
}
// Glyph quads reflect wholesale (true mirror) — the caller only routes
// text through here for MIRRTEXT-on; MIRRTEXT-off relocates via
// `translated` so glyphs stay readable.
if !out.text_verts.is_empty() {
let p1 = p1.as_dvec3();
let normal = plane_normal.as_dvec3();
out.text_verts = map_text_verts(&self.text_verts, |x, y, z| {
let point = glam::DVec3::new(x, y, z);
let mapped = point - 2.0 * normal.dot(point - p1) * normal;
(mapped.x, mapped.y, mapped.z)
});
}
out.tangent_geoms = self
.tangent_geoms
.iter()
.map(|tg| tg.mirrored(p1.as_dvec3(), plane_normal.as_dvec3()))
.collect();
out.searchable_text.clear();
out
}
/// Total arc-length of this wire (sum of segment lengths).
#[allow(dead_code)]
pub fn length(&self) -> f32 {
self.points
.windows(2)
.map(|w| {
let dx = w[1][0] - w[0][0];
let dy = w[1][1] - w[0][1];
let dz = w[1][2] - w[0][2];
(dx * dx + dy * dy + dz * dz).sqrt()
})
.sum()
}
pub fn point_world(&self, index: usize, view_height: f64) -> glam::DVec3 {
let high = self.points[index];
let low = self.points_low.get(index).copied().unwrap_or([0.0; 3]);
let point = glam::DVec3::new(
high[0] as f64 + low[0] as f64,
high[1] as f64 + low[1] as f64,
high[2] as f64 + low[2] as f64,
);
self.point_marker
.map_or(point, |marker| marker.resolve(point, view_height))
}
}
fn map_points(
points: &mut [[f32; 3]],
points_low: &mut Vec<[f32; 3]>,
map: impl Fn(glam::DVec3) -> glam::DVec3,
) {
let old_low = std::mem::take(points_low);
points_low.reserve(points.len());
for (index, high) in points.iter_mut().enumerate() {
if !high[0].is_finite() || !high[1].is_finite() || !high[2].is_finite() {
points_low.push([0.0; 3]);
continue;
}
let low = old_low.get(index).copied().unwrap_or([0.0; 3]);
let mapped = map(glam::DVec3::new(
high[0] as f64 + low[0] as f64,
high[1] as f64 + low[1] as f64,
high[2] as f64 + low[2] as f64,
));
let (hx, lx) = WireModel::split_ds(mapped.x);
let (hy, ly) = WireModel::split_ds(mapped.y);
let (hz, lz) = WireModel::split_ds(mapped.z);
*high = [hx, hy, hz];
points_low.push([lx, ly, lz]);
}
}
impl WireModel {
/// Remap every text channel geometrically in ONE call.
///
/// The SDF quads and the searchable runs describe the same placed text;
/// transforming one without the other strands invisible text away from its
/// outlines. Every geometric remap of text (block expand, OCS→WCS, paper
/// projection, preview drags) MUST go through here so the rule — origins
/// via `map`, cap height and advance scaled by `height_scale`, rotation
/// shifted by `rot_delta` — lives in exactly one place instead of being
/// re-implemented (and drifted) per call site.
pub fn map_text_layout(
&mut self,
map: &dyn Fn([f64; 3]) -> [f64; 3],
height_scale: f64,
rot_delta: f32,
) {
if !self.text_verts.is_empty() {
self.text_verts = map_text_verts(&self.text_verts, |x, y, z| {
let p = map([x, y, z]);
(p[0], p[1], p[2])
});
}
self.map_searchable_runs(map, height_scale, rot_delta);
}
/// Remap only the searchable runs (origin, cap height, advance, rotation).
/// Used where the quads need custom handling (per-vertex colour, per-quad
/// culling) but the runs must follow the same geometry — same rule as
/// [`Self::map_text_layout`], runs half.
pub fn map_searchable_runs(
&mut self,
map: &dyn Fn([f64; 3]) -> [f64; 3],
height_scale: f64,
rot_delta: f32,
) {
map_searchable_runs(&mut self.searchable_text, map, height_scale, rot_delta);
}
}
/// Remap a run slice geometrically: origins via `map`, cap height and advance
/// scaled by `height_scale`, rotation shifted by `rot_delta`. The single rule
/// behind [`WireModel::map_text_layout`] and [`WireModel::map_searchable_runs`];
/// batch code (block expand) maps runs it appends to shared entries through
/// this so the geometry can never drift from the method pair.
pub fn map_searchable_runs(
runs: &mut [SearchableTextRun],
map: &dyn Fn([f64; 3]) -> [f64; 3],
height_scale: f64,
rot_delta: f32,
) {
for run in runs {
run.origin = map(run.origin);
run.height = (run.height as f64 * height_scale) as f32;
run.adv_width = (run.adv_width as f64 * height_scale) as f32;
run.rotation += rot_delta;
}
}
/// Overlap of a text run's rect with an axis-aligned rect (SAT on both axes).
///
/// The run occupies `adv_width` along its baseline direction and roughly
/// `height` above (cap) plus a descender allowance below: corners span
/// `[-0.3h, +1.0h]` across the baseline. `rect` is `[x0, y0, x1, y1]`.
/// Conservative by design (it only gates searchability, never drawing).
pub fn run_rect_overlap(
origin: [f64; 2],
rotation: f32,
adv_width: f64,
height: f64,
rect: [f64; 4],
) -> bool {
if !adv_width.is_finite() || !height.is_finite() || adv_width <= 0.0 || height <= 0.0 {
return true;
}
let (s, c) = rotation.sin_cos();
let (dx, dy) = (c as f64, s as f64);
let (nx, ny) = (-dy, dx);
let corners = [
[
origin[0] + nx * -0.3 * height,
origin[1] + ny * -0.3 * height,
],
[
origin[0] + dx * adv_width + nx * -0.3 * height,
origin[1] + dy * adv_width + ny * -0.3 * height,
],
[
origin[0] + dx * adv_width + nx * height,
origin[1] + dy * adv_width + ny * height,
],
[origin[0] + nx * height, origin[1] + ny * height],
];
// Separating-axis test on the OBB axes plus world x/y.
for (ax, ay) in [(dx, dy), (nx, ny), (1.0, 0.0), (0.0, 1.0)] {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for p in &corners {
let t = p[0] * ax + p[1] * ay;
lo = lo.min(t);
hi = hi.max(t);
}
let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY);
for p in [
[rect[0], rect[1]],
[rect[2], rect[1]],
[rect[2], rect[3]],
[rect[0], rect[3]],
] {
let t = p[0] * ax + p[1] * ay;
rlo = rlo.min(t);
rhi = rhi.max(t);
}
if hi < rlo || rhi < lo {
return false;
}
}
true
}
/// Map every glyph vertex's double-single world position through `f`, re-
/// splitting the result. The preview transforms above move `points`, but SDF
/// glyph quads live in `text_verts` (absolute-world double-single) — so a text
/// ghost (MOVE / COPY / ROTATE / SCALE / STRETCH / MIRROR preview) must carry
/// these along or the dragged text renders frozen at its source (issue #316).
/// Paper-space viewport projection uses it for the same reason (issue #385).
pub(crate) fn map_text_verts(
verts: &[crate::scene::pipeline::text_gpu::TextVertex],
f: impl Fn(f64, f64, f64) -> (f64, f64, f64),
) -> Vec<crate::scene::pipeline::text_gpu::TextVertex> {
use crate::scene::pipeline::text_gpu::split_ds;
verts
.iter()
.map(|v| {
let (nx, ny, nz) = f(
v.pos[0] as f64 + v.pos_low[0] as f64,
v.pos[1] as f64 + v.pos_low[1] as f64,
v.pos[2] as f64 + v.pos_low[2] as f64,
);
let (xh, xl) = split_ds(nx);
let (yh, yl) = split_ds(ny);
let (zh, zl) = split_ds(nz);
crate::scene::pipeline::text_gpu::TextVertex {
pos: [xh, yh, zh],
pos_low: [xl, yl, zl],
..*v
}
})
.collect()
}
impl Default for WireModel {
fn default() -> Self {
Self {
point_marker: None,
text_verts: Vec::new(),
searchable_text: Vec::new(),
name: String::new(),
points: Vec::new(),
points_low: Vec::new(),
color: Self::WHITE,
bg_adapt: None,
selected: false,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
world_width: 0.0,
taper_widths: Vec::new(),
pattern_stations: Vec::new(),
aci: 0,
snap_pts: Vec::new(),
tangent_geoms: Vec::new(),
key_vertices: Vec::new(),
aabb: Self::UNBOUNDED_AABB,
plinegen: true,
dash_from_start: false,
dash_align_end: None,
fill_tris: Vec::new(),
fill_tris_low: Vec::new(),
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(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preview_transformations_preserve_and_update_tangent_geoms() {
let mut wire = WireModel::default();
wire.tangent_geoms.push(TangentGeom::PlanarCircle {
center: [10.0, 20.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 5.0,
});
wire.tangent_geoms.push(TangentGeom::Arc {
center: [10.0, 20.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 5.0,
start_angle: 0.0,
end_angle: std::f64::consts::FRAC_PI_2,
});
wire.tangent_geoms.push(TangentGeom::PlanarEllipse {
center: [10.0, 20.0, 0.0],
major_axis: [10.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.5,
start_param: 0.0,
end_param: std::f64::consts::PI,
});
// Test translated
let trans = wire.translated(glam::Vec3::new(5.0, -5.0, 1.0));
assert_eq!(trans.tangent_geoms.len(), 3);
if let TangentGeom::PlanarCircle { center, radius, .. } = trans.tangent_geoms[0] {
assert!((center[0] - 15.0).abs() < 1e-5);
assert!((center[1] - 15.0).abs() < 1e-5);
assert!((center[2] - 1.0).abs() < 1e-5);
assert!((radius - 5.0).abs() < 1e-5);
} else {
panic!("Expected PlanarCircle");
}
if let TangentGeom::Arc {
center,
radius,
start_angle,
end_angle,
..
} = trans.tangent_geoms[1]
{
assert!((center[0] - 15.0).abs() < 1e-5);
assert!((center[1] - 15.0).abs() < 1e-5);
assert!((center[2] - 1.0).abs() < 1e-5);
assert!((radius - 5.0).abs() < 1e-5);
assert_eq!(start_angle, 0.0);
assert_eq!(end_angle, std::f64::consts::FRAC_PI_2);
} else {
panic!("Expected Arc");
}
if let TangentGeom::PlanarEllipse {
center,
major_axis,
minor_axis_ratio,
..
} = trans.tangent_geoms[2]
{
assert!((center[0] - 15.0).abs() < 1e-5);
assert!((center[1] - 15.0).abs() < 1e-5);
assert!((center[2] - 1.0).abs() < 1e-5);
assert_eq!(major_axis, [10.0, 0.0, 0.0]);
assert_eq!(minor_axis_ratio, 0.5);
} else {
panic!("Expected PlanarEllipse");
}
// Test scaled
let scaled = wire.scaled(glam::Vec3::new(10.0, 20.0, 0.0), 2.0);
if let TangentGeom::PlanarCircle { radius, .. } = scaled.tangent_geoms[0] {
assert!((radius - 10.0).abs() < 1e-5);
} else {
panic!("Expected PlanarCircle");
}
if let TangentGeom::Arc { radius, .. } = scaled.tangent_geoms[1] {
assert!((radius - 10.0).abs() < 1e-5);
} else {
panic!("Expected Arc");
}
if let TangentGeom::PlanarEllipse { major_axis, .. } = scaled.tangent_geoms[2] {
assert!((major_axis[0] - 20.0).abs() < 1e-5);
} else {
panic!("Expected PlanarEllipse");
}
// Test rotated
let rot = wire.rotated(glam::Vec3::ZERO, std::f32::consts::FRAC_PI_2);
if let TangentGeom::PlanarCircle {
center,
axis_x,
axis_y,
..
} = rot.tangent_geoms[0]
{
assert!((center[0] - -20.0).abs() < 1e-4);
assert!((center[1] - 10.0).abs() < 1e-4);
assert!((axis_x[1] - 1.0).abs() < 1e-4);
assert!((axis_y[0] - -1.0).abs() < 1e-4);
} else {
panic!("Expected PlanarCircle");
}
// Test mirrored across Y=0 plane (normal = Y)
let mirr = wire.mirrored(
glam::Vec3::new(0.0, 0.0, 0.0),
glam::Vec3::new(1.0, 0.0, 0.0),
);
if let TangentGeom::PlanarCircle { center, .. } = mirr.tangent_geoms[0] {
assert!((center[0] - 10.0).abs() < 1e-4);
assert!((center[1] - -20.0).abs() < 1e-4);
} else {
panic!("Expected PlanarCircle");
}
}
#[test]
fn map_text_layout_moves_quads_and_runs_together() {
let mut wire = WireModel::default();
wire.searchable_text.push(SearchableTextRun {
text: "AB".into(),
origin: [10.0, 20.0, 0.0],
height: 5.0,
rotation: 0.0,
color: WireModel::WHITE,
bold: false,
font: "txt".into(),
adv_width: 8.0,
});
// Uniform scale ×2 about the origin plus translation: quads and runs
// must agree afterwards (the lockstep this helper exists to enforce).
wire.map_text_layout(&|p| [p[0] * 2.0 + 1.0, p[1] * 2.0, p[2]], 2.0, 0.5);
let run = &wire.searchable_text[0];
assert_eq!(run.origin, [21.0, 40.0, 0.0]);
assert!((run.height - 10.0).abs() < 1e-4);
assert!((run.adv_width - 16.0).abs() < 1e-4);
assert!((run.rotation - 0.5).abs() < 1e-6);
}
#[test]
fn run_rect_overlap_keeps_straddlers_drops_outsiders() {
assert!(run_rect_overlap(
[10.0, 20.0],
0.0,
8.0,
5.0,
[0.0, 0.0, 30.0, 30.0]
));
assert!(!run_rect_overlap(
[10.0, 20.0],
0.0,
8.0,
5.0,
[100.0, 100.0, 130.0, 130.0]
));
// A 90° run crossing the rect edge still counts (origin-only culling
// would have dropped it).
assert!(run_rect_overlap(
[28.0, 20.0],
std::f32::consts::FRAC_PI_2,
8.0,
5.0,
[0.0, 0.0, 30.0, 30.0]
));
// Degenerate runs never cull (conservative: searchability only).
assert!(run_rect_overlap(
[500.0, 500.0],
0.0,
0.0,
5.0,
[0.0, 0.0, 30.0, 30.0]
));
}
#[test]
fn searchable_text_cleaning_handles_mtext_traps() {
// `\P` is a real newline → space (runs arrive per-line).
assert_eq!(clean_searchable_text("A\\PB"), "A B");
// Decoration toggles are stripped, not searched.
assert_eq!(clean_searchable_text("\\LAB\\l"), "AB");
// DXF specials decode (incl. %%nnn codes).
assert_eq!(clean_searchable_text("%%d"), "°");
assert_eq!(clean_searchable_text("%%p"), "±");
assert_eq!(clean_searchable_text("%%c"), "Ø");
assert_eq!(clean_searchable_text("%%065"), "A");
// Stacking, unicode escapes and grouping braces.
assert_eq!(clean_searchable_text("\\S1^2;"), "1/2");
assert_eq!(clean_searchable_text("\\U+00E9"), "é");
assert_eq!(clean_searchable_text("{AB}"), "AB");
// Plain text survives verbatim (drawing codes stay searchable).
assert_eq!(clean_searchable_text("CMa-03"), "CMa-03");
assert_eq!(clean_searchable_text(""), "");
}
}