use std::cell::Cell; use std::collections::HashSet; use crate::scene::model::object::{GripDef, GripShape, PropValue, Property}; use crate::scene::model::wire_model::PatternStationPiece; pub const VARIES_LABEL: &str = "*VARIES*"; /// Linear / angular unit format pulled from the document header so the /// per-thread properties pipeline can format values consistently without /// passing the document through every callsite. #[derive(Clone, Copy, Default)] pub struct UnitContext { /// LUNITS — 1=Sci, 2=Decimal, 3=Engineering, 4=Architectural, 5=Fractional pub lunits: i16, /// LUPREC — decimal places (linear) pub luprec: i16, /// AUNITS — 0=Decimal degrees, 1=DMS, 2=Grad, 3=Rad, 4=Surveyor. Surfaced /// via `format_angle`, which is read on demand by code that already /// formats angular values via radians. #[allow(dead_code)] pub aunits: i16, /// AUPREC — decimal places (angular) #[allow(dead_code)] pub auprec: i16, /// ANGBASE — the world direction that a written angle of zero points in, /// in radians. Applies to directions, never to angular sizes. pub angbase: f64, /// ANGDIR — true when written angles grow clockwise. pub angdir_cw: bool, } impl UnitContext { /// The settings as this drawing holds them. Every place that formats or /// reads a number seeds the context from here, so none of them can be /// working from a different idea of the drawing's conventions. pub fn from_header(header: &acadrust::document::HeaderVariables) -> Self { Self { lunits: header.linear_unit_format, luprec: header.linear_unit_precision, aunits: header.angular_unit_format, auprec: header.angular_unit_precision, angbase: header.angle_base, angdir_cw: header.angle_direction != 0, } } } thread_local! { static UNIT_CTX: Cell = const { Cell::new(UnitContext { lunits: 2, luprec: 4, aunits: 0, auprec: 0, angbase: 0.0, angdir_cw: false, }) }; } thread_local! { /// Text styles that fix their own height, by lowercased name. /// /// A style with a non-zero height fixes the size of everything drawn in it /// — the CAD that writes the file skips the height prompt for such a style, /// and the properties palette shows the height without letting it be /// changed. The panel builders are handed an entity, not the document it /// came from, so the lookup rides here beside the unit context, seeded from /// the same place. static FIXED_TEXT_HEIGHTS: std::cell::RefCell> = std::cell::RefCell::new(rustc_hash::FxHashMap::default()); } /// Record which text styles fix their height, from the drawing's style table. pub fn set_fixed_text_heights(document: &acadrust::CadDocument) { FIXED_TEXT_HEIGHTS.with(|cell| { let mut map = cell.borrow_mut(); map.clear(); for style in document.text_styles.iter() { if style.height > 0.0 { map.insert(style.name.to_ascii_lowercase(), style.height); } } }); } /// The height `style` fixes, or `None` when it leaves the height to the entity. pub fn style_fixed_height(style: &str) -> Option { let key = style.trim().to_ascii_lowercase(); FIXED_TEXT_HEIGHTS.with(|cell| cell.borrow().get(&key).copied()) } /// Set the per-thread unit context. Properties helpers consult it when /// they format f64 values into display strings. pub fn set_unit_context(ctx: UnitContext) { UNIT_CTX.with(|c| c.set(ctx)); } pub fn unit_context() -> UnitContext { UNIT_CTX.with(|c| c.get()) } /// Format a linear length using LUNITS / LUPREC. Architectural / fractional /// produce "n'-d/D"" style strings (1 unit = 1 inch); decimal / scientific / /// engineering / Windows-desktop fall back to plain decimal at LUPREC places. pub fn format_length(value: f64) -> String { without_negative_zero(format_signed_length(value)) } /// Drop the sign from a formatted number whose digits all rounded to zero. A /// rotation leaves coordinates like -1e-15 behind, which read as `-0.0000`. fn without_negative_zero(text: String) -> String { match text.strip_prefix('-') { Some(rest) if rest.chars().any(|c| c.is_ascii_digit()) && !rest.chars().any(|c| matches!(c, '1'..='9')) => { rest.to_string() } _ => text, } } fn format_signed_length(value: f64) -> String { let ctx = unit_context(); let prec = ctx.luprec.max(0) as usize; match ctx.lunits { 1 => format!("{:.*e}", prec, value), 3 => { // Round before splitting so twelve inches carries into feet. let sign = if value < 0.0 { "-" } else { "" }; let abs = value.abs(); let scale = 10f64.powi(prec.min(15) as i32); let total = (abs * scale).round(); let per_foot = 12.0 * scale; let feet = (total / per_foot).trunc(); let rem = (total - feet * per_foot) / scale; if feet == 0.0 { format!("{}{:.*}\"", sign, prec, rem) } else { format!("{}{:.0}'-{:.*}\"", sign, feet, prec, rem) } } 4 | 5 => { // Architectural and fractional formats use 1/64-inch resolution. let sign = if value < 0.0 { "-" } else { "" }; let abs = value.abs(); let denom = 64.0; let ticks = (abs * denom).round(); let (feet, rest) = if ctx.lunits == 4 { let per_foot = 12.0 * denom; (Some((ticks / per_foot).floor()), ticks.rem_euclid(per_foot)) } else { (None, ticks) }; let whole = (rest / denom).floor(); let mut n = (rest - whole * denom).round() as u64; let mut d = denom as u64; while d > 1 && n % 2 == 0 && d % 2 == 0 { n /= 2; d /= 2; } let frac_str = if n == 0 || d == 1 { String::new() } else { format!(" {}/{}", n, d) }; let unit_suffix = if ctx.lunits == 4 { "\"" } else { "" }; match feet { Some(f) if f == 0.0 => { format!("{}{:.0}{}{}", sign, whole, frac_str, unit_suffix) } Some(f) => format!("{}{:.0}'-{:.0}{}{}", sign, f, whole, frac_str, unit_suffix), None => format!("{}{:.0}{}", sign, whole, frac_str), } } _ => format!("{:.*}", prec, value), } } /// Format an area with the drawing's linear precision. Area stays a decimal /// scalar even when linear distances use architectural or fractional notation. pub fn format_area(value: f64) -> String { let ctx = unit_context(); let precision = ctx.luprec.max(0).min(15) as usize; if ctx.lunits == 1 { format!("{:.*e}", precision, value) } else { format!("{:.*}", precision, value) } } /// Format an angle (input in radians) using AUNITS / AUPREC. pub fn format_angle(value_rad: f64) -> String { without_negative_zero(format_signed_angle(value_rad)) } fn format_signed_angle(value_rad: f64) -> String { let ctx = unit_context(); let prec = ctx.auprec.max(0) as usize; match ctx.aunits { 1 => dms(value_rad.to_degrees(), prec), 2 => { let g = value_rad.to_degrees() / 0.9; format!("{:.*}g", prec, g) } 3 => format!("{:.*}r", prec, value_rad), 4 => surveyor(value_rad, prec), _ => format!("{:.*}°", prec, value_rad.to_degrees()), } } /// Degrees / minutes / seconds. Each part carries its own mark — `d`, `'`, `"` /// — so the written angle says which convention it is in. fn dms(degrees: f64, prec: usize) -> String { let sign = if degrees < 0.0 { "-" } else { "" }; let a = degrees.abs(); // Round before splitting so seconds carry into minutes and degrees. let scale = 10f64.powi(prec.min(15) as i32); let per_minute = 60.0 * scale; let per_degree = 60.0 * per_minute; let ticks = (a * per_degree).round(); let d = (ticks / per_degree).trunc(); let rest = ticks - d * per_degree; let m = (rest / per_minute).trunc(); let s = (rest - m * per_minute) / scale; format!("{}{:.0}d{:.0}'{:.*}\"", sign, d, m, prec, s) } /// Surveyor's units: a bearing away from north or south, toward east or west, /// so the angle is never more than a quarter turn — `N 45d0'0" E`. /// /// Due north, south, east and west have no bearing to quote and are written as /// the single letter, which is also what keeps a 90° angle from reading as the /// contradictory `N 90d0'0" E`. fn surveyor(value_rad: f64, prec: usize) -> String { // Quantize before choosing a cardinal or quadrant. let scale = 3600.0 * 10f64.powi(prec.min(15) as i32); let turn = 360.0 * scale; let ticks = (value_rad.to_degrees().rem_euclid(360.0) * scale) .round() .rem_euclid(turn); let at = |degrees: f64| (ticks - degrees * scale).abs() < 0.5; if at(0.0) { return "E".into(); } if at(90.0) { return "N".into(); } if at(180.0) { return "W".into(); } if at(270.0) { return "S".into(); } let deg = ticks / scale; // Measured from the nearer pole, toward the side the angle falls on. let (pole, bearing, side) = if deg < 90.0 { ("N", 90.0 - deg, "E") } else if deg < 180.0 { ("N", deg - 90.0, "W") } else if deg < 270.0 { ("S", 270.0 - deg, "W") } else { ("S", deg - 270.0, "E") }; format!("{pole} {} {side}", dms(bearing, prec)) } // ── Reading numbers back ─────────────────────────────────────────────────── // // The inverses of the formatters above, kept beside them so the pair cannot // drift: whatever the drawing writes, it can be handed back. Reading is the // looser of the two — it takes the shorthands people type as well as the full // forms — but it never accepts a form the drawing could not have produced. /// Read a length. Accepts plain decimals, feet-and-inches, and fractions. /// /// Feet and inches separate with a dash, a space, or nothing, and the closing /// `"` is optional: `5'-9 1/2"`, `5' 9-1/2`, `5'9`, `9 1/2`, `1/2`, `60"` and /// `5'` all read. As with the architectural and engineering formats, the /// notation itself means one unit is one inch. /// /// A leading `-` is a sign, but the `-` inside `5'-9"` is a separator — after /// feet there is nothing left to subtract from. pub fn parse_length(text: &str) -> Option { let text = text.trim(); let (sign, rest) = match text.strip_prefix('-') { Some(rest) => (-1.0, rest.trim_start()), None => (1.0, text.strip_prefix('+').unwrap_or(text).trim_start()), }; if rest.is_empty() { return None; } let magnitude = match rest.split_once('\'') { Some((feet, inches)) => { let feet: f64 = feet.trim().parse().ok()?; let inches = inches.trim().trim_end_matches('"').trim(); feet * 12.0 + parse_inches(inches.trim_start_matches('-').trim())? } None => parse_inches(rest.trim_end_matches('"').trim())?, }; Some(sign * magnitude) } /// A count of inches: `9`, `9.5`, `1/2`, `9 1/2`, `9-1/2`, or nothing at all. fn parse_inches(text: &str) -> Option { if text.is_empty() { return Some(0.0); } let Some(slash) = text.find('/') else { // No fraction, so any `-` left in here belongs to an exponent // (`3.35E-01`) rather than to a separator. return text.parse().ok(); }; let denominator: f64 = text[slash + 1..].trim().parse().ok()?; if denominator == 0.0 { return None; } // The numerator is the number nearest the slash; anything before the // separator in front of it is a whole count of inches. let head = text[..slash].trim(); let (whole, numerator) = match head.rfind([' ', '-']) { Some(cut) => (head[..cut].trim(), head[cut + 1..].trim()), None => ("", head), }; let numerator: f64 = numerator.parse().ok()?; let whole: f64 = if whole.is_empty() { 0.0 } else { whole.parse().ok()? }; Some(whole + numerator / denominator) } /// Read an angle, in radians. /// /// A mark decides the convention — `g` grads, `r` radians, `d`/`'`/`"` /// degrees-minutes-seconds, compass letters a surveyor's bearing. A bare /// number is read in whatever convention the drawing is set to, so what the /// readout shows can be typed straight back. pub fn parse_angle(text: &str) -> Option { let text = text.trim(); if text.is_empty() { return None; } let upper = text.to_ascii_uppercase(); if upper.starts_with('N') || upper.starts_with('S') { return parse_surveyor(&upper); } if let Some(body) = upper.strip_suffix('G') { return Some((body.trim().parse::().ok()? * 0.9).to_radians()); } if let Some(body) = upper.strip_suffix('R') { return body.trim().parse().ok(); } // Bare east/west read as the directions they name. if upper == "E" { return Some(0.0); } if upper == "W" { return Some(std::f64::consts::PI); } if upper.contains('D') || upper.contains('°') || upper.contains('\'') { return Some(parse_dms(&upper)?.to_radians()); } let value: f64 = upper.parse().ok()?; Some(match unit_context().aunits { 2 => (value * 0.9).to_radians(), 3 => value, _ => value.to_radians(), }) } /// Degrees, minutes and seconds, each part optional after the first: /// `45`, `45d`, `45d20'`, `45d20'6"`. fn parse_dms(upper: &str) -> Option { let (sign, rest) = match upper.strip_prefix('-') { Some(rest) => (-1.0, rest.trim_start()), None => (1.0, upper), }; let (degrees, rest) = match rest.find(['D', '°']) { Some(at) => ( rest[..at].trim().parse::().ok()?, rest[at + rest[at..].chars().next()?.len_utf8()..].trim(), ), None => (rest.trim().parse::().ok()?, ""), }; let (minutes, rest) = match rest.split_once('\'') { Some((minutes, rest)) if !minutes.trim().is_empty() => { (minutes.trim().parse::().ok()?, rest.trim()) } Some((_, rest)) => (0.0, rest.trim()), None => (0.0, rest), }; let seconds = match rest.trim_end_matches('"').trim() { "" => 0.0, seconds => seconds.parse::().ok()?, }; Some(sign * (degrees + minutes / 60.0 + seconds / 3600.0)) } /// A surveyor's bearing — `N45D20'6"E`, or a lone `N` / `S` for due north and /// south. The inverse of [`surveyor`]. fn parse_surveyor(upper: &str) -> Option { let pole = upper.chars().next()?; let body = upper[1..].trim(); if body.is_empty() { return Some(if pole == 'N' { std::f64::consts::FRAC_PI_2 } else { 3.0 * std::f64::consts::FRAC_PI_2 }); } let side = body.chars().last()?; if !matches!(side, 'E' | 'W') { return None; } let bearing = parse_dms(body[..body.len() - 1].trim())?; let degrees = match (pole, side) { ('N', 'E') => 90.0 - bearing, ('N', _) => 90.0 + bearing, (_, 'W') => 270.0 - bearing, _ => 270.0 + bearing, }; Some(degrees.to_radians()) } /// Read a length typed at a command prompt. /// /// Same forms as [`parse_length`], plus the comma some keyboards put where a /// decimal point belongs. A prompt asking for one value has no other use for a /// comma, so taking it here is safe in a way it would not be inside a /// coordinate, where the comma separates the axes. pub fn parse_typed_length(text: &str) -> Option { parse_length(&text.replace(',', ".")) } /// Read an angle typed at a command prompt, in radians. Same forms as /// [`parse_angle`], with the same tolerance for a decimal comma. pub fn parse_typed_angle(text: &str) -> Option { parse_angle(&text.replace(',', ".")) } // ── Directions ───────────────────────────────────────────────────────────── // // A direction is not an angular size. Which way something points is measured // from ANGBASE and runs the way ANGDIR says, while how wide an arc opens is // the same number whatever zero the drawing counts from. Only directions go // through the pair below; `format_angle` and `parse_angle` stay for sizes. /// Write a world direction the way this drawing counts directions. pub fn format_direction(world_rad: f64) -> String { let ctx = unit_context(); let relative = world_rad - ctx.angbase; let shown = if ctx.angdir_cw { -relative } else { relative }; format_angle(shown.rem_euclid(std::f64::consts::TAU)) } /// Read a direction written the way this drawing counts them, as a world /// angle. pub fn parse_direction(text: &str) -> Option { let ctx = unit_context(); let typed = parse_angle(text)?; let relative = if ctx.angdir_cw { -typed } else { typed }; Some(relative + ctx.angbase) } /// Read a direction typed at a command prompt, accepting a decimal comma. pub fn parse_typed_direction(text: &str) -> Option { parse_direction(&text.replace(',', ".")) } /// Two interior triangles covering a quad (flat list, 6 vertices) — the /// click-anywhere pick surface for frame-like entities (image, OLE frame, /// underlay, wipeout). Corners in ring order. pub fn quad_pick_tris(c: &[[f64; 3]; 4]) -> Vec<[f64; 3]> { vec![c[0], c[1], c[2], c[0], c[2], c[3]] } pub fn square_grip(id: usize, world: glam::DVec3) -> GripDef { GripDef { id, world, is_midpoint: false, shape: GripShape::Square, dir: None, axis: None, } } /// Centre / translate grip — same square marker as a vertex grip but /// flagged as a "whole-object move" handle for the grip-edit code. pub fn center_grip(id: usize, world: glam::DVec3) -> GripDef { GripDef { id, world, is_midpoint: true, shape: GripShape::Square, dir: None, axis: None, } } /// Circle grip — a round handle, flagged as a whole-object move. Used for /// special anchors like a hatch's pattern origin. pub fn circle_grip(id: usize, world: glam::DVec3) -> GripDef { GripDef { id, world, is_midpoint: true, shape: GripShape::Circle, dir: None, axis: None, } } /// Round grip that edits one vertex. pub fn round_grip(id: usize, world: glam::DVec3) -> GripDef { GripDef { id, world, is_midpoint: false, shape: GripShape::Circle, dir: None, axis: None, } } /// Screen-offset down-arrow that opens a choice menu for an entity. pub fn dropdown_grip(id: usize, world: glam::DVec3) -> GripDef { GripDef { id, world, is_midpoint: false, shape: GripShape::Dropdown, dir: None, axis: None, } } /// Mid-segment stretch grip oriented along `dir` (the segment's in-plane /// world-XY direction). Drawn as a small rectangle elongated along the /// segment so the affordance reads as "stretch perpendicular". pub fn rectangle_grip(id: usize, world: glam::DVec3, dir: [f32; 2]) -> GripDef { GripDef { id, world, is_midpoint: true, shape: GripShape::Rectangle, dir: Some(glam::DVec3::new(dir[0] as f64, dir[1] as f64, 0.0)), axis: None, } } #[allow(dead_code)] pub fn triangle_grip(id: usize, world: glam::DVec3) -> GripDef { GripDef { id, world, is_midpoint: false, shape: GripShape::Triangle, dir: None, axis: None, } } /// Triangle grip pointing along a world-space direction. pub fn oriented_triangle_grip(id: usize, world: glam::DVec3, dir: glam::DVec3) -> GripDef { GripDef { id, world, is_midpoint: false, shape: GripShape::Triangle, dir: Some(dir), axis: None, } } /// Editable ANGLE row: displays via AUNITS/AUPREC. Angle rows used the /// LINEAR formatter, so LUNITS=Architectural showed a block rotation as /// feet-and-inches and the string wouldn't parse back (#297). Value in /// DEGREES, matching every angle call site. pub fn edit_angle_prop(label: &str, field: &'static str, value_deg: f64) -> Property { Property { label: label.into(), field, value: PropValue::EditText(format_angle(value_deg.to_radians())), } } pub fn edit_prop(label: &str, field: &'static str, value: f64) -> Property { Property { label: label.into(), field, value: PropValue::EditText(format_length(value)), } } /// Editable dimensionless value independent of drawing units. pub fn edit_scalar_prop(label: &str, field: &'static str, value: f64) -> Property { let value = if value == 0.0 { 0.0 } else { value }; Property { label: label.into(), field, value: PropValue::EditText(value.to_string()), } } /// Standard lineweight choices shared by entity-specific Properties rows. pub fn lineweight_options() -> Vec { let mut options = vec![ "ByLayer".to_string(), "ByBlock".to_string(), "Default".to_string(), ]; for value in [ 0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211, ] { options.push(format!("{:.2} mm", value as f64 / 100.0)); } options } /// DIMLWD/DIMLWE lineweight value displayed by a Properties dropdown. pub fn lineweight_label(value: i16) -> String { match value { -1 => "ByLayer".to_string(), -2 => "ByBlock".to_string(), -3 => "Default".to_string(), value if value >= 0 => format!("{:.2} mm", value as f64 / 100.0), _ => "Default".to_string(), } } pub fn ro_prop(label: &str, field: &'static str, value: impl Into) -> Property { Property { label: label.into(), field, value: PropValue::ReadOnly(value.into()), } } /// A numeric row that is an editable box when `editable`, otherwise a grayed /// read-only value using the same length formatting. Used where a field's /// editability depends on entity state (e.g. a text point that is only live for /// certain justifications, or an MText column dimension). pub fn num_prop(label: &str, field: &'static str, value: f64, editable: bool) -> Property { if editable { edit_prop(label, field, value) } else { ro_prop(label, field, format_length(value)) } } /// A ◀ / ▶ index navigator row (e.g. a polyline's Current Vertex). `display` is /// the label shown between the arrows (e.g. "2 / 7"). pub fn stepper_prop( label: &str, field: &'static str, display: impl Into, ) -> Property { Property { label: label.into(), field, value: PropValue::Stepper { field, display: display.into(), }, } } pub fn parse_f64(value: &str) -> Option { let t = value.trim(); // Length rows display via LUNITS and angle rows via AUNITS. Accept both // representations back so a value shown by Properties can always be // committed unchanged. t.parse::() .ok() .or_else(|| parse_length(t)) .or_else(|| parse_angle_deg(t)) } /// Parse an angle string the panel displayed via AUNITS back to DEGREES: /// "30", "30°"/"30d", DMS "30°15'20.5\"", grads "33.33g", radians "0.52r". pub fn parse_angle_deg(value: &str) -> Option { let s = value.trim(); if s.is_empty() { return None; } if let Ok(v) = s.parse::() { return Some(v); } let lower = s.to_ascii_lowercase(); if let Some(num) = lower.strip_suffix('g') { return num.trim().parse::().ok().map(|g| g * 0.9); } if let Some(num) = lower.strip_suffix('r') { return num.trim().parse::().ok().map(f64::to_degrees); } // Degrees with optional DMS parts: [-]D(°|d)[M'[S"]] let mut rest = s; let neg = rest.starts_with('-'); if neg { rest = &rest[1..]; } let i = rest.find(['°', 'd', 'D'])?; let d: f64 = rest[..i].trim().parse().ok()?; let mut total = d; let tail = rest[i..] .trim_start_matches(['°', 'd', 'D']) .trim(); if !tail.is_empty() { let (mpart, spart) = match tail.find('\'') { Some(j) => (&tail[..j], &tail[j + 1..]), None => (tail, ""), }; if !mpart.trim().is_empty() { total += mpart.trim().parse::().ok()? / 60.0; } let sp = spart.trim().trim_end_matches('"').trim(); if !sp.is_empty() { total += sp.parse::().ok()? / 3600.0; } } Some(if neg { -total } else { total }) } /// Bulge → arc geometry for a polyline segment, from the kernel. /// /// Re-exported rather than imported at each call site so the twelve modules /// that already reach for `entities::common::BulgeArc` keep working, and so /// there is one obvious place to see that the maths moved out. pub use cadkernel::geom2d::BulgeArc; /// Convert a 2D BulgeArc into a 3D TangentGeom::Arc with its world center, /// plane axes, radius, and counter-clockwise start/end sweep angles. pub fn bulge_arc_to_tangent( arc: &BulgeArc, to_wcs: &dyn Fn(f64, f64) -> (f64, f64, f64), normal: (f64, f64, f64), ) -> crate::scene::model::wire_model::TangentGeom { let (cwx, cwy, cwz) = to_wcs(arc.center[0], arc.center[1]); let (ax, ay) = crate::scene::view::transform::ocs_axes(normal); let (sa, ea) = if arc.sweep >= 0.0 { (arc.start_angle, arc.end_angle) } else { (arc.end_angle, arc.start_angle) }; crate::scene::model::wire_model::TangentGeom::Arc { center: [cwx, cwy, cwz], axis_x: [ax.0, ax.1, ax.2], axis_y: [ay.0, ay.1, ay.2], radius: arc.radius, start_angle: sa, end_angle: ea, } } /// Triangulate the solid bands a `wide_fills` returns into the flat WCS f64 /// triangle list `RenderEntity::pick_tris` carries, so a wide polyline is /// selectable across the band it draws and not just along its centreline. /// /// `origin` and `fills` are that function's own pair: 2-D offsets from the /// first vertex, which is the exact frame the band's `HatchModel` renders in /// (`world_origin` + boundary, no elevation). Building the pick geometry from /// the same numbers keeps hit-testing on whatever the fill actually drew. /// /// An arc band is an annular sector — concave on its inner edge — so this ear /// clips rather than fans. pub(crate) fn wide_band_tris(origin: [f64; 2], fills: &[Vec<[f32; 2]>]) -> Vec<[f64; 3]> { let mut out = Vec::new(); for poly in fills { let ring: Vec<[f64; 3]> = poly .iter() .map(|&[x, y]| [origin[0] + x as f64, origin[1] + y as f64, 0.0]) .collect(); out.extend(crate::entities::mesh::triangulate_planar(&ring)); } out } /// Extrude a wide-polyline band (from `wide_fills`) into a solid tube for a DXF /// thickness (code 39): a vertical wall between every band-boundary point and /// its `thickness`-along-`normal` copy, plus triangulated bottom and top caps. /// Returns `(fill_tris, edge_lines)` as flat WCS f64 lists — the caller wraps /// them in a `RenderEntity` (object = `Lines(edge_lines)`, `fill_tris`, and /// `pick_tris = fill_tris`). Shared by LwPolyline and Polyline2D so both wide /// polyline kinds extrude the same solid instead of just their centre-line. /// /// `polyline_segment_fill` emits each band loop as the outer boundary forward /// then the inner boundary back, so its two transition edges (`half-1 → half` /// and `n-1 → 0`) are radial cap ends inside the band — no wall is drawn there. pub(crate) fn thick_band_tube( origin: [f64; 2], fills: &[Vec<[f32; 2]>], thickness: f64, normal: (f64, f64, f64), to_wcs: &dyn Fn(f64, f64) -> (f64, f64, f64), ) -> (Vec<[f64; 3]>, Vec<[f64; 3]>) { let (nx, ny, nz) = normal; let t = thickness; let off = |p: [f64; 3]| -> [f64; 3] { [p[0] + t * nx, p[1] + t * ny, p[2] + t * nz] }; let push_seg = |lines: &mut Vec<[f64; 3]>, a: [f64; 3], b: [f64; 3]| { lines.push(a); lines.push(b); lines.push([f64::NAN; 3]); }; let mut lines: Vec<[f64; 3]> = Vec::new(); let mut fill_tris: Vec<[f64; 3]> = Vec::new(); for poly in fills { let n = poly.len(); if n < 4 { continue; } let half = n / 2; let bot: Vec<[f64; 3]> = poly .iter() .map(|&[x, y]| { let (wx, wy, wz) = to_wcs(origin[0] + x as f64, origin[1] + y as f64); [wx, wy, wz] }) .collect(); let top: Vec<[f64; 3]> = bot.iter().map(|&p| off(p)).collect(); for k in 0..n { push_seg(&mut lines, bot[k], top[k]); if k == half - 1 || k == n - 1 { continue; } let kn = (k + 1) % n; push_seg(&mut lines, bot[k], bot[kn]); push_seg(&mut lines, top[k], top[kn]); fill_tris.extend_from_slice(&[bot[k], bot[kn], top[kn], bot[k], top[kn], top[k]]); } fill_tris.extend(crate::entities::mesh::triangulate_planar(&bot)); fill_tris.extend(crate::entities::mesh::triangulate_planar(&top)); } (fill_tris, lines) } /// Build a continuous WCS point list and full width for a tapered polyline. pub(crate) fn tapered_band_points( verts: &[([f64; 2], f64, f64, f64)], is_closed: bool, to_wcs: &dyn Fn(f64, f64) -> (f64, f64, f64), ) -> (Vec<[f64; 3]>, Vec) { let n = verts.len(); let seg_count = if is_closed { n } else { n.saturating_sub(1) }; let mut pts: Vec<[f64; 3]> = Vec::new(); let mut widths: Vec = Vec::new(); let mut push = |x: f64, y: f64, w: f32| { let (wx, wy, wz) = to_wcs(x, y); pts.push([wx, wy, wz]); widths.push(w); }; for i in 0..seg_count { let (p0, bulge, sw0, ew0) = verts[i]; let (p1, _, _, _) = verts[(i + 1) % n]; if i == 0 { push(p0[0], p0[1], sw0 as f32); } if bulge.abs() < 1e-9 { push(p1[0], p1[1], ew0 as f32); } else if let Some(arc) = BulgeArc::from_bulge(p0, p1, bulge) { let samples = arc.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE); let segments = samples.len().saturating_sub(1).max(1); for (index, s) in samples.into_iter().enumerate().skip(1) { let t = index as f64 / segments as f64; push(s[0], s[1], (sw0 + (ew0 - sw0) * t) as f32); } } } (pts, widths) } pub(crate) struct WideBandOutline { pub points: Vec<[f64; 3]>, pub stations: Vec, pub point_segments: Vec, pub station_pieces: Vec, pub source_length: f32, } /// Builds the boundary of a variable-width band through the kernel. pub(crate) fn wide_band_outline( verts: &[([f64; 2], f64, f64, f64)], is_closed: bool, restart_per_segment: bool, to_wcs: &dyn Fn(f64, f64) -> (f64, f64, f64), ) -> WideBandOutline { let source = cadkernel::geom2d::Polyline { vertices: verts .iter() .map(|(position, bulge, _, _)| cadkernel::geom2d::PolylineVertex { position: *position, bulge: *bulge, }) .collect(), closed: is_closed, }; let segment_count = if is_closed { verts.len() } else { verts.len().saturating_sub(1) }; let widths: Vec<[f64; 2]> = verts .iter() .take(segment_count) .map(|(_, _, start, end)| [*start, *end]) .collect(); let boundary = cadkernel::geom2d::polyline_band_boundary( &source, &widths, cadkernel::tessellation::DEFAULT_ANGLE, ); let mut points = Vec::new(); let mut stations = Vec::new(); let mut point_segments = Vec::new(); for edge in boundary.edges { if !points.is_empty() { points.push([f64::NAN; 3]); stations.push(0.0); point_segments.push(-1); } points.extend(edge.points.into_iter().map(|point| { let (x, y, z) = to_wcs(point[0], point[1]); [x, y, z] })); stations.extend( if restart_per_segment { edge.segment_distances } else { edge.source_distances } .map(|distance| distance as f32), ); point_segments.extend([edge.source_segment as i32; 2]); } let station_pieces = boundary .station_pieces .into_iter() .map(|piece| { let start = to_wcs(piece.points[0][0], piece.points[0][1]); let end = to_wcs(piece.points[1][0], piece.points[1][1]); PatternStationPiece { source_segment: piece.source_segment as u32, source_distances: piece.source_distances.map(|distance| distance as f32), segment_distances: piece.segment_distances.map(|distance| distance as f32), vector: [ (end.0 - start.0) as f32, (end.1 - start.1) as f32, (end.2 - start.2) as f32, ], } }) .collect(); WideBandOutline { points, stations, point_segments, station_pieces, source_length: boundary.source_length as f32, } } pub(crate) fn extrude_wide_band_outline( outline: WideBandOutline, extrusion: [f64; 3], ) -> WideBandOutline { let base_points = outline.points; let base_stations = outline.stations; let base_segments = outline.point_segments; let mut points = base_points.clone(); let mut stations = base_stations.clone(); let mut point_segments = base_segments.clone(); points.push([f64::NAN; 3]); stations.push(0.0); point_segments.push(-1); points.extend(base_points.iter().map(|point| { if point[0].is_finite() { [ point[0] + extrusion[0], point[1] + extrusion[1], point[2] + extrusion[2], ] } else { *point } })); stations.extend_from_slice(&base_stations); point_segments.extend_from_slice(&base_segments); let mut emitted = HashSet::new(); for ((&point, &station), &segment) in base_points .iter() .zip(&base_stations) .zip(&base_segments) { if !point[0].is_finite() { continue; } let key = (point.map(f64::to_bits), station.to_bits(), segment); if !emitted.insert(key) { continue; } points.push([f64::NAN; 3]); stations.push(0.0); point_segments.push(-1); points.push(point); points.push([ point[0] + extrusion[0], point[1] + extrusion[1], point[2] + extrusion[2], ]); stations.push(station); stations.push(station); point_segments.extend([segment; 2]); } WideBandOutline { points, stations, point_segments, station_pieces: outline.station_pieces, source_length: outline.source_length, } } /// Compute the filled boundary polygon for one polyline segment. /// For straight segments: a rectangle/trapezoid. /// For arc segments: an arc band (outer arc + reversed inner arc). /// Returns `None` if the segment is degenerate. pub(crate) fn polyline_segment_fill( p0: [f32; 2], p1: [f32; 2], hw0: f32, hw1: f32, bulge: f32, ) -> Option> { if bulge.abs() < 1e-9 { // Straight segment — rectangle or trapezoid let dx = p1[0] - p0[0]; let dy = p1[1] - p0[1]; let len = (dx * dx + dy * dy).sqrt(); if len < 1e-9 { return None; } let nx = -dy / len; let ny = dx / len; Some(vec![ [p0[0] + hw0 * nx, p0[1] + hw0 * ny], [p1[0] + hw1 * nx, p1[1] + hw1 * ny], [p1[0] - hw1 * nx, p1[1] - hw1 * ny], [p0[0] - hw0 * nx, p0[1] - hw0 * ny], ]) } else { // Arc segment — arc band polygon. // Center math matches `bulge_to_arc` in modules/home/modify/explode.rs: // r = chord * (1 + b²) / (4·|b|) // d = r * (1 - b²) / (1 + b²) (signed: negative ⇒ major arc, center // flips to the opposite side of chord) // center = midpoint + sign(b) · d · left_perp(chord) let b = bulge as f64; let b2 = b * b; let dx = (p1[0] - p0[0]) as f64; let dy = (p1[1] - p0[1]) as f64; let chord_len = (dx * dx + dy * dy).sqrt(); if chord_len < 1e-9 || b.abs() < 1e-12 { return None; } let r = chord_len * (1.0 + b2) / (4.0 * b.abs()); let d_perp = r * (1.0 - b2) / (1.0 + b2); let mx = ((p0[0] + p1[0]) * 0.5) as f64; let my = ((p0[1] + p1[1]) * 0.5) as f64; let perp_x = -dy / chord_len; let perp_y = dx / chord_len; let sign = b.signum(); let cx = (mx + sign * d_perp * perp_x) as f32; let cy = (my + sign * d_perp * perp_y) as f32; let a0 = ((p0[1] - cy) as f32).atan2((p0[0] - cx) as f32); let a1 = ((p1[1] - cy) as f32).atan2((p1[0] - cx) as f32); let (sa, mut ea) = if bulge > 0.0 { (a0, a1) } else { (a1, a0) }; if ea < sa { ea += std::f32::consts::TAU; } let span = ea - sa; let segs = ((span.abs() / std::f32::consts::TAU) * 24.0) .ceil() .max(4.0) as u32; let r = r as f32; let r_outer = |t: f32| r + (hw0 + (hw1 - hw0) * t); let r_inner = |t: f32| (r - (hw0 + (hw1 - hw0) * t)).max(0.0); let mut boundary = Vec::with_capacity((segs as usize + 1) * 2); let inv = 1.0 / segs as f32; for j in 0..=segs { let t = j as f32 * inv; let ang = sa + span * t; let ro = r_outer(t); boundary.push([cx + ro * ang.cos(), cy + ro * ang.sin()]); } for j in (0..=segs).rev() { let t = j as f32 * inv; let ang = sa + span * t; let ri = r_inner(t); boundary.push([cx + ri * ang.cos(), cy + ri * ang.sin()]); } Some(boundary) } } #[cfg(test)] mod length_format_tests { use super::*; fn with_units(lunits: i16, luprec: i16, value: f64) -> String { let mut ctx = unit_context(); ctx.lunits = lunits; ctx.luprec = luprec; set_unit_context(ctx); format_length(value) } #[test] fn fractional_carries_a_rounded_up_fraction() { assert_eq!(with_units(5, 4, 5.99), "5 63/64"); assert_eq!(with_units(5, 4, 5.995), "6"); assert_eq!(with_units(5, 4, 0.995), "1"); assert_eq!(with_units(5, 4, 11.999), "12"); assert_eq!(with_units(5, 4, -5.995), "-6"); assert_eq!(with_units(5, 4, 0.5), "0 1/2"); assert_eq!(with_units(5, 4, 9.25), "9 1/4"); assert_eq!(with_units(5, 4, 3.0e17), "300000000000000000"); } #[test] fn architectural_carries_into_feet() { assert_eq!(with_units(4, 4, 11.99), "11 63/64\""); assert_eq!(with_units(4, 4, 0.5), "0 1/2\""); assert_eq!(with_units(4, 4, -9.25), "-9 1/4\""); assert_eq!(with_units(4, 4, 11.999), "1'-0\""); assert_eq!(with_units(4, 4, 23.999), "2'-0\""); assert_eq!(with_units(4, 4, 66.5), "5'-6 1/2\""); assert_eq!(with_units(4, 4, -11.999), "-1'-0\""); } #[test] fn engineering_carries_into_feet() { assert_eq!(with_units(3, 1, 11.94), "11.9\""); assert_eq!(with_units(3, 1, 0.5), "0.5\""); assert_eq!(with_units(3, 1, -9.25), "-9.3\""); assert_eq!(with_units(3, 1, 11.99), "1'-0.0\""); assert_eq!(with_units(3, 1, 23.99), "2'-0.0\""); assert_eq!(with_units(3, 2, 11.999), "1'-0.00\""); assert_eq!(with_units(3, 1, 66.5), "5'-6.5\""); } #[test] fn formatted_lengths_read_back() { for lunits in [3, 4, 5] { for value in [0.995f64, 5.995, 11.999, 23.999, 66.5, 9.25] { let shown = with_units(lunits, if lunits == 3 { 4 } else { 4 }, value); let read = parse_length(&shown) .unwrap_or_else(|| panic!("lunits {lunits} wrote {shown:?}, which does not read back")); assert!( (read - value).abs() < 0.02, "lunits {lunits}: {value} wrote {shown:?}, read back as {read}" ); } } } } #[cfg(test)] mod angle_format_tests { use super::*; fn shown(aunits: i16, auprec: i16, degrees: f64) -> String { let mut ctx = unit_context(); ctx.aunits = aunits; ctx.auprec = auprec; set_unit_context(ctx); format_angle(degrees.to_radians()) } #[test] fn degrees_minutes_seconds_carry() { assert_eq!(shown(1, 0, 45.9999), "46d0'0\""); assert_eq!(shown(1, 0, 89.99999), "90d0'0\""); assert_eq!(shown(1, 0, 0.99999), "1d0'0\""); assert_eq!(shown(1, 0, 45.5), "45d30'0\""); assert_eq!(shown(1, 2, 45.9999), "45d59'59.64\""); assert_eq!(shown(1, 2, 45.5), "45d30'0.00\""); } #[test] fn surveyor_bearings_stay_inside_a_quadrant() { assert_eq!(shown(4, 0, 359.99999), "E"); assert_eq!(shown(4, 0, 89.99999), "N"); assert_eq!(shown(4, 0, 45.0), "N 45d0'0\" E"); assert_eq!(shown(4, 0, 180.0), "W"); assert_ne!(shown(4, 8, 0.0000000005), "E"); for prec in [0, 2] { for deg in [0.99999f64, 45.9999, 89.99999, 179.99999, 269.99999, 359.99999] { let text = shown(4, prec, deg); assert!( !text.contains("90d"), "prec {prec}: {deg} wrote {text:?}, a quarter turn inside a quadrant" ); } } } #[test] fn formatted_angles_read_back() { for aunits in [0, 1, 4] { for prec in [0, 2] { for deg in [0.99999f64, 45.5, 45.9999, 89.99999, 200.25, 359.99999] { let text = shown(aunits, prec, deg); let read = parse_angle(&text).unwrap_or_else(|| { panic!("aunits {aunits} prec {prec} wrote {text:?}, which does not read back") }); let delta = (read.to_degrees().rem_euclid(360.0) - deg.rem_euclid(360.0)) .rem_euclid(360.0); let delta = delta.min(360.0 - delta); assert!( delta < 1.0, "aunits {aunits} prec {prec}: {deg} wrote {text:?}, read back as {}", read.to_degrees() ); } } } } } #[cfg(test)] mod negative_zero_format_tests { use super::*; /// What ROTATE leaves in X when it turns the point (0, 10) by 180°. const ROTATION_NOISE: f64 = -1.2246467991473533e-15; fn length(lunits: i16, luprec: i16, value: f64) -> String { let mut ctx = unit_context(); ctx.lunits = lunits; ctx.luprec = luprec; set_unit_context(ctx); format_length(value) } fn angle(aunits: i16, auprec: i16, value_rad: f64) -> String { let mut ctx = unit_context(); ctx.aunits = aunits; ctx.auprec = auprec; set_unit_context(ctx); format_angle(value_rad) } /// A length that rounds to zero reads as zero in every format, instead of /// `-0.0000` in Properties after a rotation leaves float noise behind. #[test] fn a_length_that_rounds_to_zero_has_no_sign() { assert_eq!(length(2, 4, ROTATION_NOISE), "0.0000"); assert_eq!(length(3, 4, ROTATION_NOISE), "0.0000\""); assert_eq!(length(4, 4, ROTATION_NOISE), "0\""); assert_eq!(length(5, 4, ROTATION_NOISE), "0"); } #[test] fn an_angle_that_rounds_to_zero_has_no_sign() { assert_eq!(angle(0, 0, ROTATION_NOISE), "0°"); assert_eq!(angle(1, 0, ROTATION_NOISE), "0d0'0\""); assert_eq!(angle(2, 0, ROTATION_NOISE), "0g"); assert_eq!(angle(3, 2, ROTATION_NOISE), "0.00r"); } /// Values that still show a digit keep their sign. #[test] fn a_visible_negative_keeps_its_sign() { assert_eq!(length(2, 4, -2.5), "-2.5000"); assert_eq!(length(2, 0, -0.6), "-1"); assert_eq!(angle(0, 0, -45f64.to_radians()), "-45°"); assert_eq!(angle(1, 1, -0.0001f64.to_radians()), "-0d0'0.4\""); } #[test] fn a_negative_infinite_value_keeps_its_sign() { assert_eq!(length(2, 4, f64::NEG_INFINITY), "-inf"); assert_eq!(angle(0, 0, f64::NEG_INFINITY), "-inf°"); } }