Skip to main content

brep_kernel/feature_pipeline/
ports.rs

1//! PORTS — a part's declared connection points, as PART DATA rather than
2//! history features.
3//!
4//! # The block
5//!
6//! A part document carries `ports` as a sibling of `features`, beside the eCAD
7//! `symbol` and `pads` blocks:
8//!
9//! ```json
10//! "ports": [
11//!   { "name": "J1", "purpose": "wiring",
12//!     "points": [ { "name": "VCC", "transform": { … }, "pointRef": "Body_PZ" } ] }
13//! ]
14//! ```
15//!
16//! A port is a NAMED GROUP of connection points with one `purpose` — `pcb`,
17//! `wiring`, `piping`, or anything else a later slice invents; the kernel
18//! carries the string and never enumerates it. One part may declare several
19//! ports with different purposes (a valve with a piping port and a wiring port
20//! for its pressure sensor). None of this is a feature: there is no history
21//! row, no dialog, no icon and no schema-catalogue entry.
22//!
23//! # Addressing
24//!
25//! A connection point is addressed `[<occurrence chain>:]<port>.<point>` —
26//! `J1.VCC` in its own part, `ACOMP3:J1.VCC` once placed, `ACOMP7:ACOMP1:J1.VCC`
27//! at depth. `:` is the occurrence namespace the assembly lane already speaks
28//! (`component::namespaced`, peeled by `split_component_namespace`); `.`
29//! separates the port group from the point. The NAME is the identity — there is
30//! no second hidden id — so `:`, `.` and `/` are refused inside a port or point
31//! name (`/` because the harness routing graph keys its nodes `{address}/A`).
32//!
33//! The address is the key in [`SceneMap::ports`], so every existing consumer —
34//! `wireHarness` `from`/`to`, a spline anchor's `attachment.portRef`, a sketch
35//! on the point's frame — refers to a connection point exactly as it referred
36//! to a PORT feature id before, only with a name a user can read.
37//!
38//! # One symbol UNIT per port group
39//!
40//! A symbol pin binds to a connection point BY NAME (`part_pins.rs`). With
41//! several port groups `J1.1` and `J2.1` are both "point 1", so the name alone
42//! cannot say which. The part's SYMBOL says it instead: eCAD's `Symbol` already
43//! carries `unit_count`, KiCad's own model for a multi-unit device, and ONE
44//! SYMBOL UNIT MAPS TO ONE PORT GROUP. Pin `1` of unit 1 is `J1.1` and pin `1`
45//! of unit 2 is `J2.1`, so pin labels stay short and two identical headers on
46//! one part are ordinary rather than a collision.
47//!
48//! [`unit_map`] states the mapping:
49//!
50//! - a group may name its unit with `symbolUnit` (1-based), and that is the
51//!   mapping, written down;
52//! - a document with ONE group and no `symbolUnit` anywhere maps that group to
53//!   unit 1. This is the ordinary single-group part, and it declares nothing;
54//! - a document with SEVERAL groups binds only the groups that name a unit.
55//!   Position is deliberately NOT a fallback here: a piping component declaring
56//!   its piping port first and the wiring port of its pressure sensor second
57//!   would otherwise bind the sensor's pins into the piping port. A group with
58//!   no unit is not a schematic thing at all, which is exactly right for that
59//!   piping port.
60//!
61//! Disagreements are REPORTED, never refused, and each names what it concerns:
62//! a unit with no group ([`UnitProblem::UnitWithoutGroup`] — its pins can bind
63//! to nothing), a `symbolUnit` past the symbol's `unit_count`, and two groups
64//! claiming one unit. `unit_count` 0 reads as 1, which is what a symbol written
65//! before units existed carries.
66//!
67//! # A placement is an OFFSET IN A SEAT
68//!
69//! A point may name geometry that places it: `pointRef` locates it,
70//! `directionRef` turns it. Those two used to REPLACE the typed placement,
71//! which made picking a reference the end of the conversation — there was
72//! nothing left to adjust, and the `transform` beside them was dead data.
73//!
74//! They now supply a SEAT ([`seat_of`]): a [`Frame`] whose origin is where the
75//! reference puts the point and whose **x_axis** is the direction it gives
76//! (x, because a point's direction is its rotation applied to +X, the
77//! convention every feature transform reads — see
78//! [`Frame::from_origin_direction`]). The point's own `transform` is then read
79//! IN that frame: `position` along the seat's three axes, `rotationEuler`
80//! composed onto its basis. So "on this face, 2 mm off it, turned 30 degrees"
81//! is one point with a reference and a small offset, and moving the face
82//! carries all of it.
83//!
84//! A point that names NOTHING has the WORLD seat — origin at the part origin,
85//! the world axes — in which an offset is an absolute placement. That is what
86//! a document written before the seat existed says, and it reads exactly as it
87//! did. A reference that seats the origin but implies no direction (a vertex,
88//! a bare scene point) keeps the world axes too.
89//!
90//! What a reference may BE is the kernel's own analytic vocabulary
91//! ([`crate::SelectionGeometry`], the same one an assembly constraint and a
92//! PMI annotation measure against): a planar face seats on its plane along its
93//! normal, a cylindrical face on its axis, a CIRCULAR edge at its CENTRE along
94//! its axis, a straight edge at its chord midpoint, a vertex
95//! (`{solid}@x,y,z`) at the vertex. A face or edge the analytic lane cannot
96//! describe falls back to the parametric midpoint and the chord this module
97//! always read.
98//!
99//! # Resolved AFTER history
100//!
101//! [`finish_history_run`] runs at the tail of every history run, after the
102//! assembly solve and before the wire-harness router. That is deliberate: a
103//! port point may reference ANY geometry in the part without a history-ordering
104//! rule, because by the time it resolves the whole part is built. The tail is
105//! authoritative: it resolves every point and overwrites whatever was there.
106//!
107//! There is ONE deviation, and it is a fixed two-phase, not an ordering rule.
108//! [`seed_history_run`] publishes, BEFORE the walk, every point whose placement
109//! is ALREADY FULLY DETERMINED — no `pointRef`, no `directionRef`, no
110//! `mapsTo`, so nothing the walk builds can move it. A SPLINE in the same
111//! document can then attach to it, and a sketch can sit on its frame, which is
112//! the whole standalone-harness workflow. A point that DOES reference geometry
113//! stays tail-only: a same-document spline attached to one reports the address
114//! `unresolved` and the harness lists it as a segment problem, by name, rather
115//! than quietly routing to a stale placement. In an assembly the question does
116//! not arise — a placed component's points arrive with the component, during
117//! the walk.
118//!
119//! # Assemblies declare DOWN
120//!
121//! An assembly declares its own ports and maps each point to a descendant's
122//! point with `mapsTo`: `J1.VCC -> ACOMP2:J5.VCC`. The mapped point takes the
123//! target's resolved placement, so the interface moves with the child. Depth is
124//! unbounded because the target is just another address.
125//!
126//! # Encapsulation
127//!
128//! A PCB assembly is a BOUNDARY: its child components are not directly
129//! wireable, and the only way to reach inside is a port the board itself
130//! declares. An ordinary mechanical assembly is TRANSPARENT — an enclosure
131//! holding a motor and a sensor does not hide them.
132//!
133//! The mark is [`document_is_boundary`]: the explicit `portBoundary` flag when
134//! the document sets one, else derived — a document carrying a `pcb` block is a
135//! board and therefore a boundary. Boundary is INDEPENDENT of `purpose`: a
136//! terminal block on a board carries wiring-purpose points and must still be
137//! declared at board level before anything may wire to it, so purpose filters
138//! what KIND of connection a point is, never whether it is reachable.
139//!
140//! The filter is applied at EXPORT, in [`export_ports`]: what a part's library
141//! entry carries is what a parent can see. A boundary part exports only the
142//! points it declares itself; a transparent part exports everything its run
143//! published, descendants included. `attach_component_ports` then namespaces
144//! whatever arrived, and arbitrary depth falls out of the address.
145
146use std::collections::{BTreeMap, BTreeSet};
147
148use serde::{Deserialize, Serialize};
149
150use crate::feature_pipeline::features::common;
151use crate::feature_pipeline::{
152    Axis, Env, FeatureResult, Frame, HistoryRequest, PortKind, PortRecord, SceneMap, ScenePoint,
153};
154use crate::{make_line, SelectionGeometry, Vec3};
155
156/// The id of the tail's appended result (never a history feature). The scene
157/// entities a declared point publishes ride it, so the display pipeline draws
158/// and picks a connection point exactly as it draws a feature's curves.
159pub const PORTS_FEATURE_ID: &str = "Ports";
160/// The type of the appended tail result.
161pub const PORTS_FEATURE_TYPE: &str = "PORTS";
162
163/// The part document's top-level key for its declared ports.
164pub const PORTS_BLOCK: &str = "ports";
165/// The part document's top-level key for the explicit encapsulation flag.
166pub const BOUNDARY_KEY: &str = "portBoundary";
167/// What separates a port group from a point in an address.
168pub const ADDRESS_SEPARATOR: char = '.';
169/// Characters a port or point name may not contain: the occurrence namespace
170/// separator, the address separator, and the harness graph's side separator.
171pub const RESERVED: [char; 3] = [':', '.', '/'];
172
173/// Default straight run a wire keeps from a connection point before it may bend.
174pub const DEFAULT_EXTENSION: f64 = 2.0;
175/// Default length of the drawn point line.
176pub const DEFAULT_DISPLAY_LENGTH: f64 = 6.0;
177/// The shortest drawn line — a zero-length line would publish no path.
178const MIN_DISPLAY_LENGTH: f64 = 0.1;
179
180// ===========================================================================
181// The block as a document declares it
182// ===========================================================================
183
184/// One declared port: a named group of connection points with one purpose.
185#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
186pub struct PortDeclaration {
187    /// The group name (`J1`), part-local and unique within the document.
188    #[serde(default)]
189    pub name: String,
190    /// What kind of connection these points are: `pcb`, `wiring`, `piping`, …
191    /// Free-form and deliberately not an enum — the set is extensible.
192    #[serde(default)]
193    pub purpose: String,
194    /// The SYMBOL UNIT whose pins bind into this group, 1-based. `None` means
195    /// derive it — see [`unit_map`]. A group that maps to no unit takes no
196    /// pins, which is what a piping port wants.
197    #[serde(default, rename = "symbolUnit", skip_serializing_if = "Option::is_none")]
198    pub symbol_unit: Option<u32>,
199    #[serde(default)]
200    pub points: Vec<PortPoint>,
201}
202
203/// One connection point of a declared port.
204#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
205pub struct PortPoint {
206    /// The point name (`VCC`), part-local and unique within its port.
207    #[serde(default)]
208    pub name: String,
209    /// `{ position, rotationEuler }` — the placement when no reference seats
210    /// it. The direction is the rotation applied to `+X`, exactly as every
211    /// feature transform reads. Components may be expression strings.
212    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
213    pub transform: serde_json::Value,
214    /// Optional geometry that LOCATES the point: a scene point, a plane/datum
215    /// frame origin, a face midpoint or an edge. Unresolved is reported, never
216    /// fatal — the transform placement stands.
217    #[serde(default, rename = "pointRef", skip_serializing_if = "Option::is_none")]
218    pub point_ref: Option<String>,
219    /// Optional geometry that sets the DIRECTION: a face normal, a frame
220    /// normal, an axis or an edge chord. A `pointRef` naming a face seats the
221    /// direction too when this is absent.
222    #[serde(default, rename = "directionRef", skip_serializing_if = "Option::is_none")]
223    pub direction_ref: Option<String>,
224    #[serde(default, rename = "reverseDirection", skip_serializing_if = "is_false")]
225    pub reverse_direction: bool,
226    /// The straight run a wire keeps before it may bend (a spline anchor
227    /// attached here takes it as its forward/backward distance).
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub extension: Option<f64>,
230    #[serde(default, rename = "displayLength", skip_serializing_if = "Option::is_none")]
231    pub display_length: Option<f64>,
232    /// ASSEMBLY declaration: the descendant point this one IS, by address
233    /// (`ACOMP2:J5.VCC`). The mapped point takes the target's resolved
234    /// placement, so the interface follows the child.
235    #[serde(default, rename = "mapsTo", skip_serializing_if = "Option::is_none")]
236    pub maps_to: Option<String>,
237}
238
239fn is_false(value: &bool) -> bool {
240    !*value
241}
242
243// ===========================================================================
244// Addresses
245// ===========================================================================
246
247/// The address of a point within its own part: `{port}.{point}`.
248pub fn address(port: &str, point: &str) -> String {
249    format!("{port}{ADDRESS_SEPARATOR}{point}")
250}
251
252/// Split a PART-LOCAL address into `(port, point)`. `None` when it carries no
253/// separator — a WAYPOINT feature id, for one, which is an address in its own
254/// right and belongs to no port.
255pub fn split_address(address: &str) -> Option<(&str, &str)> {
256    address.split_once(ADDRESS_SEPARATOR)
257}
258
259/// Why a name cannot be used, or `None` when it can.
260fn name_problem(kind: &str, name: &str) -> Option<String> {
261    if name.trim().is_empty() {
262        return Some(format!("a {kind} has no name"));
263    }
264    if name.contains(RESERVED) {
265        return Some(format!(
266            "{kind} '{name}' contains a reserved character (':', '.' or '/')"
267        ));
268    }
269    None
270}
271
272// ===========================================================================
273// Symbol units and port groups
274// ===========================================================================
275
276/// Something about the unit-to-group mapping that does not hold. Never fatal:
277/// what maps still binds, and this says what did not.
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(tag = "problem", rename_all = "kebab-case")]
280pub enum UnitProblem {
281    /// The symbol has this unit and no port group takes it, so its pins bind
282    /// to nothing.
283    UnitWithoutGroup { unit: u32 },
284    /// A group names a unit the symbol does not have (`0`, or past
285    /// `unit_count`).
286    UnitOutOfRange { port: String, unit: u32, units: u32 },
287    /// Two groups name one unit; the FIRST in block order keeps it.
288    UnitContested { unit: u32, ports: Vec<String> },
289}
290
291impl UnitProblem {
292    pub fn message(&self) -> String {
293        match self {
294            UnitProblem::UnitWithoutGroup { unit } => {
295                format!("symbol unit {unit} has no port group, so its pins bind to nothing")
296            }
297            UnitProblem::UnitOutOfRange { port, unit, units } => format!(
298                "port '{port}' names symbol unit {unit}, and the symbol has {units}"
299            ),
300            UnitProblem::UnitContested { unit, ports } => {
301                format!("ports {} all name symbol unit {unit}", ports.join(", "))
302            }
303        }
304    }
305}
306
307/// Which port group each symbol unit binds its pins into, and everything about
308/// the mapping that does not hold. The map is `unit (1-based) -> index into
309/// `declarations``; a unit absent from it binds no pins.
310///
311/// `unit_count` is the symbol's, and `0` reads as `1`. The rule is in this
312/// module's docs; in short, an explicit `symbolUnit` decides, one group with
313/// none takes unit 1, and several groups with none bind nothing.
314///
315/// `has_symbol` is whether the document carries a symbol at all. A 3D-only part
316/// has no units, so it reports no missing ones.
317pub fn unit_map(
318    declarations: &[PortDeclaration],
319    unit_count: u32,
320    has_symbol: bool,
321) -> (BTreeMap<u32, usize>, Vec<UnitProblem>) {
322    let units = unit_count.max(1);
323    let mut map: BTreeMap<u32, usize> = BTreeMap::new();
324    let mut problems = Vec::new();
325    let mut contested: BTreeMap<u32, Vec<String>> = BTreeMap::new();
326
327    let explicit: Vec<(usize, u32)> = declarations
328        .iter()
329        .enumerate()
330        .filter_map(|(index, group)| group.symbol_unit.map(|unit| (index, unit)))
331        .collect();
332    for (index, unit) in &explicit {
333        let port = declarations[*index].name.trim().to_string();
334        if *unit == 0 || *unit > units {
335            problems.push(UnitProblem::UnitOutOfRange { port, unit: *unit, units });
336            continue;
337        }
338        match map.get(unit) {
339            Some(first) => contested
340                .entry(*unit)
341                .or_insert_with(|| vec![declarations[*first].name.trim().to_string()])
342                .push(port),
343            None => {
344                map.insert(*unit, *index);
345            }
346        }
347    }
348    for (unit, ports) in contested {
349        problems.push(UnitProblem::UnitContested { unit, ports });
350    }
351    // The ordinary part: one group, nothing written down, unit 1.
352    if explicit.is_empty() && declarations.len() == 1 {
353        map.insert(1, 0);
354    }
355    if has_symbol {
356        for unit in 1..=units {
357            if !map.contains_key(&unit) {
358                problems.push(UnitProblem::UnitWithoutGroup { unit });
359            }
360        }
361    }
362    (map, problems)
363}
364
365// ===========================================================================
366// The tail's report
367// ===========================================================================
368
369/// A [`Frame`] as the ports REPORT carries it: four plain triples rather than
370/// [`Vec3`]s, because a report is compared and serialized (a `Vec3` carries no
371/// `PartialEq`, by choice, and this is the shape the app and the verifier
372/// read).
373#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
374pub struct PortSeat {
375    pub origin: [f64; 3],
376    /// The seat's x axis, which is the direction the reference gives — see
377    /// [`Frame::from_origin_direction`].
378    pub x: [f64; 3],
379    pub y: [f64; 3],
380    pub z: [f64; 3],
381}
382
383impl PortSeat {
384    pub fn to_frame(self) -> Frame {
385        let v = |a: [f64; 3]| Vec3::new(a[0], a[1], a[2]);
386        Frame {
387            origin: v(self.origin),
388            x_axis: v(self.x),
389            y_axis: v(self.y),
390            z_axis: v(self.z),
391        }
392    }
393}
394
395impl From<Frame> for PortSeat {
396    fn from(frame: Frame) -> Self {
397        let a = |v: Vec3| [v.x, v.y, v.z];
398        PortSeat {
399            origin: a(frame.origin),
400            x: a(frame.x_axis),
401            y: a(frame.y_axis),
402            z: a(frame.z_axis),
403        }
404    }
405}
406
407/// One resolved connection point, as the tail publishes it.
408#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
409pub struct PortPointRow {
410    /// The part-local address (`J1.VCC`) — the [`SceneMap::ports`] key.
411    pub address: String,
412    pub port: String,
413    pub point: String,
414    pub purpose: String,
415    /// The descendant address this point maps to, when it declares one.
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub maps_to: Option<String>,
418    /// The frame this point's `transform` was read as an offset IN
419    /// ([`seat_of`]) — the world frame for a point that names no reference.
420    ///
421    /// Report-only, and deliberately not on [`PortRecord`]: a seat is what the
422    /// last RUN made of the references, not something the document carries or
423    /// a library entry exports. The editor needs it to put a gizmo on the
424    /// point, because dragging one in world space has to be written back as an
425    /// offset in exactly this frame.
426    pub seat: PortSeat,
427    /// Where the point resolved, in world space (`record.point`).
428    pub position: [f64; 3],
429    /// The unit direction it resolved facing (`record.direction`).
430    pub direction: [f64; 3],
431}
432
433/// What the ports tail resolved, and everything about the block that does not
434/// hold — a name it refused, a reference that resolved to nothing, a mapping
435/// whose target is not in the scene. Never fatal: a problem is reported and the
436/// point still publishes at whatever placement could be worked out, so nothing
437/// downstream vanishes (the same contract rule the features follow).
438#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
439pub struct PortsReport {
440    /// Whether this document encapsulates its children ([`document_is_boundary`]).
441    #[serde(default)]
442    pub boundary: bool,
443    pub points: Vec<PortPointRow>,
444    pub problems: Vec<String>,
445}
446
447// ===========================================================================
448// Encapsulation
449// ===========================================================================
450
451/// Whether a DOCUMENT hides its children behind its declared ports.
452///
453/// The explicit `portBoundary` flag decides when the document sets one. With no
454/// flag it is derived: a document carrying a `pcb` block is a board, and a
455/// board is a boundary by construction. Derivation alone could not mark a
456/// non-PCB enclosure; a flag alone would have to be set on every board by hand,
457/// which is exactly the case the user said holds "by construction".
458pub fn document_is_boundary(document: &serde_json::Value) -> bool {
459    match document.get(BOUNDARY_KEY).and_then(serde_json::Value::as_bool) {
460        Some(explicit) => explicit,
461        None => document.get("pcb").is_some_and(|block| !block.is_null()),
462    }
463}
464
465/// The same question for a parsed request (the tail's lane).
466pub fn request_is_boundary(request: &HistoryRequest) -> bool {
467    request.port_boundary.unwrap_or(request.pcb_present)
468}
469
470/// What a part's library entry carries out of its own run — the encapsulation
471/// filter, applied ONCE, here.
472///
473/// A TRANSPARENT part exports every port record its run published: its own
474/// declared points and, namespaced, every point its children brought. A
475/// BOUNDARY part exports only the points it declares itself, so a parent can
476/// reach its connectors and nothing else.
477pub fn export_ports(
478    scene_ports: &BTreeMap<String, PortRecord>,
479    declared: &[String],
480    boundary: bool,
481) -> BTreeMap<String, PortRecord> {
482    if !boundary {
483        return scene_ports.clone();
484    }
485    let declared: BTreeSet<&str> = declared.iter().map(String::as_str).collect();
486    scene_ports
487        .iter()
488        .filter(|(address, _)| declared.contains(address.as_str()))
489        .map(|(address, record)| (address.clone(), record.clone()))
490        .collect()
491}
492
493// ===========================================================================
494// Publishing one point's scene entities
495// ===========================================================================
496
497/// The drawn line for a connection point: a termination's wire leaves along the
498/// direction; a waypoint's wire passes through, so its line straddles the base
499/// point.
500pub fn port_line(record: &PortRecord) -> (Vec3, Vec3) {
501    let point = record.point;
502    let direction = record.direction;
503    let length = record.display_length;
504    match record.kind {
505        PortKind::Termination => (point, point.add(direction.scale(length))),
506        PortKind::Waypoint => (
507            point.sub(direction.scale(length * 0.5)),
508            point.add(direction.scale(length * 0.5)),
509        ),
510    }
511}
512
513/// Publish one connection point's scene entities under `address`: the record
514/// (`ports[address]`), its base point (`{address}:Base`), its axis and its
515/// frame (both `address`) and its drawn line (`{address}:PortLine`). A sketch
516/// on the frame is a connector face square to the wire.
517///
518/// The `Vec` side-channel form, for a FEATURE result (WAYPOINT, and the ACOMP
519/// that re-publishes a placed part's points).
520pub fn publish_port(
521    result: &mut crate::feature_pipeline::FeatureResult,
522    address: &str,
523    record: PortRecord,
524) -> Result<(), String> {
525    let (start, end) = port_line(&record);
526    result
527        .paths
528        .push((format!("{address}:PortLine"), vec![make_line(start, end)?]));
529    result
530        .points
531        .push((format!("{address}:Base"), ScenePoint::model(record.point)));
532    result.axes.push((
533        address.to_string(),
534        Axis { point: record.point, direction: record.direction },
535    ));
536    result.frames.push((
537        address.to_string(),
538        Frame::from_origin_normal(record.point, record.direction)?,
539    ));
540    result.ports.push((address.to_string(), record));
541    Ok(())
542}
543
544/// The same entities written straight into a scene — the ports tail and the
545/// component re-pose lane, which moves a placed part's points without
546/// re-running its feature.
547pub fn place_scene_port(
548    scene: &mut SceneMap,
549    address: &str,
550    record: PortRecord,
551) -> Result<(), String> {
552    let (start, end) = port_line(&record);
553    scene
554        .paths
555        .insert(format!("{address}:PortLine"), vec![make_line(start, end)?]);
556    scene
557        .points
558        .insert(format!("{address}:Base"), ScenePoint::model(record.point));
559    scene.axes.insert(
560        address.to_string(),
561        Axis { point: record.point, direction: record.direction },
562    );
563    scene.frames.insert(
564        address.to_string(),
565        Frame::from_origin_normal(record.point, record.direction)?,
566    );
567    scene.ports.insert(address.to_string(), record);
568    Ok(())
569}
570
571// ===========================================================================
572// The SEAT — what a reference resolves to, and the frame a placement sits in
573// ===========================================================================
574
575/// Where a reference sits, and the direction it implies when it has one.
576///
577/// The vocabulary is the kernel's own analytic one ([`SelectionGeometry`]), so
578/// a connection point seats on exactly what an assembly constraint and a PMI
579/// annotation measure against:
580///
581/// | picked | seats at | points along |
582/// |---|---|---|
583/// | a planar face | the face's boundary-AABB centre on its plane | its outward normal |
584/// | a cylindrical / conical face (a bore) | a point on its axis | its axis |
585/// | a spherical face | the centre | — |
586/// | a CIRCULAR edge (a hole rim) | the **centre** | its axis |
587/// | a straight edge | the chord midpoint | the chord |
588/// | a vertex (`{solid}@x,y,z`) | the vertex | — |
589/// | a scene point, a datum/plane frame, an axis | its own origin | its own normal / direction |
590///
591/// The scene-native names (points, frames, axes) are tried FIRST and keep the
592/// meaning they always had. Everything else goes through the analytic
593/// resolver, and a face or edge it calls unsupported (a freeform patch, a
594/// spline edge) falls back to what this module read before: a face's
595/// parametric midpoint and normal, an edge's start and chord.
596fn resolve_seat_reference(scene: &SceneMap, name: &str) -> Result<(Vec3, Option<Vec3>), String> {
597    if let Some(position) = scene.resolve_point(name) {
598        return Ok((position, None));
599    }
600    if let Some(frame) = scene.resolve_frame(name) {
601        return Ok((frame.origin, Some(frame.z_axis)));
602    }
603    if let Some(axis) = scene.resolve_axis(name) {
604        return Ok((axis.point, Some(axis.direction)));
605    }
606    match analytic_reference(scene, name) {
607        Some(Ok(geometry)) => Ok(seat_of_geometry(&geometry)),
608        Some(Err(problem)) => Err(problem),
609        None => Err(format!("'{name}' resolves to no geometry")),
610    }
611}
612
613/// [`resolve_seat_reference`]'s direction half: the same vocabulary, keeping
614/// only what implies a direction. A reference that implies none (a vertex, a
615/// sphere's centre, a bare scene point) is refused BY NAME rather than
616/// silently leaving the direction where it was.
617fn resolve_seat_direction(scene: &SceneMap, name: &str) -> Result<Vec3, String> {
618    if let Some(frame) = scene.resolve_frame(name) {
619        return Ok(frame.z_axis);
620    }
621    if let Some(axis) = scene.resolve_axis(name) {
622        return Ok(axis.direction);
623    }
624    if scene.resolve_point(name).is_some() {
625        return Err(format!("'{name}' is a point, which implies no direction"));
626    }
627    match analytic_reference(scene, name) {
628        Some(Ok(geometry)) => seat_of_geometry(&geometry)
629            .1
630            .ok_or_else(|| format!("'{name}' implies no direction")),
631        Some(Err(problem)) => Err(problem),
632        None => Err(format!("'{name}' resolves to no geometry")),
633    }
634}
635
636/// One analytic entity's seat: where it sits and the direction it implies.
637fn seat_of_geometry(geometry: &SelectionGeometry) -> (Vec3, Option<Vec3>) {
638    match geometry {
639        SelectionGeometry::Plane { origin, normal } => (*origin, Some(*normal)),
640        SelectionGeometry::Axis { origin, direction, .. } => (*origin, Some(*direction)),
641        SelectionGeometry::Sphere { center, .. } => (*center, None),
642        SelectionGeometry::Circle { center, axis, .. } => (*center, Some(*axis)),
643        SelectionGeometry::Line { origin, direction } => (*origin, Some(*direction)),
644        SelectionGeometry::Point { position } => (*position, None),
645    }
646}
647
648/// `name` as an ANALYTIC entity — a face, an edge, or a `{solid}@x,y,z` vertex
649/// reference — or `None` when the scene holds no such thing under that name.
650///
651/// `Some(Err(..))` is a name that DID resolve to topology the analytic lane
652/// cannot describe. A face or edge in that state falls back to this module's
653/// older reading rather than refusing the point: a freeform seat is worse than
654/// an analytic one and much better than none.
655fn analytic_reference(
656    scene: &SceneMap,
657    name: &str,
658) -> Option<Result<SelectionGeometry, String>> {
659    // A VERTEX carries no kernel name, so the viewport references one by its
660    // owning solid and world position — the same form PMI writes, resolved by
661    // the same helper (nearest topology vertex, snap-gated).
662    if name.contains('@') {
663        return Some(
664            crate::feature_pipeline::pmi::resolve::resolve_reference(scene, name)
665                .map_err(|error| error.to_string()),
666        );
667    }
668    if let Some(face) = scene.resolve_face(name) {
669        let analytic = crate::with_registered_solid_str(face.handle, |solid| {
670            Ok(crate::resolve_face_selection(solid, face.face_id))
671        });
672        return Some(match analytic {
673            Ok(Ok(geometry)) => Ok(geometry),
674            // Unsupported / failed: the parametric midpoint and normal, which
675            // is what a `pointRef` on a face has always meant here.
676            _ => common::face_point_normal(face).map(|(point, normal)| {
677                SelectionGeometry::Plane { origin: point, normal }
678            }),
679        });
680    }
681    if let Some(edge) = scene.resolve_edge(name) {
682        let analytic = crate::with_registered_solid_str(edge.handle, |solid| {
683            Ok(crate::resolve_edge_selection(solid, edge.edge_id))
684        });
685        return Some(match analytic {
686            Ok(Ok(geometry)) => Ok(geometry),
687            // A freeform edge: its start and chord, as before. A CLOSED one
688            // has a zero chord and refuses itself here rather than failing the
689            // whole point — which is what a circle used to do, before the
690            // analytic lane above started answering with its centre.
691            _ => common::edge_axis(edge).map(|axis| SelectionGeometry::Line {
692                origin: axis.point,
693                direction: axis.direction,
694            }),
695        });
696    }
697    None
698}
699
700/// The FRAME a connection point's placement is an offset in.
701///
702/// Its origin is where `pointRef` seats the point (the part origin when there
703/// is none) and its **x_axis** is the direction that seats it: `directionRef`
704/// when the point names one, else whatever `pointRef` implied, else +X. The
705/// point's own `transform` is then read IN this frame — `position` along its
706/// three axes, `rotationEuler` composed onto its basis — so a point seated on
707/// a face can be nudged 2 mm off it and turned 30 degrees on it without the
708/// reference being lost.
709///
710/// With NO reference at all the seat is the world frame, and a local offset in
711/// the world frame is an absolute placement: that is the case every document
712/// written before the seat existed is in, and it reads exactly as it did.
713///
714/// Problems are collected, never fatal. A reference that resolves to nothing
715/// leaves that half of the seat at its default and says so by name.
716pub fn seat_of(
717    point: &PortPoint,
718    scene: &SceneMap,
719) -> (Frame, Vec<String>) {
720    let mut problems = Vec::new();
721    let mut origin = Vec3::new(0.0, 0.0, 0.0);
722    // `None` until a reference SUPPLIES one. A seated origin with no direction
723    // — a vertex, a bare scene point — keeps the world axes, so the point's
724    // own rotation still means what it means everywhere else in the document.
725    let mut direction: Option<Vec3> = None;
726
727    if let Some(reference) = trimmed(point.point_ref.as_deref()) {
728        match resolve_seat_reference(scene, reference) {
729            Ok((located, implied)) => {
730                origin = located;
731                if let Some(implied) = implied {
732                    direction = Some(implied);
733                }
734            }
735            Err(problem) => problems.push(problem),
736        }
737    }
738    if let Some(reference) = trimmed(point.direction_ref.as_deref()) {
739        match resolve_seat_direction(scene, reference) {
740            Ok(resolved) => direction = Some(resolved),
741            Err(problem) => problems.push(problem),
742        }
743    }
744    // No direction, or one too degenerate to make a basis from: the WORLD axes
745    // at whatever origin was seated. With no references at all that is the
746    // world frame exactly, in which an offset IS an absolute placement — which
747    // is what every document written before the seat existed says.
748    let seat = match direction {
749        Some(direction) => Frame::from_origin_direction(origin, direction)
750            .unwrap_or_else(|_| Frame { origin, ..world_seat() }),
751        None => Frame { origin, ..world_seat() },
752    };
753    (seat, problems)
754}
755
756/// A local offset carried into world space by `seat`.
757pub fn seat_point(seat: &Frame, local: Vec3) -> Vec3 {
758    seat.origin
759        .add(seat.x_axis.scale(local.x))
760        .add(seat.y_axis.scale(local.y))
761        .add(seat.z_axis.scale(local.z))
762}
763
764/// A world position read back as an offset in `seat` — the inverse of
765/// [`seat_point`], which is what a gizmo drag writes. The basis is orthonormal,
766/// so the inverse is the transpose.
767pub fn unseat_point(seat: &Frame, world: Vec3) -> Vec3 {
768    unseat_vector(seat, world.sub(seat.origin))
769}
770
771/// A local DIRECTION carried into world space by `seat` (no translation).
772pub fn seat_vector(seat: &Frame, local: Vec3) -> Vec3 {
773    seat.x_axis
774        .scale(local.x)
775        .add(seat.y_axis.scale(local.y))
776        .add(seat.z_axis.scale(local.z))
777}
778
779/// A world direction read back in `seat`'s basis.
780pub fn unseat_vector(seat: &Frame, world: Vec3) -> Vec3 {
781    Vec3::new(
782        world.dot(seat.x_axis),
783        world.dot(seat.y_axis),
784        world.dot(seat.z_axis),
785    )
786}
787
788/// The world direction a point with this seat and this rotation faces: the
789/// rotation applied to +X (every feature transform's convention), carried
790/// through the seat's basis. With the world seat this is the rotated +X
791/// itself.
792pub fn seated_direction(seat: &Frame, rotation_deg: [f64; 3]) -> Vec3 {
793    let local = crate::feature_pipeline::features::datum::rotate_euler_xyz(
794        Vec3::new(1.0, 0.0, 0.0),
795        [
796            rotation_deg[0].to_radians(),
797            rotation_deg[1].to_radians(),
798            rotation_deg[2].to_radians(),
799        ],
800    );
801    seat_vector(seat, local)
802}
803
804/// A non-empty trimmed reference, or `None`.
805fn trimmed(value: Option<&str>) -> Option<&str> {
806    value.map(str::trim).filter(|text| !text.is_empty())
807}
808
809/// A `directionRef`'s unit direction, or `None` when the name resolves to
810/// nothing that implies one. The WAYPOINT feature's lane — a waypoint has no
811/// seat (it is a feature with its own transform), so it takes the direction
812/// alone and lists an unresolved name in its own report.
813pub fn resolve_direction(scene: &SceneMap, name: &str) -> Result<Option<Vec3>, String> {
814    Ok(resolve_seat_direction(scene, name).ok())
815}
816
817// ===========================================================================
818// The tail
819// ===========================================================================
820
821/// Publish, BEFORE the history walk, every declared point whose placement is
822/// already fully determined — see the module doc. Returns each published scene
823/// name with the CONTENT VERSION of the declaration behind it, so the
824/// incremental cache invalidates a feature that consumed the point when the
825/// declaration moves (without this a spline attached to a point would replay
826/// from cache at the point's old placement).
827pub fn seed_history_run(
828    request: &HistoryRequest,
829    scene: &mut SceneMap,
830    env: &Env,
831) -> Vec<(String, u64)> {
832    let mut seeded = Vec::new();
833    let mut ports_seen: BTreeSet<&str> = BTreeSet::new();
834    for declaration in &request.ports {
835        let port = declaration.name.trim();
836        if name_problem("port", port).is_some() || !ports_seen.insert(port) {
837            continue;
838        }
839        let mut points_seen: BTreeSet<&str> = BTreeSet::new();
840        for point in &declaration.points {
841            let name = point.name.trim();
842            if name_problem("point", name).is_some() || !points_seen.insert(name) {
843                continue;
844            }
845            if point.point_ref.is_some() || point.direction_ref.is_some() || point.maps_to.is_some()
846            {
847                continue;
848            }
849            let Ok((record, _, _)) =
850                resolve_point(point, port, name, &declaration.purpose, scene, env)
851            else {
852                continue;
853            };
854            let address = address(port, name);
855            if place_scene_port(scene, &address, record).is_err() {
856                continue;
857            }
858            let version = declaration_version(port, &declaration.purpose, point);
859            for name in [
860                address.clone(),
861                format!("{address}:Base"),
862                format!("{address}:PortLine"),
863            ] {
864                seeded.push((name, version));
865            }
866        }
867    }
868    seeded
869}
870
871/// The content version of one declared point: its port, its purpose and the
872/// point itself. Two runs that declare the same point the same way produce the
873/// same number, and any edit to it produces a different one.
874fn declaration_version(port: &str, purpose: &str, point: &PortPoint) -> u64 {
875    use std::hash::{Hash, Hasher};
876    let mut hasher = std::collections::hash_map::DefaultHasher::new();
877    port.hash(&mut hasher);
878    purpose.hash(&mut hasher);
879    serde_json::to_string(point).unwrap_or_default().hash(&mut hasher);
880    hasher.finish()
881}
882
883/// What the ports tail produced: its report, and the appended result the
884/// display pipeline consumes.
885#[derive(Debug, Default)]
886pub struct PortsOutcome {
887    pub report: Option<PortsReport>,
888    pub result: Option<FeatureResult>,
889}
890
891/// Resolve the request's `ports` block against the FINISHED scene and publish
892/// every point. Runs after the assembly solve (so a mapped point reads its
893/// child's posed placement) and before the wire-harness router (which routes
894/// over what this published). Empty when the document declares no ports.
895pub fn finish_history_run(
896    request: &HistoryRequest,
897    scene: &mut SceneMap,
898    env: &Env,
899) -> PortsOutcome {
900    if request.ports.is_empty() {
901        return PortsOutcome::default();
902    }
903    let mut result = FeatureResult::pass_through(
904        PORTS_FEATURE_ID.to_string(),
905        PORTS_FEATURE_TYPE.to_string(),
906    );
907    let mut report = PortsReport {
908        boundary: request_is_boundary(request),
909        ..PortsReport::default()
910    };
911    let mut ports_seen: BTreeSet<&str> = BTreeSet::new();
912
913    for declaration in &request.ports {
914        let port = declaration.name.trim();
915        if let Some(problem) = name_problem("port", port) {
916            report.problems.push(problem);
917            continue;
918        }
919        if !ports_seen.insert(port) {
920            report.problems.push(format!("two ports are named '{port}'"));
921            continue;
922        }
923        let mut points_seen: BTreeSet<&str> = BTreeSet::new();
924        for point in &declaration.points {
925            let name = point.name.trim();
926            if let Some(problem) = name_problem(&format!("point of port '{port}'"), name) {
927                report.problems.push(problem);
928                continue;
929            }
930            if !points_seen.insert(name) {
931                report
932                    .problems
933                    .push(format!("port '{port}' has two points named '{name}'"));
934                continue;
935            }
936            let address = address(port, name);
937            match resolve_point(point, port, name, &declaration.purpose, scene, env) {
938                Ok((record, seat, problems)) => {
939                    report.problems.extend(problems);
940                    let (position, direction) = (record.point, record.direction);
941                    if let Err(error) = publish_port(&mut result, &address, record) {
942                        report.problems.push(format!("{address}: {error}"));
943                        continue;
944                    }
945                    report.points.push(PortPointRow {
946                        address,
947                        port: port.to_string(),
948                        point: name.to_string(),
949                        purpose: declaration.purpose.clone(),
950                        maps_to: point.maps_to.clone(),
951                        seat: seat.into(),
952                        position: [position.x, position.y, position.z],
953                        direction: [direction.x, direction.y, direction.z],
954                    });
955                }
956                Err(error) => report.problems.push(format!("{address}: {error}")),
957            }
958        }
959    }
960    scene.apply(&result);
961    PortsOutcome { report: Some(report), result: Some(result) }
962}
963
964/// One point's record and its seat, plus whatever about it did not hold. A
965/// reference that resolves to nothing is a problem, never an error: the point
966/// still publishes, at whatever placement could be worked out.
967///
968/// The placement rule, in one sentence: **a point's `transform` is an offset in
969/// its seat** ([`seat_of`]) — its `position` along the seat's three axes, its
970/// `rotationEuler` composed onto the seat's basis. A point with no reference
971/// has the WORLD seat, where an offset is an absolute placement, so every
972/// document written before the seat existed reads exactly as it did.
973fn resolve_point(
974    point: &PortPoint,
975    port: &str,
976    name: &str,
977    purpose: &str,
978    scene: &SceneMap,
979    env: &Env,
980) -> Result<(PortRecord, Frame, Vec<String>), String> {
981    let address = address(port, name);
982    let mut problems = Vec::new();
983
984    // A mapped point IS its target: the assembly declares the interface, the
985    // descendant holds the geometry. It has no seat of its own — its placement
986    // is the target's, and a gizmo on one would be editing the wrong document.
987    if let Some(target) = point.maps_to.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
988        match scene.resolve_port(target) {
989            Some(mapped) => {
990                let seat = Frame::from_origin_direction(mapped.point, mapped.direction)
991                    .unwrap_or_else(|_| world_seat());
992                return Ok((
993                    PortRecord {
994                        point: mapped.point,
995                        direction: mapped.direction,
996                        kind: PortKind::Termination,
997                        extension: point.extension.unwrap_or(mapped.extension).max(0.0),
998                        display_length: point
999                            .display_length
1000                            .unwrap_or(mapped.display_length)
1001                            .max(MIN_DISPLAY_LENGTH),
1002                        port_name: port.to_string(),
1003                        point_name: name.to_string(),
1004                        purpose: purpose.to_string(),
1005                    },
1006                    seat,
1007                    problems,
1008                ));
1009            }
1010            None => problems.push(format!(
1011                "{address} maps to '{target}', which is not a connection point of this document"
1012            )),
1013        }
1014    }
1015
1016    let position = common::vec3_from_value(
1017        env,
1018        point.transform.get("position"),
1019        &format!("{address}.transform.position"),
1020        [0.0, 0.0, 0.0],
1021    )?;
1022    let rotation = common::vec3_from_value(
1023        env,
1024        point.transform.get("rotationEuler"),
1025        &format!("{address}.transform.rotationEuler"),
1026        [0.0, 0.0, 0.0],
1027    )?;
1028
1029    let (seat, seat_problems) = seat_of(point, scene);
1030    problems.extend(
1031        seat_problems
1032            .into_iter()
1033            .map(|problem| format!("{address}: {problem}")),
1034    );
1035
1036    let base = seat_point(&seat, Vec3::new(position[0], position[1], position[2]));
1037    let mut direction = seated_direction(&seat, rotation)
1038        .normalized()
1039        .map_err(|_| "direction is a zero vector".to_string())?;
1040    if point.reverse_direction {
1041        direction = direction.scale(-1.0);
1042    }
1043    Ok((
1044        PortRecord {
1045            point: base,
1046            direction,
1047            kind: PortKind::Termination,
1048            extension: point.extension.unwrap_or(DEFAULT_EXTENSION).max(0.0),
1049            display_length: point
1050                .display_length
1051                .unwrap_or(DEFAULT_DISPLAY_LENGTH)
1052                .max(MIN_DISPLAY_LENGTH),
1053            port_name: port.to_string(),
1054            point_name: name.to_string(),
1055            purpose: purpose.to_string(),
1056        },
1057        seat,
1058        problems,
1059    ))
1060}
1061
1062/// The seat of a point that names no reference: the world frame, in which an
1063/// offset is an absolute placement.
1064pub fn world_seat() -> Frame {
1065    Frame {
1066        origin: Vec3::new(0.0, 0.0, 0.0),
1067        x_axis: Vec3::new(1.0, 0.0, 0.0),
1068        y_axis: Vec3::new(0.0, 1.0, 0.0),
1069        z_axis: Vec3::new(0.0, 0.0, 1.0),
1070    }
1071}
1072