feat(array): edit associative arrays through their standard records

An associative array is an AcDbAssocAction with an
AcDbAssocArrayActionBody, dependencies on the array INSERT, the block
holding the item references and the block holding the source objects,
and value parameters (Items, ItemSpacing, Rows, …). Selecting the array
INSERT now shows its counts and spacings in Properties; changing one
places the items again and rewrites the item references, the body's
item records and the parameter values together, in one undo step, so
the drawing stays associative when saved.

Rectangular arrays follow their RowProfile axis, AxesAngle and base
normal; polar arrays turn about the centre recovered from their first
item, Radius and StartAngle. Both match the stored items of the
associative arrays in the sample drawings (122 of 123 rectangular, all
polar items of the polar samples). Path arrays are shown read-only.

Refs #1109

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-13 20:32:51 +03:00
1 parent b371a0a59e
commit 2bf77e4bd6
27 files changed
+831

No files matched your search

+558
View File
@@ -0,0 +1,558 @@
//! Associative arrays as the drawing stores them: an AcDbAssocAction whose
//! body is an AcDbAssocArrayActionBody, with dependencies on the array INSERT
//! (read + write), the anonymous block holding the item INSERTs (write) and the
//! anonymous block holding the source objects (read). The action's value
//! parameters (Items, ItemSpacing, Rows, …) define where the items go.
use codec::objects::{
AssocAction, AssocArrayActionBody, AssocDependency, AssocEvalValue, AssocValueParam,
AssociativeData, ObjectType,
};
use codec::{CadDocument, Handle};
use glam::{DMat4, DVec3};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArrayKind {
Rectangular,
Polar,
Path,
}
/// One associative array, resolved from its array INSERT.
#[derive(Clone, Debug)]
pub struct AssocArray {
pub insert: Handle,
pub action: Handle,
pub body: Handle,
/// Block record holding the item INSERTs.
pub array_block: Handle,
/// Block record holding the source objects.
pub source_block: Handle,
pub kind: ArrayKind,
}
fn dependency(document: &CadDocument, handle: Handle) -> Option<&AssocDependency> {
match document.objects.get(&handle)? {
ObjectType::Associative(object) => match &object.data {
AssociativeData::Dependency(dependency) => Some(dependency),
AssociativeData::GeomDependency(geom) => Some(&geom.dependency),
AssociativeData::ValueDependency(value) => Some(&value.dependency),
_ => None,
},
_ => None,
}
}
pub fn action(document: &CadDocument, handle: Handle) -> Option<&AssocAction> {
match document.objects.get(&handle)? {
ObjectType::Associative(object) => match &object.data {
AssociativeData::Action(action) => Some(action),
_ => None,
},
_ => None,
}
}
pub fn body(document: &CadDocument, handle: Handle) -> Option<&AssocArrayActionBody> {
match document.objects.get(&handle)? {
ObjectType::Associative(object) => match &object.data {
AssociativeData::ArrayActionBody(body) => Some(body),
AssociativeData::ArrayModifyActionBody(modify) => Some(&modify.body),
_ => None,
},
_ => None,
}
}
/// The associative array whose array INSERT is `insert`, if any.
pub fn array_of_insert(document: &CadDocument, insert: Handle) -> Option<AssocArray> {
document.objects.iter().find_map(|(&handle, object)| {
let ObjectType::Associative(object) = object else {
return None;
};
let AssociativeData::Action(action) = &object.data else {
return None;
};
let array_body = body(document, action.action_body)?;
let (mut array_block, mut source_block, mut owns_insert) =
(Handle::NULL, Handle::NULL, false);
for link in &action.dependencies {
let Some(dependency) = dependency(document, link.dependency) else {
continue;
};
match (dependency.is_read_dependency, dependency.is_write_dependency) {
(true, true) if dependency.dependent_on == insert => owns_insert = true,
(false, true) => array_block = dependency.dependent_on,
(true, false) if source_block.is_null() => {
source_block = dependency.dependent_on
}
_ => {}
}
}
let kind = match array_body.parameter_block.as_str() {
"AcDbAssocArrayRectangularParameters" => ArrayKind::Rectangular,
"AcDbAssocArrayPolarParameters" => ArrayKind::Polar,
"AcDbAssocArrayPathParameters" => ArrayKind::Path,
_ => return None,
};
(owns_insert && !array_block.is_null() && !source_block.is_null()).then_some(
AssocArray {
insert,
action: handle,
body: action.action_body,
array_block,
source_block,
kind,
},
)
})
}
/// The value parameters of an array action: inline in the action (R2013+)
/// or in the body's parameter section (R2007).
pub fn value_params<'a>(document: &'a CadDocument, array: &AssocArray) -> &'a [AssocValueParam] {
match action(document, array.action) {
Some(action) if !action.values.is_empty() => &action.values,
_ => body(document, array.body).map_or(&[], |body| &body.parameter_body.values),
}
}
/// The number a value parameter holds, whatever its stored type.
pub fn param_value(params: &[AssocValueParam], name: &str) -> Option<f64> {
let param = params.iter().find(|param| param.name == name)?;
match &param.variables.first()?.value.value {
AssocEvalValue::Real(value) => Some(*value),
AssocEvalValue::Long(value) => Some(f64::from(*value)),
AssocEvalValue::Short(value) => Some(f64::from(*value)),
AssocEvalValue::Byte(value) => Some(f64::from(*value)),
_ => None,
}
}
/// Store `value` in a value parameter, keeping its stored type.
pub fn set_param_value(params: &mut [AssocValueParam], name: &str, value: f64) -> bool {
let Some(variable) = params
.iter_mut()
.find(|param| param.name == name)
.and_then(|param| param.variables.first_mut())
else {
return false;
};
variable.value.value = match variable.value.value {
AssocEvalValue::Long(_) => AssocEvalValue::Long(value.round() as i32),
AssocEvalValue::Short(_) => AssocEvalValue::Short(value.round() as i16),
AssocEvalValue::Byte(_) => AssocEvalValue::Byte(value.round() as u8),
_ => AssocEvalValue::Real(value),
};
true
}
/// The direction columns step along: the line of the action's "RowProfile"
/// parameter, X when the array has none.
pub fn column_axis(document: &CadDocument, array: &AssocArray) -> DVec3 {
let Some(action) = action(document, array.action) else {
return DVec3::X;
};
action
.owned_parameters
.iter()
.find_map(|handle| {
let ObjectType::Associative(object) = document.objects.get(handle)? else {
return None;
};
let AssociativeData::EdgeActionParam(edge) = &object.data else {
return None;
};
if edge.single_dependency.action_param.name != "RowProfile" || edge.action_type != 19
{
return None;
}
match edge.curve.get(1)? {
codec::objects::AssocCurveValue::Point(direction) => {
DVec3::new(direction.x, direction.y, direction.z).try_normalize()
}
_ => None,
}
})
.unwrap_or(DVec3::X)
}
/// The plane normal of an array: its "BaseNormal" vertex parameter, Z when
/// the array has none.
pub fn base_normal(document: &CadDocument, array: &AssocArray) -> DVec3 {
let Some(action) = action(document, array.action) else {
return DVec3::Z;
};
action
.owned_parameters
.iter()
.find_map(|handle| {
let ObjectType::Associative(object) = document.objects.get(handle)? else {
return None;
};
let AssociativeData::VertexActionParam(vertex) = &object.data else {
return None;
};
if vertex.single_dependency.action_param.name != "BaseNormal" {
return None;
}
DVec3::new(vertex.point.x, vertex.point.y, vertex.point.z).try_normalize()
})
.unwrap_or(DVec3::Z)
}
/// Item placements of a rectangular array in array-local coordinates:
/// (column, row, level) and the offset of the item's base point. Columns
/// step along `axis`; rows run at AxesAngle from it about `normal`, and
/// levels along `normal`.
pub fn rectangular_items(
params: &[AssocValueParam],
axis: DVec3,
normal: DVec3,
) -> Vec<([i32; 3], DVec3)> {
let get = |name: &str, default: f64| param_value(params, name).unwrap_or(default);
let (columns, rows, levels) = (
get("Items", 1.0).max(1.0) as i32,
get("Rows", 1.0).max(1.0) as i32,
get("Levels", 1.0).max(1.0) as i32,
);
let column_step = axis * get("ItemSpacing", 0.0);
let axes = get("AxesAngle", std::f64::consts::FRAC_PI_2);
let row_axis = glam::DQuat::from_axis_angle(normal, axes) * axis;
let row_step = row_axis * get("RowSpacing", 0.0) + normal * get("RowElevation", 0.0);
let level_step = normal * get("LevelSpacing", 0.0);
let mut items = Vec::with_capacity((columns * rows * levels).max(0) as usize);
for level in 0..levels {
for row in 0..rows {
for column in 0..columns {
let offset = column_step * f64::from(column)
+ row_step * f64::from(row)
+ level_step * f64::from(level);
items.push(([column, row, level], offset));
}
}
}
items
}
/// An array item's placement in array-local coordinates, as stored.
pub fn stored_item_transform(item: &codec::objects::AssocArrayItem) -> DMat4 {
if item.uses_default_transform {
let offset = item.x_direction;
DMat4::from_translation(DVec3::new(offset.x, offset.y, offset.z))
} else {
DMat4::from_cols_array(&item.transform).transpose()
}
}
/// The in-plane X axis angles are measured from: world X for a plan array,
/// the arbitrary-axis direction otherwise.
fn plane_x(normal: DVec3) -> DVec3 {
if normal.x.abs() < 1.0 / 64.0 && normal.y.abs() < 1.0 / 64.0 {
DVec3::Y.cross(normal).normalize()
} else {
DVec3::Z.cross(normal).normalize()
}
}
/// The centre of a polar array, recovered from its first item and its
/// Radius and StartAngle.
pub fn polar_center(document: &CadDocument, array: &AssocArray) -> Option<DVec3> {
let params = value_params(document, array);
let first = body(document, array.body)?
.items
.iter()
.find(|item| item.location == [0, 0, 0])?;
let normal = base_normal(document, array);
let x = plane_x(normal);
let y = normal.cross(x);
let start = param_value(params, "StartAngle").unwrap_or(0.0);
let radius = param_value(params, "Radius")?;
Some(
stored_item_transform(first).w_axis.truncate()
- (x * start.cos() + y * start.sin()) * radius,
)
}
/// Item placements of a polar array about `center`: each item turns about
/// the centre by its multiple of ItemSpacing (rotated with it unless
/// AlignItems is 0); rows step the radius out, levels step along the normal.
pub fn polar_items(
params: &[AssocValueParam],
center: DVec3,
normal: DVec3,
) -> Vec<([i32; 3], DMat4)> {
let get = |name: &str, default: f64| param_value(params, name).unwrap_or(default);
let (count, rows, levels) = (
get("Items", 1.0).max(1.0) as i32,
get("Rows", 1.0).max(1.0) as i32,
get("Levels", 1.0).max(1.0) as i32,
);
// ponytail: Direction 0 read as clockwise; no sample with 0 to confirm.
let sign = if get("Direction", 1.0) == 0.0 { -1.0 } else { 1.0 };
let align = get("AlignItems", 1.0) != 0.0;
let (spacing, start, radius) =
(get("ItemSpacing", 0.0), get("StartAngle", 0.0), get("Radius", 0.0));
let x = plane_x(normal);
let y = normal.cross(x);
let start_dir = x * start.cos() + y * start.sin();
let mut items = Vec::with_capacity((count * rows * levels).max(0) as usize);
for level in 0..levels {
for row in 0..rows {
let ring = radius + f64::from(row) * get("RowSpacing", 0.0);
let lift = normal
* (f64::from(row) * get("RowElevation", 0.0)
+ f64::from(level) * get("LevelSpacing", 0.0));
for item in 0..count {
let turn = glam::DQuat::from_axis_angle(normal, sign * f64::from(item) * spacing);
let position = center + turn * (start_dir * ring) + lift;
let placement = if align {
DMat4::from_rotation_translation(turn, position)
} else {
DMat4::from_translation(position)
};
items.push(([item, row, level], placement));
}
}
}
items
}
/// The rows the Properties palette shows for an array: (field, parameter,
/// label, kind) where kind 0 is a count, 1 a length and 2 an angle.
fn property_rows(kind: ArrayKind) -> &'static [(&'static str, &'static str, &'static str, u8)] {
match kind {
ArrayKind::Rectangular => &[
("assoc_array_columns", "Items", "Columns", 0),
("assoc_array_column_spacing", "ItemSpacing", "Column spacing", 1),
("assoc_array_rows", "Rows", "Rows", 0),
("assoc_array_row_spacing", "RowSpacing", "Row spacing", 1),
("assoc_array_levels", "Levels", "Levels", 0),
("assoc_array_level_spacing", "LevelSpacing", "Level spacing", 1),
],
ArrayKind::Polar => &[
("assoc_array_items", "Items", "Items", 0),
("assoc_array_fill_angle", "FillAngle", "Fill angle", 2),
("assoc_array_radius", "Radius", "Radius", 1),
("assoc_array_rows", "Rows", "Rows", 0),
("assoc_array_row_spacing", "RowSpacing", "Row spacing", 1),
("assoc_array_levels", "Levels", "Levels", 0),
("assoc_array_level_spacing", "LevelSpacing", "Level spacing", 1),
],
ArrayKind::Path => &[
("assoc_array_items", "Items", "Items", 0),
("assoc_array_item_spacing", "ItemSpacing", "Item spacing", 1),
("assoc_array_rows", "Rows", "Rows", 0),
("assoc_array_row_spacing", "RowSpacing", "Row spacing", 1),
("assoc_array_levels", "Levels", "Levels", 0),
("assoc_array_level_spacing", "LevelSpacing", "Level spacing", 1),
],
}
}
/// The Properties section of an associative array. Path arrays are shown
/// read-only: their items follow a curve this does not re-place yet.
pub fn property_section(
document: &CadDocument,
array: &AssocArray,
) -> crate::scene::model::object::PropSection {
use crate::entities::common::{format_angle, format_length};
use crate::scene::model::object::{PropSection, PropValue, Property};
let params = value_params(document, array);
let props = property_rows(array.kind)
.iter()
.filter_map(|&(field, name, label, kind)| {
let value = param_value(params, name)?;
let text = match kind {
0 => format!("{}", value.round() as i64),
1 => format_length(value),
_ => format_angle(value),
};
Some(Property {
label: crate::t!(label).into_owned(),
field,
value: if array.kind == ArrayKind::Path {
PropValue::ReadOnly(text)
} else {
PropValue::EditText(text)
},
})
})
.collect();
let title = match array.kind {
ArrayKind::Rectangular => "Array (Rectangular)",
ArrayKind::Polar => "Array (Polar)",
ArrayKind::Path => "Array (Path)",
};
PropSection {
title: crate::t!(title).into_owned(),
props,
}
}
/// Write a Properties edit of an array row: the parameter changes and the
/// items are placed again. `text` is the typed value (counts, lengths in
/// drawing units, angles as typed angles).
pub fn apply_field(
scene: &mut crate::scene::Scene,
array: &AssocArray,
field: &str,
text: &str,
) -> Result<(), String> {
let Some(&(_, name, _, kind)) =
property_rows(array.kind).iter().find(|row| row.0 == field)
else {
return Err("unknown array field".into());
};
let value = match kind {
0 => text
.trim()
.parse::<f64>()
.ok()
.filter(|v| *v >= 1.0 && *v <= 100_000.0)
.map(f64::round),
1 => crate::entities::common::parse_typed_length(text),
_ => crate::entities::common::parse_typed_angle(text),
}
.filter(|v| v.is_finite())
.ok_or("invalid value")?;
update(scene, array, &[(name, value)])
}
/// Change an array's parameters and place its items again: the item
/// INSERTs in the array block, the body's item records and the parameter
/// values move together, so the array stays associative on save.
pub fn update(
scene: &mut crate::scene::Scene,
array: &AssocArray,
changes: &[(&str, f64)],
) -> Result<(), String> {
use codec::objects::AssocArrayItem;
use codec::EntityType;
if array.kind == ArrayKind::Path {
return Err("path arrays cannot be edited yet".into());
}
let document = &scene.document;
let mut params = value_params(document, array).to_vec();
let center = match array.kind {
ArrayKind::Polar => polar_center(document, array).ok_or("array has no first item")?,
_ => DVec3::ZERO,
};
for (name, value) in changes {
set_param_value(&mut params, name, *value);
}
if array.kind == ArrayKind::Polar
&& changes.iter().any(|(name, _)| matches!(*name, "Items" | "FillAngle"))
{
let fill = param_value(&params, "FillAngle").unwrap_or(std::f64::consts::TAU);
let count = param_value(&params, "Items").unwrap_or(1.0).max(1.0);
let spacing = if (fill.abs() - std::f64::consts::TAU).abs() < 1e-9 {
fill / count
} else if count > 1.0 {
fill / (count - 1.0)
} else {
0.0
};
set_param_value(&mut params, "ItemSpacing", spacing);
}
let normal = base_normal(document, array);
let placements: Vec<([i32; 3], DMat4)> = match array.kind {
ArrayKind::Rectangular => {
rectangular_items(&params, column_axis(document, array), normal)
.into_iter()
.map(|(location, offset)| (location, DMat4::from_translation(offset)))
.collect()
}
_ => polar_items(&params, center, normal),
};
let current = body(document, array.body).ok_or("array body missing")?;
let template = current
.items
.iter()
.filter_map(|item| item.first_handle)
.find_map(|handle| match document.get_entity(handle) {
Some(EntityType::Insert(insert)) => Some(insert.clone()),
_ => None,
})
.ok_or("array has no item reference")?;
let old_items: Vec<Handle> = document
.block_records
.iter()
.find(|record| record.handle == array.array_block)
.map(|record| record.entity_handles.clone())
.unwrap_or_default()
.into_iter()
.filter(|handle| matches!(document.get_entity(*handle), Some(EntityType::Insert(_))))
.collect();
let document = &mut scene.document;
for handle in &old_items {
document.remove_entity(*handle);
}
let mut items = Vec::with_capacity(placements.len());
let mut new_handles = Vec::with_capacity(placements.len());
for (location, placement) in placements {
let mut insert = template.clone();
insert.common.handle = Handle::NULL;
insert.common.owner_handle = array.array_block;
insert.common.reactors.clear();
insert.common.xdictionary_handle = None;
let position = placement.w_axis.truncate();
insert.insert_point = codec::types::Vector3::new(position.x, position.y, position.z);
insert.rotation = placement.x_axis.y.atan2(placement.x_axis.x);
let handle = document
.add_entity(EntityType::Insert(insert))
.map_err(|error| error.to_string())?;
let turned = (placement.x_axis.truncate() - DVec3::X).length() > 1e-12
|| (placement.y_axis.truncate() - DVec3::Y).length() > 1e-12;
items.push(AssocArrayItem {
location,
flags: if turned { 12 } else { 8 },
uses_default_transform: !turned,
x_direction: if turned {
codec::types::Vector3::ZERO
} else {
codec::types::Vector3::new(position.x, position.y, position.z)
},
transform: if turned {
placement.transpose().to_cols_array()
} else {
[0.0; 16]
},
first_handle: Some(handle),
..Default::default()
});
new_handles.push(handle);
}
if let Some(record) = document
.block_records
.iter_mut()
.find(|record| record.handle == array.source_block)
{
record.insert_handles.retain(|handle| !old_items.contains(handle));
record.insert_handles.extend(new_handles);
}
let values_in_action = action(document, array.action).is_some_and(|a| !a.values.is_empty());
if let Some(ObjectType::Associative(object)) = document.objects.get_mut(&array.body) {
let array_body = match &mut object.data {
AssociativeData::ArrayActionBody(body) => Some(body),
AssociativeData::ArrayModifyActionBody(modify) => Some(&mut modify.body),
_ => None,
};
if let Some(array_body) = array_body {
array_body.items = items;
if !values_in_action {
array_body.parameter_body.values = params.clone();
}
}
}
if values_in_action {
if let Some(ObjectType::Associative(object)) = document.objects.get_mut(&array.action) {
if let AssociativeData::Action(action) = &mut object.data {
action.values = params;
}
}
}
scene.bump_geometry();
Ok(())
}
+1
View File
@@ -6,6 +6,7 @@
// view — camera, transforms, viewport, render pipeline driver
// cache — block-definition and property caches
pub mod annotative;
pub mod assoc_array;
pub mod block_service;
pub mod cache;
pub mod convert;