Skip to main content

brep_render/engine_state/
board_geometry.rs

1//! The BOARD in 3D: the substrate solid, the copper on each layer, and the
2//! plated via barrels through it — derived from the document's `pcb` block,
3//! never a feature.
4//!
5//! # Why it is derived and not a feature
6//!
7//! A board's geometry is a READING of eCAD's board, the way a part's declared
8//! connection points are a reading of its `ports` block (the 2026-09-20 ports
9//! record). It has no parameters a user would edit in a dialog, no place in the
10//! history order — nothing downstream can reference it, because nothing is
11//! ordered against it — and it must follow every track the router lays down
12//! without an undo step of its own. So it is rebuilt at the TAIL, after the run
13//! ([`EngineState::finish_apply`]) and after any write to the `pcb` block, from
14//! the block alone. The PCB workbench still creates no feature of its own.
15//!
16//! It is built HERE rather than in the kernel for two reasons: the kernel is
17//! told only that a `pcb` block is PRESENT (`HistoryRequest::pcb_present`) and
18//! deliberately neither reads nor rewrites it, and this crate already holds
19//! both `brep_ecad_core` and the scene. The cost is stated plainly below.
20//!
21//! # DISPLAY geometry, not kernel solids
22//!
23//! Every body this module inserts is a synthesized [`SolidDisplay`] with
24//! `source_handle == 0` — triangles, not B-rep. That is the cheap thing, built
25//! first and on purpose: it is visible, correctly placed, coloured, listed in
26//! the Scene tree, hideable and pickable, and it costs one GPU buffer set per
27//! LAYER however many track segments the layer carries. What it cannot do:
28//!
29//!   * it cannot be cut against — no boolean takes it as a tool or a target;
30//!   * it has no mass properties and no measurable exact topology;
31//!   * a drawing sheet that draws it falls back to MESH hidden-line for the
32//!     whole view, because the analytic pass needs every drawn solid's B-rep;
33//!   * it is not what STEP export writes: the export builds the board again as
34//!     exact solids ([`step_solids`]), handed straight to the writer and never
35//!     entered in the scene or the kernel's registry.
36//!
37//! Real SCENE solids would have to be minted on the RUNNER thread, where the
38//! kernel's solid registry lives, which means a kernel tail, which means the
39//! kernel reading eCAD's block. That is a layering decision, not a day's work.
40//!
41//! # The stack
42//!
43//! Board coordinates are integer micrometres with +Y DOWN; BREP is millimetres
44//! with +Y up, so every point is `x / 1000, -y / 1000` — the same conversion
45//! the placement writer makes (`ecad_parts::follow_sheet`), which is what keeps
46//! a part's pins over its pads. `board::layer_z` puts F.Cu's plane at 0, B.Cu's
47//! at `-SUBSTRATE_THICKNESS` and the inner layers evenly between, so a 2-layer
48//! and a 4-layer board come out of the same arithmetic. The substrate fills
49//! `[-SUBSTRATE_THICKNESS, 0]`; copper stands `COPPER_THICKNESS` proud of its
50//! own plane, outward on the two outer layers and centred on an inner one.
51
52use super::*;
53use brep_ecad_core::board::{self, Board, CopperItem, Shape};
54use brep_ecad_core::Netlist;
55use brep_kernel::{DisplaySolidPayload, Mesh, Vec3};
56use std::collections::BTreeMap;
57
58/// Every board body's name leads with this, so the scene, the Scene tree and
59/// the stale sweep can tell one apart from a feature's solid. No `:` — that is
60/// the occurrence namespace an assembly reads.
61pub const BOARD_SOLID_PREFIX: &str = "PCB_";
62/// The substrate body's scene name.
63pub const SUBSTRATE_SOLID: &str = "PCB_Board";
64/// The via-barrel body's scene name.
65pub const VIAS_SOLID: &str = "PCB_Vias";
66
67/// Separates a board body's name from the ROLE of one of its faces —
68/// `PCB_F.Cu/GND` is the GND copper on F.Cu, `PCB_F.Mask/Opening` is the bare
69/// metal the mask leaves open. `:` is the assembly occurrence namespace and `|`
70/// is the kernel's edge-name convention, so neither may be borrowed here.
71///
72/// A face name is a metadata key and the label the UI shows for what the
73/// pointer is over, which is what makes net colouring readable WITHOUT a
74/// legend: the colour says "these are one net" and hovering says which.
75const FACE_SEPARATOR: char = '/';
76/// The face name of copper on an island that carries no single schematic net.
77const NO_NET_FACE: &str = "(no net)";
78/// The face name of the mask's bare-metal openings.
79const OPENING_FACE: &str = "Opening";
80/// The face name of the mask lacquer itself.
81const LACQUER_FACE: &str = "Lacquer";
82/// The face name of one of the substrate's side walls, numbered from 1 —
83/// `PCB_Board/Side 3`. One wall per side of the outline between two corners,
84/// so a rectangular board has four, as a box does.
85const SIDE_FACE: &str = "Side";
86/// A board outline corner turning by more than this, in radians, gets a
87/// vertical display edge down the wall. A corner a polygon rounds with many
88/// short sides turns a little at each and gets none, so a rounded board is not
89/// combed with verticals.
90const CORNER_TURN: f64 = 0.5;
91
92/// The substrate's cut EDGE: the pale fibreglass-and-epoxy core a board shows
93/// where it was routed out of its panel, since the green is only on its two
94/// faces. Drawn in the green, the edge read as a shadow of the top face and a
95/// user looking for the board's thickness did not find it.
96const EDGE_COLOR: [f32; 3] = [0xb8 as f32 / 255., 0xa8 as f32 / 255., 0x7a as f32 / 255.];
97/// FR-4 green.
98const SUBSTRATE_COLOR: [f32; 3] = [0x1c as f32 / 255., 0x5e as f32 / 255., 0x38 as f32 / 255.];
99/// Bare copper.
100const COPPER_COLOR: [f32; 3] = [0xc0 as f32 / 255., 0x7b as f32 / 255., 0x38 as f32 / 255.];
101/// Solder-mask lacquer — darker and glossier than the bare substrate, so a
102/// board with the mask shown and one with it hidden do not read the same.
103const MASK_COLOR: [f32; 3] = [0x12 as f32 / 255., 0x44 as f32 / 255., 0x26 as f32 / 255.];
104/// Silkscreen ink.
105const SILK_COLOR: [f32; 3] = [0xec as f32 / 255., 0xec as f32 / 255., 0xe4 as f32 / 255.];
106
107/// How far the mask lacquer stands over the copper it covers, in board
108/// micrometres. A CONSTANT and not a [`board::DesignRules`] field, for the
109/// reason `SUBSTRATE_THICKNESS` is one: a new always-serialized field puts
110/// every `pcb` block written before it out of step with its editor.
111const MASK_OVER_COPPER: i32 = 20;
112/// How thick the silkscreen ink is drawn, on top of the lacquer.
113const SILK_THICKNESS: i32 = 15;
114
115/// Facets per HALF-turn on a round copper end or a via barrel — so eight round
116/// a full circle. Small on purpose: a track end is a fraction of a millimetre
117/// across and a dense board has thousands of them, so this number multiplies
118/// the whole scene's triangle count. The measured figures in the validation
119/// record are at this value.
120const ARC_FACETS: usize = 4;
121
122/// Board micrometres to millimetres, +Y down to +Y up.
123fn xy(p: brep_ecad_core::Point) -> [f64; 2] {
124    [f64::from(p.x) / 1000., -f64::from(p.y) / 1000.]
125}
126
127/// Millimetres from board micrometres, for a length rather than a position.
128fn mm(v: i32) -> f64 {
129    f64::from(v) / 1000.
130}
131
132/// A triangle soup being built, with flat normals and one face id per triangle.
133/// A planar piece — a quad, a cap — shares its corners between its triangles.
134/// Triangles are emitted face by face, because the display builder groups each
135/// face's triangles by their first contiguous run.
136#[derive(Default)]
137struct Soup {
138    mesh: Mesh,
139}
140
141impl Soup {
142    fn vertex(&mut self, p: [f64; 3], n: [f64; 3]) -> u32 {
143        self.mesh.positions.extend(p);
144        self.mesh.normals.extend(n);
145        (self.mesh.positions.len() / 3 - 1) as u32
146    }
147
148    /// A planar quad as two triangles that SHARE its four corners. A hovered
149    /// face is outlined by the mesh edges only one of its triangles uses, so
150    /// a corner per triangle would outline the quad's diagonal as well.
151    fn quad(&mut self, a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3], face: u32) {
152        let n = normal(a, b, c);
153        let [ia, ib, ic, id] = [a, b, c, d].map(|p| self.vertex(p, n));
154        self.mesh.indices.extend([ia, ib, ic, ia, ic, id]);
155        self.mesh.face_ids.extend([face, face]);
156    }
157
158    /// The walls of the prism `loop_xy` sweeps between `lo` and `hi`. The loop
159    /// is counter-clockwise, so the walls face outwards: each quad runs
160    /// `a -> b` along the bottom, and the right of that direction — outside a
161    /// counter-clockwise loop — is where its normal points
162    /// (`every_wall_faces_out_of_the_copper_and_the_board` pins it).
163    fn walls(&mut self, loop_xy: &[[f64; 2]], lo: f64, hi: f64, face: u32) {
164        for i in 0..loop_xy.len() {
165            let a = loop_xy[i];
166            let b = loop_xy[(i + 1) % loop_xy.len()];
167            self.quad(
168                [a[0], a[1], lo],
169                [b[0], b[1], lo],
170                [b[0], b[1], hi],
171                [a[0], a[1], hi],
172                face,
173            );
174        }
175    }
176
177    /// The wall of one open run of an outline — `walls` without the quad that
178    /// closes the loop — so each side of the board is a face of its own.
179    fn walls_open(&mut self, run: &[[f64; 2]], lo: f64, hi: f64, face: u32) {
180        for pair in run.windows(2) {
181            let (a, b) = (pair[0], pair[1]);
182            self.quad([a[0], a[1], lo], [b[0], b[1], lo], [b[0], b[1], hi], [a[0], a[1], hi], face);
183        }
184    }
185
186    /// One cap of that prism, at `z`, facing `up` or down.
187    ///
188    /// Its triangles share the loop's corners, for the reason [`Self::quad`]'s
189    /// do: hovering the cap outlines the loop, not its triangulation.
190    fn cap(&mut self, loop_xy: &[[f64; 2]], triangles: &[[usize; 3]], z: f64, up: bool, face: u32) {
191        let n = if up { [0., 0., 1.] } else { [0., 0., -1.] };
192        let first = self.mesh.positions.len() as u32 / 3;
193        for p in loop_xy {
194            self.vertex([p[0], p[1], z], n);
195        }
196        for &[a, b, c] in triangles {
197            let (b, c) = if up { (b, c) } else { (c, b) };
198            self.mesh.indices.extend([a, b, c].map(|i| first + i as u32));
199            self.mesh.face_ids.push(face);
200        }
201    }
202
203    /// One piece of a zone's fill swept between `lo` and `hi`, as ONE face: its
204    /// caps from the even-odd trapezoids of its rings (`zones::fill_triangles`,
205    /// which draws the holes a fan or plain ear clipping cannot), and walls round
206    /// the outer ring and every hole, each facing out of the copper.
207    fn slab(&mut self, piece: &board::FillPiece, lo: f64, hi: f64, face: u32) {
208        let rings: Vec<&[brep_ecad_core::Point]> = std::iter::once(piece.outer.as_slice())
209            .chain(piece.holes.iter().map(Vec::as_slice))
210            .collect();
211        // The caps SHARE their corners: the triangles are watertight, and a
212        // hovered face is outlined by the edges only one triangle uses, so a
213        // corner per triangle would outline every triangle of the fill.
214        let triangles = board::zones::fill_triangles(&rings);
215        for (z, up) in [(hi, true), (lo, false)] {
216            let n = if up { [0., 0., 1.] } else { [0., 0., -1.] };
217            let mut shared: std::collections::HashMap<(u64, u64), u32> = std::collections::HashMap::new();
218            for &[a, b, c] in &triangles {
219                let ccw = (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0) < 0.;
220                // Board +y is down, so a turn that is clockwise there is
221                // counter-clockwise here.
222                let (b, c) = if ccw == up { (b, c) } else { (c, b) };
223                let corners = [a, b, c].map(|(x, y)| {
224                    *shared
225                        .entry((x.to_bits(), y.to_bits()))
226                        .or_insert_with(|| self.vertex([x / 1000., -y / 1000., z], n))
227                });
228                self.mesh.indices.extend(corners);
229                self.mesh.face_ids.push(face);
230            }
231        }
232        for (k, ring) in rings.iter().enumerate() {
233            let mut ring: Vec<[f64; 2]> = ring.iter().copied().map(xy).collect();
234            if (signed_area2(&ring) < 0.) == (k == 0) {
235                ring.reverse();
236            }
237            self.walls(&ring, lo, hi, face);
238        }
239    }
240
241    /// A whole closed prism — bottom cap, top cap, walls — as ONE face.
242    fn prism(&mut self, loop_xy: &[[f64; 2]], lo: f64, hi: f64, face: u32) {
243        let triangles = fan_or_ears(loop_xy);
244        self.cap(loop_xy, &triangles, lo, false, face);
245        self.cap(loop_xy, &triangles, hi, true, face);
246        self.walls(loop_xy, lo, hi, face);
247    }
248}
249
250fn normal(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> [f64; 3] {
251    let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
252    let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
253    let n = [
254        u[1] * v[2] - u[2] * v[1],
255        u[2] * v[0] - u[0] * v[2],
256        u[0] * v[1] - u[1] * v[0],
257    ];
258    let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
259    if len > 1e-12 {
260        [n[0] / len, n[1] / len, n[2] / len]
261    } else {
262        [0., 0., 1.]
263    }
264}
265
266/// Twice the signed area of `loop_xy` — positive counter-clockwise.
267fn signed_area2(loop_xy: &[[f64; 2]]) -> f64 {
268    let mut sum = 0.;
269    for i in 0..loop_xy.len() {
270        let a = loop_xy[i];
271        let b = loop_xy[(i + 1) % loop_xy.len()];
272        sum += a[0] * b[1] - b[0] * a[1];
273    }
274    sum
275}
276
277/// Triangulate a simple polygon given counter-clockwise, as index triples.
278///
279/// Ear clipping, because a board outline is an arbitrary simple polygon and a
280/// fan is only right for a convex one. Copper shapes ARE convex and clip in one
281/// pass, so they pay for the generality in comparisons rather than in passes.
282/// A polygon this cannot finish (self-crossing, or a hole the outline was drawn
283/// with) falls back to a fan, which draws something wrong rather than nothing —
284/// the board is a picture here, and a missing board reads as a bug in the
285/// router rather than in its outline.
286fn fan_or_ears(loop_xy: &[[f64; 2]]) -> Vec<[usize; 3]> {
287    let n = loop_xy.len();
288    if n < 3 {
289        return Vec::new();
290    }
291    let fan = || (1..n - 1).map(|i| [0, i, i + 1]).collect::<Vec<_>>();
292    let mut remaining: Vec<usize> = (0..n).collect();
293    let mut out = Vec::with_capacity(n - 2);
294    let convex = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
295        (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
296    };
297    let inside = |a: [f64; 2], b: [f64; 2], c: [f64; 2], p: [f64; 2]| {
298        convex(a, b, p) >= 0. && convex(b, c, p) >= 0. && convex(c, a, p) >= 0.
299    };
300    // Each pass must clip at least one ear; a pass that clips none is a polygon
301    // this cannot handle, and the fan takes over.
302    while remaining.len() > 3 {
303        let before = remaining.len();
304        let mut i = 0;
305        while remaining.len() > 3 && i < remaining.len() {
306            let (ia, ib, ic) = (
307                remaining[(i + remaining.len() - 1) % remaining.len()],
308                remaining[i],
309                remaining[(i + 1) % remaining.len()],
310            );
311            let (a, b, c) = (loop_xy[ia], loop_xy[ib], loop_xy[ic]);
312            if convex(a, b, c) <= 0. {
313                i += 1;
314                continue;
315            }
316            if remaining
317                .iter()
318                .any(|&p| p != ia && p != ib && p != ic && inside(a, b, c, loop_xy[p]))
319            {
320                i += 1;
321                continue;
322            }
323            out.push([ia, ib, ic]);
324            remaining.remove(i);
325        }
326        if remaining.len() == before {
327            return fan();
328        }
329    }
330    out.push([remaining[0], remaining[1], remaining[2]]);
331    out
332}
333
334/// A copper primitive's outline in millimetres, counter-clockwise: a rectangle
335/// as four corners, a capsule as a stadium closed by two [`ARC_FACETS`] arcs
336/// (a circle when its ends coincide).
337fn shape_loop(shape: &Shape) -> Vec<[f64; 2]> {
338    match *shape {
339        Shape::Rect { min, max } => {
340            let (a, b) = (xy(min), xy(max));
341            let (x0, x1) = (a[0].min(b[0]), a[0].max(b[0]));
342            let (y0, y1) = (a[1].min(b[1]), a[1].max(b[1]));
343            vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
344        }
345        Shape::Capsule { a, b, radius } => {
346            let (a, b, r) = (xy(a), xy(b), mm(radius).max(1e-4));
347            let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
348            let len = (dx * dx + dy * dy).sqrt();
349            // A circle: one closed ring about the single centre.
350            if len <= 1e-9 {
351                return (0..ARC_FACETS * 2)
352                    .map(|i| {
353                        let t = std::f64::consts::TAU * i as f64 / (ARC_FACETS * 2) as f64;
354                        [a[0] + r * t.cos(), a[1] + r * t.sin()]
355                    })
356                    .collect();
357            }
358            // A stadium: the half-ring round `b`, then the half-ring round `a`.
359            // `base` is the direction a->b, so the arcs sweep the far side of
360            // each end and the two straights close the loop.
361            let base = dy.atan2(dx) - std::f64::consts::FRAC_PI_2;
362            let mut out = Vec::with_capacity(2 * (ARC_FACETS + 1));
363            for (centre, start) in [(b, base), (a, base + std::f64::consts::PI)] {
364                for i in 0..=ARC_FACETS {
365                    let t = start + std::f64::consts::PI * i as f64 / ARC_FACETS as f64;
366                    out.push([centre[0] + r * t.cos(), centre[1] + r * t.sin()]);
367                }
368            }
369            out
370        }
371    }
372}
373
374/// The z span of copper on `layer`: proud of the board on the two outer layers,
375/// centred on its own plane on an inner one (where there is no "outward").
376fn copper_span(layer: u8, layer_count: u8) -> (f64, f64) {
377    let plane = mm(board::layer_z(layer, layer_count));
378    let t = mm(board::COPPER_THICKNESS);
379    let last = layer_count.saturating_sub(1);
380    if layer == 0 {
381        (plane, plane + t)
382    } else if layer >= last {
383        (plane - t, plane)
384    } else {
385        (plane - t * 0.5, plane + t * 0.5)
386    }
387}
388
389/// The OUTER plane of one side of the board and the direction that is "out of
390/// the board" there: the top side's plane is 0 and its outward `+1`, the bottom
391/// side's is `-SUBSTRATE_THICKNESS` and `-1`. Everything above the copper — the
392/// mask lacquer, its openings, the silkscreen — is stacked from here, so the
393/// two sides come out of one arithmetic the way the copper layers do.
394fn side_frame(top: bool) -> (f64, f64) {
395    if top {
396        (0., 1.)
397    } else {
398        (mm(-board::SUBSTRATE_THICKNESS), -1.)
399    }
400}
401
402/// A slab on one side of the board, `from`..`to` micrometres OUT from that
403/// side's plane, as an ordered `(lo, hi)` millimetre pair — [`Soup::prism`]
404/// wants its caps the right way up, and the bottom side's "out" is `-z`.
405fn side_span(top: bool, from: i32, to: i32) -> (f64, f64) {
406    let (plane, out) = side_frame(top);
407    let (a, b) = (plane + out * mm(from), plane + out * mm(to));
408    (a.min(b), a.max(b))
409}
410
411/// The top of the mask lacquer, in micrometres out from its side's plane: over
412/// the copper, because the lacquer covers the copper it is not open over.
413const LACQUER_TOP: i32 = board::COPPER_THICKNESS + MASK_OVER_COPPER;
414
415/// One copper primitive's shape grown by `grow` on every side — a pad's solder
416/// mask OPENING, which [`board::DesignRules::mask_expansion`] sizes. The same
417/// growth `fabrication`'s Gerber writer applies when it flashes the aperture.
418fn grown(shape: Shape, grow: i32) -> Shape {
419    match shape {
420        Shape::Rect { min, max } => Shape::Rect {
421            min: brep_ecad_core::Point::new(min.x - grow, min.y - grow),
422            max: brep_ecad_core::Point::new(max.x + grow, max.y + grow),
423        },
424        Shape::Capsule { a, b, radius } => Shape::Capsule {
425            a,
426            b,
427            radius: radius + grow,
428        },
429    }
430}
431
432/// The board outline in millimetres, wound counter-clockwise for the caps and
433/// walls. eCAD stores it in a +Y-down frame, so a polygon wound one way there
434/// is wound the other here. `None` for an outline that is not a polygon.
435fn oriented_outline(board: &Board) -> Option<Vec<[f64; 2]>> {
436    let mut outline: Vec<[f64; 2]> = board.outline.iter().copied().map(xy).collect();
437    // Board documents may explicitly close their polygon, or repeat a point
438    // between segments. Our loops close implicitly. Repeated vertices block
439    // ear clipping (the duplicate lies on an ear) and create zero-length walls.
440    // Normalize here so caps, walls, and display edges use the same boundary.
441    outline.dedup();
442    if outline.len() > 1 && outline.first() == outline.last() {
443        outline.pop();
444    }
445    if outline.len() < 3 {
446        return None;
447    }
448    if signed_area2(&outline) < 0. {
449        outline.reverse();
450    }
451    Some(outline)
452}
453
454/// The scene name of one side's solder mask, KiCad's own (`F.Mask` / `B.Mask`).
455fn mask_solid(top: bool) -> String {
456    format!("{BOARD_SOLID_PREFIX}{}.Mask", if top { "F" } else { "B" })
457}
458
459/// The scene name of one side's silkscreen, KiCad's own (`F.SilkS` / `B.SilkS`).
460fn silk_solid(top: bool) -> String {
461    format!("{BOARD_SOLID_PREFIX}{}.SilkS", if top { "F" } else { "B" })
462}
463
464/// The RAILS that have a conventional colour, the spellings of each this
465/// recognises, and that colour — consulted before the hash by [`net_color`].
466///
467/// **The convention is the ATX supply wire code plus the test-lead colours it
468/// grew out of**: ground black, the positive supply red, +3.3 V orange, +12 V
469/// yellow, −12 V blue. The CHOICE OF HUE is therefore not this project's
470/// invention. A wire colour has no hex, though, so the exact point inside each
471/// band IS chosen here — and it was chosen by MEASUREMENT, not by taste.
472///
473/// **Matching is EXACT, case-insensitively, against a spelling in this table.**
474/// That is forgiving about the two things eCAD tools actually disagree on —
475/// the case of a name and whether a voltage rail carries a `+` — and reckless
476/// about nothing: there is no prefix strip, no suffix strip and no substring
477/// test, so `AGND`, `DGND`, `GND1`, `VCC_IO` and `GNDBREAK` all fall through to
478/// the hash. A board that splits its ground names it split on purpose, and
479/// painting `AGND` and `DGND` the one ground colour would put back the very
480/// defect this table exists to fix.
481///
482/// **A row is a RAIL, not a net, so the map is deliberately many-to-one.** A
483/// board carrying `VCC` and `+5V` as two DISTINCT nets draws both in the
484/// supply red. That is the price of naming the rail rather than the net, and
485/// the face name — `PCB_F.Cu/VCC` against `PCB_F.Cu/+5V` — is what separates
486/// them, exactly as it does for two hashed nets that collide.
487const CURATED_RAILS: &[(&[&str], u32)] = &[
488    // Ground is black on a test lead, in an ATX loom and in an automotive
489    // harness alike. Drawn as a near-black GREY, not `#000`: the viewport
490    // background is a dark slate (`0x131b24` since 2026-10-14, `0x0b0d10`
491    // before) and a true black track loses its silhouette where it crosses
492    // the board edge.
493    (&["GND", "VSS", "0V"], 0x3a3a40),
494    // The positive supply is red by the same three conventions, and ATX's red
495    // IS +5 V — `VCC` was a 5 V rail before it was a name, so the supply names
496    // and the 5 V spellings are one row rather than two shades of one red.
497    (&["VCC", "VDD", "+5V", "5V"], 0xcc1111),
498    // ATX orange, unmodified — and the row that had to be MEASURED hardest,
499    // because bare copper is itself an orange. This clears the `(no net)`
500    // default by ΔE00 12.3, which is the smallest gap in the whole set and is
501    // reported rather than hidden; a duller orange does not. `#e07000` sits
502    // ΔE00 8.0 from bare copper and a 3.3 V track drawn in it would read as
503    // unnetted copper.
504    (&["+3V3", "3V3", "+3.3V", "3.3V"], 0xff8000),
505    // ATX yellow.
506    (&["+12V", "12V"], 0xe8d400),
507    // ATX blue, its only negative rail. `VEE` is NOT here: it means "the
508    // negative supply" at no particular voltage, and giving it this blue would
509    // be inventing a convention rather than following one.
510    (&["-12V"], 0x1b4fc8),
511];
512
513/// A stable colour for a NET: the conventional colour of the RAIL it names if
514/// it names one, and otherwise the app's own name-hashed palette — the same
515/// function that gives an unpainted BODY its colour, asked about the net's name
516/// rather than the body's.
517///
518/// One function either way, so a net is the same colour on F.Cu as on B.Cu.
519/// Two nets share a colour only if they are the same net OR they name the same
520/// rail in [`CURATED_RAILS`]; the hash gives no separation guarantee at all,
521/// which is why the rails that MUST be told apart are not left to it.
522fn net_color(net: &str) -> [f32; 3] {
523    curated_rail_color(net)
524        .unwrap_or_else(|| crate::color::solid_color_srgb(net))
525        .map(|c| c as f32)
526}
527
528/// The conventional colour of the rail `net` names, or `None` for a net that
529/// names no rail in [`CURATED_RAILS`].
530fn curated_rail_color(net: &str) -> Option<[f64; 3]> {
531    CURATED_RAILS.iter().find_map(|(spellings, hex)| {
532        spellings
533            .iter()
534            .any(|spelling| spelling.eq_ignore_ascii_case(net))
535            .then(|| crate::color::hex_to_srgb(*hex))
536    })
537}
538
539/// The colour a board body or one of its FACES draws in when the metadata store
540/// has none of its own for it — the fallback `sync_colors_from_metadata`
541/// reaches for, since a derived body has no producing feature to have been
542/// coloured by. `None` for every name that is not a board body's or a face of
543/// one; a stored `color` attribute still wins over all of it.
544pub fn default_color(name: &str) -> Option<[f32; 3]> {
545    let rest = name.strip_prefix(BOARD_SOLID_PREFIX)?;
546    // `PCB_<body>/<role>` is a face; anything else is a body. Only the bodies
547    // that need more than one colour name their faces at all — the barrels'
548    // Barrel and the silkscreen's Ink carry no prefix and inherit their body.
549    // The split is on the FIRST separator and no body's name contains one, so a
550    // net whose own name does round-trips.
551    let Some((body, role)) = rest.split_once(FACE_SEPARATOR) else {
552        return Some(body_color(rest));
553    };
554    // A face that would draw in its BODY'S OWN colour carries NO override.
555    // `face_style_palette` re-arms the renderer's whole-mesh fast path only for
556    // a solid with no per-face colours at all, so a board with no nets — every
557    // document nobody has drawn a schematic for — must not pay a style buffer
558    // and a per-face draw for one face that changes nothing.
559    let color = face_color(body, role);
560    (color != body_color(body)).then_some(color)
561}
562
563/// The default colour of a board body, named without [`BOARD_SOLID_PREFIX`].
564fn body_color(body: &str) -> [f32; 3] {
565    if body == "Board" {
566        SUBSTRATE_COLOR
567    } else if body.ends_with(".Mask") {
568        MASK_COLOR
569    } else if body.ends_with(".SilkS") {
570        SILK_COLOR
571    } else {
572        COPPER_COLOR
573    }
574}
575
576/// The default colour of one face of a board body. The BODY decides how to read
577/// the role, so a net that happens to be called `Opening` is still a net.
578fn face_color(body: &str, role: &str) -> [f32; 3] {
579    if body == "Board" {
580        if role.starts_with(SIDE_FACE) {
581            EDGE_COLOR
582        } else {
583            SUBSTRATE_COLOR
584        }
585    } else if body.ends_with(".Mask") {
586        if role == OPENING_FACE {
587            COPPER_COLOR
588        } else {
589            MASK_COLOR
590        }
591    } else if role == NO_NET_FACE {
592        COPPER_COLOR
593    } else {
594        net_color(role)
595    }
596}
597
598/// One built body: its scene name, its colour, and the payload to display.
599struct Body {
600    name: String,
601    color: [f32; 3],
602    payload: DisplaySolidPayload,
603    /// The index of the first DISPLAY-ONLY edge in `payload.edges`: the copper
604    /// outline, which is drawn and not picked (see [`copper_outline`]).
605    aux_from: usize,
606}
607
608fn body(name: String, color: [f32; 3], faces: Vec<String>, soup: Soup) -> Option<Body> {
609    if soup.mesh.indices.is_empty() {
610        return None;
611    }
612    Some(Body {
613        name,
614        color,
615        aux_from: usize::MAX,
616        payload: DisplaySolidPayload {
617            faces: faces
618                .into_iter()
619                .enumerate()
620                .map(|(i, name)| (i as u64, Some(name)))
621                .collect(),
622            mesh: soup.mesh,
623            edges: Vec::new(),
624            vertices: Vec::new(),
625            chord_tolerance: 0.,
626        },
627    })
628}
629
630/// The substrate: the outline swept through the board's thickness, with its top
631/// face, its bottom face and its edge named apart so a user can pick one.
632///
633/// It is NOT drilled. Every via barrel below sits inside solid substrate rather
634/// than in a hole, which is right where it is and wrong about what surrounds
635/// it; cutting the holes wants a hole-aware cap triangulation this does not
636/// have. Hiding `PCB_Board` in the Scene tree shows the barrels. So there is no
637/// hole rim to draw either: the drilled board is the STEP export's.
638fn substrate(board: &Board) -> Option<Body> {
639    let outline = oriented_outline(board)?;
640    let triangles = fan_or_ears(&outline);
641    let (lo, hi) = (mm(-board::SUBSTRATE_THICKNESS), 0.);
642    let n = outline.len();
643    // The outline's SIDES: the runs between its corners. An outline with no
644    // corner at all — a round board — is one side all the way round.
645    let mut at = corners(&outline);
646    if at.is_empty() {
647        at.push(0);
648    }
649    let sides: Vec<Vec<[f64; 2]>> = (0..at.len())
650        .map(|k| {
651            let (from, to) = (at[k], at[(k + 1) % at.len()]);
652            let steps = (to + n - from - 1) % n + 1;
653            (0..=steps).map(|i| outline[(from + i) % n]).collect()
654        })
655        .collect();
656    let mut soup = Soup::default();
657    soup.cap(&outline, &triangles, hi, true, 0);
658    soup.cap(&outline, &triangles, lo, false, 1);
659    for (k, side) in sides.iter().enumerate() {
660        soup.walls_open(side, lo, hi, 2 + k as u32);
661    }
662    // Named under the body like every other board face, so the walls can
663    // carry a colour of their own ([`default_color`]) and hovering one says
664    // whose it is.
665    let face = |role: String| format!("{SUBSTRATE_SOLID}{FACE_SEPARATOR}{role}");
666    let side = |k: usize| face(format!("{SIDE_FACE} {}", k + 1));
667    let mut faces = vec![face("Top".into()), face("Bottom".into())];
668    faces.extend((0..sides.len()).map(side));
669    let mut body = body(SUBSTRATE_SOLID.to_string(), SUBSTRATE_COLOR, faces.clone(), soup)?;
670    // The edges a box has: each side's top and bottom edge, and a vertical
671    // where two sides meet — named, in the kernel's `faceA|faceB` spelling, by
672    // the two faces each one divides.
673    let mut push = |name: String, points: Vec<Vec3>| {
674        let id = body.payload.edges.len() as u64;
675        body.payload.edges.push((id, Some(name), points));
676    };
677    for (k, run) in sides.iter().enumerate() {
678        for (cap, z) in [(&faces[0], hi), (&faces[1], lo)] {
679            let points = run.iter().map(|p| Vec3::new(p[0], p[1], z)).collect();
680            push(format!("{cap}|{}", side(k)), points);
681        }
682    }
683    if sides.len() > 1 {
684        for k in 0..sides.len() {
685            let p = outline[at[k]];
686            let before = (k + sides.len() - 1) % sides.len();
687            push(
688                format!("{}|{}", side(before), side(k)),
689                vec![Vec3::new(p[0], p[1], hi), Vec3::new(p[0], p[1], lo)],
690            );
691        }
692    }
693    Some(body)
694}
695
696/// The indices of the outline's CORNERS: every vertex where the outline turns
697/// by more than [`CORNER_TURN`].
698fn corners(outline: &[[f64; 2]]) -> Vec<usize> {
699    let n = outline.len();
700    (0..n)
701        .filter(|&i| {
702            let (a, b, c) = (outline[(i + n - 1) % n], outline[i], outline[(i + 1) % n]);
703            let (u, v) = ([b[0] - a[0], b[1] - a[1]], [c[0] - b[0], c[1] - b[1]]);
704            let turn = (u[0] * v[1] - u[1] * v[0]).atan2(u[0] * v[0] + u[1] * v[1]);
705            turn.abs() > CORNER_TURN
706        })
707        .collect()
708}
709
710/// The display edges of one copper layer: where its copper STOPS — the
711/// boundary of the union of every track, pad, via ring and fill on the layer,
712/// so a bend in a track, a track landing on a pad and a pad inside a fill draw
713/// no seam ([`outlines::union_boundary`]).
714///
715/// On ONE plane, the copper's outer face (`z`): the other is 35 µm away and
716/// lands on the same pixels from every angle but edge-on, and drawing it too
717/// doubled the edge instances the frame pays. Measured on the dense synthetic
718/// board in the validation record, both planes took a spin's frame from 119 to
719/// 230 ms on the headless renderer.
720///
721/// They are DISPLAY-ONLY ([`Body::aux_from`]): drawn, but never picked. An edge
722/// outranks a face under the pointer, and copper is thin enough that its outline
723/// is under the pointer wherever the copper is, so a pickable outline would take
724/// the hover from the NET face — whose name is what hovering copper is for.
725fn copper_outline(regions: &[outlines::Region], z: f64) -> Vec<Vec<Vec3>> {
726    outlines::union_boundary(regions)
727        .into_iter()
728        .map(|line| line.into_iter().map(|p| Vec3::new(p[0], p[1], z)).collect())
729        .collect()
730}
731
732/// The OUTER face of the copper on `layer`: the top of F.Cu, the underside of
733/// B.Cu, and the upper face of an inner layer (which has no outside).
734fn copper_face(layer: u8, layer_count: u8) -> f64 {
735    let (lo, hi) = copper_span(layer, layer_count);
736    if layer > 0 && layer + 1 >= layer_count { lo } else { hi }
737}
738
739/// One copper layer: every copper primitive the board carries THROUGH that
740/// layer — the tracks on it, the pads that reach it, and each via's annular
741/// ring — as one body, so a layer costs the scene one buffer set whatever it
742/// carries.
743///
744/// The layer is ONE body but not one face. Its copper is grouped by NET, one
745/// named face per net, and [`default_color`] gives each face the net's own
746/// colour — so a trace reads as part of a net at a glance, and hovering it
747/// names that net instead of naming the layer. The grouping is why this costs
748/// nothing extra: a face is a triangle RANGE, not a buffer, and the body count
749/// — the number the viewport lives or dies by — is unchanged.
750///
751/// `nets` is index-aligned with `copper`. Copper on an island that carries no
752/// single schematic net — an unrouted stub, a board with no schematic, or a
753/// short between two nets — goes on the [`NO_NET_FACE`] face and stays bare
754/// copper, so a board nobody has drawn a schematic for looks exactly as it did.
755fn copper_layer(
756    board: &Board,
757    copper: &[CopperItem],
758    nets: &[Option<String>],
759    layer: u8,
760) -> Option<Body> {
761    let (lo, hi) = copper_span(layer, board.layer_count);
762    // Grouped in a BTreeMap so the faces come out in one order whatever order
763    // the router laid the copper down in: a rebuild that reshuffled them would
764    // move every face's metadata key, and a per-net colour the user stored
765    // with it.
766    let mut by_net: BTreeMap<Option<&str>, (Vec<usize>, Vec<&board::FillPiece>)> = BTreeMap::new();
767    for (i, item) in copper.iter().enumerate() {
768        if item.layers.0 <= layer && layer <= item.layers.1 {
769            by_net.entry(nets[i].as_deref()).or_default().0.push(i);
770        }
771    }
772    // A zone's fill is its net's copper, on the same face as that net's tracks
773    // and pads: one colour, one name to hover, and no extra body.
774    for zone in board.zones.iter().filter(|z| z.layer == layer) {
775        by_net.entry(Some(zone.net.as_str())).or_default().1.extend(&zone.fill);
776    }
777    let name = format!("{BOARD_SOLID_PREFIX}{}", board::layer_name(layer, board.layer_count));
778    let mut soup = Soup::default();
779    let mut faces: Vec<String> = Vec::with_capacity(by_net.len());
780    // Every shape drawn, for the outline: the net does not matter to where
781    // copper stops, so it is one union for the whole layer.
782    let mut regions: Vec<outlines::Region> = Vec::new();
783    for (net, (items, fill)) in &by_net {
784        // The display builder reads a face's triangles as their FIRST
785        // contiguous run of face ids, so a net's copper is emitted all at once
786        // — and the id is claimed only once something has been drawn under it.
787        let face = faces.len() as u32;
788        let mut drawn = false;
789        for &i in items {
790            let outline = shape_loop(&copper[i].shape);
791            if outline.len() >= 3 {
792                soup.prism(&outline, lo, hi, face);
793                regions.push(outlines::Region::Convex(outline));
794                drawn = true;
795            }
796        }
797        for piece in fill {
798            if piece.outer.len() >= 3 {
799                soup.slab(piece, lo, hi, face);
800                let rings = std::iter::once(&piece.outer)
801                    .chain(&piece.holes)
802                    .map(|ring| ring.iter().copied().map(xy).collect())
803                    .collect();
804                regions.push(outlines::Region::Fill(rings));
805                drawn = true;
806            }
807        }
808        if drawn {
809            faces.push(format!("{name}{FACE_SEPARATOR}{}", net.unwrap_or(NO_NET_FACE)));
810        }
811    }
812    let mut body = body(name, COPPER_COLOR, faces, soup)?;
813    body.aux_from = body.payload.edges.len();
814    for line in copper_outline(&regions, copper_face(layer, board.layer_count)) {
815        let id = body.payload.edges.len() as u64;
816        body.payload.edges.push((id, None, line));
817    }
818    Some(body)
819}
820
821/// Every solder-mask OPENING on one side, as the shape the aperture takes.
822///
823/// The rule is `fabrication::mask`'s, not a new one: a pad whose copper reaches
824/// this side is open, a bottom-side drilled pad is open on the bottom whatever
825/// its copper does, and a VIA is tented — covered — which is why no via appears
826/// here. The aperture is the pad grown by
827/// [`board::DesignRules::mask_expansion`], the same growth the Gerber writer
828/// flashes.
829fn mask_openings(board: &Board, top: bool) -> impl Iterator<Item = Shape> + '_ {
830    let side = if top { 0 } else { board.layer_count.saturating_sub(1) };
831    board.placements.iter().flat_map(move |placement| {
832        placement.footprint.pads.iter().filter_map(move |pad| {
833            let (from, to) = placement.pad_layers(pad, board.layer_count);
834            let reaches = (from..=to).contains(&side) && (top || side != 0);
835            (reaches || (!top && pad.drill.is_some()))
836                .then(|| grown(placement.pad_shape(pad), board.rules.mask_expansion))
837        })
838    })
839}
840
841/// One side's SOLDER MASK: the lacquer sheet over the whole outline, and the
842/// bare-metal opening over every pad the mask is open on.
843///
844/// Which pads those are is `fabrication::mask`'s rule, not a new one: a pad
845/// whose copper reaches this side is open, a bottom-side drilled pad is open on
846/// the bottom whatever its copper does, and a VIA is tented — covered — which
847/// is why no via appears here. The opening is the pad's shape grown by
848/// [`board::DesignRules::mask_expansion`], the same growth the Gerber writer
849/// flashes.
850///
851/// **The lacquer is not cut.** An opening is a prism standing `COPPER_THICKNESS`
852/// proud of the sheet rather than a hole through it, so it COVERS the lacquer
853/// instead of removing it — the same approximation the undrilled substrate
854/// makes for a via barrel, and for the same reason: cutting apertures out of
855/// the outline polygon wants a hole-aware triangulation this module does not
856/// have, and hundreds of pads is exactly the size at which bridging them into
857/// the ear clipper stops being cheap.
858///
859/// **It is built HIDDEN.** Opaque lacquer over the copper hides the traces,
860/// which is the thing the 3D board was asked for; a user who wants to see the
861/// board as it will be manufactured ticks it on in the Scene tree.
862///
863/// **A side with no OPENING gets no mask body at all.** Lacquer on its own is
864/// the same green the substrate already draws over the same outline, so a board
865/// with no pads on this side would gain a hidden body that shows nothing when
866/// it is shown. A board nobody has placed a part on therefore still makes
867/// exactly the four bodies it made before this.
868fn mask_layer(board: &Board, top: bool) -> Option<Body> {
869    let outline = oriented_outline(board)?;
870    // Every opening this side has, collected first — a side with none gets no
871    // body, and the lacquer has to be emitted BEFORE them in one soup because
872    // the display builder reads a face's triangles as their first contiguous
873    // run of face ids.
874    let apertures: Vec<Vec<[f64; 2]>> = mask_openings(board, top)
875        .map(|shape| shape_loop(&shape))
876        .filter(|loop_xy| loop_xy.len() >= 3)
877        .collect();
878    if apertures.is_empty() {
879        return None;
880    }
881    let mut soup = Soup::default();
882    let (lo, hi) = side_span(top, 0, LACQUER_TOP);
883    soup.prism(&outline, lo, hi, 0);
884    let (open_lo, open_hi) = side_span(top, 0, LACQUER_TOP + board::COPPER_THICKNESS);
885    for loop_xy in &apertures {
886        soup.prism(loop_xy, open_lo, open_hi, 1);
887    }
888    let name = mask_solid(top);
889    let faces = vec![
890        format!("{name}{FACE_SEPARATOR}{LACQUER_FACE}"),
891        format!("{name}{FACE_SEPARATOR}{OPENING_FACE}"),
892    ];
893    body(name, MASK_COLOR, faces, soup)
894}
895
896/// One side's SILKSCREEN: every silk polyline of every footprint mounted on
897/// that side, inked on top of the mask.
898///
899/// A polyline is drawn the way the Gerber writer strokes it — a round pen of
900/// `fabrication::SILK_WIDTH` dragged along it — which here is one capsule prism
901/// per segment, the same primitive a track segment is. A footprint's silk is a
902/// handful of segments, so a whole board's ink is a rounding error beside its
903/// copper.
904///
905/// Reference designators are NOT drawn. The Gerber legend strokes them with
906/// `fabrication::text_strokes`, which is private to that module; exporting it
907/// is a change to `brep_ecad_core` and not this one.
908fn silkscreen(board: &Board, top: bool) -> Option<Body> {
909    let (lo, hi) = side_span(top, LACQUER_TOP, LACQUER_TOP + SILK_THICKNESS);
910    let mut soup = Soup::default();
911    // A footprint is on the FRONT silkscreen when it is not mounted on the
912    // bottom — the same test `fabrication::legend` makes.
913    for placement in board.placements.iter().filter(|p| p.bottom != top) {
914        for line in &placement.footprint.silk {
915            for pair in line.windows(2) {
916                let shape = Shape::segment(
917                    placement.transform(pair[0]),
918                    placement.transform(pair[1]),
919                    brep_ecad_core::fabrication::SILK_WIDTH,
920                );
921                let loop_xy = shape_loop(&shape);
922                if loop_xy.len() >= 3 {
923                    soup.prism(&loop_xy, lo, hi, 0);
924                }
925            }
926        }
927    }
928    body(silk_solid(top), SILK_COLOR, vec!["Ink".to_string()], soup)
929}
930
931/// The plated via barrels: for each via, the tube the plating makes on the wall
932/// of its drilled hole, from the top copper plane to the bottom one.
933fn via_barrels(board: &Board) -> Option<Body> {
934    let (lo, hi) = (mm(-board::SUBSTRATE_THICKNESS), 0.);
935    let mut soup = Soup::default();
936    let facets = ARC_FACETS * 2;
937    for via in &board.vias {
938        let centre = xy(via.at);
939        let inner = mm(via.drill.max(1) / 2).max(1e-4);
940        let outer = inner + mm(board::COPPER_THICKNESS);
941        let ring = |r: f64| -> Vec<[f64; 2]> {
942            (0..facets)
943                .map(|i| {
944                    let t = std::f64::consts::TAU * i as f64 / facets as f64;
945                    [centre[0] + r * t.cos(), centre[1] + r * t.sin()]
946                })
947                .collect()
948        };
949        let (out_ring, in_ring) = (ring(outer), ring(inner));
950        soup.walls(&out_ring, lo, hi, 0);
951        // The bore, wound the other way so its wall faces into the hole.
952        let mut bore = in_ring.clone();
953        bore.reverse();
954        soup.walls(&bore, lo, hi, 0);
955        // The two annular ends.
956        for (z, up) in [(hi, true), (lo, false)] {
957            for i in 0..facets {
958                let j = (i + 1) % facets;
959                let (a, b) = (in_ring[i], in_ring[j]);
960                let (c, d) = (out_ring[j], out_ring[i]);
961                let quad = [
962                    [a[0], a[1], z],
963                    [b[0], b[1], z],
964                    [c[0], c[1], z],
965                    [d[0], d[1], z],
966                ];
967                if up {
968                    soup.quad(quad[0], quad[1], quad[2], quad[3], 0);
969                } else {
970                    soup.quad(quad[3], quad[2], quad[1], quad[0], 0);
971                }
972            }
973        }
974    }
975    body(VIAS_SOLID.to_string(), COPPER_COLOR, vec!["Barrel".to_string()], soup)
976}
977
978/// Every copper primitive on the board, and the NET each one carries.
979///
980/// The nets come from [`Board::connectivity`], which walks the copper into
981/// islands and reads each island's net off the pads it touches — so a track is
982/// on a net because it reaches a pad of that net, which is the only sense in
983/// which a track HAS one. `None` for an island carrying no net, or more than
984/// one, which is a short and must not be painted as though it were a net.
985///
986/// With no netlist — a board whose document has no placement, so no pad can
987/// carry a net — the copper is asked for directly and every item's net is
988/// `None`. That skips the island walk entirely, which is why a board nobody has
989/// drawn a schematic for pays nothing at all for net colouring.
990fn copper_and_nets(
991    board: &Board,
992    netlist: Option<&Netlist>,
993) -> (Vec<CopperItem>, Vec<Option<String>>) {
994    let Some(netlist) = netlist else {
995        let copper = board.copper();
996        let nets = vec![None; copper.len()];
997        return (copper, nets);
998    };
999    let connectivity = board.connectivity(netlist);
1000    let nets = connectivity
1001        .islands
1002        .iter()
1003        .map(|&island| connectivity.island_net(island).map(str::to_owned))
1004        .collect();
1005    (connectivity.items, nets)
1006}
1007
1008/// Every body a board contributes: the substrate, each copper layer from F.Cu
1009/// down to B.Cu, the via barrels, then the two outer surfaces — each side's
1010/// solder mask and its silkscreen. A layer, a side or a surface carrying
1011/// nothing contributes no body at all.
1012///
1013/// The copper stack comes FIRST and in its original order, so the names a
1014/// reader already knows (`PCB_Board`, `PCB_F.Cu`, …, `PCB_Vias`) are still the
1015/// first four of a two-layer board and the surfaces are appended behind them.
1016fn bodies(board: &Board, netlist: Option<&Netlist>) -> Vec<Body> {
1017    let (copper, nets) = copper_and_nets(board, netlist);
1018    let mut out = Vec::new();
1019    out.extend(substrate(board));
1020    for layer in 0..board.layer_count.max(1) {
1021        out.extend(copper_layer(board, &copper, &nets, layer));
1022    }
1023    out.extend(via_barrels(board));
1024    for top in [true, false] {
1025        out.extend(mask_layer(board, top));
1026    }
1027    for top in [true, false] {
1028        out.extend(silkscreen(board, top));
1029    }
1030    out
1031}
1032
1033/// Whether a board body is SHOWN the first time it reaches the scene.
1034///
1035/// Everything is, except the solder mask: opaque lacquer over the copper hides
1036/// the traces, and seeing the traces is what the 3D board is for. The mask is
1037/// built all the same, so ticking it on in the Scene tree costs nothing and
1038/// needs no rebuild.
1039fn default_visible(name: &str) -> bool {
1040    !name.ends_with(".Mask")
1041}
1042
1043/// What [`EngineState::refresh_board_geometry`] built, for the tests and the
1044/// cost figures in the validation record.
1045#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1046pub struct BoardBuild {
1047    pub bodies: usize,
1048    pub triangles: usize,
1049    /// Display edges over every body, and the line segments they draw — the
1050    /// instance count the edge passes pay per frame.
1051    pub edges: usize,
1052    pub edge_segments: usize,
1053}
1054
1055impl EngineState {
1056    /// (Re)build the board's 3D bodies from the document's `pcb` block.
1057    ///
1058    /// Mirrors [`refresh_committed_sketches`](Self::refresh_committed_sketches)
1059    /// in shape and for the same reason: `apply_run_output` reconciles the scene
1060    /// against the RUN's outputs and drops everything else, so a derived body
1061    /// has to be re-inserted after it. Bodies shown last time and not this time
1062    /// (the block went away, a layer emptied, the board shrank) are removed.
1063    ///
1064    /// Cheap to call: the parsed board is compared against the one the current
1065    /// bodies were built from and an unchanged block returns without touching
1066    /// the scene. That is what lets the eCAD host call it on a track edit, which
1067    /// writes the block and — changing no placement — runs no history.
1068    ///
1069    /// A board that DID change is rebuilt and every body is then seated over
1070    /// whatever is resident under its name. Replacing, not skipping, is the
1071    /// point: see the seating comment below.
1072    pub fn refresh_board_geometry(&mut self) -> BoardBuild {
1073        // The ordinary document carries no `pcb` block and pays one `Option`
1074        // test per run for the whole of this module.
1075        let block = self.history.pcb_block();
1076        if block.is_none() && self.board_displays.is_empty() {
1077            return BoardBuild::default();
1078        }
1079        // The whole DOCUMENT, not just its board: a copper island's net is read
1080        // off the pads it touches, and which net a pad carries is the
1081        // SCHEMATIC's answer. The netlist is only asked for when the board has
1082        // a placement, because a board with no pads can carry no net and the
1083        // walk would be work for a guaranteed `None`.
1084        let document = block
1085            .cloned()
1086            .and_then(|value| brep_ecad_core::Document::from_value(value).ok());
1087        let (board, netlist) = match document {
1088            Some(document) => {
1089                let netlist = (!document.board.placements.is_empty()).then(|| document.netlist());
1090                (Some(document.board), netlist)
1091            }
1092            None => (None, None),
1093        };
1094        // The rebuild test is the board AND the pin-to-net map, because those
1095        // two together decide every triangle and every face colour this module
1096        // makes. Renaming a net on the schematic moves no copper, so the board
1097        // alone would report nothing to do and the traces would keep the old
1098        // net's colour until the next routed track.
1099        let pin_nets = netlist
1100            .as_ref()
1101            .map(brep_ecad_core::board::pin_nets)
1102            .unwrap_or_default();
1103        let rebuilt = board != self.board_built_from || pin_nets != self.board_pin_nets;
1104        if rebuilt {
1105            self.board_pin_nets = pin_nets;
1106            self.rebuild_board_displays(board, netlist.as_ref());
1107        }
1108        // Seat the bodies. Two different things bring us here and each needs
1109        // its own half of the condition:
1110        //
1111        // - a REBUILD made new displays, and a body's name does not change with
1112        //   what is on it — `PCB_F.Cu` is `PCB_F.Cu` whether one track is routed
1113        //   on it or a thousand. The OLD display is still resident under that
1114        //   name, so "insert it if it is missing" leaves the stale geometry in
1115        //   the scene and the 3D board follows the 2D board only once something
1116        //   else has swept the scene (a reload, or any history run). That was
1117        //   the staleness the user saw while routing;
1118        // - a RECONCILE dropped them. `apply_run_output` keeps only the RUN's
1119        //   own outputs, so after a run the scene has none of these and every
1120        //   one goes back in. Nothing was rebuilt, so the kept displays are
1121        //   copied in rather than remade: rebuilding a dense board costs two
1122        //   orders of magnitude more than copying it, and the kept `revision`
1123        //   is what lets the renderer reuse its GPU buffers.
1124        //
1125        // A schematic edit, which writes the SAME board back, is neither: it
1126        // rebuilds nothing, finds every body resident, and touches no solid.
1127        let mut seated = false;
1128        for index in 0..self.board_displays.len() {
1129            // What the scene already shows under this name, if anything. Read
1130            // out by value so the scene is free to be written to below.
1131            let name = self.board_displays[index].name.clone();
1132            let resident = self
1133                .scene
1134                .solid(&name)
1135                .map(|solid| (solid.visible, solid.color_override));
1136            if let Some((visible, _)) = resident {
1137                // The SCENE is where a hide lives, and a history run wipes the
1138                // scene — so the kept display takes the hide back with it here,
1139                // while there is still a resident body to read it off. Without
1140                // this the mask, which is built hidden, would come back hidden
1141                // after every feature edit however many times the user ticked
1142                // it on.
1143                self.board_displays[index].visible = visible;
1144            }
1145            if !rebuilt && resident.is_some() {
1146                continue;
1147            }
1148            let mut fresh = self.board_displays[index].clone();
1149            if let Some((_, color)) = resident {
1150                // A rebuild changes the body's GEOMETRY. It does not undo what
1151                // the user has done TO the body, and replacing the whole
1152                // display would: a stored colour is meant to win over the
1153                // board's default, and nothing re-derives it on this path,
1154                // because `sync_colors_from_metadata` runs in `finish_apply`
1155                // and no history runs when a track is routed. (The other half
1156                // of that — the user's HIDE — was carried into the kept display
1157                // a few lines up, so `fresh` already has it.)
1158                //
1159                // `visibility`, the per-FACE hide map, is deliberately NOT
1160                // carried: `SolidDisplay` says a re-tessellated solid resets to
1161                // all-visible, and this is a re-tessellation.
1162                fresh.color_override = color;
1163            }
1164            self.scene.insert_solid(fresh);
1165            seated = true;
1166        }
1167        if seated {
1168            // A body's FACES are coloured too now — a copper layer's net faces,
1169            // the mask's bare openings — and a face colour the user stored is a
1170            // metadata record like any other. Resolving them through the one
1171            // seam that owns colour, rather than carrying them across by hand,
1172            // is what keeps a painted net painted on the frame it is routed on;
1173            // the pass is a no-op when nothing moved, so an untouched board
1174            // costs a compare per face.
1175            self.sync_colors_from_metadata();
1176        }
1177        self.dirty |= seated;
1178        self.board_build
1179    }
1180
1181    /// Build `board`'s bodies, sweeping out any the last board left behind that
1182    /// this one does not make (the block went away, a layer emptied, the last
1183    /// via was deleted). It does NOT put the new displays in the scene — the
1184    /// caller's seating pass does, and must, because a name that survives a
1185    /// rebuild still holds the OLD body.
1186    fn rebuild_board_displays(&mut self, board: Option<Board>, netlist: Option<&Netlist>) {
1187        let built: Vec<Body> = board
1188            .as_ref()
1189            .map(|board| bodies(board, netlist))
1190            .unwrap_or_default();
1191        let mut build = BoardBuild::default();
1192        // What each body was SHOWING before this rebuild, so a rebuild does not
1193        // un-hide a body the user hid — the mask included, which starts hidden
1194        // and stays hidden only until the user says otherwise.
1195        let was_visible: std::collections::HashMap<&str, bool> = self
1196            .board_displays
1197            .iter()
1198            .map(|display| (display.name.as_str(), display.visible))
1199            .collect();
1200        let displays: Vec<crate::scene::SolidDisplay> = built
1201            .into_iter()
1202            .map(|Body { name, color, payload, aux_from }| {
1203                build.bodies += 1;
1204                build.triangles += payload.mesh.face_ids.len();
1205                let mut solid = crate::scene::solid_display_from_payload(&name, payload);
1206                for edge in solid.edges.iter_mut().skip(aux_from) {
1207                    edge.aux = true;
1208                }
1209                build.edges += solid.edges.len();
1210                build.edge_segments += solid.edges.iter().map(|e| e.polyline.len().saturating_sub(1)).sum::<usize>();
1211                solid.color_override = Some(color);
1212                // Every named face's DEFAULT colour, stamped at build time.
1213                // The seating pass re-resolves all of them through
1214                // `sync_colors_from_metadata` — which is what makes a colour the
1215                // user STORED win — but that pass bumps the `revision` of any
1216                // solid it CHANGES, and a bumped revision re-uploads the body's
1217                // buffers. A kept display that did not already carry the colour
1218                // the pass resolves would therefore be re-uploaded on every
1219                // single history run, which is the cost the kept displays exist
1220                // to avoid. So the two are not redundant: the stamp is what
1221                // keeps the revision stable, the pass is what honours the store.
1222                for face in &mut solid.faces {
1223                    face.color_override = default_color(&face.name);
1224                }
1225                solid.visible = was_visible
1226                    .get(name.as_str())
1227                    .copied()
1228                    .unwrap_or_else(|| default_visible(&name));
1229                solid
1230            })
1231            .collect();
1232        let fed: Vec<String> = displays.iter().map(|solid| solid.name.clone()).collect();
1233        for name in std::mem::replace(&mut self.shown_board_ids, fed) {
1234            if !self.shown_board_ids.contains(&name) {
1235                self.scene.remove_solid(&name);
1236            }
1237        }
1238        self.board_displays = displays;
1239        self.board_built_from = board;
1240        self.board_build = build;
1241        self.sync_view_frame();
1242        self.dirty = true;
1243    }
1244
1245    /// What the last [`refresh_board_geometry`](Self::refresh_board_geometry)
1246    /// built — body and triangle counts, for the cost the record reports.
1247    pub fn board_build(&self) -> BoardBuild {
1248        self.board_build
1249    }
1250
1251    /// Remember that the user SHOWED or HID a board body, so the kept displays
1252    /// carry it across a history run.
1253    ///
1254    /// A run reconciles the scene against its own outputs and drops every
1255    /// derived body, so by the time the refresh at the tail of `finish_apply`
1256    /// runs there is nothing resident to read the state off — and the body
1257    /// would come back at its build-time default. That matters most for the
1258    /// solder mask, which is built HIDDEN: without this, showing it would last
1259    /// until the next feature edit.
1260    ///
1261    /// Called from [`set_visible`](Self::set_visible), which is the one seam
1262    /// the Scene tree and the BOM panel both hide a solid through.
1263    pub(super) fn remember_board_visibility(&mut self, name: &str, visible: bool) {
1264        if !name.starts_with(BOARD_SOLID_PREFIX) {
1265            return;
1266        }
1267        if let Some(display) = self
1268            .board_displays
1269            .iter_mut()
1270            .find(|display| display.name == name)
1271        {
1272            display.visible = visible;
1273        }
1274    }
1275
1276    /// The document's board as EXACT solids, for STEP export — `None` when the
1277    /// document has no `pcb` block or its board has no usable outline. Built
1278    /// from the block every call rather than kept: an export is rare, and the
1279    /// booleans that drill the holes are not what a track edit should pay for.
1280    pub(crate) fn board_step_solids(&self) -> Option<step_solids::BoardStepSolids> {
1281        let document = brep_ecad_core::Document::from_value(self.history.pcb_block()?.clone()).ok()?;
1282        let netlist = (!document.board.placements.is_empty()).then(|| document.netlist());
1283        step_solids::board_step_solids(&document.board, netlist.as_ref())
1284    }
1285
1286    /// The scene names of the board bodies currently shown, in stack order.
1287    pub fn board_solid_names(&self) -> &[String] {
1288        &self.shown_board_ids
1289    }
1290}
1291
1292mod outlines;
1293mod step_solids;
1294