Skip to main content

brep_kernel/feature_pipeline/features/
sketch3d.rs

1//! S3D — 3D Sketch. Free-space points, lines, arcs and bezier chains, held
2//! together by the 3D constraint solver (`solvers/sketch3d_solver.rs`) and
3//! tied to the model through linked REFERENCES: a vertex, an edge, a face, a
4//! datum plane or axis, or a point another feature published. It produces no
5//! solid; it publishes its solved curves as PATHS, so the tube, sweep and
6//! path-sweep features build tubular frames and routed runs along it exactly
7//! as they do along a planar sketch.
8//!
9//! # Document (`persistentData`)
10//!
11//! - `sketch3d` — the [`Sketch3dDoc`]: points `{id, x, y, z, fixed?,
12//!   construction?, externalReference?}`, geometries `{id, type, points}` with
13//!   `point` / `line` / `arc` (center, start, end) / `bezier` (3n+1 control
14//!   points) / `plane` (origin, normal tip; always construction), and
15//!   constraints `{id, type, points, value?, valueExpr?}` from the solver's
16//!   vocabulary ([`crate::SKETCH3D_CONSTRAINT_TYPES`]). Coordinates and
17//!   dimension values may be EXPRESSION strings; they are evaluated against the
18//!   document's expression sheet before every solve, exactly as the planar
19//!   sketch evaluates its own.
20//! - `externalRefs` — the materialization record of the dialog's `references`
21//!   list: one `{name, kind, points: [ids]}` per linked model entity, saying
22//!   which fixed construction points the editor made for it (`kind` is
23//!   `point`, `line`, `arc` or `plane`). On every run each entry is RE-RESOLVED
24//!   against the live scene and its points are moved to where the entity now
25//!   is, so a 3D sketch follows the model it is constrained to. An entry whose
26//!   name no longer resolves keeps its last coordinates and is reported through
27//!   `unresolved` (contract rule 1: the caller repairs; nothing vanishes).
28//!
29//! # What it publishes (all under the feature id)
30//!
31//! - `scene.paths[{id}:G{gid}]` — one world curve per MODEL geometry (a
32//!   construction line, arc or bezier publishes nothing); a line as a degree-1
33//!   NURBS, an arc as the exact rational arc in the plane of its three points
34//!   (the MINOR arc from start to end), a bezier chain as one clamped cubic
35//!   B-spline with triple interior knots.
36//! - `scene.paths[{id}]` — the longest head-to-tail chain of those segments,
37//!   with `path_segment_names` naming each segment's `{id}:G{gid}`, so a
38//!   whole-sketch `path` reference sweeps the run in one pick. A branching
39//!   sketch (a frame node) still resolves per segment.
40//! - `scene.axes[{id}:G{gid}]` — every line, construction included, as a
41//!   revolve/sweep axis.
42//! - `scene.points[{id}:P{pid}]` — every point, construction flag included.
43
44use crate::feature_pipeline::features::common;
45use crate::feature_pipeline::{Axis, FeatureContext, FeatureResult, SceneMap, ScenePoint};
46use crate::{
47    make_arc, make_line, resolve_edge_selection, resolve_face_selection,
48    resolve_vertex_selection, NurbsCurve, SelectionGeometry, Sketch3dDoc, Sketch3dSolveOptions,
49    Vec3, Vec4,
50};
51use serde_json::{json, Map, Value};
52
53/// The feature type code (the dispatch and the catalogue key).
54pub const SKETCH3D_FEATURE_TYPE: &str = "S3D";
55
56/// Key of the `persistentData` document.
57pub const SKETCH3D_DOC_KEY: &str = "sketch3d";
58
59/// Key of the `persistentData` reference materialization record.
60pub const SKETCH3D_REFS_KEY: &str = "externalRefs";
61
62/// The dialog's reference list param.
63pub const SKETCH3D_REFERENCES_PARAM: &str = "references";
64
65/// The schema button that opens the editor.
66pub const SKETCH3D_EDIT_BUTTON: &str = "editSketch3d";
67
68/// The length of the normal tip a resolved PLANE reference materializes
69/// (`normalTip = origin + normal · this`), and of the direction tip of a
70/// resolved AXIS reference. The solver normalizes directions, so the length
71/// only sizes what the editor draws.
72pub const REFERENCE_TIP_LENGTH: f64 = 1.0;
73
74/// Record key of a vertex reference's topological anchor: two edge names whose
75/// one shared end IS the vertex. A `{solid}@x,y,z` name is only a coordinate,
76/// so without this a vertex that moves stops resolving.
77pub const REFERENCE_ANCHOR_EDGES_KEY: &str = "anchorEdges";
78
79/// Two edge ends closer than this (relative to the larger coordinate) are one
80/// vertex. Loose enough for the editor's single-precision display polylines.
81const ANCHOR_END_REL: f64 = 1e-5;
82
83/// Two segment ends closer than this (relative to the sketch's length scale)
84/// are the same junction when chaining.
85const CHAIN_JOIN_REL: f64 = 1e-7;
86
87pub fn execute(ctx: &FeatureContext) -> FeatureResult {
88    match build(ctx) {
89        Ok(output) => {
90            let mut result = FeatureResult::pass_through(ctx.id.clone(), ctx.feature_type.clone());
91            for entry in output.per_geometry {
92                result.paths.push(entry);
93            }
94            if let Some((chain, names)) = output.chain {
95                result.paths.push((ctx.id.clone(), chain));
96                result.path_segment_names.push((ctx.id.clone(), names));
97            }
98            result.axes = output.axes;
99            result.points = output.points;
100            result.unresolved = output.unresolved;
101            result.notes.extend(output.notes);
102            result
103        }
104        Err(error) => ctx.fail(error),
105    }
106}
107
108pub fn context_applicable(_probe: &crate::feature_pipeline::SelectionProbe) -> bool {
109    false
110}
111
112pub fn schema() -> Value {
113    json!({
114        "type": SKETCH3D_FEATURE_TYPE,
115        "shortName": SKETCH3D_FEATURE_TYPE,
116        "longName": "3D Sketch",
117        "ribbonPath": "Home/Reference/3D Sketch",
118        "commandSize": "Large",
119        "displayBuilder": true,
120        "inputParamsSchema": {
121            "id": {
122                "type": "string",
123                "default_value": null,
124                "hint": "unique identifier for the 3D sketch feature"
125            },
126            SKETCH3D_REFERENCES_PARAM: {
127                "type": "reference_selection",
128                "selectionFilter": ["VERTEX", "EDGE", "FACE", "PLANE"],
129                "multiple": true,
130                "default_value": null,
131                "label": "References",
132                "hint": "Model geometry the sketch is linked to: a vertex becomes a fixed point, an edge a fixed line or arc, a face or datum plane a reference plane, a cylinder its axis. Linked in the editor with the Link reference tool, or listed here; re-resolved on every regenerate."
133            },
134            SKETCH3D_EDIT_BUTTON: {
135                "type": "button",
136                "label": "Edit 3D Sketch",
137                "default_value": null,
138                "hint": "open the 3D sketch editor in the viewport"
139            }
140        }
141    })
142}
143
144// ---------------------------------------------------------------------------
145// Build
146// ---------------------------------------------------------------------------
147
148struct Output {
149    per_geometry: Vec<(String, Vec<NurbsCurve>)>,
150    chain: Option<(Vec<NurbsCurve>, Vec<Option<String>>)>,
151    axes: Vec<(String, Axis)>,
152    points: Vec<(String, ScenePoint)>,
153    unresolved: Vec<String>,
154    notes: Vec<String>,
155}
156
157fn build(ctx: &FeatureContext) -> Result<Output, String> {
158    let mut unresolved: Vec<String> = Vec::new();
159    let mut notes: Vec<String> = Vec::new();
160
161    // A brand-new 3D sketch has no document yet (the app seeds an empty
162    // `persistentData`), which is a legitimate state: nothing to publish.
163    let mut sketch = match ctx.persistent.get(SKETCH3D_DOC_KEY) {
164        Some(value) if value.is_object() => value.clone(),
165        Some(value) => {
166            return Err(format!(
167                "3D sketch: `persistentData.{SKETCH3D_DOC_KEY}` must be an object, got {value}"
168            ));
169        }
170        None => {
171            // The dialog's references are still validated, so a listed name
172            // that does not resolve is reported from the first run.
173            for name in common::reference_names(ctx.param(SKETCH3D_REFERENCES_PARAM)) {
174                if resolve_reference(ctx.scene, &name).is_none() {
175                    unresolved.push(name);
176                }
177            }
178            return Ok(Output {
179                per_geometry: Vec::new(),
180                chain: None,
181                axes: Vec::new(),
182                points: Vec::new(),
183                unresolved,
184                notes,
185            });
186        }
187    };
188
189    pre_evaluate_expressions(ctx, &mut sketch)?;
190    let mut doc: Sketch3dDoc = serde_json::from_value(sketch)
191        .map_err(|error| format!("3D sketch: invalid document: {error}"))?;
192
193    // Re-resolve the linked references and move their points to where the
194    // model entity is now.
195    let refs = ctx
196        .persistent
197        .get(SKETCH3D_REFS_KEY)
198        .and_then(Value::as_array)
199        .cloned()
200        .unwrap_or_default();
201    let mut materialized: std::collections::HashSet<String> = std::collections::HashSet::new();
202    for entry in &refs {
203        let Some(name) = entry.get("name").and_then(Value::as_str) else {
204            continue;
205        };
206        materialized.insert(name.to_string());
207        let kind = entry.get("kind").and_then(Value::as_str).unwrap_or("");
208        let ids: Vec<Value> = entry
209            .get("points")
210            .and_then(Value::as_array)
211            .cloned()
212            .unwrap_or_default();
213        // A vertex with a topological anchor is found through its edges; its
214        // coordinate name is stale the moment the vertex moves, and may even
215        // name a different vertex by then, so a failed anchor is a lost
216        // reference and never falls back to the name.
217        let resolved = match anchor_edge_names(entry) {
218            Some(anchors) => resolve_anchored_vertex(ctx.scene, &anchors).map(ResolvedReference::Point),
219            None => resolve_reference(ctx.scene, name),
220        };
221        let resolved = resolved.map(|r| r.as_recorded(kind));
222        match resolved {
223            Some(resolved) if resolved.kind() == kind && resolved.positions().len() == ids.len() => {
224                // A plane is (origin, normal) and an axis line (point,
225                // direction): any origin ON the plane / line is as good as
226                // the one the exact BREP reports, so the editor's own origin
227                // is kept, projected onto the resolved entity, and only its
228                // tip follows the resolved direction. A regenerate then moves
229                // nothing the user placed. A model EDGE (a segment, a circle)
230                // is exact like a point and an arc: its ends and centre are
231                // what a midpoint or centre constraint is measured from.
232                let positions = resolved.positions();
233                let positions = match resolved {
234                    ResolvedReference::Plane(origin, tip) | ResolvedReference::Line(origin, tip) => {
235                        let direction = tip.sub(origin);
236                        let editor_origin = ids
237                            .first()
238                            .and_then(|id| doc.point(id))
239                            .map(|p| p.position())
240                            .unwrap_or(origin);
241                        let kept = match direction.normalized() {
242                            Ok(unit) => {
243                                let offset = editor_origin.sub(origin);
244                                if kind == "plane" {
245                                    editor_origin.sub(unit.scale(offset.dot(unit)))
246                                } else {
247                                    origin.add(unit.scale(offset.dot(unit)))
248                                }
249                            }
250                            Err(_) => origin,
251                        };
252                        vec![kept, kept.add(direction)]
253                    }
254                    _ => positions,
255                };
256                for (id, position) in ids.iter().zip(positions) {
257                    if let Some(point) = doc.point_mut(id) {
258                        point.x = position.x;
259                        point.y = position.y;
260                        point.z = position.z;
261                        point.fixed = true;
262                    }
263                }
264            }
265            // Resolved to a different shape than the editor materialized (an
266            // edge that became an arc after a model edit) or not at all: keep
267            // the last coordinates and say so.
268            _ => {
269                notes.push(lost_reference_note(&doc, name, &ids));
270                unresolved.push(name.to_string());
271            }
272        }
273    }
274    // Names the dialog lists that the editor has not materialized yet are
275    // validated too; the editor materializes them on its next entry.
276    for name in common::reference_names(ctx.param(SKETCH3D_REFERENCES_PARAM)) {
277        if materialized.contains(&name) {
278            continue;
279        }
280        if resolve_reference(ctx.scene, &name).is_none() {
281            unresolved.push(name);
282        }
283    }
284
285    // Solve to final coordinates.
286    let diagnostics = crate::solve_sketch3d_doc(&mut doc, &Sketch3dSolveOptions::default())?;
287    if diagnostics.conflicting {
288        return Err(format!(
289            "3D sketch: conflicting constraints ({}): the sketch has no solution near its current geometry",
290            diagnostics.conflicting_constraints.join(", ")
291        ));
292    }
293
294    let scale = doc
295        .points
296        .iter()
297        .fold(1.0f64, |acc, p| acc.max(p.x.abs()).max(p.y.abs()).max(p.z.abs()));
298
299    // Publish every point, construction flag included.
300    let mut points: Vec<(String, ScenePoint)> = Vec::with_capacity(doc.points.len());
301    for point in &doc.points {
302        points.push((
303            format!("{}:P{}", ctx.id, id_display(&point.id)),
304            ScenePoint {
305                position: point.position(),
306                construction: point.construction,
307            },
308        ));
309    }
310
311    // Curves per model geometry; axes per line.
312    let mut per_geometry: Vec<(String, Vec<NurbsCurve>)> = Vec::new();
313    let mut axes: Vec<(String, Axis)> = Vec::new();
314    let mut segments: Vec<Segment> = Vec::new();
315    for geometry in &doc.geometries {
316        let name = format!("{}:G{}", ctx.id, id_display(&geometry.id));
317        let Some(positions) = doc.geometry_positions(geometry) else {
318            return Err(format!(
319                "3D sketch: geometry {} references a missing point",
320                id_display(&geometry.id)
321            ));
322        };
323        if geometry.geom_type == "line" && positions.len() == 2 {
324            if let Ok(direction) = positions[1].sub(positions[0]).normalized() {
325                axes.push((name.clone(), Axis { point: positions[0], direction }));
326            }
327        }
328        if geometry.construction() {
329            continue;
330        }
331        let curve = match (geometry.geom_type.as_str(), positions.len()) {
332            ("point", _) => continue,
333            ("line", 2) => {
334                if positions[1].sub(positions[0]).length() <= 1e-9 * scale {
335                    return Err(format!("3D sketch: line {} has zero length", id_display(&geometry.id)));
336                }
337                make_line(positions[0], positions[1])?
338            }
339            ("arc", 3) => arc_curve(positions[0], positions[1], positions[2], scale)
340                .map_err(|error| format!("3D sketch: arc {}: {error}", id_display(&geometry.id)))?,
341            ("bezier", n) if n >= 4 && (n - 1) % 3 == 0 => bezier_chain_curve(&positions)?,
342            (other, n) => {
343                return Err(format!(
344                    "3D sketch: geometry {} is a '{other}' with {n} points, which is not a line, arc or bezier",
345                    id_display(&geometry.id)
346                ));
347            }
348        };
349        let [t0, t1] = curve.domain()?;
350        segments.push(Segment {
351            name: name.clone(),
352            start: curve.evaluate(t0)?,
353            end: curve.evaluate(t1)?,
354            curve: curve.clone(),
355        });
356        per_geometry.push((name, vec![curve]));
357    }
358
359    let chain = longest_chain(&segments, CHAIN_JOIN_REL * scale);
360
361    Ok(Output {
362        per_geometry,
363        chain,
364        axes,
365        points,
366        unresolved,
367        notes,
368    })
369}
370
371/// What the run says about a linked reference that stopped resolving: the
372/// reference by name and every constraint that depends on it.
373fn lost_reference_note(doc: &Sketch3dDoc, name: &str, ids: &[Value]) -> String {
374    let needed: Vec<String> = doc
375        .constraints
376        .iter()
377        .filter(|c| c.points().iter().any(|p| ids.iter().any(|id| crate::sketch_id_key(id) == crate::sketch_id_key(p))))
378        .map(|c| format!("{} {}", c.ctype().unwrap_or("?"), c.id().map(id_display).unwrap_or_else(|| "?".into())))
379        .collect();
380    if needed.is_empty() {
381        format!("3D sketch: reference '{name}' no longer resolves; it keeps its last position")
382    } else {
383        format!(
384            "3D sketch: reference '{name}' no longer resolves; it keeps its last position, needed by constraint {}",
385            needed.join(", ")
386        )
387    }
388}
389
390fn anchor_edge_names(entry: &Value) -> Option<Vec<String>> {
391    let names: Vec<String> = entry
392        .get(REFERENCE_ANCHOR_EDGES_KEY)?
393        .as_array()?
394        .iter()
395        .filter_map(|n| n.as_str().map(String::from))
396        .collect();
397    (names.len() == 2).then_some(names)
398}
399
400/// The one end two edges share, `None` when they share no end or both ends
401/// (two halves of a seamed rim say nothing about which end is meant).
402pub fn common_edge_end(a: (Vec3, Vec3), b: (Vec3, Vec3)) -> Option<Vec3> {
403    let scale = [a.0, a.1, b.0, b.1]
404        .iter()
405        .fold(1.0f64, |m, p| m.max(p.x.abs()).max(p.y.abs()).max(p.z.abs()));
406    let tol = ANCHOR_END_REL * scale;
407    let mut shared: Vec<Vec3> = Vec::new();
408    for end in [a.0, a.1] {
409        if [b.0, b.1].iter().any(|other| end.sub(*other).length() <= tol) {
410            shared.push(end);
411        }
412    }
413    match shared.as_slice() {
414        [only] => Some(*only),
415        [first, second] if first.sub(*second).length() <= tol => Some(*first),
416        _ => None,
417    }
418}
419
420fn resolve_anchored_vertex(scene: &SceneMap, anchors: &[String]) -> Option<Vec3> {
421    let ends = |name: &String| -> Option<(Vec3, Vec3)> {
422        let curve = common::edge_curve(scene.resolve_edge(name)?).ok()?;
423        let [t0, t1] = curve.domain().ok()?;
424        Some((curve.evaluate(t0).ok()?, curve.evaluate(t1).ok()?))
425    };
426    common_edge_end(ends(anchors.first()?)?, ends(anchors.get(1)?)?)
427}
428
429/// A geometry's `{id}:G{id}` / `{id}:P{id}` suffix: a number prints bare, a
430/// string prints as itself.
431fn id_display(id: &Value) -> String {
432    match id {
433        Value::String(text) => text.clone(),
434        other => crate::sketch_id_key(other),
435    }
436}
437
438// ---------------------------------------------------------------------------
439// Expressions
440// ---------------------------------------------------------------------------
441
442fn eval_expr(ctx: &FeatureContext, what: &str, value: &Value) -> Result<Option<f64>, String> {
443    let Value::String(source) = value else {
444        return Ok(None);
445    };
446    if source.trim().is_empty() {
447        return Ok(None);
448    }
449    let number = ctx
450        .env
451        .eval(source)
452        .map_err(|error| format!("3D sketch: {what} = `{source}`: {error}"))?;
453    if !number.is_finite() {
454        return Err(format!("3D sketch: {what} = `{source}` evaluated to {number}"));
455    }
456    Ok(Some(number))
457}
458
459/// Substitute expression strings in point coordinates (`x`/`y`/`z`) and
460/// constraint values (`valueExpr`, else a string `value`) with their numbers.
461fn pre_evaluate_expressions(ctx: &FeatureContext, sketch: &mut Value) -> Result<(), String> {
462    if let Some(points) = sketch.get_mut("points").and_then(Value::as_array_mut) {
463        for point in points {
464            let id = point.get("id").map(|id| id.to_string()).unwrap_or_else(|| "?".into());
465            for key in ["x", "y", "z"] {
466                let Some(value) = point.get(key).cloned() else { continue };
467                if let Some(number) = eval_expr(ctx, &format!("point {id} `{key}`"), &value)? {
468                    point[key] = Value::from(number);
469                }
470            }
471        }
472    }
473    if let Some(constraints) = sketch.get_mut("constraints").and_then(Value::as_array_mut) {
474        for constraint in constraints {
475            let id = constraint.get("id").map(|id| id.to_string()).unwrap_or_else(|| "?".into());
476            let expr = constraint
477                .get("valueExpr")
478                .cloned()
479                .map(|value| eval_expr(ctx, &format!("constraint {id} `valueExpr`"), &value))
480                .transpose()?
481                .flatten();
482            let evaluated = match expr {
483                Some(number) => Some(number),
484                None => constraint
485                    .get("value")
486                    .cloned()
487                    .map(|value| eval_expr(ctx, &format!("constraint {id} `value`"), &value))
488                    .transpose()?
489                    .flatten(),
490            };
491            if let Some(number) = evaluated {
492                constraint["value"] = Value::from(number);
493            }
494        }
495    }
496    Ok(())
497}
498
499// ---------------------------------------------------------------------------
500// References
501// ---------------------------------------------------------------------------
502
503/// What a model reference resolves to, in the shapes the sketch materializes.
504#[derive(Clone, Debug)]
505pub enum ResolvedReference {
506    /// A vertex, a published point, a sphere's centre: one fixed point.
507    Point(Vec3),
508    /// A datum axis or a cylinder's axis, an infinite line in all but name: a
509    /// fixed construction line `[a, b]` whose origin is free to slide.
510    Line(Vec3, Vec3),
511    /// A model EDGE, straight or a best-effort chord: `[start, end]`, exact.
512    Segment(Vec3, Vec3),
513    /// A closed circular edge: `[center, center + axis * radius]`, exact, so
514    /// the centre follows the model and the tip carries the radius.
515    CircleAxis(Vec3, Vec3),
516    /// A circular arc edge: `[center, start, end]`.
517    Arc(Vec3, Vec3, Vec3),
518    /// A planar face or a datum/construction plane: `[origin, normalTip]`.
519    Plane(Vec3, Vec3),
520    /// A free-form edge: the control points of a fitted cubic bezier chain
521    /// (see [`curve_reference_controls`]).
522    Curve(Vec<Vec3>),
523}
524
525/// How densely a free-form edge is sampled for its fit.
526const REFERENCE_CURVE_SAMPLES: usize = 128;
527
528/// How many cubic spans a free-form edge is fitted with. Fixed, so a rebuild
529/// updates the same control points in place.
530pub const REFERENCE_CURVE_SPANS: usize = 8;
531
532/// The control points (`3 * REFERENCE_CURVE_SPANS + 1`) of a C1 cubic bezier
533/// chain through samples of a curve, its anchors spaced evenly by arc length.
534/// `None` for fewer than two samples or a curve of no length.
535pub fn curve_reference_controls(samples: &[Vec3]) -> Option<Vec<Vec3>> {
536    if samples.len() < 2 {
537        return None;
538    }
539    let mut cumulative = Vec::with_capacity(samples.len());
540    let mut total = 0.0;
541    cumulative.push(0.0);
542    for pair in samples.windows(2) {
543        total += pair[1].sub(pair[0]).length();
544        cumulative.push(total);
545    }
546    if total <= 0.0 {
547        return None;
548    }
549    let spans = REFERENCE_CURVE_SPANS;
550    let mut segment = 0;
551    let anchors: Vec<Vec3> = (0..=spans)
552        .map(|i| {
553            let at = total * i as f64 / spans as f64;
554            while segment + 2 < samples.len() && cumulative[segment + 1] < at {
555                segment += 1;
556            }
557            let length = cumulative[segment + 1] - cumulative[segment];
558            let t = if length > 0.0 { ((at - cumulative[segment]) / length).clamp(0.0, 1.0) } else { 0.0 };
559            samples[segment].add(samples[segment + 1].sub(samples[segment]).scale(t))
560        })
561        .collect();
562    let closed = samples[0].sub(samples[samples.len() - 1]).length() <= 1e-9 * total;
563    let tangent = |i: usize| -> Vec3 {
564        let (before, after) = match i {
565            0 if closed => (anchors[spans - 1], anchors[1]),
566            0 => (anchors[0], anchors[1]),
567            n if n == spans && closed => (anchors[spans - 1], anchors[1]),
568            n if n == spans => (anchors[spans - 1], anchors[spans]),
569            n => (anchors[n - 1], anchors[n + 1]),
570        };
571        let scale = if !closed && (i == 0 || i == spans) { 1.0 / 3.0 } else { 1.0 / 6.0 };
572        after.sub(before).scale(scale)
573    };
574    let mut controls = Vec::with_capacity(3 * spans + 1);
575    for i in 0..spans {
576        controls.push(anchors[i]);
577        controls.push(anchors[i].add(tangent(i)));
578        controls.push(anchors[i + 1].sub(tangent(i + 1)));
579    }
580    controls.push(anchors[spans]);
581    Some(controls)
582}
583
584impl ResolvedReference {
585    /// The `kind` the materialization record stores.
586    pub fn kind(&self) -> &'static str {
587        match self {
588            ResolvedReference::Point(..) => "point",
589            // A saved record says "line" for all three; the string must not
590            // change or re-linking would re-mint the points under constraints.
591            ResolvedReference::Line(..) | ResolvedReference::Segment(..) | ResolvedReference::CircleAxis(..) => "line",
592            ResolvedReference::Arc(..) => "arc",
593            ResolvedReference::Plane(..) => "plane",
594            ResolvedReference::Curve(..) => "curve",
595        }
596    }
597
598    /// As a saved record of `kind` holds it: a free-form edge linked before
599    /// curves existed is a `line` chord, and stays one so the points under its
600    /// constraints are not re-minted.
601    pub fn as_recorded(self, kind: &str) -> ResolvedReference {
602        match self {
603            ResolvedReference::Curve(controls) if kind == "line" && controls.len() >= 2 => {
604                ResolvedReference::Segment(controls[0], controls[controls.len() - 1])
605            }
606            other => other,
607        }
608    }
609
610    /// The points it materializes, in record order.
611    pub fn positions(&self) -> Vec<Vec3> {
612        match *self {
613            ResolvedReference::Point(p) => vec![p],
614            ResolvedReference::Line(a, b) | ResolvedReference::Segment(a, b) | ResolvedReference::CircleAxis(a, b) => vec![a, b],
615            ResolvedReference::Arc(c, s, e) => vec![c, s, e],
616            ResolvedReference::Plane(o, n) => vec![o, n],
617            ResolvedReference::Curve(ref controls) => controls.clone(),
618        }
619    }
620
621    /// The construction geometry type the editor draws it with (`None` for a
622    /// lone point).
623    pub fn geometry_type(&self) -> Option<&'static str> {
624        match self {
625            ResolvedReference::Point(..) => None,
626            ResolvedReference::Line(..) | ResolvedReference::Segment(..) | ResolvedReference::CircleAxis(..) => Some("line"),
627            ResolvedReference::Arc(..) => Some("arc"),
628            ResolvedReference::Plane(..) => Some("plane"),
629            ResolvedReference::Curve(..) => Some("bezier"),
630        }
631    }
632}
633
634/// Resolve a reference name against the live scene: a published point
635/// (`{feature}:P{n}`, a waypoint's `Base`), a datum / construction PLANE
636/// frame, a published AXIS, a resident FACE (planar → plane, axis-bearing →
637/// axis line, sphere → centre), a resident EDGE (straight → line, arc →
638/// arc, closed circle → its axis line), or a `{solid}@x,y,z` VERTEX. `None`
639/// when nothing carries the name or the entity has no analytic shape.
640pub fn resolve_reference(scene: &SceneMap, name: &str) -> Option<ResolvedReference> {
641    if let Some(point) = scene.resolve_point(name) {
642        return Some(ResolvedReference::Point(point));
643    }
644    if let Some(frame) = scene.resolve_frame(name) {
645        return Some(ResolvedReference::Plane(
646            frame.origin,
647            frame.origin.add(frame.z_axis.scale(REFERENCE_TIP_LENGTH)),
648        ));
649    }
650    if let Some(axis) = scene.resolve_axis(name) {
651        return Some(ResolvedReference::Line(
652            axis.point,
653            axis.point.add(axis.direction.scale(REFERENCE_TIP_LENGTH)),
654        ));
655    }
656    if let Some(face) = scene.resolve_face(name) {
657        let geometry = crate::with_registered_solid_str(face.handle, |solid| {
658            resolve_face_selection(solid, face.face_id).map_err(|e| format!("{e:?}"))
659        })
660        .ok()?;
661        return Some(match geometry {
662            SelectionGeometry::Plane { origin, normal } => {
663                ResolvedReference::Plane(origin, origin.add(normal.scale(REFERENCE_TIP_LENGTH)))
664            }
665            SelectionGeometry::Axis { origin, direction, .. } => {
666                ResolvedReference::Line(origin, origin.add(direction.scale(REFERENCE_TIP_LENGTH)))
667            }
668            SelectionGeometry::Sphere { center, .. } => ResolvedReference::Point(center),
669            _ => return None,
670        });
671    }
672    if let Some(edge) = scene.resolve_edge(name) {
673        let curve = common::edge_curve(edge).ok()?;
674        let [t0, t1] = curve.domain().ok()?;
675        let start = curve.evaluate(t0).ok()?;
676        let end = curve.evaluate(t1).ok()?;
677        let geometry = crate::with_registered_solid_str(edge.handle, |solid| {
678            resolve_edge_selection(solid, edge.edge_id).map_err(|e| format!("{e:?}"))
679        });
680        return Some(match geometry {
681            Ok(SelectionGeometry::Circle { center, axis, radius }) => {
682                if end.sub(start).length() <= 1e-9 * radius.max(1.0) {
683                    // A closed circle: its axis is what a pipe lines up with.
684                    ResolvedReference::CircleAxis(center, center.add(axis.scale(radius)))
685                } else {
686                    ResolvedReference::Arc(center, start, end)
687                }
688            }
689            Ok(SelectionGeometry::Line { .. }) => ResolvedReference::Segment(start, end),
690            // A spline edge or anything without an analytic frame: a fitted
691            // bezier chain, or its chord if the curve will not sample.
692            _ => {
693                let samples: Option<Vec<Vec3>> = (0..=REFERENCE_CURVE_SAMPLES)
694                    .map(|i| curve.evaluate(t0 + (t1 - t0) * i as f64 / REFERENCE_CURVE_SAMPLES as f64).ok())
695                    .collect();
696                match samples.as_deref().and_then(curve_reference_controls) {
697                    Some(controls) => ResolvedReference::Curve(controls),
698                    None => ResolvedReference::Segment(start, end),
699                }
700            }
701        });
702    }
703    if let Some((solid_name, coords)) = name.rsplit_once('@') {
704        let mut parts = coords.split(',').map(|p| p.trim().parse::<f64>().ok());
705        let position = Vec3::new(parts.next()??, parts.next()??, parts.next()??);
706        if parts.next().is_some() {
707            return None;
708        }
709        let handle = scene.resolve_solid(solid_name)?;
710        let geometry = crate::with_registered_solid_str(handle, |solid| {
711            resolve_vertex_selection(solid, position).map_err(|e| format!("{e:?}"))
712        })
713        .ok()?;
714        if let SelectionGeometry::Point { position } = geometry {
715            return Some(ResolvedReference::Point(position));
716        }
717    }
718    None
719}
720
721// ---------------------------------------------------------------------------
722// Curves
723// ---------------------------------------------------------------------------
724
725/// The exact rational arc from `start` to `end` about `center`, in the plane
726/// of the three points — the MINOR arc (a sweep below π). Collinear points
727/// have no plane and are refused.
728pub fn arc_curve(center: Vec3, start: Vec3, end: Vec3, scale: f64) -> Result<NurbsCurve, String> {
729    let rs = start.sub(center);
730    let re = end.sub(center);
731    let radius = rs.length();
732    if radius <= 1e-9 * scale {
733        return Err("zero radius".into());
734    }
735    let normal = rs.cross(re);
736    if normal.length() <= 1e-9 * radius * radius {
737        return Err("center, start and end are collinear (no arc plane)".into());
738    }
739    let x_axis = rs.normalized()?;
740    let y_axis = normal.normalized()?.cross(x_axis).normalized()?;
741    // The solver holds the radii equal; the end angle is measured on the
742    // start radius so a residual difference never changes the sweep.
743    let sweep = re.dot(y_axis).atan2(re.dot(x_axis));
744    let sweep = if sweep <= 0.0 { sweep + std::f64::consts::TAU } else { sweep };
745    make_arc(center, x_axis, y_axis, radius, 0.0, sweep)
746}
747
748/// One clamped degree-3 B-spline through a chain of cubic Bézier spans
749/// (`3n+1` control points), interior knots of multiplicity 3 — the planar
750/// sketch's `bezier_chain_curve`, in world space.
751pub fn bezier_chain_curve(controls: &[Vec3]) -> Result<NurbsCurve, String> {
752    let span_count = (controls.len().saturating_sub(1)) / 3;
753    if span_count == 0 || controls.len() != span_count * 3 + 1 {
754        return Err(format!(
755            "3D sketch: bezier needs 3n+1 control points, got {}",
756            controls.len()
757        ));
758    }
759    let mut knots = vec![0.0; 4];
760    for join in 1..span_count {
761        knots.extend_from_slice(&[join as f64; 3]);
762    }
763    knots.extend_from_slice(&[span_count as f64; 4]);
764    let points = controls.iter().map(|p| Vec4::from_point(*p, 1.0)).collect();
765    NurbsCurve::new(3, knots, points)
766}
767
768// ---------------------------------------------------------------------------
769// Chaining
770// ---------------------------------------------------------------------------
771
772struct Segment {
773    name: String,
774    start: Vec3,
775    end: Vec3,
776    curve: NurbsCurve,
777}
778
779/// The longest head-to-tail chain through the model segments (segments
780/// reversed as needed), with each curve's source name. Segments are joined
781/// where an end of one lies within `tol` of an end of another; a junction
782/// where three or more ends meet (a frame node) ends the chain there. `None`
783/// when there are no segments.
784fn longest_chain(segments: &[Segment], tol: f64) -> Option<(Vec<NurbsCurve>, Vec<Option<String>>)> {
785    if segments.is_empty() {
786        return None;
787    }
788    let close = |a: Vec3, b: Vec3| a.sub(b).length() <= tol;
789    // Ends meeting at a point: (segment, is_end) per end.
790    let degree_at = |p: Vec3| -> usize {
791        segments
792            .iter()
793            .map(|s| usize::from(close(s.start, p)) + usize::from(close(s.end, p)))
794            .sum()
795    };
796    let mut best: Option<Vec<(usize, bool)>> = None;
797    // Grow a chain from every segment in both orientations; keep the longest.
798    for (seed, _) in segments.iter().enumerate() {
799        for seed_reversed in [false, true] {
800            let mut chain: Vec<(usize, bool)> = vec![(seed, seed_reversed)];
801            let mut used = vec![false; segments.len()];
802            used[seed] = true;
803            // Extend from the tail.
804            loop {
805                let (last, reversed) = *chain.last().unwrap();
806                let tail = if reversed { segments[last].start } else { segments[last].end };
807                if degree_at(tail) != 2 {
808                    break;
809                }
810                let next = segments.iter().enumerate().find_map(|(i, s)| {
811                    if used[i] {
812                        return None;
813                    }
814                    if close(s.start, tail) {
815                        Some((i, false))
816                    } else if close(s.end, tail) {
817                        Some((i, true))
818                    } else {
819                        None
820                    }
821                });
822                match next {
823                    Some((i, reversed)) => {
824                        used[i] = true;
825                        chain.push((i, reversed));
826                    }
827                    None => break,
828                }
829            }
830            // Extend from the head.
831            loop {
832                let (first, reversed) = chain[0];
833                let head = if reversed { segments[first].end } else { segments[first].start };
834                if degree_at(head) != 2 {
835                    break;
836                }
837                let prev = segments.iter().enumerate().find_map(|(i, s)| {
838                    if used[i] {
839                        return None;
840                    }
841                    if close(s.end, head) {
842                        Some((i, false))
843                    } else if close(s.start, head) {
844                        Some((i, true))
845                    } else {
846                        None
847                    }
848                });
849                match prev {
850                    Some((i, reversed)) => {
851                        used[i] = true;
852                        chain.insert(0, (i, reversed));
853                    }
854                    None => break,
855                }
856            }
857            if best.as_ref().map_or(true, |b| chain.len() > b.len()) {
858                best = Some(chain);
859            }
860        }
861    }
862    let chain = best?;
863    let mut curves = Vec::with_capacity(chain.len());
864    let mut names = Vec::with_capacity(chain.len());
865    for (i, reversed) in chain {
866        let curve = if reversed {
867            segments[i].curve.reversed().ok()?
868        } else {
869            segments[i].curve.clone()
870        };
871        curves.push(curve);
872        names.push(Some(segments[i].name.clone()));
873    }
874    Some((curves, names))
875}
876
877/// A materialization record entry for a resolved reference — the editor's
878/// side of [`resolve_reference`] (kept here so the record's shape has one
879/// author).
880pub fn reference_record(name: &str, resolved: &ResolvedReference, point_ids: &[Value], geometry_id: Option<&Value>) -> Value {
881    let mut map = Map::new();
882    map.insert("name".into(), Value::String(name.to_string()));
883    map.insert("kind".into(), Value::String(resolved.kind().to_string()));
884    map.insert("points".into(), Value::Array(point_ids.to_vec()));
885    map.insert(
886        "geometry".into(),
887        geometry_id.cloned().unwrap_or(Value::Null),
888    );
889    Value::Object(map)
890}
891