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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 66 additions & 13 deletions crates/boardui-convert/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,14 +301,34 @@ pub(crate) fn run(
|| (layer < first && drill.from == first)
|| (layer > last && drill.to == last)
};
let layer_cuts = |layer: usize| -> Vec<Region> {
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<usize> = {
let mut first = HashMap::new();
(0..stack.layers.len())
.map(|i| {
let crossing: Vec<usize> = (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<Option<usize>> = (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<Region> = part_outlines.into_iter().flatten().collect();
Expand Down Expand Up @@ -359,13 +379,26 @@ pub(crate) fn run(
let indices: Vec<usize> = (0..stack.layers.len()).collect();
let mut rows: Vec<Option<Vec<FeatureRow>>> =
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<usize> = (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<Region> = regions[i].iter().map(|r| cutter.cut(r)).collect();
(None, cut)
}
Expand All @@ -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<Region> = 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])
}
}
Expand All @@ -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));
Expand Down
74 changes: 74 additions & 0 deletions crates/boardui-geom/src/mesh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,41 @@ pub struct LayerMesh {
pub primitives: Vec<Primitive>,
}

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<Self> {
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`]
Expand Down Expand Up @@ -305,6 +340,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, &region.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.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading