///|
// Planar face discovery and sketch-on-face placement.
//
// After `Design::compile()` the repository holds a full B-Rep. This module
// walks `MANIFOLD_SOLID_BREP -> CLOSED_SHELL -> ADVANCED_FACE -> PLANE` and
// returns a `FaceInfo` for each planar face, including a right-handed frame
// (origin, u, v, normal) so a 2D profile can be mapped onto the face.
//
// This mirrors MakerCad's `Faces().AlignedWith(plane).SortByZ(...)` query
// vocabulary while keeping the kernel-free writer deterministic: a profile
// placed on a face is still *nominal* in the flat STEP backend. Use the
// OCCT-evaluated export (`write_step_evaluated`) or the Blender backend for a
// real trimmed cut.
///|
/// FaceInfo describes one planar B-Rep face in world coordinates.
///
/// `u`/`v`/`normal` are orthonormal with `u x v = normal`. `point` is the
/// plane placement's origin (a point on the face), and `boundary` holds the
/// outer loop's vertices in traversal order.
pub struct FaceInfo {
normal : Vec3
point : Vec3
area : Double
u : Vec3
v : Vec3
boundary : Array[Vec3]
face_id : Int32
surface_id : Int32
}
///|
/// FaceInfo::center returns the average of the outer loop vertices, a point
/// near the middle of the face's boundary.
pub fn FaceInfo::center(self : FaceInfo) -> Vec3 {
let n = self.boundary.length()
// A circular face's loop has fewer than three sampled vertices; its plane
// origin is the true center.
if n < 3 {
return self.point
}
let mut x = 0.0
let mut y = 0.0
let mut z = 0.0
for (px, py, pz) in self.boundary {
x = x + px
y = y + py
z = z + pz
}
let d = n.to_double()
(x / d, y / d, z / d)
}
///|
/// FaceInfo::local_xy maps a world point into the face frame's 2D coordinates.
pub fn FaceInfo::local_xy(self : FaceInfo, p : Vec3) -> Vec2 {
let d = v_sub(p, self.point)
(v_dot(d, self.u), v_dot(d, self.v))
}
///|
fn neg3(v : Vec3) -> Vec3 {
let (x, y, z) = v
(-x, -y, -z)
}
///|
fn face_cart_point(repo : @repository.Repository, r : @step.Ref) -> Vec3? {
match repo.get(r.id) {
Some(CartesianPoint(p)) =>
if p.coordinates.length() >= 3 {
Some((p.coordinates[0], p.coordinates[1], p.coordinates[2]))
} else {
None
}
_ => None
}
}
///|
fn face_direction(repo : @repository.Repository, r : @step.Ref) -> Vec3? {
match repo.get(r.id) {
Some(Direction(d)) =>
if d.ratios.length() >= 3 {
Some((d.ratios[0], d.ratios[1], d.ratios[2]))
} else {
None
}
_ => None
}
}
///|
fn face_loop_area(pts : Array[Vec3]) -> Double {
let n = pts.length()
if n < 3 {
return 0.0
}
let mut ax = 0.0
let mut ay = 0.0
let mut az = 0.0
for i in 0.. (Array[Vec3], Double?) {
let pts : Array[Vec3] = []
let mut circle_radius : Double? = None
let mut edge_count = 0
if face.bounds.length() == 0 {
return (pts, None)
}
let loop_ref = match repo.get(face.bounds[0].id) {
Some(FaceOuterBound(b)) => Some(b.bound)
Some(FaceBound(b)) => Some(b.bound)
_ => None
}
match loop_ref {
None => ()
Some(lr) =>
match repo.get(lr.id) {
Some(EdgeLoop(lp)) =>
for oe_ref in lp.edges {
match repo.get(oe_ref.id) {
Some(OrientedEdge(oe)) =>
match repo.get(oe.edge_element.id) {
Some(EdgeCurve(ec)) => {
edge_count = edge_count + 1
match repo.get(ec.edge_geometry.id) {
Some(Circle(c)) =>
circle_radius = Some(parse_num(c.radius_literal))
_ => ()
}
let vref = if oe.orientation {
ec.edge_start
} else {
ec.edge_end
}
match repo.get(vref.id) {
Some(VertexPoint(vp)) =>
match face_cart_point(repo, vp.vertex_geometry) {
Some(p) => pts.push(p)
None => ()
}
_ => ()
}
}
_ => ()
}
_ => ()
}
}
_ => ()
}
}
// Only a loop made of exactly one circular edge is a true disk.
let radius = if edge_count == 1 { circle_radius } else { None }
(pts, radius)
}
///|
/// planar_face_info reads one `ADVANCED_FACE`. Returns `None` for non-planar surfaces
/// (cylindrical, conical, B-spline) and for malformed references.
fn planar_face_info(
repo : @repository.Repository,
face_ref : @step.Ref,
) -> FaceInfo? {
let af = match repo.get(face_ref.id) {
Some(AdvancedFace(af)) => af
_ => return None
}
let plane = match repo.get(af.surface.id) {
Some(Plane(p)) => p
_ => return None
}
let a2p = match repo.get(plane.position.id) {
Some(Axis2Placement3D(a)) => a
_ => return None
}
let origin = match face_cart_point(repo, a2p.location) {
Some(p) => p
None => return None
}
let axis = match face_direction(repo, a2p.axis) {
Some(d) => v_norm(d)
None => return None
}
let ref_direction = match face_direction(repo, a2p.ref_direction) {
Some(d) => d
None => return None
}
// `same_sense` says whether the face normal agrees with the plane normal.
let normal = if af.same_sense { axis } else { neg3(axis) }
let u = v_norm(ref_direction)
let v = v_norm(v_cross(normal, u))
let (boundary, radius) = face_loop_boundary(repo, af)
let mut area = face_loop_area(boundary)
if area < 1.0e-9 {
match radius {
Some(r) => area = @math.PI * r * r
None => ()
}
}
Some({
normal,
point: origin,
area,
u,
v,
boundary,
face_id: face_ref.id.value,
surface_id: af.surface.id.value,
})
}
///|
/// planar_faces_of returns every planar face of every `MANIFOLD_SOLID_BREP` in
/// a compiled repository.
pub fn planar_faces_of(repo : @repository.Repository) -> Array[FaceInfo] {
let out : Array[FaceInfo] = []
repo.each(fn(entity) {
match entity {
ManifoldSolidBrep(brep) =>
match repo.get(brep.outer.id) {
Some(ClosedShell(shell)) =>
for face_ref in shell.faces {
match planar_face_info(repo, face_ref) {
Some(fi) => out.push(fi)
None => ()
}
}
_ => ()
}
_ => ()
}
})
out
}
///|
/// FaceSet is a small queryable collection returned by
/// `Design::planar_faces`.
pub struct FaceSet {
faces : Array[FaceInfo]
}
///|
pub fn FaceSet::length(self : FaceSet) -> Int32 {
self.faces.length()
}
///|
pub fn FaceSet::is_empty(self : FaceSet) -> Bool {
self.faces.is_empty()
}
///|
pub fn FaceSet::to_array(self : FaceSet) -> Array[FaceInfo] {
self.faces
}
///|
/// FaceSet::aligned_with keeps faces whose normal points along `dir`.
///
/// `dir` need not be normalised. `tol` is a cosine tolerance: a face is kept
/// when `dot(normal, dir) >= 1 - tol` (default 1e-6, i.e. exact alignment).
pub fn FaceSet::aligned_with(
self : FaceSet,
dir : Vec3,
tol~ : Double = 1.0e-6,
) -> FaceSet {
let d = v_norm(dir)
{ faces: self.faces.filter(fn(f) { v_dot(f.normal, d) >= 1.0 - tol }), }
}
///|
/// FaceSet::sort_by_z orders faces by the z coordinate of their plane origin.
pub fn FaceSet::sort_by_z(self : FaceSet, ascending~ : Bool = true) -> FaceSet {
let faces = self.faces.copy()
if ascending {
faces.sort_by(fn(a, b) { a.point.2.compare(b.point.2) })
} else {
faces.sort_by(fn(a, b) { b.point.2.compare(a.point.2) })
}
{ faces, }
}
///|
/// FaceSet::first_matching returns the first face satisfying `pred`.
pub fn FaceSet::first_matching(
self : FaceSet,
pred : (FaceInfo) -> Bool,
) -> FaceInfo? {
for f in self.faces {
if pred(f) {
return Some(f)
}
}
None
}
///|
/// Design::planar_faces compiles the design and queries its planar faces.
///
/// The returned faces are in world coordinates (node placements already
/// applied). Boolean nodes are compiled nominally, so query the faces of a
/// plain solid rather than of a `difference`.
pub fn Design::planar_faces(self : Design) -> FaceSet {
{ faces: planar_faces_of(self.compile()), }
}
///|
/// face_frame_matrix returns the rotation whose columns are the face's
/// u, v, and normal axes.
fn face_frame_matrix(face : FaceInfo) -> Matrix3x3 {
let (ux, uy, uz) = face.u
let (vx, vy, vz) = face.v
let (nx, ny, nz) = face.normal
{
m00: ux,
m01: vx,
m02: nx,
m10: uy,
m11: vy,
m12: ny,
m20: uz,
m21: vz,
m22: nz,
}
}
///|
/// Profile2D::on_face places the profile on `face` and extrudes it `height_mm`
/// along the face normal.
///
/// With `into=false` the extrusion starts at `face.point` and grows outward.
/// With `into=true` it ends at the face and grows inward, which makes a cutter
/// for `difference`. `offset` shifts the placement along the normal, and
/// `center` translates the profile within the face's `u`/`v` frame.
pub fn Profile2D::on_face(
self : Profile2D,
face : FaceInfo,
height_mm : Double,
into~ : Bool = false,
offset~ : Double = 0.0,
center~ : Vec2 = (0.0, 0.0),
name~ : String = "on-face",
color~ : Rgb = Rgb::orange(),
) -> SceneNode {
let (rx, ry, rz) = matrix_to_euler(face_frame_matrix(face))
let (nx, ny, nz) = face.normal
let shift = if into { offset - height_mm } else { offset }
let (ox, oy, oz) = face.point
let (cx, cy) = center
let profile = if cx == 0.0 && cy == 0.0 {
self
} else {
{
outer: translate_pts(self.outer, cx, cy),
holes: self.holes.map(fn(h) { translate_pts(h, cx, cy) }),
}
}
{
shape: Shape::ExtrudedProfile({ profile, height_mm, round_bore: None, }),
name,
color,
tx: ox + nx * shift,
ty: oy + ny * shift,
tz: oz + nz * shift,
rx,
ry,
rz,
}
}
///|
/// ExtrudedProfile::on_face places an existing extruded profile on a face.
pub fn ExtrudedProfile::on_face(
self : ExtrudedProfile,
face : FaceInfo,
into~ : Bool = false,
offset~ : Double = 0.0,
center~ : Vec2 = (0.0, 0.0),
name~ : String = "on-face",
color~ : Rgb = Rgb::orange(),
) -> SceneNode {
self.profile.on_face(
face,
self.height_mm,
into~,
offset~,
center~,
name~,
color~,
)
}
///|
/// Design::add_profile_on_face adds a profile extruded on `face` as a node.
///
/// In the flat STEP backend this is a new solid placed relative to the face;
/// combine it with `difference()` for a nominal cut, or export with
/// `write_step_evaluated` for real trimming.
pub fn Design::add_profile_on_face(
self : Design,
profile : Profile2D,
face : FaceInfo,
height_mm : Double,
into~ : Bool = false,
offset~ : Double = 0.0,
center~ : Vec2 = (0.0, 0.0),
name~ : String = "on-face",
color~ : Rgb = Rgb::orange(),
) -> Design {
self.add(
profile.on_face(face, height_mm, into~, offset~, center~, name~, color~),
)
}
///|
/// cut_on_face builds a `difference` that cuts `profile` `depth` into `base`
/// from `face`.
///
/// The cutter is extended by `overshoot` past the face so the evaluated
/// boolean does not leave a skin. STEP export remains nominal unless the
/// design is evaluated with OCCT (`write_step_evaluated`) or Blender.
pub fn cut_on_face(
base : SceneNode,
profile : Profile2D,
face : FaceInfo,
depth : Double,
name~ : String = "cut-on-face",
overshoot~ : Double = 0.5,
center~ : Vec2 = (0.0, 0.0),
color~ : Rgb = Rgb::grey(),
) -> SceneNode {
let cutter = profile.on_face(
face,
depth + overshoot,
into=true,
offset=overshoot,
center~,
name=name + "-cutter",
color~,
)
difference(base, [cutter], name~)
}