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