2026-05-20 05:43:55 +03:00
|
|
|
use std::cell::Cell;
|
|
|
|
|
|
2026-06-16 11:33:46 +03:00
|
|
|
use crate::scene::model::object::{GripDef, GripShape, PropValue, Property};
|
2026-03-22 14:56:27 +03:00
|
|
|
|
2026-05-20 05:43:55 +03:00
|
|
|
/// 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. 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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
thread_local! {
|
|
|
|
|
static UNIT_CTX: Cell<UnitContext> = const { Cell::new(UnitContext {
|
|
|
|
|
lunits: 2,
|
|
|
|
|
luprec: 4,
|
|
|
|
|
aunits: 0,
|
|
|
|
|
auprec: 0,
|
|
|
|
|
}) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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.
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
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 — degrees / minutes / seconds.
|
|
|
|
|
let deg = value_rad.to_degrees();
|
|
|
|
|
let sign = if deg < 0.0 { "-" } else { "" };
|
|
|
|
|
let a = deg.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}°{:.0}'{:.*}\"", sign, d, m, prec, s)
|
|
|
|
|
}
|
|
|
|
|
2 => {
|
|
|
|
|
let g = value_rad.to_degrees() / 0.9;
|
|
|
|
|
format!("{:.*}g", prec, g)
|
|
|
|
|
}
|
|
|
|
|
3 => format!("{:.*}r", prec, value_rad),
|
|
|
|
|
_ => format!("{:.*}°", prec, value_rad.to_degrees()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 00:04:37 +03:00
|
|
|
pub fn square_grip(id: usize, world: glam::DVec3) -> GripDef {
|
2026-03-22 14:56:27 +03:00
|
|
|
GripDef {
|
|
|
|
|
id,
|
|
|
|
|
world,
|
|
|
|
|
is_midpoint: false,
|
|
|
|
|
shape: GripShape::Square,
|
refactor(grip): square-only marker set + oriented stretch handles
Phase 1 of bringing the grip vocabulary in line with the standard
CAD convention: drop the per-position-type shape encoding (Diamond
for translate, Circle for parameter) and use a plain square for
every endpoint / vertex / centre grip. The behavioural midpoint vs
vertex distinction stays — it lives on `GripDef::is_midpoint` and
drives `Translate` vs `Absolute` in the grip-edit dispatcher — only
the marker shape collapses.
- `GripShape` keeps `Square`, `Rectangle`, `Triangle`. Diamond /
Circle are gone.
- `GripDef` gains `dir: Option<[f32; 2]>` — a world-XY direction
vector consumed by `Rectangle` to orient the box along its
segment.
- `entities::common` exposes `square_grip`, `center_grip` (same
square marker, flagged as translate), `rectangle_grip(id, world,
dir)` for oriented mid-segment handles, and a kept-for-Phase-2
`triangle_grip`.
- `overlay::GripMarker` / `grips_to_screen[_paper]` thread `dir`
through to the canvas painter; the Rectangle path rotates the
box around the grip centre using the world-XY direction (flipping
the sin for screen-Y).
Every existing `diamond_grip(id, w)` call becomes `center_grip(id,
w)` — the visual change is square-replacing-diamond at line
midpoints / arc midpoints / circle centres / ellipse centres /
viewport / hatch loop centroids / dimension grips / image corners /
multileader / ray / underlay / ole2frame / solid3d.
`LwPolyline` grows a stretch handle on every segment (straight or
arc), drawn as the new oriented rectangle. The grip-edit path
translates both segment endpoints for straight segments and
adjusts the arc bulge from the new midpoint for arc segments.
2026-05-29 02:07:34 +03:00
|
|
|
dir: None,
|
2026-03-22 14:56:27 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
refactor(grip): square-only marker set + oriented stretch handles
Phase 1 of bringing the grip vocabulary in line with the standard
CAD convention: drop the per-position-type shape encoding (Diamond
for translate, Circle for parameter) and use a plain square for
every endpoint / vertex / centre grip. The behavioural midpoint vs
vertex distinction stays — it lives on `GripDef::is_midpoint` and
drives `Translate` vs `Absolute` in the grip-edit dispatcher — only
the marker shape collapses.
- `GripShape` keeps `Square`, `Rectangle`, `Triangle`. Diamond /
Circle are gone.
- `GripDef` gains `dir: Option<[f32; 2]>` — a world-XY direction
vector consumed by `Rectangle` to orient the box along its
segment.
- `entities::common` exposes `square_grip`, `center_grip` (same
square marker, flagged as translate), `rectangle_grip(id, world,
dir)` for oriented mid-segment handles, and a kept-for-Phase-2
`triangle_grip`.
- `overlay::GripMarker` / `grips_to_screen[_paper]` thread `dir`
through to the canvas painter; the Rectangle path rotates the
box around the grip centre using the world-XY direction (flipping
the sin for screen-Y).
Every existing `diamond_grip(id, w)` call becomes `center_grip(id,
w)` — the visual change is square-replacing-diamond at line
midpoints / arc midpoints / circle centres / ellipse centres /
viewport / hatch loop centroids / dimension grips / image corners /
multileader / ray / underlay / ole2frame / solid3d.
`LwPolyline` grows a stretch handle on every segment (straight or
arc), drawn as the new oriented rectangle. The grip-edit path
translates both segment endpoints for straight segments and
adjusts the arc bulge from the new midpoint for arc segments.
2026-05-29 02:07:34 +03:00
|
|
|
/// Centre / translate grip — same square marker as a vertex grip but
|
|
|
|
|
/// flagged as a "whole-object move" handle for the grip-edit code.
|
2026-06-11 00:04:37 +03:00
|
|
|
pub fn center_grip(id: usize, world: glam::DVec3) -> GripDef {
|
2026-03-22 14:56:27 +03:00
|
|
|
GripDef {
|
|
|
|
|
id,
|
|
|
|
|
world,
|
|
|
|
|
is_midpoint: true,
|
refactor(grip): square-only marker set + oriented stretch handles
Phase 1 of bringing the grip vocabulary in line with the standard
CAD convention: drop the per-position-type shape encoding (Diamond
for translate, Circle for parameter) and use a plain square for
every endpoint / vertex / centre grip. The behavioural midpoint vs
vertex distinction stays — it lives on `GripDef::is_midpoint` and
drives `Translate` vs `Absolute` in the grip-edit dispatcher — only
the marker shape collapses.
- `GripShape` keeps `Square`, `Rectangle`, `Triangle`. Diamond /
Circle are gone.
- `GripDef` gains `dir: Option<[f32; 2]>` — a world-XY direction
vector consumed by `Rectangle` to orient the box along its
segment.
- `entities::common` exposes `square_grip`, `center_grip` (same
square marker, flagged as translate), `rectangle_grip(id, world,
dir)` for oriented mid-segment handles, and a kept-for-Phase-2
`triangle_grip`.
- `overlay::GripMarker` / `grips_to_screen[_paper]` thread `dir`
through to the canvas painter; the Rectangle path rotates the
box around the grip centre using the world-XY direction (flipping
the sin for screen-Y).
Every existing `diamond_grip(id, w)` call becomes `center_grip(id,
w)` — the visual change is square-replacing-diamond at line
midpoints / arc midpoints / circle centres / ellipse centres /
viewport / hatch loop centroids / dimension grips / image corners /
multileader / ray / underlay / ole2frame / solid3d.
`LwPolyline` grows a stretch handle on every segment (straight or
arc), drawn as the new oriented rectangle. The grip-edit path
translates both segment endpoints for straight segments and
adjusts the arc bulge from the new midpoint for arc segments.
2026-05-29 02:07:34 +03:00
|
|
|
shape: GripShape::Square,
|
|
|
|
|
dir: None,
|
2026-03-22 14:56:27 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 22:48:33 +03:00
|
|
|
/// 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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
refactor(grip): square-only marker set + oriented stretch handles
Phase 1 of bringing the grip vocabulary in line with the standard
CAD convention: drop the per-position-type shape encoding (Diamond
for translate, Circle for parameter) and use a plain square for
every endpoint / vertex / centre grip. The behavioural midpoint vs
vertex distinction stays — it lives on `GripDef::is_midpoint` and
drives `Translate` vs `Absolute` in the grip-edit dispatcher — only
the marker shape collapses.
- `GripShape` keeps `Square`, `Rectangle`, `Triangle`. Diamond /
Circle are gone.
- `GripDef` gains `dir: Option<[f32; 2]>` — a world-XY direction
vector consumed by `Rectangle` to orient the box along its
segment.
- `entities::common` exposes `square_grip`, `center_grip` (same
square marker, flagged as translate), `rectangle_grip(id, world,
dir)` for oriented mid-segment handles, and a kept-for-Phase-2
`triangle_grip`.
- `overlay::GripMarker` / `grips_to_screen[_paper]` thread `dir`
through to the canvas painter; the Rectangle path rotates the
box around the grip centre using the world-XY direction (flipping
the sin for screen-Y).
Every existing `diamond_grip(id, w)` call becomes `center_grip(id,
w)` — the visual change is square-replacing-diamond at line
midpoints / arc midpoints / circle centres / ellipse centres /
viewport / hatch loop centroids / dimension grips / image corners /
multileader / ray / underlay / ole2frame / solid3d.
`LwPolyline` grows a stretch handle on every segment (straight or
arc), drawn as the new oriented rectangle. The grip-edit path
translates both segment endpoints for straight segments and
adjusts the arc bulge from the new midpoint for arc segments.
2026-05-29 02:07:34 +03:00
|
|
|
/// 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".
|
2026-06-11 00:04:37 +03:00
|
|
|
pub fn rectangle_grip(id: usize, world: glam::DVec3, dir: [f32; 2]) -> GripDef {
|
2026-05-29 01:46:33 +03:00
|
|
|
GripDef {
|
|
|
|
|
id,
|
|
|
|
|
world,
|
|
|
|
|
is_midpoint: true,
|
|
|
|
|
shape: GripShape::Rectangle,
|
refactor(grip): square-only marker set + oriented stretch handles
Phase 1 of bringing the grip vocabulary in line with the standard
CAD convention: drop the per-position-type shape encoding (Diamond
for translate, Circle for parameter) and use a plain square for
every endpoint / vertex / centre grip. The behavioural midpoint vs
vertex distinction stays — it lives on `GripDef::is_midpoint` and
drives `Translate` vs `Absolute` in the grip-edit dispatcher — only
the marker shape collapses.
- `GripShape` keeps `Square`, `Rectangle`, `Triangle`. Diamond /
Circle are gone.
- `GripDef` gains `dir: Option<[f32; 2]>` — a world-XY direction
vector consumed by `Rectangle` to orient the box along its
segment.
- `entities::common` exposes `square_grip`, `center_grip` (same
square marker, flagged as translate), `rectangle_grip(id, world,
dir)` for oriented mid-segment handles, and a kept-for-Phase-2
`triangle_grip`.
- `overlay::GripMarker` / `grips_to_screen[_paper]` thread `dir`
through to the canvas painter; the Rectangle path rotates the
box around the grip centre using the world-XY direction (flipping
the sin for screen-Y).
Every existing `diamond_grip(id, w)` call becomes `center_grip(id,
w)` — the visual change is square-replacing-diamond at line
midpoints / arc midpoints / circle centres / ellipse centres /
viewport / hatch loop centroids / dimension grips / image corners /
multileader / ray / underlay / ole2frame / solid3d.
`LwPolyline` grows a stretch handle on every segment (straight or
arc), drawn as the new oriented rectangle. The grip-edit path
translates both segment endpoints for straight segments and
adjusts the arc bulge from the new midpoint for arc segments.
2026-05-29 02:07:34 +03:00
|
|
|
dir: Some(dir),
|
2026-05-29 01:46:33 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[allow(dead_code)]
|
2026-06-11 00:04:37 +03:00
|
|
|
pub fn triangle_grip(id: usize, world: glam::DVec3) -> GripDef {
|
2026-05-29 01:46:33 +03:00
|
|
|
GripDef {
|
|
|
|
|
id,
|
|
|
|
|
world,
|
|
|
|
|
is_midpoint: false,
|
refactor(grip): square-only marker set + oriented stretch handles
Phase 1 of bringing the grip vocabulary in line with the standard
CAD convention: drop the per-position-type shape encoding (Diamond
for translate, Circle for parameter) and use a plain square for
every endpoint / vertex / centre grip. The behavioural midpoint vs
vertex distinction stays — it lives on `GripDef::is_midpoint` and
drives `Translate` vs `Absolute` in the grip-edit dispatcher — only
the marker shape collapses.
- `GripShape` keeps `Square`, `Rectangle`, `Triangle`. Diamond /
Circle are gone.
- `GripDef` gains `dir: Option<[f32; 2]>` — a world-XY direction
vector consumed by `Rectangle` to orient the box along its
segment.
- `entities::common` exposes `square_grip`, `center_grip` (same
square marker, flagged as translate), `rectangle_grip(id, world,
dir)` for oriented mid-segment handles, and a kept-for-Phase-2
`triangle_grip`.
- `overlay::GripMarker` / `grips_to_screen[_paper]` thread `dir`
through to the canvas painter; the Rectangle path rotates the
box around the grip centre using the world-XY direction (flipping
the sin for screen-Y).
Every existing `diamond_grip(id, w)` call becomes `center_grip(id,
w)` — the visual change is square-replacing-diamond at line
midpoints / arc midpoints / circle centres / ellipse centres /
viewport / hatch loop centroids / dimension grips / image corners /
multileader / ray / underlay / ole2frame / solid3d.
`LwPolyline` grows a stretch handle on every segment (straight or
arc), drawn as the new oriented rectangle. The grip-edit path
translates both segment endpoints for straight segments and
adjusts the arc bulge from the new midpoint for arc segments.
2026-05-29 02:07:34 +03:00
|
|
|
shape: GripShape::Triangle,
|
|
|
|
|
dir: None,
|
2026-05-29 01:46:33 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
pub fn edit_prop(label: &'static str, field: &'static str, value: f64) -> Property {
|
|
|
|
|
Property {
|
|
|
|
|
label: label.into(),
|
|
|
|
|
field,
|
2026-05-20 05:43:55 +03:00
|
|
|
value: PropValue::EditText(format_length(value)),
|
2026-03-22 14:56:27 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn ro_prop(label: &'static str, field: &'static str, value: impl Into<String>) -> Property {
|
|
|
|
|
Property {
|
|
|
|
|
label: label.into(),
|
|
|
|
|
field,
|
|
|
|
|
value: PropValue::ReadOnly(value.into()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(properties): rework Text/MText/MLeader/Leader panels to standard layout
Match the entity Properties panels to the standard CAD layout and
web-verified behaviour:
- Text: combined Justify (horizontal x vertical) dropdown; Position and
Text-alignment rows become editable/grayed by justification; drop the
Annotative-scale row.
- MText: combined Justify; editable Direction, Line space distance
(back-solves the factor) and Line space style; Defined width/height
gated on column mode.
- MLeader: regrouped to match the palette - drop the Leader Structure
group (fold landing rows into Leaders), gate Text vs Block on content
type, full 9-value attachment label set, resolve handle-backed rows
(style, text style, arrowhead, linetype) to names.
- Leader: Lines & Arrows / Text / Fit are resolved from the dimension
style (arrowhead, arrow size, dim-line lineweight/colour, text gap,
vertical text pos, overall scale); Current Vertex navigator; combined
Type; Geometry group after General; no 3D-Visualisation group.
- General: rename "LT Scale" to "Linetype scale"; show ByLayer
transparency by name. Add shared num_prop helper for conditionally
editable numeric rows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 18:01:44 +03:00
|
|
|
/// 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: &'static str, field: &'static str, value: f64, editable: bool) -> Property {
|
|
|
|
|
if editable {
|
|
|
|
|
edit_prop(label, field, value)
|
|
|
|
|
} else {
|
|
|
|
|
ro_prop(label, field, format_length(value))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 17:53:05 +03:00
|
|
|
/// 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: &'static str,
|
|
|
|
|
field: &'static str,
|
|
|
|
|
display: impl Into<String>,
|
|
|
|
|
) -> Property {
|
|
|
|
|
Property {
|
|
|
|
|
label: label.into(),
|
|
|
|
|
field,
|
|
|
|
|
value: PropValue::Stepper {
|
|
|
|
|
field,
|
|
|
|
|
display: display.into(),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
pub fn parse_f64(value: &str) -> Option<f64> {
|
|
|
|
|
value.trim().parse::<f64>().ok()
|
|
|
|
|
}
|
refactor(common): unify bulge → arc math into BulgeArc
The same DXF/DWG bulge → (center, radius, sweep) formula was inlined
in six places (hatch outline, polyline tess + truck path, lwpolyline
×4, scene hatch fill, explode bulge_to_arc) — each with subtly
different sweep-direction handling for the bulge sign.
Add `entities::common::BulgeArc { center, radius, start_angle,
end_angle, sweep }` with one `from_bulge(p0, p1, bulge)` constructor
and a `sample(t)` helper. Callsites collapse to a few lines.
Net: -121 lines, one canonical sweep-direction convention shared by
all consumers.
array.rs (the path-along-curve f32 helper) keeps its inline math —
its sign convention is inverted relative to the others and changing
it without exercising the array-path command risks regressing the
output.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:42:26 +03:00
|
|
|
|
|
|
|
|
/// 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(),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix(pick): catch a wide polyline on its band, not just its centreline
A wide polyline draws a solid band, but hit-testing only ever saw the
centreline 8 px out, so a donut could only be selected by clicking the
phantom circle inside the disc it paints. `self.hatches` — the map the
hatch pick channel walks — is filled from HATCH and SOLID alone, so the
band `wide_fills` produces was drawn (via `synced_hatch_models`) and never
picked.
The band rides in on `pick_tris`, the same channel a thickness wall uses:
`wide_fills` already computes it, so it costs one tessellation and inherits
the block transform. It ear clips rather than fans — an arc band is an
annular sector, concave on its inner edge — reusing `triangulate_planar`.
Wiring `pick_tris` through `Lines` alone was not enough: a plain wide
polyline leaves `to_truck` as `Contour`, and a PLINEGEN=0 one as
`SegmentedLines`. Both now carry it.
This needs the wire's cullable box to cover the band, and it did not.
`bounding_box()` boxes a polyline's stored vertices, knowing nothing of the
width painted around them: a donut's two vertices sit on one horizontal
line, so the box had zero height and rejected every click on the disc
before any triangle was tested. Every site that stamps the entity box onto
a wire now goes through `set_wire_aabb`, which widens it over `pick_tris`
(a no-op for the entities that have none, which is nearly all), and the
block cache chains `pick_tris` into its own box for the same reason.
That box was already too small for the centreline itself — an arc leaves
the vertex box — so a donut was unselectable at its top and bottom at
enough zoom. Widening fixes that too.
Verified end-to-end: tessellate a width-0.5 donut on a 0.05 centreline
radius, then `click_hit` five points 20 px clear of the centreline — all
land, and a click outside the disc still misses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:22:56 +03:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 16:04:03 +03:00
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 23:26:14 +03:00
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
fix(pick): catch a wide polyline on its band, not just its centreline
A wide polyline draws a solid band, but hit-testing only ever saw the
centreline 8 px out, so a donut could only be selected by clicking the
phantom circle inside the disc it paints. `self.hatches` — the map the
hatch pick channel walks — is filled from HATCH and SOLID alone, so the
band `wide_fills` produces was drawn (via `synced_hatch_models`) and never
picked.
The band rides in on `pick_tris`, the same channel a thickness wall uses:
`wide_fills` already computes it, so it costs one tessellation and inherits
the block transform. It ear clips rather than fans — an arc band is an
annular sector, concave on its inner edge — reusing `triangulate_planar`.
Wiring `pick_tris` through `Lines` alone was not enough: a plain wide
polyline leaves `to_truck` as `Contour`, and a PLINEGEN=0 one as
`SegmentedLines`. Both now carry it.
This needs the wire's cullable box to cover the band, and it did not.
`bounding_box()` boxes a polyline's stored vertices, knowing nothing of the
width painted around them: a donut's two vertices sit on one horizontal
line, so the box had zero height and rejected every click on the disc
before any triangle was tested. Every site that stamps the entity box onto
a wire now goes through `set_wire_aabb`, which widens it over `pick_tris`
(a no-op for the entities that have none, which is nearly all), and the
block cache chains `pick_tris` into its own box for the same reason.
That box was already too small for the centreline itself — an arc leaves
the vertex box — so a donut was unselectable at its top and bottom at
enough zoom. Widening fixes that too.
Verified end-to-end: tessellate a width-0.5 donut on a 0.05 centreline
radius, then `click_hit` five points 20 px clear of the centreline — all
land, and a click outside the disc still misses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:22:56 +03:00
|
|
|
|