Skip to main content

brep_render/engine_state/
context_pick.rs

1//! Picking the Work part's CONTEXT: the surrounding assembly, drawn
2//! translucent in the part's frame ([`EngineState::context_scene`]). A context
3//! face can be clicked and hovered, and lights in the selection colours, but it
4//! is held apart from [`EngineState::emphasis`]: the part's own kernel cannot
5//! resolve a context name, so no feature, reference, plugin or metadata write
6//! may ever receive one. Copying its geometry (a datum on a face, edges into a
7//! sketch) is what turns it into something the part owns.
8
9use super::*;
10use crate::pick::{PickCandidate, PickKind};
11
12/// How the Work part's context is shown: on or off, and how opaque.
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub(crate) struct ContextDisplay {
15    pub shown: bool,
16    pub opacity: f32,
17}
18
19impl Default for ContextDisplay {
20    fn default() -> Self {
21        Self { shown: true, opacity: crate::render::WORK_PART_CONTEXT_RGBA[3] }
22    }
23}
24
25impl EngineState {
26    /// Whether the context is shown, and its opacity (0..=1).
27    pub fn context_display(&self) -> (bool, f32) {
28        (self.context_display.shown, self.context_display.opacity)
29    }
30
31    /// Show or hide the context and set its opacity (clamped to 0.05..=1).
32    /// Hidden context is neither drawn nor picked, and drops its selection
33    /// and hover. Kept across context rebuilds.
34    pub fn set_context_display(&mut self, shown: bool, opacity: f32) {
35        let display = ContextDisplay { shown, opacity: opacity.clamp(0.05, 1.0) };
36        if display != self.context_display {
37            self.context_display = display;
38            if !shown {
39                self.context_selected = None;
40                self.context_hovered = None;
41            }
42            self.sync_context_highlight();
43        }
44    }
45
46    /// The context FACE under CSS-pixel `(x, y)` when it is in front of the
47    /// part's own geometry there (or the part has nothing there), else `None`.
48    pub fn context_hit_at(&self, x: f64, y: f64) -> Option<PickCandidate> {
49        if self.context_scene.solids().is_empty() {
50            return None;
51        }
52        let options = self.pick_options();
53        let front = pick::pick(&self.context_scene, &self.camera, x, y, &options)
54            .into_iter()
55            .filter(|candidate| candidate.kind == PickKind::Face)
56            .min_by(|a, b| a.depth.total_cmp(&b.depth))?;
57        let own = pick::nearest_face_depth(&self.scene, &self.camera, x, y, &options);
58        // A face flush with the context (a mating face) belongs to the part.
59        let tolerance = 1e-6 * front.depth.abs().max(1.0) + 1e-4;
60        match own {
61            Some(depth) if depth <= front.depth + tolerance => None,
62            _ => Some(front),
63        }
64    }
65
66    /// The context EDGE under CSS-pixel `(x, y)`, unless the part's own
67    /// geometry is clearly in front of it (an edge flush with the part, a
68    /// mating edge, still counts).
69    pub fn context_edge_at(&self, x: f64, y: f64) -> Option<PickCandidate> {
70        if self.context_scene.solids().is_empty() {
71            return None;
72        }
73        let options = self.pick_options();
74        let edge = pick::pick(&self.context_scene, &self.camera, x, y, &options)
75            .into_iter()
76            .filter(|candidate| candidate.kind == PickKind::Edge && !candidate.name.is_empty())
77            .min_by(|a, b| a.screen_dist.total_cmp(&b.screen_dist).then(a.depth.total_cmp(&b.depth)))?;
78        let own = pick::nearest_face_depth(&self.scene, &self.camera, x, y, &options);
79        let tolerance = 1e-6 * edge.depth.abs().max(1.0) + 1e-4;
80        match own {
81            Some(depth) if depth + tolerance < edge.depth => None,
82            _ => Some(edge),
83        }
84    }
85
86    /// The sketch pickEdges tool on a Work part's context edge: copy it into
87    /// the active sketch as plain lines / arcs / a circle
88    /// ([`crate::sketch::external_ref::add_copied_edge`]), each noted "copied
89    /// from `<part>`, not linked". Nothing links it back: if that part moves,
90    /// the copy stays where it is. One undo step. Returns whether it copied.
91    pub fn sketch_copy_context_edge(&mut self, edge: &PickCandidate) -> bool {
92        let Some(polyline) = self
93            .context_scene
94            .solid(&edge.solid)
95            .and_then(|solid| solid.edges.iter().find(|e| e.name == edge.name))
96            .map(|e| e.polyline.iter().map(|p| p.map(f64::from)).collect::<Vec<_>>())
97            .filter(|polyline| polyline.len() >= 2)
98        else {
99            return false;
100        };
101        let part = part_label(&edge.solid);
102        let Some(sketch) = self.sketch_edit.as_mut() else {
103            return false;
104        };
105        let uv = crate::sketch::external_ref::project_polyline(&sketch.session.plane, &polyline);
106        let link = crate::sketch::external_ref::classify_uv(&uv);
107        sketch.record_undo();
108        let added = crate::sketch::external_ref::add_copied_edge(&mut sketch.session.doc, &link, &format!("copied from {part}, not linked"));
109        if added.is_empty() {
110            sketch.undo_stack.pop();
111            return false;
112        }
113        self.resolve_active_sketch("copy context edge");
114        self.refresh_sketch_overlay();
115        self.hover_context(None);
116        self.push_notice_as(
117            NoticeSeverity::Info,
118            format!("Copied an edge of {part} into the sketch as a {}: copied, not linked (it stays where it is if {part} moves)", link.kind()),
119        );
120        self.dirty = true;
121        true
122    }
123
124    /// Select `candidate` (a [`Self::context_hit_at`] answer) as THE context
125    /// selection, or clear it. `true` when it changed.
126    pub fn select_context(&mut self, candidate: Option<PickCandidate>) -> bool {
127        let changed = !same_entity(self.context_selected.as_ref(), candidate.as_ref());
128        self.context_selected = candidate;
129        if changed {
130            self.sync_context_highlight();
131        }
132        changed
133    }
134
135    /// Light `candidate` as hovered context, or clear the hover. `true` when it
136    /// changed.
137    pub fn hover_context(&mut self, candidate: Option<PickCandidate>) -> bool {
138        let changed = !same_entity(self.context_hovered.as_ref(), candidate.as_ref());
139        self.context_hovered = candidate;
140        if changed {
141            self.sync_context_highlight();
142        }
143        changed
144    }
145
146    /// The context selection, if any.
147    pub fn context_selection(&self) -> Option<&PickCandidate> {
148        self.context_selected.as_ref()
149    }
150
151    /// Put the context selection and hover into the context scene's highlight
152    /// list, dropping either whose face the (possibly rebuilt) context no
153    /// longer has. Call after replacing [`Self::context_scene`].
154    pub fn sync_context_highlight(&mut self) {
155        let has = |scene: &RenderScene, c: &PickCandidate| {
156            scene.solid(&c.solid).is_some_and(|solid| match c.kind {
157                PickKind::Edge => solid.edges.iter().any(|e| e.name == c.name),
158                _ => solid.faces.iter().any(|f| f.name == c.name),
159            })
160        };
161        if self.context_selected.as_ref().is_some_and(|c| !has(&self.context_scene, c)) {
162            self.context_selected = None;
163        }
164        if self.context_hovered.as_ref().is_some_and(|c| !has(&self.context_scene, c)) {
165            self.context_hovered = None;
166        }
167        let mut highlight = Vec::new();
168        if let Some(c) = &self.context_hovered {
169            highlight.push((c.solid.clone(), c.name.clone(), false));
170        }
171        if let Some(c) = &self.context_selected {
172            highlight.push((c.solid.clone(), c.name.clone(), true));
173        }
174        // A ghost a running instructions pick names is lit whole.
175        for name in &self.context_pick_lit {
176            if let Some(ghost) = self.context_scene.solid(name) {
177                highlight.extend(ghost.faces.iter().map(|face| (name.clone(), face.name.clone(), true)));
178            }
179        }
180        if self.context_scene.highlight != highlight {
181            self.context_scene.highlight = highlight;
182            self.dirty = true;
183        }
184        // The display settings, onto a (possibly rebuilt) context. Every ghost
185        // comes from a visible assembly solid, so `shown` is each one's own.
186        let [r, g, b, _] = crate::render::WORK_PART_CONTEXT_RGBA;
187        let tint = Some([r, g, b, self.context_display.opacity]);
188        if self.context_scene.tint != tint {
189            self.context_scene.tint = tint;
190            self.dirty = true;
191        }
192        let shown = self.context_display.shown;
193        let flip: Vec<String> = self.context_scene.solids().iter().filter(|solid| solid.visible != shown).map(|solid| solid.name.clone()).collect();
194        for name in flip {
195            if let Some(solid) = self.context_scene.solid_mut(&name) {
196                solid.visible = shown;
197                self.dirty = true;
198            }
199        }
200    }
201
202    /// The plane tangent to the selected context face at the point it was
203    /// picked: `(origin, unit normal)`, in the part's frame. For a flat face it
204    /// is the face's own plane; for a curved one, the plane touching it there.
205    pub fn context_tangent_plane(&self) -> Result<([f64; 3], [f64; 3]), String> {
206        let picked = self.context_selected.as_ref().ok_or("select a face of another part first")?;
207        let solid = self.context_scene.solid(&picked.solid).ok_or("that part is no longer shown")?;
208        let face = solid.faces.iter().find(|face| face.name == picked.name).ok_or("that face is no longer shown")?;
209        let mesh = &solid.mesh;
210        let at = |index: u32| mesh.positions[index as usize].map(f64::from);
211        let mut best: Option<(f64, [f64; 3])> = None;
212        for triangle in face.tri_start..face.tri_start + face.tri_count {
213            let base = triangle as usize * 3;
214            let Some(&[i0, i1, i2]) = mesh.indices.get(base..base + 3).map(|s| <&[u32; 3]>::try_from(s).unwrap()) else { continue };
215            let (a, b, c) = (at(i0), at(i1), at(i2));
216            let mut normal = cross(sub(b, a), sub(c, a));
217            let length = dot(normal, normal).sqrt();
218            if length < 1e-12 {
219                continue;
220            }
221            normal = normal.map(|v| v / length);
222            // Outward as the shading normals say.
223            let shading = [i0, i1, i2].iter().filter_map(|&i| mesh.normals.get(i as usize)).fold([0.0; 3], |acc, n| [acc[0] + n[0] as f64, acc[1] + n[1] as f64, acc[2] + n[2] as f64]);
224            if dot(normal, shading) < 0.0 {
225                normal = normal.map(|v| -v);
226            }
227            let distance = distance_to_triangle(picked.position, a, b, c);
228            if best.is_none_or(|(d, _)| distance < d) {
229                best = Some((distance, normal));
230            }
231        }
232        let (_, normal) = best.ok_or("that face has no shape to place a plane on")?;
233        Ok((picked.position, normal))
234    }
235
236    /// "Datum here": a datum (`D`) on the plane tangent to the selected context
237    /// face at the picked point, named for the part it was copied from. It is a
238    /// COPY: if that part moves, the datum stays where it is. Returns its id.
239    pub fn datum_on_context(&mut self) -> Result<String, String> {
240        let picked = self.context_selected.clone().ok_or("select a face of another part first")?;
241        let (origin, normal) = self.context_tangent_plane()?;
242        let part = part_label(&picked.solid);
243        let id = self.history.next_feature_id(&format!("Copied{}", part.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>()));
244        let (a, b) = datum_angles(normal);
245        let feature = serde_json::json!({
246            "type": "D",
247            "inputParams": {
248                "id": id,
249                "transform": {
250                    "position": origin,
251                    "rotationEuler": [a.to_degrees(), b.to_degrees(), 0.0],
252                    "scale": [1.0, 1.0, 1.0],
253                },
254            },
255            "persistentData": {},
256        });
257        self.add_feature(&feature.to_string())?;
258        self.select_context(None);
259        self.hover_context(None);
260        self.push_notice_as(
261            NoticeSeverity::Info,
262            format!("Datum {id} placed on a face of {part}: copied, not linked (it stays where it is if {part} moves)"),
263        );
264        Ok(id)
265    }
266
267    /// "Sketch on this face": [`Self::datum_on_context`], then a new sketch on
268    /// its XY plane. Returns the sketch's id.
269    pub fn sketch_on_context(&mut self) -> Result<String, String> {
270        let (origin, normal) = self.context_tangent_plane()?;
271        let datum = self.datum_on_context()?;
272        // The datum's own XY frame (its base axes turned by Rx(a)·Ry(b)), so
273        // the sketch's axes are the plane's, not re-derived.
274        let (a, b) = datum_angles(normal);
275        let frame = crate::sketch::PlaneFrame {
276            origin,
277            x_axis: [b.cos(), a.sin() * b.sin(), -a.cos() * b.sin()],
278            y_axis: [0.0, a.cos(), a.sin()],
279            z_axis: [b.sin(), -b.cos() * a.sin(), a.cos() * b.cos()],
280        };
281        self.new_sketch_on(&format!("{datum}:XY"), frame)
282    }
283
284    /// `{"solid", "face"}` of the context selection, or `null` (automation).
285    pub fn context_selection_json(&self) -> serde_json::Value {
286        entity_json(self.context_selected.as_ref())
287    }
288
289    /// `{"solid", "face"}` of the hovered context face, or `null` (automation).
290    pub fn context_hover_json(&self) -> serde_json::Value {
291        entity_json(self.context_hovered.as_ref())
292    }
293}
294
295/// The datum `rotationEuler` (radians, X then Y; Z = 0) that turns its XY
296/// plane's normal +Z onto `normal`: the kernel's `rotate_euler_xyz` maps +Z to
297/// `(sin b, −cos b·sin a, cos a·cos b)`.
298fn datum_angles(normal: [f64; 3]) -> (f64, f64) {
299    ((-normal[1]).atan2(normal[2]), normal[0].clamp(-1.0, 1.0).asin())
300}
301
302/// The part a context solid belongs to, for messages: `ACOMP2:Plate` → `Plate`.
303fn part_label(solid: &str) -> String {
304    solid.rsplit(':').next().unwrap_or(solid).to_string()
305}
306
307fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
308    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
309}
310
311fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
312    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
313}
314
315fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
316    [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
317}
318
319/// Distance from `p` to triangle `abc` (closest point on the triangle).
320fn distance_to_triangle(p: [f64; 3], a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> f64 {
321    let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a));
322    let (d1, d2) = (dot(ab, ap), dot(ac, ap));
323    let closest = if d1 <= 0.0 && d2 <= 0.0 {
324        a
325    } else {
326        let bp = sub(p, b);
327        let (d3, d4) = (dot(ab, bp), dot(ac, bp));
328        let cp = sub(p, c);
329        let (d5, d6) = (dot(ab, cp), dot(ac, cp));
330        let (vc, vb, va) = (d1 * d4 - d3 * d2, d5 * d2 - d1 * d6, d3 * d6 - d5 * d4);
331        if d3 >= 0.0 && d4 <= d3 {
332            b
333        } else if d6 >= 0.0 && d5 <= d6 {
334            c
335        } else if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
336            let v = d1 / (d1 - d3);
337            [a[0] + ab[0] * v, a[1] + ab[1] * v, a[2] + ab[2] * v]
338        } else if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
339            let w = d2 / (d2 - d6);
340            [a[0] + ac[0] * w, a[1] + ac[1] * w, a[2] + ac[2] * w]
341        } else if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
342            let w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
343            let bc = sub(c, b);
344            [b[0] + bc[0] * w, b[1] + bc[1] * w, b[2] + bc[2] * w]
345        } else {
346            let denom = 1.0 / (va + vb + vc);
347            let (v, w) = (vb * denom, vc * denom);
348            [a[0] + ab[0] * v + ac[0] * w, a[1] + ab[1] * v + ac[1] * w, a[2] + ab[2] * v + ac[2] * w]
349        }
350    };
351    let d = sub(p, closest);
352    dot(d, d).sqrt()
353}
354
355fn entity_json(candidate: Option<&PickCandidate>) -> serde_json::Value {
356    match candidate {
357        Some(c) if c.kind == PickKind::Edge => serde_json::json!({ "solid": c.solid, "edge": c.name }),
358        Some(c) => serde_json::json!({ "solid": c.solid, "face": c.name }),
359        None => serde_json::Value::Null,
360    }
361}
362
363fn same_entity(a: Option<&PickCandidate>, b: Option<&PickCandidate>) -> bool {
364    match (a, b) {
365        (Some(a), Some(b)) => a.solid == b.solid && a.name == b.name && a.kind == b.kind,
366        (None, None) => true,
367        _ => false,
368    }
369}
370