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