Quickstart

sand3d 1.6.2

sand3d is a Python library for writing 3D objects as programs. A program calls verbs that make solids, edit them and place them; Blender does the geometry. This page shows every verb with code that runs, the materials a part can be made of, articulations, the 6 rules a program follows, and how to write an assembly.

Version 1.6.2 · generated from the code by make quickstart
Install pip install sand3d
Runs on bpy 5.2 and 4.5.13 · every image below is the code beside it, rendered
70public names, all on this page
6rules a program follows
z = 0where every solid starts
mmthe default unit; export writes metres

18 programs, from a bracket to an upholstered chair. Pick one: turn it, click a part on the left, and read the code that built it on the right.

drag to turn · scroll to zoom · click a part
1"""A tufted chair: a buttoned back rolling over at the top, a domed seat on a nailed2apron, four tapered legs.3 4Millimetres, Z up, the chair's front towards -Y; built as its left half's legs, the5right ones mirrored. The architecture is the seat on its legs; the back and every6row of trim are details added to it: deleting `add_back` leaves a stool, and the7back's tufts follow the width they are given.8"""9import json10import math11import sys12 13import sand3d as s3d14 15 16def tufted_chair(width=520):17    chair = chair_frame(width)18    add_back(chair)19    add_seat_nails(chair)20    return chair.root21 22 23def chair_frame(width=520, depth=480, seat_z=430, inset=50, floor=0):24    seat = upholstered_seat(width, depth)25    legs = []26    for leg, y in ((front_leg, -(depth / 2 - inset)), (rear_leg, depth / 2 - inset)):27        left = s3d.at(leg(), x=-(width / 2 - inset), y=y, z=floor)28        legs += [left, s3d.transform(s3d.copy(left), mirror="x")]   # the right one: its image across x = 029    return s3d.assembly("chair", s3d.at(seat.root, z=seat_z), *legs,30                        seat=seat, width=width, seat_z=seat_z, rear_y=depth / 2)31 32 33def add_back(chair, behind=-30, above=200):34    back = tufted_back(chair.width)35    s3d.attach(chair, "back", s3d.at(back.root, y=chair.rear_y + behind, z=chair.seat_z + above))36    add_buttons(back)37    add_back_nails(back)38 39 40def add_seat_nails(chair, pitch=22, d=10, h=5):41    """One nail every `pitch` along the seat's seam, round its front and sides."""42    seat = chair.seat43    spots = s3d.snap(seat.parts, s3d.along(seat.seam, pitch))44    s3d.attach(seat, "seat_nails", *[s3d.at(s3d.dome("nail", d, h), on=f) for f in spots])45 46 47def add_buttons(back, d=16, h=3):48    s3d.attach(back, "buttons", *[s3d.at(s3d.dome("button", d, h), on=f) for f in back.tufts])49 50 51def add_back_nails(back, pitch=22, d=10, h=5, face=((220, -230), (226, 150)), reach=400):52    """A row up each side of the back's face, where its cover is nailed to the frame,53    from (x, z) to (x, z), found from in front, `reach` away."""54    spots = []55    for side in (-1, 1):56        (x0, z0), (x1, z1) = face57        spots += s3d.snap(back.parts, s3d.along([(side * x0, -reach, z0), (side * x1, -reach, z1)], pitch),58                          direction=(0, 1, 0))59    s3d.attach(back, "back_nails", *[s3d.at(s3d.dome("nail", d, h), on=f) for f in spots])60 61 62# ---------------------------------------------------------------- parts, in the order they are called63 64def section(z, w, d, x=0, y=0, n=2, taper=0):65    """One cross-section of a loft: a superellipse `w` wide and `d` deep, squareness66    `n` (2 an ellipse, 4 a box with round corners), its +Y side narrowed by `taper`,67    centred at (x, y, z)."""68    return s3d.at(s3d.superellipse(w, d, n, taper), x=x, y=y, z=z)69 70 71def upholstered_seat(width=520, depth=480, taper=0.04, squareness=6, board=-8,72                     crown=((15, 3, 3, 0), (35, 12, 10, 4), (55, 30, 24, 8), (68, 90, 72, 12), (76, 190, 160, 18)),73                     apron=60, dip=15, wall=8, seam_up=6, seam_out=10):74    """A cushion domed over a board, an apron hanging round it. `crown` is the dome,75    level by level: (height, inset at the sides, inset front and back, shift back)."""76    w = width / (1 + taper)  # the loft's mean width; the front edge is `width`77    cushion = s3d.loft("cushion", section(board, w, depth, n=squareness, taper=taper),78                       *[section(z, w - 2 * side, depth - 2 * end, y=back, n=squareness, taper=taper)79                         for z, side, end, back in crown])80 81    def drop(x):  # how far the apron hangs below the board: deepest mid-front82        return apron + dip * math.cos(math.pi * x / width) ** 283 84    hem = s3d.superellipse(w - wall, depth - wall, squareness, taper).loops[0].pts85    skirt = s3d.loft("apron", *[s3d.at(s3d.superellipse(drop(x), wall, squareness), x=x, y=y, z=board - drop(x) / 2,86                                       ry=-90) for x, y in hem], follow=True, loop=True)87    # the seam the nails follow: just outside the apron, round the front and sides88    # but not the back, which the back of the chair covers — from the middle of the back round89    rim = s3d.superellipse(w + 2 * seam_out, depth + 2 * seam_out, squareness, taper).loops[0].pts90    start = min(range(len(rim)), key=lambda i: abs(math.atan2(rim[i][1], rim[i][0]) - math.pi / 2))91    seam = [(x, y, board - drop(x) + seam_up) for x, y in rim[start:] + rim[:start] if y < depth / 2 - seam_out]92    return s3d.assembly("seat", cushion, skirt, parts=[cushion, skirt], seam=seam)93 94 95def front_leg(height=420, profile=((0, -10, 30, 30), (210, -4, 36, 36), (420, 0, 44, 44))):96    """A square leg with round corners, tapering to its foot, which steps forward.97    `profile` is (z, y, width, depth) from the floor up; its origin is under its foot."""98    body = s3d.loft("front_leg", *[section(z * height / profile[-1][0], w, d, y=y, n=4) for z, y, w, d in profile])99    return s3d.group("front_leg", s3d.at(body, y=-profile[0][1]))100 101 102def rear_leg(height=420, profile=((0, 16, 30, 30), (210, 6, 36, 36), (420, 0, 44, 44))):103    """The back leg, raking back to its foot. Its origin is under its foot."""104    body = s3d.loft("rear_leg", *[section(z * height / profile[-1][0], w, d, y=y, n=4) for z, y, w, d in profile])105    return s3d.group("rear_leg", s3d.at(body, y=-profile[0][1]))106 107 108def tufted_back(width=520, drawn_for=520, squareness=4, profile=(109        (-260, 0, 440, 50),    # foot, behind the seat: z, y, width, depth110        (-140, 6, 460, 60),111        (-20, 14, 470, 64),    # the cushioned middle112        (90, 24, 480, 66),113        (170, 36, 490, 72),    # leaning back114        (205, 58, 490, 70),    # over the top115        (180, 82, 490, 60),    # down the back116        (130, 88, 490, 46)),   # tucked under117        tuft_rows=((-60, -120), (-52, -40), (-44, 40), (-34, 120)), tuft_pitch=80, tuft_margin=100, tuft_r=55,118        tuft_depth=20):119    """A cushioned panel rising from the seat and rolling over backwards, one loft120    along its own centre line; buttoned on a diamond lattice, each tuft row (y, z),121    the rows' columns staggered. The profile's widths are those of a `drawn_for` chair."""122    widen = width - drawn_for123    body = s3d.loft("back", *[section(z, w + widen, d, y=y, n=squareness) for z, y, w, d in profile], follow=True)124    reach = int((width / 2 - tuft_margin) / tuft_pitch)  # columns each side of the middle125    lattice = [(j * tuft_pitch, y, z) for i, (y, z) in enumerate(tuft_rows)126               for j in range(-reach, reach + 1) if (i + j) % 2 == 0]127    tufts = s3d.snap(body, lattice)128    s3d.dent(body, tufts, tuft_r, tuft_depth)129    return s3d.assembly("tufted_back", body, parts=[body], tufts=s3d.snap(body, tufts))130 131 132if __name__ == "__main__":133    s3d.empty_scene()134    s3d.export(s3d.group("tufted_chair", tufted_chair(**json.loads(sys.argv[2] if len(sys.argv) > 2 else "{}"))),135               sys.argv[1])

