use std::collections::BTreeMap;
use std::sync::{Arc, Mutex, OnceLock};
use ferrocad_core::geometry::{Matrix4, Placement, Rotation, Vector3};
use ferrocad_core::object_registry::{self, ObjectType};
use ferrocad_core::{prop_status, Document, ObjectId, Property};
use ferrocad_geom::Shape;
use ferrocad_part::{make_shape_property, shape_of, ShapeProperty};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyTuple};
pub(crate) const LINK_TYPE: &str = "App::Link";
pub(crate) const ELEMENT_TYPE: &str = "App::LinkElement";
pub(crate) const LINK_GROUP_TYPE: &str = "App::LinkGroup";
pub(crate) const LINK_LIST_TYPE: &str = "App::LinkList";
pub(crate) const LEGACY_ELEMENT_TYPE: &str = "Part::LinkArrayElement";
pub(crate) const HIDDEN_OUTPUT: u32 =
prop_status::HIDDEN | prop_status::OUTPUT | prop_status::NORECOMPUTE | prop_status::READONLY;
pub(crate) fn register() {
object_registry::register(LINK_TYPE, Arc::new(LinkObject));
let elements: Arc<dyn ObjectType> = Arc::new(LinkElementObject);
object_registry::register(ELEMENT_TYPE, elements.clone());
object_registry::register(LEGACY_ELEMENT_TYPE, elements);
let containers: Arc<dyn ObjectType> = Arc::new(LinkContainerObject);
object_registry::register(LINK_GROUP_TYPE, containers.clone());
object_registry::register(LINK_LIST_TYPE, containers);
ferrocad_py::register_extension_provider(LINK_EXTENSION, provide_link_extension);
ferrocad_py::register_extension_method(
LINK_EXTENSION,
"configLinkProperty",
config_link_property_method,
);
}
pub(crate) const LINK_EXTENSION: &str = "App::LinkExtensionPython";
fn extension_properties() -> Vec<(String, Property, u32)> {
let mut properties: Vec<(String, Property, u32)> = link_properties()
.into_iter()
.filter(|(name, _, _)| name != "Shape")
.collect();
properties.push((
"ColoredElements".to_string(),
Property::LinkSub(String::new(), Vec::new()),
prop_status::HIDDEN,
));
properties
}
fn provide_link_extension(doc: &mut Document, id: ObjectId) -> bool {
let missing: Vec<(String, Property, u32)> = {
let Some(object) = doc.object(id) else {
return false;
};
extension_properties()
.into_iter()
.filter(|(name, _, _)| object.properties.get(name).is_none())
.collect()
};
link_configs().lock().unwrap().remove(&(document_key(doc), id));
for (name, value, status) in missing {
if doc
.add_property(id, &name, value, status, "Link", "")
.is_err()
{
return false;
}
}
true
}
#[derive(Default)]
struct LinkConfig {
linked: Vec<String>,
sources: BTreeMap<String, String>,
}
fn link_configs() -> &'static Mutex<BTreeMap<(usize, ObjectId), LinkConfig>> {
static CONFIGS: OnceLock<Mutex<BTreeMap<(usize, ObjectId), LinkConfig>>> = OnceLock::new();
CONFIGS.get_or_init(|| Mutex::new(BTreeMap::new()))
}
fn document_key(doc: &Document) -> usize {
doc as *const Document as usize
}
fn configure_link_property(
doc: &Document,
id: ObjectId,
properties: &[String],
links: &[(String, String)],
) -> Result<(), String> {
let Some(object) = doc.object(id) else {
return Err(format!("no object {id}"));
};
for name in properties {
if object.properties.get(name).is_none() {
return Err(format!(
"configLinkProperty: object '{}' has no property '{name}'",
object.name
));
}
}
for (link, source) in links {
if object.properties.get(source).is_none() {
return Err(format!(
"configLinkProperty: object '{}' has no property '{source}' \
for the link property '{link}'",
object.name
));
}
}
let mut configs = link_configs().lock().unwrap();
let config = configs.entry((document_key(doc), id)).or_default();
for name in properties {
if !config.linked.contains(name) {
config.linked.push(name.clone());
}
}
for (link, source) in links {
config.sources.insert(link.clone(), source.clone());
}
Ok(())
}
fn config_link_property_method(
doc: &mut Document,
id: ObjectId,
_method: &str,
args: &Bound<'_, PyAny>,
) -> PyResult<PyObject> {
let py = args.py();
let mut properties: Vec<String> = Vec::new();
let mut links: Vec<(String, String)> = Vec::new();
for item in args.downcast::<PyTuple>()?.iter() {
if let Ok(kwargs) = item.downcast::<PyDict>() {
for (key, value) in kwargs.iter() {
links.push((key.extract()?, value.extract()?));
}
} else {
properties.push(item.extract()?);
}
}
configure_link_property(doc, id, &properties, &links).map_err(PyValueError::new_err)?;
Ok(py.None())
}
pub(crate) fn link_properties() -> Vec<(String, Property, u32)> {
let mut properties: Vec<(String, Property, u32)> = vec![
(
"Placement".to_string(),
Property::Placement(Placement::identity()),
prop_status::NONE,
),
(
"LinkPlacement".to_string(),
Property::Placement(Placement::identity()),
prop_status::NONE,
),
("Scale".to_string(), Property::Float(1.0), prop_status::NONE),
(
"ScaleVector".to_string(),
Property::Vector(Vector3::new(1.0, 1.0, 1.0)),
prop_status::NONE,
),
(
"LinkTransform".to_string(),
Property::Bool(false),
prop_status::NONE,
),
(
"LinkMode".to_string(),
Property::Enumeration(
["AutoDelete", "KeepLink", "Translate"]
.iter()
.map(|s| s.to_string())
.collect(),
0,
),
prop_status::NONE,
),
(
"LinkCopyOnChange".to_string(),
Property::Enumeration(
["Disabled", "Enabled", "Owned", "Tracking"]
.iter()
.map(|s| s.to_string())
.collect(),
0,
),
prop_status::NONE,
),
(
"LinkedObject".to_string(),
Property::Link(String::new()),
prop_status::NONE,
),
("ShowElement".to_string(), Property::Bool(true), prop_status::NONE),
(
"ElementCount".to_string(),
Property::Integer(0),
HIDDEN_OUTPUT,
),
(
"PlacementList".to_string(),
Property::PlacementList(Vec::new()),
HIDDEN_OUTPUT,
),
(
"ElementList".to_string(),
Property::LinkList(Vec::new()),
HIDDEN_OUTPUT,
),
];
properties.push((
"Shape".to_string(),
make_shape_property(ShapeProperty::empty()),
HIDDEN_OUTPUT,
));
properties
}
pub(crate) fn container_properties() -> Vec<(String, Property, u32)> {
let mut properties = link_properties();
properties.push((
"Group".to_string(),
Property::LinkList(Vec::new()),
prop_status::NONE,
));
properties
}
pub(crate) fn element_properties() -> Vec<(String, Property, u32)> {
vec![
(
"Placement".to_string(),
Property::Placement(Placement::identity()),
prop_status::NONE,
),
(
"LinkedObject".to_string(),
Property::Link(String::new()),
prop_status::NONE,
),
("Scale".to_string(), Property::Float(1.0), prop_status::NONE),
(
"ScaleVector".to_string(),
Property::Vector(Vector3::new(1.0, 1.0, 1.0)),
prop_status::HIDDEN,
),
(
"LinkTransform".to_string(),
Property::Bool(false),
prop_status::NONE,
),
(
"LinkPlacement".to_string(),
Property::Placement(Placement::identity()),
prop_status::NONE,
),
(
"Suppressed".to_string(),
Property::Bool(false),
prop_status::NONE,
),
(
"Shape".to_string(),
make_shape_property(ShapeProperty::empty()),
HIDDEN_OUTPUT,
),
]
}
fn property<'a>(doc: &'a Document, id: ObjectId, name: &str) -> Option<&'a Property> {
doc.object(id)?.properties.get(name)
}
fn placement_property(doc: &Document, id: ObjectId, name: &str) -> Placement {
match property(doc, id, name) {
Some(Property::Placement(placement)) => *placement,
_ => Placement::identity(),
}
}
fn float_property(doc: &Document, id: ObjectId, name: &str, default: f64) -> f64 {
match property(doc, id, name) {
Some(Property::Float(value)) => *value,
Some(Property::Integer(value)) => *value as f64,
_ => default,
}
}
fn int_property(doc: &Document, id: ObjectId, name: &str, default: i64) -> i64 {
match property(doc, id, name) {
Some(Property::Integer(value)) => *value,
Some(Property::IntegerConstraint { value, .. }) => *value,
_ => default,
}
}
fn bool_property(doc: &Document, id: ObjectId, name: &str, default: bool) -> bool {
match property(doc, id, name) {
Some(Property::Bool(value)) => *value,
_ => default,
}
}
fn vector_property(doc: &Document, id: ObjectId, name: &str, default: Vector3) -> Vector3 {
match property(doc, id, name) {
Some(Property::Vector(value)) => *value,
_ => default,
}
}
fn link_target(doc: &Document, id: ObjectId) -> Option<String> {
match property(doc, id, "LinkedObject") {
Some(Property::Link(name)) if !name.is_empty() => Some(name.clone()),
Some(Property::LinkSub(name, _)) if !name.is_empty() => Some(name.clone()),
_ => None,
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct LinkTransform {
pub(crate) matrix: Matrix4,
pub(crate) general: bool,
}
impl LinkTransform {
pub(crate) fn identity() -> Self {
Self {
matrix: Matrix4::identity(),
general: false,
}
}
fn then(&self, local: &Matrix4) -> Self {
Self {
matrix: self.matrix.mul(local),
general: self.general,
}
}
}
fn link_transform(doc: &Document, id: ObjectId) -> LinkTransform {
let placement = if bool_property(doc, id, "LinkTransform", false) {
placement_property(doc, id, "LinkPlacement")
} else {
placement_property(doc, id, "Placement")
};
let matrix = placement.to_matrix();
let scale = float_property(doc, id, "Scale", 1.0);
let vector = vector_property(doc, id, "ScaleVector", Vector3::new(1.0, 1.0, 1.0));
let uniform = (vector.x - vector.y).abs() <= 1e-12 && (vector.x - vector.z).abs() <= 1e-12;
if uniform {
let factor = scale * vector.x;
LinkTransform {
matrix: if (factor - 1.0).abs() > 1e-12 {
matrix.mul(&uniform_scale_matrix(factor))
} else {
matrix
},
general: false,
}
} else {
let mut values = Matrix4::identity().m;
values[0] = scale * vector.x;
values[5] = scale * vector.y;
values[10] = scale * vector.z;
LinkTransform {
matrix: matrix.mul(&Matrix4::from_values(values)),
general: true,
}
}
}
fn uniform_scale_matrix(factor: f64) -> Matrix4 {
let mut values = Matrix4::identity().m;
values[0] = factor;
values[5] = factor;
values[10] = factor;
Matrix4::from_values(values)
}
pub(crate) fn write_shape(doc: &mut Document, id: ObjectId, shape: Option<Shape>) {
let property = match shape {
Some(shape) => make_shape_property(ShapeProperty::new(shape)),
None => make_shape_property(ShapeProperty::empty()),
};
doc.set_property_raw(id, "Shape", property);
}
pub(crate) fn element_ids(doc: &Document, id: ObjectId) -> Vec<ObjectId> {
match property(doc, id, "ElementList") {
Some(Property::LinkList(names)) => names
.iter()
.filter_map(|name| doc.get_by_name(name))
.collect(),
_ => Vec::new(),
}
}
fn placement_list(doc: &Document, id: ObjectId, name: &str) -> Vec<Placement> {
match property(doc, id, name) {
Some(Property::PlacementList(values)) => values.clone(),
_ => Vec::new(),
}
}
fn linked_shape(doc: &Document, name: &str) -> Option<Shape> {
let id = doc.get_by_name(name)?;
property(doc, id, "Shape")
.and_then(shape_of)
.and_then(|property| property.shape())
}
fn transformed(source: &Shape, transform: &LinkTransform) -> Option<Shape> {
if transform.matrix.is_unity(1e-12) {
Some(source.clone())
} else if transform.general {
ferrocad_part::backend()
.general_transform(source, &transform.matrix)
.ok()
} else {
ferrocad_part::backend()
.transform(source, &transform.matrix)
.ok()
}
}
fn grid_placement(index: usize) -> Placement {
Placement {
base: Vector3::new(
(index % 10) as f64,
((index / 10) % 10) as f64,
(index / 100) as f64,
),
rotation: Rotation::identity(),
}
}
fn app_link_placements(doc: &Document, id: ObjectId) -> Vec<Placement> {
let count = int_property(doc, id, "ElementCount", 0).max(0) as usize;
let existing = element_ids(doc, id);
let mut placements: Vec<Placement> = if !existing.is_empty() {
existing
.iter()
.map(|element| placement_property(doc, *element, "Placement"))
.collect()
} else {
placement_list(doc, id, "PlacementList")
};
if placements.len() > count {
placements.truncate(count);
}
for index in placements.len()..count {
placements.push(grid_placement(index));
}
placements
}
pub(crate) fn sync_elements(
doc: &mut Document,
owner: ObjectId,
placements: &[Placement],
flags: Option<&[bool]>,
parent: &LinkTransform,
) -> Result<bool, ()> {
doc.set_property_raw(
owner,
"ElementCount",
Property::Integer(placements.len() as i64),
);
let mut ids = element_ids(doc, owner);
if !bool_property(doc, owner, "ShowElement", true) {
if placement_list(doc, owner, "PlacementList") != placements {
doc.set_property_raw(
owner,
"PlacementList",
Property::PlacementList(placements.to_vec()),
);
}
doc.set_property_raw(owner, "ElementList", Property::LinkList(Vec::new()));
for element in ids {
doc.remove_object(element);
}
return Ok(false);
}
let owner_name = doc
.object(owner)
.map(|object| object.name.clone())
.unwrap_or_else(|| "Link".to_string());
if ids.len() > placements.len() {
for element in ids.split_off(placements.len()) {
doc.remove_object(element);
}
}
while ids.len() < placements.len() {
let name = format!("{owner_name}_Element{}", ids.len());
ids.push(doc.add_object_with(&name, ELEMENT_TYPE, true));
}
let source_name = link_target(doc, owner);
let source = source_name.as_deref().and_then(|name| linked_shape(doc, name));
let shapes: Vec<Option<Shape>> = match &source {
Some(source) => placements
.iter()
.map(|placement| {
let element = parent.then(&placement.to_matrix());
transformed(source, &element).ok_or(())
})
.collect::<Result<Vec<Shape>, ()>>()?
.into_iter()
.map(Some)
.collect(),
None => placements.iter().map(|_| None).collect(),
};
for (index, element) in ids.iter().enumerate() {
let placement = placements[index];
doc.set_property_raw(*element, "Placement", Property::Placement(placement));
if let Some(name) = &source_name {
doc.set_property_raw(*element, "LinkedObject", Property::Link(name.clone()));
} else {
doc.set_property_raw(*element, "LinkedObject", Property::Link(String::new()));
}
if let Some(flags) = flags {
let suppressed = flags.get(index).copied().unwrap_or(false);
doc.set_property_raw(*element, "Suppressed", Property::Bool(suppressed));
}
write_shape(doc, *element, shapes[index].clone());
}
let names: Vec<String> = ids
.iter()
.filter_map(|element| doc.object(*element).map(|object| object.name.clone()))
.collect();
doc.set_property_raw(owner, "ElementList", Property::LinkList(names));
if !placement_list(doc, owner, "PlacementList").is_empty() {
doc.set_property_raw(owner, "PlacementList", Property::PlacementList(Vec::new()));
}
Ok(true)
}
fn clear_elements(doc: &mut Document, owner: ObjectId) {
for element in element_ids(doc, owner) {
doc.remove_object(element);
}
doc.set_property_raw(owner, "ElementCount", Property::Integer(0));
doc.set_property_raw(owner, "ElementList", Property::LinkList(Vec::new()));
doc.set_property_raw(owner, "PlacementList", Property::PlacementList(Vec::new()));
}
struct LinkObject;
impl ObjectType for LinkObject {
fn default_properties(&self) -> Vec<(String, Property, u32)> {
link_properties()
}
fn execute(&self, doc: &mut Document, id: ObjectId) {
let Some(target_name) = link_target(doc, id) else {
clear_elements(doc, id);
write_shape(doc, id, None);
return;
};
let Some(target_id) = doc.get_by_name(&target_name) else {
clear_elements(doc, id);
write_shape(doc, id, None);
return;
};
let Some(source) = doc
.object(target_id)
.and_then(|object| object.properties.get("Shape"))
.and_then(shape_of)
.and_then(|property| property.shape())
else {
clear_elements(doc, id);
write_shape(doc, id, None);
return;
};
let transform = link_transform(doc, id);
let placements = app_link_placements(doc, id);
if placements.is_empty() {
clear_elements(doc, id);
match transformed(&source, &transform) {
Some(shape) => write_shape(doc, id, Some(shape)),
None => doc.mark_invalid(id),
}
return;
}
match sync_elements(doc, id, &placements, None, &transform) {
Ok(true) => {
write_shape(doc, id, None);
}
Ok(false) => {
let mut shapes = Vec::with_capacity(placements.len());
for placement in &placements {
let element = transform.then(&placement.to_matrix());
match transformed(&source, &element) {
Some(shape) => shapes.push(shape),
None => {
doc.mark_invalid(id);
return;
}
}
}
match ferrocad_part::backend().make_compound(&shapes) {
Ok(compound) => write_shape(doc, id, Some(compound)),
Err(_) => doc.mark_invalid(id),
}
}
Err(()) => doc.mark_invalid(id),
}
}
}
struct LinkElementObject;
impl ObjectType for LinkElementObject {
fn default_properties(&self) -> Vec<(String, Property, u32)> {
element_properties()
}
fn execute(&self, doc: &mut Document, id: ObjectId) {
let Some(target_name) = link_target(doc, id) else {
write_shape(doc, id, None);
return;
};
let Some(target_id) = doc.get_by_name(&target_name) else {
write_shape(doc, id, None);
return;
};
let Some(source) = doc
.object(target_id)
.and_then(|object| object.properties.get("Shape"))
.and_then(shape_of)
.and_then(|property| property.shape())
else {
write_shape(doc, id, None);
return;
};
let transform = link_transform(doc, id);
match transformed(&source, &transform) {
Some(shape) => write_shape(doc, id, Some(shape)),
None => doc.mark_invalid(id),
}
}
}
struct LinkContainerObject;
impl ObjectType for LinkContainerObject {
fn default_properties(&self) -> Vec<(String, Property, u32)> {
container_properties()
}
fn execute(&self, doc: &mut Document, id: ObjectId) {
let members: Vec<String> = match property(doc, id, "Group") {
Some(Property::LinkList(names)) => names
.iter()
.filter(|name| doc.get_by_name(name).is_some())
.cloned()
.collect(),
_ => Vec::new(),
};
doc.set_property_raw(
id,
"ElementCount",
Property::Integer(members.len() as i64),
);
doc.set_property_raw(id, "ElementList", Property::LinkList(members.clone()));
doc.set_property_raw(id, "PlacementList", Property::PlacementList(Vec::new()));
let transform = link_transform(doc, id);
let mut shapes = Vec::with_capacity(members.len());
for name in &members {
let Some(source) = linked_shape(doc, name) else {
continue;
};
match transformed(&source, &transform) {
Some(shape) => shapes.push(shape),
None => {
doc.mark_invalid(id);
return;
}
}
}
if shapes.is_empty() {
write_shape(doc, id, None);
return;
}
match ferrocad_part::backend().make_compound(&shapes) {
Ok(compound) => write_shape(doc, id, Some(compound)),
Err(_) => doc.mark_invalid(id),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn link_properties_cover_the_core_defaults_and_shape() {
let properties = link_properties();
let names: Vec<&str> = properties.iter().map(|(name, _, _)| name.as_str()).collect();
for expected in [
"LinkedObject",
"Placement",
"LinkPlacement",
"Scale",
"ScaleVector",
"LinkTransform",
"LinkMode",
"LinkCopyOnChange",
"ShowElement",
"ElementCount",
"PlacementList",
"ElementList",
"Shape",
] {
assert!(names.contains(&expected), "missing {expected}");
}
}
#[test]
fn container_properties_add_group_to_the_link_base() {
let names: Vec<String> = container_properties()
.into_iter()
.map(|(name, _, _)| name)
.collect();
for expected in [
"Group",
"LinkedObject",
"Placement",
"ElementCount",
"PlacementList",
"ElementList",
"Shape",
] {
assert!(names.iter().any(|name| name == expected), "missing {expected}");
}
}
#[test]
fn element_properties_cover_the_link_and_suppression_surface() {
let names: Vec<String> = element_properties()
.into_iter()
.map(|(name, _, _)| name)
.collect();
for expected in [
"Placement",
"LinkedObject",
"Scale",
"ScaleVector",
"LinkTransform",
"LinkPlacement",
"Suppressed",
"Shape",
] {
assert!(names.iter().any(|name| name == expected), "missing {expected}");
}
}
#[test]
fn default_element_grid_matches_upstream() {
let placement = grid_placement(23);
assert!((placement.base.x - 3.0).abs() < 1e-12);
assert!((placement.base.y - 2.0).abs() < 1e-12);
assert!((placement.base.z).abs() < 1e-12);
}
#[test]
fn extension_properties_cover_the_link_surface_without_shape() {
let names: Vec<String> = extension_properties()
.into_iter()
.map(|(name, _, _)| name)
.collect();
for expected in [
"LinkedObject",
"Placement",
"LinkPlacement",
"Scale",
"ScaleVector",
"LinkTransform",
"LinkMode",
"LinkCopyOnChange",
"ShowElement",
"ElementCount",
"PlacementList",
"ElementList",
"ColoredElements",
] {
assert!(names.iter().any(|name| name == expected), "missing {expected}");
}
assert!(
!names.iter().any(|name| name == "Shape"),
"the extension must not re-add the object's Shape"
);
}
#[test]
fn link_extension_provider_adds_missing_properties_and_keeps_placement() {
let mut doc = Document::new();
let id = doc.add_object("Array", "Part::FeaturePython");
let placement = Placement {
base: Vector3::new(1.0, 2.0, 3.0),
rotation: ferrocad_core::Rotation::identity(),
};
doc.set_property(id, "Placement", Property::Placement(placement))
.unwrap();
assert!(provide_link_extension(&mut doc, id));
let object = doc.object(id).unwrap();
assert!(matches!(
object.properties.get("LinkedObject"),
Some(Property::Link(_))
));
assert!(matches!(
object.properties.get("LinkTransform"),
Some(Property::Bool(false))
));
assert!(object.properties.get("ShowElement").is_some());
assert!(object.properties.get("PlacementList").is_some());
assert_eq!(
object.properties.get("Placement"),
Some(&Property::Placement(placement)),
"the object's own Placement value is kept"
);
assert!(provide_link_extension(&mut doc, id));
}
#[test]
fn config_link_property_records_links_and_rejects_missing_properties() {
let mut doc = Document::new();
let id = doc.add_object("Array", "Part::FeaturePython");
doc.set_property(id, "Base", Property::Link(String::new()))
.unwrap();
doc.set_property(id, "Placement", Property::Placement(Placement::identity()))
.unwrap();
assert!(provide_link_extension(&mut doc, id));
configure_link_property(
&doc,
id,
&["Placement".to_string()],
&[("LinkedObject".to_string(), "Base".to_string())],
)
.expect("Base exists, so the mapping is accepted");
{
let configs = link_configs().lock().unwrap();
let config = configs.get(&(document_key(&doc), id)).unwrap();
assert_eq!(config.linked, vec!["Placement".to_string()]);
assert_eq!(
config.sources.get("LinkedObject").map(String::as_str),
Some("Base")
);
}
let error = configure_link_property(
&doc,
id,
&[],
&[("ShowElement".to_string(), "Missing".to_string())],
)
.unwrap_err();
assert!(error.contains("Missing"), "{error}");
let error = configure_link_property(&doc, id, &["Missing".to_string()], &[])
.unwrap_err();
assert!(error.contains("Missing"), "{error}");
}
#[test]
fn uniform_scale_is_applied_after_the_placement() {
let matrix = Placement {
base: Vector3::new(2.0, 0.0, 0.0),
rotation: ferrocad_core::Rotation::identity(),
}
.to_matrix()
.mul(&uniform_scale_matrix(2.0));
let moved = matrix.transform(&Vector3::new(1.0, 1.0, 1.0));
assert!((moved.x - 4.0).abs() < 1e-12);
assert!((moved.y - 2.0).abs() < 1e-12);
assert!((moved.z - 2.0).abs() < 1e-12);
}
}