cad-editor/src/entities/common.rs
Hakan Seven 6cbec232bb feat(units): read back every form the drawing writes
The readout learned to write lengths as 5'-6 1/2" and angles as
N 45d0'0" E, but typed input was still a bare parse::<f64>() — a
coordinate could be displayed and not entered. Give the formatters
their inverses.

parse_length takes feet and inches separated by a dash, a space, or
nothing, with the closing quote optional, and whole-plus-fraction in
any of those forms: 5', 60", 5'-9", 5'9-1/2, 9-1/2, 1/2. A leading
minus is a sign; the one inside 5'-9" is a separator, since after feet
there is nothing left to subtract from.

parse_angle reads the mark that says which convention an angle is in —
g for grads, r for radians, d/'/" for degrees-minutes-seconds, compass
letters for a surveyor's bearing — and reads a bare number in whichever
convention the drawing is set to.

ANGBASE and ANGDIR now apply, but only where they mean something. Which
way a thing points is measured from the drawing's zero and runs the way
it says; how wide an arc opens is the same number whatever zero is
counted from. format_direction and parse_direction are the pair for
directions, and the polar readout and polar input both go through them,
so the bearing shown is the bearing accepted. format_angle stays for
sizes, where applying a base angle would corrupt the value.

The command prompts follow the same rule, each classified rather than
swept: OFFSET distance, FILLET radius, CHAMFER distances, ARRAY and
MINSERT spacing, TRACE width, HELIX, and EXTRUDE height read lengths;
the four dimension text-rotation prompts, ARRAY's total angle and
INSERT's rotation read angles. MLINE's scale and INSERT's scale factor
are left alone — a multiplier has no unit to be written in. INSERT read
both through one parse; they are now separate.

The typed variants also take a decimal comma, which a single-value
prompt has no other use for. parse_length itself does not: inside a
coordinate the comma separates the axes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:46:49 +03:00

841 lines
30 KiB
Rust

