From 2deafd0e57f8be3da1364905c13264389b221411 Mon Sep 17 00:00:00 2001 From: Michal Dub <37038617+midub@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:12:25 +0000 Subject: [PATCH 1/3] Compute each distinct dielectric sheet once; share hole cutters Dielectrics crossed by the same drills get the same sheet: cut and extrude it once and move the mesh to the other dielectrics' heights (LayerMesh::restacked). Copper and paste layers crossed by the same drills share one hole cutter, and hole cuts are no longer cloned per layer. The GLB stays byte-for-byte the same. --- crates/boardui-convert/src/pipeline.rs | 79 +++++++++++++++++++++----- crates/boardui-geom/src/mesh.rs | 69 ++++++++++++++++++++++ 2 files changed, 135 insertions(+), 13 deletions(-) diff --git a/crates/boardui-convert/src/pipeline.rs b/crates/boardui-convert/src/pipeline.rs index 6fdb808..a842a61 100644 --- a/crates/boardui-convert/src/pipeline.rs +++ b/crates/boardui-convert/src/pipeline.rs @@ -301,14 +301,34 @@ pub(crate) fn run( || (layer < first && drill.from == first) || (layer > last && drill.to == last) }; - let layer_cuts = |layer: usize| -> Vec { + let layer_cuts = |layer: usize| { + let drills = &drills; hole_list .iter() .zip(&cuts) - .filter(|((d, _), _)| through(layer, &drills[*d])) - .filter_map(|(_, cut)| cut.clone()) + .filter(move |((d, _), _)| through(layer, &drills[*d])) + .filter_map(|(_, cut)| cut.as_ref()) + }; + // Layers crossed by the same drills have the same cuts: each layer's first layer with + // its drills, whose hole cutter and dielectric sheet it shares. + let same_cuts: Vec = { + let mut first = HashMap::new(); + (0..stack.layers.len()) + .map(|i| { + let crossing: Vec = (0..drills.len()) + .filter(|&d| through(i, &drills[d])) + .collect(); + *first.entry(crossing).or_insert(i) + }) .collect() }; + // The earlier dielectric whose sheet a dielectric repeats, at other heights. + let same_sheet: Vec> = (0..stack.layers.len()) + .map(|i| { + let dielectric = |j: usize| stack.layers[j].role == Role::Dielectric; + (0..i).find(|&j| dielectric(i) && dielectric(j) && same_cuts[j] == same_cuts[i]) + }) + .collect(); // Board outline and soldermask openings. let mut profiles: Vec = part_outlines.into_iter().flatten().collect(); @@ -359,13 +379,26 @@ pub(crate) fn run( let indices: Vec = (0..stack.layers.len()).collect(); let mut rows: Vec>> = layer_features.into_iter().map(|f| Some(f.rows)).collect(); + // One hole cutter per set of drills that copper or paste layers are cut with. + let mut cutter_layers: Vec = (0..stack.layers.len()) + .filter(|&i| matches!(stack.layers[i].role, Role::Copper | Role::Paste)) + .map(|i| same_cuts[i]) + .collect(); + cutter_layers.sort_unstable(); + cutter_layers.dedup(); + let cutters = par::map(&cutter_layers, |_, &i| { + HoleCutter::new(layer_cuts(i).cloned().collect()) + }); + let cutter = |i: usize| { + let k = cutter_layers.binary_search(&same_cuts[i]).expect("cutter"); + &cutters[k] + }; let results = par::map(&indices, |_, &i| { let layer = &stack.layers[i]; - let holes = layer_cuts(i); let side = (layer.side == Side::Bottom) as usize; match layer.role { Role::Copper | Role::Paste => { - let cutter = HoleCutter::new(holes); + let cutter = cutter(i); let cut: Vec = regions[i].iter().map(|r| cutter.cut(r)).collect(); (None, cut) } @@ -374,20 +407,20 @@ pub(crate) fn run( (None, regions[i].clone()) } Role::Silkscreen => { - let mut clip = holes; - clip.extend(side_openings[side].iter().cloned()); - let cutter = HoleCutter::new(clip); + let clip = layer_cuts(i).chain(&side_openings[side]).cloned(); + let cutter = HoleCutter::new(clip.collect()); let cut: Vec = regions[i].iter().map(|r| cutter.cut(r)).collect(); (None, cut) } Role::Soldermask => { - let mut clip = holes; - clip.extend(side_openings[side].iter().cloned()); - let sheet = outline.difference(&Region::union_all(&clip)); + let clip = layer_cuts(i).chain(&side_openings[side]); + let sheet = outline.difference(&Region::union_all(clip)); (Some(sheet_row()), vec![sheet]) } + // Extruded from the earlier dielectric's sheet. + Role::Dielectric if same_sheet[i].is_some() => (Some(sheet_row()), Vec::new()), Role::Dielectric => { - let sheet = outline.difference(&Region::union_all(&holes)); + let sheet = outline.difference(&Region::union_all(layer_cuts(i))); (Some(sheet_row()), vec![sheet]) } } @@ -409,10 +442,30 @@ pub(crate) fn run( let mut layers = Vec::with_capacity(stack.layers.len()); { let _step = timings.step("extrude"); - let meshes = par::map(&finals, |i, (_, regions)| { + 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), + } + }); + for ((i, _), mesh) in repeats.into_iter().zip(repeated) { + meshes[i] = mesh; + } let sources = stack.layers.iter().zip(colours); for (((layer, color), (rows, _)), (mesh, messages)) in sources.zip(finals).zip(meshes) { messages.into_iter().for_each(|m| warnings.push(m)); diff --git a/crates/boardui-geom/src/mesh.rs b/crates/boardui-geom/src/mesh.rs index 55b9bef..1174a6a 100644 --- a/crates/boardui-geom/src/mesh.rs +++ b/crates/boardui-geom/src/mesh.rs @@ -78,6 +78,36 @@ 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`] @@ -305,6 +335,45 @@ 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. From f58f912593ed647041a852c33fdb48f8ea8cba45 Mon Sep 17 00:00:00 2001 From: Michal Dub <37038617+midub@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:20:55 +0000 Subject: [PATCH 2/3] Changelog and architecture notes for shared dielectric sheets --- CHANGELOG.md | 4 ++++ docs/architecture.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b20503..c756cae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ glTF profile has its own version (`profileVersion`, [spec §11](spec/README.md#1 writes a part's `VALUE` and `TOLERANCE` there; the BOM's `VALUE` wins); - text drawn as outlines takes its string from its set's `TEXT` attribute (KiCad), so the viewer's `info()` and the demos' details panel show the string of selected silkscreen text. +- **Faster conversion of boards with several dielectric layers:** dielectrics crossed by the + 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. - **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/architecture.md b/docs/architecture.md index 2be585f..d55ad77 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. +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. 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. From 1dd87bcdc63a416a6cf28e3677ca043eedc04735 Mon Sep 17 00:00:00 2001 From: Michal Dub <37038617+midub@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:22:53 +0000 Subject: [PATCH 3/3] rustfmt --- crates/boardui-convert/src/pipeline.rs | 6 +++--- crates/boardui-geom/src/mesh.rs | 13 +++++++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/boardui-convert/src/pipeline.rs b/crates/boardui-convert/src/pipeline.rs index a842a61..6871911 100644 --- a/crates/boardui-convert/src/pipeline.rs +++ b/crates/boardui-convert/src/pipeline.rs @@ -455,9 +455,9 @@ pub(crate) fn run( 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) - }); + 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), diff --git a/crates/boardui-geom/src/mesh.rs b/crates/boardui-geom/src/mesh.rs index 1174a6a..83a47fb 100644 --- a/crates/boardui-geom/src/mesh.rs +++ b/crates/boardui-geom/src/mesh.rs @@ -87,15 +87,20 @@ impl LayerMesh { /// 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 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 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]));