///|
pub fn Profile2D::new(outer : Array[Vec2]) -> Profile2D {
{ outer, holes: [], }
}
///|
pub fn Profile2D::add_hole(self : Profile2D, hole : Array[Vec2]) -> Profile2D {
let holes = self.holes.copy()
holes.push(hole)
{ ..self, holes, }
}
///|
pub fn Profile2D::add_holes_at(
self : Profile2D,
locs : Array[Vec2],
hole_profile : Array[Vec2],
) -> Profile2D {
let holes = self.holes.copy()
for loc in locs {
let (lx, ly) = loc
holes.push(translate_pts(hole_profile, lx, ly).rev())
}
{ ..self, holes, }
}
///|
pub fn translate_pts(pts : Array[Vec2], x : Double, y : Double) -> Array[Vec2] {
pts.map(fn(p) { (p.0 + x, p.1 + y) })
}
///|
pub fn SceneNode::repeat_at(
self : SceneNode,
locs : Array[Vec3],
) -> Array[SceneNode] {
locs.map(fn(loc) {
let (lx, ly, lz) = loc
self.translate(x=lx, y=ly, z=lz)
})
}
///|
pub fn Cuboid::new(sx : Double, sy : Double, sz : Double) -> Shape {
Shape::Cuboid({ sx, sy, sz, })
}
///|
/// Cylinder::new returns a solid right circular cylinder with analytic STEP
/// geometry (a `CYLINDRICAL_SURFACE` plus two circular `PLANE` caps), centred
/// on the local Z axis with its base at Z = 0.
///
/// Unlike `tube()`, which emits a faceted polyhedron, this emits a three-face
/// B-Rep that downstream consumers recognise as a true cylinder.
pub fn Cylinder::new(diameter : Double, height : Double) -> Shape {
Shape::Cylinder({ diameter, height, })
}
///|
/// Cuboid::from_lengths is `Cuboid::new` with typed dimensions. Mixing a
/// `Length` with a bare `Double` is a compile-time error.
pub fn Cuboid::from_lengths(lx~ : Length, ly~ : Length, lz~ : Length) -> Shape {
Cuboid::new(lx.to_mm(), ly.to_mm(), lz.to_mm())
}
///|
pub fn CalibrationChamferBlock::new(
length_mm : Double,
width_mm : Double,
height_mm : Double,
chamfer_size_mm~ : Double = 1.0,
fillet_radius_mm~ : Double = 0.0,
fillet_segments~ : Int32 = 8,
) -> Shape {
Shape::CalibrationChamferBlock({
length_mm,
width_mm,
height_mm,
chamfer_size_mm,
fillet_radius_mm,
fillet_segments,
})
}
///|
pub fn Profile2D::rect(width : Double, height : Double) -> Array[Vec2] {
[(0.0, 0.0), (width, 0.0), (width, height), (0.0, height)]
}
///|
pub fn Profile2D::centered_rect(width : Double, height : Double) -> Array[Vec2] {
let hw = width / 2.0
let hh = height / 2.0
[(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)]
}
///|
pub fn Profile2D::rounded_rect(
width : Double,
height : Double,
radius : Double,
segments~ : Int32 = 32,
) -> Array[Vec2] {
if radius <= 0.0 {
return Profile2D::rect(width, height)
}
let r = if radius > width / 2.0 {
width / 2.0
} else if radius > height / 2.0 {
height / 2.0
} else {
radius
}
let n = if segments < 4 { 4 } else { segments }
let qn = n / 4
let pts = []
pts.push_iter(
Profile2D::arc(2.0 * r, 1.5 * @math.PI, 2.0 * @math.PI, segments=qn)
.iter()
.map(fn(p : Vec2) { (p.0 + width - r, p.1 + r) }),
)
// Top-right arc: 0 to 90 degrees
pts.push_iter(
Profile2D::arc(2.0 * r, 0.0, 0.5 * @math.PI, segments=qn)
.iter()
.map(fn(p : Vec2) { (p.0 + width - r, p.1 + height - r) }),
)
// Top-left arc: 90 to 180 degrees
pts.push_iter(
Profile2D::arc(2.0 * r, 0.5 * @math.PI, @math.PI, segments=qn)
.iter()
.map(fn(p : Vec2) { (p.0 + r, p.1 + height - r) }),
)
// Bottom-left arc: 180 to 270 degrees
pts.push_iter(
Profile2D::arc(2.0 * r, @math.PI, 1.5 * @math.PI, segments=qn)
.iter()
.map(fn(p : Vec2) { (p.0 + r, p.1 + r) }),
)
pts
}
///|
/// Profile2D::polygon returns a regular polygon with the given number of sides,
/// diameter, and optional rotation in degrees.
pub fn Profile2D::polygon(
sides : Int32,
diameter : Double,
rotation~ : Double = 0.0,
) -> Array[Vec2] {
let n = if sides < 3 { 3 } else { sides }
let radius = diameter / 2.0
let angle_step = 2.0 * @math.PI / Double::from_int(n)
let rotation_rad = rotation * @math.PI / 180.0
let out = []
for i in 0.. Shape {
let outer = Profile2D::circle(outer_diameter, segments~)
if inner_diameter <= 0.0 {
ExtrudedProfile::new(outer, height)
} else {
let hole = Profile2D::circle(inner_diameter, segments~).rev()
ExtrudedProfile::new(outer, height, holes=[hole])
}
}
///|
/// tube_with_fit is `tube()` with the bore's clearance captured as a `Fit`.
/// Only the void grows: the outer diameter is unchanged. Like `tube()`, it
/// emits a faceted extrusion; use `Cylinder` for analytic geometry.
pub fn tube_with_fit(
outer_diameter : Double,
inner_diameter : Double,
height : Double,
fit~ : Fit = Nominal,
segments~ : Int32 = 64,
) -> Shape {
let outer = Profile2D::circle(outer_diameter, segments~)
if inner_diameter <= 0.0 {
ExtrudedProfile::new(outer, height)
} else {
let hole = Profile2D::hole_with_fit(inner_diameter, fit~, segments~)
ExtrudedProfile::new(outer, height, holes=[hole])
}
}
///|
/// text_at returns an array of SceneNodes representing the given text
/// rendered at the specified position and orientation.
///
/// Note that the 'draw' package represents all glyphs as type `CompoundPath`
/// where it explicitly labels each included `Path` as either `clear=true`
/// (meaning that this represents a cut-out or hole like the two holes in
/// the "8") or `clear=false` which would represent the outer path, for
/// example. Some glyphs (like "%") have multiple `clear=true` AND
/// `clear=false` `Path`s due to detached geometry. Note that WINDING
/// ORDER IS NOT GUARANTEED in the 'draw' package! So the 'cad' package
/// in THIS repo should understand this critical information, and build
/// proper STEP representations of these extruded `CompoundPath`s.
pub fn text_at(
text : String,
x : Double,
y : Double,
z : Double,
font~ : @draw.Font,
font_size~ : Double = 5.0,
max_width~ : Double = 1000.0,
emboss_depth~ : Double = 0.5,
rx~ : Double = 0.0,
ry~ : Double = 0.0,
rz~ : Double = 0.0,
segments~ : Int32 = 0, // <=0: retain cubic Beziers, >0: convert to polylines with this many segments
) -> Array[SceneNode] {
if segments <= 0 {
return text_at_bezier(
text,
x,
y,
z,
font~,
font_size~,
max_width~,
emboss_depth~,
rx~,
ry~,
rz~,
)
}
let fs = font_size
let mw = max_width
let bbox = @draw.bbox(-mw / 2.0, -fs / 2.0, mw / 2.0, fs / 2.0)
let text_graphic = @draw.to_graphic(font, text, alignment=Center, y_up=true).scale_to_fit(
bbox,
anchor=Center,
) catch {
_ => return []
}
let all_paths = text_graphic.all_paths_and_compound_paths()
let nodes = []
for g in all_paths {
let profiles = collect_profiles(g, segments)
for profile in profiles {
let (outer, holes) = profile
if outer.length() < 3 {
continue
}
let solid = ExtrudedProfile::new(outer, emboss_depth, holes~)
.with_name("text")
.with_color(Rgb::orange())
.rotate_x(rx)
.rotate_y(ry)
.rotate_z(rz)
.translate(x~, y~, z~)
nodes.push(solid)
}
}
nodes
}
///|
/// text_at_bezier returns text nodes with native cubic Bezier curves preserved.
fn text_at_bezier(
text : String,
x : Double,
y : Double,
z : Double,
font~ : @draw.Font,
font_size~ : Double = 5.0,
max_width~ : Double = 1000.0,
emboss_depth~ : Double = 0.5,
rx~ : Double = 0.0,
ry~ : Double = 0.0,
rz~ : Double = 0.0,
) -> Array[SceneNode] {
let fs = font_size
let mw = max_width
let bbox = @draw.bbox(-mw / 2.0, -fs / 2.0, mw / 2.0, fs / 2.0)
let text_graphic = @draw.to_graphic(font, text, alignment=Center, y_up=true).scale_to_fit(
bbox,
anchor=Center,
) catch {
_ => return []
}
let all_paths = text_graphic.all_paths_and_compound_paths()
let nodes = []
for g in all_paths {
let bezier_paths = collect_bezier_paths(g)
// Emit one solid per disconnected glyph component so each closed shell
// belongs to exactly one MANIFOLD_SOLID_BREP.
for group in bezier_path_groups(bezier_paths) {
let solid = ExtrudedBezierProfile::new(group, emboss_depth)
.with_name("text")
.with_color(Rgb::orange())
.rotate_x(rx)
.rotate_y(ry)
.rotate_z(rz)
.translate(x~, y~, z~)
nodes.push(solid)
}
}
nodes
}
///|
/// Convert a draw.Anchor to a cad.BezierAnchor with absolute handle positions.
fn anchor_to_bezier_anchor(anchor : @draw.Anchor) -> BezierAnchor {
let pos = anchor.position
// Handles in @draw are relative offsets, convert to absolute positions
let handle_in = (pos.x + anchor.handle_in.x, pos.y + anchor.handle_in.y)
let handle_out = (pos.x + anchor.handle_out.x, pos.y + anchor.handle_out.y)
{ position: (pos.x, pos.y), handle_in, handle_out, }
}
///|
/// Convert a draw.Path to a cad.BezierPath.
fn path_to_bezier_path(path : @draw.Path) -> BezierPath {
let anchors = path.anchors.map(anchor_to_bezier_anchor)
{ anchors, closed: path.closed, clear: path.clear, }
}
///|
/// Collect all BezierPaths from a Graphic, preserving the native cubic Bezier curves.
fn collect_bezier_paths(g : @draw.Graphic) -> Array[BezierPath] {
match g {
CompoundPath(cp) => cp.paths.map(path_to_bezier_path)
Group(group) => {
let results = []
for item in group.items {
results.push_iter(collect_bezier_paths(item).iter())
}
results
}
Path(p) => [path_to_bezier_path(p)]
}
}
///|
pub fn ExtrudedBezierProfile::new(
paths : Array[BezierPath],
height_mm : Double,
) -> Shape {
Shape::ExtrudedBezierProfile({ paths, height_mm, })
}
///|
fn collect_profiles(
g : @draw.Graphic,
segments : Int32,
) -> Array[(Array[Vec2], Array[Array[Vec2]])] {
match g {
CompoundPath(cp) => {
let outers = []
let holes = []
for p in cp.paths {
let pts_tuples = @draw.Graphic::Path(p).to_profiles_tuples(segments)
for pt_tuple in pts_tuples {
let (poly, _) = pt_tuple
if p.clear {
holes.push(poly)
} else {
outers.push(poly)
}
}
}
let results = []
for outer in outers {
let ccw_outer = force_ccw(outer)
let my_holes = []
for hole in holes {
if hole.length() > 0 && is_point_in_polygon(hole[0], ccw_outer) {
my_holes.push(force_cw(hole))
}
}
results.push((ccw_outer, my_holes))
}
results
}
Group(group) => {
let results = []
for item in group.items {
results.push_iter(collect_profiles(item, segments).iter())
}
results
}
Path(p) => {
let pts_tuples = @draw.Graphic::Path(p).to_profiles_tuples(segments)
pts_tuples.map(fn(pt_tuple) {
let (poly, _) = pt_tuple
(force_ccw(poly), [])
})
}
}
}
///|
pub fn Profile2D::ellipse(
width : Double,
height : Double,
segments~ : Int32 = 64,
) -> Array[Vec2] {
let rx = width / 2.0
let ry = height / 2.0
let n = if segments < 3 { 3 } else { segments }
let angle_step = 2.0 * @math.PI / Double::from_int(n)
let out = []
for i in 0.. Array[Vec2] {
let radius = diameter / 2.0
let n = if segments < 3 { 3 } else { segments }
let angle_step = 2.0 * @math.PI / Double::from_int(n)
let out = []
for i in 0.. Array[Vec2] {
let radius = diameter / 2.0
let n = if segments < 2 { 2 } else { segments }
let angle_step = (end_angle - start_angle) / Double::from_int(n)
let out = []
for i in 0..<=n {
let phi = start_angle + Double::from_int(i) * angle_step
out.push((radius * @math.cos(phi), radius * @math.sin(phi)))
}
out
}
///|
pub fn ExtrudedProfile::new(
outer : Array[Vec2],
height_mm : Double,
holes~ : Array[Array[Vec2]] = [],
round_bore? : (Vec2, Double),
) -> Shape {
Shape::ExtrudedProfile({ profile: { outer, holes, }, height_mm, round_bore, })
}
///|
/// union_all returns a new SceneNode representing the grouping of an array of nodes.
///
/// Note that this does not currently perform a formal STEP "union" (which is a CSG
/// operation that many CAD engines struggle to translate), but instead groups
/// the solids together in the same design.
pub fn union_all(
nodes : Array[SceneNode],
name~ : String = "union",
) -> SceneNode {
Shape::Union({ nodes, }).with_name(name)
}
///|
/// difference returns a new SceneNode representing a boolean difference
/// between a base node and a set of cutter nodes.
pub fn difference(
base : SceneNode,
cutters : Array[SceneNode],
name~ : String = "difference",
) -> SceneNode {
Shape::Difference({ base, cutters, }).with_name(name)
}
///|
/// fillet_edges records the intent to round the selected edges of `base` with
/// a circular fillet of `radius_mm`.
///
/// The flat STEP writer cannot trim edges and refuses this node with an error
/// diagnostic. Apply it through `Design::to_step_evaluated` (OCCT) or the
/// Blender backend. A sharp edge is never emitted and called a fillet.
pub fn fillet_edges(
base : SceneNode,
radius_mm : Double,
selector~ : EdgeSelector = AllEdges,
name~ : String = "fillet",
) -> SceneNode {
Shape::Fillet({ base, selector, size_mm: radius_mm, angle_deg: None, }).with_name(
name,
)
}
///|
/// chamfer_edges records the intent to bevel the selected edges of `base` with
/// a chamfer of `size_mm` (and an optional `angle_deg`).
///
/// The flat STEP writer cannot trim edges and refuses this node with an error
/// diagnostic. Apply it through `Design::to_step_evaluated` (OCCT) or the
/// Blender backend.
pub fn chamfer_edges(
base : SceneNode,
size_mm : Double,
angle_deg? : Double,
selector~ : EdgeSelector = AllEdges,
name~ : String = "chamfer",
) -> SceneNode {
Shape::Chamfer({ base, selector, size_mm, angle_deg, }).with_name(name)
}
///|
pub fn Shape::with_name(self : Shape, name : String) -> SceneNode {
SceneNode::{
shape: self,
name,
color: Rgb::orange(),
tx: 0.0,
ty: 0.0,
tz: 0.0,
rx: 0.0,
ry: 0.0,
rz: 0.0,
}
}
///|
pub fn Shape::with_color(self : Shape, color : Rgb) -> SceneNode {
self.with_name("unnamed").with_color(color)
}
///|
pub fn SceneNode::with_color(self : SceneNode, color : Rgb) -> SceneNode {
{ ..self, color, }
}
///|
pub fn SceneNode::with_name(self : SceneNode, name : String) -> SceneNode {
{ ..self, name, }
}
///|
pub fn SceneNode::translate(
self : SceneNode,
x~ : Double = 0.0,
y~ : Double = 0.0,
z~ : Double = 0.0,
) -> SceneNode {
{ ..self, tx: self.tx + x, ty: self.ty + y, tz: self.tz + z, }
}
///|
pub fn SceneNode::rotate_x(self : SceneNode, angle : Double) -> SceneNode {
{ ..self, rx: self.rx + angle, }
}
///|
pub fn SceneNode::rotate_y(self : SceneNode, angle : Double) -> SceneNode {
{ ..self, ry: self.ry + angle, }
}
///|
/// rotate_z adds the given angle in degrees to the SceneNode's rotation around
/// the Z-axis.
pub fn SceneNode::rotate_z(self : SceneNode, angle : Double) -> SceneNode {
{ ..self, rz: self.rz + angle, }
}
///|
/// add_text_on_face returns text nodes positioned on a face of the target.
///
/// With the default `segments=0` the glyphs are kept as native cubic Beziers
/// (`ExtrudedBezierProfile`); the STEP compiler flattens those curves when it
/// emits B-Rep entities, while the Blender exporter can still emit true Bezier
/// splines. A positive `segments` value converts the glyphs to polylines up
/// front with that many segments per curve span.
pub fn Design::add_text_on_face(
self : Design,
target : SceneNode,
text : String,
face : Face,
font : @draw.Font,
font_size? : Double,
emboss_depth? : Double,
offset_x~ : Double = 0.0,
offset_y~ : Double = 0.0,
rotation~ : Double = 0.0,
margin? : Double,
color~ : Rgb = Rgb::orange(),
emboss~ : Bool = true,
segments~ : Int32 = 0, // <=0: native cubic Beziers (flattened for STEP), >0: polylines with this many segments per span
) -> Design {
let (min_x, max_x, min_y, max_y, min_z, max_z) = target.shape.bounding_box()
let mid_x = (min_x + max_x) / 2.0
let mid_y = (min_y + max_y) / 2.0
let mid_z = (min_z + max_z) / 2.0
let dx = max_x - min_x
let dy = max_y - min_y
let dz = max_z - min_z
let m = match margin {
Some(v) => v
None => 2.0
}
// Local coordinates and rotations relative to shape origin (0,0,0)
// We subtract 0.01 from the "outward" direction to embed the text slightly
// and avoid Z-fighting.
let (lx, ly, lz, lrx, lry, lrz, fw, fh) = match face {
Top => {
let new_max_z = if emboss { max_z - 0.01 } else { max_z + 0.01 }
(mid_x + offset_x, mid_y + offset_y, new_max_z, 0.0, 0.0, 0.0, dx, dy)
}
Bottom => {
let new_min_z = if emboss { min_z + 0.01 } else { min_z - 0.01 }
(mid_x + offset_x, mid_y - offset_y, new_min_z, 180.0, 0.0, 180.0, dx, dy)
}
Front => {
let new_max_y = if emboss { max_y - 0.01 } else { max_y + 0.01 }
(mid_x + offset_x, new_max_y, mid_z + offset_y, 90.0, 0.0, 180.0, dx, dz)
}
Back => {
let new_min_y = if emboss { min_y + 0.01 } else { min_y - 0.01 }
(mid_x - offset_x, new_min_y, mid_z + offset_y, 90.0, 0.0, 0.0, dx, dz)
}
Left => {
let new_min_x = if emboss { min_x + 0.01 } else { min_x - 0.01 }
(new_min_x, mid_y + offset_x, mid_z + offset_y, 90.0, 0.0, -90.0, dy, dz)
}
Right => {
let new_max_x = if emboss { max_x - 0.01 } else { max_x + 0.01 }
(new_max_x, mid_y - offset_x, mid_z + offset_y, 90.0, 0.0, 90.0, dy, dz)
}
}
//
let fs = match font_size {
Some(v) => v
None => {
let mut s = fh - 2.0 * m
if s <= 0.0 {
s = 1.0
}
s
}
}
let mw = fw - 2.0 * m
let ed = emboss_depth.unwrap_or(0.5)
let (frx, fry, frz) = matrix_to_euler(
multiply_matrices(
rotation_matrix(lrx, lry, lrz),
rotation_matrix(0.0, 0.0, rotation),
),
)
let text_nodes = text_at(
text,
lx,
ly,
lz,
font~,
font_size=fs,
max_width=mw,
emboss_depth=ed,
rx=frx,
ry=fry,
rz=frz,
segments~,
)
let final_nodes = text_nodes.map(fn(n) {
let n = n.with_color(color)
// Apply target's own local-to-global transform
// We compose the rotations: R_target * R_local
let m_local = rotation_matrix(n.rx, n.ry, n.rz)
let m_target = rotation_matrix(target.rx, target.ry, target.rz)
let m_combined = multiply_matrices(m_target, m_local)
let (grx, gry, grz) = matrix_to_euler(m_combined)
let (gx, gy, gz) = rotate_point(
(n.tx, n.ty, n.tz),
target.rx,
target.ry,
target.rz,
)
{
..n,
tx: gx + target.tx,
ty: gy + target.ty,
tz: gz + target.tz,
rx: grx,
ry: gry,
rz: grz,
}
})
self.add_iter(final_nodes.iter())
}