use std::cell::Cell;
use crate::scene::model::object::{GripDef, GripShape, PropValue, Property};
/// 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<UnitContext> = const { Cell::new(UnitContext {
lunits: 2,
luprec: 4,
aunits: 0,
auprec: 0,
angbase: 0.0,
angdir_cw: false,
}) };
}
/// 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 {
let ctx = unit_context();
let prec = ctx.luprec.max(0) as usize;
match ctx.lunits {
1 => format!("{:.*e}", prec, value),
3 => {
// Engineering: ft-inches, decimal inches.
let sign = if value < 0.0 { "-" } else { "" };
let abs = value.abs();
let feet = (abs / 12.0).trunc();
let rem = abs - feet * 12.0;
format!("{}{:.0}'-{:.*}\"", sign, feet, prec, rem)
}
4 | 5 => {
// Architectural / Fractional — n + fraction with 1/2^p denom (1
// unit = 1 inch). Use 6 as a moderate denominator power so the
// result reads like 1/64".
let sign = if value < 0.0 { "-" } else { "" };
let abs = value.abs();
let (feet, in_rem) = if ctx.lunits == 4 {
let f = (abs / 12.0).trunc();
(Some(f as i64), abs - f * 12.0)
} else {
(None, abs)
};
let whole = in_rem.trunc();
let frac = in_rem - whole;
let denom = 64u64;
let numer = (frac * denom as f64).round() as i64;
let mut n = numer as u64;
let mut d = denom;
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) => format!("{}{}'-{:.0}{}{}", sign, f, whole, frac_str, unit_suffix),
None => format!("{}{:.0}{}", sign, whole, frac_str),
}
}
_ => format!("{:.*}", prec, value),
}
}
/// Format an angle (input in radians) using AUNITS / AUPREC.
pub fn format_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();
let d = a.floor();
let m_full = (a - d) * 60.0;
let m = m_full.floor();
let s = (m_full - m) * 60.0;
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 {
let deg = value_rad.to_degrees().rem_euclid(360.0);
let near = |target: f64| (deg - target).abs() < 1e-9;
if near(0.0) {
return "E".into();
}
if near(90.0) {
return "N".into();
}
if near(180.0) {
return "W".into();
}
if near(270.0) {
return "S".into();
}
// 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<f64> {
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<f64> {
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<f64> {
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::<f64>().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<f64> {
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::<f64>().ok()?,
rest[at + rest[at..].chars().next()?.len_utf8()..].trim(),
),
None => (rest.trim().parse::<f64>().ok()?, ""),
};
let (minutes, rest) = match rest.split_once('\'') {
Some((minutes, rest)) if !minutes.trim().is_empty() => {
(minutes.trim().parse::<f64>().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::<f64>().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<f64> {
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<f64> {
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<f64> {
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<f64> {
let ctx = unit_context();
let typed = parse_angle(text)?;
let relative = if ctx.angdir_cw { -typed } else { typed };
Some(relative + ctx.angbase)
}
/// 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,
}
}
/// 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,
}
}
/// 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,
}
}
/// 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(dir),
}
}
#[allow(dead_code)]
pub fn triangle_grip(id: usize, world: glam::DVec3) -> GripDef {
GripDef {
id,
world,
is_midpoint: false,
shape: GripShape::Triangle,
dir: 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)),
}
}
pub fn ro_prop(label: &str, field: &'static str, value: impl Into<String>) -> 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<String>,
) -> Property {
Property {
label: label.into(),
field,
value: PropValue::Stepper {
field,
display: display.into(),
},
}
}
pub fn parse_f64(value: &str) -> Option<f64> {
let t = value.trim();
// Angle rows display via AUNITS (#297) — accept those formats back.
t.parse::<f64>().ok().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<f64> {
let s = value.trim();
if s.is_empty() {
return None;
}
if let Ok(v) = s.parse::<f64>() {
return Some(v);
}
let lower = s.to_ascii_lowercase();
if let Some(num) = lower.strip_suffix('g') {
return num.trim().parse::<f64>().ok().map(|g| g * 0.9);
}
if let Some(num) = lower.strip_suffix('r') {
return num.trim().parse::<f64>().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::<f64>().ok()? / 60.0;
}
let sp = spart.trim().trim_end_matches('"').trim();
if !sp.is_empty() {
total += sp.parse::<f64>().ok()? / 3600.0;
}
}
Some(if neg { -total } else { total })
}
/// Bulge → arc geometry for a polyline segment.
///
/// DXF/DWG polyline arcs are encoded as a bulge factor on each vertex —
/// `bulge = tan(theta/4)` where `theta` is the included angle of the arc
/// from `p0` to `p1`. Sign convention: positive bulge = CCW from p0 to p1,
/// negative = CW. `|bulge| = 1` is a half-circle.
///
/// This struct centralises the (formerly duplicated) math that takes
/// `(p0, p1, bulge)` and produces the canonical `(center, radius,
/// start_angle, sweep)` quadruple. Callsites pick the fields they need.
#[derive(Clone, Copy, Debug)]
pub struct BulgeArc {
pub center: [f64; 2],
pub radius: f64,
/// Angle from center to p0 (atan2, range -π..π).
pub start_angle: f64,
/// Angle from center to p1 (atan2, range -π..π).
pub end_angle: f64,
/// Signed sweep from p0 to p1. Positive ⇒ CCW (bulge > 0),
/// negative ⇒ CW (bulge < 0). For exact half-turns the sign of
/// `bulge` decides the direction.
pub sweep: f64,
}
impl BulgeArc {
/// Build from endpoints + bulge. Returns `None` for degenerate input
/// (chord ≈ 0 or |bulge| ≈ 0).
pub fn from_bulge(p0: [f64; 2], p1: [f64; 2], bulge: f64) -> Option<Self> {
let chord_x = p1[0] - p0[0];
let chord_y = p1[1] - p0[1];
let chord_len = (chord_x * chord_x + chord_y * chord_y).sqrt();
if chord_len < 1e-12 || bulge.abs() < 1e-12 {
return None;
}
let b = bulge;
let b2 = b * b;
// r = chord · (1 + b²) / (4·|b|)
let r = chord_len * (1.0 + b2) / (4.0 * b.abs());
// d_perp = signed distance from chord midpoint to arc center
// = r · (1 - b²) / (1 + b²) = r · cos(theta/2)
let d_perp = r * (1.0 - b2) / (1.0 + b2);
let mx = (p0[0] + p1[0]) * 0.5;
let my = (p0[1] + p1[1]) * 0.5;
// Left perpendicular to chord (90° CCW).
let perp_x = -chord_y / chord_len;
let perp_y = chord_x / chord_len;
let sign = b.signum();
let cx = mx + sign * d_perp * perp_x;
let cy = my + sign * d_perp * perp_y;
let a0 = (p0[1] - cy).atan2(p0[0] - cx);
let a1 = (p1[1] - cy).atan2(p1[0] - cx);
// Wrap sweep to match bulge sign: bulge>0 ⇒ positive (CCW),
// bulge<0 ⇒ negative (CW). Falls back to ±τ for half-turns.
const TAU: f64 = std::f64::consts::TAU;
let mut sweep = a1 - a0;
if bulge > 0.0 {
if sweep <= 0.0 {
sweep += TAU;
}
} else if sweep >= 0.0 {
sweep -= TAU;
}
if sweep.abs() < 1e-9 {
sweep = if bulge > 0.0 { TAU } else { -TAU };
}
Some(Self {
center: [cx, cy],
radius: r,
start_angle: a0,
end_angle: a1,
sweep,
})
}
/// Sample a point on the arc at parameter `t ∈ [0, 1]`. `t=0` ↦ p0,
/// `t=1` ↦ p1, walks along the signed sweep direction.
pub fn sample(&self, t: f64) -> [f64; 2] {
let a = self.start_angle + self.sweep * t;
[
self.center[0] + self.radius * a.cos(),
self.center[1] + self.radius * a.sin(),
]
}
}
/// Triangulate the solid bands a `wide_fills` returns into the flat WCS f64
/// triangle list `TruckEntity::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 `TruckEntity` (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 + a per-point FULL band width for a
/// tapered wide polyline, so the wire shader can interpolate each segment's two
/// endpoint widths. Each `verts` entry is `(location_xy, bulge_to_next,
/// start_width, end_width)` — the effective full widths at that vertex's segment
/// start and end (already resolved against the polyline's constant width). Arcs
/// are sampled in 16 steps with the width interpolated linearly along the arc.
/// A shared vertex is emitted once (carrying the previous segment's end width),
/// which is exact for the usual continuous taper.
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<f32>) {
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<f32> = 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) {
for j in 1..=16usize {
let t = j as f64 / 16.0;
let s = arc.sample(t);
push(s[0], s[1], (sw0 + (ew0 - sw0) * t) as f32);
}
}
}
(pts, widths)
}
/// 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<Vec<[f32; 2]>> {
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)
}
}