1use super::*;
2use super::sketch_edit_ops::is_plain_number_literal;
3use brep_gizmos::hit_region::{point_region, segment_region, HitShape};
4
5const FEATURE_DIM_OVERLAY: &str = "feature-dim-leaders";
7
8#[derive(Clone, Debug)]
13enum DimRole {
14 Arrow(String),
15 Origin,
16}
17
18impl EngineState {
19 pub fn gizmo_mode(&self) -> &'static str {
22 match self.transform_gizmo.mode {
23 GizmoMode::None => "none",
24 GizmoMode::Transform => "transform",
25 GizmoMode::Dimension => "dimension",
26 }
27 }
28
29 pub fn dimension_armed_for(&self, feature_id: &str) -> bool {
32 matches!(self.transform_gizmo.mode, GizmoMode::Dimension)
33 && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
34 }
35
36 pub fn dimension_armed_feature(&self) -> String {
38 if matches!(self.transform_gizmo.mode, GizmoMode::Dimension) {
39 self.transform_gizmo.feature_id.clone().unwrap_or_default()
40 } else {
41 String::new()
42 }
43 }
44
45 pub fn arm_dimension(&mut self, feature_id: &str) {
48 self.component_move_reset();
50 self.transform_gizmo.feature_id = Some(feature_id.to_string());
51 self.transform_gizmo.mode = GizmoMode::Dimension;
52 self.transform_gizmo.drag = None;
53 let _ = self.widgets.set_transform_json("null");
55 self.refresh_feature_dimension_overlay();
56 self.dirty = true;
57 }
58
59 pub fn transform_center_pick(&self, x: f64, y: f64) -> bool {
75 matches!(self.transform_gizmo.mode, GizmoMode::Transform)
76 && self.transform_pick(x, y) == brep_gizmos::transform::HANDLE_CENTER
77 }
78
79 pub fn dimension_origin_pick(&self, x: f64, y: f64) -> bool {
91 let p = [x as f32, y as f32];
96 self.dimension_hit_regions()
97 .into_iter()
98 .any(|(role, shape)| matches!(role, DimRole::Origin) && shape.contains(p))
99 }
100
101 pub fn dimension_arrow_pick(&self, x: f64, y: f64) -> Option<String> {
111 let p = [x as f32, y as f32];
120 let mut best: Option<(f32, String)> = None;
121 for (role, shape) in self.dimension_hit_regions() {
122 if let DimRole::Arrow(key) = role {
123 let d = shape.spine_distance(p);
124 if d <= shape.radius() && best.as_ref().map(|(bd, _)| d < *bd).unwrap_or(true) {
125 best = Some((d, key));
126 }
127 }
128 }
129 best.map(|(_, key)| key)
130 }
131
132 fn dimension_hit_regions(&self) -> Vec<(DimRole, HitShape)> {
148 if !matches!(self.transform_gizmo.mode, GizmoMode::Dimension) {
149 return Vec::new();
150 }
151 let feature = self.dimension_armed_feature();
152 if feature.is_empty() {
153 return Vec::new();
154 }
155 let wpp = self.camera.world_per_pixel();
156 let arrow_px = crate::feature_dimensions::ARROW_HANDLE_HIT_RAD_PX as f32;
157 let origin_px = (crate::feature_dimensions::ORIGIN_SPHERE_RAD_PX + 2.0) as f32;
158 let cam = &self.camera;
159 let mut out: Vec<(DimRole, HitShape)> = Vec::new();
160 let mut origins: Vec<[f64; 3]> = Vec::new();
161 for ann in self.feature_dimension_annotations(&feature) {
162 match ann.kind {
163 crate::feature_dimensions::FeatureDimKind::Linear => {
164 if let Some(shape) = segment_region(cam, ann.point_a, ann.point_b, arrow_px) {
165 out.push((DimRole::Arrow(ann.field_key.clone()), shape));
166 }
167 push_origin_region(&mut out, &mut origins, cam, ann.point_a, origin_px);
168 }
169 crate::feature_dimensions::FeatureDimKind::Angular => {
170 let handle = crate::feature_dimensions::arrow_handle_point(&ann, wpp);
171 if let Some(shape) = point_region(cam, handle, arrow_px) {
172 out.push((DimRole::Arrow(ann.field_key.clone()), shape));
173 }
174 push_origin_region(&mut out, &mut origins, cam, ann.center, origin_px);
175 }
176 }
177 }
178 out
179 }
180
181 pub fn dimension_hit_areas_json(&self) -> String {
190 let regions = self.dimension_hit_regions();
191 super::camera_widgets::hit_shapes_json(regions.iter().map(|(_, shape)| shape))
192 }
193
194 pub fn toggle_to_dimension(&mut self) {
198 let feature = self.transform_armed_feature();
199 if !feature.is_empty() {
200 self.arm_dimension(&feature);
201 }
202 }
203
204 pub fn toggle_to_transform(&mut self) {
208 let feature = self.dimension_armed_feature();
209 if feature.is_empty() {
210 return;
211 }
212 if let Some(index) = self.history.index_of(&feature) {
217 if self.history.feature_type(index).as_deref() == Some("P") {
218 return;
219 }
220 }
221 self.arm_transform(&feature);
222 }
223
224 fn feature_dimension_annotations(
228 &self,
229 feature_id: &str,
230 ) -> Vec<crate::feature_dimensions::FeatureDimAnnotation> {
231 let Some(index) = self.history.index_of(feature_id) else {
232 return Vec::new();
233 };
234 let Some(feature_type) = self.history.feature_type(index) else {
235 return Vec::new();
236 };
237 let Some(params) = self.history.feature_params(index) else {
238 return Vec::new();
239 };
240 let resolved = self.resolve_param_expressions(¶ms);
241 let refs = self.feature_dimension_refs(&feature_type, ¶ms);
245 crate::feature_dimensions::build_annotations_with_refs(&feature_type, &resolved, &refs)
246 }
247
248 fn feature_dimension_refs(
257 &self,
258 feature_type: &str,
259 params: &serde_json::Value,
260 ) -> crate::feature_dimensions::ResolvedRefs {
261 let mut refs = crate::feature_dimensions::ResolvedRefs::default();
262 match feature_type {
263 "E" => {
264 if let Some(profile) = self.lookup_sketch_profile(params.get("profile")) {
265 refs.profile_center = Some(sketch_profile_centroid(profile));
266 refs.profile_normal = Some(vec3_to_arr(profile.z_axis));
267 }
268 }
269 "R" => {
270 if let Some(profile) = self.lookup_sketch_profile(params.get("profile")) {
271 refs.profile_center = Some(sketch_profile_centroid(profile));
272 refs.profile_normal = Some(vec3_to_arr(profile.z_axis));
273 }
274 if let Some((point, dir)) = self.lookup_axis_line(params.get("axis")) {
275 refs.axis_point = Some(point);
276 refs.axis_dir = Some(dir);
277 }
278 }
279 "P" => {
280 if let Some(id) = params.get("id").and_then(|v| v.as_str()) {
283 if let Some((_, frame)) =
284 self.construction_frames.iter().find(|(name, _)| name == id)
285 {
286 refs.plane_origin = Some(vec3_to_arr(frame.origin));
287 refs.plane_normal = Some(fd_normalize3(vec3_to_arr(frame.z_axis)));
288 refs.plane_dim_length = Some(self.camera.world_per_pixel() * 48.0);
290 }
291 }
292 }
293 _ => {}
294 }
295 refs
296 }
297
298 fn lookup_sketch_profile(
301 &self,
302 profile_param: Option<&serde_json::Value>,
303 ) -> Option<&brep_kernel::SketchProfile> {
304 let name = first_reference_name(profile_param?)?;
305 let base = name
311 .strip_suffix(":FACE")
312 .or_else(|| name.strip_suffix(":PROFILE"))
313 .unwrap_or(&name);
314 self.sketch_profiles
315 .iter()
316 .find(|(id, _)| id == &name || id == base)
317 .map(|(_, profile)| profile)
318 }
319
320 fn lookup_axis_line(
324 &self,
325 axis_param: Option<&serde_json::Value>,
326 ) -> Option<([f64; 3], [f64; 3])> {
327 let name = first_reference_name(axis_param?)?;
328 if let Some((_, axis)) = self.sketch_axes.iter().find(|(id, _)| id == &name) {
329 let dir = fd_normalize3(vec3_to_arr(axis.direction));
330 return Some((vec3_to_arr(axis.point), dir));
331 }
332 let poly = self.scene.edge_polyline_world(&name)?;
334 let first = *poly.first()?;
335 let last = *poly.last()?;
336 let dir = fd_sub3(last, first);
337 if fd_norm3(dir) < 1e-9 {
338 return None;
339 }
340 Some((first, fd_normalize3(dir)))
341 }
342
343 fn resolve_param_expressions(&self, params: &serde_json::Value) -> serde_json::Value {
349 let expressions = self.history.expressions();
350 let configurator = self.history.configurator();
351 let mut out = params.clone();
352 if let Some(object) = out.as_object_mut() {
353 for value in object.values_mut() {
354 if let Some(source) = value.as_str() {
355 if let Ok(number) =
356 brep_kernel::eval_expression(&expressions, &configurator, source)
357 {
358 if number.is_finite() {
359 *value = serde_json::json!(number);
360 }
361 }
362 }
363 }
364 }
365 out
366 }
367
368 pub fn feature_dimension_annotations_json(&self, feature_id: &str) -> String {
373 use crate::feature_dimensions::FeatureDimKind;
374 let annotations = self.feature_dimension_annotations(feature_id);
375 let wpp = self.camera.world_per_pixel();
376 let out: Vec<serde_json::Value> = annotations
377 .iter()
378 .map(|a| {
379 let (kind, mid) = match a.kind {
384 FeatureDimKind::Linear => ("linear", a.midpoint()),
385 FeatureDimKind::Angular => {
386 ("angular", crate::feature_dimensions::angular_chip_anchor(a, wpp))
387 }
388 };
389 serde_json::json!({
390 "fieldKey": a.field_key,
391 "pointA": a.point_a,
392 "pointB": a.point_b,
393 "value": a.value,
394 "label": a.label,
395 "mid": mid,
396 "kind": kind,
397 })
398 })
399 .collect();
400 serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
401 }
402
403 pub fn feature_dimension_state_json(&self) -> String {
406 let feature = self.dimension_armed_feature();
407 let annotations: serde_json::Value = if feature.is_empty() {
408 serde_json::json!([])
409 } else {
410 serde_json::from_str(&self.feature_dimension_annotations_json(&feature))
411 .unwrap_or_else(|_| serde_json::json!([]))
412 };
413 serde_json::json!({
414 "mode": self.gizmo_mode(),
415 "feature": feature,
416 "annotations": annotations,
417 })
418 .to_string()
419 }
420
421 fn feature_dimension_overlay_json(&self) -> String {
425 let feature = self.dimension_armed_feature();
426 let annotations = if feature.is_empty() {
427 Vec::new()
428 } else {
429 self.feature_dimension_annotations(&feature)
430 };
431 let (positions, colors) = crate::feature_dimensions::leaders_buffers(
432 &annotations,
433 self.camera.world_per_pixel(),
434 );
435 serde_json::json!({
436 "groups": [
437 {
438 "name": FEATURE_DIM_OVERLAY,
439 "renderOrder": 10003,
440 "tris": { "positions": positions, "colors": colors },
441 }
442 ]
443 })
444 .to_string()
445 }
446
447 pub fn refresh_feature_dimension_overlay(&mut self) {
453 let json = self.feature_dimension_overlay_json();
454 let _ = self.set_overlay_json(&json);
455 let wpp = self.camera.world_per_pixel();
456 self.feature_dim_overlay_wpp = if wpp > 0.0 { wpp } else { f64::MIN_POSITIVE };
457 }
458
459 pub(super) fn clear_feature_dimension_overlay(&mut self) {
462 let _ = self.set_overlay_json(&serde_json::json!({
463 "groups": [ { "name": FEATURE_DIM_OVERLAY } ]
464 }).to_string());
465 self.feature_dim_overlay_wpp = 0.0;
466 }
467
468 pub(super) fn ensure_feature_dimension_overlay_current(&mut self) {
484 if !matches!(self.transform_gizmo.mode, GizmoMode::Dimension)
485 || self.dimension_armed_feature().is_empty()
486 {
487 self.feature_dim_overlay_wpp = 0.0;
488 return;
489 }
490 let wpp = self.camera.world_per_pixel();
491 if super::overlay_wpp_stale(self.feature_dim_overlay_wpp, wpp) {
492 self.refresh_feature_dimension_overlay();
493 }
494 }
495
496 pub fn feature_dimension_drag(&mut self, feature_id: &str, field_key: &str, x: f64, y: f64) {
503 let annotations = self.feature_dimension_annotations(feature_id);
504 let Some(annotation) = annotations.iter().find(|a| a.field_key == field_key) else {
505 return;
506 };
507 if annotation.kind == crate::feature_dimensions::FeatureDimKind::Angular {
508 if let Some(degrees) = self.angular_drag_degrees(annotation, x, y) {
509 self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(degrees));
510 self.refresh_feature_dimension_overlay();
513 }
514 return;
515 }
516 let a = annotation.point_a;
517 let b = annotation.point_b;
518 let axis = fd_sub3(b, a);
519 let len = fd_norm3(axis);
520 if len < 1e-9 {
521 return;
522 }
523 let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
524 let ray = self.camera.pick_ray(x, y);
525 let ray_dir = fd_normalize3(ray.dir);
526 let Some(t_world) = closest_t_on_axis(a, dir, ray.origin, ray_dir) else {
527 return;
528 };
529 let scale_recip = if annotation.value.abs() > 1e-9 && len > 1e-9 {
534 annotation.value / len
535 } else {
536 1.0
537 };
538 let raw = t_world * scale_recip;
543 let new_value = if raw >= 0.0 { raw.max(1e-4) } else { raw.min(-1e-4) };
544 self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(new_value));
545 self.refresh_feature_dimension_overlay();
548 }
549
550 pub(super) fn angular_drag_degrees(
562 &self,
563 ann: &crate::feature_dimensions::FeatureDimAnnotation,
564 x: f64,
565 y: f64,
566 ) -> Option<f64> {
567 let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * self.camera.world_per_pixel();
568 if radius <= 1e-9 {
569 return None;
570 }
571 let current = ann.value;
577 let combined = |screen_err: f64, deg: f64| -> f64 {
578 let d = deg - current;
579 screen_err + 1e-6 * d * d
580 };
581 let mut best_deg = current;
582 let mut best_err = f64::INFINITY;
583 let mut deg = -360.0;
585 while deg <= 360.0 {
586 if let Some(err) = self.angle_arc_end_err(ann, radius, deg, x, y) {
587 let err = combined(err, deg);
588 if err < best_err {
589 best_err = err;
590 best_deg = deg;
591 }
592 }
593 deg += 2.0;
594 }
595 if !best_err.is_finite() {
596 return None;
597 }
598 let center = best_deg;
600 let mut deg = center - 2.0;
601 while deg <= center + 2.0 {
602 if (-360.0..=360.0).contains(°) {
603 if let Some(err) = self.angle_arc_end_err(ann, radius, deg, x, y) {
604 let err = combined(err, deg);
605 if err < best_err {
606 best_err = err;
607 best_deg = deg;
608 }
609 }
610 }
611 deg += 0.25;
612 }
613 let clamped = best_deg.round().clamp(-360.0, 360.0);
614 let floored = if clamped.abs() < 0.1 {
615 if clamped < 0.0 { -0.1 } else { 0.1 }
616 } else {
617 clamped
618 };
619 Some(floored)
620 }
621
622 fn angle_arc_end_err(
626 &self,
627 ann: &crate::feature_dimensions::FeatureDimAnnotation,
628 radius: f64,
629 deg: f64,
630 x: f64,
631 y: f64,
632 ) -> Option<f64> {
633 let dir = fd_normalize3(crate::feature_dimensions::rotate_about_axis(
634 ann.ref_dir,
635 ann.axis,
636 deg.to_radians(),
637 ));
638 let p = [
639 ann.center[0] + dir[0] * radius,
640 ann.center[1] + dir[1] * radius,
641 ann.center[2] + dir[2] * radius,
642 ];
643 let (sx, sy, depth) = self.camera.project(p);
644 if depth <= 0.0 {
645 return None;
646 }
647 Some((sx - x) * (sx - x) + (sy - y) * (sy - y))
648 }
649
650 pub fn feature_dimension_set_value(&mut self, feature_id: &str, field_key: &str, input: &str) {
657 let trimmed = input.trim();
658 if trimmed.is_empty() {
659 return;
660 }
661 if is_plain_number_literal(trimmed) {
662 let Ok(number) = trimmed.parse::<f64>() else {
663 return;
664 };
665 if !number.is_finite() {
666 return;
667 }
668 self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(number));
669 } else {
670 let expressions = self.history.expressions();
672 let configurator = self.history.configurator();
673 match brep_kernel::eval_expression(&expressions, &configurator, trimmed) {
674 Ok(number) if number.is_finite() => {
675 self.write_feature_dimension_param(
676 feature_id,
677 field_key,
678 serde_json::Value::String(trimmed.to_string()),
679 );
680 }
681 _ => {}
682 }
683 }
684 }
685
686 fn write_feature_dimension_param(
690 &mut self,
691 feature_id: &str,
692 field_key: &str,
693 value: serde_json::Value,
694 ) {
695 let Some(index) = self.history.index_of(feature_id) else {
696 return;
697 };
698 let mut params = self
699 .history
700 .feature_params(index)
701 .unwrap_or_else(|| serde_json::json!({}));
702 let Some(object) = params.as_object_mut() else {
703 return;
704 };
705 object.insert(field_key.to_string(), value);
706 let _ = self.update_feature_params(feature_id, ¶ms.to_string());
707 }
708}
709
710fn fd_sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
713 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
714}
715
716fn fd_dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
717 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
718}
719
720fn fd_norm3(v: [f64; 3]) -> f64 {
721 fd_dot3(v, v).sqrt()
722}
723
724fn fd_normalize3(v: [f64; 3]) -> [f64; 3] {
725 let n = fd_norm3(v);
726 if n < 1e-12 {
727 [0.0, 0.0, 1.0]
728 } else {
729 [v[0] / n, v[1] / n, v[2] / n]
730 }
731}
732
733fn push_origin_region(
738 out: &mut Vec<(DimRole, HitShape)>,
739 seen: &mut Vec<[f64; 3]>,
740 cam: &crate::view::ViewCamera,
741 p: [f64; 3],
742 px: f32,
743) {
744 if seen.iter().any(|o| fd_norm3(fd_sub3(*o, p)) < 1e-6) {
745 return;
746 }
747 seen.push(p);
748 if let Some(shape) = point_region(cam, p, px) {
749 out.push((DimRole::Origin, shape));
750 }
751}
752
753pub(super) fn closest_t_on_axis(
758 a: [f64; 3],
759 dir: [f64; 3],
760 ray_o: [f64; 3],
761 ray_d: [f64; 3],
762) -> Option<f64> {
763 let w0 = fd_sub3(a, ray_o);
764 let b = fd_dot3(dir, ray_d);
765 let d = fd_dot3(dir, w0);
766 let e = fd_dot3(ray_d, w0);
767 let denom = 1.0 - b * b;
768 if denom.abs() < 1e-9 {
769 return None;
770 }
771 Some((b * e - d) / denom)
772}
773
774fn vec3_to_arr(v: brep_kernel::Vec3) -> [f64; 3] {
775 [v.x, v.y, v.z]
776}
777
778fn first_reference_name(value: &serde_json::Value) -> Option<String> {
782 match value {
783 serde_json::Value::String(text) => {
784 let trimmed = text.trim();
785 (!trimmed.is_empty()).then(|| trimmed.to_string())
786 }
787 serde_json::Value::Object(map) => map
788 .get("name")
789 .and_then(|v| v.as_str())
790 .map(|s| s.trim().to_string())
791 .filter(|s| !s.is_empty()),
792 serde_json::Value::Array(items) => items.iter().find_map(first_reference_name),
793 _ => None,
794 }
795}
796
797fn sketch_profile_centroid(profile: &brep_kernel::SketchProfile) -> [f64; 3] {
803 let mut sum = [0.0f64; 3];
804 let mut count = 0usize;
805 if let Some(outer) = profile.regions.first().and_then(|region| region.first()) {
806 for curve in &outer.curves {
807 if let Ok(domain) = curve.domain() {
808 if let Ok(point) = curve.evaluate(domain[0]) {
809 sum[0] += point.x;
810 sum[1] += point.y;
811 sum[2] += point.z;
812 count += 1;
813 }
814 }
815 }
816 }
817 if count > 0 {
818 [sum[0] / count as f64, sum[1] / count as f64, sum[2] / count as f64]
819 } else {
820 vec3_to_arr(profile.origin)
821 }
822}
823
824#[cfg(test)]
825mod feature_dimension_tests {
826 use super::*;
827
828 fn primitive_request(feature_type: &str, id: &str, params: serde_json::Value) -> String {
831 let mut input = params.as_object().cloned().unwrap_or_default();
832 input.insert("id".into(), serde_json::json!(id));
833 input.insert(
834 "transform".into(),
835 serde_json::json!({
836 "position": [0.0, 0.0, 0.0],
837 "rotationEuler": [0.0, 0.0, 0.0],
838 "scale": [1.0, 1.0, 1.0]
839 }),
840 );
841 input.insert(
842 "boolean".into(),
843 serde_json::json!({ "targets": [], "operation": "NONE" }),
844 );
845 serde_json::json!({
846 "expressions": "",
847 "configurator": {},
848 "features": [{
849 "type": feature_type,
850 "inputParams": input,
851 "persistentData": {}
852 }]
853 })
854 .to_string()
855 }
856
857 fn cube_engine() -> EngineState {
858 let mut state = EngineState::new();
859 state
860 .set_history_json(&primitive_request(
861 "P.CU",
862 "Box",
863 serde_json::json!({ "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0 }),
864 ))
865 .unwrap();
866 state
867 }
868
869 #[test]
870 fn annotations_json_for_a_cube_has_three_linear_dims() {
871 let state = cube_engine();
872 let json: serde_json::Value =
873 serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
874 let arr = json.as_array().unwrap();
875 assert_eq!(arr.len(), 3);
876 assert_eq!(arr[0]["fieldKey"], "sizeX");
877 assert_eq!(arr[0]["value"].as_f64().unwrap(), 10.0);
878 assert!(arr[0]["pointA"].is_array() && arr[0]["pointB"].is_array());
879 assert!(arr[0]["mid"].is_array());
880 }
881
882 #[test]
883 fn annotations_json_for_a_cylinder_has_radius_and_height() {
884 let mut state = EngineState::new();
885 state
886 .set_history_json(&primitive_request(
887 "P.CY",
888 "Cyl",
889 serde_json::json!({ "radius": 4.0, "height": 12.0 }),
890 ))
891 .unwrap();
892 let json: serde_json::Value =
893 serde_json::from_str(&state.feature_dimension_annotations_json("Cyl")).unwrap();
894 let arr = json.as_array().unwrap();
895 assert_eq!(arr.len(), 2);
896 assert_eq!(arr[0]["fieldKey"], "radius");
897 assert_eq!(arr[1]["fieldKey"], "height");
898 }
899
900 #[test]
909 fn every_transformable_primitive_auto_arms_a_dimension_gizmo() {
910 let cases: [(&str, serde_json::Value); 6] = [
911 ("P.CU", serde_json::json!({ "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0 })),
912 ("P.CY", serde_json::json!({ "radius": 4.0, "height": 12.0 })),
913 ("P.CO", serde_json::json!({ "radiusBottom": 4.0, "radiusTop": 2.0, "height": 10.0 })),
914 ("P.S", serde_json::json!({ "radius": 5.0 })),
915 ("P.PY", serde_json::json!({ "baseSideLength": 6.0, "height": 8.0 })),
916 ("P.T", serde_json::json!({ "majorRadius": 6.0, "tubeRadius": 1.5, "arc": 120.0 })),
917 ];
918 for (ty, params) in cases {
919 let mut state = EngineState::new();
920 state
921 .set_history_json(&primitive_request(ty, "Feat", params))
922 .unwrap_or_else(|e| panic!("{ty} builds: {e}"));
923 assert_ne!(
924 state.feature_dimension_annotations_json("Feat"),
925 "[]",
926 "{ty} must expose a dimension gizmo so expanding it auto-arms one"
927 );
928 state.arm_dimension("Feat");
930 assert_eq!(state.gizmo_mode(), "dimension", "{ty}");
931 }
932 }
933
934 #[test]
935 fn annotations_json_empty_for_unknown_feature() {
936 let state = cube_engine();
937 assert_eq!(state.feature_dimension_annotations_json("nope"), "[]");
938 }
939
940 #[test]
941 fn set_value_updates_the_param_and_reruns() {
942 let mut state = cube_engine();
943 state.arm_dimension("Box");
944 state.feature_dimension_set_value("Box", "sizeX", "25");
945 let index = state.history.index_of("Box").unwrap();
946 let params = state.history.feature_params(index).unwrap();
947 assert_eq!(params["sizeX"].as_f64().unwrap(), 25.0);
948 let json: serde_json::Value =
950 serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
951 assert_eq!(json[0]["value"].as_f64().unwrap(), 25.0);
952 }
953
954 #[test]
955 fn set_value_stores_expression_string_when_not_a_literal() {
956 let mut state = cube_engine();
957 state.set_expressions("w = 7;");
959 state.feature_dimension_set_value("Box", "sizeX", "w * 2");
960 let index = state.history.index_of("Box").unwrap();
961 let params = state.history.feature_params(index).unwrap();
962 assert_eq!(params["sizeX"].as_str().unwrap(), "w * 2");
964 let json: serde_json::Value =
966 serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
967 assert_eq!(json[0]["value"].as_f64().unwrap(), 14.0);
968 }
969
970 #[test]
971 fn set_value_rejects_a_bad_expression() {
972 let mut state = cube_engine();
973 state.feature_dimension_set_value("Box", "sizeX", "this is not valid");
974 let index = state.history.index_of("Box").unwrap();
975 let params = state.history.feature_params(index).unwrap();
976 assert_eq!(params["sizeX"].as_f64().unwrap(), 10.0);
978 }
979
980 #[test]
981 fn origin_sphere_and_center_sphere_toggle_the_two_modes() {
982 let mut state = cube_engine();
986 state.resize(800.0, 600.0);
987 state.camera.eye = [0.0, 0.0, 40.0];
988 state.camera.target = [0.0, 0.0, 0.0];
989 state.camera.up = [0.0, 1.0, 0.0];
990 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
991
992 state.arm_dimension("Box");
994 assert_eq!(state.gizmo_mode(), "dimension");
995
996 let anns: serde_json::Value =
999 serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
1000 let pa = &anns[0]["pointA"];
1001 let origin = [
1002 pa[0].as_f64().unwrap(),
1003 pa[1].as_f64().unwrap(),
1004 pa[2].as_f64().unwrap(),
1005 ];
1006 let (ox, oy, depth) = state.camera.project(origin);
1007 assert!(depth > 0.0, "origin in front of camera");
1008
1009 assert!(state.dimension_origin_pick(ox, oy), "pick hits the origin sphere");
1012 assert!(
1013 !state.transform_center_pick(ox, oy),
1014 "no transform-center pick while in dimension mode"
1015 );
1016 state.toggle_to_transform();
1017 assert_eq!(state.gizmo_mode(), "transform");
1018
1019 let (cx, cy) = state.transform_gizmo_anchor().expect("transform anchor");
1022 assert!(state.transform_center_pick(cx, cy), "pick hits the center handle");
1023 assert!(
1024 !state.dimension_origin_pick(cx, cy),
1025 "no dimension-origin pick while in transform mode"
1026 );
1027 state.toggle_to_dimension();
1028 assert_eq!(state.gizmo_mode(), "dimension");
1029 }
1030
1031 #[test]
1038 fn plane_arms_the_offset_dim_and_never_toggles_to_transform() {
1039 let request = serde_json::json!({
1043 "expressions": "",
1044 "configurator": {},
1045 "features": [{
1046 "type": "P",
1047 "inputParams": { "id": "Pl", "orientation": "XY", "offset_distance": 3.0 },
1048 "persistentData": {}
1049 }]
1050 })
1051 .to_string();
1052 let mut state = EngineState::new();
1053 state.set_history_json(&request).expect("plane builds");
1054
1055 let anns = state.feature_dimension_annotations("Pl");
1057 assert_eq!(anns.len(), 1, "plane emits its offset dim: {anns:?}");
1058 assert_eq!(anns[0].field_key, "offset_distance");
1059
1060 state.arm_dimension("Pl");
1061 assert_eq!(state.gizmo_mode(), "dimension");
1062 state.toggle_to_transform();
1064 assert_eq!(state.gizmo_mode(), "dimension", "a plane never gets a transform gizmo");
1065 }
1066
1067 #[test]
1068 fn angular_center_sphere_toggles_a_revolve_to_transform_and_back() {
1069 let request = serde_json::json!({
1073 "expressions": "",
1074 "configurator": {},
1075 "features": [
1076 {
1077 "type": "S",
1078 "inputParams": { "id": "Sk" },
1079 "persistentData": {
1080 "basis": { "origin": [0,0,0], "x": [1,0,0], "y": [0,1,0], "z": [0,0,1] },
1081 "sketch": {
1082 "points": [
1083 {"id":1,"x":2.0,"y":0.0}, {"id":2,"x":4.0,"y":0.0},
1084 {"id":3,"x":4.0,"y":3.0}, {"id":4,"x":2.0,"y":3.0},
1085 {"id":5,"x":0.0,"y":0.0}, {"id":6,"x":0.0,"y":1.0}
1086 ],
1087 "geometries": [
1088 {"id":10,"type":"line","points":[1,2]},
1089 {"id":11,"type":"line","points":[2,3]},
1090 {"id":12,"type":"line","points":[3,4]},
1091 {"id":13,"type":"line","points":[4,1]},
1092 {"id":20,"type":"line","points":[5,6],"construction":true}
1093 ],
1094 "constraints": []
1095 }
1096 }
1097 },
1098 {
1099 "type": "R",
1100 "inputParams": { "id": "Rev", "profile": "Sk", "axis": "Sk:G20", "angle": 90.0 },
1101 "persistentData": {}
1102 }
1103 ]
1104 })
1105 .to_string();
1106
1107 let mut state = EngineState::new();
1108 state.set_history_json(&request).expect("revolve builds");
1109 state.resize(800.0, 600.0);
1110 state.camera.eye = [0.0, 0.0, 40.0];
1111 state.camera.target = [0.0, 0.0, 0.0];
1112 state.camera.up = [0.0, 1.0, 0.0];
1113 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1114
1115 let anns = state.feature_dimension_annotations("Rev");
1117 assert_eq!(anns.len(), 1, "revolve emits one (angular) dim: {anns:?}");
1118 assert_eq!(anns[0].kind, crate::feature_dimensions::FeatureDimKind::Angular);
1119 let center = anns[0].center;
1120
1121 state.arm_dimension("Rev");
1123 assert_eq!(state.gizmo_mode(), "dimension");
1124
1125 let (cx, cy, depth) = state.camera.project(center);
1127 assert!(depth > 0.0, "arc center in front of the camera");
1128 assert!(
1129 state.dimension_origin_pick(cx, cy),
1130 "pick hits the angular center sphere"
1131 );
1132 state.toggle_to_transform();
1133 assert_eq!(state.gizmo_mode(), "transform");
1134
1135 let (tx, ty) = state.transform_gizmo_anchor().expect("transform anchor");
1137 assert!(
1138 state.transform_center_pick(tx, ty),
1139 "pick hits the transform center handle"
1140 );
1141 state.toggle_to_dimension();
1142 assert_eq!(state.gizmo_mode(), "dimension");
1143 assert!(
1144 state.dimension_armed_for("Rev"),
1145 "round trip lands back on the revolve's dimension arrows"
1146 );
1147 }
1148
1149 fn revolve_engine(angle: f64) -> EngineState {
1155 revolve_engine_with_profile("Sk", angle)
1156 }
1157
1158 fn revolve_engine_with_profile(profile: &str, angle: f64) -> EngineState {
1162 let request = serde_json::json!({
1163 "expressions": "",
1164 "configurator": {},
1165 "features": [
1166 {
1167 "type": "S",
1168 "inputParams": { "id": "Sk" },
1169 "persistentData": {
1170 "basis": { "origin": [0,0,0], "x": [1,0,0], "y": [0,1,0], "z": [0,0,1] },
1171 "sketch": {
1172 "points": [
1173 {"id":1,"x":2.0,"y":0.0}, {"id":2,"x":4.0,"y":0.0},
1174 {"id":3,"x":4.0,"y":3.0}, {"id":4,"x":2.0,"y":3.0},
1175 {"id":5,"x":0.0,"y":0.0}, {"id":6,"x":0.0,"y":1.0}
1176 ],
1177 "geometries": [
1178 {"id":10,"type":"line","points":[1,2]},
1179 {"id":11,"type":"line","points":[2,3]},
1180 {"id":12,"type":"line","points":[3,4]},
1181 {"id":13,"type":"line","points":[4,1]},
1182 {"id":20,"type":"line","points":[5,6],"construction":true}
1183 ],
1184 "constraints": []
1185 }
1186 }
1187 },
1188 {
1189 "type": "R",
1190 "inputParams": { "id": "Rev", "profile": profile, "axis": "Sk:G20", "angle": angle },
1191 "persistentData": {}
1192 }
1193 ]
1194 })
1195 .to_string();
1196
1197 let mut state = EngineState::new();
1198 state.set_history_json(&request).expect("revolve builds");
1199 state.resize(800.0, 600.0);
1200 state.camera.eye = [0.0, 40.0, 0.0];
1205 state.camera.target = [0.0, 1.5, 0.0];
1206 state.camera.up = [0.0, 0.0, 1.0];
1207 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1208 state
1209 }
1210
1211 #[test]
1220 fn revolve_angle_json_is_angular_and_drag_writes_the_swept_degrees() {
1221 let mut state = revolve_engine(90.0);
1222
1223 let json: serde_json::Value =
1226 serde_json::from_str(&state.feature_dimension_annotations_json("Rev")).unwrap();
1227 let arr = json.as_array().unwrap();
1228 assert_eq!(arr.len(), 1, "revolve emits one dim: {arr:?}");
1229 assert_eq!(arr[0]["fieldKey"], "angle");
1230 assert_eq!(arr[0]["kind"], "angular");
1231 assert!((arr[0]["value"].as_f64().unwrap() - 90.0).abs() < 1e-9);
1232 assert!(arr[0]["mid"].is_array(), "angular chip anchors on the arc");
1233
1234 state.arm_dimension("Rev");
1237 let ann = state
1238 .feature_dimension_annotations("Rev")
1239 .into_iter()
1240 .find(|a| a.field_key == "angle")
1241 .expect("angle dim");
1242 assert_eq!(ann.kind, crate::feature_dimensions::FeatureDimKind::Angular);
1243
1244 let handle =
1246 crate::feature_dimensions::arrow_handle_point(&ann, state.world_per_pixel());
1247 let (hx, hy, hdepth) = state.camera.project(handle);
1248 assert!(hdepth > 0.0, "handle in front of the camera");
1249 assert_eq!(
1250 state.dimension_arrow_pick(hx, hy).as_deref(),
1251 Some("angle"),
1252 "pick at the arc arrowhead grabs the angle handle"
1253 );
1254
1255 let target = 200.0_f64;
1258 let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * state.world_per_pixel();
1259 let dir = crate::feature_dimensions::rotate_about_axis(
1260 ann.ref_dir,
1261 ann.axis,
1262 target.to_radians(),
1263 );
1264 let world = [
1265 ann.center[0] + dir[0] * radius,
1266 ann.center[1] + dir[1] * radius,
1267 ann.center[2] + dir[2] * radius,
1268 ];
1269 let (tx, ty, tdepth) = state.camera.project(world);
1270 assert!(tdepth > 0.0, "drag target in front of the camera");
1271 state.feature_dimension_drag("Rev", "angle", tx, ty);
1272
1273 let after = state.feature_dimension_annotations("Rev");
1274 let new_angle = after.iter().find(|a| a.field_key == "angle").expect("angle dim");
1275 assert!(
1276 (new_angle.value - target).abs() < 2.0,
1277 "drag should sweep the angle to ~{target}°, got {}",
1278 new_angle.value
1279 );
1280 }
1281
1282 #[test]
1290 fn revolve_face_alias_profile_still_arms_the_angle_gizmo() {
1291 let state = revolve_engine_with_profile("Sk:FACE", 90.0);
1292 let json: serde_json::Value =
1293 serde_json::from_str(&state.feature_dimension_annotations_json("Rev")).unwrap();
1294 let arr = json.as_array().unwrap();
1295 assert_eq!(
1296 arr.len(),
1297 1,
1298 "the `:FACE`-aliased revolve profile must still emit its angular dim: {arr:?}"
1299 );
1300 assert_eq!(arr[0]["fieldKey"], "angle");
1301 assert_eq!(arr[0]["kind"], "angular");
1302 assert_eq!(arr[0]["value"].as_f64().unwrap(), 90.0);
1303 }
1304
1305 #[test]
1312 fn dimension_hit_areas_are_screen_regions_matching_the_picks() {
1313 let mut state = cube_engine();
1314 state.resize(800.0, 600.0);
1315 state.camera.eye = [30.0, 20.0, 40.0];
1319 state.camera.target = [0.0, 0.0, 0.0];
1320 state.camera.up = [0.0, 1.0, 0.0];
1321 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1322
1323 assert_eq!(state.dimension_hit_areas_json(), "[]");
1325 state.arm_transform("Box");
1326 assert_eq!(state.dimension_hit_areas_json(), "[]");
1327
1328 state.arm_dimension("Box");
1329 let areas: Vec<serde_json::Value> =
1330 serde_json::from_str(&state.dimension_hit_areas_json()).unwrap();
1331 let anns = state.feature_dimension_annotations("Box");
1332
1333 let close2 = |v: &serde_json::Value, want: (f64, f64)| {
1334 (v[0].as_f64().unwrap() - want.0).abs() < 1e-3
1335 && (v[1].as_f64().unwrap() - want.1).abs() < 1e-3
1336 };
1337
1338 let capsules: Vec<&serde_json::Value> =
1341 areas.iter().filter(|a| a["kind"] == "capsule").collect();
1342 assert_eq!(capsules.len(), 3);
1343 assert_eq!(capsules.len(), anns.len());
1344 for (cap, ann) in capsules.iter().zip(anns.iter()) {
1345 let r = cap["r"].as_f64().unwrap();
1346 assert!(
1347 (r - crate::feature_dimensions::ARROW_HANDLE_HIT_RAD_PX).abs() < 1e-6,
1348 "r {r} must equal ARROW_HANDLE_HIT_RAD_PX"
1349 );
1350 let (ax, ay, _) = state.camera.project(ann.point_a);
1351 let (bx, by, _) = state.camera.project(ann.point_b);
1352 assert!(close2(&cap["a"], (ax, ay)), "leader a: {:?} vs {ax},{ay}", cap["a"]);
1353 assert!(close2(&cap["b"], (bx, by)), "leader b: {:?} vs {bx},{by}", cap["b"]);
1354 }
1355
1356 let circles: Vec<&serde_json::Value> =
1359 areas.iter().filter(|a| a["kind"] == "circle").collect();
1360 assert_eq!(circles.len(), 1, "cube dims share one origin sphere");
1361 let want_r = crate::feature_dimensions::ORIGIN_SPHERE_RAD_PX + 2.0;
1362 assert!((circles[0]["r"].as_f64().unwrap() - want_r).abs() < 1e-6);
1363 let (ox, oy, _) = state.camera.project(anns[0].point_a);
1364 assert!(close2(&circles[0]["c"], (ox, oy)), "origin circle off point_a");
1365
1366 for ann in &anns {
1370 let mid = ann.midpoint();
1371 let (mx, my, _) = state.camera.project(mid);
1372 assert_eq!(
1373 state.dimension_arrow_pick(mx, my).as_deref(),
1374 Some(ann.field_key.as_str()),
1375 "cursor on the {} leader midpoint grabs it",
1376 ann.field_key
1377 );
1378 }
1379 assert!(state.dimension_origin_pick(ox, oy), "cursor on the origin circle toggles");
1380 }
1381
1382 #[test]
1383 fn closest_t_on_axis_projects_a_perpendicular_ray() {
1384 let t = closest_t_on_axis(
1387 [0.0, 0.0, 0.0],
1388 [1.0, 0.0, 0.0],
1389 [7.0, 5.0, 0.0],
1390 [0.0, -1.0, 0.0],
1391 )
1392 .unwrap();
1393 assert!((t - 7.0).abs() < 1e-9, "t = {t}");
1394 }
1395
1396 #[test]
1397 fn closest_t_on_axis_none_when_parallel() {
1398 assert!(closest_t_on_axis(
1399 [0.0, 0.0, 0.0],
1400 [1.0, 0.0, 0.0],
1401 [0.0, 5.0, 0.0],
1402 [1.0, 0.0, 0.0],
1403 )
1404 .is_none());
1405 }
1406
1407 fn torus_engine(arc: f64) -> EngineState {
1410 let mut state = EngineState::new();
1411 state
1412 .set_history_json(&primitive_request(
1413 "P.T",
1414 "Tor",
1415 serde_json::json!({ "majorRadius": 6.0, "tubeRadius": 1.5, "arc": arc }),
1416 ))
1417 .unwrap();
1418 state
1419 }
1420
1421 #[test]
1422 fn annotations_json_for_a_torus_has_two_linear_and_one_angular() {
1423 let state = torus_engine(120.0);
1424 let json: serde_json::Value =
1425 serde_json::from_str(&state.feature_dimension_annotations_json("Tor")).unwrap();
1426 let arr = json.as_array().unwrap();
1427 assert_eq!(arr.len(), 3);
1428 assert_eq!(arr[0]["fieldKey"], "majorRadius");
1429 assert_eq!(arr[0]["kind"], "linear");
1430 assert_eq!(arr[1]["fieldKey"], "tubeRadius");
1431 assert_eq!(arr[1]["kind"], "linear");
1432 assert_eq!(arr[2]["fieldKey"], "arc");
1435 assert_eq!(arr[2]["kind"], "angular");
1436 assert_eq!(arr[2]["value"].as_f64().unwrap(), 120.0);
1437 assert!(arr[2]["mid"].is_array());
1438 }
1439
1440 #[test]
1441 fn dragging_a_torus_arc_writes_the_swept_degrees() {
1442 let mut state = torus_engine(90.0);
1443 state.arm_dimension("Tor");
1444 let anns = state.feature_dimension_annotations("Tor");
1445 let arc = anns
1446 .iter()
1447 .find(|a| a.field_key == "arc")
1448 .expect("arc dim")
1449 .clone();
1450 assert_eq!(arc.kind, crate::feature_dimensions::FeatureDimKind::Angular);
1451
1452 let target = 210.0_f64;
1455 let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * state.world_per_pixel();
1456 let dir = crate::feature_dimensions::rotate_about_axis(
1457 arc.ref_dir,
1458 arc.axis,
1459 target.to_radians(),
1460 );
1461 let world = [
1462 arc.center[0] + dir[0] * radius,
1463 arc.center[1] + dir[1] * radius,
1464 arc.center[2] + dir[2] * radius,
1465 ];
1466 let (sx, sy, depth) = state.camera.project(world);
1467 assert!(depth > 0.0, "arc-end must project in front of the camera");
1468
1469 state.feature_dimension_drag("Tor", "arc", sx, sy);
1470
1471 let after = state.feature_dimension_annotations("Tor");
1472 let new_arc = after.iter().find(|a| a.field_key == "arc").expect("arc dim");
1473 assert!(
1474 (new_arc.value - target).abs() < 2.0,
1475 "drag should sweep the arc to ~{target}°, got {}",
1476 new_arc.value
1477 );
1478 }
1479
1480 #[test]
1483 fn dimension_arrow_pick_and_drag_edits_the_param_live() {
1484 let mut state = cube_engine();
1485 state.resize(800.0, 600.0);
1486 state.camera.eye = [0.0, 0.0, 40.0]; state.camera.target = [0.0, 0.0, 0.0];
1488 state.camera.up = [0.0, 1.0, 0.0];
1489 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1490
1491 state.arm_transform("Box");
1493 assert!(
1494 state.dimension_arrow_pick(400.0, 300.0).is_none(),
1495 "no arrow pick in transform mode"
1496 );
1497
1498 state.arm_dimension("Box");
1499 assert_eq!(state.gizmo_mode(), "dimension");
1500
1501 let ann = state
1504 .feature_dimension_annotations("Box")
1505 .into_iter()
1506 .find(|a| a.field_key == "sizeX")
1507 .expect("sizeX dim");
1508 let a = ann.point_a;
1509 let b = ann.point_b;
1510 let (bx, by, depth) = state.camera.project(b);
1511 assert!(depth > 0.0, "arrowhead in front of the camera");
1512 assert_eq!(
1513 state.dimension_arrow_pick(bx, by).as_deref(),
1514 Some("sizeX"),
1515 "pick at the arrowhead grabs sizeX"
1516 );
1517 assert!(state.dimension_arrow_pick(10.0, 10.0).is_none(), "empty space → no arrow");
1519
1520 let len = {
1523 let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
1524 (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
1525 };
1526 let dir = [(b[0] - a[0]) / len, (b[1] - a[1]) / len, (b[2] - a[2]) / len];
1527 let target_len = 16.0_f64;
1528 let world = [
1529 a[0] + dir[0] * target_len,
1530 a[1] + dir[1] * target_len,
1531 a[2] + dir[2] * target_len,
1532 ];
1533 let (wx, wy, wdepth) = state.camera.project(world);
1534 assert!(wdepth > 0.0, "drag target in front of the camera");
1535
1536 let before = state
1537 .history
1538 .feature_params(state.history.index_of("Box").unwrap())
1539 .unwrap()["sizeX"]
1540 .as_f64()
1541 .unwrap();
1542 state.feature_dimension_drag("Box", "sizeX", wx, wy);
1543 let after = state
1544 .history
1545 .feature_params(state.history.index_of("Box").unwrap())
1546 .unwrap()["sizeX"]
1547 .as_f64()
1548 .unwrap();
1549
1550 let expected = target_len * (ann.value / len);
1554 assert!(after > before, "sizeX grew: {before} → {after}");
1555 assert!(
1556 (after - expected).abs() < 0.5,
1557 "drag set sizeX to ~{expected}, got {after}"
1558 );
1559 }
1560
1561 #[test]
1569 fn linear_leader_with_point_b_behind_the_eye_is_grabbable_on_its_visible_shaft() {
1570 let mut state = cube_engine();
1571 state.resize(800.0, 600.0);
1572 state.arm_dimension("Box");
1573
1574 let anns = state.feature_dimension_annotations("Box");
1579 let ann = anns.iter().find(|a| a.field_key == "sizeY").expect("sizeY dim").clone();
1580 let a = ann.point_a;
1581 let b = ann.point_b;
1582 let dir = fd_normalize3(fd_sub3(b, a));
1583 let l = fd_norm3(fd_sub3(b, a));
1584 let mut off = [0.0f64; 3];
1589 for other in anns.iter().filter(|a| a.field_key != "sizeY") {
1590 let d = fd_normalize3(fd_sub3(other.point_b, other.point_a));
1591 off = [off[0] + d[0], off[1] + d[1], off[2] + d[2]];
1592 }
1593 let off_dot = fd_dot3(off, dir);
1594 let perp = fd_normalize3([
1595 off[0] - dir[0] * off_dot,
1596 off[1] - dir[1] * off_dot,
1597 off[2] - dir[2] * off_dot,
1598 ]);
1599 let mid = [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5];
1600 let d_off = l * 0.25; let eye = [
1602 mid[0] + perp[0] * d_off,
1603 mid[1] + perp[1] * d_off,
1604 mid[2] + perp[2] * d_off,
1605 ];
1606 let forward = fd_normalize3(fd_sub3(a, eye)); state.camera.eye = eye;
1608 state.camera.target = [eye[0] + forward[0], eye[1] + forward[1], eye[2] + forward[2]];
1609 state.camera.up = if forward[2].abs() < 0.9 { [0.0, 0.0, 1.0] } else { [0.0, 1.0, 0.0] };
1610 state.camera.projection =
1611 crate::view::Projection::Orthographic { half_height: l.max(1.0) };
1612
1613 assert!(
1615 state.camera.view_depth(b) < 0.0,
1616 "point_b must be behind the eye: {}",
1617 state.camera.view_depth(b)
1618 );
1619 assert!(state.camera.view_depth(a) > 0.0, "point_a in front");
1620
1621 let (bx, by, bdepth) = state.camera.project(b);
1624 assert!(bdepth < 0.0, "the projected far-end depth the old skip rejected");
1625 assert_eq!(
1626 state.dimension_arrow_pick(bx, by).as_deref(),
1627 Some("sizeY"),
1628 "the leader is grabbable at its visible far end"
1629 );
1630
1631 let (mx, my, _) = state.camera.project(mid);
1633 assert_eq!(
1634 state.dimension_arrow_pick(mx, my).as_deref(),
1635 Some("sizeY"),
1636 "the leader is grabbable on its shaft"
1637 );
1638 }
1639
1640 #[test]
1652 fn ensure_overlays_current_rebakes_the_dimension_gizmo_on_zoom_without_looping() {
1653 let mut state = cube_engine();
1654 state.resize(800.0, 600.0);
1655 state.camera.eye = [30.0, 20.0, 40.0];
1656 state.camera.target = [0.0, 0.0, 0.0];
1657 state.camera.up = [0.0, 1.0, 0.0];
1658 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1659 state.arm_dimension("Box");
1660
1661 state.dirty = false;
1663 state.ensure_overlays_current();
1664 assert!(!state.dirty, "a quiet frame must not re-bake (no dirty loop)");
1665
1666 state.camera.projection = crate::view::Projection::Orthographic { half_height: 40.0 };
1668 state.ensure_overlays_current();
1669 assert!(state.dirty, "a zoom change re-bakes the screen-constant sizing");
1670
1671 state.dirty = false;
1675 state.ensure_overlays_current();
1676 state.ensure_overlays_current();
1677 state.ensure_overlays_current();
1678 assert!(!state.dirty, "the re-bake settles — no per-frame re-bake loop");
1679
1680 state.resize(800.0, 300.0);
1683 state.dirty = false;
1684 state.ensure_overlays_current();
1685 assert!(state.dirty, "a viewport resize changes world-per-pixel → re-bake");
1686
1687 state.dirty = false;
1691 state.camera.eye = [40.0, 20.0, 30.0]; state.ensure_overlays_current();
1693 assert!(!state.dirty, "orbit needs no re-bake (world-space geometry)");
1694 state.camera.eye = [35.0, 25.0, 45.0];
1695 state.camera.target = [5.0, 5.0, 5.0]; state.ensure_overlays_current();
1697 assert!(!state.dirty, "pan needs no re-bake (world-space geometry)");
1698 }
1699
1700 #[test]
1708 fn zoom_rescales_the_drawn_angular_arc_and_keeps_the_grab_circle_on_it() {
1709 let mut state = revolve_engine(90.0);
1710 state.arm_dimension("Rev");
1711 assert_eq!(
1712 state.widgets.overlay_group_names(),
1713 vec!["feature-dim-leaders"],
1714 "only the dimension gizmo is drawn, so the overlay bbox is its own"
1715 );
1716
1717 let extent = |s: &EngineState| {
1718 let b = s.widgets.overlay_groups_bbox();
1719 let (min, max) = (b.min, b.max);
1720 (max[0] - min[0]).max(max[1] - min[1]).max(max[2] - min[2])
1721 };
1722 let before = extent(&state);
1723 assert!(before > 0.0, "the arc is drawn");
1724
1725 let hit_center = |s: &EngineState| {
1727 let areas: Vec<serde_json::Value> =
1728 serde_json::from_str(&s.dimension_hit_areas_json()).unwrap();
1729 let c = areas
1730 .iter()
1731 .find(|a| a["kind"] == "circle" && a["r"].as_f64().unwrap() > 10.0)
1732 .expect("the arc sweep-end grab circle")
1733 .clone();
1734 [c["c"][0].as_f64().unwrap(), c["c"][1].as_f64().unwrap()]
1735 };
1736 let wpp_baked = state.camera.world_per_pixel();
1737 let drawn_handle = |s: &EngineState, wpp: f64| {
1738 let ann = s.feature_dimension_annotations("Rev");
1739 let ann = ann.iter().find(|a| a.field_key == "angle").unwrap();
1740 let p = crate::feature_dimensions::arrow_handle_point(ann, wpp);
1741 let (x, y, _) = s.camera.project(p);
1742 [x, y]
1743 };
1744 let at_arm = drawn_handle(&state, wpp_baked);
1745 let hit_at_arm = hit_center(&state);
1746 assert!(
1747 (at_arm[0] - hit_at_arm[0]).hypot(at_arm[1] - hit_at_arm[1]) < 1.0,
1748 "at arm the grab circle sits on the drawn handle"
1749 );
1750
1751 state.camera.projection = crate::view::Projection::Orthographic { half_height: 40.0 };
1753 state.ensure_overlays_current();
1754
1755 let after = extent(&state);
1757 assert!(
1758 (after / before - 2.0).abs() < 0.05,
1759 "the drawn arc must scale with the zoom: {before} → {after}"
1760 );
1761 let wpp_now = state.camera.world_per_pixel();
1764 let drawn_now = drawn_handle(&state, wpp_now);
1765 let hit_now = hit_center(&state);
1766 assert!(
1767 (drawn_now[0] - hit_now[0]).hypot(drawn_now[1] - hit_now[1]) < 1.0,
1768 "after the zoom the grab circle still sits on the drawn handle"
1769 );
1770 let stale = drawn_handle(&state, wpp_baked);
1772 assert!(
1773 (stale[0] - hit_now[0]).hypot(stale[1] - hit_now[1]) > 20.0,
1774 "the OLD-zoom handle is nowhere near the live grab circle"
1775 );
1776 }
1777
1778 #[test]
1783 fn every_zoom_path_rebakes_the_dimension_gizmo() {
1784 type Zoom = (&'static str, fn(&mut EngineState));
1785 let paths: Vec<Zoom> = vec![
1786 ("wheel", |s| {
1787 assert!(s.wheel(-240.0, None), "the wheel zooms");
1788 }),
1789 ("zoom-to-fit", |s| s.zoom_to_fit()),
1790 ("standard view (View menu — it zoom-to-fits)", |s| {
1791 assert!(s.standard_view("FRONT"));
1792 }),
1793 ("viewport resize", |s| s.resize(400.0, 200.0)),
1794 ("projection half-height", |s| {
1795 s.camera.projection = crate::view::Projection::Orthographic { half_height: 7.0 };
1796 }),
1797 ];
1798 for (name, apply) in paths {
1799 let mut state = cube_engine();
1800 state.resize(800.0, 600.0);
1801 state.camera.eye = [30.0, 20.0, 40.0];
1802 state.camera.target = [0.0, 0.0, 0.0];
1803 state.camera.up = [0.0, 1.0, 0.0];
1804 state.camera.projection =
1805 crate::view::Projection::Orthographic { half_height: 200.0 };
1806 state.arm_dimension("Box");
1807 let baked = state.feature_dim_overlay_wpp;
1808 assert!(baked > 0.0, "{name}: armed → baked");
1809
1810 apply(&mut state);
1811 let live = state.camera.world_per_pixel();
1812 assert!(
1813 super::overlay_wpp_stale(baked, live),
1814 "{name} must move world-per-pixel ({baked} → {live})"
1815 );
1816 state.ensure_overlays_current();
1817 assert!(
1818 (state.feature_dim_overlay_wpp - live).abs() < live * 1e-9,
1819 "{name}: the gizmo is re-baked at the LIVE zoom"
1820 );
1821 }
1822 }
1823
1824 #[test]
1837 fn pan_orbit_viewcube_and_projection_toggle_need_no_rebake() {
1838 let mut state = cube_engine();
1839 state.resize(800.0, 600.0);
1840 state.camera.eye = [30.0, 20.0, 40.0];
1841 state.camera.target = [0.0, 0.0, 0.0];
1842 state.camera.up = [0.0, 1.0, 0.0];
1843 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1844 state.arm_dimension("Box");
1845 let baked = state.feature_dim_overlay_wpp;
1846
1847 state.apply_look_direction([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]);
1849 state.camera.eye = [35.0, 25.0, 45.0];
1851 state.camera.target = [5.0, 5.0, 5.0];
1852 assert_eq!(state.toggle_projection(), "perspective");
1854
1855 let live = state.camera.world_per_pixel();
1856 assert!(
1857 !super::overlay_wpp_stale(baked, live),
1858 "pan/orbit/ViewCube/projection-toggle hold world-per-pixel ({baked} → {live})"
1859 );
1860 state.dirty = false;
1861 state.ensure_overlays_current();
1862 assert!(!state.dirty, "none of these must re-bake");
1863
1864 let anns = state.feature_dimension_annotations("Box");
1867 for ann in &anns {
1868 let (mx, my, _) = state.camera.project(ann.midpoint());
1869 assert_eq!(
1870 state.dimension_arrow_pick(mx, my).as_deref(),
1871 Some(ann.field_key.as_str()),
1872 "after pan+orbit+toggle the {} leader still grabs on its shaft",
1873 ann.field_key
1874 );
1875 }
1876 }
1877}