Skip to main content

brep_kernel/feature_pipeline/
wire_harness.rs

1//! Wire harness — the document's `wireHarness` block, the SIDED port graph,
2//! per-connection routing, and the bundle solids. Solved at the tail of every
3//! history run (like `assembly`), against the ports and spline segments the
4//! run published.
5//!
6//! # The `wireHarness` block
7//!
8//! `{ connections: [{ id, name, from, to, diameter }], idCounter, buildBundles,
9//! cutMargin }` on the history request, round-tripped through the saved
10//! document. `from` / `to` are PORT FEATURE IDS (references are ids, never
11//! labels — the panel shows the ports' `portName`s and stores their ids).
12//! `idCounter` mints `wire-N` ids monotonically, like the feature counter.
13//! `buildBundles` (default true) switches the bundle solids off while keeping
14//! the routing. `cutMargin` (default 0, omitted at 0) is the amount the BOM adds
15//! to every wire's routed length to give its cut length; the router never
16//! reads it.
17//!
18//! # The network
19//!
20//! - A **port** is a PORT feature's [`PortRecord`]: a point and a unit
21//!   direction. Every port has two SIDES: `A` along the direction, `B` against
22//!   it. A wire that enters a port on one side must leave it on the other —
23//!   that is the whole routing rule, and what makes a waypoint a pass-through
24//!   rather than a junction box.
25//! - A **segment** is a SPLINE feature whose first and last anchors attach to
26//!   two DIFFERENT ports. The kernel builds the curve from the ports' live
27//!   placements (`features/spline.rs`), so the segment's geometry is
28//!   authoritative: the PHYSICAL side a segment occupies at each port is read
29//!   off the curve's end tangent — `sign(t_out · dir)` at the first port,
30//!   `sign(−t_in · dir)` at the last (the wire ARRIVES from the opposite
31//!   half-space, so an anchor attached on side A at the end of a spline sits on
32//!   the port's side B). The stored attachment side is how the spline was
33//!   authored; the tangent is where the wire actually is.
34//! - Segment weight = the chain's arc length.
35//!
36//! # The sided digraph (the retired `sided_ab_graphs` builder, kept exactly)
37//!
38//! Nodes are `{port}/A` and `{port}/B`. A segment joining `P/X` to `Q/Y` adds
39//! two directed edges: `P/X → Q/inv(Y)` and `Q/Y → P/inv(X)`, with
40//! `inv(A) = B`. Reading an edge as "leave P through X, arrive at Q on Y, and
41//! you are now poised to leave Q through inv(Y)" is what encodes the pass-
42//! through rule. A connection routes from `{from}/A` or `{from}/B` to `{to}/A`
43//! or `{to}/B` (four Dijkstra queries, the shortest wins). A route that visits
44//! the same port twice is refused and the constrained best-first search (state
45//! = node + visited-port set) runs instead; if that finds nothing either the
46//! connection stays unrouted with the `port-reuse` status. Endpoints must be
47//! terminations (a waypoint is a pass-through, never a cable end).
48//!
49//! # Bundles
50//!
51//! Every routed connection contributes its `diameter` to each segment it
52//! crosses. A segment's bundle diameter is `sqrt(Σ d² / 0.75) · 1.1` (a 75 %
53//! packing efficiency and a 10 % safety factor — the established formula), and
54//! its solid is a circle of that diameter swept along the spline's exact chain
55//! (`sweep_profile_along_chain_with_stations`), named `WireHarness:{spline}`
56//! with faces `…:Wall0`, `…:Wall1`, `…:Start`, `…:End`. The bundle solids ride
57//! an extra [`FeatureResult`] (`id` [`WIRE_HARNESS_FEATURE_ID`](crate::feature_pipeline::wire_harness::WIRE_HARNESS_FEATURE_ID), `type`
58//! [`WIRE_HARNESS_FEATURE_TYPE`](crate::feature_pipeline::wire_harness::WIRE_HARNESS_FEATURE_TYPE)) appended to the run's results, so the
59//! display pipeline shows them through the standard solid path (rendering
60//! requirement R31). A bundle that fails to build reports its error on the
61//! bundle row and never halts the run.
62//!
63//! # Caching
64//!
65//! The tail caches its last outcome per thread against a fingerprint of the
66//! block + the network (port records + segment chains): an unchanged harness
67//! replays (`reused`, the same resident handles) instead of re-sweeping —
68//! which keeps the main-thread `execute_history` replays the engine makes for
69//! consumed-name / assembly sync cheap. A changed fingerprint frees the old
70//! bundle handles before building; `clear_history_cache` frees them too.
71
72use std::cell::RefCell;
73use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap};
74
75use serde::{Deserialize, Serialize};
76
77use crate::feature_pipeline::features::common;
78use crate::feature_pipeline::{Env, FeatureResult, HistoryRequest, PortKind, PortRecord, SceneMap};
79use crate::{make_arc, NurbsCurve, Vec3};
80
81/// The id of the appended tail result (and the creator the display pipeline
82/// records for every bundle solid). Never a history feature.
83pub const WIRE_HARNESS_FEATURE_ID: &str = "WireHarness";
84/// The type of the appended tail result.
85pub const WIRE_HARNESS_FEATURE_TYPE: &str = "WH";
86/// The prefix of every bundle solid name (`WireHarness:{spline id}`).
87pub const BUNDLE_SOLID_PREFIX: &str = "WireHarness:";
88
89/// Bundle packing efficiency (the fraction of the bundle cross-section the
90/// wires fill).
91const PACKING_EFFICIENCY: f64 = 0.75;
92/// Bundle safety factor on the diameter.
93const SAFETY_FACTOR: f64 = 1.1;
94/// The smallest wire / bundle diameter accepted (a zero-diameter wire is a
95/// data error, not a thin wire).
96const MIN_DIAMETER: f64 = 0.01;
97/// Chords per chain piece when the exact arc-length measure refuses a piece —
98/// never for the lines and cubic Béziers a spline publishes. Chords read SHORT
99/// (32 of them miss 1.0e-4 of a 90° Hermite span), so this is a fallback only.
100const LENGTH_SAMPLES: usize = 32;
101/// Sweep stations per chain piece for a bundle (a spline span is three pieces).
102const STATIONS_PER_PIECE: usize = 12;
103/// Zero gates for the end-tangent side test.
104const TANGENT_EPS: f64 = 1e-9;
105
106// ===========================================================================
107// Sides and attachments (shared with `features/spline.rs`)
108// ===========================================================================
109
110/// A port side: `A` along the port direction, `B` against it.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
112pub enum PortSide {
113    A,
114    B,
115}
116
117impl PortSide {
118    /// The opposite side (`inv` in the digraph builder).
119    pub fn other(self) -> Self {
120        match self {
121            PortSide::A => PortSide::B,
122            PortSide::B => PortSide::A,
123        }
124    }
125
126    /// `"A"` / `"B"` (case-insensitive). Anything else is not a side.
127    pub fn parse(text: &str) -> Option<Self> {
128        match text.trim() {
129            "A" | "a" => Some(PortSide::A),
130            "B" | "b" => Some(PortSide::B),
131            _ => None,
132        }
133    }
134
135    pub fn letter(self) -> &'static str {
136        match self {
137            PortSide::A => "A",
138            PortSide::B => "B",
139        }
140    }
141}
142
143/// A spline anchor's port attachment: `{ portRef, side }` under the anchor's
144/// `attachment` key. `side` defaults to `A` when absent.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct Attachment {
147    pub port_ref: String,
148    pub side: PortSide,
149}
150
151impl Attachment {
152    /// Parse an anchor's `attachment` value. `None` = not attached (absent,
153    /// null, not an object, or an empty `portRef`).
154    pub fn parse(value: Option<&serde_json::Value>) -> Option<Self> {
155        let object = value?.as_object()?;
156        let port_ref = object.get("portRef")?.as_str()?.trim();
157        if port_ref.is_empty() {
158            return None;
159        }
160        let side = object
161            .get("side")
162            .and_then(serde_json::Value::as_str)
163            .and_then(PortSide::parse)
164            .unwrap_or(PortSide::A);
165        Some(Attachment {
166            port_ref: port_ref.to_string(),
167            side,
168        })
169    }
170}
171
172// ===========================================================================
173// The persisted block
174// ===========================================================================
175
176/// The document's `wireHarness` block.
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
178pub struct WireHarnessState {
179    #[serde(default)]
180    pub connections: Vec<WireHarnessConnection>,
181    /// Monotonic id counter (`wire-{n}`), never reused.
182    #[serde(default, rename = "idCounter")]
183    pub id_counter: u64,
184    /// Build the bundle solids (default true). Off keeps the routing and the
185    /// report but registers no solids.
186    #[serde(default = "default_true", rename = "buildBundles")]
187    pub build_bundles: bool,
188    /// The cut margin, in model units: a set amount added to EVERY wire's
189    /// routed length to give the cut length its BOM line carries (MF QTY) —
190    /// once per wire, never a percentage and never once per BOM line. It lives
191    /// here, in the typed block, because the block is rewritten from this struct
192    /// on every harness edit and a key it did not know would be dropped.
193    ///
194    /// The router never reads it and `harness_fingerprint` deliberately does
195    /// not hash it: a margin change must not invalidate a route, so the tail
196    /// replays its cache and only the BOM's arithmetic moves. Omitted from the
197    /// saved block at 0, so a document that never set one saves as before.
198    #[serde(default, rename = "cutMargin", skip_serializing_if = "is_zero")]
199    pub cut_margin: f64,
200}
201
202impl Default for WireHarnessState {
203    fn default() -> Self {
204        Self {
205            connections: Vec::new(),
206            id_counter: 0,
207            build_bundles: true,
208            cut_margin: 0.0,
209        }
210    }
211}
212
213impl WireHarnessState {
214    /// Mint the next connection id (`wire-{n}`), bumping the counter.
215    pub fn next_id(&mut self) -> String {
216        self.id_counter += 1;
217        format!("wire-{}", self.id_counter)
218    }
219}
220
221/// One wire: its id, display name, endpoint PORT ids and diameter.
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223pub struct WireHarnessConnection {
224    pub id: String,
225    #[serde(default)]
226    pub name: String,
227    #[serde(default)]
228    pub from: String,
229    #[serde(default)]
230    pub to: String,
231    #[serde(default = "default_diameter")]
232    pub diameter: f64,
233}
234
235fn default_true() -> bool {
236    true
237}
238
239fn is_zero(value: &f64) -> bool {
240    *value == 0.0
241}
242
243fn default_diameter() -> f64 {
244    1.0
245}
246
247// ===========================================================================
248// The report (what the tail hands the app)
249// ===========================================================================
250
251/// The routing outcome of one run.
252#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
253pub struct WireHarnessReport {
254    /// Every port the run published, in id order.
255    pub endpoints: Vec<WireHarnessEndpoint>,
256    /// Every harness segment (a spline attached to two ports at both ends).
257    pub segments: Vec<WireHarnessSegment>,
258    /// One route per connection, in block order.
259    pub routes: Vec<RouteResult>,
260    /// One bundle per segment at least one routed connection crosses.
261    pub bundles: Vec<WireHarnessBundle>,
262    /// Splines that carry attachments but could not become segments, with why
263    /// (both ends must attach to two different ports that resolve).
264    pub segment_problems: Vec<String>,
265}
266
267/// A connection point as an endpoint choice.
268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
269pub struct WireHarnessEndpoint {
270    /// The point's ADDRESS, which is what `from`/`to` store
271    /// (`J1.VCC`, `ACOMP3:J1.VCC`, or a WAYPOINT's feature id).
272    pub id: String,
273    /// The port group, PART-LOCAL (empty for a waypoint).
274    #[serde(default)]
275    pub port: String,
276    /// The point name, PART-LOCAL — what a symbol pin binds to (a waypoint's
277    /// own display name).
278    #[serde(default)]
279    pub point: String,
280    /// The port group's purpose (`pcb` | `wiring` | `piping` | …; empty for a
281    /// waypoint).
282    #[serde(default)]
283    pub purpose: String,
284    pub kind: PortKind,
285    /// The placed component (ACOMP feature id) carrying this port, when the
286    /// port came in with a part rather than from a PORT feature of this
287    /// document. The display gates these on the workbench.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub component: Option<String>,
290}
291
292/// A network segment: the spline id, its two ports with the PHYSICAL side the
293/// wire occupies at each, and its length.
294#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
295pub struct WireHarnessSegment {
296    pub id: String,
297    pub first_port: String,
298    pub first_side: PortSide,
299    pub second_port: String,
300    pub second_side: PortSide,
301    pub length: f64,
302}
303
304/// Why a connection is or is not routed.
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(rename_all = "kebab-case")]
307pub enum RouteStatus {
308    /// Routed through the network.
309    Routed,
310    /// `from` or `to` is empty or names no port in the scene.
311    MissingEndpoint,
312    /// `from` or `to` is a waypoint (a pass-through, never a cable end).
313    WaypointEndpoint,
314    /// `from` and `to` are the same port.
315    SameEndpoint,
316    /// The network has no segment at all.
317    NoSegments,
318    /// No sided path joins the two ports.
319    NoRoute,
320    /// Every path joining the two ports passes through one port twice.
321    PortReuse,
322}
323
324impl RouteStatus {
325    /// The kebab-case word the panel keys its colours on.
326    pub fn as_str(self) -> &'static str {
327        match self {
328            RouteStatus::Routed => "routed",
329            RouteStatus::MissingEndpoint => "missing-endpoint",
330            RouteStatus::WaypointEndpoint => "waypoint-endpoint",
331            RouteStatus::SameEndpoint => "same-endpoint",
332            RouteStatus::NoSegments => "no-segments",
333            RouteStatus::NoRoute => "no-route",
334            RouteStatus::PortReuse => "port-reuse",
335        }
336    }
337}
338
339/// One connection's route.
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
341pub struct RouteResult {
342    pub connection_id: String,
343    pub feasible: bool,
344    pub status: RouteStatus,
345    /// Human text for the status column (empty when routed).
346    pub message: String,
347    /// Route length (the sum of the crossed segments' arc lengths).
348    pub length: Option<f64>,
349    /// The crossed segments (spline ids) in travel order.
350    pub segment_ids: Vec<String>,
351    /// The sided nodes visited (`{port}/{side}`), start to end. A node names
352    /// the side the wire is poised to LEAVE through: the first is the side it
353    /// departs the start port on, the last is the opposite of the side it
354    /// arrives at the end port on.
355    pub node_path: Vec<String>,
356    /// The ports visited, start to end.
357    pub port_ids: Vec<String>,
358}
359
360/// Why a segment's bundle solid is or is not there.
361///
362/// A ROUTE has had a status since the router existed, because "no route" is an
363/// answer about the model rather than a failure of it. A bundle's refusals are
364/// the same kind of answer — a spline that turns tighter than the bundle can be
365/// pushed round is a thing the user drew, not a thing that went wrong — so it
366/// gets a status too, and the panel keys its colour and its word on that rather
367/// than on the shape of an error string.
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
369#[serde(rename_all = "kebab-case")]
370pub enum BundleStatus {
371    /// The solid is built and registered under `solid_name`.
372    Built,
373    /// `buildBundles` is off: the routing is there and the geometry deliberately
374    /// is not.
375    BundlesOff,
376    /// The spline bends TIGHTER than the bundle's own radius, so the swept
377    /// surface would pass through itself. `error` names the bend — where it is,
378    /// the radius of curvature there, and how far the bundle reaches into it.
379    TightBend,
380    /// The sweep failed for some other reason; `error` carries it.
381    BuildFailed,
382}
383
384impl BundleStatus {
385    /// The kebab-case word the panel keys its colours on.
386    pub fn as_str(self) -> &'static str {
387        match self {
388            BundleStatus::Built => "built",
389            BundleStatus::BundlesOff => "bundles-off",
390            BundleStatus::TightBend => "tight-bend",
391            BundleStatus::BuildFailed => "build-failed",
392        }
393    }
394}
395
396/// One segment's bundle.
397#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
398pub struct WireHarnessBundle {
399    pub segment_id: String,
400    /// The registered solid's name (empty when bundles are off or the build
401    /// failed).
402    pub solid_name: String,
403    pub wire_count: usize,
404    pub diameter: f64,
405    pub length: f64,
406    pub connection_ids: Vec<String>,
407    /// Whether the solid is there, and why not when it is not.
408    pub status: BundleStatus,
409    /// The build failure in full, if the solid could not be swept. Empty
410    /// otherwise; the STATUS is what a reader classifies on.
411    pub error: String,
412}
413
414/// The bundle diameter for a set of wire diameters: `sqrt(Σ d² / 0.75) · 1.1`
415/// — 75 % packing efficiency and a 10 % safety factor. Zero for no wires.
416pub fn bundle_diameter(diameters: &[f64]) -> f64 {
417    let sum_squares: f64 = diameters
418        .iter()
419        .map(|d| d.max(0.0))
420        .map(|d| d * d)
421        .sum();
422    if sum_squares <= 0.0 {
423        return 0.0;
424    }
425    (sum_squares / PACKING_EFFICIENCY).sqrt() * SAFETY_FACTOR
426}
427
428// ===========================================================================
429// The tail hook
430// ===========================================================================
431
432/// What the tail hands back to the history loop.
433pub(crate) struct HarnessOutcome {
434    /// The appended result carrying the bundle solids (`None` when there is no
435    /// block).
436    pub result: Option<FeatureResult>,
437    /// The routing report (always present — the panel lists endpoints even
438    /// before the first connection exists).
439    pub report: Option<WireHarnessReport>,
440}
441
442/// Route the request's connections over the scene's ports and spline segments,
443/// build the bundle solids, and return them as an extra result plus the report.
444/// Runs unconditionally at the tail of every history execution.
445pub(crate) fn finish_history_run(
446    request: &HistoryRequest,
447    scene: &SceneMap,
448    _env: &Env,
449) -> HarnessOutcome {
450    let network = build_network(request, scene);
451    let Some(state) = request.wire_harness.as_ref() else {
452        // No block: nothing to route and nothing to keep resident.
453        clear_cache();
454        let report = WireHarnessReport {
455            endpoints: network.endpoints(),
456            segments: network.segment_rows(),
457            segment_problems: network.problems.clone(),
458            ..Default::default()
459        };
460        return HarnessOutcome {
461            result: None,
462            report: Some(report),
463        };
464    };
465
466    let fingerprint = harness_fingerprint(state, &network);
467    let cached = HARNESS_CACHE.with(|cache| {
468        cache.borrow().as_ref().and_then(|entry| {
469            (entry.fingerprint == fingerprint).then(|| (entry.result.clone(), entry.report.clone()))
470        })
471    });
472    if let Some((mut result, report)) = cached {
473        result.reused = true;
474        return HarnessOutcome {
475            result: Some(result),
476            report: Some(report),
477        };
478    }
479    // The fingerprint moved: the old bundle handles die before the rebuild.
480    clear_cache();
481
482    let mut report = WireHarnessReport {
483        endpoints: network.endpoints(),
484        segments: network.segment_rows(),
485        segment_problems: network.problems.clone(),
486        ..Default::default()
487    };
488    let graph = SidedGraph::build(&network);
489    for connection in &state.connections {
490        report.routes.push(route_connection(&network, &graph, connection));
491    }
492    let mut result = FeatureResult::empty(WIRE_HARNESS_FEATURE_ID, WIRE_HARNESS_FEATURE_TYPE);
493    report.bundles = build_bundles(&network, state, &report.routes, &mut result);
494
495    HARNESS_CACHE.with(|cache| {
496        *cache.borrow_mut() = Some(CachedHarness {
497            fingerprint,
498            result: result.clone(),
499            report: report.clone(),
500        });
501    });
502    HarnessOutcome {
503        result: Some(result),
504        report: Some(report),
505    }
506}
507
508struct CachedHarness {
509    fingerprint: u64,
510    result: FeatureResult,
511    report: WireHarnessReport,
512}
513
514thread_local! {
515    static HARNESS_CACHE: RefCell<Option<CachedHarness>> = const { RefCell::new(None) };
516}
517
518/// Free the cached bundle solids (a document switch, or a rebuild).
519pub fn clear_cache() {
520    HARNESS_CACHE.with(|cache| {
521        if let Some(entry) = cache.borrow_mut().take() {
522            for added in &entry.result.added {
523                crate::free_registered_solid(added.handle);
524            }
525        }
526    });
527}
528
529/// Hash what the routing reads from the block (each connection's id,
530/// endpoints and diameter, in order, plus the bundles switch — a renamed wire
531/// or a bumped id counter replays) and everything it reads from the scene:
532/// every port record and every segment's identity, sides and exact chain.
533/// Bit-exact on the floats — a moved port must rebuild. A port's LABEL is in
534/// it too: the report's endpoints (and a waypoint-end message) carry it, and a
535/// part's pin rename changes nothing else, so without it the replayed report
536/// went on naming the port by its old label.
537fn harness_fingerprint(state: &WireHarnessState, network: &Network) -> u64 {
538    use std::hash::{Hash, Hasher};
539    let mut hasher = std::collections::hash_map::DefaultHasher::new();
540    state.build_bundles.hash(&mut hasher);
541    state.connections.len().hash(&mut hasher);
542    for connection in &state.connections {
543        connection.id.hash(&mut hasher);
544        connection.from.hash(&mut hasher);
545        connection.to.hash(&mut hasher);
546        connection.diameter.to_bits().hash(&mut hasher);
547    }
548    let bits = |v: Vec3, hasher: &mut std::collections::hash_map::DefaultHasher| {
549        v.x.to_bits().hash(hasher);
550        v.y.to_bits().hash(hasher);
551        v.z.to_bits().hash(hasher);
552    };
553    for (id, port) in &network.ports {
554        id.hash(&mut hasher);
555        port.local_address().hash(&mut hasher);
556        bits(port.point, &mut hasher);
557        bits(port.direction, &mut hasher);
558        (port.kind == PortKind::Waypoint).hash(&mut hasher);
559        port.extension.to_bits().hash(&mut hasher);
560    }
561    for segment in &network.segments {
562        segment.id.hash(&mut hasher);
563        segment.first_port.hash(&mut hasher);
564        segment.first_side.hash(&mut hasher);
565        segment.second_port.hash(&mut hasher);
566        segment.second_side.hash(&mut hasher);
567        segment.length.to_bits().hash(&mut hasher);
568        for curve in &segment.chain {
569            curve.degree.hash(&mut hasher);
570            for knot in &curve.knots {
571                knot.to_bits().hash(&mut hasher);
572            }
573            for point in &curve.control_points {
574                point.x.to_bits().hash(&mut hasher);
575                point.y.to_bits().hash(&mut hasher);
576                point.z.to_bits().hash(&mut hasher);
577                point.w.to_bits().hash(&mut hasher);
578            }
579        }
580    }
581    hasher.finish()
582}
583
584// ===========================================================================
585// The network: ports + segments read off the scene
586// ===========================================================================
587
588/// A spline joining two ports.
589struct Segment {
590    /// The spline feature id.
591    id: String,
592    first_port: String,
593    /// The PHYSICAL side the wire occupies at the first port.
594    first_side: PortSide,
595    second_port: String,
596    second_side: PortSide,
597    length: f64,
598    chain: Vec<NurbsCurve>,
599}
600
601struct Network {
602    /// Id order — deterministic endpoint lists and fingerprints.
603    ports: BTreeMap<String, PortRecord>,
604    /// Port id -> the placed component that carries it (component ports only).
605    owners: BTreeMap<String, String>,
606    /// Request order.
607    segments: Vec<Segment>,
608    problems: Vec<String>,
609}
610
611impl Network {
612    fn endpoints(&self) -> Vec<WireHarnessEndpoint> {
613        self.ports
614            .iter()
615            .map(|(id, port)| WireHarnessEndpoint {
616                id: id.clone(),
617                port: port.port_name.clone(),
618                point: port.point_name.clone(),
619                purpose: port.purpose.clone(),
620                kind: port.kind,
621                component: self.owners.get(id).cloned(),
622            })
623            .collect()
624    }
625
626    fn segment_rows(&self) -> Vec<WireHarnessSegment> {
627        self.segments
628            .iter()
629            .map(|segment| WireHarnessSegment {
630                id: segment.id.clone(),
631                first_port: segment.first_port.clone(),
632                first_side: segment.first_side,
633                second_port: segment.second_port.clone(),
634                second_side: segment.second_side,
635                length: segment.length,
636            })
637            .collect()
638    }
639
640    fn segment(&self, id: &str) -> Option<&Segment> {
641        self.segments.iter().find(|segment| segment.id == id)
642    }
643}
644
645/// Every port in the scene plus every SPLINE feature in the request whose two
646/// end anchors attach to two different resolved ports and whose chain the run
647/// published (a spline past the rollback has no chain and is skipped).
648fn build_network(request: &HistoryRequest, scene: &SceneMap) -> Network {
649    let ports: BTreeMap<String, PortRecord> = scene
650        .ports
651        .iter()
652        .map(|(id, port)| (id.clone(), port.clone()))
653        .collect();
654    let owners: BTreeMap<String, String> = ports
655        .keys()
656        .filter_map(|id| {
657            scene
658                .owning_component(id)
659                .map(|record| (id.clone(), record.id.clone()))
660        })
661        .collect();
662    let mut segments = Vec::new();
663    let mut problems = Vec::new();
664    for descriptor in &request.features {
665        if !matches!(descriptor.feature_type.as_str(), "SP" | "SPLINE") {
666            continue;
667        }
668        let id = ["id", "featureID"]
669            .iter()
670            .find_map(|key| descriptor.input_params.get(key).and_then(|v| v.as_str()))
671            .unwrap_or("")
672            .trim()
673            .to_string();
674        if id.is_empty() {
675            continue;
676        }
677        let Some(points) = descriptor
678            .persistent_data
679            .get("spline")
680            .and_then(|spline| spline.get("points"))
681            .and_then(serde_json::Value::as_array)
682        else {
683            continue;
684        };
685        if points.len() < 2 {
686            continue;
687        }
688        let first = Attachment::parse(points.first().and_then(|p| p.get("attachment")));
689        let last = Attachment::parse(points.last().and_then(|p| p.get("attachment")));
690        let (first, last) = match (first, last) {
691            (Some(first), Some(last)) => (first, last),
692            // One attached end is authoring in progress — worth a row so the
693            // panel can say why the spline is not a segment; none attached is
694            // an ordinary spline, not a harness matter.
695            (Some(_), None) | (None, Some(_)) => {
696                problems.push(format!("{id}: only one end is attached to a port"));
697                continue;
698            }
699            (None, None) => continue,
700        };
701        if first.port_ref == last.port_ref {
702            problems.push(format!("{id}: both ends attach to the same port '{}'", first.port_ref));
703            continue;
704        }
705        let (Some(first_record), Some(last_record)) =
706            (ports.get(&first.port_ref), ports.get(&last.port_ref))
707        else {
708            // The spline itself already reported the missing port as unresolved.
709            continue;
710        };
711        let Some(chain) = scene.resolve_path(&format!("{id}:SplineEdge")).cloned() else {
712            continue; // past the rollback, or fully degenerate
713        };
714        if chain.is_empty() {
715            continue;
716        }
717        let length = chain_length(&chain);
718        let first_side = physical_side(first_record, end_tangent(&chain, true), first.side);
719        // The wire ARRIVES: it comes from the opposite half-space of its travel
720        // direction, so the arriving tangent is negated before the side test.
721        let second_side = physical_side(
722            last_record,
723            end_tangent(&chain, false).scale(-1.0),
724            last.side.other(),
725        );
726        segments.push(Segment {
727            id,
728            first_port: first.port_ref,
729            first_side,
730            second_port: last.port_ref,
731            second_side,
732            length,
733            chain,
734        });
735    }
736    Network {
737        ports,
738        owners,
739        segments,
740        problems,
741    }
742}
743
744/// The side of `port` a wire heading along `outward` occupies:
745/// `outward · direction ≥ 0` → `A`, else `B`. A zero tangent (a fully
746/// degenerate end) falls back to `fallback`.
747fn physical_side(port: &PortRecord, outward: Vec3, fallback: PortSide) -> PortSide {
748    if outward.length() <= TANGENT_EPS {
749        return fallback;
750    }
751    if outward.dot(port.direction) >= 0.0 {
752        PortSide::A
753    } else {
754        PortSide::B
755    }
756}
757
758/// The chain's travel tangent at its start (`at_start`) or end: the curve
759/// derivative, else the chord to a nearby sample when the derivative is
760/// degenerate (a zero Hermite tangent).
761fn end_tangent(chain: &[NurbsCurve], at_start: bool) -> Vec3 {
762    let curve = if at_start { chain.first() } else { chain.last() };
763    let Some(curve) = curve else {
764        return Vec3::new(0.0, 0.0, 0.0);
765    };
766    let Ok([t0, t1]) = curve.domain() else {
767        return Vec3::new(0.0, 0.0, 0.0);
768    };
769    let parameter = if at_start { t0 } else { t1 };
770    if let Ok(derivatives) = curve.derivatives(parameter, 1) {
771        if let Some(tangent) = derivatives.get(1) {
772            if tangent.length() > TANGENT_EPS {
773                return *tangent;
774            }
775        }
776    }
777    let near = if at_start {
778        t0 + (t1 - t0) * 0.05
779    } else {
780        t1 - (t1 - t0) * 0.05
781    };
782    match (curve.evaluate(parameter), curve.evaluate(near)) {
783        (Ok(at), Ok(nearby)) => {
784            if at_start {
785                nearby.sub(at)
786            } else {
787                at.sub(nearby)
788            }
789        }
790        _ => Vec3::new(0.0, 0.0, 0.0),
791    }
792}
793
794/// Arc length of a chain: the kernel's exact measure
795/// ([`crate::curve_arc_length`], Gauss–Legendre paneled at the knots) over each
796/// piece. This is the number a wire's cut length is read from, so it must not
797/// be a chord sum: chords are a lower bound, and a wire cut short is scrap.
798fn chain_length(chain: &[NurbsCurve]) -> f64 {
799    let mut total = 0.0;
800    for curve in chain {
801        let Ok([t0, t1]) = curve.domain() else { continue };
802        total += match crate::curve_arc_length(curve, t0, t1) {
803            Ok(length) => length,
804            Err(_) => chord_length(curve, t0, t1),
805        };
806    }
807    total
808}
809
810/// The chord-sum fallback for a piece the exact measure refused.
811fn chord_length(curve: &NurbsCurve, t0: f64, t1: f64) -> f64 {
812    let Ok(mut previous) = curve.evaluate(t0) else { return 0.0 };
813    let mut total = 0.0;
814    for sample in 1..=LENGTH_SAMPLES {
815        let t = t0 + (t1 - t0) * sample as f64 / LENGTH_SAMPLES as f64;
816        if let Ok(point) = curve.evaluate(t) {
817            total += point.sub(previous).length();
818            previous = point;
819        }
820    }
821    total
822}
823
824// ===========================================================================
825// The sided digraph + shortest paths
826// ===========================================================================
827
828fn node_key(port: &str, side: PortSide) -> String {
829    format!("{port}/{}", side.letter())
830}
831
832struct Edge {
833    to: usize,
834    weight: f64,
835    /// Index into `Network::segments`.
836    segment: usize,
837}
838
839struct SidedGraph {
840    /// Node key (`{port}/{side}`) → index.
841    index: HashMap<String, usize>,
842    keys: Vec<String>,
843    /// The port each node belongs to (index into `ports`).
844    port_of: Vec<usize>,
845    ports: Vec<String>,
846    edges: Vec<Vec<Edge>>,
847}
848
849impl SidedGraph {
850    fn build(network: &Network) -> Self {
851        let mut graph = SidedGraph {
852            index: HashMap::new(),
853            keys: Vec::new(),
854            port_of: Vec::new(),
855            ports: Vec::new(),
856            edges: Vec::new(),
857        };
858        // Every port in the scene gets both sides so an endpoint with no
859        // segment still has nodes (and simply reaches nothing).
860        for id in network.ports.keys() {
861            graph.add_port(id);
862        }
863        for (segment_index, segment) in network.segments.iter().enumerate() {
864            let weight = segment.length.max(1e-9);
865            // P/X → Q/inv(Y): leave P through X, arrive at Q on Y, poised to
866            // leave Q through the other side.
867            let from = graph.node(&segment.first_port, segment.first_side);
868            let to = graph.node(&segment.second_port, segment.second_side.other());
869            graph.edges[from].push(Edge {
870                to,
871                weight,
872                segment: segment_index,
873            });
874            // Q/Y → P/inv(X): the same segment travelled the other way.
875            let from = graph.node(&segment.second_port, segment.second_side);
876            let to = graph.node(&segment.first_port, segment.first_side.other());
877            graph.edges[from].push(Edge {
878                to,
879                weight,
880                segment: segment_index,
881            });
882        }
883        graph
884    }
885
886    fn add_port(&mut self, id: &str) -> usize {
887        if let Some(position) = self.ports.iter().position(|p| p == id) {
888            return position;
889        }
890        let port_index = self.ports.len();
891        self.ports.push(id.to_string());
892        for side in [PortSide::A, PortSide::B] {
893            let key = node_key(id, side);
894            self.index.insert(key.clone(), self.keys.len());
895            self.keys.push(key);
896            self.port_of.push(port_index);
897            self.edges.push(Vec::new());
898        }
899        port_index
900    }
901
902    fn node(&mut self, port: &str, side: PortSide) -> usize {
903        let key = node_key(port, side);
904        if let Some(&index) = self.index.get(&key) {
905            return index;
906        }
907        self.add_port(port);
908        self.index[&key]
909    }
910
911    fn node_index(&self, port: &str, side: PortSide) -> Option<usize> {
912        self.index.get(&node_key(port, side)).copied()
913    }
914}
915
916/// A found path through the sided graph.
917#[derive(Debug, Clone, PartialEq)]
918struct SidedPath {
919    distance: f64,
920    /// Node indices, start to end.
921    nodes: Vec<usize>,
922    /// Segment indices, one per hop.
923    segments: Vec<usize>,
924}
925
926/// Ordered f64 for the heap (cost is always finite here).
927#[derive(PartialEq)]
928struct HeapEntry<T> {
929    cost: f64,
930    item: T,
931}
932
933impl<T: PartialEq> Eq for HeapEntry<T> {}
934
935impl<T: PartialEq> PartialOrd for HeapEntry<T> {
936    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
937        Some(self.cmp(other))
938    }
939}
940
941impl<T: PartialEq> Ord for HeapEntry<T> {
942    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
943        // Min-heap on cost: reverse the comparison.
944        other
945            .cost
946            .partial_cmp(&self.cost)
947            .unwrap_or(std::cmp::Ordering::Equal)
948    }
949}
950
951/// Plain Dijkstra from `start` to `end` (node indices).
952fn dijkstra(graph: &SidedGraph, start: usize, end: usize) -> Option<SidedPath> {
953    let count = graph.keys.len();
954    let mut distance = vec![f64::INFINITY; count];
955    let mut parent: Vec<Option<(usize, usize)>> = vec![None; count];
956    let mut done = vec![false; count];
957    let mut heap = BinaryHeap::new();
958    distance[start] = 0.0;
959    heap.push(HeapEntry {
960        cost: 0.0,
961        item: start,
962    });
963    while let Some(HeapEntry { cost, item: node }) = heap.pop() {
964        if done[node] {
965            continue;
966        }
967        done[node] = true;
968        if node == end {
969            break;
970        }
971        for edge in &graph.edges[node] {
972            let next = cost + edge.weight;
973            if next < distance[edge.to] {
974                distance[edge.to] = next;
975                parent[edge.to] = Some((node, edge.segment));
976                heap.push(HeapEntry {
977                    cost: next,
978                    item: edge.to,
979                });
980            }
981        }
982    }
983    if !distance[end].is_finite() || start == end {
984        return None;
985    }
986    let mut nodes = vec![end];
987    let mut segments = Vec::new();
988    let mut cursor = end;
989    while let Some((previous, segment)) = parent[cursor] {
990        segments.push(segment);
991        nodes.push(previous);
992        cursor = previous;
993    }
994    nodes.reverse();
995    segments.reverse();
996    Some(SidedPath {
997        distance: distance[end],
998        nodes,
999        segments,
1000    })
1001}
1002
1003/// Whether a path visits one PORT twice (either side).
1004fn reuses_a_port(graph: &SidedGraph, path: &SidedPath) -> bool {
1005    let mut seen = BTreeSet::new();
1006    path.nodes
1007        .iter()
1008        .any(|&node| !seen.insert(graph.port_of[node]))
1009}
1010
1011/// Best-first search for the shortest path from `start` to `end` that visits
1012/// no port twice (state = node + the visited-port set). Exponential in the
1013/// worst case; harness graphs are tiny, and this only runs when plain
1014/// Dijkstra's answer reused a port.
1015fn shortest_non_reusing(graph: &SidedGraph, start: usize, end: usize) -> Option<SidedPath> {
1016    #[derive(PartialEq)]
1017    struct State {
1018        node: usize,
1019        visited: Vec<bool>,
1020        nodes: Vec<usize>,
1021        segments: Vec<usize>,
1022    }
1023    let port_count = graph.ports.len();
1024    let mut best: HashMap<(usize, Vec<bool>), f64> = HashMap::new();
1025    let mut heap = BinaryHeap::new();
1026    let mut visited = vec![false; port_count];
1027    visited[graph.port_of[start]] = true;
1028    heap.push(HeapEntry {
1029        cost: 0.0,
1030        item: State {
1031            node: start,
1032            visited,
1033            nodes: vec![start],
1034            segments: Vec::new(),
1035        },
1036    });
1037    while let Some(HeapEntry { cost, item: state }) = heap.pop() {
1038        if state.node == end && !state.segments.is_empty() {
1039            return Some(SidedPath {
1040                distance: cost,
1041                nodes: state.nodes,
1042                segments: state.segments,
1043            });
1044        }
1045        let key = (state.node, state.visited.clone());
1046        if best.get(&key).is_some_and(|&known| known < cost - 1e-12) {
1047            continue;
1048        }
1049        for edge in &graph.edges[state.node] {
1050            let port = graph.port_of[edge.to];
1051            if state.visited[port] {
1052                continue;
1053            }
1054            let mut visited = state.visited.clone();
1055            visited[port] = true;
1056            let next_cost = cost + edge.weight;
1057            let next_key = (edge.to, visited.clone());
1058            if best.get(&next_key).is_some_and(|&known| known <= next_cost + 1e-12) {
1059                continue;
1060            }
1061            best.insert(next_key, next_cost);
1062            let mut nodes = state.nodes.clone();
1063            nodes.push(edge.to);
1064            let mut segments = state.segments.clone();
1065            segments.push(edge.segment);
1066            heap.push(HeapEntry {
1067                cost: next_cost,
1068                item: State {
1069                    node: edge.to,
1070                    visited,
1071                    nodes,
1072                    segments,
1073                },
1074            });
1075        }
1076    }
1077    None
1078}
1079
1080/// Route one connection: validate its endpoints, take the shortest of the four
1081/// sided Dijkstra answers, and fall back to the non-reusing search when that
1082/// answer passes through a port twice.
1083fn route_connection(
1084    network: &Network,
1085    graph: &SidedGraph,
1086    connection: &WireHarnessConnection,
1087) -> RouteResult {
1088    let unrouted = |status: RouteStatus, message: String| RouteResult {
1089        connection_id: connection.id.clone(),
1090        feasible: false,
1091        status,
1092        message,
1093        length: None,
1094        segment_ids: Vec::new(),
1095        node_path: Vec::new(),
1096        port_ids: Vec::new(),
1097    };
1098    let from = connection.from.trim();
1099    let to = connection.to.trim();
1100    for (label, id) in [("from", from), ("to", to)] {
1101        if id.is_empty() {
1102            return unrouted(RouteStatus::MissingEndpoint, format!("no {label} port"));
1103        }
1104        let Some(port) = network.ports.get(id) else {
1105            return unrouted(
1106                RouteStatus::MissingEndpoint,
1107                format!("{label} port '{id}' is not in the model"),
1108            );
1109        };
1110        if port.kind == PortKind::Waypoint {
1111            return unrouted(
1112                RouteStatus::WaypointEndpoint,
1113                format!("{label} port '{}' is a waypoint, not a termination", port.local_address()),
1114            );
1115        }
1116    }
1117    if from == to {
1118        return unrouted(RouteStatus::SameEndpoint, "from and to are the same port".into());
1119    }
1120    if network.segments.is_empty() {
1121        return unrouted(
1122            RouteStatus::NoSegments,
1123            "no harness splines: attach a spline's end anchors to two ports".into(),
1124        );
1125    }
1126
1127    let mut best: Option<SidedPath> = None;
1128    for start_side in [PortSide::A, PortSide::B] {
1129        for end_side in [PortSide::A, PortSide::B] {
1130            let (Some(start), Some(end)) = (
1131                graph.node_index(from, start_side),
1132                graph.node_index(to, end_side),
1133            ) else {
1134                continue;
1135            };
1136            if let Some(path) = dijkstra(graph, start, end) {
1137                if best.as_ref().is_none_or(|b| path.distance < b.distance) {
1138                    best = Some(path);
1139                }
1140            }
1141        }
1142    }
1143    let Some(mut path) = best else {
1144        return unrouted(
1145            RouteStatus::NoRoute,
1146            "no sided path joins the two ports (check which side each spline leaves its ports on)".into(),
1147        );
1148    };
1149    if reuses_a_port(graph, &path) {
1150        let mut alternative: Option<SidedPath> = None;
1151        for start_side in [PortSide::A, PortSide::B] {
1152            for end_side in [PortSide::A, PortSide::B] {
1153                let (Some(start), Some(end)) = (
1154                    graph.node_index(from, start_side),
1155                    graph.node_index(to, end_side),
1156                ) else {
1157                    continue;
1158                };
1159                if let Some(found) = shortest_non_reusing(graph, start, end) {
1160                    if alternative.as_ref().is_none_or(|b| found.distance < b.distance) {
1161                        alternative = Some(found);
1162                    }
1163                }
1164            }
1165        }
1166        match alternative {
1167            Some(found) => path = found,
1168            None => {
1169                return unrouted(
1170                    RouteStatus::PortReuse,
1171                    "every path passes through the same port twice".into(),
1172                )
1173            }
1174        }
1175    }
1176    RouteResult {
1177        connection_id: connection.id.clone(),
1178        feasible: true,
1179        status: RouteStatus::Routed,
1180        message: String::new(),
1181        length: Some(path.distance),
1182        segment_ids: path
1183            .segments
1184            .iter()
1185            .map(|&index| network.segments[index].id.clone())
1186            .collect(),
1187        node_path: path.nodes.iter().map(|&node| graph.keys[node].clone()).collect(),
1188        port_ids: path
1189            .nodes
1190            .iter()
1191            .map(|&node| graph.ports[graph.port_of[node]].clone())
1192            .collect(),
1193    }
1194}
1195
1196// ===========================================================================
1197// Bundles
1198// ===========================================================================
1199
1200/// Group the routed connections per segment, size each bundle, and (when the
1201/// block asks for it) sweep a circle of that diameter along the segment's
1202/// chain into a registered solid on `result`.
1203fn build_bundles(
1204    network: &Network,
1205    state: &WireHarnessState,
1206    routes: &[RouteResult],
1207    result: &mut FeatureResult,
1208) -> Vec<WireHarnessBundle> {
1209    // Segment id → (wire diameters, connection ids), in first-use order.
1210    let mut usage: Vec<(String, Vec<f64>, Vec<String>)> = Vec::new();
1211    for route in routes.iter().filter(|route| route.feasible) {
1212        let Some(connection) = state
1213            .connections
1214            .iter()
1215            .find(|connection| connection.id == route.connection_id)
1216        else {
1217            continue;
1218        };
1219        let diameter = connection.diameter.max(MIN_DIAMETER);
1220        for segment_id in &route.segment_ids {
1221            let entry = match usage.iter_mut().find(|(id, _, _)| id == segment_id) {
1222                Some(entry) => entry,
1223                None => {
1224                    usage.push((segment_id.clone(), Vec::new(), Vec::new()));
1225                    usage.last_mut().expect("just pushed")
1226                }
1227            };
1228            entry.1.push(diameter);
1229            if !entry.2.contains(&connection.id) {
1230                entry.2.push(connection.id.clone());
1231            }
1232        }
1233    }
1234
1235    let mut bundles = Vec::with_capacity(usage.len());
1236    for (segment_id, diameters, connection_ids) in usage {
1237        let Some(segment) = network.segment(&segment_id) else {
1238            continue;
1239        };
1240        let diameter = bundle_diameter(&diameters).max(MIN_DIAMETER);
1241        let mut bundle = WireHarnessBundle {
1242            segment_id: segment_id.clone(),
1243            solid_name: String::new(),
1244            wire_count: diameters.len(),
1245            diameter,
1246            length: segment.length,
1247            connection_ids,
1248            status: BundleStatus::BundlesOff,
1249            error: String::new(),
1250        };
1251        if state.build_bundles {
1252            let name = format!("{BUNDLE_SOLID_PREFIX}{segment_id}");
1253            match sweep_bundle(segment, diameter * 0.5, &name) {
1254                Ok(swept) => {
1255                    // The station lane's measured bound (`sweep.stations`).
1256                    if let Some(report) = swept.report {
1257                        result.approximations.push(report.approximation(name.clone()));
1258                    }
1259                    result.added.push(common::register_added(swept.solid, &name));
1260                    bundle.solid_name = name;
1261                    bundle.status = BundleStatus::Built;
1262                }
1263                Err(error) => {
1264                    // Classified on the refusal's own CLASS, minted where the
1265                    // sweep decides the bend is too tight: the sweep owns the
1266                    // sentence, this owns the status, and a rewording of the
1267                    // one cannot move the other.
1268                    bundle.status = if crate::is_sweep_tight_bend(&error) {
1269                        BundleStatus::TightBend
1270                    } else {
1271                        BundleStatus::BuildFailed
1272                    };
1273                    bundle.error = error.message;
1274                }
1275            }
1276        }
1277        bundles.push(bundle);
1278    }
1279    bundles
1280}
1281
1282/// Sweep a circle of `radius` along the segment's chain: two half-arcs in the
1283/// plane square to the chain's start tangent (a closed profile needs at least
1284/// two curves), the station budget scaled with the chain's piece count.
1285fn sweep_bundle(
1286    segment: &Segment,
1287    radius: f64,
1288    name: &str,
1289) -> Result<crate::SweptChain, crate::KernelRefusal> {
1290    use crate::{KernelRefusal, KernelStage, OrRefuse};
1291    let start = segment.chain[0]
1292        .domain()
1293        .and_then(|[t0, _]| segment.chain[0].evaluate(t0))
1294        .or_refuse(KernelStage::Collect, "evaluate")?;
1295    let tangent = end_tangent(&segment.chain, true).normalized().map_err(|_| {
1296        KernelRefusal::input(KernelStage::Collect, "zero_tangent", "the chain starts with a zero tangent")
1297    })?;
1298    let x_axis = tangent.perpendicular().or_refuse(KernelStage::Collect, "perpendicular")?;
1299    let y_axis = tangent.cross(x_axis).normalized().or_refuse(KernelStage::Collect, "normalized")?;
1300    let profile = vec![
1301        make_arc(start, x_axis, y_axis, radius, 0.0, std::f64::consts::PI)
1302            .or_refuse(KernelStage::Fragment, "make_arc")?,
1303        make_arc(start, x_axis, y_axis, radius, std::f64::consts::PI, std::f64::consts::TAU)
1304            .or_refuse(KernelStage::Fragment, "make_arc")?,
1305    ];
1306    let names: Vec<String> = (0..segment.chain.len())
1307        .map(|index| format!("{}:piece{index}", segment.id))
1308        .collect();
1309    let stations = (segment.chain.len() * STATIONS_PER_PIECE).clamp(32, 1024);
1310    // The chain is classified here, where it is assembled: a harness span is a
1311    // spline cut into pieces, so it is open and tangent-continuous by
1312    // construction, and the classification is what lets the builder SAY so when a
1313    // zero-length anchor extension leaves a corner in it anyway.
1314    let path = crate::SweepPath::new(segment.chain.clone(), names)?;
1315    let crate::SweptChain { mut solid, report } = crate::sweep_profile_along_chain_with_stations_reported(
1316        &profile,
1317        &path,
1318        stations,
1319        "a harness segment must be tangent-continuous (a spline always is; a zero extension at an anchor can leave a corner)",
1320        None,
1321    )?;
1322    // Face names: the two walls (profile-curve order), then START and END.
1323    let faces = &mut solid
1324        .shells
1325        .get_mut(0)
1326        .ok_or_else(|| KernelRefusal::internal(KernelStage::Sew, "bundle_shell", "the sweep produced no shell"))?
1327        .faces;
1328    let expected = ["Wall0", "Wall1", "Start", "End"];
1329    if faces.len() != expected.len() {
1330        return Err(KernelRefusal::internal(
1331            KernelStage::Sew,
1332            "bundle_faces",
1333            format!("the sweep produced {} faces, expected {}", faces.len(), expected.len()),
1334        ));
1335    }
1336    for (face, suffix) in faces.iter_mut().zip(expected) {
1337        face.name = Some(format!("{name}:{suffix}"));
1338    }
1339    Ok(crate::SweptChain { solid, report })
1340}
1341