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());