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