From a55ce9103cbfef90f30710aed2f20a984e7c894c Mon Sep 17 00:00:00 2001 From: Michal Dub <37038617+midub@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:32:17 +0000 Subject: [PATCH 1/4] Identical dielectric sheets share one mesh and feature table (profile 0.10) A dielectric whose sheet repeats an earlier dielectric's (same crossing drills, same colour) references that layer's mesh with a node transform that scales and moves it along Y, and its feature table. The validator accepts exactly that and still rejects other transforms and other shared meshes. New hand-written sample stacked-sheets. --- crates/boardui-convert/src/pipeline.rs | 52 ++--- crates/boardui-convert/src/validate.rs | 185 +++++++++++++++++- crates/boardui-convert/tests/conformance.rs | 14 +- crates/boardui-geom/src/mesh.rs | 74 ------- crates/boardui-gltf/src/board.rs | 2 +- crates/boardui-gltf/src/writer.rs | 67 ++++++- spec/samples/README.md | 1 + spec/samples/expected/blind-buried-vias.snap | 40 ++-- .../expected/royalblue54l-feather.snap | 12 +- spec/samples/expected/stacked-sheets.snap | 48 +++++ .../expected/testcase11-rdgflx-RevC-full.snap | 6 +- .../assembly-drawing/assembly-drawing.glb | Bin 31492 -> 31492 bytes .../board-sized-package.glb | Bin 25648 -> 25648 bytes .../bom-attributes/bom-attributes.glb | Bin 32368 -> 32368 bytes .../bottom-placement/bottom-placement.glb | Bin 49536 -> 49536 bytes spec/samples/hand-written/colours/colours.glb | Bin 19324 -> 19324 bytes .../conductor-core/conductor-core.glb | Bin 14544 -> 14548 bytes .../drawing-layers/drawing-layers.glb | Bin 25992 -> 25992 bytes .../hand-written/fiducials/fiducials.glb | Bin 22172 -> 22172 bytes spec/samples/hand-written/generate.py | 43 ++++ .../hand-written/hatch-fill/hatch-fill.glb | Bin 23940 -> 23940 bytes .../hexagon-moire/hexagon-moire.glb | Bin 26640 -> 26640 bytes .../hand-written/line-styles/line-styles.glb | Bin 75744 -> 75744 bytes .../minimal-2layer/minimal-2layer.glb | Bin 26828 -> 26828 bytes .../negative-polarity/negative-polarity.glb | Bin 14212 -> 14216 bytes .../outline-cutouts/outline-cutouts.glb | Bin 34580 -> 34584 bytes .../overlap-priority/overlap-priority.glb | Bin 19600 -> 19600 bytes .../package-silkscreen/package-silkscreen.glb | Bin 29176 -> 29180 bytes .../pad-stacks-drill-layer.glb | Bin 29184 -> 29188 bytes .../hand-written/pad-stacks/pad-stacks.glb | Bin 30836 -> 30840 bytes spec/samples/hand-written/panel/panel.glb | Bin 339864 -> 339868 bytes .../hand-written/paste-layer/paste-layer.glb | Bin 41432 -> 41432 bytes .../pinless-package/pinless-package.glb | Bin 24848 -> 24848 bytes .../polygon-xform/polygon-xform.glb | Bin 16396 -> 16396 bytes .../primitive-xform/primitive-xform.glb | Bin 31024 -> 31024 bytes spec/samples/hand-written/slots/slots.glb | Bin 36232 -> 36232 bytes .../stacked-sheets/stacked-sheets.glb | Bin 0 -> 50528 bytes .../stacked-sheets/stacked-sheets.xml | 35 ++++ spec/samples/hand-written/text/text.glb | Bin 225588 -> 225592 bytes .../hand-written/units-inch/units-inch.glb | Bin 26844 -> 26844 bytes .../units-micron/units-micron.glb | Bin 26828 -> 26828 bytes .../hand-written/user-models/user-models.glb | Bin 25696 -> 25696 bytes .../zero-width-lines/zero-width-lines.glb | Bin 11576 -> 11580 bytes spec/schema/BOARDUI_board.schema.json | 2 +- spec/schema/structural-metadata.json | 2 +- 45 files changed, 437 insertions(+), 146 deletions(-) create mode 100644 spec/samples/expected/stacked-sheets.snap create mode 100644 spec/samples/hand-written/stacked-sheets/stacked-sheets.glb create mode 100644 spec/samples/hand-written/stacked-sheets/stacked-sheets.xml diff --git a/crates/boardui-convert/src/pipeline.rs b/crates/boardui-convert/src/pipeline.rs index 6871911..6804b2a 100644 --- a/crates/boardui-convert/src/pipeline.rs +++ b/crates/boardui-convert/src/pipeline.rs @@ -442,32 +442,38 @@ pub(crate) fn run( let mut layers = Vec::with_capacity(stack.layers.len()); { let _step = timings.step("extrude"); + // A dielectric that repeats an earlier one's sheet, in the same colour, shares its + // mesh, scaled and moved in Z (spec §4). + let extrudes = |i: usize| { + let (z_min, z_max) = (stack.layers[i].z_min, stack.layers[i].z_max); + z_min.is_finite() && z_max.is_finite() && z_min < z_max + }; + let mut repeats: Vec> = (0..finals.len()) + .map(|i| { + same_sheet[i].filter(|&j| colours[i] == colours[j] && extrudes(i) && extrudes(j)) + }) + .collect(); let mut meshes = par::map(&finals, |i, (_, regions)| { let layer = &stack.layers[i]; - extrude(&layer.name, regions, layer.z_min, layer.z_max) - }); - // Repeated dielectric sheets: the earlier sheet's mesh at their own heights. - let repeats: Vec<(usize, usize)> = (0..finals.len()) - .filter_map(|i| Some((i, same_sheet[i]?))) - .collect(); - let repeated = par::map(&repeats, |_, &(i, j)| { - let (layer, earlier) = (&stack.layers[i], &stack.layers[j]); - let (mesh, messages) = &meshes[j]; - let heights = (layer.z_min, layer.z_max); - // A sheet that failed to mesh fails again, with this layer's warning. - let restacked = messages - .is_empty() - .then(|| mesh.restacked((earlier.z_min, earlier.z_max), heights)); - match restacked.flatten() { - Some(mesh) => (mesh, Vec::new()), - None => extrude(&layer.name, &finals[j].1, heights.0, heights.1), + match repeats[i] { + Some(_) => (LayerMesh::default(), Vec::new()), + None => extrude(&layer.name, regions, layer.z_min, layer.z_max), } }); - for ((i, _), mesh) in repeats.into_iter().zip(repeated) { - meshes[i] = mesh; + // A sheet that failed to mesh fails again for each repeat, with its own warning. + for i in 0..finals.len() { + if let Some(j) = repeats[i] + && !meshes[j].1.is_empty() + { + let layer = &stack.layers[i]; + meshes[i] = extrude(&layer.name, &finals[j].1, layer.z_min, layer.z_max); + repeats[i] = None; + } } - let sources = stack.layers.iter().zip(colours); - for (((layer, color), (rows, _)), (mesh, messages)) in sources.zip(finals).zip(meshes) { + let sources = stack.layers.iter().zip(colours).zip(repeats); + for ((((layer, color), repeats), (rows, _)), (mesh, messages)) in + sources.zip(finals).zip(meshes) + { messages.into_iter().for_each(|m| warnings.push(m)); layers.push(LayerAsset { name: layer.name.clone(), @@ -482,6 +488,7 @@ pub(crate) fn run( color, mesh, features: rows, + repeats, }); } } @@ -523,9 +530,10 @@ pub(crate) fn run( pins_misplaced: components.pins_misplaced, ..Stats::default() }; + // The board's geometry: a shared mesh counts for each layer that shows it. let meshes = layers .iter() - .map(|l| (&l.mesh, l.features.len())) + .map(|l| (&l.repeats.map_or(l, |j| &layers[j]).mesh, l.features.len())) .chain(drill_assets.iter().map(|d| (&d.mesh, d.features.len()))); for (mesh, rows) in meshes { stats.features += rows; diff --git a/crates/boardui-convert/src/validate.rs b/crates/boardui-convert/src/validate.rs index e30d8b7..187573f 100644 --- a/crates/boardui-convert/src/validate.rs +++ b/crates/boardui-convert/src/validate.rs @@ -5,11 +5,11 @@ use std::fmt; use boardui_geom::{DVec2, GRID_STEP, Region}; use boardui_gltf::buffer::{read_u32s, read_vec3, view_bytes}; -use boardui_gltf::json::{FLOAT, Root, UNSIGNED_SHORT}; +use boardui_gltf::json::{FLOAT, Node, Root, UNSIGNED_SHORT}; use boardui_gltf::metadata::{NO_ROW, PropertyTable, SCHEMA_JSON}; use boardui_gltf::{ - Board, ComponentExtras, EXTENSIONS, FeatureKind, Fiducial, Mount, PROFILE_VERSION, Role, Side, - encode_id_segment, glb, layer_id, populate, + Board, BoardLayer, ComponentExtras, EXTENSIONS, FeatureKind, Fiducial, Mount, PROFILE_VERSION, + Role, Side, encode_id_segment, glb, layer_id, populate, }; /// How bad an [`Issue`] is. @@ -465,18 +465,30 @@ impl Validator<'_> { ); } let mut ids = HashSet::new(); + let repeats = self.repeats(board); let entries = board .layers .iter() - .map(|l| (&l.id, &l.name, l.node, l.synthesized)) + .zip(&repeats) + .map(|(l, &r)| (&l.id, &l.name, l.node, l.synthesized, Some((l, r)))) // A synthesized drill layer (spec §6.3) shows in its ID's unencoded `@`. .chain( board .drills .iter() - .map(|d| (&d.id, &d.name, d.node, d.id.starts_with("layer/@"))), + .map(|d| (&d.id, &d.name, d.node, d.id.starts_with("layer/@"), None)), ); - for (id, layer_name, index, synthesized) in entries { + // Nodes per mesh: a layer or drill mesh is used by no other node, except by later + // dielectrics that repeat its sheet. + let mut users: HashMap = HashMap::new(); + for mesh in root.nodes.iter().filter_map(|n| n.mesh) { + *users.entry(mesh).or_default() += 1; + } + let mut layer_users: HashMap = HashMap::new(); + for mesh in board.layers.iter().filter_map(|l| node(l.node)?.mesh) { + *layer_users.entry(mesh).or_default() += 1; + } + for (id, layer_name, index, synthesized, layer) in entries { if *id != layer_id(layer_name, synthesized) || !well_formed(id) { self.report.error(format!( "layer ID `{id}` is not the encoded ID of layer `{layer_name}`" @@ -491,10 +503,24 @@ impl Validator<'_> { self.report .error(format!("the node of `{id}` must be named `{id}`")); } - if !n.is_identity() { - self.report.error(format!( + match layer { + Some((layer, Some(earlier))) => { + self.repeat(&board.layers[earlier], layer, n); + } + _ if !n.is_identity() => self.report.error(format!( "the node of `{id}` must have an identity transform" - )); + )), + _ => {} + } + if let Some(mesh) = n.mesh { + let allowed = match layer { + Some(_) => layer_users[&mesh], + None => 1, + }; + if users[&mesh] > allowed { + self.report + .error(format!("the mesh of `{id}` is used by another node")); + } } if !n.children.is_empty() { self.report @@ -523,6 +549,54 @@ impl Validator<'_> { self.z_order(board); } + /// For each of `board.layers`, the first earlier layer whose node has the same mesh: the + /// layer repeats that layer's geometry (spec §4). + fn repeats(&self, board: &Board) -> Vec> { + let mut first = HashMap::new(); + let nodes = &self.root.nodes; + (board.layers.iter().enumerate()) + .map(|(i, l)| { + let mesh = nodes.get(l.node as usize)?.mesh?; + let earlier = *first.entry(mesh).or_insert(i); + (earlier != i).then_some(earlier) + }) + .collect() + } + + /// Spec §4: a dielectric may share the mesh of an earlier dielectric, with a node transform + /// that only scales and moves along Y, from that layer's Z range onto its own. + fn repeat(&mut self, earlier: &BoardLayer, layer: &BoardLayer, node: &Node) { + let (id, other) = (&layer.id, &earlier.id); + if layer.role != Role::Dielectric || earlier.role != Role::Dielectric { + self.report.error(format!( + "`{id}` shares the mesh of `{other}`, but only dielectric layers may share one" + )); + return; + } + let [x, t, z] = node.translation.unwrap_or_default(); + let [sx, s, sz] = node.scale.unwrap_or([1.0; 3]); + let rotated = node.rotation.is_some_and(|r| r != [0.0, 0.0, 0.0, 1.0]); + // The Z ranges map onto each other within float32 precision. + let largest = [earlier.z_min, earlier.z_max, layer.z_min, layer.z_max] + .iter() + .fold(0.0f64, |m, z| m.max(z.abs())); + let tolerance = 2.0 * f32_spacing(largest) + 1e-12; + let maps = |from: f64, to: f64| (s * from + t - to).abs() <= tolerance; + let moved = node.matrix.is_none() + && !rotated + && [x, z] == [0.0; 2] + && [sx, sz] == [1.0; 2] + && s > 0.0 + && maps(earlier.z_min, layer.z_min) + && maps(earlier.z_max, layer.z_max); + if !moved { + self.report.error(format!( + "the node of `{id}` shares the mesh of `{other}`: its transform must scale and \ + move it along Y only, from the Z range of `{other}` onto its own" + )); + } + } + fn z_order(&mut self, board: &Board) { let layers = &board.layers; for l in layers { @@ -899,10 +973,28 @@ impl Validator<'_> { .structural_metadata .as_ref() .expect("checked"); + let repeats = self.repeats(board); let entries = board .layers .iter() - .map(|l| (l.id.as_str(), l.node, l.feature_table, Some(l))) + .zip(&repeats) + .filter_map(|(l, &repeats)| match repeats { + // A layer that repeats another has that layer's table and geometry, checked + // there. + Some(earlier) => { + let earlier = &board.layers[earlier]; + if l.feature_table != earlier.feature_table { + self.report.error(format!( + "`{}` shares the mesh of `{}` but not its feature table", + l.id, earlier.id + )); + } + None + } + None => Some((l.id.as_str(), l.node, l.feature_table, Some(l))), + }) + .collect::>() + .into_iter() .chain( board .drills @@ -1444,6 +1536,78 @@ mod tests { assert_eq!(check(&root, &bin), Vec::::new()); } + /// The `stacked-sheets` sample: PREPREG_2 repeats the core's sheet. + fn stacked() -> (Root, Vec) { + let xml = + include_bytes!("../../../spec/samples/hand-written/stacked-sheets/stacked-sheets.xml"); + let glb = crate::convert(xml, &crate::Options::default()).unwrap().glb; + let (root, bin) = glb::read(&glb).unwrap(); + (root, bin.to_vec()) + } + + fn layer<'b>(board: &'b Board, name: &str) -> &'b BoardLayer { + board.layers.iter().find(|l| l.name == name).unwrap() + } + + #[test] + fn dielectrics_may_share_a_mesh_moved_in_z() { + let (root, bin) = stacked(); + assert_eq!(check(&root, &bin), Vec::::new()); + let b = board(&root); + let (core, prepreg) = (layer(&b, "CORE"), layer(&b, "PREPREG_2")); + let (core_node, prepreg_node) = ( + &root.nodes[core.node as usize], + &root.nodes[prepreg.node as usize], + ); + assert!(core_node.is_identity()); + assert_eq!(prepreg_node.mesh, core_node.mesh); + assert_eq!(prepreg.feature_table, core.feature_table); + let [_, s, _] = prepreg_node.scale.unwrap(); + assert!((s - 0.2).abs() < 1e-9, "{s}"); + let other = layer(&b, "PREPREG_1"); + assert_ne!(root.nodes[other.node as usize].mesh, core_node.mesh); + } + + #[test] + fn shared_meshes_are_checked() { + let (root, bin) = stacked(); + let b = board(&root); + let (core, prepreg) = (layer(&b, "CORE"), layer(&b, "PREPREG_2")); + + let mut moved = root.clone(); + moved.nodes[prepreg.node as usize].translation = Some([0.0, 0.0, 0.0]); + assert_error(&check(&moved, &bin), "must scale and move it along Y only"); + let mut rotated = root.clone(); + rotated.nodes[prepreg.node as usize].rotation = Some([0.0, 0.0, 1.0, 0.0]); + assert_error( + &check(&rotated, &bin), + "must scale and move it along Y only", + ); + + let mut copper = root.clone(); + copper.nodes[layer(&b, "IN2").node as usize].mesh = root.nodes[core.node as usize].mesh; + assert_error( + &check(&copper, &bin), + "only dielectric layers may share one", + ); + + let mut tables = root.clone(); + let mut changed = b.clone(); + let top = layer(&b, "TOP").feature_table; + changed + .layers + .iter_mut() + .find(|l| l.name == "PREPREG_2") + .unwrap() + .feature_table = top; + set_board(&mut tables, &changed); + assert_error(&check(&tables, &bin), "but not its feature table"); + + let mut drill = root.clone(); + drill.nodes[b.drills[0].node as usize].mesh = root.nodes[core.node as usize].mesh; + assert_error(&check(&drill, &bin), "is used by another node"); + } + #[test] fn extensions_must_be_declared_but_not_required() { let (mut root, bin) = sample(); @@ -1664,6 +1828,7 @@ mod tests { color: None, mesh: mesh.finish(), features: vec![row(0), row(1)], + repeats: None, }], drills: Vec::new(), nets: Vec::new(), diff --git a/crates/boardui-convert/tests/conformance.rs b/crates/boardui-convert/tests/conformance.rs index 591eafa..d7e5bd3 100644 --- a/crates/boardui-convert/tests/conformance.rs +++ b/crates/boardui-convert/tests/conformance.rs @@ -12,7 +12,7 @@ //! with a message if its file is missing, or fails if `BOARDUI_REQUIRE_IPC_TESTCASES` is set //! (CI sets it). -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::fmt::Write as _; use std::io::Write as _; use std::path::{Path, PathBuf}; @@ -187,6 +187,7 @@ samples! { pad_stacks => "pad-stacks", pad_stacks_drill_layer => "pad-stacks-drill-layer", conductor_core => "conductor-core", + stacked_sheets => "stacked-sheets", bottom_placement => "bottom-placement", paste_layer => "paste-layer", drawing_layers => "drawing-layers", @@ -864,13 +865,19 @@ fn summary(conversion: &Conversion, detailed: bool) -> String { } } let strings = |table: Option, name: &str| strings(&root, bin, table, name); + // Spec §4: a dielectric that shares the mesh of an earlier one. + let mut first_with_mesh = HashMap::new(); let entries = board .layers .iter() .map(|l| { + let shared = root.nodes[l.node as usize].mesh.and_then(|mesh| { + let first = *first_with_mesh.entry(mesh).or_insert(&l.id); + (first != &l.id).then(|| format!(" mesh of {first}")) + }); ( format!( - "layer {} {:?} {:?} z {:.4}..{:.4} mm {:?}{}{}{}", + "layer {} {:?} {:?} z {:.4}..{:.4} mm {:?}{}{}{}{}", l.id, l.role, l.side, @@ -879,7 +886,8 @@ fn summary(conversion: &Conversion, detailed: bool) -> String { l.thickness_source, if l.synthesized { " synthesized" } else { "" }, if l.visible { "" } else { " hidden" }, - source_colors(&root, l.node) + source_colors(&root, l.node), + shared.unwrap_or_default() ), l.node, l.feature_table, diff --git a/crates/boardui-geom/src/mesh.rs b/crates/boardui-geom/src/mesh.rs index 83a47fb..55b9bef 100644 --- a/crates/boardui-geom/src/mesh.rs +++ b/crates/boardui-geom/src/mesh.rs @@ -78,41 +78,6 @@ pub struct LayerMesh { pub primitives: Vec, } -impl LayerMesh { - /// The mesh of regions extruded from board height `from.0` to `from.1` - /// ([`Region::extrude`](crate::Region::extrude)), moved to the heights `to`: the same - /// mesh as extruding the regions from `to.0` to `to.1`, without triangulating them again. - /// - /// Vertices at height `from.0` move to `to.0`, all others to `to.1`. Returns `None` when - /// extrusion rejects either range, or `from.0` and `from.1` are the same in `f32` so that - /// bottom and top vertices can't be told apart. - pub fn restacked(&self, from: (f64, f64), to: (f64, f64)) -> Option { - let valid = - |(z_min, z_max): (f64, f64)| z_min.is_finite() && z_max.is_finite() && z_min < z_max; - let (bottom, top) = (from.0 as f32, from.1 as f32); - if !valid(from) || !valid(to) || bottom == top { - return None; - } - // Board z is glTF y (spec §3). - let z = |y: f32| { - if y == bottom { - to.0 as f32 - } else { - to.1 as f32 - } - }; - let mut mesh = self.clone(); - for p in &mut mesh.primitives { - p.positions.iter_mut().for_each(|v| v[1] = z(v[1])); - let ranges = p.features.iter_mut().map(|f| (&mut f.min, &mut f.max)); - for (min, max) in ranges.chain([(&mut p.min, &mut p.max)]) { - (min[1], max[1]) = (z(min[1]), z(max[1])); - } - } - Some(mesh) - } -} - /// Builds a [`LayerMesh`] from the prisms of a layer's features (spec §4, §8.1). /// /// Features are packed greedily into primitives of at most [`MAX_PRIMITIVE_VERTICES`] @@ -340,45 +305,6 @@ mod tests { assert!(LayerMeshBuilder::new().finish().primitives.is_empty()); } - /// A plate with a hole, extruded from `z_min` to `z_max` as one feature. - fn plate(z_min: f64, z_max: f64) -> LayerMesh { - let region = rect(0.0, 0.0, 3e-3, 2e-3).difference(&rect(1e-3, 5e-4, 2e-3, 1.5e-3)); - let mut builder = LayerMeshBuilder::new(); - builder - .push(0, ®ion.extrude(z_min, z_max).unwrap()) - .unwrap(); - builder.finish() - } - - #[test] - fn restacking_needs_valid_distinct_heights() { - let mesh = plate(0.0, 1e-4); - assert_eq!( - mesh.restacked((0.0, 1e-4), (1e-3, 1.2e-3)), - Some(plate(1e-3, 1.2e-3)) - ); - assert_eq!(mesh.restacked((0.0, 1e-4), (1e-3, 1e-3)), None); - assert_eq!(mesh.restacked((0.0, 1e-4), (0.0, f64::NAN)), None); - assert_eq!(mesh.restacked((1e-4, 0.0), (0.0, 1e-4)), None); - let thin = plate(1.0, 1.0 + 1e-12); - assert_eq!(thin.restacked((1.0, 1.0 + 1e-12), (0.0, 1e-4)), None); - } - - proptest! { - /// Moving an extruded mesh to other heights gives exactly the mesh extruded there. - #[test] - fn restacking_equals_extruding_again( - z in 0.0..3e-3f64, - thickness in 1e-6..1e-3f64, - to in 0.0..3e-3f64, - to_thickness in 1e-9..1e-3f64, - ) { - let restacked = plate(z, z + thickness) - .restacked((z, z + thickness), (to, to + to_thickness)); - prop_assert_eq!(restacked, Some(plate(to, to + to_thickness))); - } - } - proptest! { /// Spec §8.1: within each primitive, features are contiguous in vertices and in /// indices, in ascending ID order, and reference only their own vertices. diff --git a/crates/boardui-gltf/src/board.rs b/crates/boardui-gltf/src/board.rs index 0a573ee..2432796 100644 --- a/crates/boardui-gltf/src/board.rs +++ b/crates/boardui-gltf/src/board.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; /// The profile version written by this crate (spec §11). -pub const PROFILE_VERSION: &str = "0.9"; +pub const PROFILE_VERSION: &str = "0.10"; /// Root extension `BOARDUI_board`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/boardui-gltf/src/writer.rs b/crates/boardui-gltf/src/writer.rs index 7aa0ae8..8b50bcf 100644 --- a/crates/boardui-gltf/src/writer.rs +++ b/crates/boardui-gltf/src/writer.rs @@ -179,6 +179,17 @@ impl Transform { } } +/// The node transform that moves geometry from the board heights `from` to `to`: a scale and +/// a translation along glTF Y, which is board Z (spec §3, §4). +fn restack(from: (f64, f64), to: (f64, f64)) -> Transform { + let scale = (to.1 - to.0) / (from.1 - from.0); + Transform { + translation: [0.0, to.0 - scale * from.0, 0.0], + scale: [1.0, scale, 1.0], + ..Transform::default() + } +} + /// A row of a layer's or drill's feature table (spec §8.2). #[derive(Debug, Clone, PartialEq, Eq)] pub struct FeatureRow { @@ -224,10 +235,17 @@ pub struct LayerAsset { /// Base colour from the source, sRGB; `None` for the role's default (spec §6.10). A /// colour equal to the default is written as the default material. pub color: Option<[u8; 3]>, - /// Geometry; feature IDs are rows of `features`. + /// Geometry; feature IDs are rows of `features`. Not written when the layer shares the + /// mesh of a layer it [`repeats`](Self::repeats). pub mesh: LayerMesh, /// The feature table. pub features: Vec, + /// For a dielectric whose sheet is that of an earlier dielectric at other heights: the + /// index of that layer in [`BoardAsset::layers`]. If that layer has a mesh, this layer + /// shares it, scaled and moved in Z by its node transform, and shares its feature table + /// (spec §4); else this layer's own (empty) mesh is written. Both layers must have the + /// same features and material, and the earlier one must repeat no other layer. + pub repeats: Option, } /// A drill layer: the barrels of one drill span (spec §6.3). @@ -396,6 +414,10 @@ pub fn layer_id(name: &str, synthesized: bool) -> String { impl BoardAsset { /// Writes the asset as GLB. + /// + /// # Panics + /// + /// Panics if a layer [`repeats`](LayerAsset::repeats) a layer it can't repeat. pub fn to_glb(&self) -> Vec { Writer::default().write(self) } @@ -437,14 +459,49 @@ impl Writer { attributes: self.attributes_table(asset), }; - let mut board_layers = Vec::new(); - for layer in &asset.layers { + let mut board_layers: Vec = Vec::new(); + for (i, layer) in asset.layers.iter().enumerate() { let id = layer_id(&layer.name, layer.synthesized); - let table = self.feature_table(&id, &layer.features); let builtin = BuiltinMaterial::for_role(layer.role); let color = layer.color.filter(|&c| c != builtin.base_color()); let material = self.colored_material(builtin, color); - let node = self.layer_node(&id, &layer.mesh, material, table, layer.features.len()); + // A repeated sheet shares the earlier layer's mesh, if it has one, and its table. + let shared = layer.repeats.and_then(|j| { + let earlier = &asset.layers[j]; + let earlier_color = earlier.color.filter(|&c| c != builtin.base_color()); + assert!( + j < i + && earlier.repeats.is_none() + && layer.role == Role::Dielectric + && earlier.role == Role::Dielectric + && earlier.features == layer.features + && earlier_color == color, + "layer `{}` can't repeat layer `{}` (spec §4)", + layer.name, + earlier.name + ); + Some((j, self.out.root.nodes[board_layers[j].node as usize].mesh?)) + }); + let (node, table) = match shared { + Some((j, mesh)) => { + let earlier = &asset.layers[j]; + let mut node = Node { + mesh: Some(mesh), + ..named(&id) + }; + restack((earlier.z_min, earlier.z_max), (layer.z_min, layer.z_max)) + .apply(&mut node); + (self.node(node), board_layers[j].feature_table) + } + None => { + let table = self.feature_table(&id, &layer.features); + let rows = layer.features.len(); + ( + self.layer_node(&id, &layer.mesh, material, table, rows), + table, + ) + } + }; self.out.root.nodes[layers as usize].children.push(node); board_layers.push(BoardLayer { id, diff --git a/spec/samples/README.md b/spec/samples/README.md index d5765d8..e5f48b1 100644 --- a/spec/samples/README.md +++ b/spec/samples/README.md @@ -22,6 +22,7 @@ The IPC consortium test cases (`ipc-testcases/`) aren't in the repository: the c | [`pad-stacks`](hand-written/pad-stacks/) | hand-written | `minimal-2layer` as revision B `PadStack`s, plus a mounting hole: pads with their pins, via lands, holes on a synthesized `@drill-TOP-BOTTOM`; §6.3 | | [`pad-stacks-drill-layer`](hand-written/pad-stacks-drill-layer/) | hand-written | revision A `PadStack` holes and an Altium-style `Drill Guide` layer: a hole on both is drilled once and takes the padstack's net, a hole only in a `PadStack` is added to the drill layer; §6.3 | | [`conductor-core`](hand-written/conductor-core/) | hand-written | Altium's core: a `CONDUCTOR` layer with `side="NONE"` and `materialType="FR-4"` (revision A) is a dielectric; §6.4 | +| [`stacked-sheets`](hand-written/stacked-sheets/) | hand-written | four copper layers, two through vias and a blind via: the lower prepreg repeats the core's sheet and shares its mesh, scaled and moved in Z, and its feature table; the upper prepreg has its own sheet; §4, §6.7 | | [`bottom-placement`](hand-written/bottom-placement/) | hand-written | an asymmetric package on the bottom side at 0°, 30°, 90° and 270°, and one on top; §6.8 | | [`paste-layer`](hand-written/paste-layer/) | hand-written | paste on both sides, one thickness from the stack-up and one by default, standing on the copper through mask and silkscreen; a pasted through-hole land cut by its hole; §6.11 | | [`drawing-layers`](hand-written/drawing-layers/) | hand-written | courtyard, assembly and documentation layers stacked outside the board, an empty documentation layer and a glue layer left out, and a package assembly drawing not repeated because the assembly layer has content; §6.12, §6.13 | diff --git a/spec/samples/expected/blind-buried-vias.snap b/spec/samples/expected/blind-buried-vias.snap index bf6899f..3455c36 100644 --- a/spec/samples/expected/blind-buried-vias.snap +++ b/spec/samples/expected/blind-buried-vias.snap @@ -26,83 +26,83 @@ layer layer/DIELECTRIC_2 Dielectric Internal z 1.0056..1.0378 mm File 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In2.Cu Copper Internal z 0.9356..1.0056 mm File hidden 4 features {"Via": 4}, 206 vertices, 416 triangles, top area 15.3537 mm² -layer layer/DIELECTRIC_3 Dielectric Internal z 0.9035..0.9356 mm File +layer layer/DIELECTRIC_3 Dielectric Internal z 0.9035..0.9356 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In3.Cu Copper Internal z 0.8335..0.9035 mm File hidden 4 features {"Via": 4}, 216 vertices, 436 triangles, top area 27.9407 mm² -layer layer/DIELECTRIC_4 Dielectric Internal z 0.8013..0.8335 mm File +layer layer/DIELECTRIC_4 Dielectric Internal z 0.8013..0.8335 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In4.Cu Copper Internal z 0.7313..0.8013 mm File hidden 4 features {"Via": 4}, 206 vertices, 416 triangles, top area 15.3537 mm² -layer layer/DIELECTRIC_5 Dielectric Internal z 0.6991..0.7313 mm File +layer layer/DIELECTRIC_5 Dielectric Internal z 0.6991..0.7313 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In5.Cu Copper Internal z 0.6291..0.6991 mm File hidden 4 features {"Via": 4}, 216 vertices, 436 triangles, top area 27.9407 mm² -layer layer/DIELECTRIC_6 Dielectric Internal z 0.5970..0.6291 mm File +layer layer/DIELECTRIC_6 Dielectric Internal z 0.5970..0.6291 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In6.Cu Copper Internal z 0.5270..0.5970 mm File hidden 4 features {"Via": 4}, 206 vertices, 416 triangles, top area 15.3537 mm² -layer layer/DIELECTRIC_7 Dielectric Internal z 0.4948..0.5270 mm File +layer layer/DIELECTRIC_7 Dielectric Internal z 0.4948..0.5270 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In7.Cu Copper Internal z 0.4248..0.4948 mm File hidden 4 features {"Via": 4}, 216 vertices, 436 triangles, top area 27.9407 mm² -layer layer/DIELECTRIC_8 Dielectric Internal z 0.3926..0.4248 mm File +layer layer/DIELECTRIC_8 Dielectric Internal z 0.3926..0.4248 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In8.Cu Copper Internal z 0.3226..0.3926 mm File hidden 4 features {"Via": 4}, 206 vertices, 416 triangles, top area 15.3537 mm² -layer layer/DIELECTRIC_9 Dielectric Internal z 0.2904..0.3226 mm File +layer layer/DIELECTRIC_9 Dielectric Internal z 0.2904..0.3226 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In9.Cu Copper Internal z 0.2204..0.2904 mm File hidden 4 features {"Via": 4}, 216 vertices, 436 triangles, top area 27.9407 mm² -layer layer/DIELECTRIC_10 Dielectric Internal z 0.1883..0.2204 mm File +layer layer/DIELECTRIC_10 Dielectric Internal z 0.1883..0.2204 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In10.Cu Copper Internal z 0.1183..0.1883 mm File hidden 4 features {"Via": 4}, 206 vertices, 416 triangles, top area 15.3537 mm² -layer layer/DIELECTRIC_11 Dielectric Internal z 0.0861..0.1183 mm File +layer layer/DIELECTRIC_11 Dielectric Internal z 0.0861..0.1183 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In11.Cu Copper Internal z 0.0161..0.0861 mm File hidden 4 features {"Via": 4}, 216 vertices, 436 triangles, top area 27.9407 mm² -layer layer/DIELECTRIC_12 Dielectric Internal z -0.0161..0.0161 mm File +layer layer/DIELECTRIC_12 Dielectric Internal z -0.0161..0.0161 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In12.Cu Copper Internal z -0.0861..-0.0161 mm File hidden 4 features {"Via": 4}, 206 vertices, 416 triangles, top area 15.3537 mm² -layer layer/DIELECTRIC_13 Dielectric Internal z -0.1183..-0.0861 mm File +layer layer/DIELECTRIC_13 Dielectric Internal z -0.1183..-0.0861 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In13.Cu Copper Internal z -0.1883..-0.1183 mm File hidden 4 features {"Via": 4}, 216 vertices, 436 triangles, top area 27.9407 mm² -layer layer/DIELECTRIC_14 Dielectric Internal z -0.2204..-0.1883 mm File +layer layer/DIELECTRIC_14 Dielectric Internal z -0.2204..-0.1883 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In14.Cu Copper Internal z -0.2904..-0.2204 mm File hidden 4 features {"Via": 4}, 206 vertices, 416 triangles, top area 15.3537 mm² -layer layer/DIELECTRIC_15 Dielectric Internal z -0.3226..-0.2904 mm File +layer layer/DIELECTRIC_15 Dielectric Internal z -0.3226..-0.2904 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In15.Cu Copper Internal z -0.3926..-0.3226 mm File hidden 4 features {"Via": 4}, 216 vertices, 436 triangles, top area 27.9407 mm² -layer layer/DIELECTRIC_16 Dielectric Internal z -0.4248..-0.3926 mm File +layer layer/DIELECTRIC_16 Dielectric Internal z -0.4248..-0.3926 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In16.Cu Copper Internal z -0.4948..-0.4248 mm File hidden 4 features {"Via": 4}, 206 vertices, 416 triangles, top area 15.3537 mm² -layer layer/DIELECTRIC_17 Dielectric Internal z -0.5270..-0.4948 mm File +layer layer/DIELECTRIC_17 Dielectric Internal z -0.5270..-0.4948 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In17.Cu Copper Internal z -0.5970..-0.5270 mm File hidden 4 features {"Via": 4}, 216 vertices, 436 triangles, top area 27.9407 mm² -layer layer/DIELECTRIC_18 Dielectric Internal z -0.6291..-0.5970 mm File +layer layer/DIELECTRIC_18 Dielectric Internal z -0.6291..-0.5970 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In18.Cu Copper Internal z -0.6991..-0.6291 mm File hidden 4 features {"Via": 4}, 206 vertices, 416 triangles, top area 15.3537 mm² -layer layer/DIELECTRIC_19 Dielectric Internal z -0.7313..-0.6991 mm File +layer layer/DIELECTRIC_19 Dielectric Internal z -0.7313..-0.6991 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In19.Cu Copper Internal z -0.8013..-0.7313 mm File hidden 4 features {"Via": 4}, 216 vertices, 436 triangles, top area 27.9407 mm² -layer layer/DIELECTRIC_20 Dielectric Internal z -0.8335..-0.8013 mm File +layer layer/DIELECTRIC_20 Dielectric Internal z -0.8335..-0.8013 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In20.Cu Copper Internal z -0.9035..-0.8335 mm File hidden 4 features {"Via": 4}, 206 vertices, 416 triangles, top area 15.3537 mm² -layer layer/DIELECTRIC_21 Dielectric Internal z -0.9356..-0.9035 mm File +layer layer/DIELECTRIC_21 Dielectric Internal z -0.9356..-0.9035 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In21.Cu Copper Internal z -1.0056..-0.9356 mm File hidden 4 features {"Via": 4}, 216 vertices, 436 triangles, top area 27.9407 mm² -layer layer/DIELECTRIC_22 Dielectric Internal z -1.0378..-1.0056 mm File +layer layer/DIELECTRIC_22 Dielectric Internal z -1.0378..-1.0056 mm File mesh of layer/DIELECTRIC_2 1 features {"Sheet": 1}, 98 vertices, 204 triangles, top area 1325.4466 mm² layer layer/In22.Cu Copper Internal z -1.1078..-1.0378 mm File hidden 6 features {"Via": 6}, 266 vertices, 544 triangles, top area 15.0448 mm² diff --git a/spec/samples/expected/royalblue54l-feather.snap b/spec/samples/expected/royalblue54l-feather.snap index 19bd375..b408bfa 100644 --- a/spec/samples/expected/royalblue54l-feather.snap +++ b/spec/samples/expected/royalblue54l-feather.snap @@ -24,27 +24,27 @@ layer layer/DIELECTRIC_1 Dielectric Internal z 0.6550..0.7550 mm File 1 features {"Sheet": 1}, 6610 vertices, 14212 triangles, top area 1266.5260 mm² layer layer/In1.Cu Copper Internal z 0.6200..0.6550 mm File hidden 270 features {"Pad": 86, "Via": 184}, 24804 vertices, 50496 triangles, top area 1097.1125 mm² -layer layer/DIELECTRIC_2 Dielectric Internal z 0.3200..0.6200 mm File +layer layer/DIELECTRIC_2 Dielectric Internal z 0.3200..0.6200 mm File mesh of layer/DIELECTRIC_1 1 features {"Sheet": 1}, 6610 vertices, 14212 triangles, top area 1266.5260 mm² layer layer/In2.Cu Copper Internal z 0.2850..0.3200 mm File hidden 283 features {"Fill": 6, "Pad": 86, "Trace": 6, "Via": 185}, 34108 vertices, 68580 triangles, top area 1014.8800 mm² -layer layer/DIELECTRIC_3 Dielectric Internal z 0.1850..0.2850 mm File +layer layer/DIELECTRIC_3 Dielectric Internal z 0.1850..0.2850 mm File mesh of layer/DIELECTRIC_1 1 features {"Sheet": 1}, 6610 vertices, 14212 triangles, top area 1266.5260 mm² layer layer/In3.Cu Copper Internal z 0.1500..0.1850 mm File hidden 270 features {"Pad": 86, "Via": 184}, 24804 vertices, 50496 triangles, top area 1097.1125 mm² -layer layer/DIELECTRIC_4 Dielectric Internal z -0.1500..0.1500 mm File +layer layer/DIELECTRIC_4 Dielectric Internal z -0.1500..0.1500 mm File mesh of layer/DIELECTRIC_1 1 features {"Sheet": 1}, 6610 vertices, 14212 triangles, top area 1266.5260 mm² layer layer/In4.Cu Copper Internal z -0.1850..-0.1500 mm File hidden 299 features {"Fill": 13, "Pad": 86, "Trace": 16, "Via": 184}, 34658 vertices, 69400 triangles, top area 988.0248 mm² -layer layer/DIELECTRIC_5 Dielectric Internal z -0.2850..-0.1850 mm File +layer layer/DIELECTRIC_5 Dielectric Internal z -0.2850..-0.1850 mm File mesh of layer/DIELECTRIC_1 1 features {"Sheet": 1}, 6610 vertices, 14212 triangles, top area 1266.5260 mm² layer layer/In5.Cu Copper Internal z -0.3200..-0.2850 mm File hidden 289 features {"Fill": 9, "Pad": 86, "Trace": 10, "Via": 184}, 26680 vertices, 53996 triangles, top area 1024.6027 mm² -layer layer/DIELECTRIC_6 Dielectric Internal z -0.6200..-0.3200 mm File +layer layer/DIELECTRIC_6 Dielectric Internal z -0.6200..-0.3200 mm File mesh of layer/DIELECTRIC_1 1 features {"Sheet": 1}, 6610 vertices, 14212 triangles, top area 1266.5260 mm² layer layer/In6.Cu Copper Internal z -0.6550..-0.6200 mm File hidden 270 features {"Pad": 86, "Via": 184}, 24804 vertices, 50496 triangles, top area 1097.1125 mm² -layer layer/DIELECTRIC_7 Dielectric Internal z -0.7550..-0.6550 mm File +layer layer/DIELECTRIC_7 Dielectric Internal z -0.7550..-0.6550 mm File mesh of layer/DIELECTRIC_1 1 features {"Sheet": 1}, 6610 vertices, 14212 triangles, top area 1266.5260 mm² layer layer/B.Cu Copper Bottom z -0.7900..-0.7550 mm File 344 features {"Fill": 23, "Pad": 108, "Trace": 28, "Via": 185}, 31092 vertices, 61796 triangles, top area 964.5025 mm² diff --git a/spec/samples/expected/stacked-sheets.snap b/spec/samples/expected/stacked-sheets.snap new file mode 100644 index 0000000..38e35d1 --- /dev/null +++ b/spec/samples/expected/stacked-sheets.snap @@ -0,0 +1,48 @@ +--- +source: crates/boardui-convert/tests/conformance.rs +--- +source: IPC-2581 rev C, step board, FABRICATION +thickness: 1.5400 mm +counts: 18 features, 1178 vertices, 2384 triangles, 1 nets, 0 components, 0 pins (0 checked against pads) +warnings: 0 from the reader, 0 from the converter +layer layer/@soldermask-top Soldermask Top z 0.7350..0.7900 mm Default synthesized + 1 features {"Sheet": 1}, 92 vertices, 192 triangles, top area 39.7210 mm² + row 0: Sheet net - area 39.7210 mm² +layer layer/TOP Copper Top z 0.7350..0.7700 mm File + 3 features {"Via": 3}, 192 vertices, 384 triangles, top area 0.5521 mm² + row 0: Via net 0 area 0.1840 mm² + row 1: Via net 0 area 0.1840 mm² + row 2: Via net 0 area 0.1840 mm² +layer layer/PREPREG_1 Dielectric Internal z 0.5350..0.7350 mm File + 1 features {"Sheet": 1}, 92 vertices, 192 triangles, top area 39.7210 mm² + row 0: Sheet net - area 39.7210 mm² +layer layer/IN1 Copper Internal z 0.5000..0.5350 mm File hidden + 3 features {"Via": 3}, 192 vertices, 384 triangles, top area 0.5521 mm² + row 0: Via net 0 area 0.1840 mm² + row 1: Via net 0 area 0.1840 mm² + row 2: Via net 0 area 0.1840 mm² +layer layer/CORE Dielectric Internal z -0.5000..0.5000 mm File + 1 features {"Sheet": 1}, 64 vertices, 132 triangles, top area 39.8140 mm² + row 0: Sheet net - area 39.8140 mm² +layer layer/IN2 Copper Internal z -0.5350..-0.5000 mm File hidden + 2 features {"Via": 2}, 128 vertices, 256 triangles, top area 0.3680 mm² + row 0: Via net 0 area 0.1840 mm² + row 1: Via net 0 area 0.1840 mm² +layer layer/PREPREG_2 Dielectric Internal z -0.7350..-0.5350 mm File mesh of layer/CORE + 1 features {"Sheet": 1}, 64 vertices, 132 triangles, top area 39.8140 mm² + row 0: Sheet net - area 39.8140 mm² +layer layer/BOTTOM Copper Bottom z -0.7700..-0.7350 mm File + 2 features {"Via": 2}, 128 vertices, 256 triangles, top area 0.3680 mm² + row 0: Via net 0 area 0.1840 mm² + row 1: Via net 0 area 0.1840 mm² +layer layer/@soldermask-bottom Soldermask Bottom z -0.7900..-0.7350 mm Default synthesized + 1 features {"Sheet": 1}, 64 vertices, 132 triangles, top area 39.8140 mm² + row 0: Sheet net - area 39.8140 mm² +drill layer/DRILL_TOP-BOTTOM layer/TOP..layer/BOTTOM + 2 features {"Barrel": 2}, 108 vertices, 216 triangles, top area 0.0501 mm² + row 0: Barrel net 0 area 0.0250 mm² + row 1: Barrel net 0 area 0.0250 mm² +drill layer/DRILL_TOP-IN1 layer/TOP..layer/IN1 + 1 features {"Barrel": 1}, 54 vertices, 108 triangles, top area 0.0250 mm² + row 0: Barrel net 0 area 0.0250 mm² +components: 0 diff --git a/spec/samples/expected/testcase11-rdgflx-RevC-full.snap b/spec/samples/expected/testcase11-rdgflx-RevC-full.snap index af3c1f1..2dece41 100644 --- a/spec/samples/expected/testcase11-rdgflx-RevC-full.snap +++ b/spec/samples/expected/testcase11-rdgflx-RevC-full.snap @@ -44,15 +44,15 @@ layer layer/DIELECTRIC_INDEX_14 Dielectric Internal z 0.0686..0.1956 mm File 1 features {"Sheet": 1}, 6208 vertices, 13136 triangles, top area 10766.8032 mm² layer layer/FLEX_1 Copper Internal z 0.0381..0.0686 mm File hidden 240 features {"Fill": 6, "Pad": 6, "Trace": 51, "Via": 177}, 34196 vertices, 68444 triangles, top area 3014.9127 mm² -layer layer/DIELECTRIC_INDEX_16 Dielectric Internal z -0.0381..0.0381 mm File +layer layer/DIELECTRIC_INDEX_16 Dielectric Internal z -0.0381..0.0381 mm File mesh of layer/DIELECTRIC_INDEX_14 1 features {"Sheet": 1}, 6208 vertices, 13136 triangles, top area 10766.8032 mm² layer layer/FLEX_2 Copper Internal z -0.0686..-0.0381 mm File hidden 569 features {"Fill": 3, "Pad": 38, "Trace": 351, "Via": 177}, 43538 vertices, 80988 triangles, top area 6081.0809 mm² -layer layer/DIELECTRIC_INDEX_18 Dielectric Internal z -0.1956..-0.0686 mm File +layer layer/DIELECTRIC_INDEX_18 Dielectric Internal z -0.1956..-0.0686 mm File mesh of layer/DIELECTRIC_INDEX_14 1 features {"Sheet": 1}, 6208 vertices, 13136 triangles, top area 10766.8032 mm² layer layer/INT_4 Copper Internal z -0.2261..-0.1956 mm File hidden 271 features {"Fill": 1, "Pad": 34, "Trace": 59, "Via": 177}, 24098 vertices, 48128 triangles, top area 3984.4022 mm² -layer layer/DIELECTRIC_INDEX_20 Dielectric Internal z -0.4293..-0.2261 mm File +layer layer/DIELECTRIC_INDEX_20 Dielectric Internal z -0.4293..-0.2261 mm File mesh of layer/DIELECTRIC_INDEX_12 1 features {"Sheet": 1}, 3576 vertices, 7496 triangles, top area 10775.7880 mm² layer layer/BOTTOM Copper Bottom z -0.4597..-0.4293 mm File 89 features {"Fill": 1, "Pad": 4, "Trace": 1, "Via": 83}, 12526 vertices, 25384 triangles, top area 3927.1961 mm² diff --git a/spec/samples/hand-written/assembly-drawing/assembly-drawing.glb b/spec/samples/hand-written/assembly-drawing/assembly-drawing.glb index 7c9cc28f4d19adbb82f23b3178b0af78937a2d0d..777cbe64a1948e1706d06631e93175c26e711bc9 100644 GIT binary patch delta 22 dcmZqq#@O7t+8P;G}ye>hKX_W2HOTT0B+a_(*OVf delta 21 bcmdmRf^h>7t+8RW+`P_)iE;CK+eS42Xk7@a diff --git a/spec/samples/hand-written/bom-attributes/bom-attributes.glb b/spec/samples/hand-written/bom-attributes/bom-attributes.glb index 47bccfb2e9ecd2132a56f10fcba9e4d6c606bf26..f6d4427e67ba40814f5763be1fa41281991458e9 100644 GIT binary patch delta 21 dcmezHhw;N7#tjSHnG6j!FLaM%+~iqc3IKQ~32y)Z delta 22 ecmezHhw;N7#tjSH87((2a*tzVRM_NMUrflV$ZEGb<=4Y%;jO0RZFT3q1e; delta 33 pcmcaoc%hItJtxGCiGhLPf&~M^n~l8u`iz#7<@6tKW-++H0RX+I3a') + d.layer("TOP", "CONDUCTOR", "TOP") + d.layer("PREPREG_1", "DIELPREG", "INTERNAL") + d.layer("IN1", "CONDUCTOR", "INTERNAL") + d.layer("CORE", "DIELCORE", "INTERNAL") + d.layer("IN2", "CONDUCTOR", "INTERNAL") + d.layer("PREPREG_2", "DIELPREG", "INTERNAL") + d.layer("BOTTOM", "CONDUCTOR", "BOTTOM") + d.layer("DRILL_TOP-BOTTOM", "DRILL", "ALL", span=("TOP", "BOTTOM")) + d.layer("DRILL_TOP-IN1", "DRILL", "INTERNAL", span=("TOP", "IN1")) + stack = [("TOP", 0.035), ("PREPREG_1", 0.2), ("IN1", 0.035), ("CORE", 1.0)] + stack += [("IN2", 0.035), ("PREPREG_2", 0.2), ("BOTTOM", 0.035)] + rows = "".join( + f'' + for i, (name, t) in enumerate(stack) + ) + d.layers.append(f'{rows}') + profile(d, 8, 5) + vias = { + "DRILL_TOP-BOTTOM": [(2, 2.5), (6, 2.5)], + "DRILL_TOP-IN1": [(4, 1.5)], + } + lands = {"TOP": vias["DRILL_TOP-BOTTOM"] + vias["DRILL_TOP-IN1"]} + lands["IN1"] = lands["TOP"] + lands["IN2"] = lands["BOTTOM"] = vias["DRILL_TOP-BOTTOM"] + for layer, points in lands.items(): + sets = "".join(f'{pad(d, x, y, "VIA")}' for x, y in points) + d.step.append(f'{sets}') + for (layer, points), prefix in zip(vias.items(), "TB"): + holes = "".join( + f'' + for k, (x, y) in enumerate(points) + ) + d.step.append(f'{holes}') + return d + + def overlap_priority(): """Pad over trace over plane, and ties in document order (spec §6.2).""" d = Doc() @@ -1609,6 +1651,7 @@ def main(): "pad-stacks": pad_stacks(), "pad-stacks-drill-layer": pad_stacks_drill_layer(), "conductor-core": conductor_core(), + "stacked-sheets": stacked_sheets(), "bottom-placement": bottom_placement(), "paste-layer": paste_layer(), "drawing-layers": drawing_layers(), diff --git a/spec/samples/hand-written/hatch-fill/hatch-fill.glb b/spec/samples/hand-written/hatch-fill/hatch-fill.glb index 6da9317b9df5d9bc2d685c1657687861ec44a10a..02e8e9695bd0f8c81e14da097ff15793b9f4549d 100644 GIT binary patch delta 21 dcmZqK&DgS=al;H1CPRbCGgTgKu2JQ%003S?2o?YU delta 22 ecmZqK&DgS=al;H1M$5^wR30%ZY_3t|umAvN3kVMY diff --git a/spec/samples/hand-written/hexagon-moire/hexagon-moire.glb b/spec/samples/hand-written/hexagon-moire/hexagon-moire.glb index c6c3bafd528c3ab6a93972d8bd272441c8504e3f..c2b9637327d419442cd552dd2d8c5ba1465fea1b 100644 GIT binary patch delta 21 dcmbPmfpNkG#tmj2;l$# delta 21 dcmX?ek@3t$#tr-J7%ev+wBu&neA=E-698|82+05d diff --git a/spec/samples/hand-written/negative-polarity/negative-polarity.glb b/spec/samples/hand-written/negative-polarity/negative-polarity.glb index bfa9482be12b9f0463c5f86b85ec0256a85c6f52..9aad1e9cde40bf7df0fc4adf82256e11d9545b4e 100644 GIT binary patch delta 38 tcmZq4@5tv(&k1p3VqjqCFlS(J*~pus!enSLxk%*^vx0)c<`7i_Appvy3GV;^ delta 33 ocmeCkZ^`FP&k1p3VqjosF=t?K*vOlr!e}|USmn{?2vq|i0H1^jU;qFB diff --git a/spec/samples/hand-written/outline-cutouts/outline-cutouts.glb b/spec/samples/hand-written/outline-cutouts/outline-cutouts.glb index 87e5fb91cffd168e5ffbc27bd1072303daf9ad65..c2084e98c909973f0e7038c904cb102181705d0f 100644 GIT binary patch delta 53 zcmbQz$26mli8nnb#Epr8fkC33f#JwT-n%MHh6a;gtGwhh($ diff --git a/spec/samples/hand-written/package-silkscreen/package-silkscreen.glb b/spec/samples/hand-written/package-silkscreen/package-silkscreen.glb index ef58c4796032266d358b9c7030148aca50e5fbcc..7710d5fd87e86269bdea2bab8fd8039d68c4ad89 100644 GIT binary patch delta 40 wcmezInDNhJM&9(C5H}_U28KU{3=9W0^6qqJGBntH+?|V&NkL)r1rH5P05${-u>b%7 delta 36 scmezKnDNJBM&9(C5H}_U28JJn3=DfV^6qqJwA_5cor`hvRS$Jd00z$uLjV8( diff --git a/spec/samples/hand-written/pad-stacks-drill-layer/pad-stacks-drill-layer.glb b/spec/samples/hand-written/pad-stacks-drill-layer/pad-stacks-drill-layer.glb index fdc2f4f0d7fb7a7b1223040ddcb58a49c90d8b31..b81c75e3eb7ef86dec2641134460dfc6999c7546 100644 GIT binary patch delta 40 wcmZp8!r1bJkvBaj#Epr8fq|unfx%)UuctASp~2*EjxheX*~esuEdaR~3Y-7{ diff --git a/spec/samples/hand-written/pad-stacks/pad-stacks.glb b/spec/samples/hand-written/pad-stacks/pad-stacks.glb index 17a7fc5df261f61a2eaff88409c7b13ccdc46c80..ce69dca8fc3f7844dfc8f4d6a0564142ca0d397f 100644 GIT binary patch delta 41 xcmezJf$_%&M&9(C5H}_U28N0X1_qXmynpSO3=KAO+jBB9D<~*z7I8?h001Bu3#0%5 delta 36 qcmezIf$_@+M&9(C5H}_U28NOfAhD76uN|Z1W*&P^#?2BA2^IkCBnuS) diff --git a/spec/samples/hand-written/panel/panel.glb b/spec/samples/hand-written/panel/panel.glb index ab6308b5b07c778c8d1a32b4204487b944a57e10..03046445dc6f22d272f1a654183d9d22350c30fc 100644 GIT binary patch delta 57 zcmbPnUu4dG5#IEi5H}_U28KEMtPB@6^6oBWGBntHvQ(UrSwTUe`9WFxgEB@SW&&d7 L?GMUWcI*HELk1P= delta 52 zcmbPpUu4F85#IEi5H}_U28J2>tPE#1^6oBWwA_5ERGhK-Nm=`oGDaX~0%GRvPs&(! G>;M1*I2CLF diff --git a/spec/samples/hand-written/paste-layer/paste-layer.glb b/spec/samples/hand-written/paste-layer/paste-layer.glb index daae8d1e313ef5dae098edbc20a8522ee43627d4..9703791d34ef21dbd8219f9dca40ebb2848b0a0a 100644 GIT binary patch delta 22 ecmca{nCZr0rVY)3Ooj%VTLQTmH%|!i@CN{LPYB`w delta 21 dcmca{nCZr0rVY)3jFy{Q1GyPD_Xm0U0|0BF2)Y0O diff --git a/spec/samples/hand-written/pinless-package/pinless-package.glb b/spec/samples/hand-written/pinless-package/pinless-package.glb index 013ae571967922c83d6da4d8ef6493d978fe33ac..98618315fc878e1c0d825ce3e40b0d97cbfce0f1 100644 GIT binary patch delta 22 ecmbPmh;hOp#trROOoj%VJFK`FH&3yyQ3U{G>IfeI delta 21 dcmbPmh;hOp#trROjFy`_t+*LCPqwaA1pr>+2loH~ diff --git a/spec/samples/hand-written/polygon-xform/polygon-xform.glb b/spec/samples/hand-written/polygon-xform/polygon-xform.glb index 92326f3cac11248e347eabc81bd1cde1c32e7c17..0d4386a95e6b09ae0865ca415daabb0ed3f75e60 100644 GIT binary patch delta 21 ccmeBaVC-pN+z_e7WN0usO6%ihAMFDo08;t~w*UYD delta 22 dcmeBaVC-pN+z_e7XgN7r>m#GWW*_YXA^=&>2e1GD diff --git a/spec/samples/hand-written/primitive-xform/primitive-xform.glb b/spec/samples/hand-written/primitive-xform/primitive-xform.glb index f6e55ec23a0b15ab514fc909dde34496865c8e6e..8087d19e92842a220fc7ba80480853b9c19fea8b 100644 GIT binary patch delta 22 ccmdn+iE#rEO}1t-G}t`FnwxR+0-Fgo0CNHf5dZ)H delta 21 bcmdn+iE#rEO}1vV+&tBqn{o4en~636Y)uI1 diff --git a/spec/samples/hand-written/slots/slots.glb b/spec/samples/hand-written/slots/slots.glb index b5475aaffbdcd0d817b86aa3ad93c605201c208a..d082039d03b74b5776306e6406b51221b2060aa0 100644 GIT binary patch delta 21 ccmeB}&D1fQX@j>hlcB+6ALGxP9ZWnT0aY{ysQ>@~ delta 20 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 xobdKryAs76Rw~8O~{{TFzbVdLG literal 0 HcmV?d00001 diff --git a/spec/samples/hand-written/stacked-sheets/stacked-sheets.xml b/spec/samples/hand-written/stacked-sheets/stacked-sheets.xml new file mode 100644 index 0000000..b9c6644 --- /dev/null +++ b/spec/samples/hand-written/stacked-sheets/stacked-sheets.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/hand-written/text/text.glb b/spec/samples/hand-written/text/text.glb index acefc6560964e4e65cc761a337140a998641a3dc..de4a712b76156a4a8ba08b4550586a05d3293bba 100644 GIT binary patch delta 53 zcmdn;h$Uf%Sa5H}_U1_p~lW`+YBd3U-n85(Rp?!w8)te~LKe8IK-f-55sGi|@% I%ABGN0NRBStpET3 delta 48 zcmdn-h diff --git a/spec/samples/hand-written/units-inch/units-inch.glb b/spec/samples/hand-written/units-inch/units-inch.glb index fdb2784c3845e4d0497a01938be86089341529c8..daa6a3a642881982178e736f6e5ad560ea7d5693 100644 GIT binary patch delta 21 dcmca}k@3z&#tj$km<$azU$Wz5Tztn~6992{2{Qly delta 22 ecmca}k@3z&#tj$k7%ewnw&P@ER9Jk+UK0R$g9$7E diff --git a/spec/samples/hand-written/units-micron/units-micron.glb b/spec/samples/hand-written/units-micron/units-micron.glb index e21753c268c98b02aa8c24381cc634943dd3fcef..bbb80f80df901be46c6204e83980fb1ed60af231 100644 GIT binary patch delta 21 dcmX?ek@3t$#tr-Jm<$azAF$(QTztk}698>j2;l$# delta 21 dcmX?ek@3t$#tr-J7%ev+wBu&neA=E-698|82+05d diff --git a/spec/samples/hand-written/user-models/user-models.glb b/spec/samples/hand-written/user-models/user-models.glb index 2fbe60fbdd4c0be75a0e3c381fe536c5922877e4..ead90b7d2b76d2cdc01e1cfce7766ec3c183e59c 100644 GIT binary patch delta 22 ecmaEGg7Lu##tpSLOoj%V>ufj~H+R_HPy+yRVF?TX delta 23 fcmaEGg7Lu##tpSLjFy}0Z8#Yj6*hO+-cSPod0Ght diff --git a/spec/samples/hand-written/zero-width-lines/zero-width-lines.glb b/spec/samples/hand-written/zero-width-lines/zero-width-lines.glb index 3af0dbff11c52e58d28b45d3966c42718193c54b..8766dd94584ea13ed2438da90126831350873491 100644 GIT binary patch delta 38 ucmdlHwI_-|!Numb?f>I!54 delta 33 pcmdlJwIhl*JtxGCiGhK^LYINTWFv2a3Zv!Ze3e_9eN;cN0|21G3Dp1q diff --git a/spec/schema/BOARDUI_board.schema.json b/spec/schema/BOARDUI_board.schema.json index b2012f8..9b4cd3b 100644 --- a/spec/schema/BOARDUI_board.schema.json +++ b/spec/schema/BOARDUI_board.schema.json @@ -123,7 +123,7 @@ "visible": { "type": "boolean" }, "node": { "$ref": "#/$defs/index" }, "featureTable": { - "description": "The layer's feature table; absent when the layer has no features.", + "description": "The layer's feature table; absent when the layer has no features. A dielectric that shares an earlier dielectric's mesh shares its feature table too (spec §4).", "$ref": "#/$defs/index" } }, diff --git a/spec/schema/structural-metadata.json b/spec/schema/structural-metadata.json index e672a74..c2051f9 100644 --- a/spec/schema/structural-metadata.json +++ b/spec/schema/structural-metadata.json @@ -2,7 +2,7 @@ "id": "boardui", "name": "boardui PCB metadata", "description": "EXT_structural_metadata schema embedded in every boardui asset. See spec/README.md §8.2. References between tables are UINT32 row indices; 4294967295 means none.", - "version": "0.9", + "version": "0.10", "enums": { "FeatureKind": { "valueType": "UINT8", From b2dbf0a5a1f163a63ce27ecb8f570e3d3294db06 Mon Sep 17 00:00:00 2001 From: Michal Dub <37038617+midub@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:34:44 +0000 Subject: [PATCH 2/4] Spec, ADR 0016, changelog and viewer tests for shared sheets --- CHANGELOG.md | 6 +++ docs/adr/0016-shared-dielectric-sheets.md | 43 +++++++++++++++++++++ docs/adr/README.md | 1 + docs/architecture.md | 2 +- packages/viewer/test/runtime-models.test.ts | 2 +- packages/viewer/test/samples.test.ts | 22 +++++++++++ spec/README.md | 24 +++++++----- 7 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 docs/adr/0016-shared-dielectric-sheets.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f68e00c..7ec1753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,12 @@ glTF profile has its own version (`profileVersion`, [spec §11](spec/README.md#1 same drills share one sheet, which is cut and triangulated once and moved to each layer's heights; copper layers crossed by the same drills share one hole index. The output is unchanged. testcase1-RevC-full (11 dielectrics) converts about 10 % faster in the browser. +- **Smaller files for boards with several dielectric layers (profile 0.10, still a draft):** + a dielectric whose sheet repeats an earlier dielectric's shares that layer's mesh, scaled and + moved in Z by its node transform, and its feature table ([spec §4](spec/README.md#4-scene-structure), + [ADR 0016](docs/adr/0016-shared-dielectric-sheets.md)). testcase1-RevC-full shrinks from + 277 MB to 207 MB. `boardui validate` accepts exactly this transform on layer nodes; files of + profile 0.9 and older load as before. New sample `stacked-sheets`. - **Component attributes (profile 0.8, still a draft):** the converter reads the BOM, the approved vendor list and the exporting software. Each component gets its BOM attributes diff --git a/docs/adr/0016-shared-dielectric-sheets.md b/docs/adr/0016-shared-dielectric-sheets.md new file mode 100644 index 0000000..e99d23e --- /dev/null +++ b/docs/adr/0016-shared-dielectric-sheets.md @@ -0,0 +1,43 @@ +# 0016 Shared dielectric sheets + +**Status:** Accepted, 2026-10-07. Amends [ADR 0008](0008-hybrid-metadata.md) (profile 0.10). + +## Context + +A dielectric layer is one sheet: the board outline minus the holes that cross it (spec §6.7). +On a board whose holes all go through, every dielectric has the same sheet at a different +height. IPC consortium test case 1 (full) has 11 of them: 7.0 MB each, 77 MB of a 277 MB GLB, +4.2 M of 17.25 M triangles. Spec §4 required identity transforms on layer nodes and each layer's +primitives to name that layer's feature table, so every sheet was stored again. + +## Decision + +- **A repeated sheet shares the mesh of the first dielectric with that sheet.** Its node uses + the same mesh with a transform that scales and moves it along Y only (`translation [0, t, 0]`, + `scale [1, s, 1]`), mapping the first layer's Z range onto its own. The first layer keeps an + identity transform and the mesh stays in board coordinates at its height, so the asset still + reads as before for everything but the repeats, and the rule is easy to check. + - Rejected: a mesh in a unit Z range with a transform on every user. No node would hold board + coordinates, and the shared case would differ from every other layer. +- **The repeat shares the first layer's feature table too.** A primitive names exactly one + `propertyTable`, and a shared primitive can't name each user's own. Sharing the table keeps + `EXT_mesh_features` consistent: every primitive's table is the `featureTable` of every layer + that shows it. The tables would be identical anyway (one `SHEET` row), and feature IDs derive + from the layer, not from the table (spec §5). + - Rejected: keeping a table per layer and letting `BOARDUI_board.layers[].featureTable` + override the primitive's. Generic `EXT_mesh_features` readers would see the other layer's + table. +- **Only dielectric sheets.** They are the only layers that repeat in practice, and the converter + knows they repeat without comparing geometry: the same drills cross them. Soldermask sheets + differ by their openings, copper, paste and drawing layers never match. Widening the rule later + is a compatible change; narrowing it would not be. +- Layers share only with the same colour (the material is the mesh's), and a sheet without + geometry shares nothing. + +## Consequences + +- testcase1-RevC-full: 277 MB → 207 MB; conversion skips extruding and writing 10 sheets. +- Readers that merge layer meshes must apply node transforms. The boardui viewer always did + (`mergeLayer` uses `matrixWorld`); generic glTF viewers do anyway. A reader that assumed + identity transforms on layer nodes draws a repeat at the first layer's height. +- The profile is still a draft, so the relaxed rule goes into a minor version, 0.10 (spec §11). diff --git a/docs/adr/README.md b/docs/adr/README.md index d85d660..de79de3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,3 +19,4 @@ Each record captures one decision from the 3D rewrite design. Format: context, d | [0013](0013-panels.md) | Panels: root step by default, instances with their own IDs and nets, baked geometry | Accepted | | [0014](0014-runtime-model-sources.md) | Runtime model sources: models loaded by the viewer from servers (KiCad via GitLab), STEP in `@boardui/models` | Accepted | | [0015](0015-agpl-plus-commercial-licence.md) | Licence: AGPL-3.0-only plus a commercial licence; contributions under a CLA | Accepted | +| [0016](0016-shared-dielectric-sheets.md) | Shared dielectric sheets: repeats use the first sheet's mesh, moved in Z, and its feature table (profile 0.10) | Accepted | diff --git a/docs/architecture.md b/docs/architecture.md index cf64612..d705319 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,7 +79,7 @@ That keeps every dependency pure Rust, so the WASM build needs no C/C++ toolchai - Apply negative polarity in document order. - Resolve overlaps by priority (spec §6.2). Features go in priority order; each one subtracts the already-claimed area near it (found via the R-tree) and then claims its own. 4. **Holes and outline.** Subtract the holes from every layer they cross, and build barrels. The board outline, for the dielectric and soldermask sheets, is the union of the step profiles; a profile without `Cutout`s loses the closed contours that a `BOARD_OUTLINE` layer draws inside it, as KiCad writes them (spec §6.7). -5. **Extrude and triangulate.** Triangulate each region, emit caps and side walls, write features contiguously, and split into primitives of at most 65,535 vertices. Dielectrics crossed by the same drills have the same sheet: it is cut and triangulated once, and its mesh is moved to the other dielectrics' heights. +5. **Extrude and triangulate.** Triangulate each region, emit caps and side walls, write features contiguously, and split into primitives of at most 65,535 vertices. Dielectrics crossed by the same drills have the same sheet: it is cut and triangulated once, and the other dielectrics share its mesh, scaled and moved in Z by their node transforms (spec §4). 6. **Components.** Build placeholder bodies, or user models from the mapping file, with one shared mesh per package or model. 7. **Write.** Emit materials, nodes, meshes, `EXT_mesh_features`, the `EXT_structural_metadata` tables and `BOARDUI_board`, then pack the GLB. diff --git a/packages/viewer/test/runtime-models.test.ts b/packages/viewer/test/runtime-models.test.ts index f293574..d7c7d1e 100644 --- a/packages/viewer/test/runtime-models.test.ts +++ b/packages/viewer/test/runtime-models.test.ts @@ -196,7 +196,7 @@ describe('runtime models', () => { MPN: 'RC0603FR-0710KL', LCSC: 'C25744', }); - expect(boards.at(-1)?.profileVersion).toBe('0.9'); + expect(boards.at(-1)?.profileVersion).toBe('0.10'); }); it('replace placeholder bodies and pin-1 markers on the same state texels, and revert', async () => { diff --git a/packages/viewer/test/samples.test.ts b/packages/viewer/test/samples.test.ts index 94974d9..96efc9a 100644 --- a/packages/viewer/test/samples.test.ts +++ b/packages/viewer/test/samples.test.ts @@ -289,3 +289,25 @@ describe('BOM attributes (spec §8.2–§8.4)', () => { expect(model.describe('board')?.properties.source).not.toHaveProperty('software'); }); }); + +describe('repeated sheets (spec §4)', () => { + it('draws a sheet that shares a mesh at its own heights', async () => { + const model = await load('stacked-sheets'); + for (const name of ['PREPREG_1', 'CORE', 'PREPREG_2']) { + const layer = model.layer(`layer/${name}`); + if (!layer) throw new Error(`layer/${name} is missing`); + const { zMin, zMax } = layer.info as BoardLayerJson; + const geometry = layer.meshes[0]?.geometry; + expect(geometry?.boundingBox?.min.y).toBeCloseTo(zMin, 8); + expect(geometry?.boundingBox?.max.y).toBeCloseTo(zMax, 8); + const box = model.resolve(`feat/${name}/0`)?.box; + expect(box?.min.y).toBeCloseTo(zMin, 8); + expect(box?.max.y).toBeCloseTo(zMax, 8); + expect(model.describe(`feat/${name}/0`)?.properties).toMatchObject({ kind: 'SHEET' }); + } + // The prepreg shares the core's mesh and feature table; the upper prepreg has its own. + const table = (name: string) => model.layer(`layer/${name}`)?.table; + expect(table('PREPREG_2')).toBe(table('CORE')); + expect(table('PREPREG_1')).not.toBe(table('CORE')); + }); +}); diff --git a/spec/README.md b/spec/README.md index 4c5da76..0f6bdac 100644 --- a/spec/README.md +++ b/spec/README.md @@ -1,6 +1,6 @@ # boardui glTF profile -**Version 0.9 — draft** +**Version 0.10 — draft** This document specifies how boardui represents a printed circuit board as a glTF 2.0 asset. The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are used as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). @@ -53,8 +53,13 @@ board root node ``` - The root node MUST be named `board`, with child group nodes `layers`, `drills` and `components`. Group nodes MAY be empty. -- Layer and drill nodes MUST be named by their layer ID (§5), have identity transforms and hold geometry in board coordinates. -- Each layer and drill node with geometry has one mesh. A layer without geometry has no mesh (glTF meshes can't be empty). +- Layer and drill nodes MUST be named by their layer ID (§5), have identity transforms and hold geometry in board coordinates. The one exception is a repeated sheet (below), whose node scales and moves a shared mesh in Z. +- Each layer and drill node with geometry has one mesh. A layer without geometry has no mesh (glTF meshes can't be empty). A layer or drill mesh MUST NOT be used by any other node, except by repeated sheets. +- **Repeated sheets** (since 0.10). Dielectric layers crossed by the same holes have the same sheet (§6.7) at different heights. A dielectric layer whose sheet is that of an earlier dielectric layer (in `layers` order) MAY share that layer's mesh instead of having its own ([ADR 0016](../docs/adr/0016-shared-dielectric-sheets.md)): + - The mesh belongs to the first layer whose node uses it. That node has an identity transform, and the mesh is in board coordinates at its Z range. Only dielectric layers MAY use the mesh of another layer. + - The repeating layer's node uses the same mesh, with a `translation` of `[0, t, 0]` and a `scale` of `[1, s, 1]`, `s > 0` (either is omitted when it is the identity), and no `rotation` or `matrix`. The transform MUST map the first layer's Z range `[zMin₁, zMax₁]` onto the layer's own, within float32 precision: `s · zMin₁ + t = zMin` and `s · zMax₁ + t = zMax`, so `s = (zMax − zMin) / (zMax₁ − zMin₁)`. Its geometry in board coordinates is the mesh transformed by the node. + - The repeating layer shares the first layer's feature table too (§8.1, §8.2): both have one row, the sheet. The material is the mesh's, so the two layers have the same colour (§6.10). + - Other layers don't repeat in practice (soldermask sheets differ by their openings), so only dielectric sheets are shared. Viewers that merge layer meshes in board coordinates apply the node transform; generic glTF viewers do so anyway. - A mesh MAY have several primitives, for example chunks of the same material. Converters SHOULD keep each primitive at or below 65,535 vertices so indices fit `UNSIGNED_SHORT`. A feature MUST NOT span two primitives. ## 5. Element identifiers @@ -181,7 +186,7 @@ A feature whose region becomes empty keeps its metadata row (§8.2) and has no v ### 6.7 Dielectric and outline -Each dielectric layer is one sheet feature (kind `SHEET`): the step profile, including its cutouts, minus holes. +Each dielectric layer is one sheet feature (kind `SHEET`): the step profile, including its cutouts, minus the holes that cross it. Dielectrics crossed by the same holes have the same sheet, which they MAY share (§4, since 0.10). **Cut-outs on a board outline layer.** KiCad writes the `Profile` as its outer polygon alone and draws the board's inner contours (holes, slots, the gaps of a panel) only on its `BOARD_OUTLINE` layer, `Edge.Cuts`, as separate `Line`s and `Arc`s. When a step's profile has no `Cutout`, the converter takes them from the step's `BOARD_OUTLINE` layers: @@ -331,7 +336,7 @@ This section describes how the boardui viewer adds models when it shows an asset Every primitive of a layer or drill mesh MUST carry exactly one `featureIds` entry with: - `attribute: 0` (vertex attribute `_FEATURE_ID_0`); -- `propertyTable`: that layer's feature table; +- `propertyTable`: that layer's feature table (for a repeated sheet, the table it shares, §4); - `featureCount`: the number of distinct feature IDs in the primitive. Feature IDs are row indices into the layer's feature table: @@ -355,7 +360,7 @@ Feature IDs are row indices into the layer's feature table: | `attributes` | `attribute` | attribute of a component (since 0.8) | | `` | `feature` | source feature of that layer (or drill layer) | -- Feature tables are named by their layer ID. +- Feature tables are named by their layer ID. A repeated sheet's table is the first layer's, with that layer's name (§4); its feature IDs still derive from its own layer (§5). - **Empty tables.** `EXT_structural_metadata` property tables need at least one row, and glTF buffer views at least one byte. A table without rows is therefore omitted (and so is its index in `BOARDUI_board`), and so is an optional `STRING` property whose values are all empty (its `noData` is `""`; for example `pin.name` on a board without pin names). - **References.** References between tables are row indices (`UINT32`); `4294967295` means none. For example, `feature.net` is a row in `nets`, and `pin.component` is a row in `components`. - **Feature ID strings** are not stored. They derive from the layer name and `feature.source` (§5). @@ -382,7 +387,7 @@ This is a root-level extension ([`schema/BOARDUI_board.schema.json`](schema/BOAR ```json "BOARDUI_board": { - "profileVersion": "0.9", + "profileVersion": "0.10", "source": { "format": "IPC-2581", "revision": "C", @@ -416,6 +421,7 @@ This is a root-level extension ([`schema/BOARDUI_board.schema.json`](schema/BOAR - `layers` is ordered top to bottom. `thickness` is the copper-to-copper thickness. - `tables.*` and `featureTable` are absent for tables without rows (§8.2). `tables.instances` is present only for panels (§6.14). +- `node` and `featureTable` of a repeated sheet (since 0.10) are its own node, which uses the shared mesh, and the shared table (§4). Two layers have the same `featureTable` only then. - `source.software` (since 0.8) is the software that wrote the source file: the `name`, `revision` and `vendor` of `HistoryRecord/FileRevision/SoftwarePackage`, as written. `revision` and `vendor` are absent when empty, and `software` when the file has no `SoftwarePackage`. - `role` is one of `COPPER`, `DIELECTRIC`, `SOLDERMASK`, `SILKSCREEN`, `PASTE`, `COURTYARD`, `ASSEMBLY`, `DOCUMENTATION`. `ipcFunction` keeps the source `layerFunction`, and is absent for synthesized layers. - `visible` is the suggested default visibility. Inner copper layers and the optional layers (paste and drawings, §6.11, §6.12) default to `false`, all other layers to `true`. Dielectric layers stay visible so that the board is opaque like a real one: with them hidden, the translucent soldermask (§7) would show the other side's copper and components through the board. @@ -458,7 +464,7 @@ Anchors (for example top-centre of the bounding box) are computed from these. `boardui validate ` checks the rules of this profile: - the extensions are declared as in §2; -- the scene structure follows §4, and IDs are unique and well-formed; +- the scene structure follows §4, and IDs are unique and well-formed; layer and drill nodes have identity transforms, except repeated sheets, whose transforms map their first layer's Z range onto their own and who share its feature table; - feature IDs are contiguous and ascending within primitives; - metadata references are in range, and component `extras` match the `components` and `attributes` tables; - the `attributes` table is in component order, without empty values or repeated names per component; @@ -472,7 +478,7 @@ It runs the Khronos validator too, when the `gltf_validator` binary is on `PATH` ## 11. Versioning -`profileVersion` is `major.minor`. Minor versions only add optional data. Readers MUST reject an unknown major version and SHOULD accept an unknown minor version. +`profileVersion` is `major.minor`; versions compare as integers, so 0.10 follows 0.9. Minor versions only add optional data; while the profile is a draft (0.x), a minor version may also relax a rule, as 0.10 does for the transforms of repeated sheets (§4). Readers MUST reject an unknown major version and SHOULD accept an unknown minor version. ## 12. Open questions From be4b4098212680ad2d4c73dd9dcb49ca3a3bcc4a Mon Sep 17 00:00:00 2001 From: Michal Dub <37038617+midub@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:51:13 +0000 Subject: [PATCH 3/4] Repeats that don't share extrude the earlier sheet; picking test; sizes --- CHANGELOG.md | 2 +- crates/boardui-convert/src/pipeline.rs | 4 +++- docs/adr/0016-shared-dielectric-sheets.md | 2 +- packages/viewer/test/samples.test.ts | 16 ++++++++++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec1753..145dff1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ glTF profile has its own version (`profileVersion`, [spec §11](spec/README.md#1 a dielectric whose sheet repeats an earlier dielectric's shares that layer's mesh, scaled and moved in Z by its node transform, and its feature table ([spec §4](spec/README.md#4-scene-structure), [ADR 0016](docs/adr/0016-shared-dielectric-sheets.md)). testcase1-RevC-full shrinks from - 277 MB to 207 MB. `boardui validate` accepts exactly this transform on layer nodes; files of + 277 MB to 203 MB. `boardui validate` accepts exactly this transform on layer nodes; files of profile 0.9 and older load as before. New sample `stacked-sheets`. - **Component attributes (profile 0.8, still a draft):** the converter reads the BOM, the diff --git a/crates/boardui-convert/src/pipeline.rs b/crates/boardui-convert/src/pipeline.rs index 6804b2a..65360b4 100644 --- a/crates/boardui-convert/src/pipeline.rs +++ b/crates/boardui-convert/src/pipeline.rs @@ -453,8 +453,10 @@ pub(crate) fn run( same_sheet[i].filter(|&j| colours[i] == colours[j] && extrudes(i) && extrudes(j)) }) .collect(); - let mut meshes = par::map(&finals, |i, (_, regions)| { + let mut meshes = par::map(&finals, |i, _| { let layer = &stack.layers[i]; + // A repeat that doesn't share has no sheet of its own: it extrudes the earlier one. + let regions = &finals[same_sheet[i].unwrap_or(i)].1; match repeats[i] { Some(_) => (LayerMesh::default(), Vec::new()), None => extrude(&layer.name, regions, layer.z_min, layer.z_max), diff --git a/docs/adr/0016-shared-dielectric-sheets.md b/docs/adr/0016-shared-dielectric-sheets.md index e99d23e..2c5cfcf 100644 --- a/docs/adr/0016-shared-dielectric-sheets.md +++ b/docs/adr/0016-shared-dielectric-sheets.md @@ -36,7 +36,7 @@ primitives to name that layer's feature table, so every sheet was stored again. ## Consequences -- testcase1-RevC-full: 277 MB → 207 MB; conversion skips extruding and writing 10 sheets. +- testcase1-RevC-full: 277 MB → 203 MB; conversion skips extruding and writing 10 sheets. - Readers that merge layer meshes must apply node transforms. The boardui viewer always did (`mergeLayer` uses `matrixWorld`); generic glTF viewers do anyway. A reader that assumed identity transforms on layer nodes draws a repeat at the first layer's height. diff --git a/packages/viewer/test/samples.test.ts b/packages/viewer/test/samples.test.ts index 96efc9a..cc44142 100644 --- a/packages/viewer/test/samples.test.ts +++ b/packages/viewer/test/samples.test.ts @@ -1,10 +1,12 @@ import { readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { Ray, Vector3 } from 'three'; import { describe, expect, it } from 'vitest'; import type { BoardLayerJson } from '../src/board-extension.js'; import { BoardModel } from '../src/board-model.js'; import { loadGltf } from '../src/load.js'; +import { Picker } from '../src/picking.js'; // The converter's expected outputs (spec/samples/hand-written/*/*.glb), so the viewer is // tested on real assets, including ones that omit empty tables (spec §8.2). @@ -310,4 +312,18 @@ describe('repeated sheets (spec §4)', () => { expect(table('PREPREG_2')).toBe(table('CORE')); expect(table('PREPREG_1')).not.toBe(table('CORE')); }); + + it('picks a shared sheet at its own heights', async () => { + const model = await load('stacked-sheets'); + const picker = new Picker(model); + for (const name of ['PREPREG_1', 'CORE', 'PREPREG_2']) { + const info = model.layer(`layer/${name}`)?.info as BoardLayerJson | undefined; + const z = info ? (info.zMin + info.zMax) / 2 : Number.NaN; + // Towards the board edge at x = 8 mm, from the side, between two vias. + const ray = new Ray(new Vector3(0.05, z, -1e-3), new Vector3(-1, 0, 0)); + const hit = picker.pick(ray); + expect(hit && model.idOfTexel(hit.texel)).toBe(`feat/${name}/0`); + expect(hit?.point.x).toBeCloseTo(8e-3, 8); + } + }); }); From ffe47e7c9f07bef44711c09ce4333679e53a6208 Mon Sep 17 00:00:00 2001 From: Michal Dub <37038617+midub@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:22:23 +0000 Subject: [PATCH 4/4] validate: check repeated sheets' tables in their own loop --- crates/boardui-convert/src/validate.rs | 30 +++++++++++--------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/crates/boardui-convert/src/validate.rs b/crates/boardui-convert/src/validate.rs index 187573f..8ec7b7f 100644 --- a/crates/boardui-convert/src/validate.rs +++ b/crates/boardui-convert/src/validate.rs @@ -973,28 +973,24 @@ impl Validator<'_> { .structural_metadata .as_ref() .expect("checked"); + // A layer that repeats another has that layer's table and geometry, checked there. let repeats = self.repeats(board); + for (layer, earlier) in board.layers.iter().zip(&repeats) { + if let Some(earlier) = earlier.map(|e| &board.layers[e]) + && layer.feature_table != earlier.feature_table + { + self.report.error(format!( + "`{}` shares the mesh of `{}` but not its feature table", + layer.id, earlier.id + )); + } + } let entries = board .layers .iter() .zip(&repeats) - .filter_map(|(l, &repeats)| match repeats { - // A layer that repeats another has that layer's table and geometry, checked - // there. - Some(earlier) => { - let earlier = &board.layers[earlier]; - if l.feature_table != earlier.feature_table { - self.report.error(format!( - "`{}` shares the mesh of `{}` but not its feature table", - l.id, earlier.id - )); - } - None - } - None => Some((l.id.as_str(), l.node, l.feature_table, Some(l))), - }) - .collect::>() - .into_iter() + .filter(|(_, earlier)| earlier.is_none()) + .map(|(l, _)| (l.id.as_str(), l.node, l.feature_table, Some(l))) .chain( board .drills