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