How Ecky Thinks
Ecky has three layers. Knowing their boundaries makes compiler and renderer errors easier to diagnose.
Surface language. You write parenthesized .ecky forms such as (model (part ...)). This syntax describes authoring intent; the renderer does not execute it directly.
Core IR. The compiler lowers surface forms into a fixed vocabulary of primitives, booleans, selectors, placements, repeats, and typed mesh operations. The kernel receives this finite data model, not arbitrary Scheme. That boundary makes models reproducible and statically checkable.
Geometry runtime. Exact solids render on the native OCCT B-rep kernel. Typed polygon data renders in the bounded Rust mesh runtime; a closed mesh crosses into OCCT only through the explicit faceted poly-BRep bridge. build123d and FreeCAD are supported interop backends with smaller operation sets.
Classify a failure before changing geometry: surface syntax, Core IR validation, or backend support. Diagnostics name that boundary whenever possible.
First Solid: Ball on a Base
A renderable file needs a model, a named part, and geometry. Start with one primitive so each added transform or boolean has an obvious effect.
(model
(part marker
(sphere 10)))
model is the root. part gives the geometry a stable id. sphere produces the solid.
Add another primitive with union when two solids should become one part.
(model
(part marker
(union
(box 28 28 4)
(translate 0 0 10
(sphere 10)))))
box makes the base. translate moves the ball up so it sits on the base instead of overlapping the center.
Use this pattern for first tests: primitive first, then one transform, then one boolean.
> Watch for: primitives start at the origin. Two untransformed solids overlap instead of stacking. If a union has the right members but the wrong silhouette, inspect placement before changing the boolean.
Sketch to Solid: Plate from a Profile
Many parts begin as a closed 2D region. extrude turns that region into a solid with a specified height.
(model
(part plate
(extrude
(rounded-rect 70 42 5)
4)))
rounded-rect is the closed 2D profile. extrude gives it thickness.
Use profile when the shape has holes.
(model
(part washer_plate
(extrude
(profile
:outer (rounded-rect 70 42 5)
:holes (circle 9 64))
4)))
The outer profile defines material. The hole profile removes material during the extrusion.
The modeling sequence stays explicit: define a region, then choose how it becomes three-dimensional.
Use offset to grow or shrink a 2D outline by a fixed distance before extruding. A positive distance pushes the outline outward.
(model
(part gasket
(extrude
(profile
:outer (offset 3 (rounded-rect 30 18 4))
:holes (rounded-rect 30 18 4))
4)))
offset 3 creates the outer boundary; the original outline becomes the hole. The resulting gasket has a uniform 3 mm wall.
scale stretches a profile by separate x, y, z factors. Scale a circle in one axis and it becomes an ellipse, so you reach for scale instead of a separate ellipse primitive.
(model
(part oval_plate
(extrude (scale 1.6 1 1 (circle 10 48)) 5)))
> Watch for: extrude only works on a _closed_ region. An open polyline or a profile whose :holes poke through the :outer edge has no well-defined inside, and the extrude fails or produces junk. Keep holes strictly inside the outer boundary, and reach for profile (not a raw shape) the moment material needs to be removed — the :outer/:holes split is what tells Ecky which side is solid.
Convenience Shapes: Stop Hand-Building Common Outlines
Use named convenience shapes when they express the design directly. They avoid repeated outline math and preserve analytic geometry where the backend supports it.
A torus is a ring: major radius to the tube centre, minor radius of the tube.
(model
(part ring
(torus 20 5)))
An ellipse is a 2D profile — give it the x and y radii, then extrude it like any sketch. When the y radius is larger, the long axis simply swings to y; you do not rotate anything yourself.
(model
(part oval
(extrude (ellipse 18 10) 4)))
A regular-polygon takes a side count and a circumradius (optionally :rotation).
(model
(part hex
(extrude (regular-polygon 6 12) 5)))
A trapezoid takes the bottom width, top width, and height; add :skew to slide the top sideways.
(model
(part wedge_plate
(extrude (trapezoid 40 24 18 :skew 4) 5)))
A wedge is the 3D ramp: a dx × dy × dz box whose top face shrinks to the rectangle xmin..xmax by zmin..zmax.
(model
(part ramp
(wedge 40 20 30 10 5 30 25)))
Slots
A slot is an obround — a rectangle capped by two semicircles. Four front-ends describe the same shape from whatever you happen to know.
slot-overall takes the tip-to-tip length and the width.
(model
(part track
(extrude (slot-overall 50 12) 4)))
slot-center-to-center takes the distance between the two end-arc centres and the width.
(model
(part track_c2c
(extrude (slot-center-to-center 38 12) 4)))
slot-center-point takes the slot centre (cx cy), the centre of one end arc (px py), and the width — handy when you already know where the holes go. It orients itself along the line between the two points.
(model
(part track_cp
(extrude (slot-center-point 0 0 30 0 12) 4)))
slot-arc curves the slot along a circular arc: centreline radius, start and end angle (degrees), and width.
(model
(part curved_track
(extrude (slot-arc 30 0 120 10) 4)))
> Watch for: the slot, ellipse, regular-polygon, and trapezoid examples here are 2D profiles — they need an extrude (or revolve) to become a solid. torus and wedge are already solids, so they stand alone.
Threads
thread builds a screw thread by sweeping a ridge along a helix around a core cylinder — you do not hand-build the helix. Give it a radius, pitch, length, and depth.
(model
(part screw
(thread :radius 6 :pitch 1.5 :length 18 :depth 0.9)))
For standard hardware, :iso "M…" decodes an ISO metric coarse-pitch designation into the radius, pitch, and depth for you — pass only the length.
(model
(part bolt
(thread :iso "M8" :length 20)))
:female #t makes the matching cutter instead of a solid screw. Subtract it from a bore to tap a hole; :clearance widens the envelope so the parts actually mate.
(model
(part nut
(difference
(cylinder 10 8)
(thread :iso "M8" :length 8 :female #t :clearance 0.2))))
:lefthand #t reverses the helix. Unknown ISO designations (e.g. "M7") fail with a clear error rather than guessing.
Parameters: Make the Plate Editable
Parameters separate design inputs from derived geometry. Declare editable dimensions once under params; the UI reads their metadata and the model reads their keys.
(model
(params
(number plate_w 70 :label "Plate width" :min 40 :max 120 :step 1)
(number plate_h 42 :label "Plate height" :min 20 :max 80 :step 1)
(number corner_r 5 :label "Corner radius" :min 0 :max 12 :step 0.5)
(number thickness 4 :label "Thickness" :min 1 :max 12 :step 0.5))
(part plate
(extrude
(rounded-rect plate_w plate_h corner_r)
thickness)))
The geometry reads the parameter names directly. The UI reads labels, min/max, and step from the declarations.
Keep parameters physical: widths, heights, clearances, radii. Put derived math near the geometry.
(shape hole_r (/ bore_d 2))That line is better than repeating (/ bore_d 2) through cuts and selectors.
Units: bare numbers already have one
Ecky uses millimeters for length and degrees for angles. Bare numbers already use those base units: (box 70 42 4) is 70 × 42 × 4 mm, while (rotate 90 0 0 ...) rotates 90 degrees.
When you do write one, the suffix is a conversion into that base unit — nothing more:
| Suffix | Family | Becomes | | --- | --- | --- | | mm | length | itself (12mm → 12) | | cm | length | ×10 (1cm → 10) | | in | length | ×25.4 (1in → 25.4) | | deg | angle | itself (90deg → 90) | | rad | angle | ×(180/π) (1.5708rad → 90) |
So (box 12mm 1cm 1in) is exactly (box 12 10 25.4), and (rotate 1.5708rad 0 0 ...) is the same 90-degree turn as (rotate 90 0 0 ...). Suffixes exist so you can author in the unit a spec is written in and let Ecky normalize.
Some numbers stay unitless on purpose. Counts ((repeat 5 ...)), ratios, segment counts on a cylinder ((cylinder 6 12 96) — that 96 is facets, not millimeters), and indices are pure numbers. A suffix on them is meaningless; leave them bare.
Unit suffixes convert values; they do not type-check dimensions. 45deg in a width slot becomes the number 45, then the box reads it as 45 mm. Use length suffixes for lengths, angle suffixes for angles, and bare values for counts and ratios.
Cut and Join: Mounting Plate
Use build when a part needs several boolean stages. Each shape names an intermediate result; result identifies the final geometry. Names keep cutters and later selectors readable.
(model
(params
(number plate_w 80)
(number plate_h 48)
(number thickness 5)
(number hole_r 4))
(part mount
(build
(shape blank
(extrude (rounded-rect plate_w plate_h 4) thickness))
(shape hole_left
(translate -24 0 -0.5
(cylinder hole_r (+ thickness 1))))
(shape hole_right
(translate 24 0 -0.5
(cylinder hole_r (+ thickness 1))))
(result
(difference blank hole_left hole_right)))))
build names each step. difference subtracts cutters from the blank. The cutters are slightly taller than the plate so the cut passes fully through.
Add material with union or fuse.
(result
(union
(difference blank hole_left hole_right)
(translate 0 0 thickness
(cylinder 12 8))))The result is still one part, but the intent stays readable.
> Watch for: make cutters cross the stock completely; coincident cutter and stock faces can leave unstable slivers. Booleans also rebuild topology, so raw face and edge indices are not durable selectors. Use geometric selectors or tags after the boolean.
Round, Chamfer, Shell: Select Edges and Faces
Finishing operations need two things: a radius or thickness, and a stable way to identify target topology. This chapter pairs fillet, chamfer, and shell with geometric selectors, tags, and native provenance selectors.
Edge operations happen after the main solid exists.
(model
(part soft_block
(fillet 2
:edges "top"
(box 60 36 16))))
:edges "top" selects top boundary edges. Use chamfer when the edge should become flat instead of rounded.
(model
(part beveled_block
(chamfer 1.5
:edges "bottom"
(box 60 36 16))))
Use shell to hollow a solid by removing selected faces.
(model
(part open_tray
(shell 2
:faces "top"
(box 70 44 22))))
Selectors should describe a physical feature: top, bottom, planar normal, or a stable target id. Avoid anonymous offsets for fit-critical faces.
Tag any fit-critical selector. The tag records intended topology in the manifest, so param changes can rebind the same seat, lip, or opening instead of chasing backend face indexes.
(model
(tag-face tray_opening :faces "top" tray)
(part tray
(shell 2
:faces (tag tray_opening)
(box 70 44 22))))
When a build introduces helper solids, use :created-by <shape> to keep clause selectors scoped to topology from that intermediate shape only.
(model
(part body
(build
(shape blank (box 70 44 22))
(shape pocket (translate 0 0 10 (box 30 18 12)))
(shape tray (difference blank pocket))
(result
(shell 2
:faces "planar+normal-z+area-max"
:created-by pocket
tray)))))Here :created-by pocket limits face candidates to the cavity created from pocket, not every planar top-facing face on tray.
> Native-only. :created-by is a provenance selector: it relies on the > originating-slot index that the native OCCT kernel tracks for every face and > edge. It resolves only on the native backend (Ecky's default). The build123d > and FreeCAD interop backends have no slot-provenance index, so they reject > :created-by rather than guess. If you lower a model through an interop > backend (including ecky check, which uses build123d today), drop the > :created-by clause and lean on the geometric predicates (planar, > normal-z, area-max) or a tag-face instead.
Tapered fillets
A normal fillet uses one radius. Add :to-radius and the radius varies along each selected edge — it starts at the base radius and eases to the second one. Handy for blends that need to grow or shrink along a run.
(model
(part p
(fillet 4 :to-radius 1 :edges "top" (box 40 40 20))))> Backend note: tapered fillets are an OCCT capability rendered by the native and FreeCAD backends. The build123d backend only does single-radius fillets, so it rejects :to-radius with a clear error rather than silently giving you a uniform fillet — render tapered fillets on native or FreeCAD.
Draft
draft tilts the side walls of a solid by an angle so a molded part can release from its tool. It tapers every vertical face about a neutral plane (the level that stays the original size); pass :neutral-z to move that plane, otherwise it sits at z = 0.
(model
(part p
(draft 8 (box 30 30 20))))
> Backend note: draft is rendered by the native and build123d backends (both OpenCASCADE). The FreeCAD backend has no Part draft API, so it rejects draft with a clear error. This first cut drafts *all* vertical faces; targeting specific faces with a :faces selector is a planned extension.
Paths and Surfaces: Revolve and Sweep
Choose a surface operation from the motion of a profile: rotate it around an axis, carry it along a path, or interpolate between several sections.
Use revolve when a 2D profile turns around an axis.
(model
(part knob
(revolve
(make-face
(path
(12 0 0)
(18 0 0)
(18 18 0)
(10 24 0)
(12 0 0)))
360)))
path creates the outline. make-face turns the closed outline into a face. revolve spins it into a solid.
Use sweep when a profile follows a path.
(model
(part handle
(sweep
(circle 2.2 32)
(bezier-path
((-24 0 0) (-10 18 6) (10 18 6) (24 0 0))))))
The circle is the cross-section. The bezier path is the centerline. Sweep keeps those responsibilities separate.
Use loft when one profile needs to become another profile across height or distance.
(model
(part nozzle
(loft 24
(circle 14 32)
(circle 5 32))))
The first profile is the base, the last is the cap, and loft skins a smooth wall between them. The leading number is the total height; profiles stack evenly along it, so the wide circle sits at the bottom and the narrow one at the top.
Ribs and grooves
rib and groove are the two-step "sweep a profile, then combine" move rolled into one op. Both take a solid, a profile, and a path: rib sweeps the profile along the path and fuses the result onto the solid (a reinforcing rib); groove sweeps it and cuts it away (a channel).
(model
(part p
(rib
(box 20 20 20)
(circle 3)
(path (0 0 0) (0 0 30)))))
Swap rib for groove to subtract the same swept run instead of adding it. They lower to sweep + union/difference, so they render on every backend.
Repetition: Ribs, Slots, and Patterns
Represent repeated geometry with one body and an index. This keeps count, spacing, and fit math editable in one place.
(model
(part ribbed_plate
(build
(shape base
(box 90 40 4))
(shape ribs
(repeat-union i 5
(translate (- (* i 18) 36) 0 5
(box 4 34 6))))
(result
(union base ribs)))))
repeat-union makes one merged body from repeated solids. The index i is local to the repeat body.
When repeated features share the same fit math, hoist derived values once instead of repeating arithmetic at every call site. Use model-level let* for dependent dimensions, a helper define for placement math, and define-component when one repeated body needs the same closed geometry everywhere.
(define (divider-depth tray_d wall)
(- tray_d (* 2 wall)))
(define-component divider
((number height 12) (number depth 34))
(box 4 depth height))
(model
(let* ((tray_d 40)
(wall 3)
(pitch 18)
(slot_w 6)
(rib_h 12)
(divider_d (divider-depth tray_d wall)))
(part tray
(difference
(union
(box 80 tray_d 18)
(repeat-union i 4
(translate (- (* i pitch) 27) 0 9
(divider :height rib_h :depth divider_d))))
(repeat-union i 4
(translate (- (* i pitch) 27) 0 0
(box slot_w 30 20)))))))Here pitch, slot_w, and wall each have one definition. divider-depth owns the offset calculation, while divider owns the repeated body. Lift shared math or geometry as soon as a second call site appears.
Use repeat-compound when repeated items should stay grouped instead of merged.
(shape rollers
(repeat-compound i 4
(translate (- (* i 16) 24) 0 8
(cylinder 3 8))))Use repeat-pick when only some indices should produce geometry.
(shape end_stop
(repeat-pick i 5 (= i 4)
(translate 36 0 12
(sphere 4))))Common mistake: (define ...) inside (model ...)
(define ...) is only valid at the top level (outside (model ...)), where it defines reusable helper functions like divider-depth above. Inside (model ...), Steel evaluates define eagerly — before params have values — so any arithmetic on a param produces a misleading TypeMismatch error instead of a clear message.
Wrong — define inside model:
(model
(params (number frame_length 160))
(define half_len (/ frame_length 2)) ; ← TypeMismatch at runtime
(part body (box half_len 10 10)))Right — let* inside the part:
(model
(params (number frame_length 160))
(part body
(let* ((half_len (/ frame_length 2)))
(box half_len 10 10))))The rule is simple: **define for top-level helper functions, let* for computed values inside parts.** If a derived value needs to reference a param, it belongs in a let* binding scoped to the part (or a let* wrapping model clauses that spans multiple parts).
Components and Reuse: Lift a Proven Part
Use a component when geometry must be reused across parts or models. A component packages a closed parameter signature, its geometry, and verification clauses; each instance reuses that definition without copying source.
This component defines a bored mounting standoff and carries its minimum-wall check:
(define-component standoff
((number height 12 :label "Standoff height" :min 6 :max 30)
(number bore 3.2))
(verify (tag bore_open) (metric min_wall_thickness "body") (expect (>= value 1.2)))
(difference
(cylinder 6 height 96)
(cylinder bore (+ height 2) 96)))
(model
(part front_left (standoff :height 16))
(part rear_right (translate 40 0 0 (standoff))))Three rules define component behavior.
Reuse by reference, override by keyword. (standoff :height 16) instantiates the component and overrides one signature key; (standoff) takes every default. Omitted keys fall back to the signature, and a missing _required_ key (one with no default) is a compile error that names the component and lists its signature. There is no copy-paste, so there is no drift: change the body once and both parts move together.
Closedness makes reuse reliable. A component body sees only signature keys and local bindings (let, let*, repeat indices, and build shapes). Referencing a model parameter or outer binding is a compile error. Therefore the component can be copied into another model without hidden dependencies.
Verification expands per instance. The component's verify clause is namespaced by part key, producing tags such as front_left/bore_open and rear_right/bore_open. Every instance runs the same wall-thickness requirement.
For the exact signature grammar, nesting limits, and verify-travel rules, see define-component in the language reference appendix.
The library loop (MCP)
Components do not have to live in one file. Agents lift proven parts into a shared library and pull them back by source:
1. component_extract — hand it a model and a partKey. Referenced model params become the signature (metadata preserved); scalar outer bindings become plain defaults; any non-scalar free reference is reported as a blocker so you cannot extract something that secretly depends on its context. save: true stores it. 2. component_search — compact headers only (name, one-liner, param keys, tags). Bodies never come back from search, so the library stays browsable. 3. component_get — the full, self-contained define-component source for one name. Paste it into the model and instantiate.
The loop is copy-inline by design: what you get back is closed source, not a hidden registry link. A part proven in one project becomes a building block in the next, checks and all.
Placement and Frames: Put Geometry Where It Belongs
Use direct transforms for fixed world-axis placement. Use a named frame when several shapes share a local coordinate system or must follow a path.
(translate 20 0 0 (box 10 10 10))
(rotate 0 0 45 (box 10 10 10))
(mirror :normal (1 0 0) (box 10 10 10))Use frames when placement should be named and reused.
(model
(part angled_pin
(build
(shape pin_pose
(plane
:origin (20 0 4)
:normal (0 1 1)
:x (1 0 0)))
(shape pin
(cylinder 3 24))
(result
(place pin_pose pin)))))
plane describes a local coordinate system. place moves geometry into it.
For path-driven models, path-frame can sample a location and tangent along a path. Use it when attachments must follow a curve instead of a fixed world axis.
Verification: State What Must Stay True
verify stores measurable requirements with the model. Write the requirement before tuning geometry, run verification, and keep the clause unchanged while repairing a failed result.
Start with the invariant, not the fix. This model says the lid must keep at least 0.3 mm clearance above the body:
(model
(verify
(tag lid_clearance body.lid_gap)
(metric gap (clearance min-distance body lid))
(expect gap (>= 0.3)))
(part body (box 80 50 20))
(part lid
(translate 0 0 20.4
(box 78 48 3))))
tag names the concern. metric measures it. expect sets the condition.
Red to green: lid clearance
Red state: the required clearance is 0.3 mm, but the lid sits only 0.2 mm above the body. Verification reports the measured delta.
(model
(verify
(tag lid_clearance body.lid_gap)
(metric gap (clearance min-distance body lid))
(expect gap (>= 0.3)))
(part body (box 80 50 20))
(part lid
(translate 0 0 20.2
(box 78 48 3))))Green state: keep the same verify block and move the lid to 20.4. Re-render and run verification again. Geometry changes; the requirement does not.
(part lid
(translate 0 0 20.4
(box 78 48 3)))Worked red-to-green loop:
1. Write one verify clause from one physical requirement. 2. Run verify_generated_model and confirm the failure names the violated promise. 3. Change geometry, parameters, or named constraints. Do not weaken the requirement to get green. 4. Fix the model and re-render. 5. Run verify_generated_model again until the original clause passes.
Use verification for:
- minimum clearances
- expected part count
- STL triangle or component checks
- required STEP or preview artifacts
Do not delete a failing verification clause to make a render pass. Fix the model or the stated requirement.
Real Model Patterns: Procedural Cuts and Arrayed Frames
These fixtures combine generated cutter lists, deterministic fields, path frames, arrays, and parameter-driven cavities. Focus on how each model separates generation math from final boolean intent.
Procedural perforated panel
This model uses map and range to generate cutters, hash-signed to jitter each cutter, voronoi2 to vary cutter radius, and apply union to turn the generated list into one cutter body.

The important line is the result expression:
(result
(difference
panel
(apply union
(map
(lambda (cell)
(let* ((col (- cell (* 4 (floor (/ cell 4)))))
(row (floor (/ cell 4)))
(x (* (- col 1.5) 14))
(y (* (- row 1.0) 12))
(jx (+ x (* 2.4 (hash-signed col row 23))))
(jy (+ y (* 2.4 (hash-signed (+ col 19.19) (+ row 7.73) 54))))
(r (+ 2.2 (* 1.1 (voronoi2 (/ jx 14.0) (/ jy 12.0) 23)))))
(translate jx jy 0
(cylinder r 8 24))))
(range 0 cell-count)))))range decides how many cutters exist. map builds one cylinder per cell. let* is required because jx, jy, and r depend on earlier bindings. apply union converts the list of cylinders into one boolean operand for difference.
This is the pattern to use when the count is parametric but the result is still one printable part.
Frame and array bracket
This fixture combines curve-driven placement with arrays. The rib is swept along a bezier path. The pad is placed at a sampled path frame. The base holes, locator posts, and fan stops use three array helpers.

The model has three distinct placement styles:
(shape rail
(bezier-path ((-18 0 4) (-8 7 9) (8 -7 12) (18 0 16))))
(shape rib
(sweep (circle 1.1) rail))
(shape end-frame
(path-frame rail :at end :up (0 0 1)))
(shape placed-pad
(place end-frame pad :offset (0 0 -1.5) :rotate (0 0 18)))sweep makes geometry follow the path. path-frame samples a pose from the path. place uses that pose to attach another solid.
The array helpers do the repeated work:
(linear-array 3 14 0 0
(translate -14 0 -2 (cylinder 2.1 10)))
(grid-array 2 3 16 10
(translate -16 -5 4 (cylinder 1.2 8)))
(radial-array 6 60 11
(translate 0 0 4 (cone 1.8 0.8 5)))Use these when the pattern is regular. Use map and range when each instance needs custom math.
Woodlouse hotel
This habitat uses one generated entrance list plus repeated shelves and dividers. Shared dimensions keep openings aligned when chamber count or overall width changes.

The entrances are generated from one parametric chamber count:
(shape entrances
(apply union
(map
(lambda (cell)
(let* ((col (- cell (* chamber_cols (floor (/ cell chamber_cols)))))
(row (floor (/ cell chamber_cols)))
(x (+ (* -0.5 hotel_w) wall (* (+ col 0.5) col_gap)))
(z (+ wall (* (+ row 0.55) floor_gap))))
(translate x (* -0.5 hotel_d) z
(rotate 90 0 0
(cylinder entrance_r (+ hotel_d 6) 24)))))
(range 0 (* chamber_cols 3)))))chamber_cols drives both cutter count and divider spacing. col_gap is derived from hotel_w and chamber_cols, so openings stay centered when the model is resized.
Projects as Folders: Edit Anywhere, Stay Canonical
A project folder mirrors one thread's active source onto disk. Edit model.ecky with any file-based tool; Ecky validates the changed file and records accepted updates as new thread versions. The thread remains canonical history.
project_folder_export writes two files:
<projectsRoot>/<slug>/
model.ecky edit this with anything
ecky-project.json binding manifest, owned by Ecky — never edit by handEdit model.ecky in any editor. A polling watcher detects a digest change, compiles the source, renders a preview, and commits a folder-sync version on the bound thread. Two safeguards prevent partial or repeated failures:
- Two-tick settle. A changed file must read identical on two consecutive polls before the compiler sees it. A half-written save — the editor flushing in chunks — never reaches Ecky mid-write.
- A broken save fails once, loudly, then waits. If the edited source does not compile, the watcher reports the failure once for that exact content and then goes quiet until you change the file again. It does not re-render the same mistake every tick.
When you need to reason about the folder explicitly, project_folder_status classifies it:
clean— file matches the bound version; nothing to do.fileChanged— you edited the file; the watcher will apply it (or you can).threadAdvanced— the thread moved on without the folder; the folder is stale. Re-export to refresh it.conflict— both sides moved. The watcher will not auto-resolve this; applying requires an explicit force, and the previous head stays available as a version so nothing is lost.missing— no folder or no manifest yet.
The folder is a mirror, not a second database. Threads and versions remain authoritative. Do not edit ecky-project.json; refresh a stale mirror or resolve a conflict explicitly.
Final Model: Integrated Film Adapter Open Helicoid v9
The final example is a multi-part film adapter with sliding rail joints and a two-start helicoid. Its base, insert stack, tunnel, cover, and lens carrier share fit dimensions but remain separate printable parts.

Full source: docs/books/ecky-ir/examples/ecky-integrated-film-adapter-open-helicoid-v9.ecky. The sections below isolate the six mechanical subsystems.
1. Public controls define physical fit
The first block exposes dimensions that matter after printing: film format, aperture, rail geometry, insert stack, film gap, lens bore, and helicoid thread geometry.
(params
(select film_format "120_645" :label "film format"
:options (("120 6x9" "120_6x9") ("120 6x6" "120_6x6")
("120 6x4.5" "120_645") ("135 36x24" "135") ("110" "110")))
(number rail_tip_w 5.4 :label "joint max W" :min 3.5 :max 8 :step 0.1)
(number rail_h 4.2 :label "joint H" :min 2 :max 6 :step 0.1)
(number fit_clearance 0.25 :label "fit clearance" :min 0 :max 0.8 :step 0.05)
(number film_gap 0.6 :label "film velvet gap" :min 0.1 :max 1.5 :step 0.05)
(number lens_bore_d 59.6 :label "lens bore D" :min 50 :max 68 :step 0.1)
(number thread_turns 3.2 :label "helicoid turns" :min 1.5 :max 5 :step 0.1)
(number thread_clearance 0.25 :label "helicoid clearance" :min 0.15 :max 0.6 :step 0.05))This is the same habit as earlier chapters: public parameters are physical, not arbitrary. fit_clearance appears in rail channels and detents. film_gap controls the clamp stack. lens_bore_d, thread_turns, and thread_clearance drive the helicoid interface.
2. Base makes recessed pockets and male rails
The base starts as a rounded plate, removes the aperture and insert pocket, then adds male triangular rail profiles on both long sides.
(part base_recessed_male_rails
(build
(shape raw_plate
(extrude (rounded-rect outer_w outer_h corner_r) base_h))
(shape aperture_cut
(translate 0 0 -0.1
(box aperture_w aperture_h (+ base_h 0.2))))
(shape frame_pocket
(translate 0 0 (- base_h pocket_depth)
(extrude
(rounded-rect (+ holder_w (* 2 fit_clearance))
(+ holder_h (* 2 fit_clearance))
holder_corner_r)
(+ pocket_depth 0.2))))
(shape plate
(difference raw_plate aperture_cut frame_pocket film_path_cut))
(shape rail_left
(translate (- (/ outer_w 2)) rail_y rail_z
(rotate 0 90 0
(extrude rail_profile_pos outer_w))))
(result
(fuse plate rail_left rail_right detent_top_left detent_top_right
detent_bottom_left detent_bottom_right))))rail_profile_pos and rail_profile_neg are small triangular sketches. They become long rails by extrude, then get fused onto the base. This is the same sketch-to-extrude move from chapter 2, applied to sliding joints.
3. Film insert is a two-piece stack
The lower insert carries the film guides. The upper insert clamps above the film gap. Both use the selected film format to derive frame_w, frame_h, and film_strip_w.
(shape frame_w
(if (= film_format "135") 36
(if (= film_format "110") 17
(if (= film_format "120_645") 42
(if (= film_format "120_6x6") 56 84)))))
(shape guide_top
(translate 0 (/ film_channel_h 2) (- (+ holder_thickness (/ film_guide_h 2)) 0.24)
(box (- holder_w 8) film_guide_rail_w film_guide_h)))
(shape lower_frame
(difference
lower_raw
aperture_cut
notch_top_left
notch_top_right
notch_bottom_left
notch_bottom_right))The insert stack is why the model has holder_thickness, film_gap, and insert_lid_thickness as separate controls. Those are real Z layers, not a single magic height.
4. Tunnel joins bottom and top modules
The tunnel module has both sides of the sliding interface. Its bottom cuts female channels so it can slide onto the base rails. Its top adds male rails so the top cover can slide onto the tunnel.
(part tunnel_female_bottom_male_top
(build
(shape channel_profile_pos
(polygon
(((/ (+ rail_h (* 2 fit_clearance)) 2) 0)
(0 (/ (+ rail_tip_w (* 2 fit_clearance)) 2))
((- (/ (+ rail_h (* 2 fit_clearance)) 2)) 0))))
(shape body
(difference body_blank tunnel_cut))
(shape channel_left
(translate (- (+ (/ outer_w 2) lead_in)) rail_y channel_z
(rotate 0 90 0
(extrude channel_profile_pos (+ outer_w (* 2 lead_in))))))
(shape rail_left
(translate (- (/ outer_w 2)) rail_y rail_z
(rotate 0 90 0
(extrude rail_profile_pos outer_w))))
(result
(fuse
(difference body channel_left channel_right)
rail_left
rail_right))))This is the sliding-joint core. Female channels are oversized by fit_clearance; male rails use the nominal profile. The book built these ideas earlier as sketches, cuts, and named clearances. Here they become a printable mechanical interface.
5. Top cover is open and owns the female helicoid
The cover removes matching rail channels and opens the center so the helicoid socket is visible. The female thread is modeled as two clipped helical ridges subtracted from a sleeve.
(shape female_thread_a_raw
(translate 0 0 (+ socket_base_z thread_z0)
(helical-ridge
:radius female_root_r
:pitch thread_pitch
:height thread_len
:base-width female_axial_width
:crest-width (* female_axial_width 0.58)
:depth female_depth)))
(shape female_thread_a
(clip-box female_thread_a_raw
:x ((- female_thread_clip_r) female_thread_clip_r)
:y ((- female_thread_clip_r) female_thread_clip_r)
:z ((+ socket_base_z 0.05) (+ socket_base_z sleeve_h 1))))
(shape female_thread_b
(rotate 0 0 180 female_thread_a))
(shape socket_threaded_shell
(difference
(translate 0 0 socket_base_z
(cylinder socket_outer_r sleeve_h))
female_thread_a
female_thread_b))thread_pitch comes from carrier height and turn count. female_thread_b is the second start, made by rotating the first. The clipped ends keep the helix printable and bounded inside the socket height.
6. Moving lens carrier matches the cover
The carrier is separate and previewed to the side with carrier_preview_x. It uses the same thread pitch, height, and clearance math, but its ridges are fused onto the carrier body instead of cut out of the socket.
(shape male_thread_a_raw
(translate 0 0 thread_z0
(helical-ridge
:radius ridge_root_r
:pitch thread_pitch
:height thread_len
:base-width thread_width
:crest-width (* thread_width 0.58)
:depth ridge_sweep_depth)))
(shape male_thread_a
(clip-box male_thread_a_raw
:x ((- thread_clip_r) thread_clip_r)
:y ((- thread_clip_r) thread_clip_r)
:z (0 carrier_h)))
(shape carrier_outer
(fuse carrier_body male_thread_a male_thread_b))
(result
(translate carrier_preview_x 0 socket_base_z
(difference carrier_outer stop_aperture lens_slip_bore)))That last translate is preview layout, not fit math. The carrier is offset so the reader can see both halves of the helicoid in one render.
Combined mechanism
The mechanism combines earlier patterns directly: profiles become rails and channels; named clearances control sliding fits; repeated structures stay parametric; frames place mating geometry; verification records fit requirements. The carrier threads into the cover while the remaining modules stack through rail interfaces.
Mesh and Image Geometry: Polygons in 3D
Ecky supports typed triangle geometry alongside analytic B-rep operations. Mesh execution is bounded and deterministic; it does not run Blender Python or arbitrary scripts.
Open surfaces and closed solids
Use mesh for a triangle surface. Use polyhedron when the triangles form a printable solid.
(define vertices
'((0 0 0) (20 0 0) (0 20 0) (0 0 20)))
(define triangles
'((0 2 1) (0 1 3) (1 2 3) (2 0 3)))
(model
(verify
(tag mesh_clean)
(metric bad_edges (stl non-manifold-edge-count))
(expect bad_edges (= 0)))
(part tetrahedron
(polyhedron
:vertices vertices
:triangles triangles)))mesh permits boundaries and previews them honestly as an open surface. polyhedron requires one closed orientable component with nonzero volume. Both reject invalid indices, repeated vertices, zero-area faces, duplicates, inconsistent winding, and resource-budget overflow before render.
Prefer formula-generated vertex/triangle lists for repeated or mathematical geometry. Keep one binding for each list instead of expanding thousands of copied triangles into source.
Heightmaps become dimensioned relief
heightfield samples image luminance into a closed planar mesh. Physical dimensions remain explicit.
(model
(verify
(tag relief_closed)
(metric bad_edges (stl non-manifold-edge-count))
(expect bad_edges (= 0)))
(part relief
(heightfield image-path
:width 100
:depth 70
:relief-height 4
:base-thickness 1.2
:invert #f)))The image path points to a staged local asset. Empty selection is pending, not fake geometry. Decode errors retain raw path/error evidence. Width, depth, relief height, and base thickness must be positive.
Orthographic images become reviewed sketches
Front, Top, and Side line art follows a different route:
1. select each raster and enter physical calibration; 2. tune threshold/inversion; 3. extract closed contour candidates; 4. review a candidate into an editable sketch primitive; 5. run existing preview-hull and exact candidate validation.
Raster provenance records asset digest, view, calibration, threshold, inversion, contour id, and extractor version. Failed extraction preserves the last reviewed sketch. Preview hull remains diagnostic until STEP and hidden-line validation pass.
Export truth follows representation
Pure mesh output offers STL. Multipart viewer assets also enable 3MF or multipart STL export. Pure mesh does not offer STEP.
A closed mesh may enter the hybrid import-stl -> solidify bridge before a supported BRep boolean. Successful STEP from that route is labeled Faceted poly-BRep and carries source mesh digests/topology evidence. It is triangle-derived, not analytic source CAD.
Reference photos are another route: a vision model can propose inferred .ecky source, then normal compilation and verification run. One perspective photo remains an inferred approximation; response text alone cannot mark it reconstructed or accepted CAD.
Appendix: Language Reference
Use this section after the lessons when you need exact forms, signatures, helper names, selector strings, and verification grammar. The reference is intentionally dense; the earlier chapters show when each piece matters.
Generated Operation Index
Generated from the runtime surface registry. Do not edit this table by hand.
| Form | Available backends | | --- | --- | | * | build123d, ecky-rust, freecad | | + | build123d, ecky-rust, freecad | | - | build123d, ecky-rust, freecad | | / | build123d, ecky-rust, freecad | | < | build123d, ecky-rust, freecad | | <= | build123d, ecky-rust, freecad | | = | build123d, ecky-rust, freecad | | > | build123d, ecky-rust, freecad | | >= | build123d, ecky-rust, freecad | | abs | build123d, ecky-rust, freecad | | and | build123d, ecky-rust, freecad | | append | build123d, ecky-rust, freecad | | apply | build123d, ecky-rust, freecad | | arc-array | build123d, ecky-rust, freecad | | atan | build123d, ecky-rust, freecad | | atan2 | build123d, ecky-rust, freecad | | attractor-field | ecky-rust | | begin | build123d, ecky-rust, freecad | | bezier-path | build123d, ecky-rust, freecad | | box | build123d, ecky-rust, freecad | | bspline | build123d, ecky-rust, freecad | | build | build123d, ecky-rust, freecad | | cell-distance2 | build123d, ecky-rust, freecad | | cellular | ecky-rust | | chamfer | build123d, ecky-rust, freecad | | circle | build123d, ecky-rust, freecad | | clamp | build123d, ecky-rust, freecad | | clip-box | build123d, ecky-rust, freecad | | common | build123d, ecky-rust, freecad | | compound | build123d, ecky-rust, freecad | | concat-map | build123d, ecky-rust, freecad | | cone | build123d, ecky-rust, freecad | | cos | build123d, ecky-rust, freecad | | cut | build123d, ecky-rust, freecad | | cylinder | build123d, ecky-rust, freecad | | define | build123d, ecky-rust, freecad | | deg | build123d, ecky-rust, freecad | | deg->rad | build123d, ecky-rust, freecad | | diamond | ecky-rust | | diamond-field | ecky-rust | | difference | build123d, ecky-rust, freecad | | ellipse | build123d, ecky-rust, freecad | | empty? | build123d, ecky-rust, freecad | | enumerate | build123d, ecky-rust, freecad | | even? | build123d, ecky-rust, freecad | | extrude | build123d, ecky-rust, freecad | | fbm | ecky-rust | | fbm2 | build123d, ecky-rust, freecad | | fillet | build123d, ecky-rust, freecad | | filter | build123d, ecky-rust, freecad | | flat-map | build123d, ecky-rust, freecad | | floor | build123d, ecky-rust, freecad | | fold | build123d, ecky-rust, freecad | | for-compound | build123d, ecky-rust, freecad | | for-union | build123d, ecky-rust, freecad | | fourier | ecky-rust | | fuse | build123d, ecky-rust, freecad | | grid-array | build123d, ecky-rust, freecad | | groove | build123d, ecky-rust, freecad | | gyroid | ecky-rust | | hammered | ecky-rust | | hash-signed | build123d, ecky-rust, freecad | | hash01 | build123d, ecky-rust, freecad | | heightfield | ecky-rust | | helical-ridge | build123d, ecky-rust, freecad | | henon-points | build123d, ecky-rust, freecad | | hull | ecky-rust | | if | build123d, ecky-rust, freecad | | import-stl | build123d, ecky-rust, freecad | | intersection | build123d, ecky-rust, freecad | | jitter2 | build123d, ecky-rust, freecad | | jittered-grid | build123d, ecky-rust, freecad | | lambda | build123d, ecky-rust, freecad | | lerp | build123d, ecky-rust, freecad | | let | build123d, ecky-rust, freecad | | let* | build123d, ecky-rust, freecad | | linear-array | build123d, ecky-rust, freecad | | linspace | build123d, ecky-rust, freecad | | list | build123d, ecky-rust, freecad | | list? | build123d, ecky-rust, freecad | | location | build123d, ecky-rust, freecad | | loft | build123d, ecky-rust, freecad | | logistic-bifurcation-points | build123d, ecky-rust, freecad | | lorenz-points | build123d, ecky-rust, freecad | | make-face | build123d, ecky-rust, freecad | | map | build123d, ecky-rust, freecad | | max | build123d, ecky-rust, freecad | | mesh | ecky-rust | | meta | build123d, ecky-rust, freecad | | min | build123d, ecky-rust, freecad | | mirror | build123d, ecky-rust, freecad | | neovius | ecky-rust | | noise2 | build123d, ecky-rust, freecad | | not | build123d, ecky-rust, freecad | | null? | build123d, ecky-rust, freecad | | odd? | build123d, ecky-rust, freecad | | offset | build123d, ecky-rust, freecad | | offset-rounded | build123d, ecky-rust, freecad | | or | build123d, ecky-rust, freecad | | organic-loop | build123d, ecky-rust, freecad | | params | build123d, ecky-rust, freecad | | part | build123d, ecky-rust, freecad | | path | build123d, ecky-rust, freecad | | path-frame | build123d, ecky-rust, freecad | | place | build123d, ecky-rust, freecad | | plane | build123d, ecky-rust, freecad | | polar-points | build123d, ecky-rust, freecad | | polygon | build123d, ecky-rust, freecad | | polyhedron | ecky-rust | | polyline | build123d, ecky-rust, freecad | | profile | build123d, ecky-rust, freecad | | quote | build123d, ecky-rust, freecad | | rad | build123d, ecky-rust, freecad | | rad->deg | build123d, ecky-rust, freecad | | radial-array | build123d, ecky-rust, freecad | | range | build123d, ecky-rust, freecad | | rectangle | build123d, ecky-rust, freecad | | reduce | build123d, ecky-rust, freecad | | regular-polygon | build123d, ecky-rust, freecad | | repeat | build123d, ecky-rust, freecad | | repeat-compound | build123d, ecky-rust, freecad | | repeat-pick | build123d, ecky-rust, freecad | | repeat-union | build123d, ecky-rust, freecad | | result | build123d, ecky-rust, freecad | | reverse | build123d, ecky-rust, freecad | | revolve | build123d, ecky-rust, freecad | | rib | build123d, ecky-rust, freecad | | ribs | ecky-rust | | ring | build123d, ecky-rust, freecad | | rings | ecky-rust | | rossler-points | build123d, ecky-rust, freecad | | rotate | build123d, ecky-rust, freecad | | rounded-polygon | build123d, ecky-rust, freecad | | rounded-rect | build123d, ecky-rust, freecad | | sampled-radial-loft | build123d, ecky-rust, freecad | | scale | build123d, ecky-rust, freecad | | schwarz-d | ecky-rust | | schwarz-p | ecky-rust | | shape | build123d, ecky-rust, freecad | | shell | build123d, ecky-rust, freecad | | sin | build123d, ecky-rust, freecad | | slot-arc | build123d, ecky-rust, freecad | | slot-center-point | build123d, ecky-rust, freecad | | slot-center-to-center | build123d, ecky-rust, freecad | | slot-overall | build123d, ecky-rust, freecad | | smoothstep | build123d, ecky-rust, freecad | | sphere | build123d, ecky-rust, freecad | | spiral | ecky-rust | | superellipse-point | build123d, ecky-rust, freecad | | svg | build123d, ecky-rust, freecad | | sweep | build123d, ecky-rust, freecad | | tan | build123d, ecky-rust, freecad | | taper | build123d, ecky-rust, freecad | | text | build123d, ecky-rust, freecad | | thread | build123d, ecky-rust, freecad | | torus | build123d, ecky-rust, freecad | | translate | build123d, ecky-rust, freecad | | trapezoid | build123d, ecky-rust, freecad | | twist | build123d, ecky-rust, freecad | | union | build123d, ecky-rust, freecad | | voronoi-cells | build123d, ecky-rust, freecad | | voronoi2 | build123d, ecky-rust, freecad | | wall-pattern | ecky-rust | | wave-loop | build123d, ecky-rust, freecad | | wedge | build123d, ecky-rust, freecad | | xor | build123d, ecky-rust, freecad | | zero? | build123d, ecky-rust, freecad | | zip | build123d, ecky-rust, freecad |
Language Overview
Scope here:
ecky/cadexported CAD forms and opsecky/corehelper functions shipped with Eckyecky/paramsparameter forms- lowerer-visible keywords people otherwise guess from source
Out of scope here:
- full Steel standard library reference
- backend implementation internals
- UI behavior outside
.eckyauthoring
Mental model:
.eckyis Scheme surface syntax- compiler lowers it into Core IR
- verifier checks value kinds and op signatures
- lowerers map Core IR into build123d, FreeCAD, or direct OCCT execution
Read this order if new:
Forms and StructureParams and ControlsPrimitive SignaturesBoolean and Transform SignaturesSurface and Path SignaturesArray and Frame SignaturesSpecial / Custom OperationsSelector Strings and Named KeywordsCookbook
Forms and Structure
This is top-level authoring grammar. If source feels mysterious, start here.
model
(model
...)- root form for one design
- source must start with
(model ...) - contains
params,part,feature, helperdefines, and local setup
part
(part body expr)
(part body "Human Label" expr)- positional 1: part id symbol
- positional 2: optional display label text
- final positional: expression producing geometry
feature
Two forms exist:
(feature body :role shell expr)
(feature body :role shell :params (width height) expr)- positional 1: feature id symbol
- required keyword:
:role - optional keyword:
:params - final positional: expression producing geometry
Use feature when geometry needs explicit semantic identity, role, and parameter-key tracking.
build
(build
(shape outer expr)
(shape cavity expr)
(result expr))- local binding block
- accepts
shapebindings plus oneresult resultmust come once- do not place new
shapebindings afterresult
shape
(shape ribs expr)shape is not geometry op. It is bind statement inside build.
- positional 1: local binding name
- positional 2: expression producing value
Read it as:
- bind intermediate value
- give later code a name
- keep boolean stacks readable
result
(result expr)- final value returned by
build
assembly (planned)
Reserved shape sketch:
(model
(assembly exploded_preview
...))- planned top-level clause for explicit multi-part assembly recipes
- spelling reserved in book now; runtime/compiler support deferred
- spec'd grammar reserved now; implementation deferred until views prove the display/manufacturing split
- intended to formalize what component packages already do at the package layer
- assemblies stay placement-based as today; no mate/joint solver implied
- examples here mark intent only, not accepted source today
- until implementation lands, keep physical bodies as
parts, useviewfor preview-only offsets, and use component packages for solved assembly workflows
export (planned)
Reserved shape sketch:
(model
(export manufacturing
...))- planned top-level clause for authored export/manufacturing policy
- spelling reserved in book now; runtime/compiler support deferred
- reserved until views prove the display/manufacturing split
- preview transforms never affect STL or STEP artifacts
- examples here mark intent only, not accepted source today
- until implementation lands, use current export commands, artifact manifests, and package output modes outside
.eckysource
Components
A component is a named, parameterized, closed geometry unit. Define once, instantiate anywhere, override knobs at the call site. model and part stay valid forever; components add reuse on top without changing them.
define-component
(define-component knuckle
((number pin_d 8 :label "Pin diameter" :min 4 :max 12 :step 0.5)
(number clearance 0.3))
(difference
(cylinder (* 2 pin_d) 10 96)
(cylinder (+ pin_d clearance) 12 96)))- positional 1: component name symbol
- positional 2: signature list; entries use the same grammar as
params
entries (kind, key, optional default, keyword metadata)
- final positional: one geometry expression
- optional
(verify ...)clauses may sit alongside the geometry expression - valid at top level or as a direct
modelclause
Instantiation
(part hinge_a (knuckle :pin_d 6)) ; override pin_d, clearance defaults
(part hinge_b (knuckle)) ; all defaults apply- arguments are keywords only:
(name :key value ...) - omitted keys take their signature defaults
- a signature entry without a default is required at every call site
- unknown keyword or missing required key fails compile with the component
name and its signature listed
- components instantiate other components; cycles are rejected and nesting
is capped at depth 32
Closedness
A component body sees its signature keys plus bindings made inside the body (let, let*, lambda parameters, repeat indices, build shapes) and nothing else. Referencing a model param or outer binding is a compile error naming the variable and the component. Closedness is what makes a component copy-inlineable: paste the define-component into any model and it works.
Verify travel
verify clauses inside a component expand once per instantiation, with the tag namespaced by the instantiating part key:
(define-component pin ((number d 2))
(verify (tag pin_ok) (metric min_wall_thickness "body") (expect (>= value 1)))
(cylinder d 10 48))
(part left (pin :d 3)) ; verify tag becomes left/pin_okA pasted component therefore carries its own checks — reuse includes proof.
Component Library Workflow (MCP)
Agents lift proven parts into the shared library and reuse them by source:
1. component_extract — pass the model source and a partKey. Referenced model params become the signature with metadata preserved; scalar outer let/let* bindings become plain defaults; non-scalar free references are reported as blockers. Set save: true to store the component. 2. component_search — compact headers only (name, one-liner, param keys, tags). Bodies are never returned by search. 3. component_get — full copy-inline define-component source for one component by name. Paste it into the model and instantiate it.
Extraction is copy-inline only: the returned source is self-contained and no registry reference is created implicitly.
Projects As Folders
A project can live as a plain folder on disk: edit model.ecky with any editor or LLM file skill; Ecky stays the renderer, validator, and history.
<projectsRoot>/<slug>/
model.ecky edit this with anything
ecky-project.json binding manifest, owned by Eckyproject_folder_exportwrites the folder from a thread's active versionproject_folder_statusclassifies it:clean,fileChanged,
threadAdvanced (stale; re-export), conflict, or missing
project_folder_applycompiles the edited file, renders a preview, and
commits it as a new version on the bound thread, then rebases the manifest
Rules:
- the folder is a mirror; threads and versions remain the record
- a stale folder never silently clobbers the thread: re-export to refresh
- a conflict (both sides moved) applies only with an explicit force, and the
previous head stays available as a version
- never edit
ecky-project.jsonby hand
Verify Clauses
Use verify when source should declare structural expectations explicitly.
(model
(verify
(tag front_gap body.front_window_1)
(metric gap (clearance min-distance body lid))
(expect gap (>= 3)))
(part body (box 10 10 10))
(part lid (box 10 10 10)))verifyis top-level only undermodel- one verify clause requires three sections in order:
tagmetricexpect- nested
verifyinside geometry or helper expressions is rejected - empty
(verify)is rejected
tag
(tag body_shell body.front_window_1)- carries authored labels, ids, or references
- payload stays opaque to compiler/core IR
- useful for human grouping and later diagnostics
metric
(metric check (manifest has-step))
(metric triangles (stl triangle-count))
(metric gap (clearance min-distance body.front_window_1 lid.front_skirt))- first item usually names local check alias
- second item is metric expression
- current runtime metric namespaces:
manifeststlclearance
Current shipped metric keys:
manifest has-stepmanifest has-preview-stlmanifest edge-target-countmanifest face-target-countmanifest export-format-countmanifest part-countstl triangle-countstl connected-component-countstl non-manifold-edge-countstl overhang-face-countclearance min-distance
clearance min-distance compares the minimum distance between two named selectors.
- selectors can name parts, selection targets, or correspondence outputs
- part selectors use manifest bounds
- edge and face selectors use runtime mesh target geometry when available
- unresolved selectors fail authored verify with a raw runtime error
expect
(expect check (= true))
(expect triangles (> 100))- first item should reference the metric alias used above
- second item is comparator form
- current shipped comparators:
=>>=<<=
Authoring rule:
- fix geometry or exports until
verifypasses - do not remove
verifyclauses to bypass authored requirements
Params and Controls
Parameter forms live in ecky/params.
params
(params
decl
decl
:relations ((<= wall shell) (>= shell 1.6)))- container for parameter declarations
- optional
:relationslist attaches cross-parameter constraints
Supported relation operators:
<<=>>=
number
(number wall 2.4
:label "Wall"
:min 0.8
:max 8
:step 0.1
:unit length
:frozen #f)- positional 1: parameter key symbol
- positional 2: default number
- keywords:
:labeltext:minnumber:maxnumber:stepnumber:unitone oflength | angle | ratio | count | text:frozenboolean
Units and suffixed literals
For physical authoring, generation should emit suffixed literals like mm/cm/in/deg/rad.
Examples:
12mm2.54cm0.25in45deg1.5708rad
Prompt generators explicitly: emit suffixed literals for lengths and angles. Use bare numbers only for counts, ratios, and unitless math.
toggle
(toggle useFillet #t
:label "Use fillet"
:frozen #f)- positional 1: parameter key symbol
- positional 2: default boolean
- keywords:
:label:frozen
select
(select material "PLA"
:label "Material"
:unit text
:options
((option "PLA" "PLA")
(option "PETG" "PETG")
(option "ABS" "ABS"))
:frozen #f)- positional 1: parameter key symbol
- positional 2: default choice value
- required keyword for practical use:
:options - optional keywords:
:label:unit:frozen
image
(image decal "assets/logo.svg"
:label "Decal"
:frozen #f)- positional 1: parameter key symbol
- positional 2: default image path text
- optional keywords:
:label:frozen
option
(option "Large" 42)
(option "PLA" "PLA")- positional 1: display label
- positional 2: value
- valid value kinds:
- number
- string / text symbol
Core Helper Library
Helpers here come from ecky/core.
Constructors and Symbols
vec2
- signature:
vec2 x y - returns: 2D point
vec3
- signature:
vec3 x y z - returns: 3D point
start
- constant anchor symbol for path/frame usage
end
- constant anchor symbol for path/frame usage
xy
- constant plane symbol
yz
- constant plane symbol
xz
- constant plane symbol
true
- constant boolean alias for
#t
false
- constant boolean alias for
#f
Sequence Helpers
zip
- signature:
zip list1 list2 ... - returns: list of tuples
enumerate
- signature:
enumerate list - signature:
enumerate start-index list - returns: list of
(index item)pairs
flat-map
- signature:
flat-map fn list1 list2 ... - returns: concatenated mapped list
concat-map
- signature:
concat-map fn list1 list2 ... - same behavior as
flat-map
linspace
- signature:
linspace start stop count - returns: evenly spaced number list
- special cases:
count <= 0-> empty listcount == 1-> single-item list containingstart
Scalar Math Helpers
pi
- constant
3.141592653589793
tau
- constant
6.283185307179586
clamp
- signature:
clamp value lower upper - returns: value clamped into
[lower, upper]
lerp
- signature:
lerp start end t - returns: linear interpolation
invlerp
- signature:
invlerp start end value - returns: normalized interpolation factor
remap
- signature:
remap value in-start in-end out-start out-end - returns: value remapped from one range into another
deg
- signature:
deg degrees - returns: radians
rad
- signature:
rad radians - returns: degrees
deg->rad
- signature:
deg->rad degrees - returns: radians
rad->deg
- signature:
rad->deg radians - returns: degrees
smoothstep
- signature:
smoothstep edge0 edge1 x - returns: smoothed
0..1interpolation
square
- signature:
square x - returns:
x * x
cube
- signature:
cube x - returns:
x * x * x
Noise and Field Helpers
hash01
- signature:
hash01 x y seed - returns: deterministic
0..1scalar
hash-signed
- signature:
hash-signed x y seed - returns: deterministic
-1..1scalar
noise2
- signature:
noise2 x y seed - returns: smoothed 2D value noise
fbm2
- signature:
fbm2 x y seed octaves lacunarity gain - returns: fractal Brownian motion sample
voronoi2
- signature:
voronoi2 x y seed - returns: Voronoi-style scalar field
cell-distance2
- signature:
cell-distance2 x y seed - returns: normalized cell distance field
jitter2
- signature:
jitter2 x y amount seed - returns: jittered 2D point
jittered-grid
- signature:
jittered-grid rows cols dx dy amount seed - returns: list of jittered 2D points
Shape-Driving Point Generators
polar-points
- signature:
polar-points count radius - returns: closed-style circular 2D sample list
organic-loop
- signature:
organic-loop count radius amount seed - returns: noisy radial 2D loop
wave-loop
- signature:
wave-loop count rx ry amp waves seed - returns: wavy ellipse-like 2D loop
superellipse-point
- signature:
superellipse-point rx ry n t - returns: single 2D point on superellipse
voronoi-cells
- signature:
voronoi-cells rows cols dx dy amount seed - returns: jittered cell-center point list
Chaotic / Generative Point Clouds
lorenz-points
- signature:
lorenz-points count dt scale - returns: list of 3D points
rossler-points
- signature:
rossler-points count dt scale - returns: list of 3D points
logistic-bifurcation-points
- signature:
logistic-bifurcation-points count seed scale - returns: list of 2D points
henon-points
- signature:
henon-points count seed scale - returns: list of 2D points
Use helper outputs as inputs to polygon, bspline, path, bezier-path, map, and repetition logic.
Value Kinds and IR Nodes
Verifier-backed value kinds:
AnyNumberBooleanTextListPoint2Point3SketchPathFrameCompoundSolid
Core node kinds:
LiteralReferenceBuildLetIfCallRangeMapApplyListGroup
If typecheck fails, compiler is checking these kinds, not backend Python text.
Primitive Signatures
These are explicit authored calls. When backend diverges, caveat is spelled out.
box
- signature:
box width depth height - result:
Solid - keywords:
:align (x y z)with each axis one ofmin | center | max
sphere
- signature:
sphere radius - result:
Solid - keywords:
:align (x y z)
cylinder
- signature:
cylinder radius height - signature:
cylinder radius height segments - result:
Solid - keywords:
:align (x y z)
cone
- signature:
cone radius1 radius2 height - signature:
cone radius1 radius2 height segments - result:
Solid - keywords:
:align (x y z)
circle
- signature:
circle radius - signature:
circle radius segments - result:
Sketch
rectangle
- signature:
rectangle width height - result:
Sketch
rounded-rect
- signature:
rounded-rect width height radius - result:
Sketch
rounded-polygon
- signature:
rounded-polygon points radius - signature:
rounded-polygon points radius segments points: list of 2D points- result:
Sketch
polygon
- signature:
polygon points points: list of 2D points- result:
Sketch
profile
- signature:
profile loop1 loop2 ... - signature:
profile :outer outer-loop :holes hole-loop-or-list - result:
Sketch
Rules:
- positional form treats every argument as sketch/wire loop
- keyword form accepts
:outerand:holesonly - current hole-aware lowerers expect exactly one outer loop when
:holesis used
make-face
- signature:
make-face wire1 wire2 ... - result:
Sketch - use when you already have wire-like geometry and need face/sketch result
text
- signature:
text string size - result:
Sketch - normal use: feed into
extrude
Example:
(extrude (text "HELLO" 12) 2)svg
- build123d-authored signature:
svg path - FreeCAD-authored signature:
svg path [target-width] [target-height] [fit-mode] - result:
Sketch
Known fit modes from lowerers/tests:
"contain""cover""stretch""fill"
import-stl
- signature:
import-stl path - result: imported solid/mesh-like geometry
ring
- signature:
ring outer-radius inner-radius - signature:
ring outer-radius inner-radius segments - result:
Sketch - lowering behavior: alias for profile-with-hole semantics
Boolean and Transform Signatures
union
- signature:
union shape1 shape2 ... - result: shape-like value
fuse
- alias of
union
difference
- signature:
difference base cut1 cut2 ... - result: shape-like value
cut
- alias of
difference
intersection
- signature:
intersection shape1 shape2 ... - result: shape-like value
common
- alias of
intersection
xor
- signature:
xor shape1 shape2 ... - result: shape-like value
Boolean rule:
- minimum arity: one shape
translate
- signature:
translate x y z shape - result kind follows input shape kind
rotate
- signature:
rotate x y z shape - result kind follows input shape kind
scale
- verifier accepts:
scale factor shapescale x y z shape- build123d lowerer supports both
- FreeCAD lowerer currently expects explicit
x y z shape - result kind follows input shape kind
mirror
- signature:
mirror axis offset shape axis: string or symbol naming mirror axisoffset: numeric plane offset- result kind follows input shape kind
Examples:
(translate 20 0 0 (box 10 10 10))
(rotate 0 0 45 (box 10 10 10))
(scale 2 2 1 (circle 10))
(mirror 'x 0 (box 10 10 10))Surface and Path Signatures
extrude
- signature:
extrude profile distance - result:
Solid - backend keyword:
:symmetricboolean
revolve
- signature:
revolve profile angle - result:
Solid
loft
- signature:
loft distance profile1 profile2 ... - requires at least two profiles after distance
- result:
Solid
sweep
- signature:
sweep profile path - result:
Solid
shell
- signature:
shell thickness solid - result:
Solid - optional keyword:
:faces selector
offset
- signature:
offset amount profile - result:
Sketch - optional keyword:
:openings sketch-or-sketch-list
offset-rounded
- signature:
offset-rounded amount profile - result:
Sketch - optional keyword:
:openings sketch-or-sketch-list
fillet
- signature:
fillet radius solid - result:
Solid - optional keyword:
:edges selector
chamfer
- signature:
chamfer distance solid - result:
Solid - optional keyword:
:edges selector
taper
- signature:
taper height scale profile - signature:
taper height scale-x scale-y profile - result:
Solid - FreeCAD caveat: non-uniform taper currently rejected
twist
- signature:
twist height angle profile - result:
Solid - verifier-backed form is 3 positional args
path
- signature:
path point1 point2 ... - signature:
path point-list - each point is 3D
- result:
Path
polyline
- alias of
path
bezier-path
- signature:
bezier-path point-list - point list must be 3D
- result:
Path
bspline
- signature:
bspline point-list - optional second positional in lowerers:
closed - optional keywords:
:closedboolean:tangentspoint-list:tangent-scalarsnumeric list- result:
Sketch
Notes:
- verifier only requires point-list first
- lowerers accept tangent hints
- tangents list may use 2 entries or one per point in build123d path
Example:
(model
(part latch
(translate 0 -17 5
(sweep
(circle 1.4)
(bezier-path ((-18 0 0) (-8 -8 4) (8 -8 4) (18 0 0)))))))Array and Frame Signatures
linear-array
- signature:
linear-array count x y z shape - result: same geometry family as input
radial-array
- signature:
radial-array count angle radius shape - result: same geometry family as input
grid-array
- signature:
grid-array rows cols x y shape - result: same geometry family as input
arc-array
- signature:
arc-array count radius start-angle end-angle shape - result: same geometry family as input
repeat
- signature:
repeat index count expr - verifier recognizes form
- geometry lowerers do not currently expose dedicated authored lowering path like
repeat-union/repeat-compound/repeat-pick
repeat-union
- signature:
repeat-union index count expr - index must be symbol
- body should produce geometry
- result: union/fused geometry
repeat-compound
- signature:
repeat-compound index count expr - index must be symbol
- body should produce geometry
- result: compound geometry
- build123d caveat: currently solid-only
repeat-pick
- signature:
repeat-pick index count predicate expr - index must be symbol
- predicate decides whether current body instance is selected
- result: last matching geometry
for-union
- macro alias:
for-union (index count) body- lowers to
repeat-union
for-compound
- macro alias:
for-compound (index count) body- lowers to
repeat-compound
plane
- signature:
plane - keywords:
:origin (x y z):x (x y z):normal (x y z)- result:
Frame
Defaults:
- origin
(0 0 0) - x direction
(1 0 0) - normal
(0 0 1)
location
- verifier signature:
location [frame] - authored backend-safe signature:
location frame - optional keywords:
:offset (x y z):rotate (x y z)- result:
Frame
path-frame
- signature:
path-frame path - optional keywords:
:at start | end | number:up (x y z)- result:
Frame
place
- signature:
place frame shape - optional keywords:
:offset (x y z):rotate (x y z)- result: placed shape
clip-box
- signature:
clip-box shape - required keywords:
:x (min max):y (min max):z (min max)- result: clipped shape
Example:
(model
(part body
(build
(shape rail (path (0 0 0) (20 0 10) (20 10 10)))
(shape peg (box 4 2 6 :align '(min min min)))
(shape frame (path-frame rail :at 0.5))
(result (place frame peg :offset (1 2 3) :rotate (10 20 30))))))Special / Custom Operations
These are exported authored ops outside generic primitive/boolean/surface families.
hole
Typed placeholder op. Use to mark missing geometry intentionally.
- signature:
hole :type kind - signature:
hole :type kind :goal "why this hole exists" - required keyword:
:type- optional keyword:
:goal
Allowed :type values:
solidsketchpathshape
Current behavior:
- compiler accepts it as typed placeholder
- lowerers reject it until replaced with real geometry
compound
- signature:
compound shape1 shape2 ... - groups shapes without boolean merge semantics
helical-ridge
Keyword-only thread-like ridge generator.
- required keywords:
:radius:pitch:height:base-width:crest-width:depth- optional keywords:
:female:clearance:lefthand
Example:
(helical-ridge
:radius 10
:pitch 2
:height 18
:base-width 1.2
:crest-width 0.4
:depth 0.7
:female #t
:clearance 0.15
:lefthand #t)sampled-radial-loft
Procedural sampled shell / loft op.
(sampled-radial-loft
(theta z fz)
:height 40
:z-steps 6
:theta-steps 24
:radius expr
:z-map expr)- binder list must be exactly
(theta z fz) - required keywords:
:height:z-steps:theta-steps:radius- optional keyword:
:z-map
wall-pattern
Pattern op applied to shell/solid target.
Pattern shape seen in repo:
(wall-pattern
(:mode gyroid :depth 0.6 :uFreq 4 :vFreq 5 :phase 0.2)
shape)Observed options:
:mode:depth:uFreq:vFreq:phase
Observed modes:
gyroidcellularfbmribs
Backend caveat:
- build123d lowerer currently rejects
wall-pattern - use direct Rust/OCCT path when pattern op matters
Selector Strings and Named Keywords
This is where people waste time guessing.
Shared keyword value expectations
Verifier enforces:
:offset-> 3D point:rotate-> 3D point:origin-> 3D point:x-> 3D point on frame ops:normal-> 3D pointclip-box :x/:y/:z-> 2-item numeric list:openings-> sketch or sketch-list:edges-> edge selector payload:faces-> face selector payload
:align
Supported on:
boxspherecylindercone
Example:
(box 4 4 4 :align '(min center max))Rules:
- expects 3-axis tuple
- each axis must be
min,center, ormax
Edge selectors
Used by ops like fillet and chamfer.
Examples:
:edges top:edges "bottom":edges "left+vertical":edges "target-id:body:edge:0:0-0-0_10-0-0"
Observed canonical meaning:
top-> boundaryz maxbottom-> boundaryz minleft+vertical->x-min + axis-z
Face selectors
Used by ops like shell.
Examples:
:faces "top":faces "planar+normal-z+area-max":faces "target-id:body:face:5:0-0-10:100"
path-frame :at
Accepted anchor values:
startend- numeric position
Cookbook
Cube
(model
(part body
(box 20 20 20)))Rotate a part
(model
(part body
(rotate 0 0 45
(box 20 20 10))))Box with named intermediate shapes
(model
(part body
(build
(shape outer (box 80 60 24))
(shape cavity (translate 2 2 2 (box 76 56 22)))
(result (difference outer cavity)))))Profile with hole
(model
(part body
(extrude
(profile :outer (circle 20 96) :holes (circle 10 96))
10)))Repeat ribs and rollers
(model
(part body
(build
(shape ribs
(repeat-union i 4
(translate (* i 10) 0 0 (box 4 8 6))))
(shape rollers
(repeat-compound i 4
(translate (+ (* i 10) 5) 0 0 (cylinder 2 6))))
(result (compound ribs rollers)))))Cup from real fixture
(model
(part cup
(fillet 1.47
(union
(shell 3
(revolve
(make-face
(union
(bspline ((30 10) (69 105)) #f
:tangents ((1 0.5) (0.7 1))
:tangent-scalars (1.75 1))
(path (30 10 0) (40 0 0) (0 0 0) (0 105 0) (69 105 0))))
360))
(translate 0 0 10
(cylinder 30 3))))))Tutorial: Loop to Profile
Sample points, close loop, extrude profile.
(define control-points
(map
(lambda (angle)
(list
(* 26 (cos (* pi (/ angle 180.0))))
(* 16 (sin (* pi (/ angle 180.0))))))
(linspace 0 315 8)))
(model
(part body
(extrude (bspline control-points :closed #t) 10)))What to notice:
linspacedrives repeatable sampling- point list becomes curve
- curve becomes profile
- profile becomes solid
Tutorial: Path to Solid
Separate motion logic from body logic.
(model
(part latch
(translate 0 -17 5
(sweep
(circle 1.4)
(bezier-path ((-18 0 0) (-8 -8 4) (8 -8 4) (18 0 0)))))))What to notice:
- profile is tiny and stable
- path carries shape motion
- latch stays separate from any main body
Tutorial: Repeat Logic
Author repeated geometry as structure, not copy-paste.
(model
(part body
(build
(shape ribs
(repeat-union i 4
(translate (* i 10) 0 0 (box 4 8 6))))
(shape rollers
(repeat-compound i 4
(translate (+ (* i 10) 5) 0 0 (cylinder 2 6))))
(shape marker
(repeat-pick i 4 (= i 3)
(translate (+ (* i 10) 5) 0 12 (sphere 3))))
(result (compound ribs rollers marker)))))What to notice:
- index symbol
ibecomes body-local numeric binding - repetition lives in one source block
- final boolean/compound intent stays obvious
Constraint Dojo
Use this section as fit/tolerance checklist when a model crosses from “looks right” into “must assemble”:
- named clearances
- relation constraints
- lower/upper bounds
- failure examples
- why anonymous offsets are garbage for physical fit