135 lines · 2304 tokens (o200k_base)

A first program

A program is plain Python: import sand3d as s3d, then verbs. Makers take a name and return a solid; edits change it and return it.

A rack docs/examples/rack.py

def rack(n=4, pitch=30, depth=40, t=5):
    base = s3d.extrude("base", s3d.rect(n * pitch, depth, r=3), t)
    pegs = s3d.at(s3d.cylinder("peg", 6, 20), on=s3d.grid(n, 1, pitch), z=t)         # a copy per frame
    hook = s3d.at(s3d.extrude("hook", s3d.rect(4, 12), 12), x=n * pitch / 2 - 4, z=t)
    image = s3d.transform(s3d.copy(hook), mirror="x")                                # its image across x = 0
    s3d.join(base, pegs, hook, image)                                                # a list is its elements
    s3d.hole(base, 4, on=s3d.grid(2, 1, (n - 1) * pitch).move(y=depth / 2 - 6))
    s3d.chamfer(base, 0.6, edges="peg")
    return base

9 lines · 246 tokens (o200k_base)

the rack

Six rules

Learn these 6 and the rest of the API reads by itself.

  1. A placement is a frame plus values. at sets the values it is given and keeps the others; on= changes the frame, and x, y, z then run along its axes; transform moves the whole thing, as a parent would, mirror included.
  2. To repeat, pass a list of frames to on=: once per frame, in at, hole and pocket.
  3. A list of objects is worth its elements, wherever a verb takes objects.
  4. What a verb uses up no longer exists. copy keeps it; always use what at returns.
  5. Every solid starts at z = 0, centred in x and y: box is the extrude of its rect; extrude(both=True) centres one on its plane. A face's frame starts where the origin projects onto it: a vertical face's, at its foot. A frame's Z is the face's normal; on a face whose normal is ±Z its X is +X, on any other X is Z × normal: on a face looking +X, X is +Y and Y is up. A side, an edge or a corner of a box is named by directions: bbox(ob, "+X") is the centre of that side of its bounding box, "+X-Y" the middle of an edge, "+X-Y+Z" a corner, with the axes of a face looking the first way named, on a part or a group; at's anchor takes the same ("-Z", "+Y-Z"; its words top bottom left right front back are +Z -Z -X +X -Y +Y).
  6. A joint lets a part move on another. Place the part first: joint does not move it, it says how it moves — on the part its frame on was read on (ob.face, bbox, local, an assembly's anchor; it stays read on it through .move, .turn, .offset), about or along axis: a direction ("+Z", "-X"…) in the space the parent lives in, as in face, or "normal", on's Z. A pivot given as numbers — a source's joint origin, xyz and rpy — is local(parent, x, y, z, rx, ry, rz), in the parent's own space: at(child, on=…) places the child there, joint hangs it there. Positive values slide along the axis, or turn by the right hand about it: about +Z, +X turns toward +Y — a door hinged on the -Y edge of a +X face opens outward about "-Z". The range contains 0; a joint that follows another takes its range from it. Everything is built at rest, every joint at 0; pose(child, value) moves the mechanism. A part with no joint moves with its group; a jointed part moves by its joint, whatever group it is in; on is never read on the child itself. Joints may come before or after group/assembly. copy copies a jointed part's joints (it hangs on the same parent); transform moves the part with its pivot (a mirror image moves as the image of the motion); at refuses it. Two joints on one child compose in order (a screw: a turn, and a slide that follows it with ratio=pitch / 360).
Two traps. A face's frame starts where the parent's origin projects onto the face. That is the world origin for a part you have placed: on a box moved to x = 100, face("top") starts at x = 0. Build at the origin, read faces, then place; or read bbox(ob, "+Z"), the centre of the side wherever the part is: the frame to hinge on. And join fuses parts that don't touch without a word: they end up as separate pieces of one object.

Profiles

A profile is a flat outline in XY. It is not a solid: it becomes one through extrude, revolve, sweep or loft. Profiles add and subtract with + and -.

Basic shapes

rect(w, d, r=0, chamfer=0)Centred; corners rounded or cut
circle(d)Centred
ellipse(w, d)Centred
slot(l, d)Round-ended slot along X
polygon(n, d)Regular polygon, corner on +X

Every basic shape is centred on its origin. rect takes r= for rounded corners and chamfer= for cut ones.

shapes = [s3d.rect(40, 24, r=5), s3d.rect(40, 24, chamfer=6), s3d.circle(30),
          s3d.ellipse(40, 20), s3d.slot(44, 16), s3d.polygon(6, 32)]
for i, shape in enumerate(shapes):
    s3d.extrude("plate", s3d.at(shape, x=50 * i), 3)

4 lines · 107 tokens (o200k_base)

Basic shapes

Outlines through points

poly(pts, fillet=0, chamfer=0)Outline through points, arcs, splines
arc(center=(0, 0), r=1, start=0, end=360, *, through=None, to=None)Arc from start to end, anticlockwise if end > start: a path, or a poly run
tangent_arc(to)Poly run: arc leaving tangent
spline(pts, closed=False, tangents=None, loop=False)Smooth curve; closed: a profile; loop: a ring path

poly joins points with straight lines. fillet= and chamfer= take one value per corner. Inside a poly, arc(through=, to=) and tangent_arc(to) bend the run. A closed spline is a smooth profile.

bracket = s3d.poly([(0, 0), (40, 0), (40, 10), (15, 10), (15, 30), (0, 30)], fillet=[0, 0, 0, 5, 0, 0])
hook = s3d.poly([(0, 0), (30, 0), s3d.arc(through=(40, 12), to=(30, 24)),
                 (10, 24), s3d.tangent_arc((10, 40)), (0, 40)])
blob = s3d.spline([(0, 0), (20, 5), (30, 20), (10, 30), (-5, 15)], closed=True)
s3d.extrude("bracket", bracket, 3)
s3d.extrude("hook", s3d.at(hook, x=55), 3)
s3d.extrude("blob", s3d.at(blob, x=110, y=5), 3)

7 lines · 229 tokens (o200k_base)

Outlines through points

Operations on profiles

offset(p, t)Grown by t
mirror(p, flip='x')Plus its image, flip negated
superellipse(w, d, n=2, taper=0)Ellipse (n=2) to box (n=4); taper narrows its +Y side, an egg
text(s, size, font=None)Centred outline

offset grows a profile by a distance (shrinks it if negative). mirror(p) adds the image of p: flip="x" negates X. superellipse goes from an ellipse (n = 2) to a rounded box (n = 4 and more).

frame = s3d.offset(s3d.rect(30, 20), 5) - s3d.rect(30, 20)
wing = s3d.mirror(s3d.poly([(0, 0), (25, 0), (25, 6), (6, 22), (0, 22)]))
s3d.extrude("frame", frame, 3)
s3d.extrude("wing", s3d.at(wing, x=60), 3)
s3d.extrude("pad", s3d.at(s3d.superellipse(40, 30, 4), x=115), 3)
s3d.extrude("label", s3d.at(s3d.text("sand3d", 14), x=55, y=-35), 2)

6 lines · 179 tokens (o200k_base)

Operations on profiles

Outlines from paths and profiles

stroke(path, t)A band t wide along an open path, centred on it: a closed outline
wrap(*profiles)Convex outline round profiles

stroke(path, t) thickens an open path into a band t wide, centred on it: a bent bracket, a hook. wrap is the convex outline round profiles: a belt round its wheels.

bracket = s3d.stroke([(0, 0), (50, 0), (50, 50)], 4)
hook = s3d.stroke([(0, 0), (40, 0), s3d.tangent_arc((60, 20)), (60, 60)], 6)
s3d.extrude("bracket", bracket, 4)
s3d.extrude("hook", s3d.at(hook, x=70), 4)
wheels = [s3d.at(s3d.circle(d), x=x, y=y) for x, y, d in [(170, 20, 50), (260, 20, 50), (215, 55, 20)]]
belt = s3d.wrap(wheels)
s3d.extrude("belt", s3d.offset(belt, 4) - belt, 10)
for wheel in wheels:
    s3d.extrude("wheel", wheel, 10)

9 lines · 214 tokens (o200k_base)

Outlines from paths and profiles

Solids

A maker takes a name and returns a solid. Every solid starts at z = 0 and goes up, centred in X and Y.

Primitives

box(name, w, d, h)On Z = 0, centred in X and Y
cylinder(name, d, h)Along Z, from Z = 0
tube(name, d, d_in, h)Along Z, from Z = 0
cone(name, d, h, d2=0)Along Z, from Z = 0
sphere(name, d)On Z = 0
wedge(name, w, d, h, top=0)Box narrowed on top
torus(name, d, d_wire)Ring about Z, on Z = 0
dome(name, d, h)Cap on Z = 0

All of them stand on z = 0. cone(d, h, d2) goes from diameter d at the bottom to d2 at the top. torus(d, d_wire): d is the diameter of the wire's centre line. at(parts, on=grid) would put every part on every frame; zip pairs them one to one.

parts = [s3d.box("box", 30, 20, 20), s3d.cylinder("cylinder", 20, 30), s3d.tube("tube", 30, 20, 10),
         s3d.cone("cone", 30, 40, d2=10), s3d.sphere("sphere", 30), s3d.wedge("wedge", 40, 20, 30, top=10),
         s3d.torus("torus", 40, 8), s3d.dome("dome", 30, 10)]
for part, place in zip(parts, s3d.grid(4, 2, 50, 60)):   # one part per frame
    s3d.at(part, on=place)

5 lines · 177 tokens (o200k_base)

Primitives

extrude

extrude(name, profile, h=None, taper=0, twist=0, both=False, h2=None, on=None, until=None)Prism up Z (taper > 0 narrows it upward), or until a plane or solid; on a face, by on

A prism up Z. taper narrows it as it rises, twist turns it, both=True centres it on its plane. on= builds it on a face, until= runs it up to a plane or a solid instead of a height.

plate = s3d.extrude("plate", s3d.rect(60, 40, r=4) - s3d.circle(16), 8)
s3d.extrude("boss", s3d.circle(12), 10, on=plate.face("top").move(20, 10))
s3d.at(s3d.extrude("taper", s3d.rect(30, 30), 25, taper=10), x=-55)
s3d.at(s3d.extrude("twist", s3d.rect(30, 14), 40, twist=90), x=55)

4 lines · 139 tokens (o200k_base)

extrude

revolve

revolve(name, profile, angle=360)X radius, Y height, turned about Z

The profile is drawn with X as the radius and Y as the height, then turned about Z. A list of points is a profile too. angle= turns it less than a full turn.

s3d.revolve("cup", [(0, 0), (20, 0), (24, 40), (22, 40), (18, 3), (0, 3)])
s3d.at(s3d.revolve("half", s3d.at(s3d.rect(10, 20), x=10, y=10), 180), x=60)

2 lines · 85 tokens (o200k_base)

revolve

sweep and loft

sweep(name, profile, path, scale=1, twist=0, frenet=False)Profile along a path
loft(name, *sections, follow=False, loop=False)Through placed sections, capped; loop: a ring

sweep carries a profile along a path, square to it. loft skins placed sections: follow=True turns each section with the path, loop=True closes the ring.

s3d.sweep("bar", s3d.rect(10, 4), [(0, 0, 0), (0, 0, 50), (50, 0, 50)])
s3d.at(s3d.sweep("horn", s3d.circle(10), s3d.arc((0, 0, 0), 40, 0, 90), scale=0.3, twist=30), x=-70)
vase = [s3d.circle(40), s3d.at(s3d.circle(60), z=50), s3d.at(s3d.polygon(6, 30), z=100)]
s3d.at(s3d.loft("vase", *vase), x=90)

4 lines · 166 tokens (o200k_base)

sweep and loft

station: a loft section as a row of numbers

station(z, w, d, x=0, y=0, n=2, taper=0)Loft section at height z; n and taper as superellipse

station(z, w, d, x, y, n, taper) is a superellipse section at height z, centred at (x, y): a loft is then a table of rows. n goes from an ellipse (2) to a rounded box (4), taper narrows the +Y side into an egg.

rows = [(0, 300, 900, 50, 0.2), (40, 700, 1500, 0, 0.35), (90, 500, 1200, 20, 0)]
s3d.loft("cabin", *[s3d.station(z, w, d, y=y, n=2.5, taper=t) for z, w, d, y, t in rows])

2 lines · 99 tokens (o200k_base)

station: a loft section as a row of numbers

body: a solid from its views

body(name, plan, top, bottom, n=2)Solid along Y from its views, where they draw it: plan (y, width), side top and bottom (y, z) or numbers; superellipse sections

body(name, plan, top, bottom, n) builds along Y from the views a drawing gives: the plan's width, and the side view's top and bottom, as (y, value) points, splines or numbers. Each section is a superellipse; n may vary along Y.

nose_to_tail = [(-3500, 0), (-2500, 1000), (0, 1400), (3000, 900), (4500, 400)]
top = [(-3500, 900), (-2500, 1400), (0, 1600), (3000, 1300), (4500, 1100)]
bottom = [(-3500, 900), (-2500, 500), (0, 300), (3000, 600), (4500, 850)]
s3d.body("fuselage", s3d.spline(nose_to_tail), s3d.spline(top), s3d.spline(bottom),
         n=[(-3500, 2), (0, 3), (4500, 2)])
hull = s3d.body("hull", [(0, 0), (600, 900), (2400, 1000), (3000, 840)], 800,
                [(0, 800), (600, 120), (3000, 200)], n=4)
s3d.at(hull, x=3500)

8 lines · 252 tokens (o200k_base)

body: a solid from its views

wire and helix

wire(name, path, d, closed=None, fillet=0)Round wire along a path: (x, y, z) points, a spline, an arc, a helix; fillet rounds the corners of points, d / 2 at least
helix(d, pitch, h, left=False)Coil path up Z

wire is a round wire of diameter d along any path; fillet= rounds the corners of a path of points. helix(d, pitch, h) is a coil path up Z.

s3d.wire("hoop", s3d.arc((0, 0, 0), 30, 0, 270), 4)
s3d.at(s3d.wire("spring", s3d.helix(30, 8, 40), 3), x=70)
s3d.at(s3d.wire("rod", [(0, 0, 0), (0, 0, 30), (20, 0, 45)], 5, fillet=8), x=130)

3 lines · 119 tokens (o200k_base)

wire and helix

Edits

An edit changes its first argument in place and returns it. Edges and faces are reached by the names the maker gave them: top, bottom, side, rim, cut, the tool's name, or plate.top.

cut, join, intersect

cut(ob, *tools)Subtract tools
join(ob, *others)Fuse others in; ob's material wins: group keeps each its own
intersect(ob, tool)Keep the common part

The tools are used up. A cut that touches nothing raises: it is a mistake, not a no-op.

plate = s3d.box("plate", 60, 40, 8)
s3d.cut(plate, s3d.at(s3d.cylinder("pin", 10, 20), x=10, z=-6),
        s3d.at(s3d.box("notch", 10, 50, 20), x=-30, z=-2))
s3d.join(plate, s3d.at(s3d.cylinder("boss", 16, 10), x=-10, z=7))
s3d.at(s3d.intersect(s3d.box("cube", 30, 30, 30), s3d.at(s3d.sphere("ball", 40), z=-5)), x=70)

5 lines · 162 tokens (o200k_base)

cut, join, intersect

hole and pocket

hole(ob, d, on=(0, 0), depth=None, cbore=None, csk=None)On frames or points: through, or depth deep
pocket(ob, profile, depth=None, on=None)Profile cut in from a face

hole drills on points (x, y), on a frame or on a list of frames: through, or depth deep, with cbore=(d, depth) or csk=(d, angle). It starts where its axis enters the part. pocket cuts a profile in from a face.

plate = s3d.box("plate", 80, 50, 10)
s3d.hole(plate, 6.6, on=[(-30, -15), (30, -15), (-30, 15), (30, 15)])
s3d.hole(plate, 5, on=(0, 12), cbore=(10, 4))
s3d.hole(plate, 5, on=(0, -12), csk=(11, 90))
s3d.pocket(plate, s3d.rect(14, 8, r=2), depth=4, on=plate.face("top").move(-20, 0))
s3d.pocket(plate, s3d.circle(8), depth=3, on=s3d.grid(1, 2, 0, 16, on=plate.face("top").move(20, 0)))

6 lines · 199 tokens (o200k_base)

hole and pocket

fillet, chamfer, soften

fillet(ob, r, edges='all')Round named edges
chamfer(ob, d, edges='all', d2=None, angle=None)Bevel named edges
soften(ob, r, angle=30)Round all sharp edges

fillet rounds named edges to a radius; chamfer cuts them, d2= or angle= for an unequal cut; soften rounds every sharp edge.

s3d.fillet(s3d.box("a", 40, 30, 20), 4, edges="top")
plate = s3d.box("p", 50, 40, 10)
s3d.hole(plate, 14, on=(0, 0))
s3d.at(s3d.chamfer(plate, 2, edges="hole"), x=55)
s3d.chamfer(s3d.at(s3d.box("b", 40, 30, 20), x=110), 3, edges="top", d2=6)
s3d.soften(s3d.at(s3d.box("s", 40, 30, 20), x=165), 3)

6 lines · 162 tokens (o200k_base)

fillet, chamfer, soften

shell, split, dent

shell(ob, t, open=None)Hollow; open named faces, or the flat faces looking "+Y", "-X +X"
split(ob, frame, keep='above')By a frame's plane
dent(ob, points, r, depth)Press hollows at points

shell hollows a solid, open= names the faces it removes. split cuts by a frame's plane and keeps above, below or both. dent presses hollows at points, as buttons do in upholstery.

s3d.shell(s3d.fillet(s3d.box("tray", 40, 30, 20), 4, edges="bottom side"), 1.5, open="top")
ball = s3d.split(s3d.sphere("ball", 40), s3d.Frame((0, 0, 25), (0, 1, 1)))
s3d.at(ball, x=60)
pad = s3d.extrude("pad", s3d.superellipse(60, 60, 4), 20)
s3d.dent(pad, [(x, y, 20) for x in (-14, 14) for y in (-14, 14)], 9, 4)
s3d.at(pad, x=130)

6 lines · 170 tokens (o200k_base)

shell, split, dent

copy, merge, scale, delete

copy(ob)An independent copy, its joints with it
merge(*obs)No boolean
scale(ob, s)About its origin
delete(ob)Remove it

copy is an independent copy that keeps its name. merge puts solids in one object without a boolean. scale scales the mesh about its origin. delete removes an object.

peg = s3d.cylinder("peg", 8, 20)
s3d.at(s3d.copy(peg), x=20)
s3d.merge(s3d.at(s3d.box("m", 10, 10, 10), x=50), s3d.at(s3d.box("n", 10, 10, 10), x=64))
s3d.at(s3d.scale(s3d.sphere("egg", 20), (1.6, 1, 1)), x=100)
s3d.delete(s3d.at(s3d.box("gone", 10, 10, 10), x=140))

5 lines · 145 tokens (o200k_base)

copy, merge, scale, delete

Frames and placement

A frame is a place to build on: an origin, a normal (its Z) and an X. Faces, grids, rings and paths give frames; at puts things on them.

Frame and ob.face

Frame(origin=(0, 0, 0), normal=(0, 0, 1), up=None) · .offset(d) .turn(rx, ry, rz) .move(x, y, z) · ob.face(which)A place to build on

ob.face("top") is the frame of a named face, ob.face("+X") the face furthest that way. A frame moves along its own axes (.move), turns (.turn) and offsets along its normal (.offset).

plate = s3d.box("plate", 60, 40, 8)
s3d.extrude("boss", s3d.circle(20), 10, on=plate.face("top").move(15, 5))
s3d.extrude("tab", s3d.rect(12, 6), 6, on=plate.face("+X").move(y=4))   # on a side, Y is up
s3d.extrude("fin", s3d.rect(30, 3), 12, on=plate.face("top").turn(rz=30).move(-15))

4 lines · 134 tokens (o200k_base)

Frame and ob.face

at

at(ob, x=None, y=None, z=None, rx=None, ry=None, rz=None, on=None, anchor=None)Place it: set what is given, keep the rest; anchor: "-Z", "+Y-Z"…

at sets the values it is given and keeps the others. on= changes the frame it is placed in. anchor= picks the point of the bounding box that goes there: "top", "bottom-front-left"…

plate = s3d.box("plate", 60, 40, 8)
post = s3d.at(s3d.cylinder("post", 10, 30), on=plate.face("top"))   # stands on the top
s3d.at(post, x=20)                                                    # moved along, still on the top
s3d.at(s3d.box("b", 10, 20, 30), x=-40, rz=30, anchor="bottom-front-left")

4 lines · 106 tokens (o200k_base)

at

Patterns: on=[frames]

grid(nx, ny=1, px=0, py=0, stagger=False, on=None)On a grid
ring(n, r, start=0, angle=360, on=None)On a circle

Given a list of frames, at, hole and pocket act once per frame: at returns one copy per frame and uses the original up. grid and ring lay frames on a plane, the origin's or a face's.

disc = s3d.cylinder("disc", 80, 6)
s3d.cut(disc, s3d.at(s3d.box("slot", 12, 3, 10), x=25, z=-2, on=s3d.ring(12, 0)))
s3d.hole(disc, 4, on=s3d.ring(6, 12, on=disc.face("top")))
plate = s3d.box("plate", 70, 50, 4)
s3d.join(plate, s3d.at(s3d.cylinder("stud", 8, 6), on=s3d.grid(4, 3, 15, 13, on=plate.face("top"))))
s3d.at(plate, x=100)                 # built at the origin, placed last

6 lines · 181 tokens (o200k_base)

Patterns: on=[frames]

along and snap

along(path, pitch=None, count=None)Along a path
snap(targets, points, direction=None)On a surface

along spaces frames on a path, by pitch or count. snap drops points onto a surface and returns frames that lie on it, facing out.

path = s3d.arc((0, 0, 0), 30, 0, 270)
s3d.wire("track", path, 3)
s3d.at(s3d.box("bead", 6, 6, 6), on=s3d.along(path, count=7))
body = s3d.sphere("body", 60)
spots = s3d.snap(body, [(x, y, 70) for x in (-15, 0, 15) for y in (-15, 0, 15)])
rivets = s3d.at(s3d.dome("rivet", 6, 2), on=spots)
s3d.transform([body, rivets], x=100)  # moved together, as one

7 lines · 170 tokens (o200k_base)

along and snap

ob.edge: a named edge as a path

ob.edge(which)Path along the edges named which, in the space ob is in: the longest run (as long: the one reaching furthest -X, then -Y, -Z), closed if it closes

ob.edge("top") is the path along the edges a verb named: wire, sweep and along take it. A bead runs round the notch a cut left; a moulding and studs follow a plate's top.

cyl = s3d.cylinder("cyl", 40, 60)
s3d.cut(cyl, s3d.at(s3d.box("notch", 20, 20, 20), x=20, z=20))
s3d.wire("bead", cyl.edge("notch"), 3)                # round the loop the cut left
plate = s3d.at(s3d.box("plate", 80, 40, 6), x=100)
top = plate.edge("top")
s3d.sweep("trim", s3d.rect(2, 3), top)                # a moulding round the top
s3d.at(s3d.sphere("stud", 4), on=s3d.along(top, pitch=20))

7 lines · 170 tokens (o200k_base)

ob.edge: a named edge as a path

transform

transform(ob, x=0, y=0, z=0, rx=0, ry=0, rz=0, mirror=None)Move it by, as a parent would; its placement, not its mesh

transform moves an object from where it is, as a parent would: a door opens on its hinge. mirror="x" gives the image across x = 0.

door = s3d.at(s3d.box("door", 30, 2, 60), x=15)
s3d.transform(s3d.copy(door), rz=75)                  # opened about the hinge at x = 0
arm = s3d.at(s3d.extrude("arm", s3d.poly([(10, 0), (40, 0), (40, 20), (30, 20), (10, 6)]), 6), x=50)
s3d.transform(s3d.copy(arm), mirror="x")             # its image across x = 0

4 lines · 134 tokens (o200k_base)

transform

Articulations

A joint says how a part moves on another: on a frame read on the parent (ob.face, bbox, an assembly's anchor), about or along an axis: a direction of the parent's space, or "normal". Everything is built at rest; pose moves the mechanism, and export writes it as a URDF or a GLB nested by its joints. Each scene below is shown posed.

bbox, and anchors by directions

bbox(ob, which)Frame on its bounding box: a side "+X", an edge "+X-Y", a corner "+X-Y+Z"

bbox(ob, "+X") is the centre of that side of the bounding box, "+X-Y" the middle of an edge, "+X-Y+Z" a corner, with the axes of a face looking the first way named: the frame to hinge on. at(anchor=) takes the same directions.

cab = s3d.box("cab", 60, 60, 85)
s3d.at(s3d.cylinder("side", 8, 12), on=s3d.bbox(cab, "+X"))        # the centre of a side
s3d.at(s3d.cylinder("edge", 8, 12), on=s3d.bbox(cab, "+X-Y"))      # the middle of an edge
s3d.at(s3d.cylinder("corner", 8, 12), on=s3d.bbox(cab, "+X-Y+Z"))  # a corner
s3d.at(s3d.box("cap", 20, 20, 10), on=s3d.bbox(cab, "+Z"), anchor="-Z+Y")

5 lines · 171 tokens (o200k_base)

bbox, and anchors by directions

joint and pose: the four kinds

joint(name, child, on, kind='revolute', axis='normal', range=None, follows=None, ratio=1)How child moves on the part on was read on: revolute (range in degrees), continuous (no range), prismatic (range in the unit), free (no axis, no range); follows: a child or a joint name, and this joint's value is ratio × its (degrees per unit, or units per degree), no range; returns child
pose(ob, value, joint=None)Move the mechanism: that joint to value (degrees or the unit), an error outside its range; what it carries follows; joint= when child has two

A lid revolute on the -Y edge of the top (degrees; the range contains 0), a wheel continuous, a drawer prismatic (the program's unit), a ball free. joint neither moves nor reparents the part; pose moves the mechanism. About +X, +Y turns toward +Z: a positive value lifts the lid.

chest = s3d.box("chest", 500, 400, 300)
lid = s3d.at(s3d.box("lid", 500, 400, 20), z=300)
s3d.joint("lid_hinge", lid, s3d.bbox(chest, "+Z-Y"), axis="+X", range=(0, 110))
wheel = s3d.at(s3d.cylinder("wheel", 160, 30), on=chest.face("+X").move(0, 150))
s3d.joint("wheel_spin", wheel, chest.face("+X").move(0, 150), kind="continuous")
drawer = s3d.at(s3d.box("drawer", 180, 60, 300), on=s3d.bbox(chest, "-Y").move(y=-80).offset(-299))
s3d.seat(chest, drawer)                                  # its bay, cut where it rests
s3d.joint("drawer_slide", drawer, s3d.bbox(chest, "-Y"), kind="prismatic", axis="-Y", range=(0, 250))
ball = s3d.at(s3d.sphere("ball", 60), x=-320)
s3d.joint("ball", ball, chest.face("top"), kind="free")   # no axis, no range, no pose
s3d.pose(lid, 70)
s3d.pose(drawer, 180)

12 lines · 315 tokens (o200k_base)

joint and pose: the four kinds

A chain, posed from its root

Each joint is read on the part before: the elbow hangs on the lower arm and rides on the shoulder. pose(root, value, joint="name") poses a joint from any group above it.

base = s3d.cylinder("base", 160, 20)
knuckles = [s3d.at(s3d.sphere("knuckle", 24), z=z) for z in (15, 329, 593)]
lower = s3d.group("lower", s3d.at(s3d.cylinder("arm", 18, 300), z=34), knuckles[1])
upper = s3d.group("upper", s3d.at(s3d.cylinder("arm", 16, 250), z=348), knuckles[2])
head = s3d.at(s3d.cone("head", 110, 70, d2=50), z=612)
s3d.joint("shoulder", lower, s3d.bbox(knuckles[0], "+Y").offset(-12), axis="+Y", range=(-30, 60))
s3d.joint("elbow", upper, s3d.bbox(knuckles[1], "+Y").offset(-12), axis="+Y", range=(-90, 90))
s3d.joint("wrist", head, s3d.bbox(knuckles[2], "+Y").offset(-12), axis="+Y", range=(-45, 45))
lamp = s3d.assembly("lamp", s3d.group("stand", base, knuckles[0]), lower, upper, head)
s3d.pose(lamp, 35, joint="shoulder")
s3d.pose(lamp, -80, joint="elbow")

11 lines · 329 tokens (o200k_base)

A chain, posed from its root

local: a pivot given as numbers

local(ob, x=0, y=0, z=0, rx=0, ry=0, rz=0)Frame at (x, y, z) of its own space, turned (rx, ry, rz) degrees

local(ob, x, y, z, rx, ry, rz) is the frame at a point of the part's own space, turned as at turns: a source's joint origin, xyz and rpy, as written. One frame places the child (at(on=)) and hangs it (joint); the elbow is read in the arm's own space, wherever the arm stands.

base = s3d.cylinder("base", 160, 20)
shoulder = s3d.local(base, z=20, ry=-30)                     # the arm rises 30° at rest
arm = s3d.at(s3d.group("arm", s3d.at(s3d.box("beam", 300, 30, 30), x=150)), on=shoulder)
s3d.joint("shoulder", arm, shoulder, axis="+Y", range=(-45, 90))
elbow = s3d.local(arm, x=300, rx=90)                         # 300 along the arm, its Z the arm's -Y
hand = s3d.at(s3d.group("hand", s3d.at(s3d.box("rod", 200, 20, 20), x=100)), on=elbow)
s3d.joint("elbow", hand, elbow, axis="normal", range=(-90, 90))
s3d.pose(arm, -20)
s3d.pose(hand, -60)

9 lines · 229 tokens (o200k_base)

local: a pivot given as numbers

copy and mirror a jointed part

The leaf is a group: board and handle weld. copy carries the joint, the copy hangs on the same parent, and its mirror image moves as the image of the motion: both leaves open toward +Y.

post = s3d.at(s3d.box("post", 100, 100, 1500), x=-800)
s3d.transform(s3d.copy(post), mirror="x")
board = s3d.at(s3d.box("board", 750, 40, 1200), x=-375, z=150)
handle = s3d.at(s3d.cylinder("handle", 20, 60), on=s3d.bbox(board, "+Y").move(x=315))
leaf = s3d.group("leaf", board, handle)
s3d.joint("hinge", leaf, s3d.bbox(post, "+X").move(x=20), axis="+Z", range=(0, 90))  # on its +Y edge
twin = s3d.transform(s3d.copy(leaf), mirror="x")
s3d.pose(leaf, 60)
s3d.pose(twin, 60)

9 lines · 204 tokens (o200k_base)

copy and mirror a jointed part

follows and ratio

A joint that follows another takes ratio × its value and no range: a screw cap is a turn and a slide that follows it, 3 mm per turn; a small gear follows a big one at −2.

bottle = s3d.revolve("bottle", [(0, 0), (35, 0), (35, 140), (14, 175), (14, 200), (0, 200)])
cap = s3d.at(s3d.knurl("cap", 36, 24), z=190)
s3d.joint("cap_turn", cap, bottle.face("top"), range=(0, 720))
s3d.joint("cap_lift", cap, bottle.face("top"), kind="prismatic", follows="cap_turn", ratio=3 / 360)
s3d.pose(cap, 720, joint="cap_turn")                      # two turns: 6 mm up
plate = s3d.at(s3d.box("plate", 260, 120, 10), x=250)
big = s3d.at(s3d.gear("big", 30, 3, 12, bore=8), x=200, z=10)
small = s3d.at(s3d.gear("small", 15, 3, 12, bore=8), x=267.5, z=10, rz=12)
s3d.joint("drive", big, plate.face("top").move(200), kind="continuous")     # the face starts at x = 0
s3d.joint("driven", small, plate.face("top").move(267.5), kind="continuous", follows=big, ratio=-2)
s3d.pose(big, 20)

11 lines · 332 tokens (o200k_base)

follows and ratio

seat: the cavity a part leaves

seat(host, part)Take out of host the volume part fills, as it sits at rest; part untouched

seat(host, part) takes out of the host the volume the part fills at rest and leaves the part as it is: it may move. It cuts the exact shape: a knob turns in a round shank's seat, not a knurled one's.

console = s3d.box("console", 200, 120, 60)
top = console.face("top")
knob = s3d.group("knob", s3d.at(s3d.cylinder("shank", 24, 22), on=top.move(-50).offset(-12)),
                 s3d.at(s3d.knurl("grip", 40, 24), on=top.move(-50).offset(10)))
pin = s3d.at(s3d.cylinder("pin", 16, 60), on=top.move(50).offset(-40))
s3d.seat(console, knob)
s3d.seat(console, pin)
s3d.joint("pin_pull", pin, top.move(50), kind="prismatic", axis="+Z", range=(0, 60))
s3d.pose(pin, 45)                                        # pulled out of its bore

9 lines · 194 tokens (o200k_base)

seat: the cavity a part leaves

Parts

Ready-made parts, each standing on z = 0.

gear, knurl, thread

gear(name, teeth, module, width, bore=0, angle=20)Involute spur gear
knurl(name, d, h, pitch=None, straight=False)Knurled grip; pitch 1 mm by default
thread(name, d, length, pitch, left=False)Threaded rod

gear(teeth, module, width) is an involute spur gear. knurl is a knurled grip, its pitch 1 mm unless given. thread is an ISO V-thread rod.

s3d.gear("gear", 20, 2, 10, bore=8)
s3d.at(s3d.knurl("knurl", 20, 14), x=40)
s3d.at(s3d.knurl("ribs", 20, 14, straight=True), x=70)
s3d.at(s3d.thread("thread", 10, 24, 1.5), x=100)

4 lines · 96 tokens (o200k_base)

gear, knurl, thread

Materials

material says what a part is made of: one of 22 presets, its values changed where the call says. It tags the object and never touches its mesh; export writes it into the GLB.

The 22 presets

material(ob, kind, color=None, roughness=None, metallic=None, opacity=None, emission=None)Kind: oak walnut pine birch steel brushed_steel chrome aluminium brass copper painted_metal plastic glossy_plastic rubber glass ceramic fabric velvet leather marble concrete light; color "#rrggbb"

Woods, metals, plastics, rubber, glass, ceramic, soft goods, stone, and light, a surface that gives light in its own colour. A wrong name raises and suggests the nearest one.

kinds = ["oak", "walnut", "pine", "birch", "steel", "brushed_steel", "chrome", "aluminium",
         "brass", "copper", "painted_metal", "plastic", "glossy_plastic", "rubber", "glass",
         "ceramic", "fabric", "velvet", "leather", "marble", "concrete", "light"]
for kind, place in zip(kinds, s3d.grid(6, 4, 80, -100)):
    s3d.material(s3d.at(s3d.sphere(kind, 40), on=place), kind)
    label = s3d.extrude("label", s3d.text(kind, 9), 1)
    s3d.material(s3d.at(label, on=place.move(y=-30)), "rubber")

7 lines · 189 tokens (o200k_base)

The 22 presets

On an assembly, and what a call changes

On a group or an assembly, material reaches every part under it that names none of its own: the nearest call wins, in whatever order the calls come. color= ("#rrggbb"), roughness=, metallic=, opacity= and emission= change the preset. The tag follows the part through cut, fillet and copy.

top = s3d.fillet(s3d.box("top", 1400, 800, 32), 3, edges="top")
legs = s3d.at(s3d.box("leg", 60, 60, 718), on=s3d.grid(2, 2, 1260, 660))
table = s3d.assembly("table", s3d.at(top, z=718), legs)
s3d.material(table, "walnut")                            # every part under it
s3d.material(legs, "painted_metal", color="#2f5d50")     # the nearest call wins
vase = s3d.shell(s3d.revolve("vase", [(0, 0), (70, 0), (90, 120), (45, 260), (0, 260)]), 4, open="top")
s3d.material(s3d.at(vase, x=-300, z=750), "glass", color="#3fae6a")
bowl = s3d.shell(s3d.revolve("bowl", [(0, 0), (60, 0), (130, 80), (0, 80)]), 5, open="top")
s3d.material(s3d.at(bowl, x=250, z=750), "ceramic")
s3d.material(s3d.at(s3d.cylinder("cup", 70, 90), x=0, y=200, z=750), "brass")

10 lines · 331 tokens (o200k_base)

On an assembly, and what a call changes

group, assembly, attach, Handle, export, units, empty_scene, fingerprint

group(name, *children)Parent of children
assembly(name, *children, **anchors)Parts and anchors
attach(handle, name, *children)A detail, under a handle
Handle(root, **anchors)Root and anchors
export(root, path)root and what hangs on it by its joints, in metres: .glb at the current pose, joints in extras; .urdf a link per moving part, at rest, meshes beside it; .usda the URDF's tree as OpenUSD text, with the materials; .obj and .stl its meshes at the current pose
units(name=None)Length unit: "mm" (default), "cm", "m"
empty_scene()Fresh scene
fingerprint()Digest of the source

They are shown at work in Assemblies. export(root, path) writes in metres, whatever the program's unit: a GLB at the current pose, a URDF at rest with its meshes beside it, or USD text (.usda) with the joints and the materials.

Assemblies

Write the plan first, then the details, then the parts. Each detail hangs on the plan's anchors, so deleting it deletes only it.

  1. The top function is the table of contents. It calls the plan, then one line per detail, and returns the root. Someone reading it sees the object's structure before any number.
  2. The plan builds the architecture and publishes anchors. assembly(name, *parts, **anchors) returns a handle. The anchors are what the details need: a width, a height, a frame, a list of spots. Write them once, in the plan.
  3. Each detail is one add_ function. It takes the handle, reads only its anchors, and hangs its parts with attach. Delete its line and nothing else moves. A detail of a detail lives inside it: the buttons are added by add_back.
  4. The parts come last. Each part is a function with named parameters and defaults. It builds at the origin and stands on z = 0. It does not place itself: the plan or the detail that calls it does.
  5. Build at the origin, place last. A face's frame starts where the parent's origin projects onto it. Read faces before you move a part, or compute heights from the anchors, as add_back does with chair.seat_h.
  6. Repeat and mirror with frames. Four legs are at(leg(), on=grid(2, 2, …)). A right side is transform(copy(left), mirror="x").

A chair, and the same plan without its back docs/examples/chair.py

the chair
chair()
the stool
without add_back: a stool

The chair, part by part docs/examples/chair.py

Click a part on the left: it stands out in the model, and the functions that build it light up in the code.

drag to turn · scroll to zoom · click a part
1"""A chair, written as an assembly: the plan first, then the details, then the parts.2 3`chair` reads as a table of contents. `chair_frame` is the plan: the seat on its legs,4and the anchors the details need. Each `add_` function hangs one detail on those5anchors: delete its line and nothing else moves — without `add_back` it is a stool.6The parts come last, each built at its own origin, standing on z = 0.7"""8import sand3d as s3d9 10 11def chair(width=440, depth=420, seat_h=450):12    chair = chair_frame(width, depth, seat_h)   # the plan13    add_back(chair)                             # the details, on the plan's anchors14    add_rails(chair)15    return chair.root16 17 18# ---------------------------------------------------------------- the plan19 20def chair_frame(width, depth, seat_h, t=30, inset=35):21    """The seat on four legs. Publishes the width, the seat's height and the line22    the back stands on."""23    legs = s3d.at(leg(seat_h - t), on=s3d.grid(2, 2, width - 2 * inset, depth - 2 * inset))24    seat = s3d.at(seat_board(width, depth, t), z=seat_h - t)25    return s3d.assembly("chair", seat, legs, width=width, depth=depth, seat_h=seat_h, inset=inset,26                        back_y=depth / 2 - 20)27 28 29# ---------------------------------------------------------------- the details30 31def add_back(chair, h=420, t=24, button_d=16):32    """A panel standing on the seat's back edge, buttoned on its face."""33    back = s3d.at(back_panel(chair.width - 40, h, t), y=chair.back_y, z=chair.seat_h)34    # a side face's frame starts at z = 0, below the parent's origin, and its Y is up35    face = back.face("-Y").move(y=chair.seat_h + h * 0.6)36    buttons = s3d.at(s3d.dome("button", button_d, 5), on=s3d.grid(3, 2, 100, 110, on=face))37    s3d.attach(chair, "back", back, buttons)38 39 40def add_rails(chair, d=18, z=160):41    """A rail between the front and back legs, on each side."""42    # both=True: centred on its middle, so turned about X it spans the depth evenly43    rail = s3d.extrude("rail", s3d.circle(d), chair.depth - 2 * chair.inset, both=True)44    sides = s3d.grid(2, 1, chair.width - 2 * chair.inset, on=s3d.Frame((0, 0, z)))45    s3d.attach(chair, "rails", s3d.at(rail, rx=90, on=sides))46 47 48# ---------------------------------------------------------------- the parts49 50def seat_board(width, depth, t):51    return s3d.fillet(s3d.box("seat", width, depth, t), 8, edges="top")52 53 54def leg(h, foot=26, top=36):55    """Tapered, its foot on z = 0."""56    return s3d.cone("leg", foot, h, d2=top)57 58 59def back_panel(width, h, t):60    return s3d.fillet(s3d.box("back", width, t, h), 6, edges="top")

60 lines · 776 tokens (o200k_base)

A lamp: an anchor that is a frame docs/examples/lamp.py

The plan publishes head, the frame at the top of the stem. The shade sits on it with on=lamp.head: a longer stem moves the shade with it.

drag to turn · scroll to zoom · click a part
1import sand3d as s3d2 3 4def lamp(stem_h=300):5    base = s3d.revolve("base", [(0, 0), (70, 0), (60, 20), (0, 20)])6    stem = s3d.at(s3d.cylinder("stem", 12, stem_h), on=base.face("top"))7    lamp = s3d.assembly("lamp", base, stem, head=s3d.Frame((0, 0, 20 + stem_h)))8    add_shade(lamp)9    return lamp10 11 12def add_shade(lamp, d=180):13    shade = s3d.shell(s3d.cone("shade", d, 120, d2=d / 2), 1.5, open="top bottom")14    s3d.attach(lamp, "shade", s3d.at(shade, on=lamp.head))

14 lines · 194 tokens (o200k_base)

The same structure, at full scale programs/tufted_chair.py

A buttoned back rolling over at the top, a domed seat on a nailed apron, four tapered legs. The architecture is the seat on its legs; the back and every row of trim are details; the parts come last, in the order they are called. The legs are lofts through their sections, the right ones images of the left.

drag to turn · scroll to zoom · click a part
1"""A tufted chair: a buttoned back rolling over at the top, a domed seat on a nailed2apron, four tapered legs.3 4Millimetres, Z up, the chair's front towards -Y; built as its left half's legs, the5right ones mirrored. The architecture is the seat on its legs; the back and every6row of trim are details added to it: deleting `add_back` leaves a stool, and the7back's tufts follow the width they are given.8"""9import json10import math11import sys12 13import sand3d as s3d14 15 16def tufted_chair(width=520):17    chair = chair_frame(width)18    add_back(chair)19    add_seat_nails(chair)20    return chair.root21 22 23def chair_frame(width=520, depth=480, seat_z=430, inset=50, floor=0):24    seat = upholstered_seat(width, depth)25    legs = []26    for leg, y in ((front_leg, -(depth / 2 - inset)), (rear_leg, depth / 2 - inset)):27        left = s3d.at(leg(), x=-(width / 2 - inset), y=y, z=floor)28        legs += [left, s3d.transform(s3d.copy(left), mirror="x")]   # the right one: its image across x = 029    return s3d.assembly("chair", s3d.at(seat.root, z=seat_z), *legs,30                        seat=seat, width=width, seat_z=seat_z, rear_y=depth / 2)31 32 33def add_back(chair, behind=-30, above=200):34    back = tufted_back(chair.width)35    s3d.attach(chair, "back", s3d.at(back.root, y=chair.rear_y + behind, z=chair.seat_z + above))36    add_buttons(back)37    add_back_nails(back)38 39 40def add_seat_nails(chair, pitch=22, d=10, h=5):41    """One nail every `pitch` along the seat's seam, round its front and sides."""42    seat = chair.seat43    spots = s3d.snap(seat.parts, s3d.along(seat.seam, pitch))44    s3d.attach(seat, "seat_nails", *[s3d.at(s3d.dome("nail", d, h), on=f) for f in spots])45 46 47def add_buttons(back, d=16, h=3):48    s3d.attach(back, "buttons", *[s3d.at(s3d.dome("button", d, h), on=f) for f in back.tufts])49 50 51def add_back_nails(back, pitch=22, d=10, h=5, face=((220, -230), (226, 150)), reach=400):52    """A row up each side of the back's face, where its cover is nailed to the frame,53    from (x, z) to (x, z), found from in front, `reach` away."""54    spots = []55    for side in (-1, 1):56        (x0, z0), (x1, z1) = face57        spots += s3d.snap(back.parts, s3d.along([(side * x0, -reach, z0), (side * x1, -reach, z1)], pitch),58                          direction=(0, 1, 0))59    s3d.attach(back, "back_nails", *[s3d.at(s3d.dome("nail", d, h), on=f) for f in spots])60 61 62# ---------------------------------------------------------------- parts, in the order they are called63 64def section(z, w, d, x=0, y=0, n=2, taper=0):65    """One cross-section of a loft: a superellipse `w` wide and `d` deep, squareness66    `n` (2 an ellipse, 4 a box with round corners), its +Y side narrowed by `taper`,67    centred at (x, y, z)."""68    return s3d.at(s3d.superellipse(w, d, n, taper), x=x, y=y, z=z)69 70 71def upholstered_seat(width=520, depth=480, taper=0.04, squareness=6, board=-8,72                     crown=((15, 3, 3, 0), (35, 12, 10, 4), (55, 30, 24, 8), (68, 90, 72, 12), (76, 190, 160, 18)),73                     apron=60, dip=15, wall=8, seam_up=6, seam_out=10):74    """A cushion domed over a board, an apron hanging round it. `crown` is the dome,75    level by level: (height, inset at the sides, inset front and back, shift back)."""76    w = width / (1 + taper)  # the loft's mean width; the front edge is `width`77    cushion = s3d.loft("cushion", section(board, w, depth, n=squareness, taper=taper),78                       *[section(z, w - 2 * side, depth - 2 * end, y=back, n=squareness, taper=taper)79                         for z, side, end, back in crown])80 81    def drop(x):  # how far the apron hangs below the board: deepest mid-front82        return apron + dip * math.cos(math.pi * x / width) ** 283 84    hem = s3d.superellipse(w - wall, depth - wall, squareness, taper).loops[0].pts85    skirt = s3d.loft("apron", *[s3d.at(s3d.superellipse(drop(x), wall, squareness), x=x, y=y, z=board - drop(x) / 2,86                                       ry=-90) for x, y in hem], follow=True, loop=True)87    # the seam the nails follow: just outside the apron, round the front and sides88    # but not the back, which the back of the chair covers — from the middle of the back round89    rim = s3d.superellipse(w + 2 * seam_out, depth + 2 * seam_out, squareness, taper).loops[0].pts90    start = min(range(len(rim)), key=lambda i: abs(math.atan2(rim[i][1], rim[i][0]) - math.pi / 2))91    seam = [(x, y, board - drop(x) + seam_up) for x, y in rim[start:] + rim[:start] if y < depth / 2 - seam_out]92    return s3d.assembly("seat", cushion, skirt, parts=[cushion, skirt], seam=seam)93 94 95def front_leg(height=420, profile=((0, -10, 30, 30), (210, -4, 36, 36), (420, 0, 44, 44))):96    """A square leg with round corners, tapering to its foot, which steps forward.97    `profile` is (z, y, width, depth) from the floor up; its origin is under its foot."""98    body = s3d.loft("front_leg", *[section(z * height / profile[-1][0], w, d, y=y, n=4) for z, y, w, d in profile])99    return s3d.group("front_leg", s3d.at(body, y=-profile[0][1]))100 101 102def rear_leg(height=420, profile=((0, 16, 30, 30), (210, 6, 36, 36), (420, 0, 44, 44))):103    """The back leg, raking back to its foot. Its origin is under its foot."""104    body = s3d.loft("rear_leg", *[section(z * height / profile[-1][0], w, d, y=y, n=4) for z, y, w, d in profile])105    return s3d.group("rear_leg", s3d.at(body, y=-profile[0][1]))106 107 108def tufted_back(width=520, drawn_for=520, squareness=4, profile=(109        (-260, 0, 440, 50),    # foot, behind the seat: z, y, width, depth110        (-140, 6, 460, 60),111        (-20, 14, 470, 64),    # the cushioned middle112        (90, 24, 480, 66),113        (170, 36, 490, 72),    # leaning back114        (205, 58, 490, 70),    # over the top115        (180, 82, 490, 60),    # down the back116        (130, 88, 490, 46)),   # tucked under117        tuft_rows=((-60, -120), (-52, -40), (-44, 40), (-34, 120)), tuft_pitch=80, tuft_margin=100, tuft_r=55,118        tuft_depth=20):119    """A cushioned panel rising from the seat and rolling over backwards, one loft120    along its own centre line; buttoned on a diamond lattice, each tuft row (y, z),121    the rows' columns staggered. The profile's widths are those of a `drawn_for` chair."""122    widen = width - drawn_for123    body = s3d.loft("back", *[section(z, w + widen, d, y=y, n=squareness) for z, y, w, d in profile], follow=True)124    reach = int((width / 2 - tuft_margin) / tuft_pitch)  # columns each side of the middle125    lattice = [(j * tuft_pitch, y, z) for i, (y, z) in enumerate(tuft_rows)126               for j in range(-reach, reach + 1) if (i + j) % 2 == 0]127    tufts = s3d.snap(body, lattice)128    s3d.dent(body, tufts, tuft_r, tuft_depth)129    return s3d.assembly("tufted_back", body, parts=[body], tufts=s3d.snap(body, tufts))130 131 132if __name__ == "__main__":133    s3d.empty_scene()134    s3d.export(s3d.group("tufted_chair", tufted_chair(**json.loads(sys.argv[2] if len(sys.argv) > 2 else "{}"))),135               sys.argv[1])

135 lines · 2304 tokens (o200k_base)

Limits

sand3d 1.6.2 · generated by tools/quickstart from the code it shows Sandflow