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/// Two segment ends closer than this (relative to the sketch's length scale)
75/// are the same junction when chaining.
76const CHAIN_JOIN_REL: f64 = 1e-7;
77
78pub fn execute(ctx: &FeatureContext) -> FeatureResult {
79    match build(ctx) {
80        Ok(output) => {
81            let mut result = FeatureResult::pass_through(ctx.id.clone(), ctx.feature_type.clone());
82            for entry in output.per_geometry {
83                result.paths.push(entry);
84            }
85            if let Some((chain, names)) = output.chain {
86                result.paths.push((ctx.id.clone(), chain));
87                result.path_segment_names.push((ctx.id.clone(), names));
88            }
89            result.axes = output.axes;
90            result.points = output.points;
91            result.unresolved = output.unresolved;
92            result
93        }
94        Err(error) => ctx.fail(error),
95    }
96}
97
98pub fn context_applicable(_probe: &crate::feature_pipeline::SelectionProbe) -> bool {
99    false
100}
101
102pub fn schema() -> Value {
103    json!({
104        "type": SKETCH3D_FEATURE_TYPE,
105        "shortName": SKETCH3D_FEATURE_TYPE,
106        "longName": "3D Sketch",
107        "ribbonPath": "Home/Reference/3D Sketch",
108        "commandSize": "Large",
109        "displayBuilder": true,
110        "inputParamsSchema": {
111            "id": {
112                "type": "string",
113                "default_value": null,
114                "hint": "unique identifier for the 3D sketch feature"
115            },
116            SKETCH3D_REFERENCES_PARAM: {
117                "type": "reference_selection",
118                "selectionFilter": ["VERTEX", "EDGE", "FACE", "PLANE"],
119                "multiple": true,
120                "default_value": null,
121                "label": "References",
122                "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."
123            },
124            SKETCH3D_EDIT_BUTTON: {
125                "type": "button",
126                "label": "Edit 3D Sketch",
127                "default_value": null,
128                "hint": "open the 3D sketch editor in the viewport"
129            }
130        }
131    })
132}
133
134// ---------------------------------------------------------------------------
135// Build
136// ---------------------------------------------------------------------------
137
138struct Output {
139    per_geometry: Vec<(String, Vec<NurbsCurve>)>,
140    chain: Option<(Vec<NurbsCurve>, Vec<Option<String>>)>,
141    axes: Vec<(String, Axis)>,
142    points: Vec<(String, ScenePoint)>,
143    unresolved: Vec<String>,
144}
145
146fn build(ctx: &FeatureContext) -> Result<Output, String> {
147    let mut unresolved: Vec<String> = Vec::new();
148
149    // A brand-new 3D sketch has no document yet (the app seeds an empty
150    // `persistentData`), which is a legitimate state: nothing to publish.
151    let mut sketch = match ctx.persistent.get(SKETCH3D_DOC_KEY) {
152        Some(value) if value.is_object() => value.clone(),
153        Some(value) => {
154            return Err(format!(
155                "3D sketch: `persistentData.{SKETCH3D_DOC_KEY}` must be an object, got {value}"
156            ));
157        }
158        None => {
159            // The dialog's references are still validated, so a listed name
160            // that does not resolve is reported from the first run.
161            for name in common::reference_names(ctx.param(SKETCH3D_REFERENCES_PARAM)) {
162                if resolve_reference(ctx.scene, &name).is_none() {
163                    unresolved.push(name);
164                }
165            }
166            return Ok(Output {
167                per_geometry: Vec::new(),
168                chain: None,
169                axes: Vec::new(),
170                points: Vec::new(),
171                unresolved,
172            });
173        }
174    };
175
176    pre_evaluate_expressions(ctx, &mut sketch)?;
177    let mut doc: Sketch3dDoc = serde_json::from_value(sketch)
178        .map_err(|error| format!("3D sketch: invalid document: {error}"))?;
179
180    // Re-resolve the linked references and move their points to where the
181    // model entity is now.
182    let refs = ctx
183        .persistent
184        .get(SKETCH3D_REFS_KEY)
185        .and_then(Value::as_array)
186        .cloned()
187        .unwrap_or_default();
188    let mut materialized: std::collections::HashSet<String> = std::collections::HashSet::new();
189    for entry in &refs {
190        let Some(name) = entry.get("name").and_then(Value::as_str) else {
191            continue;
192        };
193        materialized.insert(name.to_string());
194        let kind = entry.get("kind").and_then(Value::as_str).unwrap_or("");
195        let ids: Vec<Value> = entry
196            .get("points")
197            .and_then(Value::as_array)
198            .cloned()
199            .unwrap_or_default();
200        match resolve_reference(ctx.scene, name) {
201            Some(resolved) if resolved.kind() == kind && resolved.positions().len() == ids.len() => {
202                // A plane is (origin, normal) and an axis line (point,
203                // direction): any origin ON the plane / line is as good as
204                // the one the exact BREP reports, so the editor's own origin
205                // is kept, projected onto the resolved entity, and only its
206                // tip follows the resolved direction. A regenerate then moves
207                // nothing the user placed; a point and an arc are exact.
208                let positions = resolved.positions();
209                let positions = match resolved {
210                    ResolvedReference::Plane(origin, tip) | ResolvedReference::Line(origin, tip) => {
211                        let direction = tip.sub(origin);
212                        let editor_origin = ids
213                            .first()
214                            .and_then(|id| doc.point(id))
215                            .map(|p| p.position())
216                            .unwrap_or(origin);
217                        let kept = match direction.normalized() {
218                            Ok(unit) => {
219                                let offset = editor_origin.sub(origin);
220                                if kind == "plane" {
221                                    editor_origin.sub(unit.scale(offset.dot(unit)))
222                                } else {
223                                    origin.add(unit.scale(offset.dot(unit)))
224                                }
225                            }
226                            Err(_) => origin,
227                        };
228                        vec![kept, kept.add(direction)]
229                    }
230                    _ => positions,
231                };
232                for (id, position) in ids.iter().zip(positions) {
233                    if let Some(point) = doc.point_mut(id) {
234                        point.x = position.x;
235                        point.y = position.y;
236                        point.z = position.z;
237                        point.fixed = true;
238                    }
239                }
240            }
241            // Resolved to a different shape than the editor materialized (an
242            // edge that became an arc after a model edit) or not at all: keep
243            // the last coordinates and say so.
244            _ => unresolved.push(name.to_string()),
245        }
246    }
247    // Names the dialog lists that the editor has not materialized yet are
248    // validated too; the editor materializes them on its next entry.
249    for name in common::reference_names(ctx.param(SKETCH3D_REFERENCES_PARAM)) {
250        if materialized.contains(&name) {
251            continue;
252        }
253        if resolve_reference(ctx.scene, &name).is_none() {
254            unresolved.push(name);
255        }
256    }
257
258    // Solve to final coordinates.
259    let diagnostics = crate::solve_sketch3d_doc(&mut doc, &Sketch3dSolveOptions::default())?;
260    if diagnostics.conflicting {
261        return Err(format!(
262            "3D sketch: conflicting constraints ({}): the sketch has no solution near its current geometry",
263            diagnostics.conflicting_constraints.join(", ")
264        ));
265    }
266
267    let scale = doc
268        .points
269        .iter()
270        .fold(1.0f64, |acc, p| acc.max(p.x.abs()).max(p.y.abs()).max(p.z.abs()));
271
272    // Publish every point, construction flag included.
273    let mut points: Vec<(String, ScenePoint)> = Vec::with_capacity(doc.points.len());
274    for point in &doc.points {
275        points.push((
276            format!("{}:P{}", ctx.id, id_display(&point.id)),
277            ScenePoint {
278                position: point.position(),
279                construction: point.construction,
280            },
281        ));
282    }
283
284    // Curves per model geometry; axes per line.
285    let mut per_geometry: Vec<(String, Vec<NurbsCurve>)> = Vec::new();
286    let mut axes: Vec<(String, Axis)> = Vec::new();
287    let mut segments: Vec<Segment> = Vec::new();
288    for geometry in &doc.geometries {
289        let name = format!("{}:G{}", ctx.id, id_display(&geometry.id));
290        let Some(positions) = doc.geometry_positions(geometry) else {
291            return Err(format!(
292                "3D sketch: geometry {} references a missing point",
293                id_display(&geometry.id)
294            ));
295        };
296        if geometry.geom_type == "line" && positions.len() == 2 {
297            if let Ok(direction) = positions[1].sub(positions[0]).normalized() {
298                axes.push((name.clone(), Axis { point: positions[0], direction }));
299            }
300        }
301        if geometry.construction() {
302            continue;
303        }
304        let curve = match (geometry.geom_type.as_str(), positions.len()) {
305            ("point", _) => continue,
306            ("line", 2) => {
307                if positions[1].sub(positions[0]).length() <= 1e-9 * scale {
308                    return Err(format!("3D sketch: line {} has zero length", id_display(&geometry.id)));
309                }
310                make_line(positions[0], positions[1])?
311            }
312            ("arc", 3) => arc_curve(positions[0], positions[1], positions[2], scale)
313                .map_err(|error| format!("3D sketch: arc {}: {error}", id_display(&geometry.id)))?,
314            ("bezier", n) if n >= 4 && (n - 1) % 3 == 0 => bezier_chain_curve(&positions)?,
315            (other, n) => {
316                return Err(format!(
317                    "3D sketch: geometry {} is a '{other}' with {n} points, which is not a line, arc or bezier",
318                    id_display(&geometry.id)
319                ));
320            }
321        };
322        let [t0, t1] = curve.domain()?;
323        segments.push(Segment {
324            name: name.clone(),
325            start: curve.evaluate(t0)?,
326            end: curve.evaluate(t1)?,
327            curve: curve.clone(),
328        });
329        per_geometry.push((name, vec![curve]));
330    }
331
332    let chain = longest_chain(&segments, CHAIN_JOIN_REL * scale);
333
334    Ok(Output {
335        per_geometry,
336        chain,
337        axes,
338        points,
339        unresolved,
340    })
341}
342
343/// A geometry's `{id}:G{id}` / `{id}:P{id}` suffix: a number prints bare, a
344/// string prints as itself.
345fn id_display(id: &Value) -> String {
346    match id {
347        Value::String(text) => text.clone(),
348        other => crate::sketch_id_key(other),
349    }
350}
351
352// ---------------------------------------------------------------------------
353// Expressions
354// ---------------------------------------------------------------------------
355
356fn eval_expr(ctx: &FeatureContext, what: &str, value: &Value) -> Result<Option<f64>, String> {
357    let Value::String(source) = value else {
358        return Ok(None);
359    };
360    if source.trim().is_empty() {
361        return Ok(None);
362    }
363    let number = ctx
364        .env
365        .eval(source)
366        .map_err(|error| format!("3D sketch: {what} = `{source}`: {error}"))?;
367    if !number.is_finite() {
368        return Err(format!("3D sketch: {what} = `{source}` evaluated to {number}"));
369    }
370    Ok(Some(number))
371}
372
373/// Substitute expression strings in point coordinates (`x`/`y`/`z`) and
374/// constraint values (`valueExpr`, else a string `value`) with their numbers.
375fn pre_evaluate_expressions(ctx: &FeatureContext, sketch: &mut Value) -> Result<(), String> {
376    if let Some(points) = sketch.get_mut("points").and_then(Value::as_array_mut) {
377        for point in points {
378            let id = point.get("id").map(|id| id.to_string()).unwrap_or_else(|| "?".into());
379            for key in ["x", "y", "z"] {
380                let Some(value) = point.get(key).cloned() else { continue };
381                if let Some(number) = eval_expr(ctx, &format!("point {id} `{key}`"), &value)? {
382                    point[key] = Value::from(number);
383                }
384            }
385        }
386    }
387    if let Some(constraints) = sketch.get_mut("constraints").and_then(Value::as_array_mut) {
388        for constraint in constraints {
389            let id = constraint.get("id").map(|id| id.to_string()).unwrap_or_else(|| "?".into());
390            let expr = constraint
391                .get("valueExpr")
392                .cloned()
393                .map(|value| eval_expr(ctx, &format!("constraint {id} `valueExpr`"), &value))
394                .transpose()?
395                .flatten();
396            let evaluated = match expr {
397                Some(number) => Some(number),
398                None => constraint
399                    .get("value")
400                    .cloned()
401                    .map(|value| eval_expr(ctx, &format!("constraint {id} `value`"), &value))
402                    .transpose()?
403                    .flatten(),
404            };
405            if let Some(number) = evaluated {
406                constraint["value"] = Value::from(number);
407            }
408        }
409    }
410    Ok(())
411}
412
413// ---------------------------------------------------------------------------
414// References
415// ---------------------------------------------------------------------------
416
417/// What a model reference resolves to, in the shapes the sketch materializes.
418#[derive(Clone, Debug)]
419pub enum ResolvedReference {
420    /// A vertex, a published point, a sphere's centre: one fixed point.
421    Point(Vec3),
422    /// A straight edge, a datum axis, a cylinder's axis, a closed circular
423    /// edge's axis: a fixed construction line `[a, b]`.
424    Line(Vec3, Vec3),
425    /// A circular arc edge: `[center, start, end]`.
426    Arc(Vec3, Vec3, Vec3),
427    /// A planar face or a datum/construction plane: `[origin, normalTip]`.
428    Plane(Vec3, Vec3),
429}
430
431impl ResolvedReference {
432    /// The `kind` the materialization record stores.
433    pub fn kind(&self) -> &'static str {
434        match self {
435            ResolvedReference::Point(..) => "point",
436            ResolvedReference::Line(..) => "line",
437            ResolvedReference::Arc(..) => "arc",
438            ResolvedReference::Plane(..) => "plane",
439        }
440    }
441
442    /// The points it materializes, in record order.
443    pub fn positions(&self) -> Vec<Vec3> {
444        match *self {
445            ResolvedReference::Point(p) => vec![p],
446            ResolvedReference::Line(a, b) => vec![a, b],
447            ResolvedReference::Arc(c, s, e) => vec![c, s, e],
448            ResolvedReference::Plane(o, n) => vec![o, n],
449        }
450    }
451
452    /// The construction geometry type the editor draws it with (`None` for a
453    /// lone point).
454    pub fn geometry_type(&self) -> Option<&'static str> {
455        match self {
456            ResolvedReference::Point(..) => None,
457            ResolvedReference::Line(..) => Some("line"),
458            ResolvedReference::Arc(..) => Some("arc"),
459            ResolvedReference::Plane(..) => Some("plane"),
460        }
461    }
462}
463
464/// Resolve a reference name against the live scene: a published point
465/// (`{feature}:P{n}`, a waypoint's `Base`), a datum / construction PLANE
466/// frame, a published AXIS, a resident FACE (planar → plane, axis-bearing →
467/// axis line, sphere → centre), a resident EDGE (straight → line, arc →
468/// arc, closed circle → its axis line), or a `{solid}@x,y,z` VERTEX. `None`
469/// when nothing carries the name or the entity has no analytic shape.
470pub fn resolve_reference(scene: &SceneMap, name: &str) -> Option<ResolvedReference> {
471    if let Some(point) = scene.resolve_point(name) {
472        return Some(ResolvedReference::Point(point));
473    }
474    if let Some(frame) = scene.resolve_frame(name) {
475        return Some(ResolvedReference::Plane(
476            frame.origin,
477            frame.origin.add(frame.z_axis.scale(REFERENCE_TIP_LENGTH)),
478        ));
479    }
480    if let Some(axis) = scene.resolve_axis(name) {
481        return Some(ResolvedReference::Line(
482            axis.point,
483            axis.point.add(axis.direction.scale(REFERENCE_TIP_LENGTH)),
484        ));
485    }
486    if let Some(face) = scene.resolve_face(name) {
487        let geometry = crate::with_registered_solid_str(face.handle, |solid| {
488            resolve_face_selection(solid, face.face_id).map_err(|e| format!("{e:?}"))
489        })
490        .ok()?;
491        return Some(match geometry {
492            SelectionGeometry::Plane { origin, normal } => {
493                ResolvedReference::Plane(origin, origin.add(normal.scale(REFERENCE_TIP_LENGTH)))
494            }
495            SelectionGeometry::Axis { origin, direction, .. } => {
496                ResolvedReference::Line(origin, origin.add(direction.scale(REFERENCE_TIP_LENGTH)))
497            }
498            SelectionGeometry::Sphere { center, .. } => ResolvedReference::Point(center),
499            _ => return None,
500        });
501    }
502    if let Some(edge) = scene.resolve_edge(name) {
503        let curve = common::edge_curve(edge).ok()?;
504        let [t0, t1] = curve.domain().ok()?;
505        let start = curve.evaluate(t0).ok()?;
506        let end = curve.evaluate(t1).ok()?;
507        let geometry = crate::with_registered_solid_str(edge.handle, |solid| {
508            resolve_edge_selection(solid, edge.edge_id).map_err(|e| format!("{e:?}"))
509        });
510        return Some(match geometry {
511            Ok(SelectionGeometry::Circle { center, axis, radius }) => {
512                if end.sub(start).length() <= 1e-9 * radius.max(1.0) {
513                    // A closed circle: its axis is what a pipe lines up with.
514                    ResolvedReference::Line(center, center.add(axis.scale(radius.max(REFERENCE_TIP_LENGTH))))
515                } else {
516                    ResolvedReference::Arc(center, start, end)
517                }
518            }
519            Ok(SelectionGeometry::Line { .. }) => ResolvedReference::Line(start, end),
520            // A spline edge or anything without an analytic frame: its chord,
521            // the same best-effort the edge-axis resolver makes.
522            _ => ResolvedReference::Line(start, end),
523        });
524    }
525    if let Some((solid_name, coords)) = name.rsplit_once('@') {
526        let mut parts = coords.split(',').map(|p| p.trim().parse::<f64>().ok());
527        let position = Vec3::new(parts.next()??, parts.next()??, parts.next()??);
528        if parts.next().is_some() {
529            return None;
530        }
531        let handle = scene.resolve_solid(solid_name)?;
532        let geometry = crate::with_registered_solid_str(handle, |solid| {
533            resolve_vertex_selection(solid, position).map_err(|e| format!("{e:?}"))
534        })
535        .ok()?;
536        if let SelectionGeometry::Point { position } = geometry {
537            return Some(ResolvedReference::Point(position));
538        }
539    }
540    None
541}
542
543// ---------------------------------------------------------------------------
544// Curves
545// ---------------------------------------------------------------------------
546
547/// The exact rational arc from `start` to `end` about `center`, in the plane
548/// of the three points — the MINOR arc (a sweep below π). Collinear points
549/// have no plane and are refused.
550pub fn arc_curve(center: Vec3, start: Vec3, end: Vec3, scale: f64) -> Result<NurbsCurve, String> {
551    let rs = start.sub(center);
552    let re = end.sub(center);
553    let radius = rs.length();
554    if radius <= 1e-9 * scale {
555        return Err("zero radius".into());
556    }
557    let normal = rs.cross(re);
558    if normal.length() <= 1e-9 * radius * radius {
559        return Err("center, start and end are collinear (no arc plane)".into());
560    }
561    let x_axis = rs.normalized()?;
562    let y_axis = normal.normalized()?.cross(x_axis).normalized()?;
563    // The solver holds the radii equal; the end angle is measured on the
564    // start radius so a residual difference never changes the sweep.
565    let sweep = re.dot(y_axis).atan2(re.dot(x_axis));
566    let sweep = if sweep <= 0.0 { sweep + std::f64::consts::TAU } else { sweep };
567    make_arc(center, x_axis, y_axis, radius, 0.0, sweep)
568}
569
570/// One clamped degree-3 B-spline through a chain of cubic Bézier spans
571/// (`3n+1` control points), interior knots of multiplicity 3 — the planar
572/// sketch's `bezier_chain_curve`, in world space.
573pub fn bezier_chain_curve(controls: &[Vec3]) -> Result<NurbsCurve, String> {
574    let span_count = (controls.len().saturating_sub(1)) / 3;
575    if span_count == 0 || controls.len() != span_count * 3 + 1 {
576        return Err(format!(
577            "3D sketch: bezier needs 3n+1 control points, got {}",
578            controls.len()
579        ));
580    }
581    let mut knots = vec![0.0; 4];
582    for join in 1..span_count {
583        knots.extend_from_slice(&[join as f64; 3]);
584    }
585    knots.extend_from_slice(&[span_count as f64; 4]);
586    let points = controls.iter().map(|p| Vec4::from_point(*p, 1.0)).collect();
587    NurbsCurve::new(3, knots, points)
588}
589
590// ---------------------------------------------------------------------------
591// Chaining
592// ---------------------------------------------------------------------------
593
594struct Segment {
595    name: String,
596    start: Vec3,
597    end: Vec3,
598    curve: NurbsCurve,
599}
600
601/// The longest head-to-tail chain through the model segments (segments
602/// reversed as needed), with each curve's source name. Segments are joined
603/// where an end of one lies within `tol` of an end of another; a junction
604/// where three or more ends meet (a frame node) ends the chain there. `None`
605/// when there are no segments.
606fn longest_chain(segments: &[Segment], tol: f64) -> Option<(Vec<NurbsCurve>, Vec<Option<String>>)> {
607    if segments.is_empty() {
608        return None;
609    }
610    let close = |a: Vec3, b: Vec3| a.sub(b).length() <= tol;
611    // Ends meeting at a point: (segment, is_end) per end.
612    let degree_at = |p: Vec3| -> usize {
613        segments
614            .iter()
615            .map(|s| usize::from(close(s.start, p)) + usize::from(close(s.end, p)))
616            .sum()
617    };
618    let mut best: Option<Vec<(usize, bool)>> = None;
619    // Grow a chain from every segment in both orientations; keep the longest.
620    for (seed, _) in segments.iter().enumerate() {
621        for seed_reversed in [false, true] {
622            let mut chain: Vec<(usize, bool)> = vec![(seed, seed_reversed)];
623            let mut used = vec![false; segments.len()];
624            used[seed] = true;
625            // Extend from the tail.
626            loop {
627                let (last, reversed) = *chain.last().unwrap();
628                let tail = if reversed { segments[last].start } else { segments[last].end };
629                if degree_at(tail) != 2 {
630                    break;
631                }
632                let next = segments.iter().enumerate().find_map(|(i, s)| {
633                    if used[i] {
634                        return None;
635                    }
636                    if close(s.start, tail) {
637                        Some((i, false))
638                    } else if close(s.end, tail) {
639                        Some((i, true))
640                    } else {
641                        None
642                    }
643                });
644                match next {
645                    Some((i, reversed)) => {
646                        used[i] = true;
647                        chain.push((i, reversed));
648                    }
649                    None => break,
650                }
651            }
652            // Extend from the head.
653            loop {
654                let (first, reversed) = chain[0];
655                let head = if reversed { segments[first].end } else { segments[first].start };
656                if degree_at(head) != 2 {
657                    break;
658                }
659                let prev = segments.iter().enumerate().find_map(|(i, s)| {
660                    if used[i] {
661                        return None;
662                    }
663                    if close(s.end, head) {
664                        Some((i, false))
665                    } else if close(s.start, head) {
666                        Some((i, true))
667                    } else {
668                        None
669                    }
670                });
671                match prev {
672                    Some((i, reversed)) => {
673                        used[i] = true;
674                        chain.insert(0, (i, reversed));
675                    }
676                    None => break,
677                }
678            }
679            if best.as_ref().map_or(true, |b| chain.len() > b.len()) {
680                best = Some(chain);
681            }
682        }
683    }
684    let chain = best?;
685    let mut curves = Vec::with_capacity(chain.len());
686    let mut names = Vec::with_capacity(chain.len());
687    for (i, reversed) in chain {
688        let curve = if reversed {
689            segments[i].curve.reversed().ok()?
690        } else {
691            segments[i].curve.clone()
692        };
693        curves.push(curve);
694        names.push(Some(segments[i].name.clone()));
695    }
696    Some((curves, names))
697}
698
699/// A materialization record entry for a resolved reference — the editor's
700/// side of [`resolve_reference`] (kept here so the record's shape has one
701/// author).
702pub fn reference_record(name: &str, resolved: &ResolvedReference, point_ids: &[Value], geometry_id: Option<&Value>) -> Value {
703    let mut map = Map::new();
704    map.insert("name".into(), Value::String(name.to_string()));
705    map.insert("kind".into(), Value::String(resolved.kind().to_string()));
706    map.insert("points".into(), Value::Array(point_ids.to_vec()));
707    map.insert(
708        "geometry".into(),
709        geometry_id.cloned().unwrap_or(Value::Null),
710    );
711    Value::Object(map)
712}
713