///|
/// SocketGeometry is the resolved geometry of a `SocketedSolid` pocket.
pub(all) struct SocketGeometry {
top_z : Double
top_radius : Double
nominal_radius : Double
top_hex_radius : Double
mid_z : Double
floor_z : Double
lead : Double
across_flats : Double
rotation_deg : Double
}
///|
/// SocketedSolid::new builds a revolved body with a blind hexagonal socket.
///
/// `across_flats_mm` is the socket width across flats. `socket_floor_z_mm` is
/// the absolute local height of the socket floor. `lead_in_mm` is the optional
/// parallel 45-degree break on the six socket flats.
pub fn SocketedSolid::new(
body : RevolvedProfile,
across_flats_mm : Double,
socket_floor_z_mm : Double,
lead_in_mm~ : Double = 0.0,
// Vertices at 0/60/... so flats face 30/90/... - the clocking used by the
// downloaded socket-head goldens.
rotation_deg~ : Double = 0.0,
// Conical pocket: a `rim_angle_deg` countersink at the mouth and a
// `floor_angle_deg` drill point whose apex is `socket_floor_z_mm`. The hex
// walls are then trimmed by both cones (hyperbola edges with pcurves),
// matching the detailed ISO 4762 references.
conical~ : Bool = false,
rim_angle_deg~ : Double = 45.0,
floor_angle_deg~ : Double = 70.0,
) -> SocketedSolid {
{
body,
across_flats_mm,
socket_floor_z_mm,
lead_in_mm,
rotation_deg,
conical,
rim_angle_deg,
floor_angle_deg,
}
}
///|
/// SocketedSolid::body_segments returns the revolved body profile chain so
/// callers and tests can verify the underlying solid of revolution.
pub fn SocketedSolid::body_segments(self : SocketedSolid) -> Array[RevSegment] {
self.body.segments
}
///|
/// SocketedSolid::as_shape wraps the intent as a `Shape`.
pub fn SocketedSolid::as_shape(self : SocketedSolid) -> Shape {
Shape::SocketedSolid(self)
}
///|
fn socket_clamp01(v : Double) -> Double {
if v < 0.0 {
0.0
} else if v > 1.0 {
1.0
} else {
v
}
}
///|
/// socketed_geometry resolves the body's flat top and the socket dimensions.
///
/// The body must be a canonical closed-on-axis `RevolvedProfile` (positive
/// signed area) whose last segment is the radial flat top cap ending on the
/// axis. Every shipped `SocketedSolid` is built that way.
fn socketed_geometry(s : SocketedSolid) -> SocketGeometry? {
if s.conical {
return None
}
match s.body.validate() {
Err(_) => return None
Ok(_) => ()
}
let segs = s.body.segments
let n = segs.length()
if n < 2 || rev_profile_signed_area(segs) <= 0.0 {
return None
}
let (top_radius, top_z) = match segs[n - 1] {
RevLine(a, b) =>
if b.0.abs() > CAD_LENGTH_EPS ||
(a.1 - b.1).abs() > CAD_LENGTH_EPS ||
a.0 <= 0.0 {
return None
} else {
(a.0, b.1)
}
RevArc(_, _, _) => return None
}
let af = s.across_flats_mm
let depth = top_z - s.socket_floor_z_mm
if af <= 0.0 || depth <= 0.0 {
return None
}
let nominal = af / Double::sqrt(3.0)
let lead = if s.lead_in_mm > 0.0 {
let cap = depth / 2.0
if s.lead_in_mm < cap {
s.lead_in_mm
} else {
cap
}
} else {
0.0
}
let mid_z = top_z - lead
let top_hex = nominal + lead / @math.cos(@math.PI / 6.0)
if top_hex >= top_radius - CAD_LENGTH_EPS {
return None
}
Some({
top_z,
top_radius,
nominal_radius: nominal,
top_hex_radius: top_hex,
mid_z,
floor_z: s.socket_floor_z_mm,
lead,
across_flats: af,
rotation_deg: s.rotation_deg,
})
}
///|
/// socketed_pocket_area returns the exact pocket cross-section area at `z`.
fn socketed_pocket_area(g : SocketGeometry, z : Double) -> Double {
if z < g.floor_z - CAD_DEGENERATE_EPS || z > g.top_z + CAD_DEGENERATE_EPS {
return 0.0
}
let lead_span = g.top_z - g.mid_z
let t = if lead_span > CAD_DEGENERATE_EPS {
socket_clamp01((z - g.mid_z) / lead_span)
} else {
0.0
}
let af_z = g.across_flats + 2.0 * g.lead * t
Double::sqrt(3.0) / 2.0 * af_z * af_z
}
///|
/// socketed_pocket_volume_centroid integrates the pocket exactly. The area is
/// linear in the frustum scale and therefore quadratic in `z`; Simpson's rule
/// is exact for the volume and (up to cubic) for the first moment.
fn socketed_pocket_volume_centroid(g : SocketGeometry) -> (Double, Double) {
let mut volume = 0.0
let mut moment = 0.0
let add_span = fn(z0 : Double, z1 : Double) -> Unit {
if z1 <= z0 + CAD_DEGENERATE_EPS {
return
}
let zm = (z0 + z1) / 2.0
let a0 = socketed_pocket_area(g, z0)
let am = socketed_pocket_area(g, zm)
let a1 = socketed_pocket_area(g, z1)
volume = volume + (z1 - z0) / 6.0 * (a0 + 4.0 * am + a1)
moment = moment + (z1 - z0) / 6.0 * (z0 * a0 + 4.0 * zm * am + z1 * a1)
}
add_span(g.floor_z, g.mid_z)
add_span(g.mid_z, g.top_z)
if volume <= CAD_DEGENERATE_EPS {
(0.0, 0.0)
} else {
(volume, moment / volume)
}
}
///|
/// SocketedSolid::volume returns the exact solid volume in the local frame.
pub fn SocketedSolid::volume(self : SocketedSolid) -> Double {
let (body_volume, _zc) = an_rev_volume_centroid(self.body)
if self.conical {
let v = match conical_socket_geometry(self) {
Some(g) => {
let (void_volume, _vz) = conical_socket_volume_centroid(g)
body_volume - void_volume
}
None => 0.0
}
return if v < 0.0 { 0.0 } else { v }
}
match socketed_geometry(self) {
Some(g) => {
let (void_volume, _vz) = socketed_pocket_volume_centroid(g)
let v = body_volume - void_volume
if v < 0.0 {
0.0
} else {
v
}
}
None => 0.0
}
}
///|
/// SocketedSolid::centroid returns the exact centroid in the local frame.
pub fn SocketedSolid::centroid(self : SocketedSolid) -> Vec3 {
let (body_volume, body_z) = an_rev_volume_centroid(self.body)
if self.conical {
match conical_socket_geometry(self) {
Some(g) => {
let (void_volume, void_z) = conical_socket_volume_centroid(g)
let v = body_volume - void_volume
if v <= CAD_DEGENERATE_EPS {
(0.0, 0.0, 0.0)
} else {
(0.0, 0.0, (body_volume * body_z - void_volume * void_z) / v)
}
}
None => (0.0, 0.0, 0.0)
}
} else {
match socketed_geometry(self) {
Some(g) => {
let (void_volume, void_z) = socketed_pocket_volume_centroid(g)
let v = body_volume - void_volume
if v <= CAD_DEGENERATE_EPS {
(0.0, 0.0, 0.0)
} else {
(0.0, 0.0, (body_volume * body_z - void_volume * void_z) / v)
}
}
None => (0.0, 0.0, 0.0)
}
}
}
///|
/// socketed_band resolves one revolved profile segment into a `RevBandInfo`.
fn socketed_band(seg : RevSegment) -> RevBandInfo {
match seg {
RevLine(a, b) => {
let info : RevBandInfo = {
a,
b,
center: None,
sample: ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0),
normal: rev_line_normal(a, b),
radial: (a.1 - b.1).abs() < CAD_LENGTH_EPS,
axial: (a.0 - b.0).abs() < CAD_LENGTH_EPS,
}
info
}
RevArc(a, b, c) => {
let (sample, normal) = rev_arc_sample_normal(a, b, c)
let info : RevBandInfo = {
a,
b,
center: Some(c),
sample,
normal,
radial: false,
axial: false,
}
info
}
}
}
///|
/// compile_socketed_solid emits one closed solid for a revolved body with a
/// blind hexagonal socket cut into its flat top.
///
/// Every revolved band except the flat top cap is compiled by the analytic
/// revolve compiler, so the body's cylinders, cones, spheres, and tori stay
/// analytic. The top cap is replaced by a planar annular face (outer circle,
/// inner socket rim), six planar socket walls, an optional ruled lead-in, and a
/// planar hexagonal floor. Shared edges are traversed once per adjacent face in
/// opposite directions, so the result is a single watertight shell.
pub fn compile_socketed_solid(
ctx : StepContext,
node : SceneNode,
s : SocketedSolid,
reverse~ : Bool = false,
parent~ : Transform? = None,
) -> Array[@step.Ref] {
if s.conical {
return compile_conical_socketed_solid(ctx, node, s, reverse~, parent~)
}
let xform = node_transform(node, parent)
let g = match socketed_geometry(s) {
Some(v) => v
None => return []
}
let axis_dir = xform.apply_dir((0.0, 0.0, 1.0))
let ref_dir = xform.apply_dir((1.0, 0.0, 0.0))
let circles : Map[String, RevCircle] = Map([])
let faces : Array[@step.Ref] = []
// Every revolved band except the final flat top cap.
let segs = s.body.segments
let n = segs.length()
for i in 0..<(n - 1) {
let band = socketed_band(segs[i])
match
rev_compile_band(ctx, xform, band, axis_dir, ref_dir, circles, reverse~) {
Some(f) => faces.push(f)
None => ()
}
}
// The top circle was emitted (with reversed sense) by the last real band.
let top_circle = rev_get_circle(ctx, xform, g.top_radius, g.top_z, circles)
// Socket vertices: top ring, optional mid ring, floor ring.
let top_pts : Array[Vec3] = []
for k in 0..<6 {
let ang = (g.rotation_deg + 60.0 * Double::from_int(k)) * @math.PI / 180.0
top_pts.push(
xform.apply(
(
g.top_hex_radius * @math.cos(ang),
g.top_hex_radius * @math.sin(ang),
g.top_z,
),
),
)
}
let mid_pts : Array[Vec3] = []
for k in 0..<6 {
let ang = (g.rotation_deg + 60.0 * Double::from_int(k)) * @math.PI / 180.0
mid_pts.push(
xform.apply(
(
g.nominal_radius * @math.cos(ang),
g.nominal_radius * @math.sin(ang),
g.mid_z,
),
),
)
}
let floor_pts : Array[Vec3] = []
for k in 0..<6 {
let ang = (g.rotation_deg + 60.0 * Double::from_int(k)) * @math.PI / 180.0
floor_pts.push(
xform.apply(
(
g.nominal_radius * @math.cos(ang),
g.nominal_radius * @math.sin(ang),
g.floor_z,
),
),
)
}
let has_mid = g.lead > CAD_DEGENERATE_EPS
let pts : Array[Vec3] = []
pts.push_iter(top_pts.iter())
if has_mid {
pts.push_iter(mid_pts.iter())
}
pts.push_iter(floor_pts.iter())
let cp_refs = pts.map(fn(p) {
let (px, py, pz) = p
ctx.cartesian_point(px, py, pz)
})
let vp_refs = cp_refs.map(fn(cp) { ctx.vertex_point(cp) })
let edges : Array[EdgeInfo] = []
let seen : Map[String, Bool] = Map([])
let add_edge = fn(a : Int32, b : Int32) -> Unit {
let key = if a < b { "\{a}:\{b}" } else { "\{b}:\{a}" }
if seen.get(key).unwrap_or(false) {
return
}
seen.set(key, true)
let start = if a < b { a } else { b }
let end = if a < b { b } else { a }
let dir_raw = v_norm(v_sub(pts[end], pts[start]))
let dir_ref = ctx.direction(dir_raw.0, dir_raw.1, dir_raw.2)
let vec_ref = ctx.vector(dir_ref, 1.0)
let line_ref = ctx.line(cp_refs[start], vec_ref)
let ec_ref = ctx.edge_curve(vp_refs[start], vp_refs[end], line_ref, true)
edges.push(EdgeInfo::{ start, end, edge: ec_ref, })
}
// Index layout: top 0..5, optional mid 6..11, floor next.
let mid_base = if has_mid { 6 } else { 0 }
let floor_base = if has_mid { 12 } else { 6 }
for k in 0..<6 {
add_edge(k, (k + 1) % 6)
add_edge(floor_base + k, floor_base + (k + 1) % 6)
if has_mid {
add_edge(mid_base + k, mid_base + (k + 1) % 6)
add_edge(k, mid_base + k)
add_edge(mid_base + k, floor_base + k)
} else {
add_edge(k, floor_base + k)
}
}
// Top annulus: analytic outer circle, hexagonal inner rim. The outer circle
// is traversed in the direction opposite to the adjacent revolved band.
let outer_loop = ctx.edge_loop([ctx.oriented_edge(top_circle.edge, true)])
let inner_idx : Array[Int32] = []
for k in 0..<6 {
inner_idx.push(5 - k)
}
let inner_loop = face_loop(ctx, edges, inner_idx)
let top_origin = xform.apply((0.0, 0.0, g.top_z))
let top_origin_cp = ctx.cartesian_point(
top_origin.0,
top_origin.1,
top_origin.2,
)
faces.push(
add_plane_face_from_loops(
ctx,
top_origin_cp,
axis_dir,
ref_dir,
outer_loop,
[inner_loop],
reverse~,
),
)
// Lead-in quads (ruled 45-degree break) if requested.
if has_mid {
for k in 0..<6 {
let k2 = (k + 1) % 6
let cycle = [k, k2, mid_base + k2, mid_base + k]
let normal = v_norm(cycle_area_3d(pts, cycle))
faces.push(
add_plane_face_oriented(
ctx,
edges,
pts,
cp_refs,
cycle,
normal,
reverse~,
),
)
}
}
// Socket walls.
let wall_top_base = if has_mid { mid_base } else { 0 }
for k in 0..<6 {
let k2 = (k + 1) % 6
let cycle = [
wall_top_base + k,
wall_top_base + k2,
floor_base + k2,
floor_base + k,
]
let normal = v_norm(cycle_area_3d(pts, cycle))
faces.push(
add_plane_face_oriented(ctx, edges, pts, cp_refs, cycle, normal, reverse~),
)
}
// Hexagonal socket floor, normal +Z (up into the pocket).
let floor_cycle : Array[Int32] = []
for k in 0..<6 {
floor_cycle.push(floor_base + k)
}
faces.push(
add_plane_face_oriented(
ctx,
edges,
pts,
cp_refs,
floor_cycle,
axis_dir,
reverse~,
),
)
faces
}
///|
/// socketed_solid_mesh builds a visualization mesh of the revolved body with
/// the blind socket. It samples the socket boundary at the same angular steps
/// as the body so the annulus and walls close without T-junctions.
pub fn socketed_solid_mesh(
s : SocketedSolid,
angular_segments~ : Int32 = 48,
arc_segments~ : Int32 = 12,
) -> (Array[Vec3], Array[Array[Int32]]) {
if s.conical {
// Visualization approximation: a flat hex floor at the drill corners. The
// STEP exporter and the analysis volume use the true cones.
match conical_socket_geometry(s) {
Some(g) => {
let flat = SocketedSolid::new(
s.body,
s.across_flats_mm,
g.floor_apex + g.r_corner / g.floor_tan,
rotation_deg=s.rotation_deg,
)
return socketed_solid_mesh(flat, angular_segments~, arc_segments~)
}
None => return ([], [])
}
}
let g = match socketed_geometry(s) {
Some(v) => v
None => return ([], [])
}
let k = if angular_segments < 8 { 8 } else { angular_segments }
// Body profile without the top centre point.
let profile = rev_profile_polyline(s.body, arc_segments~)
let m = profile.length()
let body_pts : Array[Vec2] = []
if m > 0 && profile[m - 1].0 < CAD_LENGTH_EPS {
for i in 0..<(m - 1) {
body_pts.push(profile[i])
}
} else {
body_pts.push_iter(profile.iter())
}
let mb = body_pts.length()
let verts : Array[Vec3] = []
let faces : Array[Array[Int32]] = []
// Revolve the body bands.
for j in 0.. Int32 { j * mb + i }
for j in 0.. Double {
let step = 2.0 * @math.PI / 6.0
let rot = g.rotation_deg * @math.PI / 180.0
let rel = th - rot
let sector = @math.floor(rel / step)
let loc = rel - sector * step
radius * @math.cos(step / 2.0) / @math.cos(loc - step / 2.0)
}
let top_base = verts.length()
for j in 0.. CAD_DEGENERATE_EPS {
let b = verts.length()
for j in 0..