Build plans · CoScad
Build plans:
coscad plan
coscad plan foo.assemble turns an assembly into a
numbered build sequence a person can follow without backtracking: nuts
go in before the slot they need is consumed, every screw is driven with
its driver clear, each step names which face rests on the bench, and
every fastener carries a torque for its host's material. The design
rationale is in the assembly instruction generator design note; this
file is the working reference.
What you write
Placement can come from an asm expression or from
fixed / mate constraints. The planner uses the
same solved poses as the assembly loader and checker. Beyond placement,
it needs a few part hints and one fastener line per screw
group:
rx ← rail200.coscad ×0 profile=2020 material=aluminium
rz ← rail160.coscad ×0 profile=2020 material=printed ends=open,blocked
br ← flat90.coscad ×16
fastener M5x10 br#1 rx#1 bot at=30 // spec clamped host face [options]
fastener M5x10 px ry#2 rt ×2 // two screws, spaced along the overlap
fastener M5x10 br1 railA top through=6 // counterbored bracket: 6 mm under the head
plan beam=30 rest=bot,top,fwd,bak // optional planner settings
profile=2020marks a rail: all four long faces are T-slots, both ends open unlessends=open,blocked(min end, max end along the rail).material=aluminium|printed|steelsets the torque class;aluminumis also accepted.pla,petg,abs,asa,tpu,nylon, andpcuse the printed class, including premade×0parts. Values are case insensitive. Parts printed by this assembly (×n, n > 0) default toprinted, premade (×0) parts toaluminium.torque=on a part or a fastener overrides.mass=(grams) overrides the volume-based estimate. Mass and torque overrides must be finite and greater than zero.after=instance[,instance...]on a part declares placement prerequisites that the box model cannot discover. For example,axle2 ← axle.coscad ×1 after=rotor#1keeps a rotor's entry open until it is installed. Use numbered names for repeated instances. Unknown, ambiguous and cyclic references are errors.fastener spec clamped host face:specisM<d>x<len>;clampedandhostare instance names (br#3when a part is placed more than once, the bare name when once);faceis the host face the screw enters (top bot lft rt fwd bak, world frame). Options:×nscrews spaced along the overlap,at=mm[,mm]positions from the host's marked end,nut=dropin|slidein|hex|none(drop-in,slide-in, andcaptiveforhexare also accepted),head=button|socket,through=mm(material under the head when the clamped part is thinner at the hole than its bounding box, e.g. counterbored),torque=Nm.nut=hex pocket=face: the host is not a rail; the screw meets a plain hex nut captive in a pocket that opens on that host face. Nuts still go in before the part that covers them, the pocket face may not be on the bench or under another part while they go in, and the plan says "hex nut into the pocket on the top face" instead of slot positions.examples/assemble/ball/(two hemisphere shells bolted to a core disc) is the worked example.pocket=clamped.face: the nut pocket is in the clamped part, not the host (a corner block whose screw crosses it into a bar end and finds its nut on the block's far face). The nut goes in with the part in hand, so nothing can cover it; if that face points down when the screw is driven the step says to hold the nut in.nut=none: the screw threads into the host itself (a printed bar, a tapped block); nothing to preload, the reach check still applies.fastener peg clamped host face: a friction joint (a peg in a hole, a bar in a socket) with no screw: no driver, no torque, no nut, but the same ordering and support rules, and "Push ... until it seats" in the steps.examples/assemble/tesseract/(a wire-frame cube whose inner cube hangs on eight diagonal pegs) uses both.inset=mmon a peg joint moves its seating plane inward from the named host face, for an internal shoulder or recessed socket. For example,fastener peg rotor#1 axle1 top inset=13seats the rotor on a shoulder 13 mm below the axle's top. The inset must be within the host and is not supported for screws. The way the peg goes in is checked separately, on the meshes (see "Joints and how parts go in").joint KIND moving host [pitch=mm] [axis=face]: how a printed joint is made, for joints with no hardware.push(a press or snap fit that holds once seated),drop(an open socket the part only rests in: gravity or a part that captures it keeps it there) orscrew(an integral thread;pitch=is required, and the moving part turns). The axis is the host's frame normal at thematebetween the two parts;axis=facenames a world face of the host instead, forasm-placed assemblies. The moving part goes on after its host.planoptions:beam=N(search width; 30 default, wider is slower and better),rest=facesto restrict allowed rest faces,astar=Nto try exact search with an expansion cap first,step_parts=N(parts placed together in one step, 4). Cost weights:flip vertical driver shrink sibling loose insert turn hand finger part fastener nut(see "Costs"). Settings, with defaults:driver_angle=30(degrees off vertical before a driver costs),standing_angle=17.5(degrees above horizontal at which a rail or bar counts as standing),slender=3,driver_length=45,driver_margin=2(mm),phase_tau=2.5,finger_size=16(mm, a fingertip),contact=0.1(mm that counts as touching),settle=0.5(mm a part may drop to come to rest in its seat),sweep_step=0.5(mm, the finest sweep step),shell=0.85(mm of solid perimeter on a printed part) andinfill=0.15.beam,astarandstep_partsmust be positive whole numbers; cost weights must be finite and non-negative; each setting has a range. Unknown plan options and invalid rest faces are errors, and each option may appear only once across allplanlines.
Part names must be unique. Repeated options on a part or fastener are
errors, including repeated ×count or
▽orientation. Unknown fastener options are errors; unknown
part hints such as seam=rear remain available as metadata.
Recognized hints are checked before planning: profile must
be 2020, and ends must contain exactly two
open or blocked values.
Distances in at=, through=, and
inset= must be finite and non-negative. Screw diameter and
length must be positive; write peg for a friction joint.
Fastener quantities are from 1 to 10,000. Part print quantities are from
0 to 10,000, with a total expanded print quantity of at most 10,000
across recursive assemblies. Expanded fasteners also total at most
10,000, including explicit at= positions. These are input
limits, not a promise that planning a large assembly will be fast.
plate width depth [margin] requires finite positive
dimensions and a finite, non-negative margin that leaves usable space on
both axes; the default margin is 6 mm. clearance mm
requires a finite, non-negative distance.
Driver access is judged on boxes: a driver is blocked only when its cylinder really enters another part's oriented box (the part's own box under its placement, so a diagonal strut is a thin rod, not the block its axis-aligned box would be), and touching does not count, so a shell whose box brushes the neighbouring shell's box, as in the ball, does not block the screw on the other side. A part may be placed "held" against anything already placed that it will be joined to, on either side of the joint. A slender part (three times longer than wide) stood on end costs the same as a rail stood on end and gets the same warning.
The planner derives the driver axis (the host face's outward normal),
the screw positions, the thread reach (length minus material under the
head), and the T-nut preload sheet. Along the host, positions use the
overlap or explicit at= distances. Across a
profile=2020 side face, they stay on the host's slot centre
even when an inset panel covers only part of the face. A clamped part
that does not cover that centre is rejected. Other hosts and rail end
faces use the transverse overlap centre.
The 200 mm cube is a worked constraint-based assembly: both holes of each of its 16 brackets are mated to rail interfaces, and its six panel bores match explicit screw distances. See the geometry and hardware verification.
Frame-based screws and bearing seats
Use a named host frame instead of a world face when the joint has an explicit bore. Each line describes one physical screw:
fastener M4x12 arm center @root_hole seat=@head_seat head=socket nut=hex pocket=@nut_seat
The host frame origin is the hole's interface point; its outward +Z
axis points toward the screw head. seat=@head_seat selects
a frame on the clamped part at the underside of the head, including any
counterbore. Its normal points in the same direction.
pocket=@nut_seat selects the nut's bearing seat on the
host, with its normal pointing away from the head.
pocket=clamped.@nut_seat selects a seat on the clamped part
instead. These are bearing surfaces, not the centres of the screw head
or nut.
The solved part poses carry every point and direction into world
coordinates. The planner checks that the frames are coaxial, their
normals agree, and seats lie within their parts' bounds. Screw reach is
the screw length minus the distance between the head and nut bearing
seats. For a T-nut or threaded host, it measures from the head seat to
the host interface. Existing minimum-reach checks still apply.
Frame-based rail holes must lie on the 2020 slot centre and outer face.
at=, ×count, through= and
inset= cannot be combined with a host frame; they would
provide a second source of placement or seating information. Pegs retain
their face/inset syntax.
preload=hand on a part prepares its nuts while it is
independent, before placing that part. This is useful for opposing rail
slots or underside pockets: turn the loose part to reach them and retain
the nuts during placement. The default preload=placed keeps
the existing access checks against the bench and previously placed
parts. This hint declares a preparation operation, not a fixed assembly
order.
The generated JSON includes axis,
head_position_mm, thread_reach_mm, and, for a
framed nut pocket, nut_seat_mm, alongside
position_mm. Step renders and driver-clearance checks start
at the same head seat. No model names, coordinates, screw counts or
assembly sequences are built into the planner. Bore and seat frames and
hardware specifications are explicit design metadata; the planner does
not infer threads or discover holes from arbitrary boolean geometry.
Bounds checks are not proof that a bore is empty or that a real tool
fits: verify the part geometry too.
Worked examples: the repaired rail corner uses two closed mounting interfaces per outer bracket and six M5 screws; the elastic-cord toy bow uses four M4x12 screws, four captive nuts and recessed head seats. The bow's 8 mm bearing-seat distance leaves 4 mm of screw past the nut bearing face.
Joints and how parts go in
Before searching, coscad plan meshes every part with the
native kernel and measures, once, everything the search asks about
shape:
- Mass and balance. Each part's mass and centre of
mass come from its mesh: a printed part is a solid perimeter shell
(
shellmm over its surface) aroundinfill; metal is solid; a 2020 rail modelled as a plain bar counts as the extrusion's 37% fill.mass=still wins. The bench footprint is the convex hull of each part's lowest mesh vertices, and a part resting on another rests on their real contact patch (where it would touch after dropping up tosettlemm). - Ways in. For every part, candidate directions: the
26 directions of a cube lattice (axes, face and space diagonals), both
directions of every joint axis, mated frame normal and the part's own
axes, and the combined way out of two or three joints. Each is swept
against every other part at its final pose. The sweep advances
conservatively (a part g mm from another can move g mm without touching
it), and where the two touch, a copy of the moving part shrunk by
contacttells touching from pushing in. A screw joint's partner is not swept (its thread interlocks by design). - Alignment work. Seating a part against a joint partner costs its Fitts index, log2(1 + D/W): D the length over which it is engaged with the partner, W its play at the seat (the smaller transverse range it can shift before running into the partner). A 6 mm peg 16 mm into a 6.4 mm hole is log2(1 + 16/0.4) = 5.4 bits.
- Finger room. Behind each part, along its way in, a fingertip needs room to push it home; around a screw joint's moving part, two fingertips need room to turn it (a part sunk flush in a socket cannot be turned by hand).
- Screw joints. The turns are the engaged length (how far along its axis the part stays within a quarter pitch of its partner) over the pitch. The part is spun about its axis at its seat against every other part; what it would hit must come later.
- Order from burial. Once a part's certain
predecessors are placed, it still has some ways in. A part that alone
closes every one of them (blocks the path, takes the finger room, or is
in the way of it turning) must come after it. Repeated until nothing
changes. This is what the cap
after=hints of the Tesseract builds were standing in for; a part with no way in at all, or a cycle, is a design error.
The search also refuses any move after which a part still to place has no way in left: placed parts only accumulate, so such a state is a dead end whatever the rest face.
Rest faces are the six world faces plus, when the assembly is not square to the world, the six faces of its largest part ("the block's bottom face down").
What it checks
Design errors (reported, and the run exits 1):
- screws that collide inside a host (same spot on one face, or two faces meeting at the same station with enough reach to cross);
- too little thread reaching the T-nut (under 3 mm);
- a clamped part that does not sit on the named host face.
Hard predicates on every step: a free part must rest on the bench or on placed parts and balance on that support (a part about to be joined may be held against its host); nothing may reach under the partial assembly (flip instead); the assembly's centre of mass stays over its bench contact; the part has a clear way in that keeps to the axis of any screw joint it makes, does not come up through the bench (far enough to clear what it touches) and leaves a fingertip room to push it home; a screw joint's moving part turns clear of everything placed and can be gripped; a screw's driver cylinder is clear of everything but its own parts and does not come up through the bench; T-nuts can still enter (drop-in: the slot face is exposed; slide-in: an end is open and unsealed).
Holding: after each move, a placed part stays put on its own if it is on the bench, or (repeatedly) joined by a made joint that holds, resting balanced on, or captured by (every way out blocked) parts already known to stay. Every other piece (parts joined together count once) needs a hand. One hand does each move. While the assembly turns, gravity holds nothing: pieces not joined to or captured by the heaviest piece must be held, and the two hands turning it can each press one.
Costs: each move records its terms, and the plan cost is each term
times its weight (plan options; the defaults are guesses
until calibrated with coscad calibrate, after which they
are seconds):
| term | counted | default |
|---|---|---|
| flip | per flip | 10 |
| vertical | per move with a host rail, or a rail or slender part still loose, standing | 6 |
| driver | per screw driven more than driver_angle off
vertical |
3 |
| shrink | per move that shrinks the bench footprint | 2 |
| sibling | per screw that leaves another on the same part undone | 1 |
| loose | per loose piece after each move | 1 |
| insert | per bit of joint alignment work | 0.5 |
| turn | per turn of a screw joint | 0.3 |
| hand | per hand needed beyond two, per move or turn | 20 |
| finger | per move, times the fraction of the finger room other parts take | 2 |
| part, fastener, nut | base handling per part, joint and nut batch | 0 |
insert, turn, part,
fastener and nut are the joints' own work,
about equal for every order: the phase search's "cheap move" test
ignores them, and the completion estimate includes what is left of
them.
Outputs
foo_plan.md: bill of materials, T-nut preload sheet (nuts per rail, slot, mm from the marked end), then numbered steps: rest face, parts placed, rails preloaded, screws tightened with torque, warnings.foo_plan.json: the same as data. Each fastener'sposition_mm: [x,y,z]is its host-face contact point in the assembled world frame, in millimetres (includinginset=for a recessed peg seat), before the instruction step's bench rotation. It is not the screw head position.foo_stepN.scad: the scene after step N, rest face down, this step's parts orange, earlier parts grey, screw heads red, the bench as a grey slab just under the rest face.--pngalso rendersfoo_stepN.pngusing the native CPU renderer in a kernel build: cached native meshes, orthographic auto-fit, depth testing, flat lighting and antialiasing. It needs neither OpenSCAD nor a GPU/display. Both representations share part poses, step highlights, fastener-head proxies and the bench. Physically coincident surfaces use a deterministic depth tie; this does not repair overlapping CAD.--backend=openscadselects the previous image path explicitly (and remains the default in a build without the native kernel). Unsupported native shapes fail rather than silently switching backends. Image errors fail the command, so the site cannot silently publish a successful build with missing images.
Companion site
coscad site DIR a.assemble b.assemble ... plans each
assembly with renders and writes a static site:
DIR/index.html (search across builds by name, part,
material, screw spec) and DIR/<name>/index.html (step
cards with the render, rest face, parts, preloads, screws with torque,
warnings; prev/next buttons, arrow keys, a filter box,
#step-N links). It is plain HTML with the plan JSON
embedded, so it works from a file or any static host (GitHub Pages, a
folder on the router box). Light and dark themes, safe-area padding for
phones.
How the search works
Nodes are part instances, one T-nut batch per rail, and fasteners;
edges are requires-before relations derived from the model (host and
clamped part before a screw, a rail's nuts before its screws and before
the parts clamped to it, slide-in nuts before anything sealing an end).
Another part covering a drop-in slot or a hex pocket is not an edge:
nutCanEnter rejects the nuts once the entry is covered, and
the order works because settling loads a host's nuts as soon as it is
placed (see "Feature burial" under Planned). A state is the placed set
plus the rest face. Placing a part settles its follow-ups (nuts that can
now go in, screws whose driver is clear). The search is a beam over
rest-face phases: on one face it greedily takes every cheap move; it
branches on flipping to another face or on accepting one expensive move.
A completion heuristic charges for parts that will need a flip to reach
and for screw groups that cannot be vertical on the current face.
Search width matters on larger frames. For the 29-part cube fixture the plan cost was 208 / 183 / 179 / 167 at beam 10 / 30 / 80 / 200, in 3 / 10 / 25 / 60 seconds; the corner example is instant at any width.
Limits
- Support, contact, balance, paths and finger room use the meshes; driver and nut access still use boxes, so a driver beside a bracket edge may be judged blocked. Screw heads and nuts are not solids.
- Parts are rigid. A design that only goes together by flexing (the old bolted tesseract: its last bar or corner must slide onto perpendicular sockets at once) is reported as unbuildable.
- Ways in are the candidate directions above, straight lines only. A part that must follow a bent path or rotate while going in is not found.
- A screw joint's rotation is checked at the seat only, and its thread against its partner is not swept (verify the thread fit with a coupon).
- One torque class per material; the printed default (0.6 Nm) is a guess to measure on an offcut.
- Sub-assemblies are not discovered; each recursive
.assembleis a part (see "Sub-assemblies" under Planned). - Every screw is driven to its final torque in the step it becomes reachable; there is no snug-then-torque pass (see "Operations").
- The bench has no size and the finished assembly is not checked against a doorway (see "Workspace and delivery").
- Fixtures, clamps, and third hands are not modelled: a warning asks you to steady a rail stood on end.
- Internal peg seats still need
inset=onfastener peglines. Capture order is derived from the meshes;after=remains for orders the geometry cannot show. The Orbit Tower is a worked example: 20 pieces, 19 peg joints, assembled upright without flips.
Planned
Not implemented yet. These four extensions come from a separate
prototype planner (AsmPlan.hs) that modelled joints, buried
features, staging areas and a doorway on a desk build. The prototype's
geometry was a toy; the ideas below are the parts worth carrying over.
Proposed syntax follows the existing .assemble conventions
and may change.
Feature burial as one rule
Done for part paths, finger room and screw joints turning on 2026-10-29 (see "Joints and how parts go in" and PLAN-EASE). Nut slots, pockets and driver cylinders still use the special cases below.
Today the edges are special cases: clamped part after its nuts, slide-in nuts before end caps, and a predicate (not an edge) for drop-in slots and hex pockets. Replace them with one rule:
Node A must come before part P when A needs access to a region that P occupies once placed.
The regions the model already computes are the access regions:
- a drop-in nut's entry box on its slot face
(
dropInOk), - a hex pocket's entry box (
hexBlocked,fPocketEntry), - a slide-in nut's end caps (
endCaps), - a screw's driver cylinder (
driverBox): the driver axis is fixed by the host face, so a part that would block it later must come after the screw.
For every access region and every part whose oriented box enters it (other than the node's own host and clamped part), add the edge. The edges do not depend on the rest face; the bench covering a face stays a predicate, because a flip clears it. A cycle among these edges is a design error that names the feature, for example "the top slot of bottom_front is covered by panel, but panel's screw needs a nut in that slot". This turns today's dead ends in the search into errors reported before planning, and the extra edges also prune the search.
Operations
Steps that join nothing: square a frame, check a diagonal, test a mechanism before it is enclosed. One line per operation:
op square "Check both diagonals; tap the corners square" over=bottom_front,bottom_back,bottom_left,bottom_right before=panel
op check "Spin the rotor by hand; it must turn freely" over=rotor#1 uses=rotor#1.top
over=names the parts the operation is about. It runs after every fastener that joins twoover=parts has been started.uses=part.facenames faces that must be exposed. They become access regions under the burial rule, so anything that covers them is ordered after the operation.before=andafter=add explicit edges, asafter=does on parts.- The quoted text is printed in the step as written.
Fasteners that join over= parts are driven in two
passes: snug when placed ("finger tight, then a quarter turn"), and to
their torque in a "Torque" step after the operation. Assemblies with no
op lines plan exactly as now. Operations are nodes in the
requires-before graph with no geometry of their own; they never block a
driver or a nut.
Sub-assemblies
The planner grows one assembly on one bench. Many builds are faster and steadier as separate pieces joined at the end: two side frames, then the rails between them.
sub left = upright_fl, upright_bl, bottom_left, top_left, br_front_low_left, br_back_low_left
sub right = upright_fr, upright_br, bottom_right, top_right, br_front_low_right, br_back_low_right
- Each
subis planned on its own with the fasteners whose clamped part and host are both inside it, and gets its own steps ("Build left: 2a, 2b, ..."). - In the parent plan a finished
subis a single rigid part: box is the union of its members' boxes, mass is their sum, and it is placed like any other part. - Fasteners that cross a
subboundary belong to the parent. Nuts they need inside a member are preloaded while thatsubis built, since a member's slot may be covered by the time the parent joint is made; the burial rule decides whether that is still possible and reports it if not. - A part may be in at most one
sub;subs do not nest at first. Recursive.assembleparts stay as they are.
Discovering sub-assemblies (connected groups that need nothing from the rest) can come later, once declared ones work.
Workspace and delivery
Two plan options:
plan bench=900x600 floor=2000x2000
plan door=690x2040
bench=WxD(and optionallyfloor=WxD) sizes the work areas. After each step the footprint of the partial assembly in its rest orientation must fit an area; the step names the area ("on the bench", "on the floor"). Moving from bench to floor costs a flip's weight. A footprint larger than every area is a design error. Two sub-assemblies on the bench at once must fit side by side.door=WxHchecks the finished assembly's box against an opening in each axis-aligned orientation. Tilting through diagonally is not considered, so the check is conservative. If the assembly does not pass, the planner finds the smallest set of parts it can leave off (closed under the requires-before relation: nothing that stays depends on them) such that the rest passes, replans with those last, and splits the plan into "Before moving" and "After moving it through the door". If no such set exists, that is a design error.