///|
// Builder-style CAD helpers.
//
// The goal is a pleasant authoring UX for examples: build a model using a few
// high-level calls, then serialize to a valid STEP Part 21 file.
///|
/// Repository URL included in generated STEP headers.
let step_repo_url : String = "https://github.com/gmlewis/moonbit-step"
///|
/// Notice appended to the STEP file description when a design contains a
/// nominal boolean (one whose faces are concatenated rather than trimmed).
let nominal_boolean_notice : String = "NOMINAL BOOLEANS: difference()/union_all() concatenate faces; they do not trim or subtract."
///|
pub struct Design {
nodes : Array[SceneNode]
next_solid_id : Int32
name : String
description : String
/// Self-documentation emitted as a comment block in the STEP header.
documentation : String
snapping : Double?
}
///|
pub fn Design::new(
name~ : String = "model",
description~ : String = "generated by gmlewis/step/cad",
documentation~ : String = "",
snapping? : Double,
) -> Design {
Design::{
nodes: [],
next_solid_id: 1,
name,
description,
documentation,
snapping,
}
}
///|
/// with_documentation sets the self-documentation block written into the STEP
/// header (see `@cli.Args::documentation`).
pub fn Design::with_documentation(
self : Design,
documentation : String,
) -> Design {
{ ..self, documentation, }
}
///|
///|
/// add_styled_item writes one STEP presentation style for `item_ref`. It may
/// target a whole solid (`MANIFOLD_SOLID_BREP`) or a single face
/// (`ADVANCED_FACE`).
fn add_styled_item(
ctx : StepContext,
name : String,
color : Rgb,
item_ref : @step.Ref,
) -> @step.Ref {
let rgb = @presentation.ColorRgb::new(name, color.r, color.g, color.b)
let rgb_ref = ctx.add(@repository.any_entity_color_rgb(rgb))
let fas_col = @presentation.FillAreaStyleColor::new("", rgb_ref)
let fas_col_ref = ctx.add(
@repository.any_entity_fill_area_style_color(fas_col),
)
let fas = @presentation.FillAreaStyle::new("", [fas_col_ref])
let fas_ref = ctx.add(@repository.any_entity_fill_area_style(fas))
let ssfa = @presentation.SurfaceStyleFillArea::new(fas_ref)
let ssfa_ref = ctx.add(@repository.any_entity_surface_style_fill_area(ssfa))
let sss = @presentation.SurfaceSideStyle::new("", [ssfa_ref])
let sss_ref = ctx.add(@repository.any_entity_surface_side_style(sss))
let ssu = @presentation.SurfaceStyleUsage::new(".BOTH.", sss_ref)
let ssu_ref = ctx.add(@repository.any_entity_surface_style_usage(ssu))
let psa = @presentation.PresentationStyleAssignment::new([ssu_ref])
let psa_ref = ctx.add(
@repository.any_entity_presentation_style_assignment(psa),
)
let styled = @presentation.StyledItem::new("", [psa_ref], item_ref)
ctx.add(@repository.any_entity_styled_item(styled))
}
///|
fn compile_node_geometry_and_styling(
ctx : StepContext,
node : SceneNode,
solid_ref : @step.Ref,
faces~ : Array[@step.Ref] = [],
) -> Unit {
// Presentation styling for the whole solid.
let styled_ref = add_styled_item(ctx, node.name, node.color, solid_ref)
// A `RevolvedProfile` may carry per-band colors (for example a black shield
// groove on a grey bearing). Emit one face-level style for each colored band
// so the solid stays a single closed B-rep.
let face_styled : Array[@step.Ref] = []
match node.shape {
RevolvedProfile(rev) => {
let colors = rev.emission_colors()
let limit = if faces.length() < colors.length() {
faces.length()
} else {
colors.length()
}
for i in 0..
face_styled.push(
add_styled_item(ctx, node.name + "-face-\{i}", color, faces[i]),
)
None => ()
}
}
}
_ => ()
}
let layer = @presentation.PresentationLayerAssignment::new("1", "Layer 1", [
solid_ref,
])
let _ = ctx.add(@repository.any_entity_presentation_layer_assignment(layer))
let md = @presentation.MechanicalDesignGeometricPresentationRepresentation::new(
"",
[styled_ref, ..face_styled],
{ id: ctx.repo_ctx_id, },
)
let _ = ctx.add(
@repository.any_entity_mechanical_design_geometric_presentation_representation(
md,
),
)
}
///|
fn add_node_to_ctx(
ctx : StepContext,
node : SceneNode,
product_defs : Array[@step.Ref],
shape_reps : Array[@step.Ref],
parent~ : Transform? = None,
) -> Unit {
let (brep_ref, faces) = compile_node_to_brep_with_faces(ctx, node, parent~)
add_solid_node_to_ctx(
ctx,
node,
brep_ref,
product_defs,
shape_reps,
faces~,
is_csg=false,
)
}
///|
fn add_solid_node_to_ctx(
ctx : StepContext,
node : SceneNode,
solid_ref : @step.Ref,
product_defs : Array[@step.Ref],
shape_reps : Array[@step.Ref],
faces~ : Array[@step.Ref] = [],
is_csg~ : Bool,
) -> Unit {
compile_node_geometry_and_styling(ctx, node, solid_ref, faces~)
// Product structure for this part
let prod_def_ref = ctx.add_product_definition(node.name)
product_defs.push(prod_def_ref)
// Axis placement for the part
let origin = ctx.cartesian_point(node.tx, node.ty, node.tz)
let (dzx, dzy, dzz) = rotate_point((0.0, 0.0, 1.0), node.rx, node.ry, node.rz)
let (dxx, dxy, dxz) = rotate_point((1.0, 0.0, 0.0), node.rx, node.ry, node.rz)
let dz = ctx.direction(dzx, dzy, dzz)
let dx = ctx.direction(dxx, dxy, dxz)
let _a2p_ref = ctx.axis2_placement_3d(origin, dz, dx)
// Axis placement for the part
let origin = ctx.cartesian_point(node.tx, node.ty, node.tz)
let (dzx, dzy, dzz) = rotate_point((0.0, 0.0, 1.0), node.rx, node.ry, node.rz)
let (dxx, dxy, dxz) = rotate_point((1.0, 0.0, 0.0), node.rx, node.ry, node.rz)
let dz = ctx.direction(dzx, dzy, dzz)
let dx = ctx.direction(dxx, dxy, dxz)
let a2p_ref = ctx.axis2_placement_3d(origin, dz, dx)
let shape_rep = @product.ShapeRepresentation::new(
node.name + "-None",
[a2p_ref],
{ id: ctx.repo_ctx_id, },
)
let shape_rep_ref = ctx.add(
@repository.any_entity_shape_representation(shape_rep),
)
shape_reps.push(shape_rep_ref)
let specific_rep_ref = if is_csg {
let csg_rep = @product.CsgShapeRepresentation::new(
node.name + "-None",
[solid_ref],
{ id: ctx.repo_ctx_id, },
)
ctx.add(@repository.any_entity_csg_shape_representation(csg_rep))
} else {
let brep_rep = @product.AdvancedBrepShapeRepresentation::new(
node.name + "-None",
[solid_ref],
{ id: ctx.repo_ctx_id, },
)
ctx.add(@repository.any_entity_advanced_brep_shape_representation(brep_rep))
}
let rel = @product.ShapeRepresentationRelationship::new(
"None", "relationship", shape_rep_ref, specific_rep_ref,
)
let _ = ctx.add(@repository.any_entity_shape_representation_relationship(rel))
let pds = @product.ProductDefinitionShape::new("", "", prod_def_ref)
let pds_ref = ctx.add(@repository.any_entity_product_definition_shape(pds))
let sdr = @product.ShapeDefinitionRepresentation::new(pds_ref, shape_rep_ref)
let _ = ctx.add(@repository.any_entity_shape_definition_representation(sdr))
}
///|
fn compile_node_to_brep_with_faces(
ctx : StepContext,
node : SceneNode,
reverse~ : Bool = false,
parent~ : Transform? = None,
) -> (@step.Ref, Array[@step.Ref]) {
let faces = compile_node_shape(ctx, node, reverse~, parent~)
if faces.is_empty() {
abort("shape '\{node.name}' produced no faces")
}
let shell = @topology.ClosedShell::new("", faces)
let shell_ref = ctx.add(@repository.any_entity_closed_shell(shell))
let brep = @topology.ManifoldSolidBrep::new("", shell_ref)
let brep_ref = ctx.add(@repository.any_entity_manifold_solid_brep(brep))
(brep_ref, faces)
}
///|
fn compile_node_to_brep(
ctx : StepContext,
node : SceneNode,
reverse~ : Bool = false,
parent~ : Transform? = None,
) -> @step.Ref {
let (brep_ref, _faces) = compile_node_to_brep_with_faces(
ctx,
node,
reverse~,
parent~,
)
brep_ref
}
///|
pub fn Design::compile(self : Design) -> @repository.Repository {
let ctx = StepContext::new(snapping?=self.snapping)
ctx.init_standard_context(self.name)
let product_defs : Array[@step.Ref] = []
let shape_reps : Array[@step.Ref] = []
for node in self.nodes {
match node.shape {
Union(u) => {
if u.nodes.length() == 0 {
continue
}
let parent = Transform::from_node(node)
if u.nodes.length() == 1 {
add_node_to_ctx(
ctx,
{ ..u.nodes[0], name: node.name, },
product_defs,
shape_reps,
parent=Some(parent),
)
continue
}
let all_faces : Array[@step.Ref] = []
for child in u.nodes {
all_faces.push_iter(
compile_node_shape(ctx, child, parent=Some(parent)).iter(),
)
}
if all_faces.is_empty() {
abort("shape '\{node.name}' produced no faces")
}
let shell = @topology.ClosedShell::new("", all_faces)
let shell_ref = ctx.add(@repository.any_entity_closed_shell(shell))
let brep = @topology.ManifoldSolidBrep::new("", shell_ref)
let solid_ref = ctx.add(
@repository.any_entity_manifold_solid_brep(brep),
)
add_solid_node_to_ctx(
ctx,
node,
solid_ref,
product_defs,
shape_reps,
is_csg=false,
)
}
Difference(d) => {
let parent = Transform::from_node(node)
if d.cutters.length() == 0 {
add_node_to_ctx(
ctx,
{ ..d.base, name: node.name, },
product_defs,
shape_reps,
parent=Some(parent),
)
continue
}
let all_faces : Array[@step.Ref] = []
all_faces.push_iter(
compile_node_shape(ctx, d.base, parent=Some(parent)).iter(),
)
for cutter in d.cutters {
all_faces.push_iter(
compile_node_shape(ctx, cutter, reverse=true, parent=Some(parent)).iter(),
)
}
if all_faces.is_empty() {
abort("shape '\{node.name}' produced no faces")
}
let shell = @topology.ClosedShell::new("", all_faces)
let shell_ref = ctx.add(@repository.any_entity_closed_shell(shell))
let brep = @topology.ManifoldSolidBrep::new("", shell_ref)
let solid_ref = ctx.add(
@repository.any_entity_manifold_solid_brep(brep),
)
add_solid_node_to_ctx(
ctx,
node,
solid_ref,
product_defs,
shape_reps,
is_csg=false,
)
}
_ => add_node_to_ctx(ctx, node, product_defs, shape_reps)
}
}
// Set the header
let file_name = self.name + ".step"
let has_nominal_boolean = self.diagnostics().any(fn(d) { d.is_nominal() })
ctx.repo = ctx.repo.set_header(
default_header(
file_name,
self.description,
self.documentation,
nominal=has_nominal_boolean,
),
)
ctx.repo
}
///|
fn compile_node_shape(
ctx : StepContext,
node : SceneNode,
reverse~ : Bool = false,
parent~ : Transform? = None,
) -> Array[@step.Ref] {
match node.shape {
Cuboid(c) => compile_cuboid(ctx, node, c, reverse~, parent~)
Cylinder(c) => compile_cylinder(ctx, node, c, reverse~, parent~)
RevolvedProfile(r) =>
compile_revolved_profile(ctx, node, r, reverse~, parent~)
ChamferedPrism(p) =>
compile_chamfered_prism(ctx, node, p, reverse~, parent~)
SocketedSolid(s) => compile_socketed_solid(ctx, node, s, reverse~, parent~)
ConicalNut(n) => compile_conical_nut(ctx, node, n, reverse~, parent~)
AcornNut(n) => compile_acorn_nut(ctx, node, n, reverse~, parent~)
SquareNut(n) => compile_square_nut(ctx, node, n, reverse~, parent~)
SlitTube(t) => compile_slit_tube(ctx, node, t, reverse~, parent~)
CalibrationChamferBlock(b) =>
compile_calibration_chamfer_block(ctx, node, b, reverse~, parent~)
ExtrudedProfile(e) =>
compile_extruded_profile(ctx, node, e, reverse~, parent~)
ExtrudedArcProfile(e) =>
compile_extruded_arc_profile(ctx, node, e, reverse~, parent~)
SquareHoleWasher(w) =>
compile_square_hole_washer(ctx, node, w, reverse~, parent~)
TabWasher(w) => compile_tab_washer(ctx, node, w, reverse~, parent~)
HexHeadWasherFace(h) =>
compile_hex_head_pcurves(ctx, node, h, reverse~, parent~)
ExtrudedBezierProfile(e) =>
compile_extruded_bezier_profile(ctx, node, e, reverse~, parent~)
SweptProfile(s) => compile_swept_profile(ctx, node, s, reverse~, parent~)
Fillet(_) | Chamfer(_) =>
abort(
"edge feature '\{node.name}' requires an evaluated kernel: the flat STEP writer cannot trim edges. Export with Design::to_step_evaluated() (OCCT) or the Blender backend.",
)
Difference(_) => []
_ =>
// This should not be reached for Union shapes as they are expanded in Design::compile
[]
}
}
///|
/// Design::to_step_file compiles the design to ISO 10303-21 text.
///
/// Booleans are nominal: `difference()`/`union_all()` concatenate faces rather
/// than trimming or subtracting. Use `to_step_file_checked()` to refuse
/// designs with error diagnostics.
pub fn Design::to_step_file(self : Design) -> String {
self.compile().to_step_file()
}
///|
// Convenience alias for examples that want `println(design.to_string())`.
pub fn Design::to_string(self : Design) -> String {
self.to_step_file()
}
///|
/// Design::has_errors reports whether the design has any diagnostic that
/// blocks checked export (nested booleans, empty solids).
pub fn Design::has_errors(self : Design) -> Bool {
self.diagnostics().any(fn(d) { d.severity().is_error() })
}
///|
/// Design::to_step_file_checked compiles the design, aborting with the first
/// error diagnostic instead of emitting plausible-but-wrong geometry.
pub fn Design::to_step_file_checked(self : Design) -> String {
for d in self.diagnostics() {
if d.severity().is_error() {
abort(d.message())
}
}
self.to_step_file()
}
///|
/// Design::write_step_file writes the design as STEP. It is unchecked by
/// default (booleans stay nominal); pass `checked=true` to abort on error
/// diagnostics instead.
pub async fn Design::write_step_file(
self : Design,
out_path : String?,
checked~ : Bool = false,
) -> Unit {
let step = if checked {
self.to_step_file_checked()
} else {
self.to_step_file()
}
write_text_file(step, out_path)
}
///|
/// Convenience alias: `design.write_step(...)`. Unchecked by default; pass
/// `checked=true` to refuse error designs.
pub async fn Design::write_step(
self : Design,
out_path : String?,
checked~ : Bool = false,
) -> Unit {
self.write_step_file(out_path, checked~)
}
///|
pub fn Design::add(self : Design, node : SceneNode) -> Design {
let nodes = self.nodes.copy()
nodes.push(node)
Design::{ ..self, nodes, next_solid_id: self.next_solid_id + 1, }
}
///|
pub fn Design::add_iter(self : Design, nodes : Iter[SceneNode]) -> Design {
let mut d = self
for node in nodes {
d = d.add(node)
}
d
}
///|
pub fn Design::rotate_x(self : Design, angle : Double) -> Design {
let nodes = self.nodes.map(fn(node) {
let (tx, ty, tz) = rotate_point(
(node.tx, node.ty, node.tz),
angle,
0.0,
0.0,
)
let new_node = node.rotate_x(angle)
{ ..new_node, tx, ty, tz, }
})
{ ..self, nodes, }
}
///|
pub fn Design::rotate_y(self : Design, angle : Double) -> Design {
let nodes = self.nodes.map(fn(node) {
let (tx, ty, tz) = rotate_point(
(node.tx, node.ty, node.tz),
0.0,
angle,
0.0,
)
let new_node = node.rotate_y(angle)
{ ..new_node, tx, ty, tz, }
})
{ ..self, nodes, }
}
///|
pub fn Design::rotate_z(self : Design, angle : Double) -> Design {
let nodes = self.nodes.map(fn(node) {
let (tx, ty, tz) = rotate_point(
(node.tx, node.ty, node.tz),
0.0,
0.0,
angle,
)
let new_node = node.rotate_z(angle)
{ ..new_node, tx, ty, tz, }
})
{ ..self, nodes, }
}
///|
pub fn Design::translate(
self : Design,
x~ : Double = 0.0,
y~ : Double = 0.0,
z~ : Double = 0.0,
) -> Design {
let nodes = self.nodes.map(fn(node) { node.translate(x~, y~, z~) })
{ ..self, nodes, }
}
///|
/// Writes `text` to `out_path`, or to stdout when `out_path` is `None`.
///
/// Existing files are overwritten. I/O errors propagate, so callers (and the
/// process exit code) see a failed write instead of a stale output file.
pub async fn write_text_file(text : String, out_path : String?) -> Unit {
match out_path {
None => println(text)
Some(path) =>
@fs.write_file(
path,
@encoding/utf8.encode(text),
permission=0o644,
create_mode=@fs.CreateMode::CreateOrTruncate,
)
}
}
///|
fn default_header(
file_name : String,
description : String,
documentation : String,
nominal~ : Bool = false,
) -> @header.HeaderSection {
let timestamp = now_iso_timestamp()
let entities : Array[@header.HeaderEntity] = []
if documentation != "" {
entities.push(@header.HeaderEntity::Comment(documentation))
}
let description_strings = if nominal {
[description, nominal_boolean_notice]
} else {
[description]
}
entities.push(
@header.HeaderEntity::FileDescription(
@header.FileDescription::new(description_strings, "2;1"),
),
)
entities.push(
@header.HeaderEntity::FileName(
@header.FileName::new(
@header.StringOrDollar::Str(file_name),
@header.StringOrDollar::Str(timestamp),
@header.StringListOrDollar::List(["gmlewis"]),
@header.StringListOrDollar::List(["gmlewis/step", step_repo_url]),
@header.StringOrDollar::Str("gmlewis/step"),
@header.StringOrDollar::Str("moonbit"),
@header.StringOrDollar::Str("none"),
),
),
)
entities.push(
@header.HeaderEntity::FileSchema(
@header.FileSchema::new(["AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }"]),
),
)
@header.HeaderSection::new(entities)
}
///|
fn now_iso_timestamp() -> String {
let ms = @env.now()
let seconds = (ms / 1000UL).reinterpret_as_int64()
try
@time.PlainDateTime::from_unix_second(seconds, 0, @time.utc_offset)
catch {
_ => "2025-01-01T00:00:00"
} noraise {
dt => dt.to_string()
}
}