/// 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, pub pieces: Vec, } 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, source_length: f32, point_segments: &[i32], pieces: &[PatternStationPiece], ) -> Vec { 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 { 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>; /// 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::() { 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, /// 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, /// Centreline stations and optional source-piece transform metadata. pub pattern_stations: Vec, /// 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, /// 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, /// 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, /// 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, /// 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, /// 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, } 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 { 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(""), ""); } }