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