use super::transform_gizmo::{
euler_xyz_deg_from_quat, normalize3, quat_from_axis_angle, quat_from_euler_xyz_deg, quat_mul,
Quat,
};
use super::*;
#[derive(Default)]
pub struct ComponentMoveArm {
pub(super) feature_id: Option<String>,
pub(super) anchor: [f64; 3],
pub(super) drag: Option<ComponentMoveDrag>,
}
#[derive(Clone)]
pub(super) struct ComponentMoveDrag {
handle: u32,
sx: f32,
sy: f32,
start: ComponentPose,
pending: Option<ComponentPose>,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) struct ComponentPose {
pub translate: [f64; 3],
pub rotate_deg: [f64; 3],
pub anchor: [f64; 3],
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum ComponentDelta {
Translate([f64; 3]),
Rotate { axis: [f64; 3], radians: f64 },
}
impl EngineState {
pub fn component_move_armed(&self) -> bool {
self.component_move.feature_id.is_some()
}
pub fn component_move_armed_feature(&self) -> String {
self.component_move.feature_id.clone().unwrap_or_default()
}
pub fn component_move_toggle(&mut self, feature_id: &str) {
let Some(info) = self.component_info(feature_id) else {
self.push_notice(format!("'{feature_id}' is not an assembly component"));
return;
};
if info.fixed {
self.push_notice(format!(
"{} ({feature_id}) is fixed — unfix it to move",
info.part_name
));
return;
}
if self.component_move.feature_id.as_deref() == Some(feature_id) {
self.disarm_transform();
} else {
self.component_move_arm_widget(feature_id);
}
}
pub(super) fn component_move_arm(&mut self, feature_id: &str) {
match self.component_info(feature_id) {
Some(info) if !info.fixed => self.component_move_arm_widget(feature_id),
_ => {}
}
}
pub(super) fn component_move_reset(&mut self) {
self.component_move = ComponentMoveArm::default();
}
fn component_move_arm_widget(&mut self, feature_id: &str) {
self.transform_gizmo.feature_id = None;
self.transform_gizmo.mode = GizmoMode::None;
self.transform_gizmo.drag = None;
self.clear_feature_dimension_overlay();
let anchor = self
.component_bbox_center(feature_id)
.or_else(|| self.component_info(feature_id).map(|info| info.translate))
.unwrap_or([0.0; 3]);
self.component_move.feature_id = Some(feature_id.to_string());
self.component_move.anchor = anchor;
self.component_move.drag = None;
self.feed_component_widget();
self.dirty = true;
}
fn feed_component_widget(&mut self) {
let Some(id) = self.component_move.feature_id.clone() else {
return;
};
let Some(info) = self.component_info(&id) else {
return;
};
let pose = ComponentPose {
translate: info.translate,
rotate_deg: info.rotate_deg,
anchor: self.component_move.anchor,
};
let json = component_frame_json(&pose);
let _ = self.widgets.set_transform_json(&json);
}
pub(super) fn component_move_sync(&mut self) {
let Some(id) = self.component_move.feature_id.clone() else {
return;
};
match self.component_info(&id) {
Some(info) if !info.fixed => {
self.component_move.anchor =
self.component_bbox_center(&id).unwrap_or(info.translate);
self.feed_component_widget();
self.dirty = true;
}
_ => self.disarm_transform(),
}
}
pub fn component_press(&mut self, x: f64, y: f64) -> bool {
let Some(id) = self.component_move.feature_id.clone() else {
return false;
};
let handle = self.transform_pick(x, y);
if handle == 0 {
return false;
}
let Some(info) = self.component_info(&id) else {
return false;
};
self.widgets.set_transform_active(handle);
self.component_move.drag = Some(ComponentMoveDrag {
handle,
sx: x as f32,
sy: y as f32,
start: ComponentPose {
translate: info.translate,
rotate_deg: info.rotate_deg,
anchor: self.component_move.anchor,
},
pending: None,
});
self.dirty = true;
true
}
pub fn component_move_dragging(&self) -> bool {
self.component_move.drag.is_some()
}
pub fn component_drag_to(&mut self, cx: f64, cy: f64) {
let Some(drag) = self.component_move.drag.clone() else {
return;
};
let cam = gizmo_camera(&self.camera);
let frame = component_frame_json(&drag.start);
let json = self.widgets.transform_drag_json_with_frame(
&cam,
&frame,
drag.handle,
drag.sx,
drag.sy,
cx as f32,
cy as f32,
);
let Some(delta) = parse_drag_delta(&json) else {
return;
};
let pending = compose_component_delta(&drag.start, &delta);
let json = component_frame_json(&pending);
let _ = self.widgets.set_transform_json(&json);
if let Some(live) = self.component_move.drag.as_mut() {
live.pending = Some(pending);
}
self.dirty = true;
}
pub fn component_release(&mut self) {
let Some(drag) = self.component_move.drag.take() else {
return;
};
self.widgets.set_transform_active(0);
self.dirty = true;
let Some(pending) = drag.pending else {
return;
};
let Some(id) = self.component_move.feature_id.clone() else {
return;
};
self.component_move.anchor = pending.anchor;
let Some(index) = self.history.index_of(&id) else {
return;
};
let mut params = self
.history
.feature_params(index)
.unwrap_or_else(|| serde_json::json!({}));
if !params.get("transform").map(|t| t.is_object()).unwrap_or(false) {
if let Some(object) = params.as_object_mut() {
object.insert("transform".into(), serde_json::json!({}));
}
}
if let Some(transform) = params.get_mut("transform").and_then(|t| t.as_object_mut()) {
transform.insert("translate".into(), serde_json::json!(pending.translate));
transform.insert("rotateEulerDeg".into(), serde_json::json!(pending.rotate_deg));
}
let _ = self.update_feature_params(&id, ¶ms.to_string());
}
pub fn component_move_json(&self) -> String {
serde_json::json!({
"armed": self.component_move_armed(),
"feature": self.component_move_armed_feature(),
"anchor": self.component_move.anchor,
})
.to_string()
}
}
fn component_frame_json(pose: &ComponentPose) -> String {
let euler = [
pose.rotate_deg[0].to_radians(),
pose.rotate_deg[1].to_radians(),
pose.rotate_deg[2].to_radians(),
];
let x = normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler));
let y = normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler));
let z = normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler));
serde_json::json!({
"origin": pose.anchor,
"x": x,
"y": y,
"z": z,
"showCenter": true,
"showAxes": true,
"showRings": true,
})
.to_string()
}
fn parse_drag_delta(json: &str) -> Option<ComponentDelta> {
let value: serde_json::Value = serde_json::from_str(json).ok()?;
let vec3 = |key: &str| -> [f64; 3] {
let mut out = [0.0; 3];
if let Some(array) = value.get(key).and_then(|v| v.as_array()) {
for (index, slot) in out.iter_mut().enumerate() {
if let Some(number) = array.get(index).and_then(|v| v.as_f64()) {
*slot = number;
}
}
}
out
};
match value.get("kind").and_then(|k| k.as_str()) {
Some("translate") => Some(ComponentDelta::Translate(vec3("world"))),
Some("rotate") => Some(ComponentDelta::Rotate {
axis: vec3("axisWorld"),
radians: value.get("radians").and_then(|n| n.as_f64()).unwrap_or(0.0),
}),
_ => None,
}
}
pub(super) fn compose_component_delta(
start: &ComponentPose,
delta: &ComponentDelta,
) -> ComponentPose {
match delta {
ComponentDelta::Translate(d) => ComponentPose {
translate: add3(start.translate, *d),
rotate_deg: start.rotate_deg,
anchor: add3(start.anchor, *d),
},
ComponentDelta::Rotate { axis, radians } => {
let dq = quat_from_axis_angle(*axis, *radians);
let q0 = quat_from_euler_xyz_deg(start.rotate_deg);
let rotate_deg = euler_xyz_deg_from_quat(quat_mul(dq, q0));
let offset = sub3(start.translate, start.anchor);
ComponentPose {
translate: add3(start.anchor, quat_rotate(dq, offset)),
rotate_deg,
anchor: start.anchor,
}
}
}
}
fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn quat_rotate(q: Quat, v: [f64; 3]) -> [f64; 3] {
let u = [q[0], q[1], q[2]];
let w = q[3];
let cross = |a: [f64; 3], b: [f64; 3]| -> [f64; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
};
let t = cross(u, add3(cross(u, v), [w * v[0], w * v[1], w * v[2]]));
[v[0] + 2.0 * t[0], v[1] + 2.0 * t[1], v[2] + 2.0 * t[2]]
}
#[cfg(test)]
mod component_move_tests {
use super::super::component_fixtures::two_instance_assembly_json;
use super::*;
fn pose(translate: [f64; 3], rotate_deg: [f64; 3], anchor: [f64; 3]) -> ComponentPose {
ComponentPose { translate, rotate_deg, anchor }
}
#[test]
fn compose_translate_shifts_pose_and_anchor_together() {
let out = compose_component_delta(
&pose([20.0, 0.0, 0.0], [0.0, 0.0, 0.0], [25.0, 5.0, 5.0]),
&ComponentDelta::Translate([4.0, -1.0, 0.5]),
);
assert_eq!(out.translate, [24.0, -1.0, 0.5]);
assert_eq!(out.anchor, [29.0, 4.0, 5.5]);
assert_eq!(out.rotate_deg, [0.0, 0.0, 0.0]);
}
#[test]
fn compose_rotate_pivots_about_the_anchor() {
let out = compose_component_delta(
&pose([20.0, 0.0, 0.0], [0.0, 0.0, 0.0], [25.0, 5.0, 5.0]),
&ComponentDelta::Rotate {
axis: [0.0, 0.0, 1.0],
radians: std::f64::consts::FRAC_PI_2,
},
);
for (got, want) in out.translate.iter().zip([30.0, 0.0, 0.0]) {
assert!((got - want).abs() < 1e-9, "translate {:?}", out.translate);
}
assert!((out.rotate_deg[2] - 90.0).abs() < 1e-6, "{:?}", out.rotate_deg);
assert!(out.rotate_deg[0].abs() < 1e-6 && out.rotate_deg[1].abs() < 1e-6);
assert_eq!(out.anchor, [25.0, 5.0, 5.0], "rotation never moves the pivot");
let back = compose_component_delta(
&out,
&ComponentDelta::Rotate {
axis: [0.0, 0.0, 1.0],
radians: -std::f64::consts::FRAC_PI_2,
},
);
for (got, want) in back.translate.iter().zip([20.0, 0.0, 0.0]) {
assert!((got - want).abs() < 1e-9, "round-trip {:?}", back.translate);
}
for angle in back.rotate_deg {
assert!(angle.abs() < 1e-6, "round-trip {:?}", back.rotate_deg);
}
}
fn assembly_engine_with_camera() -> EngineState {
let mut engine = EngineState::new();
engine
.set_history_json(&two_instance_assembly_json())
.unwrap();
engine.resize(800.0, 600.0);
engine.camera.eye = [25.0, 5.0, 45.0];
engine.camera.target = [25.0, 5.0, 5.0];
engine.camera.up = [0.0, 1.0, 0.0];
engine.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
engine
}
#[test]
fn toggle_arms_the_full_gizmo_then_disarms_and_fixed_refuses() {
let mut engine = assembly_engine_with_camera();
assert!(!engine.component_move_armed());
engine.component_move_toggle("ACOMP1");
assert!(!engine.component_move_armed());
let notices = engine.take_notices();
assert!(
notices.iter().any(|n| n.contains("fixed")),
"fixed refusal toast: {notices:?}"
);
engine.component_move_toggle("ACOMP2");
assert!(engine.component_move_armed());
assert!(engine.widgets.has_transform(), "arming feeds the widget");
let cam = gizmo_camera(&engine.camera);
assert!(
engine.widgets.transform_ring_grabs(&cam).is_some(),
"rotation arcs are shown together with the move handles"
);
assert!(!engine.transform_armed(), "the FEATURE gizmo stays disarmed");
engine.component_move_toggle("ACOMP2");
assert!(!engine.component_move_armed(), "second toggle disarms");
assert!(!engine.widgets.has_transform(), "widget cleared");
assert!(engine.take_notices().is_empty(), "no spurious toasts");
}
#[test]
fn arm_transform_routes_acomp_to_the_component_gizmo() {
let mut engine = assembly_engine_with_camera();
engine.arm_transform("ACOMP2");
assert!(engine.component_move_armed());
assert_eq!(engine.component_move_armed_feature(), "ACOMP2");
assert!(!engine.transform_armed(), "generic gizmo must not arm for ACOMP");
engine.disarm_transform();
engine.arm_transform("ACOMP1");
assert!(!engine.component_move_armed());
assert!(!engine.transform_armed());
assert!(engine.take_notices().is_empty(), "routed refusal is silent");
}
#[test]
fn translate_drag_is_free_move_and_commits_on_release() {
let mut engine = assembly_engine_with_camera();
engine.component_move_toggle("ACOMP2");
let anchor = engine.component_move.anchor;
assert!((anchor[0] - 25.0).abs() < 1e-6, "bbox-center anchor: {anchor:?}");
let (sx, sy, depth) = engine.camera.project(anchor);
assert!(depth > 0.0);
assert!(engine.component_press(sx, sy), "center handle grabbed");
assert!(engine.component_move_dragging());
engine.component_drag_to(sx + 60.0, sy);
let info = engine.component_info("ACOMP2").unwrap();
assert_eq!(info.translate, [20.0, 0.0, 0.0], "no param write mid-drag");
let solid_x = engine
.scene
.solid("ACOMP2:Part")
.expect("member solid")
.bbox
.min[0];
assert!((solid_x - 20.0).abs() < 1e-6, "mesh stays put mid-drag");
let widget_origin = engine.widgets.transform_origin().expect("widget shown");
assert!(
widget_origin[0] as f64 > anchor[0] + 2.0,
"the gizmo follows the pointer: {widget_origin:?}"
);
engine.component_release();
assert!(!engine.component_move_dragging());
let info = engine.component_info("ACOMP2").unwrap();
assert!(
(info.translate[0] - 24.0).abs() < 0.2,
"committed translate: {:?}",
info.translate
);
let solid_x = engine.scene.solid("ACOMP2:Part").unwrap().bbox.min[0];
assert!(
(solid_x - info.translate[0]).abs() < 1e-6,
"the rerun re-posed the member to the committed pose"
);
assert!(engine.history.can_undo(), "commit minted an undo entry");
}
#[test]
fn component_move_hit_areas_match_the_shared_hit_test() {
let mut engine = assembly_engine_with_camera();
engine.camera.eye = [55.0, 25.0, 45.0];
assert_eq!(engine.transform_hit_areas_json(), "[]", "nothing armed → no outlines");
engine.component_move_toggle("ACOMP2");
assert_eq!(engine.gizmo_mode(), "none", "component arm keeps the ◎ mode none");
let areas: Vec<serde_json::Value> =
serde_json::from_str(&engine.transform_hit_areas_json()).unwrap();
let capsules = areas.iter().filter(|a| a["kind"] == "capsule").count();
let circles = areas.iter().filter(|a| a["kind"] == "circle").count();
assert_eq!(capsules, 3, "one capsule per axis arrow");
assert_eq!(circles, 4, "center + 3 ring grab circles");
let cam = gizmo_camera(&engine.camera);
let regions = engine.widgets.transform_hit_regions(&cam);
assert_eq!(regions.len(), areas.len(), "outline set == hit-region set");
for (_, shape) in ®ions {
let probe = match shape {
brep_gizmos::hit_region::HitShape::Circle { c, .. } => *c,
brep_gizmos::hit_region::HitShape::Capsule { a, b, .. } => {
[(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5]
}
};
let picked = engine.transform_pick(probe[0] as f64, probe[1] as f64);
assert_ne!(picked, 0, "a handle under its own region at {probe:?}");
let picked_shape = regions.iter().find(|(id, _)| *id == picked).unwrap().1;
assert!(
picked_shape.contains(probe),
"picked handle {picked}'s region must contain the cursor {probe:?}"
);
}
engine.component_move_toggle("ACOMP2");
assert_eq!(engine.transform_hit_areas_json(), "[]", "disarm retracts the outlines");
}
#[test]
fn rotate_drag_commits_the_anchor_pivot_compose() {
let mut engine = assembly_engine_with_camera();
engine.component_move_toggle("ACOMP2");
let cam = gizmo_camera(&engine.camera);
let grabs = engine.widgets.transform_ring_grabs(&cam).expect("gizmo shown");
let grab = grabs[0];
let anchor = engine.component_move.anchor;
let start = cam
.world_to_screen(grab)
.expect("grab projects");
assert!(engine.component_press(start[0] as f64, start[1] as f64), "ring grabbed");
let offset = [grab.x as f64 - anchor[0], grab.y as f64 - anchor[1]];
let target_world = [
anchor[0] - offset[1],
anchor[1] + offset[0],
grab.z as f64,
];
let (tx, ty, _) = engine.camera.project(target_world);
engine.component_drag_to(tx, ty);
engine.component_release();
let info = engine.component_info("ACOMP2").unwrap();
assert!(
(info.rotate_deg[2] - 90.0).abs() < 0.5,
"committed +90° about Z: {:?}",
info.rotate_deg
);
assert!(
(info.translate[0] - 30.0).abs() < 0.1 && info.translate[1].abs() < 0.1,
"pivot compose: {:?}",
info.translate
);
let center = engine.component_bbox_center("ACOMP2").unwrap();
assert!(
(center[0] - 25.0).abs() < 0.1 && (center[1] - 5.0).abs() < 0.1,
"bbox center invariant under the pivot rotation: {center:?}"
);
}
}