Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
approved vendor list and the exporting software. Each component gets its BOM attributes
Expand Down
56 changes: 33 additions & 23 deletions crates/boardui-convert/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,32 +442,40 @@ pub(crate) fn run(
let mut layers = Vec::with_capacity(stack.layers.len());
{
let _step = timings.step("extrude");
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]?)))
// 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<Option<usize>> = (0..finals.len())
.map(|i| {
same_sheet[i].filter(|&j| colours[i] == colours[j] && extrudes(i) && extrudes(j))
})
.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),
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),
}
});
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(),
Expand All @@ -482,6 +490,7 @@ pub(crate) fn run(
color,
mesh,
features: rows,
repeats,
});
}
}
Expand Down Expand Up @@ -523,9 +532,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;
Expand Down
181 changes: 171 additions & 10 deletions crates/boardui-convert/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<u32, usize> = HashMap::new();
for mesh in root.nodes.iter().filter_map(|n| n.mesh) {
*users.entry(mesh).or_default() += 1;
}
let mut layer_users: HashMap<u32, usize> = 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}`"
Expand All @@ -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
Expand Down Expand Up @@ -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<Option<usize>> {
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 {
Expand Down Expand Up @@ -899,10 +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()
.map(|l| (l.id.as_str(), l.node, l.feature_table, Some(l)))
.zip(&repeats)
.filter(|(_, earlier)| earlier.is_none())
.map(|(l, _)| (l.id.as_str(), l.node, l.feature_table, Some(l)))
.chain(
board
.drills
Expand Down Expand Up @@ -1444,6 +1532,78 @@ mod tests {
assert_eq!(check(&root, &bin), Vec::<String>::new());
}

/// The `stacked-sheets` sample: PREPREG_2 repeats the core's sheet.
fn stacked() -> (Root, Vec<u8>) {
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::<String>::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();
Expand Down Expand Up @@ -1664,6 +1824,7 @@ mod tests {
color: None,
mesh: mesh.finish(),
features: vec![row(0), row(1)],
repeats: None,
}],
drills: Vec::new(),
nets: Vec::new(),
Expand Down
14 changes: 11 additions & 3 deletions crates/boardui-convert/tests/conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -864,13 +865,19 @@ fn summary(conversion: &Conversion, detailed: bool) -> String {
}
}
let strings = |table: Option<u32>, 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,
Expand All @@ -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,
Expand Down
Loading
Loading