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(®ions, 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