feat(properties): fill placeholder rows from acadrust fields + doc lookups

Bump acadrust to a46cb8b (spline knot parameterization + mtext line-space
style now stored) and populate rows the builders left blank:

Entity-local: spline Knot Parameterization + closed-spline Area (shoelace),
mtext Line space style, hatch Spacing (pattern-line offset), leader Text
offset (annotation offset), region Area + Perimeter (wire-loop approx).

Doc-dependent (resolved in app/properties.rs where the document is
reachable): insert Block Unit + Unit factor (block record units vs
INSUNITS), underlay Name + Path (definition object), leader Text style /
vertical placement / overall scale and tolerance Text style (dimension
style), multileader Max points + segment angles (mleader style).

Rows needing an annotation-scale / sheet-set subsystem or an internal
PDF/DWF layer parser are intentionally left blank.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-03 00:05:58 +03:00
commit f50ad0dde4
7 changed files with 258 additions and 8 deletions

2
Cargo.lock generated
View file

@ -71,7 +71,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "acadrust"
version = "0.4.0"
source = "git+https://github.com/HakanSeven12/acadrust?branch=main#486c0a9447774e3997d4ff3c7645263b8bad72b7"
source = "git+https://github.com/HakanSeven12/acadrust?branch=main#a46cb8b3a2e5523bbd28e5afa0f8efb7ae291c50"
dependencies = [
"ahash 0.8.12",
"anyhow",

View file

@ -280,6 +280,112 @@ impl OpenCADStudio {
}
}
// ── Doc-dependent property rows ──────────────────────────
// Rows whose value lives on another object (a block record,
// an underlay definition, a dimension / multileader style)
// are left empty by the entity builders and resolved here,
// where the document is reachable.
let doc = &self.tabs[i].scene.document;
match entity {
// Block reference: the referenced block's units and the
// unit-scale factor against the drawing's INSUNITS.
acadrust::EntityType::Insert(ins) => {
let host = doc.header.insertion_units;
let src = doc
.block_records
.get(&ins.block_name)
.map(|br| br.units)
.unwrap_or(0);
set_row(&mut sections, "block_unit", insunits_name(src).to_string());
let host_mm = if host == 0 { 1.0 } else { insunits_to_mm(host) };
let src_mm = if src == 0 { 1.0 } else { insunits_to_mm(src) };
let factor = if host_mm.abs() > 1e-12 { src_mm / host_mm } else { 1.0 };
set_row(&mut sections, "unit_factor", format!("{factor:.4}"));
}
// Underlay: name + path from the referenced definition.
acadrust::EntityType::Underlay(ul) => {
if let Some((name, path)) =
doc.objects.iter().find_map(|(h, o)| match o {
acadrust::objects::ObjectType::UnderlayDefinition(def)
if *h == ul.definition_handle =>
{
let nm = if !def.name.is_empty() {
def.name.clone()
} else {
def.page_name.clone()
};
Some((nm, def.file_path.clone()))
}
_ => None,
})
{
set_row(&mut sections, "ul_name", name);
set_row(&mut sections, "ul_path", path);
}
}
// Leader: text style / vertical text placement / overall
// scale come from its dimension style.
acadrust::EntityType::Leader(ld) => {
if let Some(ds) = find_dim_style(doc, &ld.dimension_style) {
if !ds.dimtxsty.is_empty() {
set_row(&mut sections, "text_style", ds.dimtxsty.clone());
}
set_row(
&mut sections,
"text_pos_vert",
dimtad_label(ds.dimtad).to_string(),
);
set_row(
&mut sections,
"dim_scale_overall",
format!("{:.4}", ds.dimscale),
);
}
}
// Feature-control frame: FCF text style is the dimension
// style's DIMTXSTY.
acadrust::EntityType::Tolerance(tol) => {
if let Some(ds) = find_dim_style(doc, &tol.dimension_style_name) {
if !ds.dimtxsty.is_empty() {
set_row(&mut sections, "tol_text_style", ds.dimtxsty.clone());
}
}
}
// MultiLeader: max points + segment-angle constraints
// are MLeaderStyle settings, not stored on the entity.
acadrust::EntityType::MultiLeader(ml) => {
if let Some(sh) = ml.style_handle {
if let Some((mx, a1, a2)) =
doc.objects.iter().find_map(|(h, o)| match o {
acadrust::objects::ObjectType::MultiLeaderStyle(s)
if *h == sh =>
{
Some((
s.max_leader_points,
s.first_segment_angle,
s.second_segment_angle,
))
}
_ => None,
})
{
set_row(&mut sections, "max_leader_points", mx.to_string());
set_row(
&mut sections,
"first_segment_angle",
format!("{a1:.4}"),
);
set_row(
&mut sections,
"second_segment_angle",
format!("{a2:.4}"),
);
}
}
}
_ => {}
}
if !group_names.is_empty() {
let label = group_names.join(", ");
if let Some(general) = sections.first_mut() {
@ -926,6 +1032,73 @@ fn merge_prop_value(
}
}
/// Set the first property row matching `field` (across all sections) to a
/// read-only `value`. No-op when the field is absent. Used to fill the
/// doc-dependent placeholder rows the entity builders leave empty.
fn set_row(
sections: &mut [crate::scene::model::object::PropSection],
field: &str,
value: String,
) {
for section in sections.iter_mut() {
if let Some(row) = section.props.iter_mut().find(|p| p.field == field) {
row.value = crate::scene::model::object::PropValue::ReadOnly(value);
return;
}
}
}
/// Resolve a dimension style by name (case-insensitive), falling back to
/// "Standard" when the name is blank.
fn find_dim_style<'a>(
doc: &'a acadrust::CadDocument,
name: &str,
) -> Option<&'a acadrust::tables::DimStyle> {
doc.dim_styles.iter().find(|s| {
s.name.eq_ignore_ascii_case(name)
|| (name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard"))
})
}
/// Vertical text placement (DIMTAD) label.
fn dimtad_label(dimtad: i16) -> &'static str {
match dimtad {
1 => "Above",
2 => "Outside",
3 => "JIS",
4 => "Below",
_ => "Centered",
}
}
/// Human-readable INSUNITS name (DXF group 70 unit codes).
fn insunits_name(code: i16) -> &'static str {
match code {
1 => "Inches",
2 => "Feet",
3 => "Miles",
4 => "Millimeters",
5 => "Centimeters",
6 => "Meters",
7 => "Kilometers",
8 => "Microinches",
9 => "Mils",
10 => "Yards",
11 => "Angstroms",
12 => "Nanometers",
13 => "Microns",
14 => "Decimeters",
15 => "Decameters",
16 => "Hectometers",
17 => "Gigameters",
18 => "Astronomical Units",
19 => "Light Years",
20 => "Parsecs",
21 => "US Survey Feet",
_ => "Unitless",
}
}
/// Convert INSUNITS (DXF group 70) to millimetres.
/// 0 = unitless / unknown: returns 1.0 so the caller treats it as "do not scale".
fn insunits_to_mm(code: i16) -> f64 {

View file

@ -150,7 +150,15 @@ fn properties(h: &Hatch) -> Vec<PropSection> {
),
ro("Layer override", "layer_override", String::new()),
ro("Double", "double", if h.is_double { "Yes" } else { "No" }),
ro("Spacing", "spacing", String::new()),
ro(
"Spacing",
"spacing",
h.pattern
.lines
.first()
.map(|l| format!("{:.4}", l.offset.length()))
.unwrap_or_default(),
),
ro("ISO pen width", "iso_pen_width", String::new()),
ro("Gradient colors", "gradient_colors", g.colors.len().to_string()),
ro("Gradient tint", "gradient_tint", format!("{:.4}", g.color_tint)),

View file

@ -231,7 +231,11 @@ fn properties(leader: &Leader) -> Vec<PropSection> {
let text = vec![
edit("Text height", "text_height", leader.text_height),
ro("Text offset", "text_offset", String::new()),
ro(
"Text offset",
"text_offset",
format!("{:.4}", leader.annotation_offset.length()),
),
ro("Text style", "text_style", String::new()),
ro("Text color", "text_color", color_str(&leader.override_color)),
ro("Text position vert", "text_pos_vert", String::new()),

View file

@ -272,7 +272,14 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec<PropSection> {
"line_space_distance",
format!("{line_space_distance:.4}"),
),
ro("Line space style", "line_space_style", String::new()),
ro(
"Line space style",
"line_space_style",
match t.line_spacing_style {
acadrust::entities::LineSpacingStyle::Exactly => "Exactly",
_ => "At least",
},
),
ro(
"Background mask",
"background_mask",

View file

@ -52,6 +52,39 @@ fn translate_wires(wires: &mut Vec<acadrust::entities::Wire>, d: Vec3) {
}
}
/// Approximate a region's enclosed area and boundary perimeter from its
/// wireframe loops. Perimeter is the total edge length across every wire.
/// Area accumulates the Newell area vector of each loop (opposite-wound
/// holes subtract) and halves its magnitude — exact for a single planar
/// loop, approximate for multi-loop or curved regions. Returns zeros when
/// there is nothing to measure.
fn region_area_perimeter(wires: &[acadrust::entities::Wire]) -> (f64, f64) {
let mut perimeter = 0.0;
let (mut nx, mut ny, mut nz) = (0.0, 0.0, 0.0);
for wire in wires {
let pts = &wire.points;
if pts.len() < 2 {
continue;
}
for seg in pts.windows(2) {
let dx = seg[1].x - seg[0].x;
let dy = seg[1].y - seg[0].y;
let dz = seg[1].z - seg[0].z;
perimeter += (dx * dx + dy * dy + dz * dz).sqrt();
}
let n = pts.len();
for i in 0..n {
let a = &pts[i];
let b = &pts[(i + 1) % n];
nx += (a.y - b.y) * (a.z + b.z);
ny += (a.z - b.z) * (a.x + b.x);
nz += (a.x - b.x) * (a.y + b.y);
}
}
let area = 0.5 * (nx * nx + ny * ny + nz * nz).sqrt();
(area, perimeter)
}
// ── Solid3D ───────────────────────────────────────────────────────────────────
impl Grippable for Solid3D {
@ -132,11 +165,12 @@ impl Grippable for Region {
impl PropertyEditable for Region {
fn geometry_properties(&self, _text_style_names: &[String]) -> Vec<PropSection> {
let (area, perimeter) = region_area_perimeter(&self.wires);
vec![PropSection {
title: "Geometry".into(),
props: vec![
ro("Area", "rgn_area", String::new()),
ro("Perimeter", "rgn_perimeter", String::new()),
ro("Area", "rgn_area", format!("{area:.4}")),
ro("Perimeter", "rgn_perimeter", format!("{perimeter:.4}")),
],
}]
}

View file

@ -133,6 +133,30 @@ fn properties(spline: &Spline) -> Vec<PropSection> {
let closed = spline.flags.closed || spline.flags.periodic;
let yes_no = |b: bool| if b { "Yes" } else { "No" };
// Knot parameterization method (R2013+ DWG); older splines report 0.
let knot_param = match spline.knot_parameterization {
0 => "Chord",
1 => "Square Root",
2 => "Uniform",
_ => "Custom",
};
// Closed splines enclose an area; approximate it with the shoelace
// formula over the defining points projected to the XY plane, matching
// the polyline approximation already used for Length.
let area = if closed && pts.len() >= 3 {
let mut acc = 0.0;
for w in pts.windows(2) {
acc += w[0].x * w[1].y - w[1].x * w[0].y;
}
if let (Some(first), Some(last)) = (pts.first(), pts.last()) {
acc += last.x * first.y - first.x * last.y;
}
format!("{:.4}", acc.abs() * 0.5)
} else {
String::new()
};
vec![
PropSection {
title: "Data Points".into(),
@ -161,7 +185,7 @@ fn properties(spline: &Spline) -> Vec<PropSection> {
format!("{:.4}", cp0.map(|p| p.z).unwrap_or(0.0)),
),
ro("Weight", "weight", format!("{:.4}", w0.unwrap_or(1.0))),
ro("Knot Parameterization", "knot_param", String::new()),
ro("Knot Parameterization", "knot_param", knot_param),
ro(
"Fit Point Count",
"fit_pt_count",
@ -198,7 +222,7 @@ fn properties(spline: &Spline) -> Vec<PropSection> {
ro("Closed", "closed", yes_no(closed)),
ro("Planar", "planar", yes_no(spline.flags.planar)),
ro("Length", "length", format!("{length:.4}")),
ro("Area", "area", String::new()),
ro("Area", "area", area),
],
},
]