Files
OneCAD-Draft/src/entities/common.rs
T
Hakan SevenandClaude Opus 5.5 f537aff75c feat: node graph overlay; move to opencadcodec/opencadkernel/opencadgraph
The codec and kernel repositories and crates are renamed opencadcodec and
opencadkernel. Inside OCS they are depended on under short keys, so code
reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node
graph engine). File-format and protocol spellings stay as they were: the
ACIS product id `acadrust`, the ACADRUST_* switches and the plugin
version keys `acadrust_version` / `acadrust_source`.

A button left of MCP on the command line opens a translucent node graph
over the viewport, with a palette of object, input, math, logic, list,
text and geometry nodes added by click or drag. An object node owns
drawing entities and its rows are the Properties panel's own rows for
them: the single-object page is extracted from refresh_properties as
entity_property_sections, values use the control protocol's
property_json, and writes go through set_property_value with a property
target override, so the panel, MCP and the graph share one read/write
path. A list reaching an object row makes one entity per element. Each
graph action is one undo step; the canvas pans and zooms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 07:34:23 +03:00

1389 lines
48 KiB
Rust

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: &codec::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,
}) };
}
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<rustc_hash::FxHashMap<String, f64>> =
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: &codec::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<f64> {
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`.
pub 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<f64> {
parse_length_unbounded(text).filter(|value| typed_value_in_range(*value))
}
/// Largest magnitude a typed length or angle may have. Rust's float parser
/// accepts `inf`, `nan` and overflowing exponents; geometry built from those,
/// or from values whose differences overflow, breaks downstream tolerance and
/// intersection math.
pub const MAX_TYPED_MAGNITUDE: f64 = 1e15;
pub fn typed_value_in_range(value: f64) -> bool {
value.is_finite() && value.abs() <= MAX_TYPED_MAGNITUDE
}
fn parse_length_unbounded(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> {
parse_angle_unbounded(text).filter(|value| typed_value_in_range(*value))
}
fn parse_angle_unbounded(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)
}
/// Read a direction typed at a command prompt, accepting a decimal comma.
pub fn parse_typed_direction(text: &str) -> Option<f64> {
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<String> {
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<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();
// 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::<f64>()
.ok()
.or_else(|| parse_length(t))
.or_else(|| parse_angle_deg(t))
.filter(|value| typed_value_in_range(*value))
}
/// 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> {
parse_angle_deg_unbounded(value).filter(|value| typed_value_in_range(*value))
}
fn parse_angle_deg_unbounded(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, 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 kernel::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.
/// Directly triangulates a band polygon (either a 4-vertex straight trapezoid or
/// a 2m-vertex annular sector quad strip) into non-overlapping triangles.
/// Falls back to `triangulate_planar` if the vertex count is odd or less than 4.
pub(crate) fn triangulate_band_ring(ring: &[[f64; 3]]) -> Vec<[f64; 3]> {
let n = ring.len();
if n >= 4 && n % 2 == 0 {
let m = n / 2;
let mut tris = Vec::with_capacity((m - 1) * 6);
for j in 0..m - 1 {
let p0 = ring[j];
let p1 = ring[j + 1];
let p2 = ring[2 * m - 2 - j];
let p3 = ring[2 * m - 1 - j];
tris.push(p0);
tris.push(p1);
tris.push(p2);
tris.push(p0);
tris.push(p2);
tris.push(p3);
}
tris
} else {
crate::entities::mesh::triangulate_planar(ring)
}
}
pub(crate) fn wide_band_tris(origin: [f64; 2], fills: &[Vec<[f32; 2]>]) -> Vec<[f64; 3]> {
if fills.is_empty() {
return Vec::new();
}
let mut total_verts = 0;
for poly in fills {
let n = poly.len();
if n >= 4 && n % 2 == 0 {
total_verts += (n / 2 - 1) * 6;
}
}
let mut out = Vec::with_capacity(total_verts);
let ox = origin[0];
let oy = origin[1];
for poly in fills {
let n = poly.len();
if n >= 4 && n % 2 == 0 {
let m = n / 2;
for j in 0..m - 1 {
let v0 = poly[j];
let v1 = poly[j + 1];
let v2 = poly[2 * m - 2 - j];
let v3 = poly[2 * m - 1 - j];
let p0 = [ox + v0[0] as f64, oy + v0[1] as f64, 0.0];
let p1 = [ox + v1[0] as f64, oy + v1[1] as f64, 0.0];
let p2 = [ox + v2[0] as f64, oy + v2[1] as f64, 0.0];
let p3 = [ox + v3[0] as f64, oy + v3[1] as f64, 0.0];
out.push(p0);
out.push(p1);
out.push(p2);
out.push(p0);
out.push(p2);
out.push(p3);
}
} else {
let ring: Vec<[f64; 3]> = poly
.iter()
.map(|&[x, y]| [ox + x as f64, oy + 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(triangulate_band_ring(&bot));
fill_tris.extend(triangulate_band_ring(&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<f32>) {
let n = verts.len();
let seg_count = if is_closed { n } else { n.saturating_sub(1) };
let mut pts: Vec<[f64; 3]> = Vec::with_capacity(seg_count + 1);
let mut widths: Vec<f32> = Vec::with_capacity(seg_count + 1);
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(kernel::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<f32>,
pub point_segments: Vec<i32>,
pub station_pieces: Vec<PatternStationPiece>,
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 = kernel::geom2d::Polyline {
vertices: verts
.iter()
.map(|(position, bulge, _, _)| kernel::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 = kernel::geom2d::polyline_band_boundary(
&source,
&widths,
kernel::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<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 segs_u = segs as usize;
let mut boundary = vec![[0.0_f32; 2]; (segs_u + 1) * 2];
let inv = 1.0 / segs as f32;
for j in 0..=segs_u {
let t = j as f32 * inv;
let ang = sa + span * t;
let (sin, cos) = ang.sin_cos();
let ro = r_outer(t);
let ri = r_inner(t);
boundary[j] = [cx + ro * cos, cy + ro * sin];
boundary[2 * segs_u + 1 - j] = [cx + ri * cos, cy + ri * sin];
}
Some(boundary)
}
}
#[cfg(test)]
mod typed_range_tests {
use super::*;
#[test]
fn non_finite_and_huge_typed_values_are_rejected() {
for text in ["inf", "-inf", "nan", "1e400", "1e308"] {
assert_eq!(parse_length(text), None, "length {text}");
assert_eq!(parse_angle(text), None, "angle {text}");
assert_eq!(parse_f64(text), None, "property {text}");
assert_eq!(parse_angle_deg(text), None, "angle degrees {text}");
}
assert_eq!(parse_length("1e16"), None);
assert_eq!(parse_length("1e15"), Some(1e15));
assert_eq!(parse_length("-5'-6\""), Some(-66.0));
}
}
#[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°");
}
}