mesh: split a projection block through its whole sheet; name twisted elements' block junction (#399)

Topology::split cuts a block at given fractions and every block chained to it
through parallel edges, orienting the fractions through the chain, so the
topology stays conforming. A certified-invalid element is reported with its
block, cell, centre and the block vertex and blocks meeting there; the mesh is
never emitted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 16:57:05 -04:00
1 parent 4dadaa50aa
commit febd40fadc
6 files changed
+312 -23

No files matched your search

+34 -1
View File
@@ -20,7 +20,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const HEX_PROJECT: CommandSpec = CommandSpec {
id: "mesh.hex.project",
label: "Projection Blocking",
params: r#"{"body": "Body1", "size": 2.0 (element size, mm; default 1/25 of the body's diagonal), "template": "grid" (default) | "sphere" | "butterfly" | "blocks", "center": [x, y, z], "radius": r, "axis": [0, 0, 1], "levels": [z0, z1, …] (sphere and butterfly: default from the body's bounding box; butterfly levels are distances along the axis from the centre), "inset": 0.55 (inner square or cube corner at this fraction of the radius), "grid": {"planes": {"x": [...], "y": [...], "z": [...]} or "cells": [ni, nj, nk] (even planes over the bounding box, default [1, 1, 1]), "carve": [{"i": [a, b], "j": [c, d], "k": [e, f]}] (block index ranges, inclusive, removed)}, "blocks": {"vertices": [[x, y, z], ...], "blocks": [[v1..v8], ...] (0-based vertices, hex8 order)}, "operations": [{"op": "o_grid", "block": b, "open": [faces], "inset": 0.5 or [i1, i2, i3], "extrude": {"offset": [dx, dy, dz], "layers": 1, "faces": "all" (the open face's butterfly: a round branch) | "inner" (its inner face: a square stub)}} | {"op": "extrude", "faces": [[block, face], ...], "offset": [dx, dy, dz], "layers": 1} | {"op": "connect", "from": [block, face], "to": [block, face], "layers": 1} (blocks bridging two faces: closes a handle) | {"op": "remove", "blocks": [...]} (carve; later blocks renumber) | {"op": "merge", "distance": d}] (applied in order; blocks are 0-based, new blocks appended, the O-grid's inner block keeping the old index; a face is its hex8 number 1-6 or the direction it faces, "+x" … "-z"), "preview": true (return the topology without meshing), "faces": [{"block": b, "face": 1-6, "to": ["face names"]}] (targets set by hand; other outer block faces go to the smooth group of B-rep faces nearest them), "divisions": [{"block": b, "direction": 1|2|3, "count": n}], "smoothing": "elliptic" (default) | "elliptic-controlled" (Thomas–Middlecoff) | "laplace" | "none", "iterations": 100, "min_quality": 0.2 (smallest scaled Jacobian accepted), "merge": distance (join nodes of other bodies' meshes within it; default 1e-6 of the diagonal, 0 never), "tolerance": chordal tolerance of the tessellation} — replaces the body's mesh with an all-hex mesh of the block topology: outer block faces projected onto their B-rep faces, edges between faces with different targets onto the curve where those meet (tangent-plane iteration), vertices on three onto their point, nodes placed by transfinite interpolation and smoothed with the boundary held on the geometry. Elements are tagged with the body and boundary faces with their B-rep faces. Reports blocks, divisions, targets and quality before and after smoothing; a corner that flattens on a curved face is an error that says to use an O-grid or template there"#,
params: r#"{"body": "Body1", "size": 2.0 (element size, mm; default 1/25 of the body's diagonal), "template": "grid" (default) | "sphere" | "butterfly" | "blocks", "center": [x, y, z], "radius": r, "axis": [0, 0, 1], "levels": [z0, z1, …] (sphere and butterfly: default from the body's bounding box; butterfly levels are distances along the axis from the centre), "inset": 0.55 (inner square or cube corner at this fraction of the radius), "grid": {"planes": {"x": [...], "y": [...], "z": [...]} or "cells": [ni, nj, nk] (even planes over the bounding box, default [1, 1, 1]), "carve": [{"i": [a, b], "j": [c, d], "k": [e, f]}] (block index ranges, inclusive, removed)}, "blocks": {"vertices": [[x, y, z], ...], "blocks": [[v1..v8], ...] (0-based vertices, hex8 order)}, "operations": [{"op": "o_grid", "block": b, "open": [faces], "inset": 0.5 or [i1, i2, i3], "extrude": {"offset": [dx, dy, dz], "layers": 1, "faces": "all" (the open face's butterfly: a round branch) | "inner" (its inner face: a square stub)}} | {"op": "extrude", "faces": [[block, face], ...], "offset": [dx, dy, dz], "layers": 1} | {"op": "connect", "from": [block, face], "to": [block, face], "layers": 1} (blocks bridging two faces: closes a handle) | {"op": "remove", "blocks": [...]} (carve; later blocks renumber) | {"op": "merge", "distance": d} | {"op": "split", "block": b, "direction": 1|2|3 or "x"|"y"|"z", "at": [fractions]} (cuts the block and the whole sheet of blocks the cut runs through, so the topology stays conforming; fractions from the block's side-0 face, or from the low end along an axis; the block keeps its first slab, the others are appended)] (applied in order; blocks are 0-based, new blocks appended, the O-grid's inner block keeping the old index; a face is its hex8 number 1-6 or the direction it faces, "+x" … "-z"), "preview": true (return the topology without meshing), "faces": [{"block": b, "face": 1-6, "to": ["face names"]}] (targets set by hand; other outer block faces go to the smooth group of B-rep faces nearest them), "divisions": [{"block": b, "direction": 1|2|3, "count": n}], "smoothing": "elliptic" (default) | "elliptic-controlled" (Thomas–Middlecoff) | "laplace" | "none", "iterations": 100, "min_quality": 0.2 (smallest scaled Jacobian accepted), "merge": distance (join nodes of other bodies' meshes within it; default 1e-6 of the diagonal, 0 never), "tolerance": chordal tolerance of the tessellation} — replaces the body's mesh with an all-hex mesh of the block topology: outer block faces projected onto their B-rep faces, edges between faces with different targets onto the curve where those meet (tangent-plane iteration), vertices on three onto their point, nodes placed by transfinite interpolation and smoothed with the boundary held on the geometry. Elements are tagged with the body and boundary faces with their B-rep faces. Reports blocks, divisions, targets and quality before and after smoothing; a corner that flattens on a curved face is an error that says to use an O-grid or template there"#,
form: Some(super::forms::HEX_PROJECT),
kind: CommandKind::Model,
requires: &[Requirement::Geometry],
@@ -101,6 +101,20 @@ enum Operation {
Merge {
distance: f64,
},
Split {
block: usize,
direction: DirSel,
at: Vec<f64>,
},
}
/// A block direction: 1, 2 or 3 (along 1→2, 1→4, 1→5), or the axis it runs nearest ("x", "y",
/// "z"; fractions then count from its low end).
#[derive(Deserialize, Clone)]
#[serde(untagged)]
enum DirSel {
Number(usize),
Axis(String),
}
fn face_of(t: &Topology, block: usize, f: &FaceSel) -> Result<u8> {
@@ -164,6 +178,25 @@ fn operate(t: &mut Topology, ops: &[Operation]) -> Result<()> {
}
t.merge(*distance);
}
Operation::Split { block, direction, at } => {
bounded(at, "operations.at")?;
let (d, against) = match direction {
DirSel::Number(n @ 1..=3) => (n - 1, false),
DirSel::Number(n) => return Err(EngineError::Params(format!("split direction {n}: 1, 2, 3 or x, y, z"))),
DirSel::Axis(a) => {
let axis = match a.as_str() {
"x" => [1.0, 0.0, 0.0],
"y" => [0.0, 1.0, 0.0],
"z" => [0.0, 0.0, 1.0],
other => return Err(EngineError::Params(format!("split direction `{other}`: 1, 2, 3 or x, y, z"))),
};
t.direction_along(*block, axis)?
}
};
// Fractions count from the block's side-0 face; along an axis, from its low end.
let at: Vec<f64> = if against { at.iter().rev().map(|x| 1.0 - x).collect() } else { at.clone() };
t.split(*block, d, &at)?;
}
}
}
Ok(())
+59 -16
View File
@@ -99,6 +99,10 @@ fn hostile_parameters_change_nothing() {
json!({"body": "Ball", "template": "sphere", "operations": [{"op": "o_grid", "block": 77}]}),
json!({"body": "Ball", "template": "sphere", "operations": [{"op": "extrude", "faces": [[0, "+w"]], "offset": [0, 0, 1]}]}),
json!({"body": "Ball", "template": "sphere", "operations": [{"op": "merge", "distance": -1}]}),
json!({"body": "Ball", "template": "sphere", "operations": [{"op": "split", "block": 0, "direction": "w", "at": [0.5]}]}),
json!({"body": "Ball", "template": "sphere", "operations": [{"op": "split", "block": 0, "direction": 4, "at": [0.5]}]}),
json!({"body": "Ball", "template": "sphere", "operations": [{"op": "split", "block": 0, "direction": "x", "at": [1.5]}]}),
json!({"body": "Ball", "template": "sphere", "operations": [{"op": "split", "block": 99, "direction": 1, "at": [0.5]}]}),
json!({"body": "Ball", "template": "blocks"}),
json!({"body": "Ball", "template": "blocks", "blocks": {"vertices": [[0, 0, 0]], "blocks": [[0, 0, 0, 0, 0, 0, 0, 0]]}}),
json!({"body": "Ball", "grid": {"cells": [0, 1, 1]}}),
@@ -128,26 +132,66 @@ fn cup_params(size: f64, inset: Value) -> Value {
json!({"body": "Cup", "template": "butterfly", "center": [0, 0, 0], "axis": [0, 0, 1], "radius": 20, "levels": [0, 4, 6, 14, 18, 26, 30], "size": size,
"operations": [
{"op": "o_grid", "block": 0, "open": [1, 2], "inset": 0.6},
{"op": "o_grid", "block": 14, "open": ["+x"], "inset": inset},
{"op": "o_grid", "block": 22, "open": ["+x"], "inset": inset},
{"op": "split", "block": 14, "direction": "y", "at": [0.36, 0.64]},
{"op": "o_grid", "block": 54, "open": ["+x"], "inset": inset},
{"op": "o_grid", "block": 62, "open": ["+x"], "inset": inset},
{"op": "extrude", "faces": [[54, "+x"]], "offset": [12, 0, 0]},
{"op": "extrude", "faces": [[84, "+x"]], "offset": [4, 0, 0]},
{"op": "extrude", "faces": [[62, "+x"]], "offset": [12, 0, 0]},
{"op": "extrude", "faces": [[86, "+x"]], "offset": [4, 0, 0]},
{"op": "connect", "from": [85, "+z"], "to": [87, "-z"], "layers": 2},
{"op": "remove", "blocks": [1, 2, 3, 4, 5, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45]}
]})
}
/// A cup with a handle: a double O-grid section (the floor's butterfly inside the wall's), the
/// cavity carved, the handle a square bar extruded from O-grids in the wall and closed by a
/// connection. The wall's +x petals are split in three around the handle first, so each arm's
/// footprint sits in a block face with even margins round it (#399).
fn cup_with_a_handle_at(size: f64) {
let (_d, mut s) = cup_session();
let r = s.execute("mesh.hex.project", &cup_params(size, json!(0.5))).unwrap();
assert!(sj(&r) >= 0.3, "{r}");
let (v, body) = (r["volume"].as_f64().unwrap(), r["body_volume"].as_f64().unwrap());
assert!((v - body).abs() < 0.01 * body, "{r}");
assert!(r["max_gap"].as_f64().unwrap() < 1e-6, "{r}");
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["valid"].as_u64(), Some(s.model.elements().len() as u64), "{q}");
}
#[test]
fn cup_with_a_handle() {
cup_with_a_handle_at(2.0);
}
#[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn cup_with_a_handle_refined() {
cup_with_a_handle_at(1.0);
}
/// Without the split, the ring block under the upper arm is squeezed between the petal's edge
/// and the window-top plane and folds: the mesher refuses the mesh and names the twisted
/// elements' block and the block junction they touch.
#[test]
fn twisted_junction_elements_are_reported_not_meshed() {
let (_d, mut s) = cup_session();
let mut p = cup_params(2.0, json!([0.5, 0.3, 0.3]));
p["operations"] = json!([
{"op": "o_grid", "block": 0, "open": [1, 2], "inset": 0.6},
{"op": "o_grid", "block": 14, "open": ["+x"], "inset": [0.5, 0.3, 0.3]},
{"op": "o_grid", "block": 22, "open": ["+x"], "inset": [0.5, 0.3, 0.3]},
{"op": "extrude", "faces": [[14, "+x"]], "offset": [12, 0, 0]},
{"op": "extrude", "faces": [[44, "+x"]], "offset": [4, 0, 0]},
{"op": "extrude", "faces": [[22, "+x"]], "offset": [12, 0, 0]},
{"op": "extrude", "faces": [[46, "+x"]], "offset": [4, 0, 0]},
{"op": "connect", "from": [45, "+z"], "to": [47, "-z"], "layers": 2},
{"op": "remove", "blocks": [1, 2, 3, 4, 5]}
]})
}
/// A cup with a handle: a double O-grid section (the floor's butterfly inside the wall's), the
/// cavity carved, the handle a square bar extruded from O-grids in the wall and closed by a
/// connection. Open: a few elements at the upper handle junction stay folded (#399).
#[test]
#[ignore = "#399: the upper handle junction leaves a few folded elements"]
fn cup_with_a_handle() {
let (_d, mut s) = cup_session();
let r = s.execute("mesh.hex.project", &cup_params(2.0, json!([0.5, 0.3, 0.3]))).unwrap();
assert!(sj(&r) >= 0.3, "{r}");
]);
let model = s.model.clone();
let e = s.execute("mesh.hex.project", &p).unwrap_err().to_string();
assert!(e.contains("twisted") && e.contains("where blocks 17, 37, 38, 41 meet"), "{e}");
assert_eq!(s.model, model);
}
/// The cup without its handle: the floor's O-grid inside the wall's butterfly, cavity carved.
@@ -158,8 +202,7 @@ fn cup_floor_double_o_grid() {
{"command": "solid.cylinder", "params": {"base": [0, 0, 4], "radius": 16, "height": 27, "operation": "cut", "targets": ["Cup"]}}
]));
let mut p = cup_params(2.0, json!(0.5));
let ops = p["operations"].as_array().unwrap().clone();
p["operations"] = json!([ops[0], ops[8]]);
p["operations"] = json!([{"op": "o_grid", "block": 0, "open": [1, 2], "inset": 0.6}, {"op": "remove", "blocks": [1, 2, 3, 4, 5]}]);
let r = s.execute("mesh.hex.project", &p).unwrap();
assert!(sj(&r) >= 0.5, "{r}");
let (v, body) = (r["volume"].as_f64().unwrap(), r["body_volume"].as_f64().unwrap());
+37 -6
View File
@@ -516,12 +516,12 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
// Certified quality, the gap to the geometry and the tags.
let (mut min_sj, mut sum_sj, mut volume) = (f64::INFINITY, 0.0, 0.0);
let mut invalid = 0usize;
for h in &lat.hexes {
let mut invalid: Vec<usize> = Vec::new();
for (e, h) in lat.hexes.iter().enumerate() {
let q = element_quality(ElementKind::Hex8, &corners_of(&nodes, h))?;
let sj = q.get(Metric::ScaledJacobian).unwrap_or(-1.0);
if q.validity().is_none_or(|v| v != crate::quality::Validity::Valid) {
invalid += 1;
invalid.push(e);
}
min_sj = min_sj.min(sj);
sum_sj += sj;
@@ -568,11 +568,12 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
format!("worst in block {} near ({:.3}, {:.3}, {:.3})", c.0, x[0], x[1], x[2])
})
};
if invalid > 0 {
if !invalid.is_empty() {
return Err(Error::Failed(format!(
"{invalid} of {} elements are inverted or degenerate after projection (smallest scaled Jacobian {min_sj:.3}, {}): the blocks do not fit the part — add an O-grid or a template where a block corner lands on a curved face",
"{} of {} elements are inverted, twisted or degenerate after projection (certified Jacobian; smallest corner scaled Jacobian {min_sj:.3}): {}. The blocks do not fit the part there — add an O-grid or a template where a block corner lands on a curved face, or split the blocks so the faces meeting at that junction are not squeezed",
invalid.len(),
lat.hexes.len(),
worst()
where_invalid(t, &lat, &nodes, &invalid)
)));
}
if min_sj < opts.min_quality {
@@ -595,6 +596,36 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
Ok(ProjectedMesh { nodes, hexes: lat.hexes, boundary, report })
}
/// Most invalid elements an error names.
const NAMED_INVALID: usize = 4;
/// Where the invalid elements `invalid` are: per element its block, cell and centre, and when it
/// touches a block vertex (a junction), the vertex and the blocks that meet there.
fn where_invalid(t: &Topology, lat: &lattice::Lattice, nodes: &[P], invalid: &[usize]) -> String {
let mut parts: Vec<String> = Vec::new();
for &e in invalid.iter().take(NAMED_INVALID) {
let (Some(h), Some((b, cell))) = (lat.hexes.get(e), lat.hex_cell.get(e)) else { continue };
let x = scale(h.iter().fold([0.0; 3], |a, &v| add(a, nodes.get(v as usize).copied().unwrap_or_default())), 0.125);
let mut s = format!("block {b} cell {cell:?} near ({:.3}, {:.3}, {:.3})", x[0], x[1], x[2]);
let vertices: BTreeSet<u32> = h
.iter()
.filter_map(|&v| match lat.entity.get(v as usize) {
Some(Entity::Vertex(k)) => Some(*k),
_ => None,
})
.collect();
for k in vertices {
let around: Vec<String> = t.blocks.iter().enumerate().filter(|(_, blk)| blk.contains(&k)).map(|(i, _)| i.to_string()).collect();
s.push_str(&format!(" at block vertex {k} where blocks {} meet", around.join(", ")));
}
parts.push(s);
}
if invalid.len() > NAMED_INVALID {
parts.push(format!("and {} more", invalid.len() - NAMED_INVALID));
}
parts.join("; ")
}
/// The polyline's points redistributed evenly by arc length (ends kept).
fn even(line: &[P]) -> Vec<P> {
let m = line.len().saturating_sub(1);
+32
View File
@@ -193,6 +193,38 @@ fn o_grid_keeps_neighbours_and_opens_faces() {
t.check().unwrap();
}
/// A split runs through the whole sheet of blocks: here a 2×2 grid split across its first
/// direction, and the reversed neighbour counts its fractions from the shared end.
#[test]
fn split_cuts_the_whole_sheet_and_stays_conforming() {
let mut t = Topology::grid([&[0.0, 1.0, 2.0], &[0.0, 1.0, 2.0], &[0.0, 1.0]]).unwrap();
// Block 1 relabelled so its direction 0 runs the other way (still right-handed).
t.blocks[1] = [t.blocks[1][2], t.blocks[1][3], t.blocks[1][0], t.blocks[1][1], t.blocks[1][6], t.blocks[1][7], t.blocks[1][4], t.blocks[1][5]];
t.check().unwrap();
// Splitting block 0 along Y cuts block 1 (its X neighbour shares the Y edges) but not 2 or 3.
let made = t.split(0, 1, &[0.25]).unwrap();
assert_eq!(made.len(), 2);
assert_eq!(t.blocks.len(), 6);
t.check().unwrap();
let uses = lattice::face_uses(&t).unwrap();
assert!(uses.values().all(|&u| u <= 2));
// Every new vertex is a quarter of the way up in Y; the cut faces are shared.
assert!(t.vertices.iter().any(|v| (v[1] - 0.25).abs() < 1e-12));
assert!(t.vertices.iter().all(|v| [0.0, 0.25, 1.0, 2.0].iter().any(|y| (v[1] - y).abs() < 1e-12)));
// Splitting along X goes through the whole grid's X sheet: four more blocks.
let made = t.split(2, 0, &[1.0 / 3.0, 2.0 / 3.0]).unwrap();
assert_eq!(t.blocks.len(), 6 + 3 * 2);
assert!(!made.is_empty());
t.check().unwrap();
assert!(lattice::face_uses(&t).unwrap().values().all(|&u| u <= 2));
let mut u = t.clone();
for bad in [vec![], vec![0.0], vec![0.5, 0.4], vec![1.2], vec![f64::NAN]] {
assert!(u.split(0, 0, &bad).is_err(), "{bad:?}");
}
assert!(u.split(0, 3, &[0.5]).is_err() && u.split(99, 0, &[0.5]).is_err());
assert_eq!(u, t);
}
#[test]
fn hostile_topologies_are_errors() {
let (s, g) = sphere(1.0, 32);
+142
View File
@@ -31,6 +31,17 @@ pub(crate) fn face_number(d: usize, s: usize) -> u8 {
FACE_SIDES.iter().position(|x| *x == (d, s)).map_or(1, |i| i as u8 + 1)
}
/// The four edges of a block along direction `d`, each from its side-0 corner to its side-1 corner.
fn edges_along(block: &[u32; 8], d: usize) -> [[u32; 2]; 4] {
let mut out = [[0; 2]; 4];
for (slot, (k, bits)) in out.iter_mut().zip(CORNERS.iter().enumerate().filter(|(_, bits)| bits[d] == 0)) {
let mut e = *bits;
e[d] = 1;
*slot = [block[k], block[corner_at(e)]];
}
out
}
/// Blocks over shared vertices.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Topology {
@@ -322,6 +333,24 @@ impl Topology {
Ok(best.1)
}
/// The direction of block `b` (0 to 2) most nearly along `dir`, and whether it runs against it.
pub fn direction_along(&self, b: usize, dir: P) -> Result<(usize, bool)> {
let c = self.corners(b)?;
let mut best = (f64::NEG_INFINITY, 0, false);
for d in 0..3 {
let mut v = [0.0; 3];
for [p, q] in edges_along(&[0, 1, 2, 3, 4, 5, 6, 7], d) {
v = add(v, sub(c[q as usize], c[p as usize]));
}
let l = crate::vec3::norm(v);
let cos = if l > 0.0 { crate::vec3::dot(v, dir) / l } else { 0.0 };
if cos.abs() > best.0 {
best = (cos.abs(), d, cos < 0.0);
}
}
Ok((best.1, best.2))
}
/// The corners of a block face in cyclic order.
fn face_quad(&self, b: usize, f: u8) -> Result<[u32; 4]> {
let (d, s) = FACE_SIDES.get(usize::from(f).wrapping_sub(1)).copied().ok_or_else(|| Error::Invalid(format!("face {f} is not a hex8 face")))?;
@@ -391,6 +420,119 @@ impl Topology {
Ok(made)
}
/// Split block `b` along its direction `d` (0 to 2) at the fractions `at` (increasing, between
/// 0 and 1, measured from its side-0 face), and with it the whole sheet of blocks the cut runs
/// through: every block holding an edge parallel to the cut edges (chained through shared
/// edges) is split the same way, so the topology stays conforming. Each new vertex lies on its
/// edge at the fraction, counted from the end `b`'s side-0 face reaches through the chain. A
/// split block keeps its index for its first slab (from its own side 0); the other slabs are
/// appended. Returns the appended blocks.
pub fn split(&mut self, b: usize, d: usize, at: &[f64]) -> Result<Vec<usize>> {
if d > 2 {
return Err(Error::Invalid(format!("a block has directions 0 to 2, not {d}")));
}
if at.is_empty() || at.len() > 1000 || !at.iter().all(|t| *t > 0.0 && *t < 1.0) || at.windows(2).any(|w| !(w[1] > w[0])) {
return Err(Error::Invalid(format!("split fractions must be increasing numbers between 0 and 1, got {at:?}")));
}
let seed = self.blocks.get(b).copied().ok_or_else(|| Error::Invalid(format!("there is no block {b}")))?;
let key = |[p, q]: [u32; 2]| [p.min(q), p.max(q)];
// The sheet: each cut edge (ascending key) with the vertex its fractions count from, and
// each block in it with its cut direction, grown through an edge → blocks index.
let mut by_edge: BTreeMap<[u32; 2], Vec<usize>> = BTreeMap::new();
for (i, block) in self.blocks.iter().enumerate() {
for dd in 0..3 {
for e in edges_along(block, dd) {
by_edge.entry(key(e)).or_default().push(i);
}
}
}
let mut start: BTreeMap<[u32; 2], u32> = BTreeMap::new();
for e in edges_along(&seed, d) {
start.insert(key(e), e[0]);
}
let mut cut: BTreeMap<usize, usize> = BTreeMap::from([(b, d)]);
let mut queue = vec![(b, d)];
while let Some((i, dd)) = queue.pop() {
let block = self.blocks.get(i).copied().unwrap_or([0; 8]);
let along = edges_along(&block, dd);
// The block runs the sheet's way, or against it: read off an edge already in the
// sheet (the one it was reached through), and orient its other edges to match.
let against = along.iter().find_map(|e| start.get(&key(*e)).map(|s| *s != e[0])).unwrap_or(false);
for e in along {
let k = key(e);
start.entry(k).or_insert(if against { e[1] } else { e[0] });
for &n in by_edge.get(&k).map_or(&[][..], Vec::as_slice) {
if cut.contains_key(&n) {
continue;
}
let nb = self.blocks.get(n).copied().unwrap_or([0; 8]);
if let Some(nd) = (0..3).find(|&x| edges_along(&nb, x).iter().any(|f| key(*f) == k)) {
cut.insert(n, nd);
queue.push((n, nd));
}
}
}
}
// Each cut block's four edges must all count from its side-0 face or all from its side-1
// face; a sheet that comes back on itself reversed cannot take uneven fractions.
let mut flipped: BTreeMap<usize, bool> = BTreeMap::new();
for (&i, &dd) in &cut {
let block = self.blocks.get(i).copied().unwrap_or([0; 8]);
let ways = edges_along(&block, dd).map(|e| start.get(&key(e)) != Some(&e[0]));
if ways.iter().any(|w| *w != ways[0]) {
return Err(Error::Invalid(format!(
"block {i} would be cut from both ends: the sheet through block {b} comes back on itself reversed; split another block"
)));
}
flipped.insert(i, ways[0]);
}
if self.blocks.len() + cut.len() * at.len() > MAX_BLOCKS {
return Err(Error::Limit(format!("the split makes more than {MAX_BLOCKS} blocks")));
}
// The vertices along each cut edge, from its start.
let mut rows: BTreeMap<[u32; 2], Vec<u32>> = BTreeMap::new();
for (k, &s) in &start {
let e = if k[0] == s { k[1] } else { k[0] };
let (Some(ps), Some(pe)) = (self.vertices.get(s as usize).copied(), self.vertices.get(e as usize).copied()) else {
return Err(Error::Invalid(format!("vertex {s} or {e} does not exist")));
};
let mut row = vec![s];
for &t in at {
row.push(self.vertices.len() as u32);
self.vertices.push(crate::vec3::lerp(ps, pe, t));
}
row.push(e);
rows.insert(*k, row);
}
let n = at.len() + 1;
let mut made = Vec::new();
for (&i, &dd) in &cut {
let block = self.blocks.get(i).copied().unwrap_or([0; 8]);
let rev = flipped.get(&i).copied().unwrap_or(false);
// Slab `j`, counted from the block's own side 0.
let slab = |j: usize| -> [u32; 8] {
let mut out = block;
for (k, bits) in CORNERS.iter().enumerate() {
let (mut low, mut high) = (*bits, *bits);
low[dd] = 0;
high[dd] = 1;
let row = rows.get(&key([block[corner_at(low)], block[corner_at(high)]])).map_or(&[][..], Vec::as_slice);
let local = j + bits[dd];
out[k] = row.get(if rev { n - local } else { local }).copied().unwrap_or(block[k]);
}
out
};
if let Some(first) = self.blocks.get_mut(i) {
*first = slab(0);
}
for j in 1..n {
made.push(self.blocks.len());
self.blocks.push(slab(j));
}
}
Ok(made)
}
/// Remove blocks (the others keep their order and are renumbered).
pub fn remove(&mut self, blocks: &[usize]) -> Result<()> {
if let Some(b) = blocks.iter().find(|b| **b >= self.blocks.len()) {
+8
View File
@@ -119,6 +119,14 @@ handles, curved caps), lay a block topology over the part and project it (`mesh.
ops, and close loops with `connect`. Inner corners of a stretched topology follow the
projection, but keep the initial blocks roughly where the geometry is (offsets measured from
the block faces, not from the surface).
- **Footprint margins**: a stub's footprint must sit in the O-gridded block face with roughly even
margins all round. A 6 mm footprint in a 30 mm wide petal face with 2 mm above and below leaves
ring corners near 170° that twist. Split first (before the O-grid),
`{"op": "split", "block", "direction": "y", "at": [0.36, 0.64]}`, so the side margins match the
levels' margins; the split runs through the whole sheet of blocks, so preview again for the
new indices.
- A twisted-element error names the block, cell and the block vertex (junction) with the blocks
meeting there: fix the topology at that junction (split, move a level, change the inset).
- Use `"preview": true` to list block indices before meshing; faces can be given as `"+x"` … `"-z"`.
- Targets default to the smooth group of B-rep faces each outer block face faces; set them with
`faces: [{block, face, to}]` when a block face is near two surfaces.