# hackerscad for agents hackerscad builds CAD models from .hcad files, a sandboxed subset of D. Typical loop: read this reference, write a model, call hcad_render to get dimensions and pictures, compare them with the request, fix, then export. ## Rules that avoid most mistakes - Every exported body is a function marked @part that takes no arguments and returns a Solid, with the annotation on its own line: @part("steelblue") Solid body() { ... } - Reference objects (the shaft a knob fits on, the phone a stand holds) are parts too, see-through and not exported, so the fit can be checked: @part("slategray", alpha: 0.35, export: false) Tag the faces of the part that touch the object (rest, grip) and check the fit on them. - Lengths: write units (60.mm, 2.5.cm, 1.inch). Bare numbers passed to geometry are millimetres. Never add a bare number to a length (60.mm + 5 is an error; write 60.mm + 5.mm, or d.to!double for the bare number a length stands for in this file). Vectors too: write the unit on every number, for clarity (vec(6.25.mm, 31.5.mm)); a vector of bare numbers and one of lengths do not add (vec(1, 2) + vec(1.mm, 2.mm) is an error). Your own functions take Length parameters (Solid peg(Length d) …): a bare number passes as mm, a @param as it is, no .to!double. - Angles: rotate(z: 45) uses degrees; 30.deg is explicit. - A number takes its unit after a dot: 8.2.mm, not 8.2mm (in -D too). There is no cast: x.to!int, x.to!double, round(x), floor(x), ceil(x). - Primitives stand on the XY plane and are centred in X and Y: box(x, y, z) spans z = 0 .. z; cylinder(r: or d:, h:) also; center: true centres in Z. sphere(r) is centred on the origin. - Booleans: a + b (union), a - b (difference), a & b (intersection). - Place parts relative to each other instead of computing coordinates: lid.nextTo(body, X, gap: 10.mm), cap.on(body). To put a part where another part has a place for it (a pin in a hole, a part on a mount), tag that place where it is made: a point or the hole's circle in the sketch (c.addTag("pivot")), or on the solid (b.addTag("pin", at: vec(…))); then part.placeOn(body.frame("pivot")), and body.point("pin") or body.axis("pivot") for sizes. They follow the body through every turn and cut: no coordinates to work out again. - Fillets, chamfers and shells take selectors, evaluated on the solid they apply to: .fillet(edges.parallel(Z), r: 3.mm), .chamfer(edges.filter(e => e.mid.z > 5.mm), d: 1.mm), .shell(open: faces.top, thickness: 2.mm). Apply fillets before shell and before cutting holes; a fillet radius must be smaller than the faces next to the edge. When one fails, its error names the edges that fail and, when that is why, the face beside them too narrow for the size (round before the cut that narrows it, or make the size smaller). - To round or chamfer the edges a cut left behind, select them by provenance: .fillet(edges.between(faces.from(holes), faces.top), r: 0.5.mm). - Select by provenance, not by position: edges.between(faces.from(upright), faces.from(foot)) follows the @params, a filter on e.mid.z does not. faces.top is the highest face (after an upright with a round top is added, that top, no longer the foot's); faces.normal(Z) also takes curved faces facing up on the whole (a dome); faces.from(foot) has neither problem, and a frame can be taken on the solid before (foot.frame(faces.top)). edges.chain(edges.…) takes a curved outline made of many pieces whole (the edges running on without a corner), edges.loop(faces.top) a face's rim without its holes; a profile's p.outer, p.holes and p.pieces give its outline, its holes and its regions. - Where you can, avoid fixed coordinates and selectors by position (a filter on e.mid.z, on a face's area or centre): they stop matching when a size changes. Prefer what follows the model, provenance or a tag; when no plain selector picks a face, tag it rather than filter for it. Fixed coordinates are right where the place itself is fixed (a hole measured on the real part). Tag a face you will come back to, where it is made: b.faces.top.addTag("lid") (it changes b and gives it back, so box(…).faces.top.addTag("lid") works in a chain), then faces.tagged("lid") wherever the part was turned or placed since; a tool tagged recursive (drill.addTag("bore", recursive: true)) tags the wall it cuts. Name what a face does (lid, mount, seat, grip), not where it is; tag only what a later step uses, and in a library the faces its users need. A tag not found: the error lists the tags there are; fix the name rather than switching to a selector by position. The libraries tag what you come back to (hardware.bore, gears.axis, hinges.pin, enclosures.rim…: the list is in hcad docs): use them rather than selecting those faces again; two of a kind in one part: tag each whole with a name of yours and chain, faces.tagged("left").tagged("hinges.pin"). - Cut with planes: body.halves(body.plane(faces.top).offset(-10.mm)) gives .above and .below; body.split(plane) the pieces on both sides; s.pieces lists separate bodies, ordered along X, Y, Z. - Profiles: rect, circle, ellipse, polygon, slot, teardrop (the shapes of a sketch, same names and arguments, centred on the origin unless given a point: rect(40.mm, 20.mm, corner: 3.mm), rect(p, q), circle(c, d: 5.mm), rect(center: vec(15.4, 3.3), w: 5.6.mm, h: 14.mm, angle: 45.deg) (a rotated one, round a point), polygon(6, d: 13.mm, fit: "sides"), slot(p, q, w: 6.mm)), polygon(points), text; combined with + - &, moved, turned, scaled, mirrored like solids, .round(r), .offset(d); turn them into solids with .extrude(h) or .revolve(axis: Y). A prism along X or Y: .extrude(h, along: Y) (the profile's x along X, its y up Z, towards +Y; along: X its x along Y, towards +X; a negative h the other way); taper: 3.deg narrows the solid and widens its holes going away from the profile, whichever way it goes (a negative taper widens it and narrows the holes). shear: vec(dx, dy) moves the top sideways from the bottom, the sides leaning straight: an oblique prism (a blade's bevel; a section's outline that moves the same way at every level). A profile in another plane (a section, one turned or moved, one drawn on a face) extrudes across its own plane, from where it lies. On a face: body.extrude(profile, on: frame, depth:) adds, body.cut(profile, on: frame, depth: | through: true) removes. Stand a profile up with p.across(X) or across(Y) (as extrude(along:) does), not with rotate: loft([a.across(X), b.across(X).move(x: 20.mm)]) runs along X. - Sweeps and lofts: circle(d: 4.mm).sweep(path([vec(0, 0, 0), vec(0, 0, 30), vec(30, 0, 30)])), circle(d: 1.mm).sweep(helix(r: 5.mm, pitch: 2.mm, height: 20.mm), frenet: true), loft([circle(r: 20), rect(25).move(z: 40)]). A path to sweep along: drawn in a sketch (s.path; the sketch's constraints hold it), along a solid's edges (body.path(edges.loop(faces.top))), with bent corners (path(points, bend: 5.mm) for tubes and wires), or from two views (path(top.path, front.path): a handle drawn from above on XY and from the front on XZ). How the profile turns about the path: up: Z for a road that does not bank, guide: rail where up fails (a path going vertical), twist: t => angle for a roll that varies; never two of them. - Surfaces: a curve swept (path([vec(-8.mm, 0), vec(8.mm, 0)]) .sweep(route)) or paths lofted make a Surface: body.split(surface) cuts a solid along it (a tube split lengthwise by a ribbon swept along its own path), pieces.push(from: surface, by:) opens them, surface.thicken(t) and sew([surfaces, caps]) make solids. A part cannot be a surface. - Exact 2D outlines with dimensions: auto s = sketch(); points, lines, arcs, circles, ellipses, splines (s.spline([p, q, r]): a smooth curve through points, closed: true a loop of its own) and shapes (s.rect, s.polygon(c, 6, d:, fit: "sides"), s.slot, s.teardrop), then constraints (horizontal, vertical, length, distance, angle, tangent, on, equal, concentric…) and s.profile, to extrude or revolve. construction: true on any of them makes a guide to constrain to, never part of the profile. The profile fills what lies inside an odd number of loops: a loop in a loop is a hole, and where two loops overlap so is the overlap; s.fill(at) makes the area holding at full, s.fill(at, full: false) empty. Prefer a sketch over unions and differences of boxes and cylinders for the outline of a prismatic part (built from flat outlines, extruded, revolved and cut: a bracket, a plate, a case, a holder, a knob, a cam, a gasket, a clip; not a sculpted shape like a figurine), with the sizes given or estimated (estimated ones become @params): write each dimension as a constraint, aim for s.dof == 0 (a warning lists what is left free), then add 3D features (holes, fillets, shells) to the extruded solid. A profile made of more than three shapes, moved by sizes worked out by hand, or rounded with .offset(r).offset(-r) is an outline that wants a sketch: it reads as its dimensions, keeps its tangents when a @param changes, and its edges take fillets. One shape alone can stay one line (rect(40.mm, 20.mm, corner: 3.mm)); in a sketch (s.rect) its sizes become dimensions the user changes with a click in the window. Examples below: the medal (a first sketch, step by step), the pillow block (a part from five sketches on five surfaces) and the carlo library (a clip drawn with tangent arcs, a loop and if/else). - To build on a face, take its frame and place parts built on XY (Z up): cylinder(d: 8.mm, h: 5.mm).placeOn(body.frame(faces.top).at(x: 10.mm)). - To draw an outline on a face, measured from the part's own edges, sketch there: auto s = body.sketch(faces.top); its edges are references in s.edges (the lowest first, counter-clockwise: bottom, right, top, left on a rectangular face), s.origin is its centre, s.project(edges.…) flattens other edges onto it (a hole's rim below). Constrain the new geometry to them (s.distance(p, s.edges[0], 3.mm)), then body.cut(s.profile, depth:) or body.extrude(s.profile, depth:): the profile knows its face, no on:. In the window: hcad_gui_feature with action sketch on a flat face. - Axes and planes anywhere: rotate(90.deg, axis: body.axis(edges.filter(e => e.isCircle && e.radius == 3.mm))), s.mirror(plane(origin: vec(10, 0, 0), normal: X)), plane(p1, p2, p3), plane(axis, point). XY, XZ and YZ face the Top, Front and Right views (XZ's normal is -Y): a sketch on XZ reads as the Front view shows it. A plane kept in a variable shows in the window's planes, named after it; body.plane(faces.top, faces.bottom) is halfway between two parallel faces. - Existing parts: load("part.stl"/".obj"/".ply"/".3mf"/".glb"/".step", scale: 1) gives a solid to modify (glTF is turned from metres and Y up to mm and Z up; repair: true mends a scan's doubled walls and sheets that touch); text("LABEL", size: 4.mm).centered centres a profile for engraving. - When asked to copy lettering from a photo, a drawing or a model: if the font cannot be recognised, or text() does not have it (it uses the fonts installed), ask the user before going on, offering a similar installed font (name it), or tracing the letters as a sketch (lines and arcs sized from the picture), or load("letters.svg") if they can give a vector tracing; never swap the font before the user has chosen (saying so afterwards is not enough). Working with no one to ask (a task run on its own): trace the letters as a sketch, and say so in the report. Logos, engraved or hand-drawn lettering are traced as a sketch. Lettering on a mesh or STEP to copy needs no question: its exact shape is there (see Remodelling a mesh). - Constants are immutable; module-level values cannot be modified. - Solids, profiles and paths are values, not references: b.move(x: 5.mm); alone changes nothing (warning E0208), write b = b.move(x: 5.mm); only addTag, removeTag and clearTags change the solid they are called on. - Expose the dimensions a user may want to change as parameters: @param("Outer width", min: 20.mm, max: 200.mm) immutable width = 60.mm; and put requirements in unittest blocks: unittest { assert(body.bbox.size.z <= 40.mm, "must fit in the drawer"); } - Use the libraries instead of modelling common parts by hand: import std; gives them all (import std.hardware : M3, hole; only those names). std.hardware: M3.clearance, M3.tap, screw(M3, 20.mm), nut(M3), hole(M3, 5.mm, "counterbore"|"insert"|…); std.threads (threadedRod, tappedHole); std.gears (spurGear, rack, centerDistance); std.hinges (hinge(…).fixed/.moving, print in place); std.snaps (snapHook, snapSlot); std.enclosures (roundedBox, tray, enclosure(vec(x, y, z)).base/.lid); std.joints (splitJoint(body, plane, kind: "dovetail"|"dowels"|…).a/.b for a part longer than the bed, .loose/.print the wedge or dowels in place and laid flat; splitFlat for two halves printed on their cut faces, joinParts(a, b, plane)); std.molds (mold(part, plane).aPrint/.bPrint); std.flex (kerf(body, zone) to make it bend; hinges also has pinHinge, snapHinge, livingHinge). Built in: region.tiles(kind: "hex"|"voronoi"|…) fills a profile with cells, body.knurl(face) raises a grip, body.cut(profile, wrap: face, depth:) lays a profile on a curved face. - Before modelling anything common by hand (a teardrop, a screw hole, a nut trap, a thread, a slot, a rounded box), look for it: hcad_docs with a topic, or format: cheatsheet (every function in one line); write your own only when nothing fits. - Functions are ordinary arguments: xs.map(x => x * 2), xs.filter(f). - Your own modules: import parts.bracket; is parts/bracket.hcad next to the model; import pizza; is pizza.hcad or pizza/package.hcad (which can public import its modules), also from the personal library folder. - Strings interpolate: writeln("volume: $(body.volume)"); text("…", size:) is a 2D text profile, not a string function. - Errors come with a line, a column and often a suggestion: apply it. - Technical drawings: a function marked @drawing returns drawing(title: …), filled with d.standardViews(part) (views, overall dimensions, hole callouts) and d.section(part, XZ, label: "A"); export it as .pdf/.svg/.dxf. - Keep models quick to evaluate: text cut into curved faces and threads take seconds; prefer text in relief as a separate part, and plain holes or heat-set inserts (hole(M3, d, "insert")) or ribs the screw cuts its thread into (hole(M3, d, "ribs")) to modelled threads under M5. - Style: annotations (@param, @preset, @part, @drawing) go on their own line above the declaration, several per line when short; chains break before each call, the line starting with the dot; lines short (the editor shows about 56 characters at its default width); an empty line above each comment (not right after { or another comment); comments say why. - For an example, a trial or an explanation that needs some object to work on (to move, cut, place, measure), use Carlo, the mascot clip: import std; then carlo(), and call it Carlo (always the same, recognisable, small and quick to evaluate). Not instead of an object the request names (the phone, the shaft): model that one. ## Sketches: habits - Pin one point (fixed: true) or the outline floats. - A distance is never negative: which side comes from where the points are drawn; draw them roughly where they go. - Guides that are not part of the outline: construction: true lines and circles (a diagonal whose midpoint is the centre, a circle to inscribe in). - s.on(shape, circle) puts every corner of a rect/polygon on a circle, s.tangent(shape, circle) every side touching it; polygon(…, fit: "sides") sizes it across the flats (a nut's spanner size). - A profile is a flat closed shape with its holes. One outline told by its sizes and relations: a sketch. Overlapping shapes, an offset outline, text or an SVG on a shape: profile operations (+ - &, .offset). They mix: s.profile + circle(d: 5.mm). - Tag the lines and circles whose faces a later step works on (a flank for a label, a hole's wall to thread): sides[1].addTag("flank"), then faces.tagged("flank") on the solid. It holds when the profile is extruded or revolved as the sketch gives it (moved or turned first is fine), not when it is joined to another profile, tapered or swept first. ## Prismatic parts and 3D printing - A prismatic part: its outline as a sketch from the drawing's dimensions, extruded or revolved; then fillets and chamfers; then holes (hardware: hole(M3, depth, "clearance" | "tap" | "insert" | "ribs" | "counterbore" | "countersink")); then check. - Then build in steps, as on a machine: each further feature (a boss, a pocket, a slot, a rib, a step) starts from a face and is extruded from it or cut into it; on the faces it makes, again. Not one profile with everything at once: each step reads as what it does, and follows its face when a @param moves it. A feature of one shape at a place you know is one profile on the face: body.cut(circle(d: 6.mm), on: body.frame(faces.top), depth: 3.mm), body.extrude(rect(10.mm, 4.mm), on: …). A sketch on the face, body.sketch(faces.…), when its outline has several shapes or relations (tangent, equal), or its place is measured from the face's edges. A feature with a tool of its own takes that tool: hole() for a screw, shell for thin walls, a loop for a pattern, a library part, a loft or sweep for a shape that changes along its length. Example: the pillow block. - Fits: ask how it will be made (FDM, resin, machined) before choosing clearances; for FDM about 0.2–0.3 mm for a sliding fit, press fits from a test print. Put clearances in @params. - Holes across a wall that will print sideways: teardrop(d:) with the point up (flat: true cuts the point). Say how the part is meant to print, and lay it so (its largest flat face on the ground); look at --printability. ## Copying a real part, or building onto one - From a photo or a real object to copy or build onto (not a mesh or STEP: those have the sizes, see below), your first reply is the questions, before writing any code or touching the user's window (no hcad_gui_new, no hcad_gui_edit), even when the user says "make it" without giving sizes. In one message: the sizes to measure (below), the font if there is lettering, and, said plainly, that any of them can be left: the more answered the better, the rest estimated from the photo (they may be off: @params to correct after a test print). Estimate only when the user answers so, or already said so in the request ("just estimate", "I have no caliper"). Saying at the end what was estimated does not replace asking. - Do not invent shapes or sizes you are not sure of: ask. Ask in the way that helps: not "how big is it?" but what to measure and how ("with a caliper, the distance between the centres of the two holes, axis to axis"; "how thick is the rim at the arrow in the photo?"; "is the left side straight or curved?"). Number the questions, say what each size is for, and which sizes are critical (fits, holes) and which can be rough. If, once asked, the user cannot measure or asks you to estimate, say what is estimated and make those sizes @params to correct after a test print. - From a photo: ask for one known size (the scale) and about the hidden sides; say which sizes are guessed. ## Remodelling a mesh or STEP - The sizes are in the file: start at once, no questions. Load it as a see-through reference part beside the copy (@part("slategray", alpha: 0.35, export: false) Solid original() => load("x.stl");), straightened if tilted (load(…).aligned). - 1, read it: hcad_features (chamfers with their size, holes, surfaces turned round an axis), hcad_scan along x, y and z (the holes and pockets as the operations they were made with), then hcad_section at the levels the scans name: exact lines and arcs (region: only the outlines inside a rectangle, a lettering, a pocket). - 2, build from the largest to the smallest: the outside as one outline per stretch, the sketch hcad_section gives at that level pasted and extruded; then, as for any prismatic part, the features on its faces in steps: one of one shape (a round hole, a rectangular pocket, a boss) is a profile with the sizes hcad_scan and hcad_features give (hole() when they name a screw size); one whose outline has several shapes, a section of the original parallel to that face, just inside it, its sketch pasted and extruded from the face or cut into it (the sketch is in the plane's own coordinates: extrude it along that axis from the face's level); the chamfers of the end faces before anything is cut in them; then holes, pockets and traps; last small chamfers, notches and lettering. Lettering (small regular grooves 0.5 mm deep in a face are lettering, not notches): cut a section 0.25 mm inside the face, paste the sketch hcad_section gives with region round the letters (it solves as it is), extrude it 0.5 mm into the face; never trace by hand. - 3, THE ROUND: after every change, hcad_review (hcad review on the command line). One call does it all: the distances both ways, the material that differs, the drawings outside and of the voids, and close-ups of the first entries of each, chosen and framed where they are in sight; it ends saying whether it is done or what comes next. It is a summary, the first three entries of each report; detail: true gives them all. Fix what the close-ups show and review again. To look at a place again after a change, give its point (look: [x, y, z]): entry numbers change, points do not. The single tools (hcad_compare, hcad_diff, hcad_lines, focus, view) go deeper on one place; hcad_section with compare where the review says, to read a shape; opaque: true draws the reference solid. - 4, then: hcad_match (the operations one by one: a hexagon where the copy has a circle, a trap cut in steps, a notch for printing); hcad_lines with depth once the outside and the voids match; hcad_fit only to tune sizes of a shape already right. - 5, done when hcad_review says so (95% or more of both surfaces within 0.1 mm) and its close-ups show nothing worth fixing. - 6, name the measures that matter: hcad_literals lists the numbers the model repeats and which sizes of the original they are (a hole's diameter, an axis); make those @params or named constants, use the names, and put the relations in unittest blocks (the rod fits its hole). Tag the faces that do the work (the one it rests on, a mating face, a screw's seat) where they are made, so the checks and later changes find them by name. - What the numbers do not say: hcad_diff leaves out what is thinner than two cubes, and says how much it left (chamfers, lettering, notches: look at hcad_lines). When hcad_fit takes away little, or moves a size away from what an exact section measures, the shape differs, not the sizes: cut sections elsewhere, off the axis too (a step that drops towards the sides is often turned round an axis; hcad_features names it). - Parts printed and shared online have habits to look for: notches on top of sideways holes, a layer closing a hole for a millimetre (printed over, drilled after), teardrop points cut flat at an angle that is not 45° (read it from the section, not teardrop()), nut traps wider at the mouth, 0.5 mm chamfers where holes open. ## Before saying it is done - No errors and no warnings (an under-constrained sketch's warning counts); the sizes match the request; reference parts fit, with no unwanted overlap ((a & b).volume); printability looked at if it is to be printed; the pictures actually looked at. - Say what was not checked (a fit that needs a test print, a size guessed). - Say it briefly, and name the @params the user can change to try variants. ## Showing and asking with annotations (hcad_gui_annotate) - Use them to ask and to answer, not only words: "is this the face you mean?" (an arrow on it), "the wall is 0.8 mm here" (a cross and a label), "should the hole go here or here?" (two numbered crosses, and the question in the chat with the same numbers). When an answer is about a place on the model, show it. - Turn and frame the view first, so it can be understood: the part of interest in sight, not hidden behind another, seen from a side that makes it recognisable, with enough around it to tell where it is on the part (not zoomed so close that it is unclear what it is, nor so far that the detail is lost); one view per question if needed. Annotations lock the view: frame it, then annotate. - Unless the user asks otherwise: arrows, crosses and circles in a colour that stands out in that view, against the part's colour and the background (not the part's own colour: red or magenta on a sky-blue part, blue or black on a red one); short labels, off the part; few marks at a time, one per thing to point at. - When the user says "here", "this", "from here to there", or asks you to look at the marks: read them in hcad_gui_state, not in a screenshot. Each point has the part, face, point in mm and selector under it; a point on the background has points_at, the surface the mark points at (ahead of an arrow, or the nearest round it) and how many pixels away; each mark has colour_name beside its hex (the default magenta is often called purple or violet). A screenshot only for what the data cannot tell: the shape of a line drawn by hand, a mark with nothing under it and no points_at. If in doubt, confirm what you understood with a mark of your own before changing the model. - When they have served, clear your marks (clear: "agent", or mode: false): while any are there the view stays locked and changes to the model do not show. Do not clear the user's marks without asking. # The `.hcad` language A `.hcad` file is written in an **interpreted, sandboxed scripting language inspired by D**. Anyone who knows D can read it without surprises, and its C-style syntax reads at once to anyone who has written C, Java, C# or JavaScript: braces, semicolons, `if` and `for`, functions with typed parameters. There is no compilation step: the interpreter runs inside hackerscad and re-evaluates a model in milliseconds. New to it? The [tour of the language](#a-tour-of-the-language) at the end is one model that uses each basic construct once, with comments saying what each makes appear; in the application it is under **Examples › Language tour** (and ☰ › Language Tour). This page lists everything in brief; the [guides by topic](GUIDES.md) explain each subject at length, with pictures. ## What stays the same as D - **Lexical:** identifiers; numbers (`42`, `1_000`, `2.5`, `0xFF`, `2.5e3`); strings `"…"`, `` `…` ``, `r"…"` (the last two WYSIWYG as in D); comments `//`, `/* */` and nested `/+ +/`. - **Declarations:** `immutable`, `const`, `auto`, typed variables, `struct` (fields, defaults, methods), functions (with `{ … }` or `=> expr;` bodies), `import`, `unittest`, UDAs. - **Statements:** `if`/`else`, `foreach` (`foreach (i, x; arr)`, `foreach (i; 0 .. n)`, `foreach_reverse`), `for`, `while`, `break`, `continue`, `return`, `assert`, `switch` (with `case a, b:` and `default:`). A condition (of `if`, `while`, `&&`, `!`, `?:`, a `filter` or `any` predicate) is a `bool`, or an integer as in D (0 is false); a length, a solid or a text there is an error: compare it (`e.length > 2.mm`). - **Assignments:** `=`, `+=`, `-=`, `*=`, `/=`, `%=`, `~=`, `++`, `--`. - **Expressions:** - D precedence, including `^^`; ternary `?:`. - UFCS: `x.f(y)` is `f(x, y)`. - Named arguments: `f(x: 1)`. - Lambdas: `x => e`, `(a, b) => e`, `(int x) { return x; }`. - Functions are ordinary arguments: `xs.map(x => x * 2)`, `xs.map(&twice)`. - Array literals `[1, 2]` and associative literals `["a": 1]`. - Slices `a[1 .. $]`, concatenation `~`, `in`. - Struct literals `Screw("M3", 3.mm)` or `Screw(d: 3.mm)`. - **Paren-less calls:** a declared function whose parameters all have defaults is called when named without parentheses (`body.bbox`, `nextTo(body, …)`). Use `&f` to pass the function itself. - **Modules:** - `import std;` (every built-in library at once), `import std.math;`, `import std.hardware : M3;`, `import hw = std.hardware;`, `import lib.hinges;`, `import a, b;`. - `lib.hinges` resolves to `lib/hinges.hcad`; a folder is a package: `import pizza;` loads `pizza.hcad`, or else `pizza/package.hcad`, whose `public import pizza.base, pizza.toppings;` makes their names visible to whoever imports `pizza` (as in D). - Modules are looked for in the directory of the model, then in the folders of `$HCAD_PATH`, then in the personal library folder: `~/.local/share/hackerscad/lib` on Linux (`$XDG_DATA_HOME/hackerscad/lib`), `~/Library/Application Support/hackerscad/lib` on macOS, `%APPDATA%\hackerscad\lib` on Windows. The built-in libraries are `std.hardware`, `std.gears`… (`import std;` imports them all, also as `import std : M3, hole;` or `import s = std;`; imported without `std.`, the old way, they still work with a warning, and a module of yours of that name comes first). - `private` symbols are not exported. ## What is different from D - **Strings interpolate:** `"volume: $(body.volume)"` inserts the value of the expression; `\$` is a literal dollar. Backtick and `r"…"` strings are WYSIWYG and never interpolate. - **`~` joins anything with a string:** `"side " ~ width` gives `"side 60 mm"` (in D both sides must be strings). - **Dynamic typing with run-time checks.** - `auto` works everywhere. - Declared types (`Length h`, `Solid f()`) are checked on calls, returns and assignments, with clear errors. - There are no templates: functions are passed as normal arguments (`xs.filter(x => x > 0)`). `!` is only used for type conversions (`to!string`). - **Value semantics.** Arrays, associative arrays and structs behave as values: `b = a; b[0] = 9;` does not change `a`. Solids, profiles, surfaces and paths too: a method gives a new one and leaves the one it is called on as it was, so `pieces[0].color("red");` alone changes nothing (a warning says so, E0208); write `pieces[0] = pieces[0].color("red");`. Only `addTag`, `removeTag` and `clearTags` change the solid they are called on (and give it back too). - **An array literal holds one type**, as in D: `[1, "a"]` or `[1.mm, 2.deg]` is an error naming the two elements; whole and fractional numbers mix, and bare numbers with Lengths (`[1.mm, 2]`, the bare number in mm, as in `vec`). - **Purity.** - Locals inside functions are mutable. - Module-level declarations (`immutable`, and even `auto`) are read-only and evaluated lazily, in any order. Constants use `immutable`; `enum` is reserved for enumerations. - Every function is therefore deterministic, and results can be cached. This is the basis of incremental regeneration. - **Not available:** classes, pointers, `mixin`, `static if`, user templates, exceptions, `asm`, `alias`, `new`. - **Sandbox.** - `load()` reads mesh, STEP and drawing (SVG, DXF) files inside the project directory only. - Library imports are limited to hackerscad's standard modules: `std.shapes`, `std.math`, `std.algorithm`, `std.range`, `std.conv`, `std.format`, `std.stdio`, `std.array`, and the built-in libraries (`std.hardware`, `std.threads`, `std.gears`, `std.hinges`, `std.snaps`, `std.enclosures`, `std.joints`, `std.molds`, `std.flex`, `std.carlo`; `std` is all of them). - Imports cannot leave the project directory or the library folder they were found in. - There is no file, network or process access. - Evaluation steps, recursion depth and array sizes are limited. - **`writeln`** writes to hackerscad's console (and to the JSON output for agents), not to stdout. A shape is written as a short summary (`Solid 30 × 20 × 10 mm, volume 6000 mm³`, `Profile 30 × 20 mm, area 600 mm²`, `Path 82.3 mm long, open`), a selection taken from a solid with how many it selects (`faces (3)`). ## hackerscad additions - **Units** are UFCS functions: `60.mm`, `2.5.cm`, `1.m`, `1.inch`, `45.deg`, `0.5.rad`. - Values carry a dimension (`Length`, `Angle`, `Area`, `Volume`), and arithmetic checks it: `Length * Length` is an `Area`, while `60.mm + 45.deg` is an error. An angle multiplied with a length, or a length divided by an angle, counts in radians as the plain ratio it is (an arc over its radius): `r * 90.deg` is the length of a quarter of a circle of radius r, a `Length`, and `arc / 90.deg` is the radius of that arc, a `Length` too (`arc / r` is a plain number, the angle in radians). - **Bare numbers** passed where a length is expected use the file's default unit: `mm`, or the unit set with `@unit(inch) module name;`. Values keep their unit, so libraries written with different default units combine correctly. - Mixing bare and dimensioned numbers in arithmetic (`60.mm + 5`) is an error. Scaling is fine (`2 * 60.mm`). - A dimensioned number as a bare one: `d.to!double` is the bare number that means it in this file, a length in the file's unit (millimetres, as `d / 1.mm`, or inches under `@unit(inch)`; squared for an area, cubed for a volume), an angle in degrees (as `a / 1.deg`); `d.to!int` the same when it is whole. To mix it with bare numbers: `c.y + d.to!double / 2`, or give the other side its unit: `vec(6.25.mm, 31.5.mm)`. - Bare angles passed to features (`rotate(z: 45)`) are degrees. `sin`/`cos` take radians for bare numbers, as in D. - **Vectors:** `vec(10.mm, 5.mm)`, `vec(x, y, z)`, with `.x .y .z .xy .length .normalized`, `dot`, `cross`, `distance`. Vectors compare with a small tolerance. - **Directions:** `X`, `Y`, `Z`, `-X`… - **`@param("description", min:, max:, step:, choices:)`** above **`immutable name = default;`** declares a parameter. It can be overridden from the GUI, from the CLI (`-D name=80.mm`, or `-D name=80` in the model unit; for a string, `-D style=square` is the word itself when it names nothing, else quote it: `-D style='"square"'`) and by agents, and is checked against its limits. - **`@preset("name", value)`** on a parameter gives its value in a named preset (`--preset name`, or the preset menu in the GUI). A preset is the set of values declared under its name; parameters without a value for it keep their default. - Parameters never change during an evaluation: their value is decided beforehand, from default, then preset, then explicit override. - Like a configuration table: every parameter is a column, every preset a row. - **`@part("colour")`** above **`Solid name() { … }`** declares an exported body: a separate object in 3MF, a separate named body in STEP, its own file in a zip of STLs (`name.stl`; a lone `.stl` takes one part). A part is evaluated once per run even when other parts refer to it. Without a colour a part is Sky Pop (`#0FC3F2`). `@part("slategray", alpha: 0.35)` makes it see-through; `export: false` makes it a reference part (a shaft, a board, the object a holder is for): drawn in the view and in PNGs, left out of STL, 3MF and STEP. `name: "Tank"` is the part's name in exported files (object, body and file names) instead of the function's; two parts cannot share one. - **`@parts`** above a function makes **a part of each separate body** it returns: of a `Solid` (two halves of a cut, copies that do not touch, the objects of a loaded 3MF) or of each element of a `Solid[]`. The parts are named after the function with a number, `jaws1`, `jaws2`… (just `jaws` when there is one), in the order of `.pieces` (along X, then Y, then Z); how many there are comes from the evaluation, so a parameter can change it. It takes what `@part` takes, plus `varied: true`, which gives the pieces colours that tell apart (the first in the part's colour). A piece coloured on its own (`pieces[1] = pieces[1].color("Blue Pop");`, what the menu's Colour writes on a piece) keeps its colour; the others take the annotation's. A solid in a `Solid[]` made of several bodies stays one part. An empty `Solid[]` makes no part: a part to choose with a parameter, `@parts Solid[] lid() => withLid ? [makeLid()] : [];`. `@part` on a function returning a `Solid[]` is an error that points at `@parts`; on a `Solid` of several bodies it is one part (one object in 3MF). ```d import std; // Carlo, the mascot clip, is in it: an object to try things on immutable cutPlane = plane(origin: vec(10, 0, 0), normal: X); @parts(varied: true) Solid[] pieces() => carlo().split(cutPlane); // pieces1, pieces2, pieces3: the plane crosses the slot @parts("Yellow Pop") Solid jaws() => carlo().halves(cutPlane).above; // jaws1, jaws2: the side above is two bodies ``` - **Colours** are CSS names (`"steelblue"`), hackerscad's own bright colours, alike in brightness under the view's light and offered by the 3D view's right-click menu (`"Sky Pop"` — the default —, `"Blue Pop"`, `"Cyan Pop"`, `"Violet Pop"`, `"Pink Pop"`, `"Rose Pop"`, `"Red Pop"`, `"Orange Pop"`, `"Yellow Pop"`, `"White Pop"`, `"Gray Pop"`: with the space, `"Pink Pop"` is not the web's `"pink"`), `"#RRGGBB"`, `"#RRGGBBAA"` (the last byte is the opacity) or `rgb(r, g, b)` with 0…255 channels, `rgb(r, g, b, alpha: 0.5)`. `solid.color("orange", alpha: 0.5)` works too. - **`unittest { assert(…); }`** blocks are design checks. They run after evaluation; a failure does not discard the geometry, but is reported in the GUI, the CLI and to agents. `hcad check`, `hcad render` and the exports run them every time; the window runs them when asked (F5, the bug button over the console, `hcad_gui_test` for agents) and before an export, not at every change. - **Solids and booleans:** - `a + b` union, `a - b` difference, `a & b` intersection; `s += x` inside functions. - `hull(a, b, …)` or `hull([a, b, …])`: the smallest convex shape around solids and profiles (two spheres make a capsule, four cylinders a plate with round corners); profiles in one plane give a profile, `hull(circle(r: 4.mm), circle(r: 4.mm).move(x: 20.mm))` a slot. Curved surfaces become flat facets within 0.05 mm. Their flat faces the hull lies on keep their tags. `hull(a, b, grow: 2.mm)` is the hull grown that much all round, its edges and corners rounded (at most 2% farther, never nearer): a case round what it holds, quicker and surer than `.offset` on a hull of thousands of faces. - `minkowski(a, b)`: `a` moved by every point of `b`. With a sphere (a circle for profiles) it rounds every edge and corner exactly, like `s.offset(r)`; with another convex shape (a box, a cylinder) it is made of flat facets, and one of the two shapes must be convex. Their flat faces, moved out, keep their tags; `a`'s own tags and references stay. - `s.offset(d)` grows a solid by `d` (or shrinks it, `d` negative), round where it turns; `sharp: true` moves its faces and extends them until they meet, the edges staying sharp (a box stays a box). Holes it grows over (a hole's radius or more) close up: they are filled first. - **Printing:** `teardrop(d: 5.mm)` (in a sketch `s.teardrop(c, d: 5.mm)`) is a round hole with a 45° point towards +Y: cut on a wall (`carlo().cut(teardrop(d: 3.4.mm), on: faces.farthest(-Y), through: true)`) the point is up and the hole prints without supports (`angle:` another point, `flat: true` cuts it at the circle's top). `faces.overhanging()` selects the faces facing down more steeply than 45° from a wall (`faces.overhanging(60.deg)` another angle; faces resting on the bed are not counted), `s.overhangs(45.deg)` is their area and `s.thinWalls(0.8.mm)` the surface over walls thinner than that: `assert(part.thinWalls == 0.mm ^^ 2)` in a unittest keeps a model printable. The Printability check box of the display menu marks both on the model. - **Profiles from solids:** `s.section(XZ)` (any plane: `s.section(s.plane(faces.top).offset(-2.mm))`) is the cut of the solid by the plane, a profile lying in it; `s.projection` is the solid's shadow seen from above, a profile on XY, and `s.projection(XZ)` its shadow on any plane, cast along the plane's normal (curved surfaces within 0.02 mm; its outline read back as lines, arcs and circles, so that it extrudes and sweeps as fast as one drawn). Both can be extruded, offset, combined like any profile. - **Mass:** `s.centroid` is the centre of mass; `s.mass(density: 1.24)` the mass in grams for a density in g/cm³ (PLA 1.24, PETG 1.27, ABS 1.04, aluminium 2.70, steel 7.85); `s.inertia(density: 1.24)` the principal moments of inertia about the centre of mass, ascending, in g·mm². - `a - [b, c, …]` takes every solid of the list away from `a` at once: much quicker than one at a time (400 holes in a plate: 0.7 s, against 16 s in a loop). - `Solid.init` is the empty solid; `Solid all;` and `Profile all;` start empty, to add to in a loop. `solids.sum` and `fuse(…)` unite arrays. A `@part` that returns `Solid.nothing` is no part this time (`return solid ? Solid.nothing : cap();`: a lid left out by a parameter); one left empty otherwise (a cut that took it all, a loop that added nothing) is an error, as it is most likely a mistake. - **Math** (with `import std.math`): `sqrt`, `abs`, `floor`, `ceil`, `round`, `sin`, `cos`, `tan` (angles; bare numbers are radians), `asin`, `acos`, `atan`, `atan2(y, x)`, `pow(x, y)` (as `x ^^ y`: a length squared is an area), `exp`, `log(x)` and `log(x, base)`, `log10`, `log2`, `hypot(a, b)`, `sign`, `min`, `max`, `clamp(v, lo, hi)`, `lerp(a, b, t)` (numbers, lengths or vectors), and `PI`, `E`. - **Primitives** stand on the XY plane, centred in X and Y; `center: true` centres them in Z too. Spheres are centred. - `box(x, y, z)`, `cylinder(r | d, h)`, `cone(r1 | d1, r2 | d2, h)`, `sphere(r | d)`, `torus(major, minor)` (round Z, centred on the origin: major to the middle of the tube, minor its radius). - **Profiles** (2D shapes on the XY plane, type `Profile`): - The shapes are those of a sketch (below), with the same names and arguments: a profile's shape is a one-shape sketch with everything given, centred on the origin unless a point is given (a vector, `vec(10, 5)` or `[10, 5]`). `rect(w, h)` (`rect(20.mm)`, a square), `rect(p, q)` (two opposite corners), `rect(p, w:, h:)` (from its lower left corner) or `rect(center: c, w:, h:)`, with `angle: 30.deg` (rotated about its centre, its first side that far from X: a slanted rectangle anywhere is `rect(center: vec(15.4, 3.3), w: 5.6.mm, h: 14.mm, angle: 45.deg)`) and `corner: 3.mm` (the four corners rounded); `circle(r | d)` or `circle(c, r | d)`; `ellipse(rx, ry)` or `ellipse(c, rx:, ry:)`, with `angle:`; `polygon(6, d: 10.mm)` or `polygon(c, 6, d: 10.mm)`, regular (d/r through the corners; `fit: "sides"`: the circle the sides touch, `d` across the flats; `angle:` the direction of its first corner, else its first side lies at the bottom, horizontal); `slot(length, w)` (a stadium, length end to end along X; `angle:` turned, `center:` elsewhere) or `slot(p, q, w:)` (round two centres); `teardrop(r | d, angle:, flat:)` or `teardrop(c, r | d, …)` (see Printing). - Not sketch shapes: `polygon([vec(0, 0), vec(30, 0), vec(0, 20)])` (through the points; `smooth: true`, a smooth closed curve through them), `text("text", size: 10.mm, font: "Sans")` (starting at the origin on the baseline; `weight: "light"`, thin … black or 100 … 900, the nearest the font has installed; `bold: true`, `italic: true`, a style at the end of the font's name counting too, `"Sans Bold"`; `spacing: 1.mm` more between the letters, `lineSpacing: 1.5` times between the lines) and `load("outline.svg")` (see Importing parts). - `s.contains(p)`: whether point `p` lies inside a solid (on its surface: no), or inside a profile's area (`p`, a world point, taken onto the profile's plane); e.g. to choose the half of a cut that holds a point. A profile drawn on a face or lying in another plane gives its `bbox`, `spots` and `outline` where it lies, in world coordinates, as `contains` reads them. - A profile's parts: `p.outer` (the outline without its holes), `p.holes` (each hole filled, a Profile each), `p.pieces` (its separate regions, each with its holes). - Combine with `+ - &`: profiles overlap, add up and cut each other, where a sketch draws one outline; a sketch's `s.profile` is a Profile too, so the two mix (`s.profile - circle(d: 5.mm)`). Profiles move, turn, scale and mirror like solids: `p.move(x: 10.mm, y: 5.mm)`, `p.rotate(30.deg)` or `p.rotate(z: 30)` (in its plane, about the origin; `around: q` about a point), `p.scale(2)` or `p.scale(x: 2, y: 3)`, `p.mirror(X)` (across the line through the origin with that normal). `.round(r)` rounds the corners; `.offset(d)` grows (d > 0) or shrinks it (shrunk to nothing, an empty profile); `.area`. A difference that takes all of a profile is an empty profile too (`isEmpty`), still a Profile; extruding one is an error that says so. `p.across(X)` stands it up across X as `extrude(along: X)` does (its x along Y, its y up Z; `across(Y)`: its x along X): `loft([a.across(X), b.across(X).move(x: 20.mm)])` lofts along X. - `.extrude(h)` makes a solid along +Z (negative h goes down); options `shear: vec(dx, dy)` (the top moved that much sideways from the bottom, the sides leaning straight: an oblique prism), `taper: 3.deg` (the sides lean in going away from the profile, whichever way the solid goes: up, down with a negative height, along X or Y; staying flat and meeting at sharp edges: the outline narrows and the holes widen; a negative taper the other way; `h` is still the height along the axis, and an edge or hole of the profile that would close up before the top is an error), `symmetric: true` (half the height each side of the profile, which is the middle of the solid: a taper narrows both ways from it, a twist turns half each way), `twist: 90.deg` (the profile turns around Z as it rises, evenly: twisted vases, spiral columns; `twist: 2 * 360.deg` two turns; not with `taper`). `along: Y` stands the profile up across Y (its x along X, its y up Z) and pushes it towards +Y, `along: X` across X (its x along Y, its y up Z) towards +X; a negative height goes the other way: a prism along X or Y without turning anything. A profile lying in another plane, a section (`body.section(XZ.offset(10.mm)).extrude(2.mm)`), one turned or moved whole, or one drawn on a face, is extruded across its own plane, along its normal, from where it lies. - `.revolve()`: turned around the axis, standing up: the profile's x is the radius and its y the height along Z (pots, bottles, knobs). `.revolve(axis: X, angle: 180.deg)`: around an axis in the profile's plane (a direction through the origin or an `Axis`), without standing up; `angle:` turns less than all the way. A profile drawn on a face or plane, or lying in one (a section, one turned), turns where it lies, and its axis is a world one, as everything else in that plane (a direction through the plane's origin, an `Axis` anywhere): the half section of a lathe part drawn on XZ, as the Front view shows it, turns round Z with `revolve(axis: Z)`, or `revolve()`, which turns a profile standing upright round its own y axis. - On a face or plane, add or remove material in one step: `carlo().extrude(circle(d: 6.mm), on: frame, depth: 5.mm)` grows outwards along the frame's Z; `carlo().cut(circle(d: 6.mm), on: frame, depth: 2.mm)` digs in, `through: true` cuts all the way. The depth is always positive, the call saying which way (a negative one is an error: use the other call). Both accept `taper:`; `on:` also takes a face selector (`on: faces.top`) or a plane, and `at: vec(…)` a point, brought onto the face, where the profile's origin goes (`body.cut(circle(d: 3.mm), on: faces.top, at: vec(5.mm, 2.mm, 0.mm), depth: 2.mm)`); a solid in place of the profile is placed as it is, its z = 0 on the face and +Z out of it, no depth: `body.cut(hole(M3, 6.mm, "counterbore"), on: faces.top, at: vec(…))`. - Threads: `body.thread(face, "M8")` cuts a thread on a cylindrical face: on the outside of a shaft (made at the thread's nominal diameter, ⌀8) the grooves, on the wall of a hole (made at the tap drill, about ⌀6.8 for M8) the thread a screw fits. The size names the standard: ISO metric `"M8"` (coarse) or `"M8x1"` (fine), unified inch `"1/4-20 UNC"`, `"1/4 UNF"`, `"#10-32"`, trapezoidal for lead screws `"Tr12x3"`, ACME `"1/2-10 ACME"`, buttress `"S20x4"` (DIN 513), BSP pipe threads `"G1/2"` (parallel) and `"R1/2"` (tapered), `"1/2 NPT"` (tapered; the shaft is the pipe's outside diameter), Edison lamp bases `"E27"`, `"E14"`, bottle and jar necks `"28-400"` (SPI/GPI 400, 410, 415 from 13 to 120 mm), PET bottles `"PCO 1881"`, `"PCO 1810"`, and the garden hose `"3/4 GHT"`; which to use, and how to recognise one, under Threads: choosing one, recognising one. Tapered threads have their nominal size at the fixed end and narrow by 1 in 16 on the diameter towards the tip of a shaft, or into a hole. `taper: 1.79.deg` makes any thread conical the same way, the angle each side leans from the axis (R and NPT have 1.79°, 1 in 16; `taper: 0.deg` makes them parallel): a tapered metric plug, a conical thread that tightens as it goes in. `pitch: 1.mm` alone puts a 60° thread of that pitch on the cylinder's own diameter. The thread covers the whole face, or `length: 10.mm` or `turns: 8` from the free end (the tip of a shaft, the opening of a hole; `flip: true` the other end). `starts: 2` for multi-start threads, `left: true` left-handed (every thread is at the same angle at its free end: a shaft and a hole threaded from the same free end mesh, whatever their lengths, so a nut cut out of a tube threaded over a bolt's tip runs on it; the Examples menu's dual-thread bolt does so), `clearance: 0.2.mm` the play for printed parts (0.15 mm by default in holes, none on shafts), `chamfer: false` without the 45° bevel at the free end. In the 3D view, right-click a cylinder: **Thread** lists the sizes that fit it. Every thread is built exactly from its boundary (each corner of the profile on its own helix, on a cone for tapered ones, a ruled face between two of them; the profile repeated in a lead for more starts) and cut to the face: a second or less for an M8 × 25. The `threads` library (`threadedRod`, `tappedHole`) makes loose rods and tools to cut. - Extrude and cut faces: `carlo().extrude(faces.top, depth: 5.mm)` sweeps flat faces of the body outwards along their normals and adds the material, `carlo().cut(faces.top, depth: 2.mm)` sinks them (a negative depth to extrude does the same). `along: vec(1, 0, 1)` sweeps them in another direction, slanted sides: material is added when the direction leaves the face, removed when it enters. In the 3D view: right-click a face, Extrude face… or Cut face…. - **Paths, sweeps and lofts:** - Paths (type `Path`): `path([vec(0, 0, 0), vec(0, 0, 20), vec(30, 0, 20)])` (straight segments, mitred corners; `closed: true`), `spline([…])` (a smooth curve through the points), `helix(r | d, pitch:, height:, left: false)` (around +Z, starting at (r, 0, 0)). Paths move, rotate and mirror like solids; `p.length` is how long one is, along it. - `path([…], bend: 5.mm)` rounds every corner with an arc of that radius, as a bent tube or wire: straight stretches and arcs of a known radius (unlike `spline`, curved all along); a corner whose sides are too short for its arc is an error naming it. - From a sketch: `s.path` is what the sketch draws (its lines, arcs, curves and circles that are not construction) as one path, open or closed, where the sketch lies; drawn with its constraints, so it follows them. A sketch drawing several separate chains gives them all in `s.paths` (in the order they are drawn), and `s.path` is then an error. Mind that `s.polyline` closes unless `closed: false`. - From a solid's edges: `body.path(edges.loop(faces.top))` joins the selected edges end to end (a bead along a rim, a groove round a lid); they must make one chain. - From two views: `path(top.path, front.path)` is the curve in space that, seen across the first path's plane, is the first, and across the second's, the second: draw a handle or a bent rod from above and from the front, each in its own sketch, and sweep along the result. The two must cover the same stretch along the line they share. - `profile.sweep(path)`: the profile, drawn on XY around the origin, starts at the beginning of the path, normal to it, with its +Y kept towards world +Z (towards +Y when the path starts vertically). `frenet: true` follows the curvature (use it for helices: springs, threads). `twist: 360.deg` turns the profile once around the path on the way, evenly along it (twisted ribbons, ropes; not with `frenet`). Holes in the profile give tubes. - How the profile turns about the path (its roll; it always stays across the path): by itself as little as it can, which after many bends in space leaves its +Y wherever it ends up. To decide it, give one of: `twist: t => 60.deg * sin(t * 720.deg)`, a function of how far along the path (0 at the start, 1 at the end) giving how far it has turned there (counted from the start: `t => …` at 0 is the profile as placed); `up: Z`, its +Y as close to that way as it can be all along (a road or a rail that does not bank; an error, with where, if the path runs that way somewhere, as a vertical stretch does for `up: Z`); `guide: rail`, another path, its +Y towards the point of the rail as far along it (halfway along the path, halfway along the rail): it works where `up` cannot, a path going up and over. Along a helix the profile keeps to its axis by itself (a square wire coils with its sides along the axis, over any number of turns), and a twist is counted from there. On a path with sharp corners (`path` without `bend:`) these work too: each stretch between corners is swept with its roll, and the stretches meet in the planes that halve the corners, mitred. A path that turns right back on itself cannot be swept along: an error says where. - `loft([circle(r: 20), rect(30).move(z: 40), circle(r: 10).move(z: 80)])`: a smooth solid through profiles placed in space; `ruled: true` gives flat sides. Profiles with holes are lofted as tubes when every profile has the same number of holes. - **Constrained sketches** (2D, on XY or on a face): `auto s = sketch();` then add geometry and constraints and take `s.profile`. - Areas: the profile fills what lies inside an odd number of loops: a loop inside another is a hole, and where two loops overlap (two circles crossing) the overlap is a hole too. Where more than two lines or arcs meet at a point (a line across a rectangle from side to side, an arc from one corner to another; a line or arc ending on another between its ends splits it there), there are no simple loops: every area they close is full. `s.fill(at)` makes the area holding `at` full whatever the loops say, `s.fill(at, full: false)` empty (`at` a vector, or a point of the sketch, which keeps it in its area as the constraints move it; such a point, used by nothing else, does not count among the degrees of freedom; the last fill in an area counts). In the window the full areas are tinted, and the sketch bar's ◩ turns an area full or empty with a click inside it. - Construction geometry: `construction: true` on any geometry call or shape (`s.line(p, q, construction: true)`, `s.rect(…, construction: true)`) makes guides to constrain to, drawn dashed in the window, never part of the profile. - Geometry: `s.point(x, y)` (where it starts: constraints move it; `fixed: true` pins it there), `s.line(p, q)`, `s.arc(center, start, end)` (counter-clockwise; their points sketch points or vectors, which become new points: to join two lines, give the second the first one's `.end`), `s.circle(center, r: | d:)` (without r/d the radius is left to the constraints), `s.polyline([p1, p2, vec(…)], closed: true)` (returns the lines; vectors become new points), `s.ellipse(center, rx:, ry:, angle:)` (rx along its own x axis, `angle` from X, ry across; what is not given is left to the constraints, the turn too: `s.ellipse(c, rx: 20.mm, ry: 12.mm)` with c fixed leaves 1 degree of freedom), `s.spline([p1, p2, vec(…)], closed: false)` (a smooth curve through the points, in order, vectors becoming new points: open, its two ends join lines and arcs as theirs do; `closed: true` goes round back to the first point, a loop of its own; it bends evenly, as a thin strip bent through the points would, and its points move it: constrain them; `.points`, `.start`, `.end`, `.closed`, `.length` along the curve). `construction: true` geometry helps constraining but is not part of the outline. - Shapes, made of the geometry above with their constraints, the same shapes and arguments as the profiles above (the points they need are new; a vector where a point goes, here and in `s.circle` and `s.ellipse`, becomes a new point starting there; without the centre, `s.circle(d: 10.mm)`, `s.rect(40.mm, 20.mm)`, `s.polygon(6, d: 10.mm)`, `s.slot(30.mm, 6.mm)`, `s.teardrop(d: 5.mm)`, it is a new point at the origin, free to move): `s.rect(p, q)` (opposite corners), `s.rect(p, w:, h:)` (one corner, p the lower left) or `s.rect(center: c, w:, h:)` returns its 4 lines p → (q.x, p.y) → q → (p.x, q.y), that is bottom, right, top, left when p is the lower left corner; they are horizontal and vertical, so a rectangle of new corners has 4 degrees of freedom, 0 with w, h and a fixed centre (a construction diagonal holds the centre in its middle); `angle: 30.deg` turns it (its first side that far from X: the sides are then square to each other and parallel in pairs instead of horizontal and vertical), `angle: "free"` leaves the turn to the constraints (`s.parallel(r[0], line)` lays it along a slanted line); `corner: r` rounds the four corners. `s.polygon(center, n, r: | d:, fit:, angle:)` returns the n lines of a regular polygon, its corners on a construction circle of that radius (`fit: "corners"`, the default), or its sides touching it with `fit: "sides"` (`d` across the flats: a nut's spanner size); `angle` is the direction of the first corner from the centre, else the turn is left free (1 degree of freedom: `s.horizontal(lines[0])` takes it; it starts with the first side at the bottom, horizontal, where the profile has it); `s.polygon([p, q, vec(0, 20)])` is the closed polyline through the points. `s.slot(p, q, w:)` returns `[line, arc round q, line, arc round p]`: two half circles joined by lines tangent to them, 0 degrees of freedom with p, q fixed and w given; `s.slot(length, w, center: c, angle:)` the same by its length end to end, along X unless turned (a construction line joins the two centres, c in its middle). `s.teardrop(c, r: | d:, angle:, flat:)` returns `[arc, line, line]`: an arc round c and two lines tangent to it meeting above it, `angle` (45° unless given) from the vertical; `flat: true` cuts the point with a line touching the circle's top (`[arc, line, flat, line]`); 0 degrees of freedom with c fixed and r given. Sizes given (w, h, r, d, rx, ry, angle, corner: one dimension for the four corners) are dimensions, shown and editable in the sketch view (a slot's length is not: the dimension would be between its centres). - Constraints: `coincident(p, q)`, `fix(p)`, `horizontal(line | p, q)`, `vertical(…)`, `length(line, v)`, `distance(p, q, v)`, `distance(p, q, v, along: X | Y)`, `distance(p, line, v)`, `on(p, line | circle | arc)` (a shape's lines instead of p: every corner on the circle, the shape inscribed: `s.on(s.polygon(c, 6), circ)`), `parallel(a, b)`, `perpendicular(a, b)`, `angle(a, b, v)` (between the line directions, start → end), `equal(a, b)` (lengths or radii), `radius(c, v)`, `diameter(c, v)`, `tangent(line | circle, circle | line)` (a shape's lines and a circle: every side touching it, the shape round it: `s.tangent(s.rect(center: c), circ)`), `midpoint(p, line)`, `symmetric(p, q, line)`, `concentric(a, b)` (circles, arcs, ellipses or a point round the same centre), `angle(line, v)` (of one line from X). - On ellipses: `on(p, e)`, `tangent(line, e)`, `concentric(e, …)`, `radius(e, rx: v, ry: v)` (either or both), `horizontal(e)` / `vertical(e)` (its x axis along X or Y), `angle(e, v)`, and `equal(e)`: as wide as tall, a circle. A round ellipse looks the same however it is turned: its turn then stays where it is, unless an angle sets it, and is not a degree of freedom. - `s.fillet(p, r)` rounds the corner where two lines meet at p (r is shown as a dimension on its arc). - Reading back: `p.x`, `p.y`, `p.pos`, `line.start`, `line.end`, `line.length`, `line.mid`, `c.center`, `c.radius`, `e.center`, `e.rx`, `e.ry`, `e.angle`, `s.dof` (degrees of freedom left; conflicting constraints are an error naming them), `s.solved`. - `s.profile` solves and returns the closed loops of non-construction lines, arcs, circles and ellipses as a `Profile` (inner loops are holes). Lines, arcs and curves with a free end (open) stay in the sketch, drawn, but out of the profile, with a warning naming them. Under-constrained sketches still work, with a warning listing the lines of the free points; conflicting constraints are an error pointing at them. - **On a face:** `auto s = carlo().sketch(faces.top);` draws on that flat face, with the axes of `carlo().frame(faces.top)` (origin at the face's centre; on a wall x to the right and y up seen from outside; `origin:` and `x:` as frame takes them). The face's edges are in the sketch from the start, as references: fixed construction geometry, no degree of freedom, shown in the sketch view. `s.edges` lists them, each loop from its lowest edge counter-clockwise like a rect's sides (bottom, right, top, left), the outside first; `s.origin` is the origin as a fixed point; `s.project(edges.…)` flattens other edges of the solid onto the plane as references (a hole's rim under the face; a circle seen aslant becomes an ellipse). Constrain to them as to any geometry: `s.distance(c.center, s.edges[0], 5.mm)`, `s.concentric(c, s.project(edges.filter(…))[0])`. `sketch(frame)` and `sketch(plane)` draw on a frame or plane, with `s.origin` and no edges. - The profile of such a sketch lies where it was drawn: `carlo().cut(s.profile, depth: 2.mm)` and `carlo().extrude(s.profile, depth: 1.mm)` need no `on:`, `s.profile.extrude(3.mm)` stands on the face, `revolve` turns it there and `loft` goes through profiles drawn on different planes. Moved, offset, tiled, hulled or combined with a profile of no place it stays in its plane (in the sketch's coordinates); profiles lying in one plane (a sketch's, a section, one moved up) combine, two in different planes are an error. - **Transforms:** `move(v | x:, y:, z:)`, `rotate(x:, y:, z:)` or `rotate(angle, axis:)` (a direction through the origin, or an `Axis` anywhere), `scale(k)` or `scale(x: 2, y: 0.5, z: 1)` (a factor each: an ellipsoid from a sphere; the faces become freeform; `around: p` keeps point p where it is instead of the origin), `mirror(X)` (plane through the origin with that normal) or `mirror(plane)` (any plane), `color(c)`. - **Placement:** `a.nextTo(b, X, gap: 10.mm)` (beside `b` that way, only its x changing; an oblique direction like `vec(1, 2, 0)` puts its centre on the line from `b`'s centre that way, the boxes touching, `gap` further along it), `a.on(b)`, `a.align(b, x: "center", z: "min")` (each axis `"min"`, `"center"` or `"max"` of the bounding boxes). - **Patterns:** `hole.linearPattern(4, step: vec(10, 0, 0))`, `peg.circularPattern(6, axis: Z, angle: 360.deg)` give one solid with all the copies (subtract it to cut them all). - **Axes:** `axis(origin: vec(10, 0, 0), direction: Z)`, `axis(p1, p2)` (through two points), `carlo().axis(edges.filter(…))` (the line of a straight edge, or the centre and normal of a circular one such as a hole rim), `shaft.axis(faces.largest)` (the axis of a cylinder's, a cone's or a turned face), `frame.axis(X)` (an axis of a frame). Fields: `origin`, `direction`. Use them with `rotate(angle, axis: a)` and `circularPattern(n, axis: a)`. - **Surfaces** (type `Surface`: faces with an edge open, no inside): - Made by sweeping a curve instead of a profile: `path([vec(-8.mm, 0), vec(8.mm, 0)]).sweep(route)` (the curve drawn on XY around the origin, as a profile is, placed across the path in the same way: a ribbon through the middle of anything swept along that path), or by lofting through paths: `loft([path1, path2])` (`ruled: true` for flat strips). - `body.split(surface)`: the pieces the surface cuts the solid into (it must reach across it), sorted as `split(plane)` sorts them; `pieces.push(from: surface, by: 3.mm)` moves each away from the surface, across it where it is nearest. A tube split lengthwise and opened: `tube.split(ribbon).push(from: ribbon, by: 3.mm)`, the ribbon swept along the tube's own path. - `fill(rim)`: a smooth face closing a closed path, a Surface; `through: [vec(…)]` points it passes through too (a dome); on a solid, `body.fill(edges.…, tangent: faces.…)` closes a loop of its edges (an opening, a cavity's rim), meeting the faces given smoothly. Thickened, it is a lid that follows the rim; sewn, it closes an open sheet. - `surface.thicken(2.mm)`: a solid that thick, on the side its normals point (negative: the other side); a surface lofted through closed paths (a square to a circle) whose sharp and smooth edges meet is thickened from its paths instead, each offset in its own plane, outwards for a positive thickness (the wall then that thick across the paths' planes). - `sew([sides, bottom, top])`: surfaces (and flat profiles for their ends) that close round a space, joined into one solid; when they leave a gap, the error says how many edges stay open. - A part cannot be a surface (it has no inside), and surfaces do not combine with `+ - &`: split, thicken or sew them. - **Planes:** `XY` (its normal +Z, up), `XZ` (−Y, towards the front) and `YZ` (+X, to the right): each faces the way the Top, Front and Right views look at it, so a sketch on one reads as that view shows it, x to the right and y up; `plane(origin: vec(0, 0, 10), normal: Z)`, `plane(p1, p2, p3)` (three points; the normal follows p1 → p2 → p3 by the right-hand rule), `plane(axis, point)` (containing a line and a point), or the plane of a face: `carlo().plane(faces.top)`, or halfway between two parallel faces: `body.plane(faces.top, faces.bottom)` (the first one's normal). Planes also mirror: `s.mirror(plane)`. - `p.offset(5.mm)` is the parallel plane moved along the normal (negative goes the other way); `p.flip` reverses the normal. - Fields: `origin`, `normal`. - **Frames** (an origin and X, Y, Z axes) to build on faces and planes: - `carlo().frame(faces.top)`: origin at the face centre, Z along the outward normal. On a wall (any face that is not flat) X is horizontal and to the right seen from outside and Y goes up, on every side: a text reads and a teardrop points up whichever wall it is on; on a flat face X is the global X. The same axes place profiles with `on:`. Options: `origin: vec(…)` (projected on the face), `x: Y`. - `plane.frame(origin: vec(…), x: …)`: on a plane the origin defaults to the plane's origin; give a point to choose another one. - `World` is the global frame; `f.at(x: 10, y: -5)` moves the origin in local coordinates; `f.turn(30.deg)` rotates X and Y around Z. Fields: `origin`, `x`, `y`, `z`. - `solid.placeOn(f)`: a solid built on XY with Z up is moved onto the frame. A plane works too (`placeOn(plane)`): its origin is the zero, with the axes of a frame on a face. A boss: `cylinder(d: 8, h: 5).placeOn(f)`; a pocket: `carlo() - box(10, 10, 3).move(z: -3).placeOn(f)`. - `solid.placeOn(f, from: faces.bottom)` lays a face of the solid against the frame instead (the face's middle on its origin, the two facing each other, X on X); `at: vec(…)` carries that point of the face instead of its middle. A lid on a vase's mouth, wherever the vase leans: `lid.placeOn(vase().frame(faces.farthest(Z)), from: faces.bottom)`; turn it with `frame.turn(30.deg)`. Place on… in the window writes these. - **Cutting:** `s.halves(plane)` returns the two sides of the cut, each one solid: `.above` (where the normal points) and `.below`. `s.split(plane)` returns the pieces the cut leaves, both sides, as a `Solid[]` ordered along X, then Y, then Z: more than two when the plane crosses a slot; choose among them with `.above(plane)`, `.below(plane)`, `.largest`… or make a part of each with `@parts`. - **Textures and noise:** `noise(x, y, z, seed: 3)` is smooth noise, about -1…1 with features about one unit across (lengths count in mm); `loopNoise(around, along, bumps: 6)` is noise round a loop, `around` 0…1 once round, with no seam where it closes. Both take `kind:`: `"cells"` (0 at the point each cell grows from, about 1 farthest from it), `"edges"` (0 on the borders between the cells: a Voronoi relief on any surface), `"ridged"` (1 on sharp ridges), each 0…1; and `octaves:` (up to 8), finer and weaker layers added on top for a rougher, more natural texture. `profile.outline(n, round: 2.mm)` gives n points evenly round a profile's outline, each with `.at`, its outward `.normal` (turning round a corner as on a fillet `round` big, so that a push inwards smaller than that does not cross itself) and `.around` (0…1). `polygon(points, smooth: true)` is a smooth closed curve through the points, filled. `loft` also takes rings of points (`Vec3[][]`, the same count in each): one smooth surface through every point, closed round each ring, the end rings filled flat, in milliseconds where a loft through as many smooth profiles takes seconds; `loft(rings, inside: innerRings)` makes it hollow, the inner wall through the inner rings, a floor at the inner first ring and a flat rim between the last rings, which lie in one plane. Together they make textured solids: a ring per height, every outline point pushed along its normal by a function of where it is, lofted; along a curve, `path.frames(n)` gives n frames evenly along it, Z along the path and turning as little as they can, to place the rings on with `placeOn` (see `examples/03_randomvase.hcad`, a vase along a curve with its lid): ```d Vec3[] ring(double up) { Vec3[] pts; foreach (q; circle(r: 30.mm).outline(140, round: 2.mm)) { auto p = q.at + q.normal * (2.mm * loopNoise(q.around, up * 8, bumps: 9)); pts ~= vec(p.x, p.y, 100.mm * up); } return pts; } // then: Vec3[][] rings; foreach (k; 0 .. 41) rings ~= [ring(k / 40.0)]; return loft(rings); ``` - **Profiles on curved faces:** `body.cut(profile, wrap: faces.largest, depth: 1.mm)` and `body.extrude(profile, wrap: …, depth:)` lay a flat profile on one face of any shape, rolled onto it like a label on a bottle: lengths are kept along the face, exactly on cylinders, cones and extrusions, nearly on lofts and revolved shapes (stretched a little where the face swells away from the line through the anchor), stretched on a sphere. The profile's x runs across the face (round a cylinder), its y along it, read from outside (on a hole's wall, from inside it); its origin goes to the front of the face (the side facing -Y, halfway along) or to `at:`, a point over it. `project: faces.…` carries it along a direction instead (`along:`, else the face's normal at that point), from the plane touching the face there, as a light casts it: stretched where the face turns away, for a logo on the front of a ball. The edges are curves on the face, not a mesh; text, tilings and sketches all work. Except on a cylinder (where everything is carried exactly), a curve shorter than 0.6 mm, a small rounding, is laid as its chord. Pockets and reliefs wholly inside the face are built at once, the face holed and the floors sewn in, with no boolean: a hundred regions on a vase in a second or two; so are cuts through a wall whose other side is the face moved in (a shelled cup, a tube: both faces holed, the sides between). Regions crossing the face's edges (a tiling's cells cut at its rim, a letter over it), or cuts into other faces, take the boolean way, slower on curved freeform faces (seconds for dozens of regions); only those: the regions wholly inside are still built at once. - **Knurling:** `body.knurl(faces.largest, kind: "diamond" | "straight" | "diagonal", pitch: 1.6.mm, depth: 0.5.mm, angle: 30.deg, margin: 0.5.mm)` raises a grip on a face of any shape (a knob, a handle, a slider's pad, the side of a lofted grip), laid on it as `wrap:` lays a profile: diamonds, ridges along the face or leaning ones, `margin` in from its edges; round a face that closes on itself (a cylinder, a loft through rings) a whole number round, so it closes too; built on the face with no boolean. - **Tilings:** `region.tiles(kind: "hex", size: 10.mm, gap: 2.mm)` fills a profile with cells `gap` apart, as one profile: subtract it for a grille (`plate - region.tiles(…)`), or cut or extrude it on a face for pockets or reliefs (`body.extrude(r.tiles(…), on: faces.top, depth: 1.mm, taper: 30.deg)`, slanted sides). Kinds: `"square"`, `"hex"`, `"triangle"`, `"brick"`, `"octagon"` (with small squares between), `"diamond"`, `size` the distance between neighbouring cells' centres; `"voronoi"`, cells round points scattered about `size` apart (`jitter:` 0, hexagons, to 1, the default; `relax:` passes that even them out; `seed:`), or round `points:` of your own. Your own tile: `tile:` a profile or a list of them (a sketch's profile, say), repeated by `step: [a, b]`; the copies (shrunk by the gap) must not overlap, and an error says which two do. Cells crossing the region's border are cut there, `whole: true` keeps only those wholly inside. With no gap (the default) the cells touch, and are joined into one area. A region drawn on a face or lying in another plane (a section) is tiled in that plane, and the tiles stay there (`body.cut(cells, depth: 1.mm)` needs no `on:`). `round:` rounds each cell, `angle:` turns the tiling, `origin:` puts a cell there (else at the region's centre). `scale:` a factor for each cell and `keep:` which cells stay, as functions of the cell's centre: `scale: (Vec2 c) => 1 - c.length / 80.mm` grades the holes, `keep: (Vec2 c) => noise(c.x / 20, c.y / 20) > 0` leaves some out. A region on a curved surface takes a noise texture instead (`kind: "edges"`). - **Separate bodies:** `s.pieces` lists the disconnected bodies of a solid (e.g. after a cut through it), ordered by position along X, then Y, then Z; pick one with `[i]` or sort them (`s.pieces.sort((a, b) => a.volume > b.volume)`). A list of solids also says which are where: `.above(plane)` and `.below(plane)` (by centre of mass, so the pieces of a split, which touch the plane, count), `.crossing(plane)` (those it cuts through), `.nearest(point)`, `.largest`, `.smallest`, and `.top`, `.bottom`, `.left`, `.right`, `.front`, `.back` (the highest, lowest…): `carlo().split(cutPlane).below(cutPlane).largest`. To make parts of them, see `@parts`. - **Push and pull:** `s.push(from: ref, by: 5.mm)` moves a solid away from a point, a Plane or an Axis, `s.pull(to: ref, by: 5.mm)` towards it, along the line from the reference to the solid's centre of mass (from a plane along its normal, from an axis square to it). On a list each solid goes its own way, so the pieces of a cut spread apart, an exploded view: `carlo().split(cutPlane).push(from: cutPlane, by: 4.mm)`. A solid whose centre is on the reference stays, with a warning. - **Importing parts:** `load("part.stl")`, `load("part.obj")`, `load("part.ply")`, `load("part.3mf")`, `load("part.glb")` (or `.gltf`), `load("part.step")`, with `scale:` (STL, OBJ and PLY are read in mm; 3MF uses its own unit and build transforms; glTF its nodes' transforms, from metres to mm and turned from Y up to Z up, not compressed with Draco or meshopt). A mesh that is not quite a solid, as scans often are, loads with `repair: true`: triangles doubled facing each other (walls of no thickness) are taken out and, where the surface touches itself along an edge or at a vertex, the sheets are parted; holes are left, and the error says what remains. Meshes become solids (coplanar triangles merged into faces, closed meshes only), STEP files keep their exact geometry; both can be cut, filleted, measured and selected like any other solid. Paths are relative to the model file and must stay inside the project directory. To remodel a mesh exactly, set it straight first if it came tilted (`load("part.stl").aligned` lies on its largest flat face, longest along X, centred in X and Y and resting on z = 0); `writeln(load("part.stl").features)` lists what it is made of (flat faces, holes and shafts with the ISO size they match, cones, spheres, roundings, chamfers with their size, surfaces turned round an axis, patterns of holes, with plausible sizes; `hcad features part.stl` on the command line, `hcad_features` for agents); load it as a see-through reference part to compare. `hcad section part.stl --plane xy --at 3` (`hcad_section` for agents) gives a cut across it as lines, arcs and circles with their sizes, and as a sketch ready to paste and extrude. `clone.deviation(original)` is how far apart the two surfaces are at most (`assert(clone.deviation(original) < 0.2.mm)` in a unittest); `hcad compare model.hcad --a clone --b original -o map.png` (`hcad_compare`) also gives the mean, the share within a tolerance, a picture of the clone coloured green where it matches, yellow at the tolerance, red beyond, and the patches of each surface the other does not follow (area, offset, facing: a missing chamfer or countersink shows there). `hcad diff` lists the pieces of material to add or cut, the thin differences apart, and the section that shows each piece; `hcad scan file.stl --axis z` the holes and pockets met along an axis with their shapes and depths; `hcad section … --compare` draws two sections on each other and `--projection` reads a shadow's outline; `hcad fit model.hcad --a clone --b original --param width` tunes @param lengths until the two match (and says when the shape, not the sizes, differs); `hcad match` matches the operations of the two one by one (a nut trap cut in steps is one hole); `hcad section … --region u0,v0,u1,v1` keeps the outlines inside a rectangle of the plane (a lettering); `hcad literals model.hcad --original original` lists the numbers the model repeats and which sizes of the original they are, to name them as @params once the copy matches. `hcad review model.hcad --a clone --b original -o review.png` (`hcad_review`) runs the whole comparison round in one: distances both ways, material, drawings outside and of the voids, and close-ups of the first entries of each, framed where they are in sight; `--look x,y,z` adds a close-up of what is at that point. The reports of compare, diff, lines and match are numbered: `--focus 2 -o close.png` (`focus: 2`) draws a close-up of entry 2, from where it is in sight (what lies in front cut away when nothing is); `hcad render … --from right --look-at 1,2,13 --size 14 --opaque -o detail.png` (`view`, `opaque` for agents) takes a picture from anywhere, close on a detail, and compare and lines take the same `--from`, `--look-at`, `--size`, `--cut`. Drawings become profiles on XY: `load("logo.svg")` and `load("plate.dxf")` give their closed outlines (an outline inside another is a hole, inside a hole an island), ready to extrude; SVG sizes come from the drawing's width and height in mm, cm, in or pt (plain numbers are mm), DXF from its $INSUNITS (mm when unset); curves within 0.05 mm. Open lines, text and images are left out. - **Centring:** `shape.centered` moves a profile (in X and Y) or a solid (in X, Y and Z) so its bounding box is centred on the origin, e.g. `text("LABEL", size: 4.mm).centered`. - **Measuring:** `distance(a, b)` between two solids (0 when they touch), `s.bbox`, `s.volume`, `s.area`. - **Introspection:** `s.bbox` (`min`, `max`, `size`, `center`), `s.volume`, `s.area`, `s.isValid`. - **Features:** `fillet(edges, r:)`, `chamfer(edges, d:)`, `shell(open: faces, thickness:)`, `draft(faces, angle:, pull: Z, neutral: 0.mm)` (mould taper around the neutral plane). - **Face provenance:** every face remembers the feature that created it (primitive, fillet, shell, split…). Clicking a face in the GUI highlights it and jumps to that line. - **Topological selectors** are resolved against the solid the feature is applied to: - predicates: `edges.filter(e => e.isParallel(Z))`, `faces.filter(f => f.normal == Z)`; - shortcuts: `edges.parallel(Z)`, `edges.perpendicular(Z)`, `edges.lines`, `edges.circles`, `faces.top/bottom/left/right/front/back` (facing that way the most: on a part turned a little, the faces within half a degree of the most; a curved face by its normal over the whole face, a dome's up, a cylinder's whole side and a sphere none), `faces.normal(X)`, `faces.largest`, `faces.farthest(Z)` (the face whose centre lies farthest that way, whichever way it faces: a tilted top); - by tag: `faces.tagged("lid")` (the faces given that tag, a list of tags for any of them; see Tags below); - by provenance: `faces.from(tool)` (faces created by a tool or feature, e.g. the walls of the holes cut with it; a list, those of any of them), `edges.from(tool)` (edges between two such faces); - by adjacency: `edges.loop(faces.top)` (the outer loop of each face, not the edges round its holes), `edges.chain(edges.between(faces.top, faces.right))` (those edges and every edge running on from them without a corner, tangent within `angle:`, 2° unless given: a curved outline made of many pieces, taken whole; in the window, right-click a selected edge: **Select the chain**), `edges.of(faces.top)` (edges touching those faces), `edges.between(faces.from(holes), faces.top)` (the rim of the holes on the top face); - edge properties: `start`, `end`, `mid`, `direction`, `length`, `isLine`, `isCircle`, `isParallel(axis)`, `isPerpendicular(axis)`, and for circles `center`, `axis`, `radius` (e.g. `edges.filter(e => e.isCircle && e.radius == 3.mm)`); - face properties: `center`, `normal`, `area`. - selectors combine: `edges.parallel(Z) - edges.of(faces.top)`, `faces.top + faces.bottom`, `a & b` (in both). - **Tags:** names given to faces and to whole solids, which follow them wherever they go. `b.faces.top.addTag("lid")` tags the faces selected on `b` (`b.faces` and `b.edges` are the solid's own, to select from); `b.addTag("body")` tags the solid as an object, `recursive: true` its faces as well. `removeTag(tag)`, `clearTags()` and, read only, `tags` and `hasTag(tag)`, on a solid or its faces (`b.faces.top.tags`); a tag is a text, or a list of them to add several. Like a method of a D struct, `addTag` changes the variable it is called on (`b`, or `parts[2]`) and gives the solid back, so it also works in a chain: `auto b = box(…).faces.top.addTag("lid");`. A face keeps its tags through what changes it: moved, turned, mirrored, scaled, split, trimmed by a cut, filleted, drafted, offset, pushed (`extrude(faces…)`), textured (`knurl`, wrap): its pieces all keep them, two faces merged into one (blocks side by side) have the tags of both, faces made new (a fillet's round, a hole's wall, a thread's flanks: a rod's end threaded over is new too) have none, except the walls a tagged tool leaves (`body - drill`, the drill tagged `recursive: true`: its wall is tagged). A hull or a Minkowski sum keeps the tags of the flat faces it lies on (for a Minkowski sum, moved out by how far the other shape reaches). A solid made from others keeps the first one's own tags (`a - b` is still `a`); a profile's go to what is extruded or revolved from it. `faces.tagged("lid")` finds the faces later, wherever the solid was turned since (`faces.top` would find another face); `b.faces.tagged("lid").count` and `.list` (or `foreach (f; b.faces.top)`) read them. Mistakes: a tag found on no face says which tags there are; a tag added to a solid in no variable or in an immutable one is lost, an error. Edges have no tags yet: tag the faces on either side, `edges.between(faces.tagged("a"), faces.tagged("b"))`. Exported, the faces' tags name the faces of a STEP file (`lid;seat`) and named sets of triangles in a 3MF (the part's own tags in its object's metadata); `load("part.step")` gives a STEP file's face names back as tags. `faces.tagged("a").tagged("b")` takes the faces with both tags, a list those with either. A tag with a dot belongs to a module: `"hinges.pin"` can only be given in module `hinges` (read anywhere), so libraries tag what they make without colliding with the model's tags: `hardware.bore` and `hardware.seat` (a `hole()`'s faces, on the wall it leaves), `hardware.axis` (holes, screws, nuts, washers), `gears.axis`, `gears.bore`, `gears.pitch` (a rack), `hinges.axis`, `hinges.pin`, `enclosures.rim`, `enclosures.floor`, `enclosures.lip`, `joints.cut`, `joints.plane`, `molds.cavity`, `molds.parting`, `molds.sprue`. Points, axes, planes and frames can be tagged on a solid too, as references that move, turn, mirror and scale with it and stay through cuts and joins: `b.addTag("pin", at: vec(15.mm, 10.mm, 8.mm))` (or `at:` an `axis(…)`, a `plane(…)`, a frame), read back where the solid now is with `b.point("pin")`, `b.axis("hinge")`, `b.plane("mid")`, `b.frame("seat")` (a frame on whatever was tagged: `part.placeOn(body.frame("seat"))`; on an axis or a plane its X is the one `plane(…).frame()` and `frame(faces…)` give, however the solid was turned). Tagged again, the new reference replaces the old one. A copy joined to itself (two halves joined again, a face pushed or pulled, `b + b`) holds each reference once; copies in different places joined together hold one each, and reading the tag then is an error: tag the copies apart, or tag the joined solid again. Split (`split`, `halves`, `pieces`, the libraries' joints), each piece has the references lying in it or on its surface (one on the cut, both pieces), one lying in none (a hole's axis) the nearest piece; an intersection drops those in the part it cuts away; a pattern (`linearPattern`, `circularPattern`) keeps its first copy's; `minkowski(a, b)` keeps `a`'s; a union with an empty solid (`Solid.nothing`) keeps the empty one's references (not its own tags). In a sketch, `s.addTag("plate")` tags its profile and paths, and an entity's tag (`c.addTag("pivot")` on a point, `s.circle(c, d: 6.mm).addTag("bore")`, construction ones too) becomes a reference of the profile and of what is made from it (swept, where the profile starts on the path): a point where it is, a circle's or arc's centre an axis across the sketch, a line an axis along it. A tagged line, circle or arc also tags the face extruded or revolved from it: `sides[1].addTag("slope")`, then `faces.tagged("slope")` on the solid, the side swept from that line (a circle: the hole's wall). This holds for a profile extruded (straight, tapered, sheared, twisted or symmetric) or revolved, moved, turned, scaled or mirrored first, offset (a line gives the parallel side, a circle the one round the same centre), rounded, cut into `pieces`, or joined with, less or intersected with another profile (the edges left of it keep the tag); a hull of the profile, a sweep or a loft loses the faces' names (not the points'), and looking for a face so tagged says which step lost it. A tag is a name: not blank, without `;` (a STEP file joins a face's tags with it); a dot goes only between a module's name and a name. `foreach (s; parts) s.addTag("x");` tags copies and is an error: tag `parts[i]`. - **Built-in libraries** (`import std;` for all of them, `import std.gears;` for one, selective imports work too): - `joints`: `splitJoint(body, plane, kind: "tenon" | "dovetail" | "pins" | "fingers" | "lap" | "kama" | "kanawa" | "koshikake" | "barbed" | "dowels", clearance: 0.2.mm, size:, length:, turn:, count:)` (`keys:` the same as `count:`) cuts a solid along a plane into two pieces that lock together without screws, as joiners join timber: a tenon, a dovetail sliding in sideways, round pins, a finger joint, a half lap, a gooseneck (kama tsugi), a half lap with hooks locked by a wedge (kanawa tsugi), a dovetail over a seat (koshikake ari tsugi), a barbed tenon split down its middle, driven in for good, loose printed dowels (rods with two flats, split at the ends, `count:` of them) held by friction in plain round holes; it gives a `Joint` with `.a` (behind the plane), `.b` (in front of it), `.loose` (the wedge or the dowels, in place) and `.print` (those lying flat in a row, to print), the hollows `clearance` larger; the joint sits in the middle of the cut, sized by the square root of the section's area unless `size:` says (fingers share the section's width; pins and dowels go on the material round a hollow middle, a tube's, a tenon there is an error); `jointKinds` lists the kinds. For a part longer than the printer's bed. `splitFlat(body, plane, keys: 2, kind: "bowtie" | "disc", size:, depth:, clearance: 0.15.mm, wall: 1.6.mm)` (`count:` the same as `keys:`) cuts it into two halves with flat faces, each to print lying on its cut face, joined by flat keys (butterflies, chigiri) in pockets cut in both faces, placed where the cut has room, as far apart as they can be: a `FlatSplit` with `.a`, `.b`, `.keys` (in place), and laid for printing `.aPrint`, `.bPrint` (on their cut faces, on z = 0) and `.print` (the keys flat in a row). `joinParts(a, b, plane, kind:)` joins two separate parts where `b` ends on a face of `a` (the plane, its normal into `b`): the same joints but `"kanawa"` (a sliding dovetail's, a gooseneck's and a seated dovetail's housing running out of `a`), from `b` into `a`, sized by `b`'s end, a tenon longer than `a` is thick flush on the far side; joints lie along the cut's longer side unless `turn:` says. `profile.spots(n, margin:)` gives the points it uses: up to n inside a profile in any plane, at least margin from its edges, far apart. - `molds`: `mold(part, plane, wall: 10.mm, keys: 4, sprue: 6.mm, vents: 2, seal: 0.8.mm, clearance: 0.15.mm, pry: true)` (`count:` the same as `keys:`) a two-part mold to cast the part in (silicone, resin, plaster): a block round it cut along the parting plane (where the part is widest), the part hollow inside, cone keys on one half's parting face and sockets in the other, a seal ridge round the cavity and its groove, a pour hole with a funnel and vents up through the half in front of the plane, notches on two sides to pry it open; a `Mold` with `.a`, `.b` and, laid on their outer faces with the parting face up, `.aPrint`, `.bPrint`. - `flex`: `kerf(body, zone, width: 0.8.mm, bridge: 2.mm, pitch: 2.5.mm, length:, along:)` makes the body bend inside the zone (any solid): rows of slots through its thinnest way, each row shifted half a slot, running across the body's length (or `along:` X, Y or Z), so a plate bends end to end and a tube bows; the slots `length:` long, six pitches unless given, shorter on a narrow strap (its width less a bridge); slots that cut the part apart are an error. They run along X, Y or Z only: bend a part before turning it at an angle. - `hardware`: `Screw` sizes `M2`, `M2_5`, `M3`, `M4`, `M5`, `M6`, `M8`, `M10`, `M12`, unified inch `UNC4_40`, `UNC6_32`, `UNC8_32`, `UNC10_24`, `UNC1_4_20` (cameras and tripods), and self-tapping screws for plastic `ST2_2`, `ST2_9`, `ST3_5`, `ST4_2` (their `tap` is the hole they cut their thread in), with `d`, `pitch`, `tap`, `clearance`, `head`, `headHeight`, `key`, `nut`, `nutHeight`, `washer`, `insert`, `insertDepth`; `screw(M3, 20.mm, threaded: false, head: "socket" | "button" | "flat" | "hex" | "pan")` (ISO 4762, 7380, 10642, 4017, 7049; underside of the head on z = 0, a flat head's top), `headOf(M3, "button")` its `d`, `h`, `key`, `setScrew(M3, 6.mm, point: "cup" | "flat" | "cone" | "dog", threaded: false)` (a grub screw, ISO 4029, 4026, 4027, 4028: no head, its socket end on z = 0, the rest going down) with `setKey(M3)` its hex key, `nut(M3, threaded: false)` (its hole's wall tagged `hardware.bore`, threaded or not; none for the self-tapping sizes), `washer(M3)`, `hexPrism(acrossFlats, h)`, `squareNut(M3)` (DIN 562, `squareNutSize`, `squareNutHeight`); tools to subtract, top on z = 0 going down: `hole(M3, depth, "clearance" | "tap" | "insert" | "counterbore" | "countersink" | "ribs", head: "socket", chamfer:)` (the whole hole within `depth`: an insert, a counterbore's or a countersink's seat deeper is an error; a counterbore sized for that head; `chamfer:` widens the mouth at 45°, a counterbore's round the head, 0.5 mm for ribs unless given, none for a countersink; `"ribs"` a hole the screw passes with three ribs along it, 120° apart and one towards -Y, the bottom one on a wall, that the screw cuts its thread into), `nutTrap(M3, depth)`, `squareNutSlot(M3, reach, clearance: 0.2.mm)` (centred on the axis, its middle on z = 0, open towards +X `reach` long, for the nut to slide in from the side); aluminium extrusions of the 20 and 30 series (2020, 3030, B-type slot, typical sizes): `extrusion(20, length)` (standing along Z, centred: a reference part), `extrusionSeries(20)` its sizes (`size`, `slot`, `lip`, `cavity`, `depth`, `bore`, `screw`), `tNut(20, length: 10.mm)` (a T-nut as it sits in a slot facing +Z, the face on z = 0, a tap hole for M5 or M6) and `slotKey(20, length)` (a key under a printed part, down from z = 0 into a slot, to keep it square to the extrusion). - `threads`: `threadedRod(d, pitch, length, chamfer: true, left: false)` (ISO metric profile, real geometry), `tappedHole(d, pitch, depth, clearance: 0.15.mm, left: false)` (a tool to subtract for an internal thread), `toothHeight(pitch)`. They are made with `isoThread(d:, pitch:, length:, chamfer:)`, a thread built exactly from its boundary (each corner of the profile on its own helix, a ruled face between two of them, one a turn) and cut to length: no sweep, no core to fuse; an M8 × 60 rod in about a second. - `gears`: `spurGear(m, teeth, thickness, bore: 0.mm, pressure: 20.deg, backlash: 0.1.mm, steps: 6)` (involute, as a rack cuts it: undercut under about 21 teeth, so small pinions mesh; centred, a tooth on +X; pressure from above 0° to 30°), `gearProfile(m, teeth, …)`, `rack(m, teeth, thickness, base:, pressure: 20.deg, backlash: 0.1.mm)`, `pitchDiameter`, `outsideDiameter`, `rootDiameter`, `centerDistance(m, z1, z2)` (where to put two meshing gears; turn the second so a gap faces the first's tooth on +X: `rotate(z: 180.deg - 180.deg / z2)`, right for any number of teeth). - `hinges`: `hinge(length:, width:, thickness:, knuckles: 5, clearance: 0.4.mm)` returns `.fixed` and `.moving`, two leaves printed in place around an axis along X at z = thickness; `pinHinge(…, pin: 1.75.mm, clearance: 0.3.mm)` the same with the knuckles holed for a separate pin (filament, a nail, a screw), leaves printed apart; `snapHinge(length: 30.mm, …)` three knuckles, the outer two with cones that snap into sockets in the middle one; `livingHinge(length:, width:, thickness:, web: 0.5.mm, gap: 3.mm)` two plates joined by a thin web on their underside, for PETG, PP or TPU. - `snaps`: `snapHook(length:, thickness:, width:, overhang:, lead: 30.deg)` (lead from 5° to 85°; a cantilever hook standing on z = 0, barb towards +X) and `snapSlot(…)` (the window to subtract from the mating wall where the hook stands: round the beam, with room for it to bend, the barb then resting on its +X edge). - `enclosures`: `roundedBox(x, y, z, r)`, `tray(x, y, z, wall:, r:)` (open on top), `enclosure(vec(x, y, z), wall:, r:, lidHeight:, screw: M3)` returns `.base` and `.lid` (lid with a lip that fits inside, screw posts in the corners); `shadowCase(part, on: XY, wall:, clearance:, split: 0.5, close: "lip" | "snap", lip:, play:)` a case round one part, its walls upright round the convex hull of its shadow on the plane `on` (so it drops in and lifts out), base and lid meeting at `split` of its height, `.base` and `.lid`; the lip, on the base, 3 mm high unless `lip:` says, lower on a flat part; the base's tags `enclosures.floor`, `enclosures.rim`, `enclosures.lip`, the lid none. - `carlo`: `carlo(teeth: 3, height: 15.mm, toothR:, gap:, jaw:, back:, lean: 17.deg, mouth: 1.5.deg, eye: true)`, Carlo the mascot, a bag clip lying on z = 0 (the constrained sketch of the tutorial's chapter 13, extruded; `lean` tilts the back and the tips, a positive `mouth` opens the slot towards the tips, a negative one closes it). - **Drawings** (technical sheets, exported as SVG, PDF or DXF; in the application **Export › Show the drawing**, a window with the sheet that follows the code, and its export): a function marked `@drawing` returns `drawing(size: "A4", title:, author:, material:, projection: "first" | "third", scale: 0 (fit), landscape: true)` after adding to it: - `d.standardViews(solid, iso: true, dimensions: true)`: front, top, side and an isometric view, laid out and scaled to fit, with overall dimensions and hole callouts (one per kind of hole seen end-on, with the count: `2× ⌀4.5, countersink ⌀9.26`, `⌀4.5, counterbore ⌀7.6 × 4.2`; bosses and round outlines are not holes); - `d.view(solid, "front" | "back" | "top" | "bottom" | "right" | "left" | "iso", at: vec(x, y), scale:, dimensions: true)` (without `at:` the layout places it; `at:` is in paper mm from the bottom-left corner); like `standardViews`, a view other than iso gets its overall width (below it) and height (on its left) and its hole callouts by itself: `dimensions: false` leaves them out, and an overall one is left out where a `d.dimension` of yours measures the same span; - `d.section(solid, plane, label: "A")`: the solid cut by the plane, seen from the side the normal points to, cut faces hatched; a plane that misses the solid is an error; - `d.dimension(view, from: vec(…), to: vec(…), offset: 8.mm, text:)` between two model points, on a view returned by `view`/`standardViews`, `offset` paper mm away from the part (a negative one towards its middle); `d.note("text", at: vec(x, y), size: 3.5.mm)`. - `hcad render model.hcad -o sheet.pdf` exports the first drawing, `--drawing name` another one. - **Standard algorithms** (eager, Phobos names, functions as normal arguments): `map`, `filter`, `fold`, `sum`, `any`, `all`, `count`, `canFind`, `sort`, `reverse`, `iota`, `cartesianProduct`, `zip`, `enumerate`, `join`, `min`, `max`, `minElement`, `maxElement`, `clamp`, `to!T`, `format`. ## Threads: choosing one, recognising one Which thread, by what it does: | Thread | Use it for | Found on | |---|---|---| | ISO metric coarse `M8` | holding parts together: the default | most things in Europe: machines, furniture, electronics | | ISO metric fine `M8x1` | fine adjustment, thin walls, vibration | lens rings, hydraulics, bicycles | | Unified inch `1/4-20 UNC`, `1/4 UNF` | meeting American things | cameras and tripods (1/4-20, 3/8-16 UNC), US hardware | | Trapezoidal `Tr12x3`, ACME `1/2-10 ACME` | a screw that moves a load | vices, lathes, 3D printers' Z (Tr8x8: 4 starts) | | Buttress `S20x4` | a load one way only | presses, vice spindles, big caps | | BSP parallel `G1/2` | pipes sealed by a gasket or O-ring on a face | taps, hoses, showers, filters, pumps | | BSP tapered `R1/2`, `1/2 NPT` | pipes sealed by the thread (PTFE tape) | plumbing, gas, compressed air (NPT in the USA) | | Edison `E27`, `E14` | lamp bases and sockets | bulbs | | Necks `28-400`, `PCO 1881` | caps for bottles and jars | cosmetics and lab bottles (SPI/GPI), soft drinks and water (PCO 1881) | | Garden hose `3/4 GHT` | American hoses | US taps (Europe: G3/4) | A rule of thumb: water or air goes through it, a pipe thread (G, R, NPT); it holds parts together, ISO metric. G1/2 (20.96 mm) and M20 look alike and do not mix. Recognising a thread on a real object, to screw something onto it: 1. The outer diameter of the male thread, over the crests, with calipers (from a female one: its inner diameter, plus about twice the tooth's depth, or try a known screw). 2. The pitch: count the threads over 10 mm (pitch = 10 / count), or over an inch, 25.4 mm (threads per inch); a thread gauge gives it at once. 3. A taper: the diameter different near the tip and near the base means R or NPT. 4. How it seals: a gasket or an O-ring on a flat face, G; PTFE tape on the threads, tapered. 5. Match the table: outer diameter and pitch. | Outer diameter | Pitch | Thread | |---|---|---| | 6.35 mm | 1.27 mm (20/inch) | 1/4-20 UNC (cameras) | | 9.53 mm | 1.59 mm (16/inch) | 3/8-16 UNC (tripod heads) | | 13.16 mm | 1.34 mm (19/inch) | G1/4, R1/4 | | 16.66 mm | 1.34 mm (19/inch) | G3/8, R3/8 | | 20.96 mm | 1.81 mm (14/inch) | G1/2, R1/2 (taps, showers) | | 21.34 mm | 1.81 mm (14/inch) | 1/2 NPT | | 26.44 mm | 1.81 mm (14/inch) | G3/4 (garden taps, washing machines) | | 26.45 mm | 3.63 mm, round | E27 (bulbs) | | 26.99 mm | 2.21 mm (11.5/inch) | 3/4 GHT (US hoses) | | 27.43 mm | 2.7 mm | PCO 1881 (PET bottles) | | 27.38 mm | 4.23 mm (6/inch) | 28-400 (bottles) | | 33.25 mm | 2.31 mm (11/inch) | G1, R1 | | M sizes | 0.5 (M3), 0.7 (M4), 0.8 (M5), 1 (M6), 1.25 (M8), 1.5 (M10), 1.75 (M12), 2 (M16) mm | ISO metric coarse | Printing threads: under M5 they print badly (a heat-set insert or a self-tapping screw instead); M6 to M12 print well standing, with 0.15 to 0.3 mm of play (clearance:, 0.15 mm by default in holes); a printed lid that opens often wants a coarse pitch (2 to 4 mm), and more starts for less turning; caps for bottles 0.3 to 0.5 mm of play, in PETG (it flexes onto the neck's seal where PLA cracks). Necks: 400 has about one turn of thread, 410 one and a half, 415 two (turns: on the thread); the tables give the thread's outer diameter, the diameter under it and the pitch, the tooth's flanks are the usual 30° (necks) or 15° (PCO) of such threads: print a test ring first. ## Style - Annotations (`@param`, `@preset`, `@part`, `@drawing`) go on their own line, above what they annotate; short ones share a line (`@preset("small", 40.mm) @preset("large", 120.mm)`), long ones get one line each. - Long chains break before each call, the new line starting with the dot: ```d @part("steelblue") Solid block() => box(40, 30, 20) .fillet(edges.parallel(Z), r: 3.mm) .shell(open: faces.top, thickness: 2.mm); ``` ## A tour of the language Every basic construct once (values and units, parameters, functions, structs, `if`, `foreach`, `while`, `for` with `continue`, `switch`, lists with `map`, `?:`, `unittest`, `writeln`), each with a comment on what it makes appear in the view or in the console. It is [examples/05_language.hcad](../examples/05_language.hcad), and **Examples › Language tour** in the application: open it there, change the numbers and watch. ```d // Language tour: if, for, while, switch, functions, structs and arrays, each with what it shows /// A tour of the language: every basic construct once, each with what it /// makes appear in the 3D view or in the Console below the editor. /// Read it top to bottom, change the numbers, and watch what happens. module language; import std.shapes; import std.math; // ---- values and units // Lengths carry their unit: 10.mm, 2.cm and 1.inch are all lengths, and a // plain number in a shape is in mm. Angles are 30.deg (or radians). // Module-level values are read-only: `immutable` for constants. immutable plate = 60.mm; // the width of the base plate below immutable gap = 4.mm; immutable tilt = 15.deg; // ---- parameters // @param puts a value in the Parameters panel (on the right), to change it: // the model follows. min and max keep it sensible. // Try 1, 3 and 5: the pegs on the plate change, and so do the green // markers in front (see `switch` below). @param("How many pegs", min: 1, max: 6) immutable pegs = 4; @param("Round pegs instead of square ones") immutable round = true; // ---- functions // A function with a block body: statements, then `return`. Solid peg(Length size, bool isRound) { // `if`/`else`: one of the two shapes is made, never both if (isRound) return cylinder(d: size, h: 12.mm); else return box(size, size, 12.mm); } // A function with an expression body: `=> expression;` Length pitch(int count) => (plate - 2 * gap) / count; // ---- structs // A struct groups values; it can have defaults and methods. struct Marker { Length r = 2.mm; Length height = 3.mm; Solid shape() => cylinder(r: r, h: height); } // ---- the parts: what the view shows // Each @part function is one object in the view (and in the exports), // with its colour. Without @part a function is only a helper. /// The base plate with a row of pegs on it. @part("steelblue") Solid base() { auto b = box(plate, 20.mm, 4.mm); // boxes stand on z = 0, centred in x and y // `foreach` over a range: one peg per step, `pegs` of them (the parameter) foreach (i; 0 .. pegs) { immutable x = -plate / 2 + gap + pitch(pegs) * (i + 0.5); // `min`: as wide as the pitch allows, but never more than 16 mm, // so a few pegs do not grow past the plate's 20 mm depth b += peg(min(pitch(pegs) - 2.mm, 16.mm), round).move(x: x, z: 4.mm); } return b; } /// A staircase, built with `while`: it stops when the steps reach 15 mm. @part("orange") Solid stairs() { auto s = Solid.init; // the empty solid: nothing to see yet auto h = 3.mm; int step = 0; while (h <= 15.mm) { s += box(6.mm, 12.mm, h).move(x: 6.mm * step); h += 3.mm; step++; } // a C-style `for` and `continue`: holes in every step but the second for (int i = 0; i < step; i++) { if (i == 1) continue; // this step keeps no hole: look for it in the view s -= cylinder(d: 2.mm, h: 40.mm, center: true).move(x: 6.mm * i); } return s.move(y: 30.mm); } /// `switch`: the green markers in front change shape with the number of /// pegs: circles for 1 or 2, triangles for 3, hexagons for 4 to 6. @part("seagreen") Solid markers() { auto outline = circle(r: 3.mm); // a profile: a flat outline on XY switch (pegs) { case 1, 2: outline = circle(r: 3.mm); break; case 3: outline = polygon(3, r: 3.mm); break; default: outline = polygon(6, r: 3.mm); // 4 to 6 pegs: hexagons break; } // arrays and algorithms: positions computed with `map`, shapes joined with `sum` auto xs = [-20.mm, 0.mm, 20.mm]; return xs .map(x => outline.extrude(2.mm).move(x: x, y: -25.mm)) .sum; } /// A struct at work, and the ternary `?:`: tall when the pegs are round. @part("crimson") Solid marker() { auto m = Marker(r: 3.mm, height: round ? 8.mm : 3.mm); return m.shape.rotate(x: tilt).move(x: 45.mm); } // A function that is never called and has no @part: nothing of it appears. Solid unused() => sphere(r: 100.mm); // ---- checks and messages // `unittest` blocks run after the parts are built. `writeln` prints to the // Console; a failing `assert` is reported as a problem (the parts still show). unittest { writeln("pegs: ", pegs, ", pitch: ", pitch(pegs)); writeln("base volume: ", base.volume); // the plate is wider than the pegs need: try 6 pegs and a plate of 20.mm assert(pitch(pegs) > 4.mm, "the pegs do not fit on the plate"); // an associative array, looked up by key immutable names = [1: "one", 2: "two", 3: "three"]; writeln("stairs: ", stairs.pieces.length == 1 ? "one piece" : "several pieces", ", three is ", names[3]); } ``` ## Example: box with lid ```d // Box with lid: rounded corners, and a lid that snaps on, sits on a lip, // screws down or lies flush // A box with rounded corners and a lid to choose: Snap, a lip under the // lid goes into the box and a ridge round it clicks into a groove inside // the wall; Lip, the same lip without the click; Screws, M3 posts in the // corners; Flush, the lid sits in a step cut in the rim, level with it. // A thin tab on the front gives a finger something to lift the lid with; // the flush lid has a hole in the middle for it instead. // Both print without supports: the box standing as it is, the lid upside // down, its top on the bed. // // Try it: // - change the sizes in Parameters, or pick a preset (a small cube, a // flat tray): the lid follows; // - pick another Closure; // - switch Lid on the box to see it closed, off to lay it out to print; // - too tight or too loose once printed? Change Lid fit clearance; // - the snap is `ridge`: a profile grown and extruded with a 45° taper, // added to the lip and cut out of the wall. module box; import std.shapes; import std.hardware : M3, Screw; // Each parameter lists its own presets: @preset("name", value). @param("Inner width", min: 30.mm, max: 200.mm) @preset("small", 40.mm) @preset("flat", 160.mm) immutable innerW = 80.mm; @param("Inner depth", min: 30.mm, max: 200.mm) @preset("small", 40.mm) @preset("flat", 110.mm) immutable innerD = 60.mm; @param("Inner height", min: 15.mm, max: 150.mm) @preset("small", 40.mm) @preset("flat", 18.mm) immutable innerH = 40.mm; @param("Wall", min: 1.2.mm, max: 4.mm) immutable wall = 2.mm; @param("Corner radius", min: 3.mm, max: 20.mm) @preset("small", 6.mm) @preset("flat", 12.mm) immutable cornerR = 8.mm; // snap: a lip that clicks in; lip: a lip alone; screws: M3 posts in the // corners; flush: the lid in a step cut in the rim @param("Closure", choices: ["snap", "lip", "screws", "flush"]) immutable closure = "snap"; @param("Lid fit clearance", min: 0.mm, max: 0.5.mm) immutable clearance = 0.2.mm; @param("Lid on the box (off: laid out to print)") immutable closed = false; immutable floorT = 2.mm; immutable lidT = 2.4.mm; // the lid plate immutable lipH = 7.mm; // the lip that goes into the box immutable lipT = 1.6.mm; immutable bead = 0.4.mm; // the snap: a ridge round the lip, a groove in the wall immutable beadZ = 3.mm; // its height above the bottom of the lip immutable tabT = 1.6.mm; immutable tabOut = 5.mm; // how far the tab sticks out immutable tabD = 12.mm; // the width of the tab immutable edge = 0.8.mm; // chamfers on the outer edges immutable outerW = innerW + 2 * wall, outerD = innerD + 2 * wall; immutable outerH = floorT + innerH; immutable hasLip = closure == "snap" || closure == "lip"; Profile outer() => rect(outerW, outerD).round(cornerR); Profile inner() => outer().offset(-wall); /// The lid outline: the box's, with a rounded tongue out of the front edge /// when it has a lip to pull out; the step's, a gap smaller, when flush. Profile lidOutline() { if (closure == "flush") return outer().offset(-wall / 2 - clearance); if (!hasLip) return outer(); immutable y = outerD / 2 + tabD / 2 - tabOut; return (outer() + circle(d: tabD).move(vec(0.mm, -y))).round(2.mm); } /// A ridge with 45° sides standing out of an outline, its middle at z = 0 /// (a ring: only what lies outside the outline, less a wall's width inside). Solid ridge(Profile p, Length d) { auto half = p.offset(d).extrude(d, taper: 45.deg); return (half + half.mirror(Z)) - p.offset(-1.mm).extrude(4 * d, symmetric: true); } /// How far each screw's axis is in from the outside: as near the corner as /// it can be, with 1.2 mm of wall outside its hole, across the rounded /// corner too, and its head on the lid. Length screwInset(Screw screw = M3) { immutable keep = max(screw.tap / 2 + 1.2.mm, screw.head / 2 + 0.5.mm); // across the corner, the round edge is nearer than the faces immutable corner = keep > cornerR ? cornerR : cornerR - (cornerR - keep) / sqrt(2.0); return max(keep, corner); } /// Where the screws go: the four corners, `screwInset` in. Vec2[] screwSpots() { immutable dx = outerW / 2 - screwInset, dy = outerD / 2 - screwInset; return [vec(-dx, -dy), vec(dx, -dy), vec(-dx, dy), vec(dx, dy)]; } @part("#F2EDE4") Solid base() { immutable outside = outer().extrude(outerH) .chamfer(edges.of(faces.bottom), d: edge) .chamfer(edges.of(faces.top), d: edge / 2); auto b = outside - inner().extrude(innerH + 1.mm).move(z: floorT); if (closure == "snap") b -= ridge(inner(), bead + 0.1.mm).move(z: outerH - lipH + beadZ); if (closure == "flush") // the step: half the wall, as deep as the lid is thick b -= outer().offset(-wall / 2).extrude(lidT + 1.mm).move(z: outerH - lidT); if (closure == "screws") foreach (p; screwSpots()) { // a post in the corner, one piece with the walls, and its pilot hole immutable sx = p.x > 0.mm ? 1 : -1, sy = p.y > 0.mm ? 1 : -1; auto post = cylinder(d: M3.d * 2.2, h: outerH).move(x: p.x, y: p.y) + box(outerW / 2 - abs(p.x), outerD / 2 - abs(p.y), outerH) .move(x: (p.x + sx * outerW / 2) / 2, y: (p.y + sy * outerD / 2) / 2); b += post & outside; b -= cylinder(d: M3.tap, h: innerH + 1.mm).move(x: p.x, y: p.y, z: floorT); } return b; } /// The lid as it sits on the box, its plate on z = 0. Solid lidInPlace() { auto l = lidOutline().extrude(lidT) .chamfer(edges.of(faces.top), d: closure == "flush" ? edge / 2 : edge); if (hasLip) { immutable lipOut = inner().offset(-clearance); // the tongue is thinner than the plate: a finger gets under it l -= (lidOutline() - outer()).offset(0.5.mm).extrude(lidT - tabT).move(z: -0.01.mm) - outer().extrude(lidT); l += (lipOut - lipOut.offset(-lipT)).extrude(lipH).move(z: -lipH); if (closure == "snap") l += ridge(lipOut, bead).move(z: -lipH + beadZ); } if (closure == "screws") foreach (p; screwSpots()) l -= cylinder(d: M3.clearance, h: 3 * lidT, center: true).move(x: p.x, y: p.y); if (closure == "flush") { // a hole in the middle for a finger to lift it by, its rims softened immutable hole = min(18.mm, innerD / 2); l = l.cut(circle(d: hole), on: faces.top, through: true) .chamfer(edges.filter(e => e.isCircle && e.radius == hole / 2), d: 0.6.mm); } return l; } /// The lid where it closes the box: on the rim, or in its step when flush. Solid closedLid() => lidInPlace().move(z: closure == "flush" ? outerH - lidT : outerH); /// On the box to see it closed, or beside it upside down (its top on the /// bed) to print, turned about Y so that its tab stays at the front. @part("#C9A27E") Solid lid() => closed ? closedLid() : lidInPlace().rotate(y: 180.deg).move(x: outerW + 15.mm, z: lidT); unittest { writeln("base ", base.bbox.size, " lid ", lid.bbox.size); // the lid closes without running into the box: only the snap's ridge // overlaps the wall, by the bead immutable overlap = (closedLid() & base).volume; assert(overlap < (closure == "snap" ? 200.mm ^^ 3 : 1.mm ^^ 3), "the lid does not fit the box"); } ``` ## Example: knob on a shaft ```d // Knob: fluted, for a potentiometer's D shaft // A fluted knob for a potentiometer with a 6 mm D shaft, printable // standing up as it is (no overhangs, no supports). // // Try it: // - change the values in Parameters, or pick a preset; // - edit the code: the view updates as you type; // - right-click a face or an edge to add a fillet, a hole, a pocket... // the GUI writes the code for you. module knob; import std.shapes; import std.hardware : M3, hole; // @param puts a field in the Parameters panel; @preset groups values under a name @param("Diameter", min: 16.mm, max: 50.mm) @preset("small", 18.mm) @preset("large", 36.mm) immutable diameter = 26.mm; @param("Height", min: 12.mm, max: 30.mm) @preset("small", 16.mm) @preset("large", 20.mm) immutable height = 17.mm; @param("Flutes", min: 0, max: 30) @preset("small", 10) @preset("large", 18) immutable flutes = 14; @param("Shaft clearance", min: 0.mm, max: 0.5.mm) immutable clearance = 0.15.mm; // the potentiometer shaft: 6 mm, with a flat leaving 4.5 mm immutable shaftD = 6.mm; immutable shaftFlat = 4.5.mm; immutable shaftDepth = 10.mm; // how far the shaft enters the knob // the shape: a skirt at the bottom, a body narrowing towards the top immutable r = diameter / 2; immutable skirtR = r + 2.5.mm; immutable skirtH = 1.6.mm; immutable topR = r * 0.86; immutable taper = atan((r - topR) / (height - skirtH)); /// A D-shaped prism: a cylinder with one side cut flat (towards +X). Solid dShape(Length d, Length flat, Length h) => cylinder(d: d, h: h) & box(d, d, h).move(x: flat - d); /// The body as a profile (radius, height) turned around the axis. Solid body() { auto profile = polygon([ vec(0.mm, 0.mm), vec(skirtR, 0.mm), vec(skirtR, skirtH), vec(r, skirtH + 2.5.mm), // 45° up to the body: printable vec(topR, height), vec(0.mm, height), ]); return profile .revolve(); } // @part marks a body to show and export; without a colour it is Sky Pop @part Solid knob() { auto k = body() .fillet(edges.of(faces.top), r: 1.5.mm) .chamfer(edges.of(faces.bottom), d: 0.4.mm); // a shallow dish on top immutable dish = 4 * r; k -= sphere(r: dish).move(z: height + dish - 0.8.mm); // flutes: grooves leaning with the taper, half a step away from +X // so the pointer and the set screw fall between two of them immutable flute = 2 * PI * r / flutes * 0.55; foreach (i; 0 .. flutes) k -= cylinder(d: flute, h: height, center: true) .rotate(y: -taper) .move(x: (r + topR) / 2 + flute * 0.2, z: height / 2 + 5.mm) .rotate(z: (i + 0.5) * 360.deg / flutes); // the pointer: a groove across the top, in line with the flat of the shaft immutable pointer = topR * 0.6; k -= box(pointer, 1.2.mm, 3.mm) .move(x: topR - 2.5.mm - pointer / 2, z: height - 1.6.mm); // the shaft hole, ending in a cone so it prints without a bridge immutable hd = shaftD + 2 * clearance; k -= dShape(hd, shaftFlat + clearance, shaftDepth); k -= cone(d1: hd, d2: 0.mm, h: hd / 2).move(z: shaftDepth); // an M3 set screw pressing on the flat (hole() comes from the hardware library) k -= hole(M3, skirtR, "tap") .rotate(y: 90.deg) .move(x: skirtR, z: 6.mm); return k; } // a reference part: drawn see-through to check the fit, but left out of // STL/3MF/STEP exports @part("slategray", alpha: 0.35, export: false) Solid shaft() => dShape(shaftD, shaftFlat, shaftDepth + 12.mm).move(z: -12.mm); // unittests run after every evaluation: their output goes to the console unittest { assert(height >= shaftDepth + 5.mm, "the shaft would come out of the top: make the knob taller"); writeln("knob: $(format("%.1f", knob.volume / 1.cm ^^ 3)) cm³"); } ``` ## Example: a constrained sketch, a medal ```d // A first sketch: a medal with an H and a rim in relief module medal; import std; @param("Diameter", min: 30.mm, max: 60.mm) immutable diameter = 40.mm; @param("Rim width", min: 2.mm, max: 5.mm) immutable rim = 3.5.mm; // the disc under the relief, and how high the H and the // rim stand on it immutable base = 2.mm; immutable relief = 1.mm; /// The rim and the H, seen from above: a sketch. Profile face() { auto s = sketch(); // drawn wrong on purpose, to see what each rule // below does: the inside of the rim a squashed // ellipse off the centre, the H small, crooked, // away from the middle; the centre is pinned at // the origin auto c = s.point(0, 0, fixed: true); s.circle(c, d: diameter); auto inner = s.ellipse(vec(2, 2)); // the H: twelve points, drawn by eye, and the // lines round them auto h = s.polyline([ vec(-3, -4), vec(0, -5), vec(1, 1), vec(4, 2), vec(3, -5), vec(6, -4), vec(6, 7), vec(4, 8), vec(4, 4), vec(1, 3), vec(0, 8), vec(-3, 7), ], closed: true); // the rim: the ellipse round the same centre, as // wide as tall (a circle), its size s.concentric(inner, c); s.equal(inner); s.radius(inner, rx: diameter / 2 - rim); // across and up, in turn foreach (i, l; h) { if (i % 2 == 0) s.horizontal(l); else s.vertical(l); } // the strokes all 5 mm wide, the letter 22 mm tall s.length(h[0], 5.mm); s.equal(h[4], h[0]); s.equal(h[6], h[0]); s.equal(h[10], h[0]); s.length(h[11], 22.mm); s.equal(h[5], h[11]); s.distance(h[2].start, h[8].end, 5.mm, along: Y); // 18 mm wide, the feet level, the bar in the middle s.distance(h[0].start, h[5].start, 18.mm, along: X); s.horizontal(h[0].start, h[5].start); s.equal(h[1], h[9]); // the H in the middle: the centre halfway along a // diagonal that is not part of the outline auto diagonal = s.line(h[0].start, h[6].start, construction: true); s.midpoint(c, diagonal); writeln("degrees of freedom left: ", s.dof); return s.profile; } @part Solid medal() { // the disc, the relief on it, a keyring hole // through the rim at the top return circle(d: diameter).extrude(base) + face().extrude(relief).move(z: base) - cylinder(d: rim - 1.mm, h: 10.mm) .move(y: diameter / 2 - rim / 2, z: -1.mm); } ``` ## Example: the shapes of a sketch ```d // The shapes a sketch has, each fully constrained module shapes; import std; @part Solid sampler() { auto s = sketch(); // places on a grid, 35 mm apart, and points pinned // there Vec2 grid(double x, double y) => vec(x * 35, y * 35); SketchPoint at(Vec2 p) => s.point(p.x, p.y, fixed: true); // a rectangle from its centre, the corners round s.rect(center: at(grid(-1, 1)), w: 26.mm, h: 18.mm, corner: 4.mm); // hexagons: the circle through the corners, or // the one the sides touch (across the flats) auto hex = s.polygon(at(grid(0, 1)), 6, d: 24.mm); s.horizontal(hex[0]); auto nut = s.polygon(at(grid(1, 1)), 6, d: 20.mm, fit: "sides"); s.horizontal(nut[0]); // an ellipse, tilted s.ellipse(at(grid(-1, 0)), rx: 13.mm, ry: 7.mm, angle: 20.deg); // a slot between two points, 8 mm wide s.slot(at(grid(0, 0) - vec(10, 0)), at(grid(0, 0) + vec(10, 0)), w: 8.mm); // a pentagon in a circle (construction: not part of // the outline), its corners on it auto ring = s.circle(at(grid(1, 0)), d: 24.mm, construction: true); auto five = s.polygon(ring.center, 5); s.on(five, ring); s.vertical(five[0].start, ring.center); // a square round a circle: every side touching it auto round = s.circle(at(grid(-1, -1)), d: 20.mm, construction: true); s.tangent(s.rect(center: round.center), round); // a rectangle in a circle, one side given auto disc = s.circle(at(grid(0, -1)), d: 26.mm, construction: true); auto inside = s.rect(center: disc.center); s.on(inside, disc); s.length(inside[0], 22.mm); // a circle and an ellipse made round: as wide as // tall s.circle(at(grid(1, -1) - vec(6, 0)), d: 10.mm); auto e = s.ellipse(at(grid(1, -1) + vec(7, 0))); s.equal(e); s.radius(e, rx: 5.mm); writeln("degrees of freedom left: ", s.dof); return s.profile.extrude(2.mm); } ``` ## Example: a pillow block, five sketches on five surfaces ```d // Pillow block: a 608 bearing housing from five sketches // A pillow block: a housing for a 608 ball bearing, // bolted down by two slotted feet. Each feature is a // sketch drawn on a different surface: the ground, // the front of the foot, the top of the foot, the // front of the upright and its slanted flank. module pillowblock; import std.shapes; import std.hardware : M5; @param("Foot length", min: 80.mm, max: 140.mm) immutable footLength = 90.mm; @param("Foot width", min: 30.mm, max: 50.mm) immutable footWidth = 30.mm; @param("Foot height", min: 5.mm, max: 15.mm) immutable footHeight = 8.mm; @param("Shaft height above the foot", min: 20.mm, max: 50.mm) immutable axisHeight = 26.mm; @param("Upright width at the bottom", min: 40.mm, max: 70.mm) immutable uprightWidth = 50.mm; @param("Upright thickness", min: 10.mm, max: 20.mm) immutable uprightDepth = 16.mm; @param("Slot travel", min: 2.mm, max: 10.mm) immutable travel = 6.mm; // the 608 bearing: 22 mm across, 7 mm wide immutable bearingD = 22.mm; immutable bearingWidth = 7.mm; immutable footCorner = 6.mm; immutable spotFace = 9.mm; immutable topRadius = 17.mm; // the slots halfway between the upright and the ends immutable slotX = (uprightWidth + footLength) / 4; unittest { assert(footLength / 2 - slotX >= travel / 2 + spotFace / 2 + 2.mm, "no room for the slots: a longer foot, a " ~ "narrower upright or less travel"); } /// The foot seen from above, on the ground (XY). Profile footSketch() { auto s = sketch(); auto c = s.point(0, 0, fixed: true); s.rect(center: c, w: footLength, h: footWidth, corner: footCorner); return s.profile; } /// The two bolt slots, w wide: through the foot, and /// wider for the washers on its top face. Profile slotsSketch(Length w) { auto s = sketch(); auto c = s.point(0, 0, fixed: true); foreach (side; [-1, 1]) { auto a = s.point(side * (slotX - travel / 2), 0); auto b = s.point(side * (slotX + travel / 2), 0); s.slot(a, b, w: w); // on the long axis, slotX from the middle s.horizontal(c, a); s.horizontal(a, b); s.distance(c, a, slotX - travel / 2, along: X); s.distance(a, b, travel); } return s.profile; } /// The upright seen from the front, standing on the /// foot: two flanks touching the round top, and the /// hole for the shaft. Profile uprightSketch() { auto s = sketch(); auto o = s.point(0, 0, fixed: true); auto bl = s.point(-uprightWidth / 2, 0); auto br = s.point(uprightWidth / 2, 0); auto centre = s.point(0, axisHeight); auto tr = s.point(topRadius, axisHeight + 4.mm); auto tl = s.point(-topRadius, axisHeight + 4.mm); auto bottom = s.line(bl, br); // the right flank and the shaft's centre named: the face swept // from the line, and the point, keep the names in the block auto right = s.line(br, tr).addTag("flank"); centre.addTag("shaft"); auto top = s.arc(centre, tr, tl); auto left = s.line(tl, bl); s.horizontal(bottom); s.midpoint(o, bottom); s.length(bottom, uprightWidth); s.vertical(o, centre); s.distance(o, centre, axisHeight); s.radius(top, topRadius); s.tangent(right, top); s.tangent(left, top); // room for the shaft behind the bearing s.circle(centre, d: bearingD - 8.mm); return s.profile; } /// The bearing's seat, on the upright's front face: /// the bearing, and a notch across it to lever it out. Profile seatSketch() { auto s = sketch(); auto c = s.point(0, 0, fixed: true); s.circle(c, d: bearingD); s.slot(bearingD + 6.mm, 3.mm, center: c); // where the notch crosses the seat, cut too: two // loops over each other leave their overlap empty s.fill(c); return s.profile; } /// A label sunk into the slanted flank, its letters /// left standing. Profile labelSketch() { auto s = sketch(); auto c = s.point(0, 0, fixed: true); s.rect(center: c, w: 9.mm, h: 24.mm, corner: 2.mm); return s.profile - text("608", size: 5.mm).centered.rotate(90); } @part("White Pop") Solid pillowBlock() { // 1. on the ground: the foot, its top edge rounded // before anything is cut in it auto foot = footSketch().extrude(footHeight); auto block = foot .fillet(edges.of(faces.top), r: 1.5.mm); // 2. on the front of the foot, moved back to the // middle: the upright, pushed in from there auto inset = (footWidth - uprightDepth) / 2; auto front = block.plane(faces.front) .offset(-inset) .frame(origin: vec(0, 0, footHeight)); auto upright = uprightSketch() .extrude(-uprightDepth) .placeOn(front); block += upright; // where the upright meets the foot, rounded block = block.fillet( edges.between(faces.from(upright), faces.from(foot)), r: 3.mm); // 3. on the top of the foot: the slots through it, // and flat seats for the washers auto top = foot.frame(faces.top); block = block.cut(slotsSketch(M5.clearance), on: top, through: true); block = block.cut(slotsSketch(spotFace), on: top, depth: 1.5.mm); // 4. on the upright's front face, round the shaft: // the bearing's seat auto face = faces.from(upright) .filter(f => f.normal.y < -0.99); block = block.cut(seatSketch(), on: block.frame(face, origin: block.point("shaft")), depth: bearingWidth + 0.2.mm); // 5. on the right flank, leaning: the label return block.cut(labelSketch(), on: block.frame(faces.tagged("flank")), depth: 0.6.mm); } // the bearing as a reference, in its seat: it goes in // from the front, 0.2 mm short of the bottom @part("goldenrod", alpha: 0.5, export: false) Solid bearing() => cylinder(d: bearingD, h: bearingWidth) .rotate(x: -90.deg) .move(pillowBlock().point("shaft") + vec(0.mm, 0.1.mm, 0.mm)); unittest { // a hair thinner than the bearing: nothing in its way auto probe = cylinder(d: bearingD - 0.1.mm, h: bearingWidth) .rotate(x: -90.deg) .move(pillowBlock().point("shaft") + vec(0.mm, 0.1.mm, 0.mm)); assert((pillowBlock() & probe).volume < 0.01 * 1.mm ^^ 3, "the bearing does not go into its seat"); } ``` ## Library source: hardware ```d module hardware; /// Metric screw sizes (ISO 4762 socket head cap screws, ISO 4032 nuts, /// ISO 7089 washers) with the holes to print or drill for them. /// /// Tags: a hole's faces are tagged "hardware.bore" (the counterbore's and /// the countersink's, where the head sits, "hardware.seat"), and stay so in /// the part it is cut from; a nut's hole is "hardware.bore" too, threaded or /// not; holes, screws, nuts and washers carry the axis /// "hardware.axis" (body.axis("hardware.axis"), with one of them in it). struct Screw { string name; Length d; /// nominal diameter Length pitch; /// coarse thread pitch Length tap; /// pilot hole for tapping or self-tapping Length clearance; /// clearance hole Length head; /// head diameter Length headHeight; Length key; /// hex key size (across flats) Length nut; /// nut width across flats Length nutHeight; Length washer; /// washer outer diameter Length insert; /// hole for a heat-set threaded insert Length insertDepth; } immutable M2 = Screw("M2", 2.mm, 0.4.mm, 1.6.mm, 2.4.mm, 3.8.mm, 2.mm, 1.5.mm, 4.mm, 1.6.mm, 5.mm, 3.2.mm, 4.mm); immutable M2_5 = Screw("M2.5", 2.5.mm, 0.45.mm, 2.05.mm, 2.9.mm, 4.5.mm, 2.5.mm, 2.mm, 5.mm, 2.mm, 6.mm, 3.6.mm, 5.mm); immutable M3 = Screw("M3", 3.mm, 0.5.mm, 2.5.mm, 3.4.mm, 5.5.mm, 3.mm, 2.5.mm, 5.5.mm, 2.4.mm, 7.mm, 4.2.mm, 5.7.mm); immutable M4 = Screw("M4", 4.mm, 0.7.mm, 3.3.mm, 4.5.mm, 7.mm, 4.mm, 3.mm, 7.mm, 3.2.mm, 9.mm, 5.6.mm, 8.1.mm); immutable M5 = Screw("M5", 5.mm, 0.8.mm, 4.2.mm, 5.5.mm, 8.5.mm, 5.mm, 4.mm, 8.mm, 4.7.mm, 10.mm, 6.4.mm, 9.5.mm); immutable M6 = Screw("M6", 6.mm, 1.mm, 5.mm, 6.6.mm, 10.mm, 6.mm, 5.mm, 10.mm, 5.2.mm, 12.mm, 8.mm, 12.7.mm); immutable M8 = Screw("M8", 8.mm, 1.25.mm, 6.8.mm, 9.mm, 13.mm, 8.mm, 6.mm, 13.mm, 6.8.mm, 16.mm, 9.7.mm, 12.7.mm); immutable M10 = Screw("M10", 10.mm, 1.5.mm, 8.5.mm, 11.mm, 16.mm, 10.mm, 8.mm, 16.mm, 8.4.mm, 20.mm, 12.mm, 14.mm); immutable M12 = Screw("M12", 12.mm, 1.75.mm, 10.2.mm, 13.5.mm, 18.mm, 12.mm, 10.mm, 18.mm, 10.8.mm, 24.mm, 14.4.mm, 16.mm); /// Unified inch (UNC) sizes, socket head cap screws (ASME B18.3), finished /// hex nuts and SAE washers: #4-40, #6-32, #8-32, #10-24 and 1/4-20 (the /// thread of cameras and tripods). Insert holes vary between makers: check yours. immutable UNC4_40 = Screw("#4-40 UNC", 2.845.mm, 0.635.mm, 2.26.mm, 3.3.mm, 4.65.mm, 2.845.mm, 2.38.mm, 6.35.mm, 2.38.mm, 7.9.mm, 4.mm, 3.4.mm); immutable UNC6_32 = Screw("#6-32 UNC", 3.505.mm, 0.794.mm, 2.71.mm, 3.7.mm, 5.74.mm, 3.505.mm, 2.78.mm, 7.94.mm, 2.78.mm, 9.5.mm, 4.7.mm, 3.8.mm); immutable UNC8_32 = Screw("#8-32 UNC", 4.166.mm, 0.794.mm, 3.45.mm, 4.4.mm, 6.86.mm, 4.166.mm, 3.57.mm, 8.73.mm, 3.18.mm, 11.1.mm, 5.6.mm, 5.mm); immutable UNC10_24 = Screw("#10-24 UNC", 4.826.mm, 1.058.mm, 3.8.mm, 5.1.mm, 7.92.mm, 4.826.mm, 3.97.mm, 9.53.mm, 3.18.mm, 12.7.mm, 6.4.mm, 6.4.mm); immutable UNC1_4_20 = Screw("1/4-20 UNC", 6.35.mm, 1.27.mm, 5.11.mm, 6.8.mm, 9.53.mm, 6.35.mm, 4.76.mm, 11.11.mm, 5.56.mm, 15.9.mm, 8.1.mm, 9.5.mm); /// Self-tapping screws for plastic (ISO 7049 pan head, ST sizes): they cut /// their own thread in the hole `tap` wide (0.8 of the diameter, for PLA, /// PETG and ABS: a little wider in harder plastics), with no nut nor insert. /// screw() draws them with head: "pan". immutable ST2_2 = Screw("ST2.2", 2.2.mm, 0.8.mm, 1.8.mm, 2.6.mm, 4.2.mm, 1.6.mm, 1.5.mm, 0.mm, 0.mm, 5.mm, 0.mm, 0.mm); immutable ST2_9 = Screw("ST2.9", 2.9.mm, 1.1.mm, 2.3.mm, 3.3.mm, 5.6.mm, 2.4.mm, 2.mm, 0.mm, 0.mm, 7.mm, 0.mm, 0.mm); immutable ST3_5 = Screw("ST3.5", 3.5.mm, 1.3.mm, 2.8.mm, 4.mm, 7.mm, 2.6.mm, 2.5.mm, 0.mm, 0.mm, 8.mm, 0.mm, 0.mm); immutable ST4_2 = Screw("ST4.2", 4.2.mm, 1.4.mm, 3.4.mm, 4.8.mm, 8.mm, 3.1.mm, 3.mm, 0.mm, 0.mm, 10.mm, 0.mm, 0.mm); /// The head of another kind for screw s: its diameter (across the flats for /// "hex"), its height and the hex key. "button" ISO 7380, "flat" ISO 10642 /// (countersunk, 90°), "hex" ISO 4017 (spanner across the flats), "pan" /// ISO 7049 (self-tapping); "socket" the ISO 4762 of the size itself. struct Head { Length d; Length h; Length key; } Head headOf(Screw s, string head) { // the hex keys of button and flat heads, a size smaller than the socket's immutable small = s.d <= 3.mm ? 2.mm : s.d <= 4.mm ? 2.5.mm : s.d <= 5.mm ? 3.mm : s.d <= 6.mm ? 4.mm : s.d <= 8.mm ? 5.mm : s.d <= 10.mm ? 6.mm : 8.mm; switch (head) { case "socket": return Head(s.head, s.headHeight, s.key); case "button": return Head((s.d <= 5.mm ? 1.9 : 1.75) * s.d, 0.55 * s.d, small); case "flat": return Head(2.24 * s.d, 0.62 * s.d, small); case "hex": // ISO 4017 heights (k), about 0.65 d (0.7 under M5) return Head(s.nut > 0.mm ? s.nut : 1.6 * s.d, (s.d < 5.mm ? 0.7 : 0.65) * s.d, 0.mm); case "pan": return Head(s.head, s.headHeight, s.key); default: assert(false, "head must be socket, button, flat, hex or pan, not " ~ head); } } /// Hexagonal prism standing on XY, flats parallel to the X axis. Solid hexPrism(Length acrossFlats, Length h) { auto slab = box(acrossFlats * 2, acrossFlats, h); return slab & slab.rotate(z: 60) & slab.rotate(z: 120); } /// A screw: head "socket" (ISO 4762, the default), "button" (ISO 7380), /// "flat" (ISO 10642, countersunk), "hex" (ISO 4017) or "pan" (the /// self-tapping ST sizes). The underside of the head lies on z = 0 (for a /// flat head, its top: it sinks into its countersink): the head grows /// upwards and the shank downwards, so the screw drops into a hole. /// threaded: true models the thread (the ISO one for metric sizes), built on /// the shank's side; otherwise the shank is plain; socket: false leaves out /// the hex socket. Solid screw(Screw s, Length length, bool socket = true, bool threaded = false, string head = "socket") { checkLength("screw", s, length); auto shank = threaded ? cylinder(d: s.d, h: length).move(z: -length) : cylinder(d: s.d, h: length) .chamfer(edges.filter(e => e.isCircle && e.mid.z < length / 2), d: s.d * 0.1) .move(z: -length); auto r = threaded ? threadShank(withHead(s, shank, socket, head), shank, s) : withHead(s, shank, socket, head); return r.addTag("hardware.axis", at: axis(origin: vec(0.mm, 0.mm, 0.mm), direction: Z)); } /// A screw or a set screw at least half its diameter long: shorter, its /// chamfers and point leave nothing. private void checkLength(string what, Screw s, Length length) { assert(length >= s.d / 2, what ~ ": an " ~ s.name ~ " screw is $(s.d / 2) long at least (half its " ~ "diameter), not $(length)"); } /// The thread on a screw's shank, cut on its side: built straight from its faces, quicker than a threaded /// rod joined with a boolean. Metric sizes by their name, the others (inch, self-tapping) by their pitch. private Solid threadShank(Solid screw, Solid shank, Screw s) { auto side = faces.from(shank).filter(f => abs(f.normal.z) < 0.5); return s.name.length > 0 && s.name[0 .. 1] == "M" ? screw.thread(side, s.name) : screw.thread(side, pitch: s.pitch); } /// The head on top of the shank. private Solid withHead(Screw s, Solid shank, bool socket, string head) { immutable hd = headOf(s, head); Solid top; switch (head) { case "flat": // a cone from the shank to the head, its top on z = 0 top = cone(d1: s.d, d2: hd.d, h: (hd.d - s.d) / 2).move(z: -(hd.d - s.d) / 2); if (socket) top -= hexPrism(hd.key, hd.h).move(z: -hd.h * 0.6); return top + shank; case "hex": top = hexPrism(hd.d, hd.h) .chamfer(edges.of(faces.top), d: hd.h * 0.12); return top + shank; case "button", "pan": top = cylinder(d: hd.d, h: hd.h) .fillet(edges.filter(e => e.isCircle && e.mid.z > hd.h / 2), r: hd.h * 0.6); break; default: top = cylinder(d: hd.d, h: hd.h) .fillet(edges.filter(e => e.isCircle && e.mid.z > hd.h / 2), r: s.d * 0.12); break; } // a pan head's recess is a cross (Phillips): left plain if (socket && hd.key > 0.mm && head != "pan") top -= hexPrism(hd.key, hd.h).move(z: hd.h * 0.45); return top + shank; } /// The hex key of a set screw (a grub screw) of size s: ISO 4026-4029, /// smaller than the socket head's. Length setKey(Screw s) => s.d <= 2.mm ? 0.9.mm : s.d <= 2.5.mm ? 1.3.mm : s.d <= 3.mm ? 1.5.mm : s.d <= 4.mm ? 2.mm : s.d <= 5.mm ? 2.5.mm : s.d <= 6.mm ? 3.mm : s.d <= 8.mm ? 4.mm : s.d <= 10.mm ? 5.mm : 6.mm; /// A set screw (a grub screw): threaded all along, no head, a hex socket in /// its top. point: "cup" (ISO 4029, the common one: a ring that bites into a /// shaft), "flat" (ISO 4026, to leave a mark), "cone" (ISO 4027, into a /// dimple) or "dog" (ISO 4028, a pin into a hole). Its socket end lies on /// z = 0 and the rest goes down, `length` long, point and all, so it drops /// into a hole the way hole() is placed. threaded: true models the thread. Solid setScrew(Screw s, Length length, string point = "cup", bool threaded = false) { checkLength("setScrew", s, length); immutable d = s.d; auto shank = cylinder(d: d, h: length).move(z: -length); auto r = shank.chamfer(edges.filter(e => e.isCircle && e.mid.z > -0.01.mm), d: d * 0.08); immutable tip = -length; switch (point) { case "cup": // a cone-shaped hollow half the diameter wide, the rim chamfered r = r.chamfer(edges.filter(e => e.isCircle && e.mid.z < tip + 0.01.mm), d: d * 0.12); r -= cone(d1: d * 0.5, d2: 0.mm, h: d * 0.15).move(z: tip - 0.001.mm); break; case "flat": r = r.chamfer(edges.filter(e => e.isCircle && e.mid.z < tip + 0.01.mm), d: d * 0.2); break; case "cone": // a 90° point, just blunt r = r & cone(d1: d * 0.1, d2: d * 0.1 + 2 * length, h: length).move(z: tip); break; case "dog": // a pin 0.65 of the diameter, 0.4 of it long, the step chamfered immutable pin = d * 0.4; r = r & (cylinder(d: d * 0.65, h: pin).move(z: tip) + cone(d1: d * 0.65, d2: d * 1.65, h: d * 0.5).move(z: tip + pin) + cylinder(d: d * 2, h: length).move(z: tip + pin + d * 0.5)); break; default: assert(false, "point must be cup, flat, cone or dog, not " ~ point); } // the socket: about 0.6 of the diameter deep, less in a short one immutable depth = min(d * 0.6, length * 0.5); r -= hexPrism(setKey(s), depth + 0.01.mm).move(z: -depth); if (threaded) r = threadShank(r, shank, s); return r.addTag("hardware.axis", at: axis(origin: vec(0.mm, 0.mm, 0.mm), direction: Z)); } /// Hex nut standing on z = 0, with the corners chamfered on both faces. /// threaded: true threads the hole (the ISO thread for metric sizes, with no /// play: the nut of a real screw). Solid nut(Screw s, bool threaded = false) { checkNut(s); // a cone narrow at the face and wider than the corners at mid height auto lower = cone(d1: s.nut * 0.95, d2: s.nut * 1.4, h: s.nutHeight / 2); auto upper = lower.mirror(Z).move(z: s.nutHeight); auto body = hexPrism(s.nut, s.nutHeight) & (lower + upper); if (threaded) { // the hole at the tap drill, its wall threaded (a sleeve sewn in, quicker than a boolean) auto bore = cylinder(d: s.tap, h: 3 * s.nutHeight, center: true); auto wall = faces.from(bore); // the outside's faces marked for a moment, so that the thread's (new, untagged) are the others body.faces.addTag("hardware.outside"); auto drilled = body - bore; auto threadedNut = s.name.length > 0 && s.name[0 .. 1] == "M" ? drilled.thread(wall, s.name, chamfer: false, clearance: 0.mm) : drilled.thread(wall, pitch: s.pitch, chamfer: false, clearance: 0.mm); // the thread's faces, the hole's wall as in the plain nut threadedNut.faces.addTag("hardware.bore"); threadedNut.faces.tagged("hardware.outside").removeTag(["hardware.bore", "hardware.outside"]); return threadedNut.addTag("hardware.axis", at: axis(origin: vec(0.mm, 0.mm, 0.mm), direction: Z)); } return (body - cylinder(d: s.d, h: 3 * s.nutHeight, center: true) .addTag("hardware.bore", recursive: true)) .addTag("hardware.axis", at: axis(origin: vec(0.mm, 0.mm, 0.mm), direction: Z)); } /// Flat washer standing on z = 0. Solid washer(Screw s) => (cylinder(d: s.washer, h: s.d * 0.2) - cylinder(d: s.clearance, h: s.d, center: true)) .addTag("hardware.axis", at: axis(origin: vec(0.mm, 0.mm, 0.mm), direction: Z)); /// A hole to subtract, with its top on z = 0 going down `depth`: /// "clearance" (the screw passes), "tap" (pilot hole for the thread), /// "insert" (heat-set insert), "counterbore" (clearance with room for the /// head), "countersink" (for flat heads) or "ribs": a hole the screw passes /// with three ribs along it, 120° apart, that the screw cuts its thread into /// as it goes in (better than a printed thread for small screws, and than a /// plain tap hole: less material to cut, so less force and no split wall). /// One rib points to -Y: on a wall (where a face's frame has y up) it is the /// bottom one, which prints best; the ribs start 0.6 mm in, a lead for the tip. /// /// The whole hole lies within `depth`: an insert, a counterbore or a /// countersink deeper than that is an error. /// /// `chamfer` widens the mouth at 45° by that much all round (a counterbore's /// round the head): it guides the screw in, and takes up the first layers' /// spread when the hole opens on the bed. 0.5 mm for "ribs" unless given, /// none for the others; a countersink's cone is a chamfer already (one given /// to it is an error). Solid hole(Screw s, Length depth, string kind = "clearance", string head = "socket", Length chamfer = -1.mm) { // its faces the hole's wall in the part (the seat, tagged so in holeShape, keeps its own tag too); // its axis down from the mouth auto shape = holeShape(s, depth, kind, head) .addTag("hardware.bore", recursive: true) .addTag("hardware.axis", at: axis(origin: vec(0.mm, 0.mm, 0.mm), direction: Z)); assert(kind != "countersink" || chamfer <= 0.mm, "hole: a countersink's cone is a chamfer already: " ~ "leave chamfer: out"); immutable c = chamfer >= 0.mm ? chamfer : kind == "ribs" ? 0.5.mm : 0.mm; if (c <= 0.mm) return shape; immutable mouth = kind == "tap" ? s.tap : kind == "insert" ? s.insert : kind == "ribs" ? s.d + 0.3.mm : kind == "counterbore" ? counterboreWidth(s, head) : s.clearance; return shape + cone(d1: mouth, d2: mouth + 2 * c, h: c + 0.01.mm).move(z: -c); } /// A counterbore's width: room for the head given (a hex head's corners, /// 1.155 times across its flats), and 0.6 mm. private Length counterboreWidth(Screw s, string head) { immutable hd = headOf(s, head); return (head == "hex" ? hd.d * 1.155 : hd.d) + 0.6.mm; } private Solid holeShape(Screw s, Length depth, string kind, string head) { switch (kind) { case "clearance": return cylinder(d: s.clearance, h: depth + 0.01.mm).move(z: -depth); case "tap": return cylinder(d: s.tap, h: depth + 0.01.mm).move(z: -depth); case "insert": assert(s.insert > 0.mm, s.name ~ " has no heat-set insert: a tap hole for a self-tapping screw"); // the whole hole within depth: an insert longer than that would come out of the part's other side assert(s.insertDepth <= depth, "hole: the " ~ s.name ~ " insert is $(s.insertDepth) long, deeper than " ~ "the hole's $(depth): a depth of $(s.insertDepth) at least, or a cylinder of the insert you have"); return cylinder(d: s.insert, h: s.insertDepth + 0.01.mm).move(z: -s.insertDepth) + cylinder(d: s.tap, h: depth).move(z: -depth); case "counterbore": { immutable seat = headOf(s, head).h + 0.2.mm; assert(seat <= depth, "hole: the counterbore for the " ~ s.name ~ " head is $(seat) deep, deeper than " ~ "the hole's $(depth): a depth of $(seat) at least, or a thicker part"); return cylinder(d: counterboreWidth(s, head), h: seat + 0.01.mm).move(z: -seat) .addTag("hardware.seat", recursive: true) + cylinder(d: s.clearance, h: depth).move(z: -depth); } case "ribs": { // the wall clear of the crests; the ribs reach in to the tap size immutable wall = s.d / 2 + 0.15.mm; immutable w = max(0.8.mm, 0.35 * s.d); immutable lead = min(0.6.mm, depth / 4); Solid[] ribs; foreach (k; 0 .. 3) ribs ~= cylinder(d: w, h: depth) .move(y: -(s.tap / 2 + w / 2), z: -depth - lead) .rotate(z: k * 120); return cylinder(r: wall, h: depth + 0.01.mm).move(z: -depth) - ribs; } case "countersink": { // 90°, wide enough for an ISO 10642 flat head (2.24 d at most) to sit a little below the face immutable top = 2.24 * s.d + 0.3.mm, sink = (top - s.clearance) / 2; assert(sink <= depth, "hole: the countersink for the " ~ s.name ~ " head is $(sink) deep, " ~ "deeper than the hole's $(depth): a depth of $(sink) at least, or a thicker part"); return cone(d1: s.clearance, d2: top, h: sink) .move(z: -sink) .addTag("hardware.seat", recursive: true) + cylinder(d: s.clearance, h: depth).move(z: -depth); } default: assert(false, "hole kind must be clearance, tap, insert, counterbore, countersink or ribs, not " ~ kind); } } /// A pocket to trap a nut, from z = 0 going down `depth` (with a little clearance). Solid nutTrap(Screw s, Length depth, Length clearance = 0.2.mm) { checkNut(s); return hexPrism(s.nut + 2 * clearance, depth + 0.01.mm).move(z: -depth); } /// The self-tapping sizes have no nut. private void checkNut(Screw s) { assert(s.nut > 0.mm, s.name ~ " is a self-tapping screw: it cuts its own thread in a tap hole, " ~ "with no nut"); } /// Square nuts (DIN 562, thin): across the flats and height. They slide /// into a slot from the side, where a hex nut needs a pocket from above. Length squareNutSize(Screw s) { checkSquareNut(s); return s.d <= 2.mm ? 4.mm : s.d <= 2.5.mm ? 5.mm : s.d <= 3.mm ? 5.5.mm : s.d <= 4.mm ? 7.mm : s.d <= 5.mm ? 8.mm : s.d <= 6.mm ? 10.mm : 13.mm; } Length squareNutHeight(Screw s) { checkSquareNut(s); return s.d <= 2.mm ? 1.2.mm : s.d <= 2.5.mm ? 1.6.mm : s.d <= 3.mm ? 1.8.mm : s.d <= 4.mm ? 2.2.mm : s.d <= 5.mm ? 2.7.mm : s.d <= 6.mm ? 3.2.mm : 4.mm; } private void checkSquareNut(Screw s) { assert(s.d <= 8.mm, "square nuts (DIN 562) go from M2 to M8, not " ~ s.name); } /// A square nut standing on z = 0, centred, its hole plain. Solid squareNut(Screw s) => box(squareNutSize(s), squareNutSize(s), squareNutHeight(s)) - cylinder(d: s.d, h: 3 * squareNutHeight(s), center: true); /// The slot a square nut slides into, to subtract: centred on the screw's /// axis at the origin, its middle on z = 0, open towards +X for `reach` /// (out through the part's side), `clearance` larger all round. Solid squareNutSlot(Screw s, Length reach, Length clearance = 0.2.mm) { immutable w = squareNutSize(s) + 2 * clearance, h = squareNutHeight(s) + 2 * clearance; return box(w, w, h).move(z: -h / 2) + box(reach, w, h).move(x: reach / 2, z: -h / 2); } /// Aluminium extrusions, B-type slot (the common 20 and 30 series: 2020, /// 3030, Misumi HFS5 and HFS6 and their copies): `series` 20 or 30. The /// sizes are typical ones: check your supplier's drawing for tight fits. struct Extrusion { Length size; /// the square's side Length slot; /// the opening of a slot Length lip; /// the thickness of the slot's lips Length cavity; /// the width of the space behind the lips Length depth; /// from the face to the bottom of the slot Length bore; /// the centre hole (tapped for the end screw) Screw screw; /// the screw its T-nuts take } Extrusion extrusionSeries(int series) { assert(series == 20 || series == 30, "extrusions here are of the 20 or 30 series"); return series == 20 ? Extrusion(20.mm, 6.2.mm, 1.8.mm, 11.mm, 6.1.mm, 4.2.mm, M5) : Extrusion(30.mm, 8.2.mm, 2.2.mm, 16.5.mm, 9.mm, 6.8.mm, M6); } /// A length of extrusion standing on z = 0 along Z, centred in X and Y: a /// reference part to build onto (a bracket, a printed T-nut, a clamp). Solid extrusion(int series, Length length) { immutable e = extrusionSeries(series); auto body = box(e.size, e.size, length) - cylinder(d: e.bore, h: 3 * length, center: true); foreach (i; 0 .. 4) { // the slot: its opening through the lip, then the cavity behind, narrowing at 45° immutable back = e.depth - e.lip; auto cut = box(e.lip + 0.02.mm, e.slot, length + 2.mm).move(x: e.size / 2 - e.lip / 2 + 0.01.mm) + (polygon([vec(e.size / 2 - e.lip, -e.cavity / 2), vec(e.size / 2 - e.lip, e.cavity / 2), vec(e.size / 2 - e.depth, e.cavity / 2 - back), vec(e.size / 2 - e.depth, -e.cavity / 2 + back)]) .extrude(length + 2.mm)); body -= cut.move(z: -1.mm).rotate(z: i * 90); } return body; } /// A T-nut that slides into the slot of an extrusion (`series` 20 or 30), /// as it sits in a slot facing +Z with the extrusion's face on z = 0: its /// neck in the opening, its body under the lips, `length` long along X, a /// hole for the series' screw through it (a tap hole: printed, the screw /// cuts its thread; the metal ones are the same shape). Solid tNut(int series, Length length = 10.mm, Length clearance = 0.2.mm) { immutable e = extrusionSeries(series); immutable back = e.depth - e.lip, w = e.cavity - 2 * clearance; // the body: the cavity's section, a clearance smaller, under the lips auto body = polygon([vec(-w / 2, -e.lip), vec(w / 2, -e.lip), vec(w / 2 - back, -e.depth + clearance), vec(-w / 2 + back, -e.depth + clearance)]) .extrude(length, along: X) .move(x: -length / 2); auto neck = box(length, e.slot - 2 * clearance, e.lip).move(z: -e.lip); return body + neck - cylinder(d: e.screw.tap, h: 4 * e.depth, center: true); } /// A key along the bottom of a printed part, to sit in an extrusion's slot /// and keep the part square to it: `length` long along X, standing down /// from z = 0 into the slot. Solid slotKey(int series, Length length, Length clearance = 0.15.mm) { immutable e = extrusionSeries(series); return box(length, e.slot - 2 * clearance, e.lip).move(z: -e.lip); } ``` ## Library source: threads ```d module threads; /// ISO metric threads (basic profile, 60°). Threads are real geometry: they /// print and export to STEP, but take longer to compute than plain shapes. /// Height of the thread tooth for a pitch (from the minor to the major diameter). Length toothHeight(Length pitch) => 0.5413 * pitch; /// A threaded rod of nominal diameter `d` standing on z = 0, with the ends /// chamfered (chamfer: false leaves them square). Solid threadedRod(Length d, Length pitch, Length length, bool chamfer = true, bool left = false) { // built from its boundary (each corner of the profile on its own helix, ruled faces between // them), cut to length: see isoThread auto rod = isoThread(d: d, pitch: pitch, length: length, chamfer: chamfer ? toothHeight(pitch) : 0.mm); // left-handed: the right-handed one mirrored return left ? rod.mirror(XZ) : rod; } /// A tool to subtract for an internal thread that a `d` screw fits, from /// z = 0 going down `depth`. `clearance` widens it for printed parts; left: /// true for a left-handed screw. Solid tappedHole(Length d, Length pitch, Length depth, Length clearance = 0.15.mm, bool left = false) => threadedRod(d + 2 * clearance, pitch, depth + pitch, chamfer: false, left: left).move(z: -depth); ``` ## Library source: gears ```d module gears; /// Involute spur gears and racks. `m` is the module (pitch diameter / teeth): /// two gears mesh when they have the same module and pressure angle, with /// their centres `centerDistance(m, z1, z2)` apart. Length pitchDiameter(Length m, int teeth) => m * teeth; Length outsideDiameter(Length m, int teeth) => m * (teeth + 2); Length rootDiameter(Length m, int teeth) => m * (teeth - 2.5); Length centerDistance(Length m, int teeth1, int teeth2) => m * (teeth1 + teeth2) / 2; /// Angle of the involute of a circle of radius rb at radius r. double involute(Length rb, Length r) { immutable t = sqrt((r / rb) ^^ 2 - 1); return t - atan(t) / 1.rad; } /// The outline of a spur gear centred on the origin, a tooth on +X. /// backlash thins the teeth (measured on the pitch circle) so that printed /// gears do not bind. The teeth are those a rack cuts (generated): under /// about 21 teeth the rack's tip undercuts the flanks' roots, room for the /// tips of the gear they mesh with. Profile gearProfile(Length m, int teeth, Angle pressure = 20.deg, Length backlash = 0.1.mm, int steps = 6) { assert(teeth >= 6, "a gear needs at least 6 teeth"); checkPressure(pressure); assert(backlash >= 0.mm, "the backlash cannot be negative"); immutable r = m * teeth / 2; immutable rb = r * cos(pressure); immutable ra = r + m; immutable rf = r - 1.25 * m; // half the tooth thickness on the pitch circle, as an angle, plus the involute offset; the tip keeps // some thickness up to the backlash `most` immutable half = PI / (2 * teeth) - backlash / (2 * r) + involute(rb, r); immutable most = 2 * r * (PI / (2 * teeth) + involute(rb, r) - involute(rb, ra)); assert(backlash < most, "the backlash would thin the teeth to nothing: keep it below $(most) " ~ "for this gear"); // the rack that cuts the gear, its teeth 1.25 m high and sharp (the deepest undercut): the corner // of its tip beside a flank, along the pitch line from the tooth's centre. Its straight flank cuts // the involute down to `low`, its tip's corner the trochoid below, which undercuts the involute // of a gear with few teeth immutable u = backlash / 2 - PI * m / 4 - 1.25 * m * tan(pressure); immutable reach = 1.25 * m / sin(pressure), along = r * sin(pressure); immutable low = reach < along ? hypot(rb, along - reach) : rb; // the flank at radius rr, as an angle from the tooth's centre line (negative): as near to the // centre line as the involute or the corner's trochoid cuts double side(Length rr) { immutable s = rr > rf ? sqrt(rr ^^ 2 - rf ^^ 2) : 0.mm; auto a = max(atan2(s, rf) / 1.rad - (s - u) / r, atan2(-s, rf) / 1.rad - (-s - u) / r); return rr >= low ? max(a, involute(rb, max(rr, rb)) - half) : a; } // a flank from the root to the tip: steps points on the trochoid, steps on the involute Length[] radii; if (low > rf + 0.001.mm) foreach (i; 0 .. steps) radii ~= rf + (low - rf) * i / steps; foreach (i; 0 .. steps + 1) radii ~= low + (ra - low) * i / steps; double[] sides; foreach (rr; radii) { sides ~= side(rr); assert(sides[$ - 1] < 0, "the backlash would thin the teeth to nothing: keep it below $(most) " ~ "for this gear"); } Vec2[] pts; foreach (k; 0 .. teeth) { immutable c = 2 * PI * k / teeth; // the side of negative angles from the root to the tip, the other back down foreach (i, rr; radii) pts ~= vec(rr * cos(c + sides[i]), rr * sin(c + sides[i])); foreach_reverse (i, rr; radii) pts ~= vec(rr * cos(c - sides[i]), rr * sin(c - sides[i])); } return polygon(pts); } /// The pressure angles that give teeth: from above 0° to 30° (14.5°, 20° /// and 25° are the usual ones). private void checkPressure(Angle pressure) { assert(pressure > 0.deg && pressure <= 30.deg, "the pressure angle goes from above 0° to 30° (20° is the " ~ "standard, 14.5° and 25° the others): not $(pressure)"); } /// A spur gear standing on z = 0, with an optional bore; `steps` points on /// each flank's involute and as many on its root (more: smoother teeth, /// slower). Tags: the axis "gears.axis", the bore's wall "gears.bore". Solid spurGear(Length m, int teeth, Length thickness, Length bore = 0.mm, Angle pressure = 20.deg, Length backlash = 0.1.mm, int steps = 6) { assert(bore < rootDiameter(m, teeth) - 2 * m, "spurGear: the bore must be smaller than the gear's root circle, with a module of material left"); auto g = gearProfile(m, teeth, pressure, backlash, steps).extrude(thickness); if (bore > 0.mm) g -= cylinder(d: bore, h: 3 * thickness, center: true) .addTag("gears.bore", recursive: true); return g.addTag("gears.axis", at: axis(origin: vec(0.mm, 0.mm, 0.mm), direction: Z)); } /// A straight rack along +X starting at x = 0, teeth up, pitch line at y = 0; /// backlash thins the teeth on the pitch line, as on a gear. Tag: the pitch /// line "gears.pitch" (an axis along it, on the rack's bottom face). Solid rack(Length m, int teeth, Length thickness, Length base = 0.mm, Angle pressure = 20.deg, Length backlash = 0.1.mm) { assert(teeth >= 1, "a rack needs at least 1 tooth"); checkPressure(pressure); immutable e = backlash / 2; immutable p = PI * m; immutable b = base > 0.mm ? base : 2 * m; immutable t = tan(pressure); // the tooth's top keeps some width immutable most = p / 2 - 2 * m * t; assert(backlash >= 0.mm && backlash < most, "rack: the backlash goes from 0 to under $(most) (the width " ~ "of the teeth's tops)"); Vec2[] pts = [vec(0.mm, -1.25 * m - b)]; foreach (k; 0 .. teeth) { immutable x0 = k * p; pts ~= [vec(x0, -1.25 * m), vec(x0 + p / 4 - 1.25 * m * t + e, -1.25 * m), vec(x0 + p / 4 + m * t + e, m), vec(x0 + 3 * p / 4 - m * t - e, m), vec(x0 + 3 * p / 4 + 1.25 * m * t - e, -1.25 * m)]; } pts ~= [vec(teeth * p, -1.25 * m), vec(teeth * p, -1.25 * m - b)]; return polygon(pts) .extrude(thickness) .addTag("gears.pitch", at: axis(origin: vec(0.mm, 0.mm, 0.mm), direction: X)); } ``` ## Library source: hinges ```d module hinges; /// The two leaves of a hinge, printed in place: they come out of the /// printer already joined and turn around the X axis. Tags: both leaves /// carry the axis they turn round, "hinges.axis"; pinHinge's holes for the /// pin are tagged "hinges.pin". struct Hinge { Solid fixed; /// the leaf on -Y, with the pin Solid moving; /// the leaf on +Y, turning around the pin } /// A print-in-place hinge along X, centred on the origin, leaves lying flat /// on z = 0 and the axis at z = thickness. `knuckles` alternate between /// the leaves (odd numbers keep it symmetric). Hinge hinge(Length length = 40.mm, Length width = 15.mm, Length thickness = 3.mm, int knuckles = 5, Length clearance = 0.4.mm) { assert(knuckles >= 3, "a hinge needs at least 3 knuckles"); immutable outer = 2 * thickness; // knuckle diameter immutable pin = outer * 0.45; immutable segment = length / knuckles; /// Knuckle i with the web that joins it to its leaf (side -1 or +1). Solid knuckle(int i, int side) { immutable x0 = -length / 2 + i * segment + (i > 0 ? clearance / 2 : 0.mm); immutable x1 = -length / 2 + (i + 1) * segment - (i < knuckles - 1 ? clearance / 2 : 0.mm); immutable reach = outer / 2 + clearance; auto web = box(x1 - x0, reach, thickness).move(x: (x0 + x1) / 2, y: side * reach / 2); return cylinder(d: outer, h: x1 - x0).rotate(y: 90).move(x: x0, z: thickness) + web; } Solid a = box(length, width, thickness).move(y: -width / 2 - outer / 2 - clearance); Solid b = box(length, width, thickness).move(y: width / 2 + outer / 2 + clearance); foreach (i; 0 .. knuckles) { if (i % 2 == 0) a += knuckle(i, -1); else b += knuckle(i, 1); } // the pin belongs to the fixed leaf and runs through the moving knuckles with clearance a += cylinder(d: pin, h: length - 2 * clearance).rotate(y: 90).move(x: -length / 2 + clearance, z: thickness); foreach (i; 0 .. knuckles) if (i % 2 == 1) b -= cylinder(d: pin + 2 * clearance, h: length).rotate(y: 90).move(x: -length / 2, z: thickness); return leaves(a, b, thickness); } /// The two leaves, each with the axis they turn round (along X at z = thickness). private Hinge leaves(Solid a, Solid b, Length thickness) { auto turn = axis(origin: vec(0.mm, 0.mm, thickness), direction: X); return Hinge(a.addTag("hinges.axis", at: turn), b.addTag("hinges.axis", at: turn)); } /// A living hinge: two plates `width` wide each, lying flat side by side /// along X, joined by a thin web `web` thick and `gap` wide at their /// underside, which bends. For PETG, PP or TPU (PLA cracks after a few /// bends); print it flat, the web on the bed, so its layers run across the /// fold. The plates lie on -Y and +Y, the fold line along X at y = 0. Solid livingHinge(Length length = 40.mm, Length width = 15.mm, Length thickness = 3.mm, Length web = 0.5.mm, Length gap = 3.mm) { assert(web < thickness, "livingHinge: the web must be thinner than the plates"); auto plates = box(length, width, thickness).move(y: -width / 2 - gap / 2) + box(length, width, thickness).move(y: width / 2 + gap / 2); return plates + box(length, gap + 2.mm, web); } /// The knuckles of a hinge along X round the axis at z = thickness, each with /// its web to its leaf, for leaf `side` (-1 or +1), as hinge() makes them. private Solid knuckleOf(int i, int side, Length length, Length thickness, int knuckles, Length clearance) { immutable outer = 2 * thickness, segment = length / knuckles; immutable x0 = -length / 2 + i * segment + (i > 0 ? clearance / 2 : 0.mm); immutable x1 = -length / 2 + (i + 1) * segment - (i < knuckles - 1 ? clearance / 2 : 0.mm); immutable reach = outer / 2 + clearance; auto web = box(x1 - x0, reach, thickness).move(x: (x0 + x1) / 2, y: side * reach / 2); return cylinder(d: outer, h: x1 - x0).rotate(y: 90).move(x: x0, z: thickness) + web; } /// A hinge turning on a separate pin: the knuckles of both leaves holed /// `pin` + 2 × `clearance` across, for a length of 1.75 mm filament, a nail or /// a screw pushed through when assembled. Laid as hinge() lays its leaves (the /// axis along X at z = thickness); the leaves print apart, flat, and the /// hinge comes apart again. Hinge pinHinge(Length length = 40.mm, Length width = 15.mm, Length thickness = 3.mm, int knuckles = 5, Length pin = 1.75.mm, Length clearance = 0.3.mm) { assert(knuckles >= 3, "a hinge needs at least 3 knuckles"); assert(pin < 1.6 * thickness, "pinHinge: the pin is too thick for the knuckles: thicker leaves"); immutable outer = 2 * thickness; Solid a = box(length, width, thickness).move(y: -width / 2 - outer / 2 - clearance); Solid b = box(length, width, thickness).move(y: width / 2 + outer / 2 + clearance); foreach (i; 0 .. knuckles) { if (i % 2 == 0) a += knuckleOf(i, -1, length, thickness, knuckles, clearance); else b += knuckleOf(i, 1, length, thickness, knuckles, clearance); } auto bore = cylinder(d: pin + 2 * clearance, h: length + 2.mm) .rotate(y: 90) .move(x: -length / 2 - 1.mm, z: thickness) .addTag("hinges.pin", recursive: true); return leaves(a - bore, b - bore, thickness); } /// A hinge that snaps together: three knuckles, the two outer ones on the /// fixed leaf with a cone standing in from each, the middle one on the /// moving leaf with a conical socket at each end. Spring the outer knuckles /// apart a little, push the middle one between them, and the cones click into /// their sockets; it comes apart the same way. Laid as hinge() lays its /// leaves; they print apart, flat. Hinge snapHinge(Length length = 30.mm, Length width = 15.mm, Length thickness = 3.mm, Length clearance = 0.3.mm) { immutable outer = 2 * thickness, segment = length / 3; Solid a = box(length, width, thickness).move(y: -width / 2 - outer / 2 - clearance) + knuckleOf(0, -1, length, thickness, 3, clearance) + knuckleOf(2, -1, length, thickness, 3, clearance); Solid b = box(length, width, thickness).move(y: width / 2 + outer / 2 + clearance) + knuckleOf(1, 1, length, thickness, 3, clearance); // the cones: from the outer knuckles' inner faces towards the middle, 45° so they print and snap immutable cd = 0.6 * outer, ch = cd / 2 * 0.8; auto cone1 = cone(d1: cd, d2: cd - 2 * ch, h: ch).rotate(y: 90); immutable inner0 = -length / 2 + segment - clearance / 2, inner1 = length / 2 - segment + clearance / 2; a = a + cone1.move(x: inner0, z: thickness) + cone1.mirror(X).move(x: inner1, z: thickness); auto socket = cone(d1: cd + 2 * clearance, d2: cd - 2 * ch + 2 * clearance, h: ch + clearance).rotate(y: 90); b = b - socket.move(x: inner0 - 0.01.mm, z: thickness) - socket.mirror(X).move(x: inner1 + 0.01.mm, z: thickness); return leaves(a, b, thickness); } ``` ## Library source: snaps ```d module snaps; /// Cantilever snap-fit hooks. The hook stands on z = 0 and leans out along /// +X; print it standing so the layers run along the beam. /// A hook: a flexible beam `length` long with a barb `overhang` deep at the /// top. `lead` is the slope that pushes it aside when inserting. Solid snapHook(Length length = 10.mm, Length thickness = 1.5.mm, Length width = 5.mm, Length overhang = 1.mm, Angle lead = 30.deg) { assert(lead >= 5.deg && lead <= 85.deg, "snapHook: the lead is the slope of the barb's face, from 5° (long " ~ "and gentle) to 85° (short and steep): not $(lead)"); immutable barb = overhang / tan(lead); // height of the sloped face auto profile = polygon([vec(0.mm, 0.mm), vec(thickness, 0.mm), vec(thickness, length), vec(thickness + overhang, length), vec(thickness, length + barb), vec(0.mm, length + barb)]); // drawn in the XY plane (y up the beam), then stood up return profile.extrude(width).move(z: -width / 2).rotate(x: 90).move(y: 0.mm); } /// The slot a hook clicks into, to subtract from the mating wall: a window /// round the beam, `clearance` larger, with room on the -X side for the beam /// to bend aside while the barb goes through; then the barb rests on the /// wall beyond the window's +X edge. Solid snapSlot(Length thickness = 1.5.mm, Length width = 5.mm, Length overhang = 1.mm, Length wall = 2.mm, Length clearance = 0.3.mm) => box(thickness + overhang + 2 * clearance, width + 2 * clearance, wall + 1.mm) .move(x: (thickness - overhang) / 2, z: -0.5.mm); ``` ## Library source: enclosures ```d module enclosures; import std.hardware : Screw, M3, hole; /// Boxes with rounded corners and enclosures with lids. /// /// Tags: a tray's or a base's rim, where the lid rests, is tagged /// "enclosures.rim", its floor inside "enclosures.floor"; the lip, the ring /// between a base and its lid, "enclosures.lip". /// A box centred in X and Y on z = 0, with vertical edges rounded by `r`. Solid roundedBox(Length x, Length y, Length z, Length r = 3.mm) { assert(r > 0.mm && 2 * r < min(x, y), "roundedBox: r must be more than 0 and less than half the shorter side"); return box(x, y, z).fillet(edges.parallel(Z), r: r); } /// An open box: walls `wall` thick, open on top. Solid tray(Length x, Length y, Length z, Length wall = 2.mm, Length r = 3.mm) { assert(wall > 0.mm && 2 * wall < min(x, y) && wall < z, "tray: the wall must be thinner than half the box and its height"); // a wall as thick as the corners' radius leaves the inside corners a radius of nothing assert(abs(wall - r) > 0.05.mm, "tray: a wall as thick as r leaves sharp inside corners that cannot be built: " ~ "make r larger than the wall (or the wall thicker than r)"); auto t = roundedBox(x, y, z, r).shell(open: faces.top, thickness: wall); t.faces.top.addTag("enclosures.rim"); t.faces.normal(Z).filter(f => abs(f.center.z - wall) < 0.001.mm).addTag("enclosures.floor"); return t; } /// A base and a lid that fit together, both standing on z = 0. struct Enclosure { Solid base; Solid lid; } /// An enclosure with `inner` free space (x, y, z), a lid with a lip that /// fits inside the base, and a screw post in each corner. Enclosure enclosure(Vec3 inner, Length wall = 2.mm, Length r = 3.mm, Length lidHeight = 4.mm, Screw screw = M3, Length clearance = 0.2.mm) { assert(lidHeight > 0.mm && lidHeight < inner.z, "enclosure: the lid's lip (lidHeight) must be lower than the inside (inner.z)"); immutable ox = inner.x + 2 * wall, oy = inner.y + 2 * wall; auto base = tray(ox, oy, inner.z + wall, wall, r); auto lid = roundedBox(ox, oy, wall, r); // the lip: a thin ring that slides into the base immutable lx = inner.x - 2 * clearance, ly = inner.y - 2 * clearance; auto lip = roundedBox(lx, ly, lidHeight, max(r - wall, 0.5.mm)) - roundedBox(lx - 2 * wall, ly - 2 * wall, lidHeight * 3, max(r - 2 * wall, 0.5.mm)).move(z: -lidHeight); lid += lip.move(z: wall).addTag("enclosures.lip", recursive: true); // posts: tapped for the screw in the base, clearance holes in the lid. // The screw's axis is as near the corner as it can be: its hole keeps // 1.2 mm of wall outside it, across the rounded corner too, and its head // stays on the lid. With thick walls the axis is inside the wall and the // post all but disappears into the corner immutable postR = screw.tap / 2 + screw.d * 0.8; immutable keep = max(screw.tap / 2 + 1.2.mm, screw.head / 2 + 0.5.mm); // from the axis to the outside // the axis' distance from the inner faces (negative: inside the walls) auto inset = keep - wall; if (keep > r) inset = max(inset, r - wall); else inset = max(inset, r - wall - (r - keep) / sqrt(2.0)); immutable px = inner.x / 2 - inset, py = inner.y / 2 - inset; // up to the rim, where the lid rests: the screw bites from the lid on (the lip is notched round // each post, below); a short screw holds, one lid's thickness and a few mm into the post immutable postH = inner.z + wall; // from the axis to the middle of the walls: the post is one piece with // the corner it stands in, with no gap behind it immutable reach = inset + wall / 2; foreach (sx; [-1, 1]) foreach (sy; [-1, 1]) { auto post = cylinder(r: postR, h: postH); if (reach > 0.1.mm) post += box(reach, reach, postH).move(x: sx * reach / 2, y: sy * reach / 2); // near the corner the post would stick out: trimmed by the box's outside base += post.move(x: sx * px, y: sy * py) & roundedBox(ox, oy, postH, r); // from the rim (the screw may go through the wall) down to the floor base -= hole(screw, inner.z, "tap").move(x: sx * px, y: sy * py, z: inner.z + wall); lid -= cylinder(d: screw.clearance, h: 10 * wall, center: true).move(x: sx * px, y: sy * py); // room for the post in the lip, the clearance all round it; the // square reaches past the lip, which stops short of the corner immutable out = max(inset, 0.mm) + wall + clearance; auto room = cylinder(r: postR + clearance, h: lidHeight * 2) + box(out, out, lidHeight * 2).move(x: sx * out / 2, y: sy * out / 2); lid -= room.move(x: sx * px, y: sy * py, z: wall); } return Enclosure(base, lid); } /// A case with upright walls round one part: the shadow it casts on the /// plane `on` (the ground, XY, unless given), its convex hull grown by /// `clearance`, walls `wall` thick standing square to the plane, so the part /// drops in along the plane's normal and lifts out. Base and lid meet at /// `split` of the part's height (halfway unless given): opened, the part /// stands out of the base, to take it. The base: a floor `wall` thick under /// the part, its walls, and a lip `lip` high rising from their inner half; /// the lid: its walls, `play` wider round the lip, and a top `wall` thick /// `clearance` over the part. close: "lip" holds the lid on by friction; /// "snap" adds a bead round the lip, halfway up it, that clicks into a groove /// inside the lid (its wall gives a little as it goes over). The lip is 3 mm /// high unless given, lower on a flat part (a card, a coin, a board) where /// that does not fit under the lid's top. /// /// Tags: the base's floor inside is "enclosures.floor", the top of its walls, /// where the lid rests, "enclosures.rim", the lip (on the base) "enclosures.lip"; /// the lid has none. Enclosure shadowCase(Solid inside, Plane on = XY, Length wall = 2.mm, Length clearance = 0.5.mm, double split = 0.5, string close = "lip", Length lip = 0.mm, Length play = 0.2.mm) { assert(close == "lip" || close == "snap", "shadowCase: close is \"lip\" or \"snap\", not \"" ~ close ~ "\""); assert(wall > 0.mm && clearance >= 0.mm && lip >= 0.mm && play >= 0.mm && play < wall, "shadowCase: a wall and a lip thicker than 0, a clearance and a play of 0 or more (the play thinner " ~ "than the wall)"); assert(split > 0 && split < 1, "shadowCase: split is where base and lid meet, between 0 (the bottom) and " ~ "1 (the top) of the part"); immutable n = on.normal, o = on.origin; // how far the part reaches along the normal, from the plane: its box once turned so the normal is Z immutable square = abs(n.z) > 0.999999; immutable b = square ? inside.bbox : inside .rotate(acos(n.z), axis: axis(origin: vec(0.mm, 0.mm, 0.mm), direction: cross(n, Z).normalized)) .bbox; immutable low = (square && n.z < 0 ? -b.max.z : b.min.z) - dot(o, n); immutable high = (square && n.z < 0 ? -b.min.z : b.max.z) - dot(o, n); immutable cut = low + split * (high - low), top = high + clearance; // the lip as high as asked, or 3 mm where that leaves the lid's walls half the room above it immutable room = top - cut - play; immutable h = lip > 0.mm ? lip : min(3.mm, room / 2); assert(cut + h + play < top, "shadowCase: the lip reaches the lid's top: a shorter lip, or split lower"); immutable bead = close == "snap" ? min(0.4.mm, wall / 4) : 0.mm; assert(h > 2 * bead + play, "shadowCase: the lip is too low for a snap's bead: close: \"lip\", or split lower"); // the outline: the shadow grown, its convex hull where it is not convex; the walls drawn as rings in // the plane and extruded across it, from where they go immutable projected = inside.projection(on), hullOf = hull(projected); immutable shadow = hullOf.area - projected.outer.area < 1e-6 * hullOf.area ? projected.outer : hullOf; Profile around(Length by) => by > 0.mm ? shadow.offset(by) : shadow; Solid along(Profile p, Length from, Length height) => p.extrude(height).move(n * from); immutable outer = around(clearance + wall), inner = around(clearance); // the layers below overlap where they are joined: faces that meet on a slanted plane are not // exactly on each other, and joining them is slow and comes out broken. // The base: floor, walls up to the cut (from the floor's bottom), the lip on their inner half auto floor = along(outer, low - wall, wall); floor.faces.normal(n).addTag("enclosures.floor"); auto side = along(outer - inner, low - wall, cut - low + wall); side.faces.normal(n).addTag("enclosures.rim"); // the lid: the outer half of the wall round the lip (play wider), the whole wall above it, the top immutable over = top - cut - h - play; auto walls = along(outer - inner, cut + h + play, over + wall / 2); auto cap = along(outer, top, wall); // the lip and the lid's skirt round it: with snap, halfway up, a bead `bead` out from the lip and a // groove as much (and play) into the skirt (a step that small prints without support) immutable lipOut = clearance + wall / 2 - play / 2; immutable mid = cut + h / 2; auto ring = along(around(lipOut) - inner, cut - (cut - low) / 2, h + (cut - low) / 2); auto skirt = along(outer - around(lipOut + play), cut, h + play + over / 2); if (bead > 0.mm) { ring += along(around(lipOut + bead) - around((clearance + lipOut) / 2), mid - bead, 2 * bead); skirt -= along(around(lipOut + bead + play) - around(lipOut), mid - bead - play / 2, 2 * bead + play); } ring.addTag("enclosures.lip", recursive: true); auto base = floor + side + ring, lid = skirt + walls + cap; return Enclosure(base, lid); } ``` ## Library source: joints ```d module joints; /// Tags: on both pieces, the faces left by the cut (in its plane) are tagged /// "joints.cut", and the plane itself "joints.plane" (piece.plane("joints.plane")). /// `s` with its faces lying in the plane `p` tagged "joints.cut", and the plane "joints.plane". private Solid cutTagged(Solid s, Plane p) { bool on(Face f) => abs(dot(f.center - p.origin, p.normal)) < 0.001.mm && abs(dot(f.normal, p.normal)) > 0.999; // (a joint can leave no face whole in the plane: a finger joint's cut is all fingers) if (s.faces.filter(f => on(f)).count > 0) s.faces.filter(f => on(f)).addTag("joints.cut"); return s.addTag("joints.plane", at: p); } /// The two pieces of a solid cut along a plane with a joint between them, /// and the loose parts that lock it (a wedge), if any. struct Joint { Solid a; /// the piece behind the plane (against its normal) Solid b; /// the piece in front of it (where the normal points) Solid[] loose; /// a wedge or dowels to print apart, in place Solid[] print; /// the same lying flat on z = 0 in a row beside the body, to print } /// The kinds of joint splitJoint() knows. immutable string[] jointKinds = ["tenon", "dovetail", "pins", "fingers", "lap", "kama", "kanawa", "koshikake", "barbed", "dowels"]; /// What a joint pushes across the plane, in the plane's own frame (x and y /// in the plane, z along its normal), `s` its size and `g` added all round /// (the clearance, for the hollow it fits in): `up` what piece a pushes into /// b (z > 0), `down` what b pushes into a (z < 0). private struct Keys { Solid up; Solid down; } /// A box from (x0, y0, z0) to (x1, y1, z1), grown by g all round. private Solid span(Length x0, Length x1, Length y0, Length y1, Length z0, Length z1, Length g) => box(x1 - x0 + 2 * g, y1 - y0 + 2 * g, z1 - z0 + 2 * g) .move(x: (x0 + x1) / 2, y: (y0 + y1) / 2, z: z0 - g); /// A prism across the whole piece along y, its outline in x and z. private Solid acrossY(Vec2[] xz, Length reach) => polygon(xz).extrude(2 * reach, along: Y).move(y: -reach); /// `across`: how wide the section is along x, which the fingers share. private Keys jointKeys(string kind, Length s, Length len, Length g, int count, Length across) { // far enough to cross the whole section, whatever its shape immutable far = 4 * s; Solid none = box(0.01.mm, 0.01.mm, 0.01.mm).move(z: -10 * s - 1.mm); switch (kind) { case "tenon": { // a rectangular tenon, its tip chamfered to find its mortise immutable w = 0.5 * s, t = 0.3 * s, c = min(0.1 * t, 1.mm); Solid tenon = span(-w / 2, w / 2, -t / 2, t / 2, -0.01.mm, len, g); if (g == 0.mm) tenon = tenon.chamfer(edges.of(faces.top), d: c); return Keys(tenon, none); } case "pins": { // `count` round pins in a row along x: stout (a third of the size across, at most one // and a half times as long as thick) and flared at their foot, where a pin printed // standing up would snap along a layer; their holes get the same flare as a mouth // as thick as `count` of them allow in a row across 0.85 of the size, flares 1.4 times // their width apart, a third of the size at most immutable n = max(1, count); immutable d = min(0.3 * s, 0.85 * s / (2.24 * (n - 1) + 1.6)); immutable tall = min(len, 1.5 * d), flare = 0.3 * d; immutable step = n > 1 ? max(0.55 * s / (n - 1), 2.24 * d) : 0.mm; Solid[] pins; foreach (i; 0 .. n) { auto pin = cylinder(d: d + 2 * g, h: tall + g + 0.01.mm).move(z: -0.01.mm) + cone(d1: d + 2 * flare + 2 * g, d2: d + 2 * g, h: flare).move(z: -0.01.mm); if (g == 0.mm) pin = pin.chamfer(edges.of(faces.top), d: 0.12 * d); pins ~= pin.move(x: (i - (n - 1) / 2.0) * step); } return Keys(fuse(pins), none); } case "dovetail": { // a tail widening into b, across the piece: it slides in along y // and holds against pulling apart along the normal immutable w = 0.35 * s, flare = len * tan(12.deg); return Keys(acrossY([vec(-w / 2 - g, -0.01.mm), vec(w / 2 + g, -0.01.mm), vec(w / 2 + flare + g, len + g), vec(-w / 2 - flare - g, len + g)], far), none); } case "fingers": { // `count` fingers across the section's width along x, b's and a's in turn, as long as len. // The width, not the size, and nothing past it: on a plane aslant the part's sides, what // lies beyond the outer fingers stays with the piece it is on, where a finger there would // take a sliver of it, loose immutable n = max(count, 3), w = across / n; Solid[] ups, downs; foreach (i; 1 .. n + 1) { immutable x0 = -across / 2 - w + i * w; if (i % 2 == 0) ups ~= span(x0, x0 + w, -far, far, -0.01.mm, len, g); else downs ~= span(x0, x0 + w, -far, far, -len, 0.01.mm, g); } return Keys(fuse(ups), fuse(downs)); } case "lap": { // a half lap: a keeps the half on -y over len, b the half on +y return Keys(span(-far, far, -far, 0.mm, -0.01.mm, len, g), span(-far, far, 0.mm, far, -len, 0.01.mm, g)); } case "kama": { // a gooseneck: a neck and a head wider than it, through along y; // it drops into b from the side and holds against pulling apart immutable neck = 0.25 * s, head = 0.45 * s, h = len / 3; return Keys(acrossY([vec(-neck / 2 - g, -0.01.mm), vec(neck / 2 + g, -0.01.mm), vec(neck / 2 + g, len - h - g), vec(head / 2 + g, len - h - g), vec(head / 2 + g, len + g), vec(-head / 2 - g, len + g), vec(-head / 2 - g, len - h - g), vec(-neck / 2 - g, len - h - g)], far), none); } case "kanawa": { // a half lap with a hook at each end that holds the two against // pulling apart; a wedge through the middle keeps them together immutable hook = 0.2 * s, lip = min(0.15 * len, 0.3 * s); auto up = span(-far, far, -far, 0.mm, -0.01.mm, len, g) + span(-far, far, -0.01.mm, hook, len - lip, len, g); auto down = span(-far, far, 0.mm, far, -len, 0.01.mm, g) + span(-far, far, -hook, 0.01.mm, -len, -len + lip, g); return Keys(up, down); } case "koshikake": { // a dovetail in the upper part and, under it, a seat: b's lower part runs on under a, // and a rests on it, so the joint carries a load without bending open immutable w = 0.35 * s, flare = len * tan(12.deg), seatTop = -0.15 * s, seat = 0.5 * len; auto tail = acrossY([vec(-w / 2 - g, -0.01.mm), vec(w / 2 + g, -0.01.mm), vec(w / 2 + flare + g, len + g), vec(-w / 2 - flare - g, len + g)], far); return Keys(tail & span(-far, far, seatTop, far, -far, far, g), span(-far, far, -far, seatTop, -seat, 0.01.mm, g)); } case "barbed": { // a tenon with barbs, driven in: each tooth leans towards the tip (it slides in) and // has a flat back (it does not pull out); a slot down its middle lets the barbs close as // they go in and open behind the teeth they pass immutable w = 0.3 * s, barb = 0.05 * s, teeth = 3, seg = len / teeth; Vec2[] right; foreach (k; 0 .. teeth) { right ~= vec(w / 2, k * seg); right ~= vec(w / 2 + barb, k * seg + 0.01.mm); } right ~= vec(w / 2, len); Vec2[] outline = [vec(-w / 2, -0.01.mm), vec(w / 2, -0.01.mm)] ~ right[1 .. $]; foreach_reverse (q; right[1 .. $]) outline ~= vec(-q.x, q.y); auto shape = polygon(outline); if (g > 0.mm) shape = shape.offset(g); else shape = shape - rect(0.3 * w, 0.6 * len).move(y: len - 0.3 * len + 0.01.mm); return Keys(shape.extrude(2 * far, along: Y).move(y: -far), none); } case "dowels": // loose: the dowels and their sockets are made by dowels(), the pieces push nothing return Keys(none, none); default: assert(false, "the joint kind is one of tenon, dovetail, pins, fingers, lap, kama, kanawa, koshikake, barbed, " ~ "dowels, not " ~ kind); } } /// Loose dowels of a "dowels" joint and their sockets, in the joint's frame (x across, y the dowel's /// thickness, z along the normal, the plane at z = 0). private struct Dowels { Solid[] pins; Solid[] sockets; } /// `count` dowels in a row along x, each `2 len` long across the plane: a round rod with two flats /// (it prints lying on one), each end split down its middle and pointed; `grip` thicker than its /// socket across the split, so the halves press on the socket's wall. The sockets: plain round holes /// `g` larger than the dowel's diameter, a little deeper than it goes, chamfered at the mouth. private Dowels dowels(Length s, Length len, Length g, int count, Length grip = 0.2.mm) { immutable n = max(1, count); immutable d = min(0.3 * s, 0.85 * s / (1.6 * n)), t = 0.75 * d; immutable step = n > 1 ? max(0.55 * s / (n - 1), 1.6 * d) : 0.mm; immutable rod = d + g + grip, point = 0.15 * d; // the rod along z, its flats facing ±y, pointed at both ends auto pin = (cylinder(d: rod, h: 2 * len, center: true) & box(rod, t, 2 * len + 1.mm).move(z: -len - 0.5.mm)) .chamfer(edges.filter(e => abs(e.mid.z) > len - 0.01.mm), d: point); // the splits, through the flats, from each tip to 0.4 of the way back immutable sw = max(0.25 * d, 0.6.mm), sl = 0.6 * len; auto split = box(sw, 2 * t, sl + 0.01.mm).move(z: len - sl); pin = pin - split - split.mirror(Z); // the socket: round, the dowel's diameter and the play, 0.3 mm deeper, its mouth chamfered immutable hole = d + g, lip = 0.4.mm; auto socket = cylinder(d: hole, h: 2 * (len + 0.3.mm), center: true) + cone(d1: hole + 2 * lip, d2: hole, h: lip) + cone(d1: hole + 2 * lip, d2: hole, h: lip).mirror(Z); Dowels r; foreach (i; 0 .. n) { immutable x = (i - (n - 1) / 2.0) * step; r.pins ~= pin.move(x: x); r.sockets ~= socket.move(x: x); } return r; } /// The loose parts of a joint (in place) laid flat on z = 0, in a row along x beyond `body`'s box. private Solid[] flatRow(Solid[] loose, Solid body) { Solid[] r; auto bb = body.bbox; Length x = bb.max.x + 10.mm; foreach (p; loose) { // on its largest face (of the two alike, a flat part has, the first), then along the row auto down = p.faces.largest.list[0].normal; auto laid = p.placeOn(World, from: faces.normal(down).largest); auto lb = laid.bbox; r ~= laid.move(x: x - lb.min.x, y: bb.center.y - lb.center.y); x += lb.size.x + 5.mm; } return r; } /// How far to turn a joint in the frame of plane `here` so it lies along the /// section's longer side: a tenon's width, the way a dovetail slides. private Angle alongLonger(Profile section, Plane here, string kind) { // a dovetail and a gooseneck slide along the frame's y: that way along the longer side // the frame's x and y, read from a thin bar placed along each auto xd = box(10.mm, 0.001.mm, 0.001.mm).placeOn(here).bbox.size / 10.mm; auto yd = box(0.001.mm, 10.mm, 0.001.mm).placeOn(here).bbox.size / 10.mm; auto size = section.bbox.size; immutable longerY = dot(size, yd) > dot(size, xd) + 0.01.mm; immutable slides = kind == "dovetail" || kind == "kama" || kind == "koshikake" || kind == "barbed"; return longerY != slides ? 90.deg : 0.deg; } /// `body` cut along the plane `at` into two pieces that fit together with a /// joint, `clearance` apart where they meet (0.2 mm suits most FDM printers: /// see the fits guide). The joint is centred on the cut (the middle of the /// section), laid along the section's longer side; `turn` turns it further /// about the plane's normal; `size` sets its scale /// (by default the square root of the section's area: the width of a square /// beam) and `length` how far it reaches across the plane (0.6 of the size). /// `count` (or `keys`, as in splitFlat and mold) is the number of fingers, /// pins or dowels (pins thinner the more there are). The fingers share the /// section's width; pins and dowels go in a row across its middle or, where /// that is hollow (a tube), on the material round it, as thick as it has /// room for; a tenon there is an error. /// /// - "tenon": a tenon on a, its mortise in b (hozo); /// - "dovetail": a tail across the piece, sliding in sideways (ari); /// - "pins": round pins on a, holes in b; /// - "fingers": a box joint, fingers of a and b in turn; /// - "lap": a half lap (ai-gaki), each piece keeping half the thickness; /// - "kama": a gooseneck, dropping in from the side (kama tsugi); /// - "kanawa": a half lap with hooks at both ends, locked by a wedge driven /// through the middle (kanawa tsugi): the wedge is in `loose`; /// - "koshikake": a dovetail over a seat that the other piece rests on /// (koshikake ari tsugi), which carries a load without bending open; /// - "barbed": a tenon with barbs, split down its middle, driven in: it goes /// in and does not come out. /// - "dowels": `count` loose dowels, the printed kind of wooden ones: a rod /// with two flats, each end split, half into a round hole of a and half /// into one of b, held by friction (the split halves press on the walls); /// they print lying on a flat (`print`), and the holes are plain round ones. Joint splitJoint(Solid body, Plane at, string kind = "tenon", Length size = 0.mm, Length length = 0.mm, Length clearance = 0.2.mm, Angle turn = 0.deg, int count = 2, int keys = -1) { immutable many = keys >= 0 ? keys : count; auto section = body.section(at); immutable s = size > 0.mm ? size : sqrt(section.area); immutable len = length > 0.mm ? length : 0.6 * s; // the joint's frame: on the plane, at the middle of the section auto here = plane(origin: section.bbox.center, normal: at.normal); immutable spin = turn + alongLonger(section, here, kind); Solid placed(Solid k) => k.rotate(z: spin).placeOn(here); immutable wide = widthAlong(section, here, spin); auto exact = jointKeys(kind, s, len, 0.mm, many, wide); auto room = jointKeys(kind, s, len, clearance, many, wide); auto up = placed(exact.up), down = placed(exact.down); auto upRoom = placed(room.up), downRoom = placed(room.down); // what goes through both pieces (a wedge's hole, the dowels' sockets), and the loose parts Solid[] holes, loose; // a tenon or a row of pins must stand on material where it crosses the plane (its foot, half a // millimetre each side): in the middle of a hollow part (a tube) or a narrow one, a tenon goes // nowhere, and the pins go on the material round it instead auto slab = box(8 * s, 8 * s, 1.mm).move(z: -0.5.mm).placeOn(here); bool onMaterial(Solid k) => (body & k & slab).volume >= 0.99 * (k & slab).volume; assert(kind != "tenon" || onMaterial(up), "splitJoint: the part is hollow or narrow in the middle of the " ~ "cut, where the tenon goes: kind: \"pins\" or \"dowels\" (they go where the material is), or a " ~ "smaller size:"); if (kind == "pins" && !onMaterial(up)) { auto p = spotsFor(section, many, s, clearance, true); auto one = jointKeys("pins", p.size, len, 0.mm, 1, wide); auto oneRoom = jointKeys("pins", p.size, len, clearance, 1, wide); Solid[] pins, rooms; foreach (q; p.at) { auto there = plane(origin: q, normal: at.normal); pins ~= one.up.rotate(z: spin).placeOn(there); rooms ~= oneRoom.up.rotate(z: spin).placeOn(there); } up = fuse(pins); upRoom = fuse(rooms); } if (kind == "kanawa") { // the wedge: square, tapering 1 in 20, through both halves in the // middle of the joint, on the cut, where the two hooks pull against // each other; its hole is left the clearance larger. The // wedge goes in narrow end first: seated, that end lies flush with // the piece's side, the wide end stands out 3 mm to tap it home and // pull it out immutable w = 0.18 * s, reach = 2 * s, taper = reach / 20; // the piece's thickness the way the wedge goes through immutable through = widthAlong(section, here, spin + 90.deg), head = 3.mm; Length thick(Length z) => w + taper * (z + reach / 2) / reach; auto wedge = loft([rect(thick(-through / 2), thick(-through / 2)).move(z: -through / 2), rect(thick(through / 2 + head), thick(through / 2 + head)).move(z: through / 2 + head)]) .rotate(x: -90); auto hole = loft([rect(w + 2 * clearance, w + 2 * clearance).move(z: -reach / 2), rect(w + taper + 2 * clearance, w + taper + 2 * clearance).move(z: reach / 2)]) .rotate(x: -90); holes ~= placed(hole); loose ~= placed(wedge); } if (kind == "dowels") { auto d = dowels(s, len, clearance, many); if (onMaterial(fuse(d.sockets.map(k => placed(k))))) { holes ~= d.sockets.map(k => placed(k)); loose ~= d.pins.map(k => placed(k)); } else { // the row would leave the material: one dowel on each spot of it, as thick as it has room for auto p = spotsFor(section, many, s, clearance, false); auto one = dowels(p.size, length > 0.mm ? len : 0.6 * p.size, clearance, 1); foreach (q; p.at) { auto there = plane(origin: q, normal: at.normal); holes ~= one.sockets[0].rotate(z: spin).placeOn(there); loose ~= one.pins[0].rotate(z: spin).placeOn(there); } } } auto sides = body.halves(here); // WORKAROUND (OpenCascade 7.9, boolean cut): a kanawa's wedge hole cut from a piece of a tube once // its parts are joined fails (AcquiredSelfIntersection); cut from each part before, it does not. // tests.d tries the joined cut, on splitJoint(tube, …, kind: "kanawa") Solid drilled(Solid piece) => holes.length > 0 ? piece - holes : piece; auto a = drilled(cutTagged(sides.below, here) - downRoom) + drilled(body & up); auto b = drilled(cutTagged(sides.above, here) - upRoom) + drilled(body & down); return Joint(a, b, loose, flatRow(loose, body)); } /// How wide the section is along the joint's x, turned by `spin` in the plane `here`: its box's reach /// that way (a little more than the section's own where it lies aslant). private Length widthAlong(Profile section, Plane here, Angle spin) { auto kx = box(10.mm, 0.001.mm, 0.001.mm).rotate(z: spin).placeOn(here).bbox.size / 10.mm; return dot(section.bbox.size, kx); } /// Where pins or dowels go, and the size each is made for (0.3 of it across). private struct Spots { Vec3[] at; Length size; } /// Where `n` pins or dowels go on a section whose middle would leave them in the air (a tube's): on /// its material, as far apart as they can be, each made for the size `s`, smaller until they find /// room, the flared foot of a pin (the chamfered mouth of a dowel's socket) and 0.4 mm of wall round /// them. private Spots spotsFor(Profile section, int n, Length s, Length g, bool pin) { Length margin(Length size) => (pin ? 0.24 * size + g : 0.15 * size + g / 2 + 0.4.mm) + 0.4.mm; Length size = s; auto at = section.spots(max(1, n), margin: margin(size)); while (at.length < max(1, n) && size > 5.mm) { size = max(5.mm, 0.85 * size); at = section.spots(max(1, n), margin: margin(size)); } assert(at.length > 0, "splitJoint: the part's walls are too thin at the cut for " ~ (pin ? "pins" : "dowels") ~ ": kind: \"lap\" or \"fingers\""); return Spots(at, size); } /// The two halves of a solid cut flat along a plane, each to print lying on /// its cut face, and the flat keys that join them. struct FlatSplit { Solid a; /// the half behind the plane (against its normal) Solid b; /// the half in front of it Solid[] keys; /// the keys, in place, where they go Solid aPrint; /// a, lying on its cut face on z = 0, to print Solid bPrint; /// b, the same Solid[] print; /// the keys lying flat on z = 0 in a row beside the body, to print } /// The outline of a key, along x, `len` long, centred on the origin. private Profile keyOutline(string kind, Length len) { switch (kind) { case "bowtie": { // a butterfly (chigiri): wide ends, a narrow waist immutable wide = 0.6 * len, waist = 0.3 * len; return polygon([vec(-len / 2, -wide / 2), vec(0.mm, -waist / 2), vec(len / 2, -wide / 2), vec(len / 2, wide / 2), vec(0.mm, waist / 2), vec(-len / 2, wide / 2)]) .round(min(1.mm, 0.08 * len)); } case "disc": return circle(d: 0.6 * len); default: assert(false, "the key kind is bowtie or disc, not " ~ kind); } } /// `body` cut flat along the plane `at` into two halves, each printed lying /// on its cut face (nothing stands out of it, so it lies flat on the bed), /// joined by `keys` flat keys (or `count`, as in splitJoint): a pocket in /// each half's cut face, half the key in each. The keys print lying flat /// too, and fit their pockets `clearance` apart; glue them in, or leave them /// dry to take apart. /// /// The keys go where the cut has room for them, at least `wall` from its /// edges, as far apart as they can be. "bowtie" keys (butterflies, chigiri) /// keep the halves from sliding and from turning, even one alone; "disc" /// keys are round, two at least. `size` is a key's length (from the /// section's size unless given), `depth` how deep it goes into each half. /// The pockets open with a 0.4 mm chamfer: the first layers spread a little /// on the bed, and the pocket's mouth is on the bed. FlatSplit splitFlat(Solid body, Plane at, int keys = 2, string kind = "bowtie", Length size = 0.mm, Length depth = 0.mm, Length clearance = 0.15.mm, Length wall = 1.6.mm, int count = -1) { immutable many = count >= 0 ? count : keys; assert(many >= 1, "splitFlat needs one key at least"); auto section = body.section(at); // the keys' length: given, or from the section's size, smaller until they find room in it // (half their length, half their width for a disc, and the wall, from its edges) Length len = size > 0.mm ? size : clamp(0.3 * sqrt(section.area), 8.mm, 30.mm); Length room(Length l) => (kind == "disc" ? 0.3 : 0.5) * l + wall; auto spots = section.spots(many, margin: room(len)); while (spots.length == 0 && size == 0.mm && len > 4.mm) { len = max(4.mm, 0.8 * len); spots = section.spots(many, margin: room(len)); } assert(spots.length > 0, "splitFlat: the cut has no room for a key $(len) long $(wall) from its edges: " ~ "a smaller size: or wall:"); immutable d = depth > 0.mm ? depth : clamp(0.25 * len, 2.mm, 6.mm); immutable thick = 2 * d - 0.3.mm; auto outline = keyOutline(kind, len); // the pocket through both halves at the cut, its mouth chamfered both ways immutable lip = 0.4.mm; auto mouth = outline.offset(clearance + lip).extrude(lip, taper: 45.deg); auto pocket = outline.offset(clearance).extrude(2 * d).move(z: -d) + mouth + mouth.mirror(Z); auto key = outline.extrude(thick) .chamfer(edges.of(faces.top) + edges.of(faces.bottom), d: 0.3.mm); Solid[] pockets, placed, flat; auto bb = body.bbox; foreach (i, p; spots) { auto here = plane(origin: p, normal: at.normal); pockets ~= pocket.placeOn(here); placed ~= key.move(z: -thick / 2).placeOn(here); flat ~= key.move(x: bb.max.x + 10.mm + len / 2 + i * (len + 5.mm), y: bb.center.y); } auto sides = body.halves(at); auto a = cutTagged(sides.below, at) - pockets; auto b = cutTagged(sides.above, at) - pockets; // each half on its cut face: the face lying in the plane, facing out of the half bool cutFace(Face f, Vec3 n) => dot(f.normal, n) > 0.999 && abs(dot(f.center - at.origin, at.normal)) < 0.001.mm; auto aPrint = a.placeOn(World, from: faces.filter(f => cutFace(f, at.normal))); auto bPrint = b.placeOn(World, from: faces.filter(f => cutFace(f, -at.normal))); return FlatSplit(a, b, placed, aPrint, bPrint, flat); } /// Two separate parts joined where one meets the other: `b` ends on the plane /// `at` (a face of `a`, its normal pointing into `b`), and the joint goes from /// `b` into `a`: a shelf into an upright, a rail into a post. The joint is /// centred on `b`'s end and sized by it, as splitJoint does; what reaches past /// `a`'s far side stops flush with it (a through tenon). Kinds: "tenon", /// "dovetail" (a sliding dovetail, its housing running out of `a` the way it /// slides in), "pins", "fingers" (a corner of a box), "lap", "kama" (its /// housing too runs out of `a`), "koshikake" (a sliding dovetail over a /// seat, its housing running out of `a` too), "barbed" (driven in for good), /// "dowels" (holes in both, the dowels in `.loose` and, lying flat, in /// `.print`); not "kanawa", which joins two ends: splitJoint. `count` (or /// `keys`) as in splitJoint. Gives the two parts changed: `.a` with the /// hollows, `.b` with what fits in them. Joint joinParts(Solid a, Solid b, Plane at, string kind = "tenon", Length size = 0.mm, Length length = 0.mm, Length clearance = 0.2.mm, Angle turn = 0.deg, int count = 2, int keys = -1) { assert(kind != "kanawa", "joinParts joins two parts that meet; a wedged scarf joins two ends: splitJoint"); immutable many = keys >= 0 ? keys : count; // b's end, a hair inside it auto end = b.section(plane(origin: at.origin + at.normal * 0.01.mm, normal: at.normal)); immutable s = size > 0.mm ? size : sqrt(end.area); immutable slides = kind == "dovetail" || kind == "kama" || kind == "koshikake"; // a's depth behind the plane: a housing that runs out of a the way it slides must stop short // of a's far side, or it cuts a in two auto ab = a.bbox; Length deep = 0.mm; foreach (x; [ab.min.x, ab.max.x]) foreach (y; [ab.min.y, ab.max.y]) foreach (z; [ab.min.z, ab.max.z]) deep = max(deep, dot(at.origin - vec(x, y, z), at.normal)); Length len = length > 0.mm ? length : 0.6 * s; if (slides) len = min(len, 0.6 * deep); // the joint's frame on the plane, in the middle of b's end, its z going into a auto here = plane(origin: end.bbox.center - at.normal * 0.01.mm, normal: -at.normal); immutable spin = turn + alongLonger(end, here, kind); Solid placed(Solid k) => k.rotate(z: spin).placeOn(here); immutable wide = widthAlong(end, here, spin); auto exact = jointKeys(kind, s, len, 0.mm, many, wide); auto room = jointKeys(kind, s, len, clearance, many, wide); // what one pushes into the other stays within b's end, carried across the plane both ways auto footprint = end.extrude(2 * (len + 1.mm), symmetric: true); // a dovetail's or a gooseneck's housing runs on along the way it slides, out of a, so b goes in auto housing = slides ? placed(room.up) : placed(room.up) & footprint.offset(clearance, sharp: true); auto na = a - housing + (b & placed(exact.down) & footprint); auto nb = b + (a & placed(exact.up) & footprint) - placed(room.down); Solid[] loose; if (kind == "dowels") { auto d = dowels(s, len, clearance, many); na -= d.sockets.map(k => placed(k)); nb -= d.sockets.map(k => placed(k)); loose ~= d.pins.map(k => placed(k)); } return Joint(cutTagged(na, at), cutTagged(nb, at), loose, flatRow(loose, a + b)); } ``` ## Library source: molds ```d module molds; /// The two halves of a mold, where they go and laid for printing. /// /// Tags: the faces of the hollow the object leaves are "molds.cavity", the /// halves' faces along the parting plane "molds.parting", the pour hole's /// "molds.sprue". struct Mold { Solid a; /// the half behind the parting plane (against its normal), with the keys and the seal Solid b; /// the half in front of it, with the pour hole and the vents Solid aPrint; /// a on its outer face on z = 0, the parting face up Solid bPrint; /// b the same } /// A two-part mold to cast `object` in (silicone, resin, plaster): a block /// `wall` thick round it, cut along the parting plane `at`, the object's /// shape hollow inside. The plane should cross the object where it is widest, /// so each half lets go of the cast. /// /// - `keys` (or `count`, as in splitJoint) cones on a's parting face and /// their sockets in b's line the halves up (where the face has room, as /// far apart as they can be), `clearance` apart; /// - a seal round the cavity (its convex hull), a ridge `seal` high on a and its groove in b, /// keeps the liquid from creeping out between the halves (0.mm: none); /// - a pour hole `sprue` wide with a funnel, and `vents` thin channels for the /// air, go up through b from the cavity's highest points (along the plane's /// normal: put that side up when pouring). /// /// - `pry`: a notch on two opposite sides, across the parting line, to open /// the mold with a flat screwdriver without marring its faces. /// /// Both halves print with the parting face up, on their outer faces /// (`aPrint`, `bPrint`): the keys and the seal stand up, the sockets and the /// groove open upwards, with no supports. Mold mold(Solid object, Plane at, Length wall = 10.mm, int keys = 4, Length sprue = 6.mm, int vents = 2, Length seal = 0.8.mm, Length clearance = 0.15.mm, bool pry = true, int count = -1) { immutable many = count >= 0 ? count : keys; immutable n = at.normal; auto bb = object.bbox; // the block square to the parting plane: the object's box measured along the plane's own axes // (read from a speck laid on it along each), grown by the wall, built there and placed on the plane auto centre = plane(origin: bb.center - n * dot(bb.center - at.origin, n), normal: n); Vec3 axis(Solid speck) => (speck.placeOn(centre).bbox.center - centre.origin).normalized; auto xd = axis(box(0.001.mm, 0.001.mm, 0.001.mm).move(x: 10.mm)); auto yd = axis(box(0.001.mm, 0.001.mm, 0.001.mm).move(y: 10.mm)); Vec3[] corners; foreach (x; [bb.min.x, bb.max.x]) foreach (y; [bb.min.y, bb.max.y]) foreach (z; [bb.min.z, bb.max.z]) corners ~= vec(x, y, z) - centre.origin; // how far the object reaches along d from the centre: between where its box's corners say, halved // until what of the object lies beyond is gone immutable big = 4 * bb.size.length + 10.mm; immutable square = abs(n.x) > 0.9999 || abs(n.y) > 0.9999 || abs(n.z) > 0.9999; Length extent(Vec3 d) { Length inside = corners.map(c => dot(c, d)).minElement, outside = corners.map(c => dot(c, d)).maxElement; // a plane square to an axis: the box is the object's own, along the frame's axes too if (square) return outside; foreach (k; 0 .. 14) { immutable mid = (inside + outside) / 2; auto past = box(big, big, big).placeOn(plane(origin: centre.origin + d * mid, normal: d)); if ((object & past).volume > 0.001 * 1.mm ^^ 3) inside = mid; else outside = mid; } return outside; } immutable hx = extent(xd), lx = -extent(-xd), hy = extent(yd), ly = -extent(-yd), hn = extent(n), ln = -extent(-n); Length lo(Vec3 d) => d == xd ? lx : d == yd ? ly : ln; Length hi(Vec3 d) => d == xd ? hx : d == yd ? hy : hn; auto block = box(hi(xd) - lo(xd) + 2 * wall, hi(yd) - lo(yd) + 2 * wall, hi(n) - lo(n) + 2 * wall) .move(x: (hi(xd) + lo(xd)) / 2, y: (hi(yd) + lo(yd)) / 2, z: lo(n) - wall) .fillet(edges.parallel(Z), r: wall / 2) .placeOn(centre); auto sides = (block - object.addTag("molds.cavity", recursive: true)).halves(at); Solid a = sides.below, b = sides.above; bool parting(Face f) => abs(dot(f.center - at.origin, n)) < 0.001.mm && abs(dot(f.normal, n)) > 0.999; if (a.faces.filter(f => parting(f)).count > 0) a.faces.filter(f => parting(f)).addTag("molds.parting"); if (b.faces.filter(f => parting(f)).count > 0) b.faces.filter(f => parting(f)).addTag("molds.parting"); // the keys: truncated cones, half as high as they are wide at the foot, on the parting face round // the cavity, clear of the edges and of the seal immutable kd = clamp(0.35 * wall, 3.mm, 8.mm), kh = kd / 2; auto key = cone(d1: kd, d2: kd / 2, h: kh) + cylinder(d: kd, h: 0.01.mm).move(z: -0.01.mm); auto socket = cone(d1: kd + 2 * clearance, d2: kd / 2 + 2 * clearance, h: kh + clearance) .move(z: -0.01.mm); // the seal runs round the cavity's convex hull (an outline grown that far round a concave // cavity would overlap itself across its notches); the keys stay outside the seal auto rim = hull(object.section(at)); auto face = block.section(at) - (seal > 0.mm ? rim.offset(1.5.mm + seal + clearance) : rim); immutable clear = kd / 2 + 1.5.mm; Solid[] knobs, holes; auto spots = face.spots(many, margin: clear); assert(spots.length > 0, "mold: no room for the keys on the parting face: a thicker wall:"); foreach (p; spots) { auto here = plane(origin: p, normal: n); knobs ~= key.placeOn(here); holes ~= socket.placeOn(here); } a = a + fuse(knobs); b = b - holes; // the seal: a ridge round the cavity, 1.5 mm out from it, as wide as it is high, and its groove if (seal > 0.mm) { // each outline extruded on its own, then one less the other (a ring extruded in a slanted // plane comes out broken); the ridge starts a little inside a, so the two do not merely meet // on the parting face (a fuse on a slanted face that they share fails) immutable sink = 0.05.mm; a = a + (rim.offset(1.5.mm + seal).extrude(seal + sink) - rim.offset(1.5.mm).extrude(seal + sink)) .move(n * -sink); b = b - (rim.offset(1.5.mm + seal + clearance).extrude(seal + clearance) - rim.offset(1.5.mm - clearance).extrude(seal + clearance)); } // the pour hole and the vents: from points just under the cavity's top, along the normal, out // through b; the pour hole opens into a funnel at the block's face immutable top = dot(centre.origin, n) + hn; immutable reach = top + wall - dot(at.origin, n); auto near = plane(origin: at.origin + n * (reach - wall - 0.3.mm), normal: n); auto highs = object.section(near).spots(vents + 1, margin: 0.mm); foreach (i, p; highs) { auto here = plane(origin: p, normal: n); immutable length = wall + 1.mm; if (i == 0) b = b - (cylinder(d: sprue, h: length) + cone(d1: sprue, d2: 2 * sprue, h: sprue / 2 + 0.01.mm).move(z: wall + 0.5.mm - sprue / 2)) .placeOn(here) .addTag("molds.sprue", recursive: true); else b = b - cylinder(d: 2.mm, h: length).placeOn(here); } // the notches to pry it open: half-round, across the parting line, in the middle of two opposite // sides of the block (those facing X, or across the normal and Z), where the block reaches if (pry) { auto way = abs(n.z) > 0.9 ? X : cross(n, Z).normalized; auto across = abs(dot(way, xd)) >= abs(dot(way, yd)) ? xd : yd; auto mid = centre.origin + xd * ((hx + lx) / 2) + yd * ((hy + ly) / 2); immutable half = (across == xd ? hx - lx : hy - ly) / 2 + wall; immutable notch = clamp(0.6 * wall, 4.mm, 8.mm); Solid[] notches; foreach (side; [1, -1]) notches ~= cylinder(d: notch, h: notch / 2 + 1.mm) .move(z: -notch / 2) .placeOn(plane(origin: mid + across * (side * half), normal: across * side)); a = a - notches; b = b - notches; } return Mold(a, b, a.placeOn(World, from: faces.farthest(-n)), b.placeOn(World, from: faces.farthest(n))); } ``` ## Library source: flex ```d module flex; /// `body` made to bend where it lies inside `zone` (any solid: a box across /// a plate, a ring round a tube): rows of slots through its thickness, each /// row shifted half a slot from the next, so what is left is a lattice of thin /// bars that twist and bend (kerf bending, as in laser-cut plywood). The slots /// go through the part's thinnest way inside the zone, and run across the /// body's length (along its shorter side), so it bends along its length. /// `along:` (X, Y or Z) sets the way they run instead. The slots run along /// the axes X, Y and Z only, and go through along one of them: make a part /// that lies turned at an angle bend before turning it. /// /// - `width`: how wide each slot is (0.8 mm: two lines of a 0.4 mm nozzle /// leave it open); /// - `bridge`: the material between two slots of a row: the narrower, the /// softer it bends; /// - `pitch`: from one row to the next: the closer, the tighter it bends; /// - `length`: of each slot (six times the pitch unless given, shorter on a /// narrow part: its width less a bridge, so there is material at its /// ends). Slots that cut the part apart are an error. /// /// In PLA a few degrees per row; PETG and TPU bend much further. Print the part /// lying flat, the slots upright. Solid kerf(Solid body, Solid zone, Length width = 0.8.mm, Length bridge = 2.mm, Length pitch = 2.5.mm, Length length = 0.mm, Vec3 along = vec(0, 0, 0)) { auto part = body & zone; assert(part.volume > 0.mm ^^ 3, "kerf: the zone does not meet the body"); auto bb = part.bbox; auto size = bb.size; // which way is through (the thinnest), which along (the longest, or as given), which across int thin = size.x <= size.y && size.x <= size.z ? 0 : size.y <= size.z ? 1 : 2; int run = 0; if (along != vec(0, 0, 0)) { run = abs(along.x) > 0.5 ? 0 : abs(along.y) > 0.5 ? 1 : 2; assert(run != thin, "kerf: the slots cannot run along the way they go through"); } else { // across the body's length: the shorter of the other two ways of the whole body, so the part // bends along its length (a strip bends end to end, a tube's band lets it bow) auto whole = body.bbox.size; auto s = [whole.x / 1.mm, whole.y / 1.mm, whole.z / 1.mm]; immutable a = (thin + 1) % 3, b = (thin + 2) % 3; run = s[a] <= s[b] ? a : b; } auto dims = [size.x / 1.mm, size.y / 1.mm, size.z / 1.mm]; immutable across = 3 - thin - run; immutable longSide = dims[run] * 1.mm, wide = dims[across] * 1.mm, deep = dims[thin] * 1.mm; // six pitches, or on a narrow part (a strap) the part's width less a bridge: in every other row a // slot in the middle, half a bridge of material at each end, and in the rows between two from the // edges with a bridge between them immutable len = length > 0.mm ? length : min(6 * pitch, longSide - bridge); assert(len > 2 * width, "kerf: the zone is too narrow along the slots for slots and bridges: a wider " ~ "zone, or narrower bridges (bridge:)"); // the slots as a profile: x along them, y across, then stood on the plane square to `through` auto region = rect(longSide + 2 * len, wide); auto slots = region.tiles(tile: slot(len, width), step: [vec(len + bridge, 0.mm), vec((len + bridge) / 2, pitch)], whole: true); // the profile's plane square to the thinnest way: extrude(along:) stands it across X or Y, its x along // the other horizontal axis (along: X) or X itself (along: Y), its y up Z; turned when the slots must // run the other way in that plane immutable h = deep + 2.mm; auto turned = (thin == 0 ? run == 1 : run == 0) ? slots : slots.rotate(z: 90); auto cutter = thin == 2 ? turned.extrude(h).move(z: -h / 2) : thin == 1 ? turned.extrude(h, along: Y).move(y: -h / 2) : turned.extrude(h, along: X).move(x: -h / 2); auto r = body - (cutter.move(x: bb.center.x, y: bb.center.y, z: bb.center.z) & zone); assert(r.pieces.length <= body.pieces.length, "kerf: the slots cut the part apart: shorter slots " ~ "(length:) or wider bridges (bridge:)"); return r; } ``` ## Library source: carlo ```d module carlo; /// Carlo, hackerscad's mascot: a bag clip whose toothed /// slot and keyring eye make a little crocodile. Fold /// the top of a bag of biscuits and push it into the /// wavy slot: the teeth hold it. The start example /// (examples/00_carlo.hcad) shows this same code; /// chapter 13 of the tutorial explains it. /// Carlo lying flat on z = 0, `height` tall, the /// slot opening towards +X. `lean` is how far the back /// and the tips lean (the clip slides onto the bag). /// `mouth` tilts the lower jaw: down to open the slot /// towards the tips (it slides on more easily), up to /// close it (it holds tighter). `eye` adds the keyring /// eye on the top corner. Prints flat, as it comes: a /// constrained sketch, extruded. Solid carlo(int teeth = 3, Length height = 15.mm, Length toothR = 2.5.mm, Length gap = 2.9.mm, Length jaw = 5.mm, Length back = 5.4.mm, Angle lean = 17.deg, Angle mouth = 1.5.deg, bool eye = true) { assert(teeth >= 1, "a jaw needs a tooth at least"); auto s = sketch(); // where the points start: near where they end up // (the constraints below move them into place), // from the rows of teeth, the top and bottom sides // and the slot's depth immutable axis = toothR + gap / 2; immutable top = axis + toothR + jaw; immutable slot = 4 * teeth * toothR; immutable slope = tan(lean); auto a = s.point(-back + top * slope, -top, fixed: true); auto b = s.point(slot + 2 * toothR, -top); auto c = s.point(slot - 2 * toothR, top); // the block: a level top, the back leaning, the // tips of the jaws parallel to it auto bottom = s.line(a, b); SketchLine left, topSide; immutable corner = -back - top * slope; // top left if (eye) { // the eye: a ring on the top edge, its centre // on the top's line, round over the corner and // down onto the back, touching it; where it // rises from the top, a small rounding (an arc // touching both), not a corner immutable eyeR = 5.mm, nook = 1.5.mm; immutable rise = corner + 2 * eyeR; auto centre = s.point(corner + eyeR, top); auto eyeBack = s.point(corner + 3.mm * slope, top - 3.mm); auto nookCentre = s.point(rise + 1.mm, top + nook); auto nookTop = s.point(rise + 1.mm, top); auto nookEye = s.point(rise - 0.3.mm, top + 1.mm); left = s.line(a, eyeBack); topSide = s.line(c, nookTop); auto ring = s.arc(centre, nookEye, eyeBack); auto rounding = s.arc(nookCentre, nookEye, nookTop); s.on(centre, topSide); s.radius(ring, eyeR); s.tangent(left, ring); s.radius(rounding, nook); s.tangent(topSide, rounding); s.tangent(ring, rounding); s.distance(a, centre, 2 * top, along: Y); // the keyring hole, in the middle of the eye s.circle(centre, d: 3.8.mm); } else { // no eye: the back meets the top in a corner, // rounded auto d = s.point(corner, top); left = s.line(a, d); topSide = s.line(c, d); s.distance(a, d, 2 * top, along: Y); s.fillet(d, 2.5.mm); } s.horizontal(topSide); s.angle(topSide, left, 90.deg - lean); // the two rows of teeth: semicircles of one radius, // end to end, pocket and tooth in turn; a row is // points along a line, each arc between two of // them, its centre on the line too. The upper row // is level, the lower one tilts by the mouth. The // first pocket of each row starts where it touches // the end of the slot instead, and the last tooth // ends where it touches the tip of its jaw SketchPoint[] lower, upper; foreach (i; 0 .. 2 * teeth + 1) { lower ~= s.point(2 * toothR * i, -axis); upper ~= s.point(2 * toothR * i, axis); } SketchArc firstLower, firstUpper; SketchArc lastLower, lastUpper; // the upper row's last centre: with upper[1], its // direction SketchPoint lastCentre; // the lower row's points and centres, put on its // line below SketchPoint[] lowerOnRow; foreach (k; 0 .. 2 * teeth) { immutable x = 2 * toothR * k + toothR; auto cl = s.point(x, -axis); auto cu = s.point(x, axis); // counter-clockwise from start to end: left to // right goes under, right to left goes over. // Lower row: pockets down, teeth up; upper row: // pockets up, teeth down (facing the lower // pockets) auto pl = k % 2 == 0 ? s.arc(cl, lower[k], lower[k + 1]) : s.arc(cl, lower[k + 1], lower[k]); auto pu = k % 2 == 0 ? s.arc(cu, upper[k + 1], upper[k]) : s.arc(cu, upper[k], upper[k + 1]); s.radius(pl, toothR); s.radius(pu, toothR); // each centre on its row; the upper row level immutable onRow = k == 0 ? k + 1 : k; lowerOnRow ~= cl; if (k > 1) lowerOnRow ~= lower[k]; s.horizontal(upper[onRow], cu); if (k > 0 && k < 2 * teeth - 1) s.horizontal(upper[k], upper[k + 1]); if (k == 0) { firstLower = pl; firstUpper = pu; } if (k == 2 * teeth - 1) { lastLower = pl; lastUpper = pu; lastCentre = cu; } } // the end of the slot: parallel to the back, the // back's thickness behind it, touching the first // pocket of each row where they meet it auto end = s.line(lower[0], upper[0]); s.parallel(end, left); s.distance(lower[0], left, back * cos(lean)); s.tangent(end, firstLower); s.tangent(end, firstUpper); // the rows start on the end's line, the gap apart: // the upper one level, the lower one tilted by the // mouth, down to open it, up to close it // (construction lines, which guide the outline // without being part of it) auto start = s.point(axis * slope, -axis); s.on(start, end); s.distance(a, start, top - axis, along: Y); s.distance(start, upper[1], 2 * axis, along: Y); auto lowerRow = s.line(start, lowerOnRow[$ - 1], construction: true); auto upperRow = s.line(upper[1], lastCentre, construction: true); // from the lower row to the upper one: // counter-clockwise when it opens s.angle(lowerRow, upperRow, mouth); foreach (p; lowerOnRow[0 .. $ - 1]) s.on(p, lowerRow); s.on(lower[1], lowerRow); // the bottom drops with the lower row, from below // its start auto under = s.point(axis * slope, -top); s.vertical(start, under); s.horizontal(a, under); auto drop = s.line(under, b, construction: true); s.parallel(drop, lowerRow); // the tips of the jaws, parallel to the back, // touching the last teeth auto tipLow = s.line(b, lower[$ - 1]); auto tipUp = s.line(upper[$ - 1], c); s.parallel(tipLow, left); s.parallel(tipUp, left); s.tangent(tipLow, lastLower); s.tangent(tipUp, lastUpper); // round outer corners foreach (p; [a, b, c]) s.fillet(p, 2.5.mm); return s.profile.extrude(height); } ```