Add signed distance over the ordered brush stream

Assembler::distance(p) and stream_distance fold the brush stream into a
scalar field (Add=min, Subtract=max(d,-b), Intersect=max). Exact for boxes,
oriented boxes and Z cylinders; surface-exact non-overestimating bounds for
domes and floret arms. Honours every op on every primitive, unlike the
polygon kernel's non-box cutter warnings. Seam for isosurface meshing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-25 16:53:51 +02:00
commit d84acb66be
2 changed files with 237 additions and 0 deletions

235
src/field.rs Normal file
View file

@ -0,0 +1,235 @@
//! Signed distance over the ordered brush stream.
//!
//! The polygon assembler lowers brushes to triangles; this lowers the same
//! brushes to a scalar field: negative inside, positive outside, zero on the
//! surface. Isosurface meshers and GPU field refiners consume it.
//!
//! The fold matches the stream's meaning: starting from empty space, `Add`
//! unions (`min`), `Subtract` removes (`max(d, -b)`), `Intersect` clips
//! (`max`). Unlike the polygon kernel, which currently warns and skips
//! non-convex cutters, the field honours every operation on every primitive.
//!
//! Boxes, oriented boxes and Z cylinders are exact distances. The dome is a
//! half-ellipsoid bound and the floret arm is a max-of-planes bound: both are
//! exact on the surface and never overestimate, which is what sign tests,
//! zero-crossing interpolation and Newton projection need.
use bevy_math::{Vec2, Vec3};
use crate::{Assembler, Brush, BrushOp, Primitive};
impl Primitive {
/// Signed distance from `p` to this primitive's solid.
pub fn distance(&self, p: Vec3) -> f32 {
match *self {
Self::Box { bounds } => box_distance(p - bounds.center(), bounds.size() * 0.5),
Self::OrientedBox {
center,
size,
rotation,
} => box_distance(rotation.inverse() * (p - center), size * 0.5),
Self::CylinderZ {
center,
radius,
depth,
..
} => {
let q = p - center;
let d = Vec2::new(q.truncate().length() - radius, q.z.abs() - depth * 0.5);
d.max(Vec2::ZERO).length() + d.x.max(d.y).min(0.0)
}
Self::DomeCapZ {
center,
radius,
height,
..
} => {
// Half-ellipsoid with radii (radius, radius, height) above its base plane.
let ellipsoid = ellipsoid_bound(p - center, Vec3::new(radius, radius, height));
ellipsoid.max(center.z - p.z)
}
Self::FloretArm {
anchor,
direction,
length,
root_width,
tip_width,
thickness,
tip_lift,
} => floret_distance(
p, anchor, direction, length, root_width, tip_width, thickness, tip_lift,
),
}
}
}
impl Brush {
/// Signed distance from `p` to this brush's solid, ignoring its operation.
pub fn distance(&self, p: Vec3) -> f32 {
self.primitive.distance(p)
}
}
/// Fold an ordered brush stream into one signed distance at `p`.
pub fn stream_distance<'a>(brushes: impl IntoIterator<Item = &'a Brush>, p: Vec3) -> f32 {
brushes.into_iter().fold(f32::INFINITY, |d, brush| {
let b = brush.distance(p);
match brush.op {
BrushOp::Add => d.min(b),
BrushOp::Subtract => d.max(-b),
BrushOp::Intersect => d.max(b),
}
})
}
impl Assembler {
/// Signed distance from `p` to the solid this assembler's brush stream describes.
pub fn distance(&self, p: Vec3) -> f32 {
stream_distance(self.brushes(), p)
}
}
fn box_distance(q: Vec3, half: Vec3) -> f32 {
let d = q.abs() - half;
d.max(Vec3::ZERO).length() + d.max_element().min(0.0)
}
/// Standard ellipsoid distance bound (Quilez): exact on the surface, never an overestimate.
fn ellipsoid_bound(q: Vec3, radii: Vec3) -> f32 {
let k0 = (q / radii).length();
let k1 = (q / (radii * radii)).length();
if k1 == 0.0 {
return -radii.min_element();
}
k0 * (k0 - 1.0) / k1
}
#[allow(clippy::too_many_arguments)]
fn floret_distance(
p: Vec3,
anchor: Vec3,
direction: Vec3,
length: f32,
root_width: f32,
tip_width: f32,
thickness: f32,
tip_lift: f32,
) -> f32 {
// Same frame as the mesh and bounds builders in primitives.rs and brush.rs.
let forward = direction.normalize_or_zero();
let forward = if forward.length_squared() == 0.0 { Vec3::X } else { forward };
let mut side = forward.cross(Vec3::Z).normalize_or_zero();
if side.length_squared() == 0.0 {
side = Vec3::Y;
}
let tip = anchor + forward * length + Vec3::Z * tip_lift;
let z = Vec3::Z * (thickness * 0.5);
let r = side * (root_width * 0.5);
let t = side * (tip_width * 0.5);
let pts = [
anchor - r - z, anchor + r - z, anchor - r + z, anchor + r + z,
tip - t - z, tip + t - z, tip - t + z, tip + t + z,
];
let centroid = pts.iter().copied().sum::<Vec3>() / 8.0;
// bottom, top, -side, +side, root, tip
const FACES: [[usize; 3]; 6] = [[0, 1, 4], [2, 3, 6], [0, 2, 4], [1, 3, 5], [0, 1, 2], [4, 5, 6]];
FACES
.iter()
.map(|&[a, b, c]| {
let mut n = (pts[b] - pts[a]).cross(pts[c] - pts[a]).normalize_or_zero();
if n.dot(centroid - pts[a]) > 0.0 {
n = -n; // face normals point out of the solid
}
n.dot(p - pts[a])
})
.fold(f32::NEG_INFINITY, f32::max)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Aabb, MaterialId};
use bevy_math::Quat;
const EPS: f32 = 1e-5;
fn unit_box() -> Primitive {
Primitive::Box { bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)) }
}
#[test]
fn box_distance_is_exact_inside_on_faces_edges_and_corners() {
let b = unit_box();
assert!((b.distance(Vec3::ZERO) + 1.0).abs() < EPS);
assert!((b.distance(Vec3::new(3.0, 0.0, 0.0)) - 2.0).abs() < EPS);
assert!((b.distance(Vec3::new(2.0, 2.0, 0.0)) - 2f32.sqrt()).abs() < EPS);
assert!((b.distance(Vec3::splat(2.0)) - 3f32.sqrt()).abs() < EPS);
assert!(b.distance(Vec3::new(1.0, 0.3, -0.2)).abs() < EPS);
}
#[test]
fn oriented_box_rotates_the_query_not_the_answer() {
let rotation = Quat::from_rotation_z(std::f32::consts::FRAC_PI_4);
let b = Primitive::OrientedBox { center: Vec3::ONE, size: Vec3::splat(2.0), rotation };
let along_local_x = Vec3::ONE + rotation * Vec3::new(3.0, 0.0, 0.0);
assert!((b.distance(along_local_x) - 2.0).abs() < EPS);
// A world-axis point that the unrotated box would contain lies outside this one.
assert!(b.distance(Vec3::ONE + Vec3::new(0.95, 0.95, 0.0)) > 0.0);
}
#[test]
fn cylinder_distance_is_exact_radially_and_on_caps() {
let c = Primitive::CylinderZ { center: Vec3::ZERO, radius: 1.0, depth: 2.0, segments: 16 };
assert!((c.distance(Vec3::new(3.0, 0.0, 0.0)) - 2.0).abs() < EPS);
assert!((c.distance(Vec3::new(0.0, 0.0, 4.0)) - 3.0).abs() < EPS);
assert!((c.distance(Vec3::ZERO) + 1.0).abs() < EPS);
}
#[test]
fn dome_is_zero_at_apex_and_rim_and_solid_only_above_its_base() {
let d = Primitive::DomeCapZ { center: Vec3::ZERO, radius: 2.0, height: 1.0, rings: 4, segments: 8 };
assert!(d.distance(Vec3::new(0.0, 0.0, 1.0)).abs() < EPS);
assert!(d.distance(Vec3::new(2.0, 0.0, 0.0)).abs() < EPS);
assert!(d.distance(Vec3::new(0.0, 0.0, 0.5)) < 0.0);
assert!(d.distance(Vec3::new(0.0, 0.0, -0.5)) > 0.0);
}
#[test]
fn floret_contains_its_axis_and_excludes_beyond_the_tip() {
let f = Primitive::FloretArm {
anchor: Vec3::ZERO, direction: Vec3::X, length: 4.0, root_width: 1.0,
tip_width: 0.5, thickness: 0.4, tip_lift: 0.0,
};
assert!(f.distance(Vec3::new(2.0, 0.0, 0.0)) < 0.0);
assert!((f.distance(Vec3::new(2.0, 0.0, 0.2))).abs() < EPS); // top face
assert!(f.distance(Vec3::new(4.5, 0.0, 0.0)) > 0.0);
assert!(f.distance(Vec3::new(3.9, 0.3, 0.0)) > 0.0); // tapered past the tip half-width
}
#[test]
fn stream_folds_add_subtract_intersect_in_order() {
let mut asm = Assembler::new();
assert_eq!(asm.distance(Vec3::ZERO), f32::INFINITY);
asm.solid_box("slab", Aabb::from_center_size(Vec3::ZERO, Vec3::new(4.0, 4.0, 2.0)), MaterialId(1));
asm.cut_box("hole", Aabb::from_center_size(Vec3::ZERO, Vec3::splat(1.0)));
assert!((asm.distance(Vec3::ZERO) - 0.5).abs() < EPS); // inside the hole: 0.5 to its wall
assert!(asm.distance(Vec3::new(1.5, 0.0, 0.0)) < 0.0);
asm.add_brush(
"clip",
BrushOp::Intersect,
Primitive::Box { bounds: Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(4.0)) },
MaterialId(1),
);
assert!(asm.distance(Vec3::new(-1.5, 0.0, 0.0)) > 0.0); // clipped away
assert!(asm.distance(Vec3::new(1.5, 0.0, 0.0)) < 0.0); // kept
}
#[test]
fn field_agrees_with_the_sample_room_door() {
let room = Assembler::sample_room_with_door();
assert!(room.distance(Vec3::new(0.0, 4.0, 2.5)) < 0.0, "wall above the door is solid");
assert!(room.distance(Vec3::new(0.0, 4.0, 1.0)) > 0.0, "door void is empty");
assert!(room.distance(Vec3::new(2.0, 4.0, 1.0)) < 0.0, "wall beside the door is solid");
assert!(room.distance(Vec3::new(0.0, 0.0, -0.1)) < 0.0, "floor is solid");
}
}

View file

@ -13,6 +13,7 @@ mod convex;
mod cult_geometry;
mod domain;
mod dsl;
mod field;
mod frontier;
mod mesh;
mod primitives;
@ -37,6 +38,7 @@ pub use domain::{
ragnarok_column_fixture, ragnarok_column_spec, select_domain_cut,
};
pub use dsl::LevelDsl;
pub use field::stream_distance;
pub use frontier::{DemandFrontier, DemandPair, DirtyDemandFrontier};
pub use mesh::TriangleMesh;
pub use primitives::{