brep_render/engine_state/feature_dims.rs
1use brep_kernel::first_reference_name;
2use super::*;
3use super::sketch_edit_ops::is_plain_number_literal;
4use brep_gizmos::hit_region::{point_region, segment_region, HitShape};
5
6/// The world-space overlay group carrying the FD leaders + arrowheads.
7const FEATURE_DIM_OVERLAY: &str = "feature-dim-leaders";
8
9/// The role a dimension-gizmo hit region plays. An `Arrow` is a value-drag handle
10/// (a linear leader or the angular arc handle, keyed by its `field_key`); `Origin`
11/// is the shared origin/center sphere (the mode toggle). Both share ONE region
12/// list so the pick + the drawn outline can never drift.
13#[derive(Clone, Debug)]
14enum DimRole {
15 Arrow(String),
16 Origin,
17}
18
19impl EngineState {
20 /// The armed ◎ gizmo mode: `"none"`, `"transform"`, or `"dimension"`. Drives
21 /// the ◎ highlight + the app's dimension-overlay draw / input routing.
22 pub fn gizmo_mode(&self) -> &'static str {
23 match self.transform_gizmo.mode {
24 GizmoMode::None => "none",
25 GizmoMode::Transform => "transform",
26 GizmoMode::Dimension => "dimension",
27 }
28 }
29
30 /// Whether the DIMENSION gizmo is armed for THIS feature (drives the ◎
31 /// dimension-mode highlight).
32 pub fn dimension_armed_for(&self, feature_id: &str) -> bool {
33 matches!(self.transform_gizmo.mode, GizmoMode::Dimension)
34 && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
35 }
36
37 /// The dimension-armed feature id (empty unless in dimension mode).
38 pub fn dimension_armed_feature(&self) -> String {
39 if matches!(self.transform_gizmo.mode, GizmoMode::Dimension) {
40 self.transform_gizmo.feature_id.clone().unwrap_or_default()
41 } else {
42 String::new()
43 }
44 }
45
46 /// Arm the DIMENSION gizmo for `feature_id`: hide the transform widget, show
47 /// the annotation overlay. Re-arming a different feature moves it.
48 pub fn arm_dimension(&mut self, feature_id: &str) {
49 // The widget slot is shared — an armed component Move gizmo yields.
50 self.component_move_reset();
51 self.transform_gizmo.feature_id = Some(feature_id.to_string());
52 self.transform_gizmo.mode = GizmoMode::Dimension;
53 self.transform_gizmo.drag = None;
54 // The transform widget and the dimension overlay are mutually exclusive.
55 let _ = self.widgets.set_transform_json("null");
56 self.refresh_feature_dimension_overlay();
57 self.dirty = true;
58 }
59
60 // --- The orange center-sphere ◎ TOGGLE (dimension ↔ transform) ---------
61 //
62 // A single orange sphere sits at the gizmo center in BOTH modes: the
63 // transform gizmo's `HANDLE_CENTER` sphere and the dimension arrows' shared
64 // origin sphere project to the same point. Clicking it flips the two modes,
65 // mirroring the old app's `CombinedTransformControls` center-handle toggle
66 // (pointer-down on `HANDLE_CENTER` calls
67 // `toggleDisplayMode`). The viewport routes a bare CLICK here; a DRAG on the
68 // center still free-moves via `transform_press` (unchanged).
69
70 /// Whether a screen-px pick in TRANSFORM mode lands on the orange CENTER
71 /// free-move sphere (`HANDLE_CENTER`). The viewport uses this to make a bare
72 /// click on the center TOGGLE to the dimension arrows (via
73 /// [`toggle_to_dimension`](Self::toggle_to_dimension)) instead of swallowing
74 /// it as a generic handle click. False in any other gizmo mode.
75 pub fn transform_center_pick(&self, x: f64, y: f64) -> bool {
76 matches!(self.transform_gizmo.mode, GizmoMode::Transform)
77 && self.transform_pick(x, y) == brep_gizmos::transform::HANDLE_CENTER
78 }
79
80 /// Whether a screen-px pick in DIMENSION mode lands on an orange ORIGIN
81 /// sphere of the armed feature's dimension arrows. Each distinct annotation
82 /// draws such a sphere — a LINEAR dim at its `point_a` (a cube's three axis
83 /// dims share one, a cone/pyramid draw two), an ANGULAR dim at its arc
84 /// `center` (the vertex; its sweep-END sphere is the angle DRAG handle, not a
85 /// toggle) — so every one is projected via the camera and hit-tested against
86 /// the screen-constant sphere radius. The viewport uses this to TOGGLE back to
87 /// the transform gizmo (via [`toggle_to_transform`](Self::toggle_to_transform)),
88 /// which is the ONLY way an angular-only feature (a revolve) reaches transform.
89 /// False in any other gizmo mode. The hit radius mirrors the gizmo center's own
90 /// tolerance (`PX_CENTER_RAD + 2.0`, transform.rs) so the two toggle targets match.
91 pub fn dimension_origin_pick(&self, x: f64, y: f64) -> bool {
92 // The origin sphere (LINEAR at `point_a`, ANGULAR at the arc `center`) is an
93 // `Origin`-role region. 2D-test the cursor against the SAME screen-space
94 // regions the outline draws — a point-in-circle test, so what is outlined
95 // is exactly what toggles.
96 let p = [x as f32, y as f32];
97 self.dimension_hit_regions()
98 .into_iter()
99 .any(|(role, shape)| matches!(role, DimRole::Origin) && shape.contains(p))
100 }
101
102 /// Whether a screen-px pick in DIMENSION mode lands on a dimension ARROWHEAD
103 /// (a linear leader's orange cone TIP at `point_b`, or an angular arc's orange
104 /// sweep-END handle sphere). Returns the grabbed annotation's `field_key` — the
105 /// viewport routes a DRAG that starts here to [`feature_dimension_drag`](Self::
106 /// feature_dimension_drag), editing that param live (Fix 4). `None` in any other
107 /// gizmo mode / when no arrowhead is under the pointer. Distinct from
108 /// [`dimension_origin_pick`](Self::dimension_origin_pick): that grabs the SHARED
109 /// origin sphere (a mode toggle), this grabs an arrowHEAD (a value edit). The
110 /// nearest arrowhead within the screen-constant hit radius wins.
111 pub fn dimension_arrow_pick(&self, x: f64, y: f64) -> Option<String> {
112 // The NEAREST `Arrow`-role region containing the cursor wins. LINEAR: the
113 // WHOLE leader CAPSULE (`point_a → point_b`) is grabbable, so a cursor
114 // anywhere on the visible shaft grabs it — even when `point_b` crosses
115 // BEHIND the eye (the reported sizeY failure), because in ortho the whole
116 // leader still projects (and in perspective the region is front-clipped to
117 // its visible part). ANGULAR: the arc sweep-END handle CIRCLE. These are
118 // the SAME screen-space regions the outline draws, so what is outlined is
119 // exactly what grabs.
120 let p = [x as f32, y as f32];
121 let mut best: Option<(f32, String)> = None;
122 for (role, shape) in self.dimension_hit_regions() {
123 if let DimRole::Arrow(key) = role {
124 let d = shape.spine_distance(p);
125 if d <= shape.radius() && best.as_ref().map(|(bd, _)| d < *bd).unwrap_or(true) {
126 best = Some((d, key));
127 }
128 }
129 }
130 best.map(|(_, key)| key)
131 }
132
133 /// The authoritative SCREEN-space (viewport-local px) pickable regions of the
134 /// armed feature's dimension gizmo, each paired with its [`DimRole`]. The
135 /// SINGLE source `dimension_arrow_pick` (its `Arrow` regions), `dimension_origin_pick`
136 /// (its `Origin` regions), and `dimension_hit_areas_json` (draws them ALL) all
137 /// consume — so the grabbable area is exactly the drawn outline. Projection +
138 /// the perspective front-clip happen ONCE in [`brep_gizmos::hit_region`].
139 /// * LINEAR → a leader CAPSULE (`point_a → point_b`, `ARROW_HANDLE_HIT_RAD_PX`)
140 /// with role `Arrow` + an origin CIRCLE (`point_a`, `ORIGIN_SPHERE_RAD_PX + 2`)
141 /// with role `Origin`.
142 /// * ANGULAR → an arc-handle CIRCLE (`arrow_handle_point`,
143 /// `ARROW_HANDLE_HIT_RAD_PX`) with role `Arrow` + an arc-center origin
144 /// CIRCLE (`center`, `ORIGIN_SPHERE_RAD_PX + 2`) with role `Origin`.
145 /// Origin balls are deduped by world position (a torus's linear origin + its
146 /// angular center coincide) so they match the single drawn sphere. `[]` unless
147 /// the DIMENSION gizmo is armed for a feature.
148 fn dimension_hit_regions(&self) -> Vec<(DimRole, HitShape)> {
149 if !matches!(self.transform_gizmo.mode, GizmoMode::Dimension) {
150 return Vec::new();
151 }
152 let feature = self.dimension_armed_feature();
153 if feature.is_empty() {
154 return Vec::new();
155 }
156 let wpp = self.camera.world_per_pixel();
157 let arrow_px = crate::feature_dimensions::ARROW_HANDLE_HIT_RAD_PX as f32;
158 let origin_px = (crate::feature_dimensions::ORIGIN_SPHERE_RAD_PX + 2.0) as f32;
159 let cam = &self.camera;
160 let mut out: Vec<(DimRole, HitShape)> = Vec::new();
161 let mut origins: Vec<[f64; 3]> = Vec::new();
162 for ann in self.feature_dimension_annotations(&feature) {
163 match ann.kind {
164 crate::feature_dimensions::FeatureDimKind::Linear => {
165 if let Some(shape) = segment_region(cam, ann.point_a, ann.point_b, arrow_px) {
166 out.push((DimRole::Arrow(ann.field_key.clone()), shape));
167 }
168 push_origin_region(&mut out, &mut origins, cam, ann.point_a, origin_px);
169 }
170 crate::feature_dimensions::FeatureDimKind::Angular => {
171 let handle = crate::feature_dimensions::arrow_handle_point(&ann, wpp);
172 if let Some(shape) = point_region(cam, handle, arrow_px) {
173 out.push((DimRole::Arrow(ann.field_key.clone()), shape));
174 }
175 push_origin_region(&mut out, &mut origins, cam, ann.center, origin_px);
176 }
177 }
178 }
179 out
180 }
181
182 /// DEBUG overlay: the EXACT SCREEN-space (viewport-local px) pickable regions
183 /// of the armed feature's dimension gizmo — the SAME regions
184 /// `dimension_arrow_pick` + `dimension_origin_pick` 2D-test the cursor against
185 /// ([`dimension_hit_regions`](Self::dimension_hit_regions)) — so the red
186 /// outline can NEVER drift from the grabbable area. Each item is a
187 /// `{ kind:"capsule", a:[x,y], b:[x,y], r }` (linear leaders) or
188 /// `{ kind:"circle", c:[x,y], r }` (origin / arc-handle spheres); the app only
189 /// offsets by `rect.min`. `[]` unless the DIMENSION gizmo is armed.
190 pub fn dimension_hit_areas_json(&self) -> String {
191 let regions = self.dimension_hit_regions();
192 super::camera_widgets::hit_shapes_json(regions.iter().map(|(_, shape)| shape))
193 }
194
195 /// Toggle the armed ◎ gizmo from TRANSFORM to DIMENSION for the currently
196 /// transform-armed feature (the orange center-sphere click). No-op unless a
197 /// feature is transform-armed.
198 pub fn toggle_to_dimension(&mut self) {
199 let feature = self.transform_armed_feature();
200 if !feature.is_empty() && self.feature_dimension_annotations_json(&feature) != "[]" {
201 self.arm_dimension(&feature);
202 }
203 }
204
205 /// Toggle the armed ◎ gizmo from DIMENSION to TRANSFORM for the currently
206 /// dimension-armed feature (the orange origin-sphere click). No-op unless a
207 /// feature is dimension-armed.
208 pub fn toggle_to_transform(&mut self) {
209 let feature = self.dimension_armed_feature();
210 if feature.is_empty() {
211 return;
212 }
213 // A PLANE has NO transform — its placement is fully the orientation +
214 // `offset_distance` the offset dimension gizmo drives — so it never gets a
215 // transform gizmo: the origin-sphere toggle stays in dimension mode. (Other
216 // dim features, e.g. a revolve, still toggle to their transform gizmo.)
217 if let Some(index) = self.history.index_of(&feature) {
218 if self.history.feature_type(index).as_deref() == Some("P") {
219 return;
220 }
221 }
222 self.arm_transform(&feature);
223 }
224
225 /// The linear dimension annotations for `feature_id` (resolving expression
226 /// params against the live history env first). `[]` for a feature type with
227 /// no FD-1 builder / a missing feature.
228 fn feature_dimension_annotations(
229 &self,
230 feature_id: &str,
231 ) -> Vec<crate::feature_dimensions::FeatureDimAnnotation> {
232 let Some(index) = self.history.index_of(feature_id) else {
233 return Vec::new();
234 };
235 let Some(feature_type) = self.history.feature_type(index) else {
236 return Vec::new();
237 };
238 let Some(params) = self.history.feature_params(index) else {
239 return Vec::new();
240 };
241 let resolved = self.resolve_param_expressions(¶ms);
242 // Resolve any scene references the builder needs (extrude profile plane,
243 // revolve axis line) from the run report's profiles/axes — keyed off the
244 // ORIGINAL params so reference-name strings are read verbatim.
245 let refs = self.feature_dimension_refs(&feature_type, ¶ms);
246 crate::feature_dimensions::build_annotations_with_refs(&feature_type, &resolved, &refs)
247 }
248
249 /// Resolve the scene references a feature-dimension builder needs beyond its
250 /// pure params: the extrude/revolve profile PLANE (center + normal) and the
251 /// revolve AXIS line. Sourced from the run report the engine already holds —
252 /// `sketch_profiles` (the sketch's world profile, which survives being
253 /// consumed by the extrude/revolve since only solids honor `removed`) and
254 /// `sketch_axes` (a sketch's published axis lines) — with resident-scene
255 /// fallbacks for a profile that is a solid FACE (its display-mesh plane, see
256 /// [`Self::lookup_profile_plane`]) and an axis that is a solid EDGE (its
257 /// polyline). Empty for any other feature type; missing pieces stay `None`
258 /// so the builder degrades to `[]` gracefully.
259 fn feature_dimension_refs(
260 &self,
261 feature_type: &str,
262 params: &serde_json::Value,
263 ) -> crate::feature_dimensions::ResolvedRefs {
264 let mut refs = crate::feature_dimensions::ResolvedRefs::default();
265 match feature_type {
266 "E" => {
267 if let Some((center, normal)) = self.lookup_profile_plane(params.get("profile")) {
268 refs.profile_center = Some(center);
269 refs.profile_normal = Some(normal);
270 }
271 }
272 "R" => {
273 if let Some((center, normal)) = self.lookup_profile_plane(params.get("profile")) {
274 refs.profile_center = Some(center);
275 refs.profile_normal = Some(normal);
276 }
277 if let Some((point, dir)) = self.lookup_axis_line(params.get("axis")) {
278 refs.axis_point = Some(point);
279 refs.axis_dir = Some(dir);
280 }
281 }
282 "P" => {
283 // The plane feature registers ONE scene frame under its own id; the
284 // offset dim hangs off that resolved plane (origin + z-axis normal).
285 if let Some(id) = params.get("id").and_then(|v| v.as_str()) {
286 if let Some((_, frame)) =
287 self.construction_frames.iter().find(|(name, _)| name == id)
288 {
289 refs.plane_origin = Some(vec3_to_arr(frame.origin));
290 refs.plane_normal = Some(fd_normalize3(vec3_to_arr(frame.z_axis)));
291 // Screen-constant handle stub for the offset ≈ 0 case (~48 px).
292 refs.plane_dim_length = Some(self.camera.world_per_pixel() * 48.0);
293 }
294 }
295 }
296 _ => {}
297 }
298 refs
299 }
300
301 /// Resolve a `profile` reference param to the world plane the extrude/revolve
302 /// gizmo hangs off, as `(center, unit normal)`. A SKETCH profile first (its
303 /// outer-loop centroid + `z_axis`), else a resident solid FACE named by the
304 /// reference (the area-weighted centroid + outward normal of its display
305 /// mesh) — the same two sources the kernel's extrude/revolve accept for
306 /// `profile` (`scene.resolve_profile`, then `resolve_face` →
307 /// `face_profile`, whose `z_axis` is likewise the OUTWARD face normal, so the
308 /// arc's `orient_revolve_axis` sign matches the kernel's sweep direction).
309 /// `None` when neither resolves — a face whose owning solid was later
310 /// consumed (a boolean target) is no longer in the scene, and the builder
311 /// then degrades to `[]` as before.
312 fn lookup_profile_plane(
313 &self,
314 profile_param: Option<&serde_json::Value>,
315 ) -> Option<([f64; 3], [f64; 3])> {
316 if let Some(profile) = self.lookup_sketch_profile(profile_param) {
317 return Some((sketch_profile_centroid(profile), vec3_to_arr(profile.z_axis)));
318 }
319 let name = first_reference_name(profile_param)?;
320 self.scene.face_plane_world(&name)
321 }
322
323 /// Resolve a `profile` reference param to the sketch profile the engine holds
324 /// (exact name, or the `:PROFILE`-suffixed form — mirrors `SceneMap::resolve_profile`).
325 fn lookup_sketch_profile(
326 &self,
327 profile_param: Option<&serde_json::Value>,
328 ) -> Option<&brep_kernel::SketchProfile> {
329 let name = first_reference_name(profile_param)?;
330 // A committed sketch is surfaced as a render display sheet aliased
331 // `{sketch}:FACE`, and profile consumers may reference the `{sketch}:PROFILE`
332 // form; both alias the base sketch id the run report keys `sketch_profiles`
333 // by. Strip either so the extrude/revolve gizmo resolves the same profile the
334 // kernel does (mirrors `common::normalize_profile_alias` / `resolve_profile`).
335 let base = name
336 .strip_suffix(":FACE")
337 .or_else(|| name.strip_suffix(":PROFILE"))
338 .unwrap_or(&name);
339 self.sketch_profiles
340 .iter()
341 .find(|(id, _)| id == &name || id == base)
342 .map(|(_, profile)| profile)
343 }
344
345 /// Resolve an `axis` reference param to a world line `(point, unit direction)`:
346 /// a published sketch axis first (`sketch_axes`), else a resident solid EDGE's
347 /// polyline endpoints (`scene.edge_polyline_world`). `None` if neither resolves.
348 fn lookup_axis_line(
349 &self,
350 axis_param: Option<&serde_json::Value>,
351 ) -> Option<([f64; 3], [f64; 3])> {
352 let name = first_reference_name(axis_param)?;
353 if let Some((_, axis)) = self.sketch_axes.iter().find(|(id, _)| id == &name) {
354 let dir = fd_normalize3(vec3_to_arr(axis.direction));
355 return Some((vec3_to_arr(axis.point), dir));
356 }
357 // Fallback: a resident edge used as an axis — take its polyline endpoints.
358 let poly = self.scene.edge_polyline_world(&name)?;
359 let first = *poly.first()?;
360 let last = *poly.last()?;
361 let dir = fd_sub3(last, first);
362 if fd_norm3(dir) < 1e-9 {
363 return None;
364 }
365 Some((first, fd_normalize3(dir)))
366 }
367
368 /// A copy of `params` with each top-level STRING field evaluated against the
369 /// history's `expressions` + `configurator` (the kernel `eval_expression`) and
370 /// replaced by its finite numeric result — so an expression-valued param
371 /// (e.g. `sizeX: "a + b"`) places its dimension at the resolved length.
372 /// Non-numeric strings (ids, enum options) fail to eval and stay verbatim.
373 fn resolve_param_expressions(&self, params: &serde_json::Value) -> serde_json::Value {
374 let expressions = self.history.expressions();
375 let configurator = self.history.configurator();
376 let mut out = params.clone();
377 if let Some(object) = out.as_object_mut() {
378 for value in object.values_mut() {
379 if let Some(source) = value.as_str() {
380 if let Ok(number) =
381 brep_kernel::eval_expression(&expressions, &configurator, source)
382 {
383 if number.is_finite() {
384 *value = serde_json::json!(number);
385 }
386 }
387 }
388 }
389 }
390 out
391 }
392
393 /// The dimension annotations for `feature_id` as JSON:
394 /// `[{ fieldKey, pointA, pointB, value, label, mid }]` (world-space points;
395 /// `mid` is the leader midpoint the app anchors the label at). `[]` when the
396 /// feature type has no FD-1 builder.
397 pub fn feature_dimension_annotations_json(&self, feature_id: &str) -> String {
398 use crate::feature_dimensions::FeatureDimKind;
399 let annotations = self.feature_dimension_annotations(feature_id);
400 let wpp = self.camera.world_per_pixel();
401 let out: Vec<serde_json::Value> = annotations
402 .iter()
403 .map(|a| {
404 // The chip anchor: a linear leader's midpoint, or an angular arc's
405 // mid-sweep point at the screen-constant radius (camera-dependent,
406 // so computed here with the live `world_per_pixel`). `kind` lets the
407 // app format the chip (`A 234°` for an angular value in DEGREES).
408 let (kind, mid) = match a.kind {
409 FeatureDimKind::Linear => ("linear", a.midpoint()),
410 FeatureDimKind::Angular => {
411 ("angular", crate::feature_dimensions::angular_chip_anchor(a, wpp))
412 }
413 };
414 serde_json::json!({
415 "fieldKey": a.field_key,
416 "pointA": a.point_a,
417 "pointB": a.point_b,
418 "value": a.value,
419 "label": a.label,
420 "mid": mid,
421 "kind": kind,
422 })
423 })
424 .collect();
425 serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
426 }
427
428 /// The `{ mode, feature, annotations }` snapshot the headless verifier reads
429 /// (published as `__brepFeatureDim`).
430 pub fn feature_dimension_state_json(&self) -> String {
431 let feature = self.dimension_armed_feature();
432 let annotations: serde_json::Value = if feature.is_empty() {
433 serde_json::json!([])
434 } else {
435 serde_json::from_str(&self.feature_dimension_annotations_json(&feature))
436 .unwrap_or_else(|_| serde_json::json!([]))
437 };
438 serde_json::json!({
439 "mode": self.gizmo_mode(),
440 "feature": feature,
441 "annotations": annotations,
442 })
443 .to_string()
444 }
445
446 /// The `set_overlay` JSON for the `feature-dim-leaders` group — the annotation
447 /// leaders + arrowheads for the dimension-armed feature (empty when not in
448 /// dimension mode, so a stale group is cleared).
449 fn feature_dimension_overlay_json(&self) -> String {
450 let feature = self.dimension_armed_feature();
451 let annotations = if feature.is_empty() {
452 Vec::new()
453 } else {
454 self.feature_dimension_annotations(&feature)
455 };
456 let (positions, colors) = crate::feature_dimensions::leaders_buffers(
457 &annotations,
458 self.camera.world_per_pixel(),
459 );
460 serde_json::json!({
461 "groups": [
462 {
463 "name": FEATURE_DIM_OVERLAY,
464 "renderOrder": 10003,
465 "tris": { "positions": positions, "colors": colors },
466 }
467 ]
468 })
469 .to_string()
470 }
471
472 /// (Re)project the dimension leaders onto the current geometry. Called on arm
473 /// + after every param change (drag / value edit / rerun in dimension mode)
474 /// + on a material ZOOM ([`Self::ensure_feature_dimension_overlay_current`]).
475 /// Remembers the `world_per_pixel` it baked at, which is what lets that
476 /// per-frame ensure fire on change ONLY.
477 pub fn refresh_feature_dimension_overlay(&mut self) {
478 let json = self.feature_dimension_overlay_json();
479 let _ = self.set_overlay_json(&json);
480 let wpp = self.camera.world_per_pixel();
481 self.feature_dim_overlay_wpp = if wpp > 0.0 { wpp } else { f64::MIN_POSITIVE };
482 }
483
484 /// Clear the dimension overlay (an empty group), e.g. when disarming or
485 /// switching to transform mode.
486 pub(super) fn clear_feature_dimension_overlay(&mut self) {
487 let _ = self.set_overlay_json(&serde_json::json!({
488 "groups": [ { "name": FEATURE_DIM_OVERLAY } ]
489 }).to_string());
490 self.feature_dim_overlay_wpp = 0.0;
491 }
492
493 /// Per-frame upkeep for the DIMENSION gizmo (driven by
494 /// [`Self::ensure_overlays_current`]).
495 ///
496 /// The group is baked into pre-expanded vertices at feed time, and its
497 /// rod/cone/origin-sphere sizing — plus the angular arc's entire world
498 /// RADIUS (`ANGLE_ARC_RAD_PX × world_per_pixel`) — is screen-constant. So a
499 /// zoom that is not followed by a re-bake leaves the handles drawn at the old
500 /// pixel size, and the angular sweep handle drawn at the old world position
501 /// while [`Self::dimension_hit_regions`] (which projects against the LIVE
502 /// camera) grabs at the new one: the outline and the drawn handle drift apart.
503 ///
504 /// Re-bakes ONLY on a material `world_per_pixel` change
505 /// ([`overlay_wpp_stale`](super::overlay_wpp_stale)) — a quiet frame does not
506 /// touch the overlay, so there is no per-frame dirty loop. Nothing armed →
507 /// nothing baked, and the remembered zoom is dropped so re-arming re-bakes.
508 pub(super) fn ensure_feature_dimension_overlay_current(&mut self) {
509 if !matches!(self.transform_gizmo.mode, GizmoMode::Dimension)
510 || self.dimension_armed_feature().is_empty()
511 {
512 self.feature_dim_overlay_wpp = 0.0;
513 return;
514 }
515 let wpp = self.camera.world_per_pixel();
516 if super::overlay_wpp_stale(self.feature_dim_overlay_wpp, wpp) {
517 self.refresh_feature_dimension_overlay();
518 }
519 }
520
521 /// Drag a dimension handle: project the pointer pixel `(x, y)` onto the
522 /// annotation's world axis (`pointA → pointB`), take the distance along the
523 /// axis from `pointA` as the new value (correcting for any transform scale so
524 /// the PARAM — not the scaled world length — is what changes), set the param,
525 /// and re-run the history live. Degenerate projections (parallel ray / zero
526 /// axis) no-op.
527 pub fn feature_dimension_drag(&mut self, feature_id: &str, field_key: &str, x: f64, y: f64) {
528 let annotations = self.feature_dimension_annotations(feature_id);
529 let Some(annotation) = annotations.iter().find(|a| a.field_key == field_key) else {
530 return;
531 };
532 if annotation.kind == crate::feature_dimensions::FeatureDimKind::Angular {
533 if let Some(degrees) = self.angular_drag_degrees(annotation, x, y) {
534 self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(degrees));
535 // Live-follow: re-bake the world-space leaders onto the rebuilt
536 // geometry so the arc tracks the pointer this frame (Fix 3).
537 self.refresh_feature_dimension_overlay();
538 }
539 return;
540 }
541 let a = annotation.point_a;
542 let b = annotation.point_b;
543 let axis = fd_sub3(b, a);
544 let len = fd_norm3(axis);
545 if len < 1e-9 {
546 return;
547 }
548 let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
549 let ray = self.camera.pick_ray(x, y);
550 let ray_dir = fd_normalize3(ray.dir);
551 let Some(t_world) = closest_t_on_axis(a, dir, ray.origin, ray_dir) else {
552 return;
553 };
554 // World distance → param value: correct for the local axis scale via the
555 // CURRENT ratio (world length / current param). Under unit scale this is
556 // the identity; when the param is ~0 there is no ratio, so use the world
557 // distance directly (unit-scale assumption).
558 let scale_recip = if annotation.value.abs() > 1e-9 && len > 1e-9 {
559 annotation.value / len
560 } else {
561 1.0
562 };
563 // Preserve SIGN so a linear dim can be dragged through the origin to the
564 // negative side (a directional dim — cube size, height — then extends the
565 // other way; the kernel takes |value| for magnitude dims). A small dead-zone
566 // keeps it off an exact 0 (a degenerate extent the builders reject).
567 let raw = t_world * scale_recip;
568 let new_value = if raw >= 0.0 { raw.max(1e-4) } else { raw.min(-1e-4) };
569 self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(new_value));
570 // Live-follow: re-bake the world-space leaders onto the rebuilt geometry so
571 // the arrow tracks the pointer this frame (Fix 3).
572 self.refresh_feature_dimension_overlay();
573 }
574
575 /// Map a pointer pixel to a swept angle (DEGREES) for an ANGULAR annotation:
576 /// search the sweep for the degree whose arc-end projects nearest the pointer
577 /// (a coarse 2° pass, then a ±2° refine at 0.25°), snap to 1°, clamp to
578 /// `[-360, 360]`. Ported from the overlay `angle` drag. The magnitude is
579 /// floored off exactly 0 so a torus `arc` drag never lands on 0 — which the
580 /// kernel's `|| 360` falsy fallback would flip to a FULL torus mid-drag.
581 /// `None` if the arc never projects in front of the camera.
582 ///
583 /// `pub(super)`: the assembly-constraint angle-arc drag
584 /// (`assembly_overlay.rs`) maps its pointer through this SAME search so the
585 /// two angle gizmos share one drag feel.
586 pub(super) fn angular_drag_degrees(
587 &self,
588 ann: &crate::feature_dimensions::FeatureDimAnnotation,
589 x: f64,
590 y: f64,
591 ) -> Option<f64> {
592 let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * self.camera.world_per_pixel();
593 if radius <= 1e-9 {
594 return None;
595 }
596 // A sweep of `deg` and `deg - 360` share the SAME arc-end world point, so
597 // the screen-nearest search alone can't tell them apart at the wrap. Break
598 // the tie toward the CURRENT value (angle-unwrap
599 // continuity) with a tiny bias `~1e-6·Δ°²` — decisive only when screen
600 // errors are essentially equal, negligible against any real pointer move.
601 let current = ann.value;
602 let combined = |screen_err: f64, deg: f64| -> f64 {
603 let d = deg - current;
604 screen_err + 1e-6 * d * d
605 };
606 let mut best_deg = current;
607 let mut best_err = f64::INFINITY;
608 // Coarse sweep over the full range.
609 let mut deg = -360.0;
610 while deg <= 360.0 {
611 if let Some(err) = self.angle_arc_end_err(ann, radius, deg, x, y) {
612 let err = combined(err, deg);
613 if err < best_err {
614 best_err = err;
615 best_deg = deg;
616 }
617 }
618 deg += 2.0;
619 }
620 if !best_err.is_finite() {
621 return None;
622 }
623 // Refine around the coarse best.
624 let center = best_deg;
625 let mut deg = center - 2.0;
626 while deg <= center + 2.0 {
627 if (-360.0..=360.0).contains(°) {
628 if let Some(err) = self.angle_arc_end_err(ann, radius, deg, x, y) {
629 let err = combined(err, deg);
630 if err < best_err {
631 best_err = err;
632 best_deg = deg;
633 }
634 }
635 }
636 deg += 0.25;
637 }
638 let clamped = best_deg.round().clamp(-360.0, 360.0);
639 let floored = if clamped.abs() < 0.1 {
640 if clamped < 0.0 { -0.1 } else { 0.1 }
641 } else {
642 clamped
643 };
644 Some(floored)
645 }
646
647 /// Squared screen-pixel distance from `(x, y)` to the arc-end at `deg` for an
648 /// angular annotation (`center + rotate(ref_dir, axis, deg) * radius`), or
649 /// `None` when that point is behind the camera.
650 fn angle_arc_end_err(
651 &self,
652 ann: &crate::feature_dimensions::FeatureDimAnnotation,
653 radius: f64,
654 deg: f64,
655 x: f64,
656 y: f64,
657 ) -> Option<f64> {
658 let dir = fd_normalize3(crate::feature_dimensions::rotate_about_axis(
659 ann.ref_dir,
660 ann.axis,
661 deg.to_radians(),
662 ));
663 let p = [
664 ann.center[0] + dir[0] * radius,
665 ann.center[1] + dir[1] * radius,
666 ann.center[2] + dir[2] * radius,
667 ];
668 let (sx, sy, depth) = self.camera.project(p);
669 if depth <= 0.0 {
670 return None;
671 }
672 Some((sx - x) * (sx - x) + (sy - y) * (sy - y))
673 }
674
675 /// Edit a dimension value from a label field: a plain numeric literal sets the
676 /// param to that number; otherwise the input is treated as an EXPRESSION —
677 /// evaluated LIVE against the history's `expressions` + `configurator` (the
678 /// kernel `eval_expression`) and, on success, STORED as the expression string
679 /// (the kernel re-evaluates it via `ctx.number`, so it stays live). A blank /
680 /// bad-expression input no-ops (never corrupts the feature). Re-runs live.
681 pub fn feature_dimension_set_value(&mut self, feature_id: &str, field_key: &str, input: &str) {
682 let trimmed = input.trim();
683 if trimmed.is_empty() {
684 return;
685 }
686 if is_plain_number_literal(trimmed) {
687 let Ok(number) = trimmed.parse::<f64>() else {
688 return;
689 };
690 if !number.is_finite() {
691 return;
692 }
693 self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(number));
694 } else {
695 // Validate the expression before storing it (a bad expression no-ops).
696 let expressions = self.history.expressions();
697 let configurator = self.history.configurator();
698 match brep_kernel::eval_expression(&expressions, &configurator, trimmed) {
699 Ok(number) if number.is_finite() => {
700 self.write_feature_dimension_param(
701 feature_id,
702 field_key,
703 serde_json::Value::String(trimmed.to_string()),
704 );
705 }
706 _ => {}
707 }
708 }
709 }
710
711 /// Set one `inputParams` field of `feature_id` (a number or an expression
712 /// string) and re-run the history (which re-projects the leaders in dimension
713 /// mode). No-op when the feature is absent.
714 fn write_feature_dimension_param(
715 &mut self,
716 feature_id: &str,
717 field_key: &str,
718 value: serde_json::Value,
719 ) {
720 let Some(index) = self.history.index_of(feature_id) else {
721 return;
722 };
723 let mut params = self
724 .history
725 .feature_params(index)
726 .unwrap_or_else(|| serde_json::json!({}));
727 let Some(object) = params.as_object_mut() else {
728 return;
729 };
730 object.insert(field_key.to_string(), value);
731 let _ = self.update_feature_params(feature_id, ¶ms.to_string());
732 }
733}
734
735// --- FD-1 geometry helpers (self-contained, `fd_` prefixed to avoid clashes) ---
736
737fn fd_sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
738 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
739}
740
741fn fd_dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
742 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
743}
744
745fn fd_norm3(v: [f64; 3]) -> f64 {
746 fd_dot3(v, v).sqrt()
747}
748
749fn fd_normalize3(v: [f64; 3]) -> [f64; 3] {
750 let n = fd_norm3(v);
751 if n < 1e-12 {
752 [0.0, 0.0, 1.0]
753 } else {
754 [v[0] / n, v[1] / n, v[2] / n]
755 }
756}
757
758/// Push an origin-ball region at world `p` unless a ball already sits there (dedup
759/// by world position, matching `leaders_buffers`' single drawn sphere). The shared
760/// point-region builder projects it to a screen circle; a behind-eye (perspective)
761/// origin is omitted — invisible, so not grabbable — matching the outline.
762fn push_origin_region(
763 out: &mut Vec<(DimRole, HitShape)>,
764 seen: &mut Vec<[f64; 3]>,
765 cam: &crate::view::ViewCamera,
766 p: [f64; 3],
767 px: f32,
768) {
769 if seen.iter().any(|o| fd_norm3(fd_sub3(*o, p)) < 1e-6) {
770 return;
771 }
772 seen.push(p);
773 if let Some(shape) = point_region(cam, p, px) {
774 out.push((DimRole::Origin, shape));
775 }
776}
777
778/// The parameter `t` of the point on the axis line `a + t*dir` (dir UNIT) closest
779/// to the ray `ray_o + s*ray_d` (ray_d UNIT). `None` when the two are parallel
780/// (no well-defined projection). `t` is the signed world distance along `dir`
781/// from `a`. (`pub(super)`: shared with the constraint distance-arrow drag.)
782pub(super) fn closest_t_on_axis(
783 a: [f64; 3],
784 dir: [f64; 3],
785 ray_o: [f64; 3],
786 ray_d: [f64; 3],
787) -> Option<f64> {
788 let w0 = fd_sub3(a, ray_o);
789 let b = fd_dot3(dir, ray_d);
790 let d = fd_dot3(dir, w0);
791 let e = fd_dot3(ray_d, w0);
792 let denom = 1.0 - b * b;
793 if denom.abs() < 1e-9 {
794 return None;
795 }
796 Some((b * e - d) / denom)
797}
798
799fn vec3_to_arr(v: brep_kernel::Vec3) -> [f64; 3] {
800 [v.x, v.y, v.z]
801}
802
803/// The world CENTROID of a sketch profile — the average of its outer-loop curve
804/// start points (the profile-polygon vertices), approximating the previous
805/// face-average-center computation. Falls back to the sketch plane origin when
806/// no outer loop is available. Used to anchor the extrude/revolve gizmos on the
807/// geometry rather than at a possibly-far sketch-plane origin.
808fn sketch_profile_centroid(profile: &brep_kernel::SketchProfile) -> [f64; 3] {
809 let mut sum = [0.0f64; 3];
810 let mut count = 0usize;
811 if let Some(outer) = profile.regions.first().and_then(|region| region.first()) {
812 for curve in &outer.curves {
813 if let Ok(domain) = curve.domain() {
814 if let Ok(point) = curve.evaluate(domain[0]) {
815 sum[0] += point.x;
816 sum[1] += point.y;
817 sum[2] += point.z;
818 count += 1;
819 }
820 }
821 }
822 }
823 if count > 0 {
824 [sum[0] / count as f64, sum[1] / count as f64, sum[2] / count as f64]
825 } else {
826 vec3_to_arr(profile.origin)
827 }
828}
829
830// BREP private tests: e42e8dd362af6efd