Skip to main content

brep_render/sketch3d/
mod.rs

1//! The engine-native 3D SKETCH editor's session: the solved document, its
2//! diagnostics, the interaction state (selection, hover, armed tool, working
3//! plane), screen-space picking and the overlay tessellation.
4//!
5//! The planar sketcher (`crate::sketch`) lives in a plane's `(u, v)` frame
6//! and hit-tests there. A 3D sketch has no such frame: its points are world
7//! points, so picking is done in SCREEN space (every point and polyline
8//! sample projected through the live camera, distances measured in pixels)
9//! and a click lands in 3D through the WORKING PLANE — one of the three world
10//! planes through the sketch's current anchor (the last placed point) — or on
11//! model geometry under the cursor (see `EngineState::sketch3d_cursor_world`).
12//!
13//! The document and the solver are the kernel's ([`brep_kernel::Sketch3dDoc`],
14//! [`brep_kernel::solve_sketch3d_doc`]); the feature (`S3D`) solves the same
15//! document on every regenerate, so what the editor shows is what the model
16//! gets.
17
18use serde_json::{json, Value};
19
20pub use brep_kernel::{
21    Sketch3dConstraint, Sketch3dDiagnostics, Sketch3dDoc, Sketch3dGeometry, Sketch3dPoint,
22    SKETCH3D_CONSTRAINT_TYPES,
23};
24use brep_kernel::{sketch3d_constraint_type, Vec3};
25
26use crate::sketch::session::{constraint_ref, geometry_ref, point_ref, refs_equal, RefSelection};
27use crate::sketch::tessellate::{interaction_color, SketchTessellation, POINT_SIZE_PX};
28use crate::sketch::PlaneFrame;
29use crate::style::SketchColors;
30use crate::view::ViewCamera;
31
32/// The `set_overlay` group names. Separate from the planar sketch's so the
33/// two editors never clear each other's groups.
34pub const OVERLAY_GEOMETRY: &str = "sketch3d-geometry";
35pub const OVERLAY_POINTS: &str = "sketch3d-points";
36pub const OVERLAY_PREVIEW: &str = "sketch3d-preview";
37pub const OVERLAY_WORKPLANE: &str = "sketch3d-workplane";
38/// Every group, for a clear.
39pub const OVERLAY_GLYPHS: &str = "sketch3d-constraints";
40pub const OVERLAY_GROUPS: &[&str] =
41    &[OVERLAY_GEOMETRY, OVERLAY_POINTS, OVERLAY_PREVIEW, OVERLAY_WORKPLANE, OVERLAY_GLYPHS];
42
43/// Half-size of a constraint glyph, and how far apart stacked glyphs sit, in
44/// pixels.
45const GLYPH_HALF_PX: f64 = 6.0;
46const GLYPH_STEP_PX: f64 = 16.0;
47/// The direction glyphs step away from their anchor: not along any axis, so a
48/// glyph stays clear of its point from the usual views.
49const GLYPH_OFFSET_DIR: [f64; 3] = [0.76, 0.56, 0.33];
50
51/// Samples per arc and per bezier span when drawing / hit-testing.
52const ARC_SEGMENTS: usize = 48;
53const BEZIER_SEGMENTS_PER_SPAN: usize = 24;
54/// Half-size of the drawn working-plane square, in pixels.
55const WORKPLANE_HALF_PX: f64 = 90.0;
56/// Half-size of a drawn reference plane's square, in pixels.
57const REF_PLANE_HALF_PX: f64 = 40.0;
58
59/// The draw tools the 3D editor arms (`select` is the neutral state, stored
60/// as `None`).
61pub const TOOLS: &[&str] = &["select", "point", "line", "arc", "bezier", "pickRef"];
62
63/// The three working planes, by name.
64pub const WORK_PLANES: &[&str] = &["XY", "YZ", "XZ"];
65
66/// The working plane: one of the three world planes through an anchor.
67#[derive(Clone, Debug, PartialEq)]
68pub struct WorkPlane {
69    /// `"XY"`, `"YZ"` or `"XZ"`.
70    pub name: String,
71    /// The point the plane passes through (the sketch's current anchor).
72    pub anchor: [f64; 3],
73}
74
75impl WorkPlane {
76    pub fn new(name: &str, anchor: [f64; 3]) -> Option<Self> {
77        if !WORK_PLANES.contains(&name) {
78            return None;
79        }
80        Some(Self {
81            name: name.to_string(),
82            anchor,
83        })
84    }
85
86    /// The plane as a frame through the anchor.
87    pub fn frame(&self) -> PlaneFrame {
88        let mut frame = match self.name.as_str() {
89            "YZ" => PlaneFrame::yz(),
90            "XZ" => PlaneFrame::xz(),
91            _ => PlaneFrame::xy(),
92        };
93        frame.origin = self.anchor;
94        frame
95    }
96
97    /// The next plane in the `XY → YZ → XZ → XY` cycle.
98    pub fn next_name(&self) -> &'static str {
99        match self.name.as_str() {
100            "XY" => "YZ",
101            "YZ" => "XZ",
102            _ => "XY",
103        }
104    }
105}
106
107impl Default for WorkPlane {
108    fn default() -> Self {
109        Self {
110            name: "XY".into(),
111            anchor: [0.0; 3],
112        }
113    }
114}
115
116/// One dimension label the app draws as editable text at a projected world
117/// anchor — the 3D sibling of [`crate::sketch::dimensions::DimLabel`], in the
118/// same JSON shape so the app's label pass draws both.
119#[derive(Clone, Debug, serde::Serialize)]
120pub struct Sketch3dDimLabel {
121    pub id: Value,
122    pub text: String,
123    pub world: [f64; 3],
124    pub value: Option<f64>,
125    #[serde(rename = "valueExpr")]
126    pub value_expr: Option<String>,
127    pub mode: &'static str,
128    pub conflicting: bool,
129}
130
131/// A constraint the palette can add for the current selection: the type and
132/// the ordered point ids it would take.
133#[derive(Clone, Debug, PartialEq, serde::Serialize)]
134pub struct ApplicableConstraint {
135    /// The solver type (`distance`, `onPlane`, …).
136    #[serde(rename = "type")]
137    pub ctype: &'static str,
138    /// The palette label (`"Normal to plane"` for a line–plane `parallel`).
139    pub label: &'static str,
140    /// The ordered point ids the constraint takes.
141    pub points: Vec<Value>,
142    /// Whether it carries a value (seeded from the current geometry).
143    pub dimensional: bool,
144}
145
146/// State for one active 3D sketch.
147pub struct Sketch3dSession {
148    /// The solved document (points carry solved coordinates).
149    pub doc: Sketch3dDoc,
150    /// The last solve's diagnostics.
151    pub diagnostics: Sketch3dDiagnostics,
152    /// Selected entity refs (`{"kind":"point"|"geometry"|"constraint","id":…}`)
153    /// and the entity under the cursor — the planar session's set.
154    pub selection: RefSelection,
155    /// The armed draw tool (`None` = select).
156    pub tool: Option<String>,
157    /// The working plane a free click lands on.
158    pub work_plane: WorkPlane,
159    /// The overlay palette (the planar sketch's, shared).
160    pub colors: SketchColors,
161    /// Linked references the backdrop can no longer resolve: `(name, the
162    /// fixed point ids holding its last position)`.
163    pub lost_references: Vec<(String, Vec<Value>)>,
164}
165
166impl Sketch3dSession {
167    /// Build a session from a (possibly unsolved) doc, solving it once.
168    pub fn new(doc: Sketch3dDoc) -> Result<Self, String> {
169        let mut session = Self {
170            doc,
171            diagnostics: Sketch3dDiagnostics::default(),
172            selection: RefSelection::default(),
173            tool: None,
174            work_plane: WorkPlane::default(),
175            colors: SketchColors::default(),
176            lost_references: Vec::new(),
177        };
178        session.resolve()?;
179        Ok(session)
180    }
181
182    /// Re-solve the document in place.
183    pub fn resolve(&mut self) -> Result<(), String> {
184        let diagnostics = brep_kernel::solve_sketch3d_doc(
185            &mut self.doc,
186            &brep_kernel::Sketch3dSolveOptions::default(),
187        )?;
188        self.diagnostics = diagnostics;
189        Ok(())
190    }
191
192    // --- selection / hover -------------------------------------------------
193
194    pub fn is_selected(&self, entity_ref: &Value) -> bool {
195        self.selection.is_selected(entity_ref)
196    }
197
198    pub fn toggle_selection(&mut self, entity_ref: Value) {
199        self.selection.toggle(entity_ref)
200    }
201
202    pub fn clear_selection(&mut self) {
203        self.selection.clear()
204    }
205
206    pub fn set_hover(&mut self, entity_ref: Option<Value>) {
207        self.selection.hovered = entity_ref;
208    }
209
210    /// The selected entity refs split by kind: `(point ids, geometry ids,
211    /// constraint ids)`.
212    pub fn selection_by_kind(&self) -> (Vec<Value>, Vec<Value>, Vec<Value>) {
213        let mut points = Vec::new();
214        let mut geometries = Vec::new();
215        let mut constraints = Vec::new();
216        for r in &self.selection.items {
217            let Some(id) = r.get("id").cloned() else { continue };
218            match r.get("kind").and_then(Value::as_str) {
219                Some("point") => points.push(id),
220                Some("geometry") => geometries.push(id),
221                Some("constraint") => constraints.push(id),
222                _ => {}
223            }
224        }
225        (points, geometries, constraints)
226    }
227
228    // --- geometry sampling ---------------------------------------------------
229
230    /// A point's world position.
231    pub fn point_world(&self, id: &Value) -> Option<[f64; 3]> {
232        self.doc.point(id).map(|p| [p.x, p.y, p.z])
233    }
234
235    /// Sample a geometry into an ordered world polyline (`None` for a missing
236    /// point or a degenerate shape). A `point` geometry samples to nothing; a
237    /// reference `plane` draws as a square about its origin, sized in pixels.
238    pub fn sample_geometry(&self, geometry: &Sketch3dGeometry, world_per_pixel: f64) -> Option<Vec<[f64; 3]>> {
239        let positions = self.doc.geometry_positions(geometry)?;
240        let out: Vec<[f64; 3]> = match (geometry.geom_type.as_str(), positions.len()) {
241            ("line", n) if n >= 2 => vec![arr(positions[0]), arr(positions[1])],
242            ("arc", n) if n >= 3 => sample_arc(positions[0], positions[1], positions[2])?,
243            ("bezier", n) if n >= 4 && (n - 1) % 3 == 0 => sample_bezier(&positions),
244            ("plane", n) if n >= 2 => {
245                let frame = plane_frame_of(positions[0], positions[1])?;
246                plane_square(&frame, REF_PLANE_HALF_PX * world_per_pixel)
247            }
248            _ => return None,
249        };
250        (out.len() >= 2).then_some(out)
251    }
252
253    // --- screen-space picking ------------------------------------------------
254
255    /// The entity under CSS-pixel `(x, y)` within `radius_px`: a POINT wins over
256    /// geometry when one is in range, else the nearest geometry whose projected
257    /// polyline passes within range. Construction geometry is pickable.
258    pub fn pick_entity(&self, camera: &ViewCamera, x: f64, y: f64, radius_px: f64) -> Option<Value> {
259        let mut best_pt: Option<(f64, &Value)> = None;
260        for p in &self.doc.points {
261            let Some((sx, sy)) = project(camera, [p.x, p.y, p.z]) else { continue };
262            let d = (sx - x).hypot(sy - y);
263            if d <= radius_px && best_pt.map_or(true, |(bd, _)| d < bd) {
264                best_pt = Some((d, &p.id));
265            }
266        }
267        if let Some((_, id)) = best_pt {
268            return Some(point_ref(id));
269        }
270        let wpp = camera.world_per_pixel();
271        let mut best_glyph: Option<(f64, Value)> = None;
272        for (id, centre, _) in self.constraint_glyphs(wpp) {
273            let Some((sx, sy)) = project(camera, centre) else { continue };
274            let d = (sx - x).hypot(sy - y);
275            if d <= radius_px.min(GLYPH_HALF_PX) && best_glyph.as_ref().map_or(true, |(bd, _)| d < *bd) {
276                best_glyph = Some((d, id));
277            }
278        }
279        let mut best_geo: Option<(f64, &Value)> = None;
280        for g in &self.doc.geometries {
281            let Some(poly) = self.sample_geometry(g, wpp) else { continue };
282            let screen: Vec<Option<(f64, f64)>> = poly.iter().map(|w| project(camera, *w)).collect();
283            let mut dmin = f64::INFINITY;
284            for pair in screen.windows(2) {
285                let (Some(a), Some(b)) = (pair[0], pair[1]) else { continue };
286                let d = crate::geometry2d::point_segment_distance((x, y), a, b).0;
287                if d < dmin {
288                    dmin = d;
289                }
290            }
291            if dmin <= radius_px && best_geo.map_or(true, |(bd, _)| dmin < bd) {
292                best_geo = Some((dmin, &g.id));
293            }
294        }
295        // A glyph near a line must not steal a click aimed at the line.
296        match (best_glyph, best_geo) {
297            (Some((glyph, id)), Some((geo, _))) if glyph < geo => Some(entity_constraint_ref(&id)),
298            (Some((_, id)), None) => Some(entity_constraint_ref(&id)),
299            (_, geo) => geo.map(|(_, id)| geometry_ref(id)),
300        }
301    }
302
303    /// One mark per non-dimensional constraint: `(id, centre, shape)`. Marks
304    /// sharing an anchor are stacked, so each stays selectable.
305    pub fn constraint_glyphs(&self, world_per_pixel: f64) -> Vec<(Value, [f64; 3], &'static str)> {
306        let mut stacks: std::collections::HashMap<[i64; 3], usize> = std::collections::HashMap::new();
307        let step = GLYPH_STEP_PX * world_per_pixel;
308        let mut out = Vec::new();
309        for constraint in &self.doc.constraints {
310            let (Some(id), Some(ctype)) = (constraint.id(), constraint.ctype()) else { continue };
311            if sketch3d_constraint_type(ctype).map_or(true, |t| t.dimensional) {
312                continue;
313            }
314            let ids = constraint.points();
315            let at = |i: usize| ids.get(i).and_then(|id| self.point_world(id));
316            let (anchor, shape) = match ctype {
317                "fixed" | "coincident" => (at(0), "star"),
318                "onLine" | "onPlane" | "onArc" | "onCircle" | "onCurve" | "midpoint" => (at(0), "diamond"),
319                "symmetric" => (at(0).zip(at(1)).map(|(a, b)| [0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1]), 0.5 * (a[2] + b[2])]), "diamond"),
320                _ => (at(0).zip(at(1)).map(|(a, b)| [0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1]), 0.5 * (a[2] + b[2])]), "box"),
321            };
322            let Some(anchor) = anchor else { continue };
323            let key = anchor.map(|c| (c / step.max(1e-9)).round() as i64);
324            let slot = stacks.entry(key).or_insert(0);
325            *slot += 1;
326            let distance = step * *slot as f64;
327            let centre = [
328                anchor[0] + GLYPH_OFFSET_DIR[0] * distance,
329                anchor[1] + GLYPH_OFFSET_DIR[1] * distance,
330                anchor[2] + GLYPH_OFFSET_DIR[2] * distance,
331            ];
332            out.push((id.clone(), centre, shape));
333        }
334        out
335    }
336
337    fn glyph_tessellation(&self, world_per_pixel: f64) -> SketchTessellation {
338        let mut out = SketchTessellation::default();
339        let half = GLYPH_HALF_PX * world_per_pixel;
340        let colors = &self.colors;
341        for (id, c, shape) in self.constraint_glyphs(world_per_pixel) {
342            let color = interaction_color(colors, colors.preview, self.selection.hovered.as_ref(), &self.selection.items, "constraint", &id);
343            let rgb = rgb_of(color);
344            let at = |x: f64, y: f64, z: f64| [c[0] + x * half, c[1] + y * half, c[2] + z * half];
345            let segments: Vec<([f64; 3], [f64; 3])> = match shape {
346                "star" => vec![(at(-1.0, 0.0, 0.0), at(1.0, 0.0, 0.0)), (at(0.0, -1.0, 0.0), at(0.0, 1.0, 0.0)), (at(0.0, 0.0, -1.0), at(0.0, 0.0, 1.0))],
347                "diamond" => {
348                    let ring = [at(1.0, 0.0, 0.0), at(0.0, 1.0, 0.0), at(-1.0, 0.0, 0.0), at(0.0, -1.0, 0.0)];
349                    (0..4)
350                        .flat_map(|i| [(ring[i], ring[(i + 1) % 4]), (ring[i], at(0.0, 0.0, 1.0)), (ring[i], at(0.0, 0.0, -1.0))])
351                        .collect()
352                }
353                _ => {
354                    let corner = |i: usize| at(if i & 1 == 0 { -0.8 } else { 0.8 }, if i & 2 == 0 { -0.8 } else { 0.8 }, if i & 4 == 0 { -0.8 } else { 0.8 });
355                    (0..8usize)
356                        .flat_map(|i| [1usize, 2, 4].map(|bit| (i, i | bit)))
357                        .filter(|(i, j)| i != j)
358                        .map(|(i, j)| (corner(i), corner(j)))
359                        .collect()
360                }
361            };
362            for (a, b) in segments {
363                push_seg(&mut out, a, b, rgb);
364            }
365        }
366        out
367    }
368
369    /// The nearest DRAGGABLE point under `(x, y)` (id + its authored `fixed`
370    /// flag): a point the solver reports movable.
371    pub fn pick_draggable_point(&self, camera: &ViewCamera, x: f64, y: f64, radius_px: f64) -> Option<(Value, bool)> {
372        let mut best: Option<(f64, &Sketch3dPoint)> = None;
373        for p in &self.doc.points {
374            if self.diagnostics.point_movable(&p.id) != Some(true) {
375                continue;
376            }
377            let Some((sx, sy)) = project(camera, [p.x, p.y, p.z]) else { continue };
378            let d = (sx - x).hypot(sy - y);
379            if d <= radius_px && best.map_or(true, |(bd, _)| d < bd) {
380                best = Some((d, p));
381            }
382        }
383        best.map(|(_, p)| (p.id.clone(), p.fixed))
384    }
385
386    /// The points a drag of `entity_ref` moves: the one point, or every
387    /// (deduped, movable) point of a geometry; `None` for a locked point or a
388    /// constraint.
389    pub fn drag_points_from_ref(&self, entity_ref: &Value) -> Option<Vec<(Value, [f64; 3], bool)>> {
390        let id = entity_ref.get("id")?;
391        match entity_ref.get("kind").and_then(Value::as_str) {
392            Some("point") => {
393                if self.diagnostics.point_movable(id) != Some(true) {
394                    return None;
395                }
396                let p = self.doc.point(id)?;
397                Some(vec![(p.id.clone(), [p.x, p.y, p.z], p.fixed)])
398            }
399            Some("geometry") => {
400                let g = self.doc.geometry(id)?;
401                if g.geom_type == "plane" {
402                    return None;
403                }
404                let mut out: Vec<(Value, [f64; 3], bool)> = Vec::new();
405                for pid in &g.points {
406                    if out.iter().any(|(seen, _, _)| refs_equal(&point_ref(seen), &point_ref(pid))) {
407                        continue;
408                    }
409                    let Some(p) = self.doc.point(pid) else { continue };
410                    if self.diagnostics.point_movable(pid) != Some(true) {
411                        continue;
412                    }
413                    out.push((p.id.clone(), [p.x, p.y, p.z], p.fixed));
414                }
415                (!out.is_empty()).then_some(out)
416            }
417            _ => None,
418        }
419    }
420
421    // --- overlay -----------------------------------------------------------
422
423    /// Tessellate the solved sketch with hover + selection coloring.
424    pub fn tessellation(&self, world_per_pixel: f64) -> SketchTessellation {
425        let mut out = SketchTessellation::default();
426        let colors = &self.colors;
427        let hovered = self.selection.hovered.as_ref();
428        let selection = &self.selection.items;
429        let dash_len = (8.0 * world_per_pixel).max(0.02);
430        let gap_len = (6.0 * world_per_pixel).max(0.01);
431        for g in &self.doc.geometries {
432            let Some(pts) = self.sample_geometry(g, world_per_pixel) else { continue };
433            let mobility = match self.diagnostics.geometry_movable(&g.id) {
434                None => colors.geometry,
435                Some(true) => colors.movable,
436                Some(false) => colors.locked,
437            };
438            let color = interaction_color(colors, mobility, hovered, selection, "geometry", &g.id);
439            let rgb = rgb_of(color);
440            if g.construction() {
441                push_dashed(&mut out, &pts, rgb, dash_len, gap_len);
442            } else {
443                push_solid(&mut out, &pts, rgb);
444            }
445        }
446        for p in &self.doc.points {
447            let base = if p.construction {
448                colors.construction_point
449            } else {
450                match self.diagnostics.point_movable(&p.id) {
451                    Some(true) => colors.movable,
452                    Some(false) => colors.locked,
453                    None => colors.point,
454                }
455            };
456            let color = interaction_color(colors, base, hovered, selection, "point", &p.id);
457            out.point_positions.extend_from_slice(&[p.x as f32, p.y as f32, p.z as f32]);
458            out.point_colors.extend_from_slice(&rgb_of(color));
459        }
460        out
461    }
462
463    /// The `set_overlay` JSON for the solved geometry + points.
464    pub fn overlay_json(&self, world_per_pixel: f64) -> String {
465        let tess = self.tessellation(world_per_pixel);
466        let glyphs = self.glyph_tessellation(world_per_pixel);
467        json!({
468            "groups": [
469                {
470                    "name": OVERLAY_GLYPHS,
471                    "renderOrder": 10001,
472                    "lines": { "positions": glyphs.line_positions, "colors": glyphs.line_colors },
473                },
474                {
475                    "name": OVERLAY_GEOMETRY,
476                    "renderOrder": 10000,
477                    "lines": { "positions": tess.line_positions, "colors": tess.line_colors },
478                },
479                {
480                    "name": OVERLAY_POINTS,
481                    "renderOrder": 10001,
482                    "points": {
483                        "positions": tess.point_positions,
484                        "colors": tess.point_colors,
485                        "size": POINT_SIZE_PX,
486                    },
487                },
488            ]
489        })
490        .to_string()
491    }
492
493    /// The `set_overlay` JSON for the armed tool's rubber band: the pending
494    /// points joined, and the last one joined to the hovered world point.
495    /// Always the `sketch3d-preview` group (empty when nothing is pending).
496    pub fn preview_overlay_json(&self, pending: &[Value], hover: Option<[f64; 3]>) -> String {
497        let mut pts: Vec<[f64; 3]> = pending.iter().filter_map(|id| self.point_world(id)).collect();
498        if let Some(h) = hover {
499            pts.push(h);
500        }
501        let mut out = SketchTessellation::default();
502        let rgb = rgb_of(self.colors.preview);
503        match self.tool.as_deref() {
504            Some("arc") if pts.len() >= 3 => {
505                // center, start, end: preview the arc itself.
506                if let Some(arc) = sample_arc(vec(pts[0]), vec(pts[1]), vec(pts[2])) {
507                    push_solid(&mut out, &arc, rgb);
508                }
509                push_solid(&mut out, &pts[..2], rgb);
510            }
511            Some("bezier") if pts.len() == 4 => {
512                let controls: Vec<Vec3> = pts.iter().map(|p| vec(*p)).collect();
513                push_solid(&mut out, &sample_bezier(&controls), rgb);
514                push_solid(&mut out, &pts, rgb);
515            }
516            _ if pts.len() >= 2 => push_solid(&mut out, &pts, rgb),
517            _ => {}
518        }
519        json!({
520            "groups": [{
521                "name": OVERLAY_PREVIEW,
522                "renderOrder": 10002,
523                "lines": { "positions": out.line_positions, "colors": out.line_colors },
524            }]
525        })
526        .to_string()
527    }
528
529    /// The `set_overlay` JSON for the working plane: a dashed square through
530    /// the anchor with its two in-plane axes, sized in pixels so it reads the
531    /// same at any zoom. Hidden (empty group) while the select tool is armed —
532    /// it matters only where a click lands.
533    pub fn workplane_overlay_json(&self, world_per_pixel: f64) -> String {
534        let mut out = SketchTessellation::default();
535        if self.tool.is_some() {
536            let frame = self.work_plane.frame();
537            let half = WORKPLANE_HALF_PX * world_per_pixel;
538            let rgb = rgb_of(self.colors.preview);
539            let square = plane_square(&frame, half);
540            push_dashed(&mut out, &square, rgb, (8.0 * world_per_pixel).max(0.02), (6.0 * world_per_pixel).max(0.01));
541            let o = frame.origin;
542            push_solid(&mut out, &[frame.to_world(-half, 0.0), frame.to_world(half, 0.0)], rgb);
543            push_solid(&mut out, &[frame.to_world(0.0, -half), frame.to_world(0.0, half)], rgb);
544            let _ = o;
545        }
546        json!({
547            "groups": [{
548                "name": OVERLAY_WORKPLANE,
549                "renderOrder": 9999,
550                "lines": { "positions": out.line_positions, "colors": out.line_colors },
551            }]
552        })
553        .to_string()
554    }
555
556    // --- dimensions ------------------------------------------------------------
557
558    /// One label per dimensional constraint, anchored at the dimension's
559    /// natural midpoint.
560    pub fn dimension_labels(&self) -> Vec<Sketch3dDimLabel> {
561        let mut out = Vec::new();
562        for c in &self.doc.constraints {
563            let (Some(ctype), Some(id)) = (c.ctype(), c.id()) else { continue };
564            let Some(spec) = sketch3d_constraint_type(ctype) else { continue };
565            if !spec.dimensional {
566                continue;
567            }
568            let pts: Vec<[f64; 3]> = c.points().iter().filter_map(|pid| self.point_world(pid)).collect();
569            if pts.len() < spec.arity {
570                continue;
571            }
572            let (world, mode) = match ctype {
573                "distance" => (mid(pts[0], pts[1]), "distance"),
574                "angle" => (mid(mid(pts[0], pts[1]), mid(pts[2], pts[3])), "angle"),
575                _ => (pts[0], "distance"),
576            };
577            let value = c.value();
578            let text = match (mode, value) {
579                ("angle", Some(v)) => format!("{v:.2}°"),
580                (_, Some(v)) => format!("{v:.3}"),
581                _ => "?".to_string(),
582            };
583            out.push(Sketch3dDimLabel {
584                id: id.clone(),
585                text,
586                world,
587                value,
588                value_expr: c.raw.get("valueExpr").and_then(Value::as_str).map(String::from),
589                mode,
590                conflicting: self.diagnostics.constraint_conflicting(id),
591            });
592        }
593        out
594    }
595
596    // --- the constraint palette ------------------------------------------------
597
598    /// The constraints the palette offers for the current selection, each with
599    /// the point ids it would take, in palette order.
600    pub fn applicable_constraints(&self) -> Vec<ApplicableConstraint> {
601        let (points, geometries, _) = self.selection_by_kind();
602        let geos: Vec<&Sketch3dGeometry> = geometries.iter().filter_map(|id| self.doc.geometry(id)).collect();
603        let lines: Vec<&Sketch3dGeometry> = geos.iter().copied().filter(|g| g.geom_type == "line" && g.points.len() >= 2).collect();
604        let arcs: Vec<&Sketch3dGeometry> = geos.iter().copied().filter(|g| g.geom_type == "arc" && g.points.len() >= 3).collect();
605        let planes: Vec<&Sketch3dGeometry> = geos.iter().copied().filter(|g| g.geom_type == "plane" && g.points.len() >= 2).collect();
606        let others = geos.len() - lines.len() - arcs.len() - planes.len();
607        let mut out = Vec::new();
608        let mut push = |ctype: &'static str, label: &'static str, ids: Vec<Value>| {
609            let dimensional = sketch3d_constraint_type(ctype).map_or(false, |t| t.dimensional);
610            out.push(ApplicableConstraint { ctype, label, points: ids, dimensional });
611        };
612        let line_pts = |g: &Sketch3dGeometry| vec![g.points[0].clone(), g.points[1].clone()];
613        let plane_pts = |g: &Sketch3dGeometry| vec![g.points[0].clone(), g.points[1].clone()];
614        if let ([point], [curve]) = (points.as_slice(), geos.as_slice()) {
615            if curve.geom_type == "bezier" && curve.points.len() >= 4 {
616                let mut ids = vec![point.clone()];
617                ids.extend(curve.points.iter().cloned());
618                push("onCurve", "On curve", ids);
619                return out;
620            }
621        }
622        if others > 0 {
623            return out;
624        }
625        match (points.len(), lines.len(), arcs.len(), planes.len()) {
626            (1, 0, 0, 0) => {
627                let fixed = self.doc.point(&points[0]).map_or(false, |p| p.fixed);
628                if !fixed {
629                    push("fixed", "Fix", vec![points[0].clone()]);
630                }
631            }
632            (2, 0, 0, 0) => {
633                push("coincident", "Coincident", vec![points[0].clone(), points[1].clone()]);
634                push("distance", "Distance", vec![points[0].clone(), points[1].clone()]);
635            }
636            (0, 1, 0, 0) => {
637                let ids = line_pts(lines[0]);
638                push("distance", "Length", ids.clone());
639                push("alongX", "Along X", ids.clone());
640                push("alongY", "Along Y", ids.clone());
641                push("alongZ", "Along Z", ids);
642            }
643            (0, 2, 0, 0) => {
644                let mut ids = line_pts(lines[0]);
645                ids.extend(line_pts(lines[1]));
646                push("parallel", "Parallel", ids.clone());
647                push("perpendicular", "Perpendicular", ids.clone());
648                push("angle", "Angle", ids.clone());
649                push("equal", "Equal length", ids);
650            }
651            // A linked circle is published as its axis line, flagged `circle`.
652            (1, 1, 0, 0) if lines[0].extra.get("circle").and_then(Value::as_bool) == Some(true) => {
653                let mut ids = vec![points[0].clone()];
654                ids.extend(line_pts(lines[0]));
655                push("coincident", "At centre", vec![points[0].clone(), lines[0].points[0].clone()]);
656                push("onCircle", "On circle", ids);
657            }
658            (1, 1, 0, 0) => {
659                let mut ids = vec![points[0].clone()];
660                ids.extend(line_pts(lines[0]));
661                push("onLine", "On line", ids.clone());
662                push("lineDistance", "Distance to line", ids.clone());
663                push("midpoint", "At midpoint", ids);
664            }
665            (1, 0, 1, 0) => {
666                let mut ids = vec![points[0].clone()];
667                ids.extend(arcs[0].points[..3].iter().cloned());
668                push("coincident", "At centre", vec![points[0].clone(), arcs[0].points[0].clone()]);
669                push("onArc", "On arc", ids);
670            }
671            (2, 0, 0, 1) => {
672                let mut ids = vec![points[0].clone(), points[1].clone()];
673                ids.extend(plane_pts(planes[0]));
674                push("symmetric", "Symmetric", ids);
675            }
676            (1, 0, 0, 1) => {
677                let mut ids = vec![points[0].clone()];
678                ids.extend(plane_pts(planes[0]));
679                push("onPlane", "On plane", ids.clone());
680                push("planeDistance", "Distance to plane", ids);
681            }
682            (0, 1, 0, 1) => {
683                let mut ids = line_pts(lines[0]);
684                ids.extend(plane_pts(planes[0]));
685                push("parallel", "Normal to plane", ids.clone());
686                push("perpendicular", "Parallel to plane", ids);
687            }
688            (0, 1, 1, 0) => {
689                let mut ids = line_pts(lines[0]);
690                ids.extend(arcs[0].points[..3].iter().cloned());
691                push("tangent", "Tangent", ids);
692            }
693            (0, 0, 1, 0) => {
694                let a = &arcs[0].points;
695                push("distance", "Radius", vec![a[0].clone(), a[1].clone()]);
696            }
697            (0, 0, 2, 0) => {
698                let (a, b) = (&arcs[0].points, &arcs[1].points);
699                push("equal", "Equal radius", vec![a[0].clone(), a[1].clone(), b[0].clone(), b[1].clone()]);
700            }
701            _ => {}
702        }
703        out
704    }
705
706    /// The value a dimensional constraint over `ids` would be SEEDED with — the
707    /// current measurement, so adding a dimension changes nothing until the
708    /// user edits it.
709    pub fn measure(&self, ctype: &str, ids: &[Value]) -> Option<f64> {
710        let p: Vec<Vec3> = ids.iter().filter_map(|id| self.point_world(id)).map(vec).collect();
711        match ctype {
712            "distance" if p.len() >= 2 => Some(p[1].sub(p[0]).length()),
713            "lineDistance" if p.len() >= 3 => {
714                let d = p[2].sub(p[1]).normalized().ok()?;
715                let v = p[0].sub(p[1]);
716                Some(v.sub(d.scale(d.dot(v))).length())
717            }
718            "planeDistance" if p.len() >= 3 => {
719                let n = p[2].sub(p[1]).normalized().ok()?;
720                Some(n.dot(p[0].sub(p[1])))
721            }
722            "angle" if p.len() >= 4 => {
723                let a = p[1].sub(p[0]).normalized().ok()?;
724                let b = p[3].sub(p[2]).normalized().ok()?;
725                Some(a.dot(b).clamp(-1.0, 1.0).acos().to_degrees())
726            }
727            _ => None,
728        }
729    }
730
731    /// The name of the lost reference a constraint depends on, if any.
732    pub fn lost_reference_of(&self, constraint: &Sketch3dConstraint) -> Option<&str> {
733        let key = brep_kernel::sketch_id_key;
734        self.lost_references
735            .iter()
736            .find(|(_, ids)| constraint.points().iter().any(|p| ids.iter().any(|id| key(id) == key(p))))
737            .map(|(name, _)| name.as_str())
738    }
739
740    /// One sentence per lost reference, naming the constraints that need it.
741    pub fn lost_reference_messages(&self) -> Vec<String> {
742        self.lost_references
743            .iter()
744            .map(|(name, _)| {
745                let needed: Vec<String> = self
746                    .doc
747                    .constraints
748                    .iter()
749                    .filter(|c| self.lost_reference_of(c) == Some(name.as_str()))
750                    .map(|c| format!("{} {}", c.ctype().unwrap_or("?"), c.id().map(brep_kernel::sketch_id_key).unwrap_or_default()))
751                    .collect();
752                if needed.is_empty() {
753                    format!("3D sketch: reference '{name}' no longer resolves; it keeps its last position")
754                } else {
755                    format!(
756                        "3D sketch: reference '{name}' no longer resolves; it keeps its last position, needed by constraint {}",
757                        needed.join(", ")
758                    )
759                }
760            })
761            .collect()
762    }
763
764    /// Rows for the entity lists: `(kind, id, label)` per point, geometry and
765    /// constraint, with the mobility / conflict state the list colors by.
766    pub fn entity_rows_json(&self) -> Value {
767        let points: Vec<Value> = self
768            .doc
769            .points
770            .iter()
771            .map(|p| {
772                json!({
773                    "id": p.id,
774                    "fixed": p.fixed,
775                    "construction": p.construction,
776                    "externalReference": p.external_reference,
777                    "movable": self.diagnostics.point_movable(&p.id),
778                    "position": [p.x, p.y, p.z],
779                })
780            })
781            .collect();
782        let geometries: Vec<Value> = self
783            .doc
784            .geometries
785            .iter()
786            .map(|g| {
787                json!({
788                    "id": g.id,
789                    "type": g.geom_type,
790                    "construction": g.construction(),
791                    "movable": self.diagnostics.geometry_movable(&g.id),
792                    "points": g.points,
793                })
794            })
795            .collect();
796        let constraints: Vec<Value> = self
797            .doc
798            .constraints
799            .iter()
800            .map(|c| {
801                json!({
802                    "id": c.id(),
803                    "type": c.ctype(),
804                    "points": c.points(),
805                    "value": c.value(),
806                    "conflicting": c.id().map_or(false, |id| self.diagnostics.constraint_conflicting(id)),
807                    "lostReference": self.lost_reference_of(c),
808                })
809            })
810            .collect();
811        json!({ "points": points, "geometries": geometries, "constraints": constraints })
812    }
813}
814
815// ---------------------------------------------------------------------------
816// Entity refs (shared shape with the planar sketch)
817// ---------------------------------------------------------------------------
818
819pub fn entity_point_ref(id: &Value) -> Value {
820    point_ref(id)
821}
822
823pub fn entity_geometry_ref(id: &Value) -> Value {
824    geometry_ref(id)
825}
826
827pub fn entity_constraint_ref(id: &Value) -> Value {
828    constraint_ref(id)
829}
830
831// ---------------------------------------------------------------------------
832// Geometry helpers
833// ---------------------------------------------------------------------------
834
835fn arr(v: Vec3) -> [f64; 3] {
836    [v.x, v.y, v.z]
837}
838
839fn vec(a: [f64; 3]) -> Vec3 {
840    Vec3::new(a[0], a[1], a[2])
841}
842
843fn mid(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
844    [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5]
845}
846
847/// Project a world point to CSS pixels, `None` when behind the eye.
848pub fn project(camera: &ViewCamera, world: [f64; 3]) -> Option<(f64, f64)> {
849    if !camera.projectable(world) {
850        return None;
851    }
852    let (x, y, _) = camera.project(world);
853    Some((x, y))
854}
855
856/// The minor arc from `start` to `end` about `center`, sampled; `None` when the
857/// three points are collinear or the radius is zero.
858pub fn sample_arc(center: Vec3, start: Vec3, end: Vec3) -> Option<Vec<[f64; 3]>> {
859    let rs = start.sub(center);
860    let re = end.sub(center);
861    let radius = rs.length();
862    if radius <= 1e-12 {
863        return None;
864    }
865    let normal = rs.cross(re);
866    if normal.length() <= 1e-12 * radius * radius {
867        return None;
868    }
869    let x_axis = rs.normalized().ok()?;
870    let y_axis = normal.normalized().ok()?.cross(x_axis).normalized().ok()?;
871    let sweep = re.dot(y_axis).atan2(re.dot(x_axis));
872    let sweep = if sweep <= 0.0 { sweep + std::f64::consts::TAU } else { sweep };
873    let r_end = re.length();
874    let mut out = Vec::with_capacity(ARC_SEGMENTS + 1);
875    for i in 0..=ARC_SEGMENTS {
876        let t = i as f64 / ARC_SEGMENTS as f64;
877        let a = sweep * t;
878        // Blend the radius from start to end so an arc whose implied
879        // equal-radius row is not yet satisfied still draws through both ends.
880        let r = radius + (r_end - radius) * t;
881        out.push(arr(center.add(x_axis.scale(r * a.cos())).add(y_axis.scale(r * a.sin()))));
882    }
883    Some(out)
884}
885
886/// A cubic bezier chain (`3n+1` controls), sampled per span.
887pub fn sample_bezier(controls: &[Vec3]) -> Vec<[f64; 3]> {
888    let span_count = controls.len().saturating_sub(1) / 3;
889    let mut out = Vec::with_capacity(span_count * BEZIER_SEGMENTS_PER_SPAN + 1);
890    for span in 0..span_count {
891        let p = &controls[span * 3..span * 3 + 4];
892        let first = if span == 0 { 0 } else { 1 };
893        for i in first..=BEZIER_SEGMENTS_PER_SPAN {
894            let t = i as f64 / BEZIER_SEGMENTS_PER_SPAN as f64;
895            let u = 1.0 - t;
896            let point = p[0]
897                .scale(u * u * u)
898                .add(p[1].scale(3.0 * u * u * t))
899                .add(p[2].scale(3.0 * u * t * t))
900                .add(p[3].scale(t * t * t));
901            out.push(arr(point));
902        }
903    }
904    out
905}
906
907/// The frame of a reference plane `[origin, normalTip]`.
908pub fn plane_frame_of(origin: Vec3, tip: Vec3) -> Option<PlaneFrame> {
909    let normal = tip.sub(origin);
910    if normal.length() <= 1e-12 {
911        return None;
912    }
913    Some(PlaneFrame::from_normal(arr(origin), arr(normal)))
914}
915
916/// A closed square of half-size `half` in `frame`, as a 5-point polyline.
917fn plane_square(frame: &PlaneFrame, half: f64) -> Vec<[f64; 3]> {
918    vec![
919        frame.to_world(-half, -half),
920        frame.to_world(half, -half),
921        frame.to_world(half, half),
922        frame.to_world(-half, half),
923        frame.to_world(-half, -half),
924    ]
925}
926
927fn rgb_of(color: u32) -> [f32; 3] {
928    [
929        ((color >> 16) & 0xff) as f32 / 255.0,
930        ((color >> 8) & 0xff) as f32 / 255.0,
931        (color & 0xff) as f32 / 255.0,
932    ]
933}
934
935fn push_seg(out: &mut SketchTessellation, a: [f64; 3], b: [f64; 3], rgb: [f32; 3]) {
936    out.line_positions.extend_from_slice(&[
937        a[0] as f32, a[1] as f32, a[2] as f32, b[0] as f32, b[1] as f32, b[2] as f32,
938    ]);
939    out.line_colors.extend_from_slice(&[rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]]);
940}
941
942fn push_solid(out: &mut SketchTessellation, pts: &[[f64; 3]], rgb: [f32; 3]) {
943    for pair in pts.windows(2) {
944        push_seg(out, pair[0], pair[1], rgb);
945    }
946}
947
948fn push_dashed(out: &mut SketchTessellation, pts: &[[f64; 3]], rgb: [f32; 3], dl: f64, gl: f64) {
949    let mut on = true;
950    let mut need = dl;
951    for pair in pts.windows(2) {
952        let (a, b) = (vec(pair[0]), vec(pair[1]));
953        let seg = b.sub(a);
954        let len = seg.length();
955        if len <= 0.0 {
956            continue;
957        }
958        let dir = seg.scale(1.0 / len);
959        let mut t = 0.0;
960        while t < len {
961            let step = need.min(len - t);
962            if on {
963                push_seg(out, arr(a.add(dir.scale(t))), arr(a.add(dir.scale(t + step))), rgb);
964            }
965            t += step;
966            need -= step;
967            if need <= 1e-12 {
968                on = !on;
969                need = if on { dl } else { gl };
970            }
971        }
972    }
973}
974