Skip to content
genworksPublic

About

A little CAD program for making Lego and Lego Technics assemblies

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

36 Commits

Folders and files

Repository files navigation

Brickyard

Assemble studded bricks and Technic beams, pins and axles in 3D, in Gendl. A build is a list of part specs; Brickyard works out which parts hold each other, which collide, which are loose, and pulls a dropped part onto the nearest place it fits.

New to the editor? The first ten minutes with Brickyard walks from an invite link to a build of your own.

(asdf:load-system :brickyard)

(defparameter *build* (make-object 'brickyard:assembly))

(the-object *build* connections)        ; ((0 2 8 (:face)) (0 4 1 (:axial)) ...)
(the-object *build* collisions)         ; nil
(the-object *build* loose)              ; nil
(the-object *build* bill-of-materials)  ; ((2 :technic-brick (1 8 3) :grey) ...)

;; drop a 2x2 brick roughly over the plate; it lands on the studs
(let ((i (the-object *build* (add-part! '(:type :brick :size (2 2) :color :yellow)))))
  (the-object *build* (snap-part! i '(17 45 40))))
;; => (:type :brick :size (2 2) :color :yellow :at (20 40 32))

The editor

Loading the system publishes /brickyard on the Gendl web server. It opens a new build (a small sample chassis; ?new=empty for none) at its own address, /brickyard?b=<id>, and keeps it in /projects/.state/brickyard-<id>.sexp, so the address keeps working across restarts.

  • Click a part in the bin to add it: anywhere on the selected part that it fits (a gear finds the free end of a long axle), in whichever orientation needs the least moving; failing that onto what is near, and off to the side otherwise.
  • Drag a part to slide it across the build; on release it drops onto the nearest place it fits, landing on top before going underneath. A part with no studs or sockets (a gear, a beam, a pin) turns to fit when nothing takes it as it stands, and where it cannot stay it looks further, ten studs off, for a joint that takes it.
  • A part moves with what it carries: whatever rests on it and nothing else (a plate bridging it and a neighbor stays), and whatever hangs from those (a beam on pins, a wheel on an axle) without standing on the ground through the rest of the build. The carried parts glow softly when the part is selected, and the group drops, snaps and turns as one body. Hold Shift to move the part alone.
  • Arrow keys nudge the selected part a stud, PgUp and PgDn a plate; a part with no studs that a step runs into something goes to the joint it was making for, since a hole's axis is on neither grid. R, T and E turn, tilt and roll it a quarter; C copies it on top; Delete removes it; a swatch recolors it.
  • Run turns what the motors drive, at the speeds the report gives, meshed gears together, tyres and bushes riding along; with no motor turning anything, the selected gear, axle or wheel is turned by hand and takes its train with it. Any change stops it. It turns parts about their own axes and no more: the build stays where it is.
  • The report counts joined pairs and names overlapping and loose parts, and keeps the parts list.

"Export LDraw" downloads the build as an LDraw multi-part document (.mpd) for LDraw programs such as LeoCAD. Parts the official LDraw library has are placed by their library numbers (3001, 3702, 32316, 3705, 3647 ...), each in the frame its library file uses, checked against the files' own studs, holes, axle ends and outlines; a brick several bricks high goes out as a stack of standard bricks. Wheels, the motor, the controller and sizes the library lacks go in the same file as subfiles drawn from the Gendl model. to-ldraw gives the same text from Lisp.

"Import LDraw" takes an .ldr or .mpd file and opens it as a new build. Library parts Brickyard has come in by number (older numbers of the same parts too, such as 3023 and 3648), Brickyard's own subfiles by name, and submodels are flattened through their placements. Parts Brickyard does not have, parts turned off the square grid, and lines that cannot be read are left out and named in a note. A file is limited to 4 MB and 2000 parts; numbers are read by hand, never by the Lisp reader. from-ldraw does the same from Lisp and returns the specs and that report.

The package brickyard-api drives a build from Lisp, one flat function for each thing the page does: add-part, move-part, turn-part, swing-part, report, describe-build and the rest, taking literals and answering plists, with a refusal an error saying why. docs/api.md is the reference, with recipes.

With Gendl's sexp-agent loaded, (brickyard:prompting URL) opens a prompt box on the page: a person says what to build, a language model at URL (a gate that holds the key, in production) answers with forms of this API, and they run in the sandbox, which admits only the operators with literal arguments. A prompt runs on its own thread and the page asks after it, and Undo puts the build back as it stood before the prompt. GET /brickyard/api/reference serves the reference the model reads.

Call prompting again with :add? t to offer a second model, and the box grows a menu, the first one offered being the default:

(brickyard:prompting "http://gate/llm/messages" :name "Opus 5.5")
(brickyard:prompting "http://ollama:11434/v1/messages" :add? t
                     :name "Local" :model "ornith-1.5:9b")

Any server that speaks Anthropic's Messages API will do, and Ollama does at /v1/messages. The page sends the name it chose, the server answers only for names it offers, and the browser remembers the choice. A model that shares a GPU with something else can take :turn, a function of one thunk, and each of its prompts runs inside that.

Missions and the score

Two models stand on the table and turn when the robot meets them: a lever, a bar hinged on a post by the north wall that swings up to 90 degrees and stops, and a spinner south of the launch area, a cross of two bars turning freely about its middle. Each is a body that cannot move, only turn about its post, so the robot's push turns it as hard as it pushes and as far out on the bar as it meets it; an arm raised above one passes over it. After a run the table scores itself: 10 a piece in the red ring, 5 in the blue or the yellow, 25 for the lever turned 60 degrees, 15 for the spinner turned 45. Like the mat, the missions and the scoring are Brickyard's own; the table is a plist (*table* in source/table.lisp) and new missions and rules go there.

(turn -90) (straight 400) (turn 90) (straight 900)    ; flips the lever: 25

SPIKE Prime parts

The SPIKE Prime row of the parts bin has LEGO's own: the hub, the large and medium angular motors, the color, distance and force sensors, and the 56 mm wheel. Their sizes and their pin and axle holes are read from the LDraw library's files (library.ldraw.org, CC BY 4.0), so they pin together as the real parts do and export to LDraw as those parts; they are drawn here as boxes. On the table each motor turns at LEGO's figures for it (175 and 185 rpm with nothing to turn, 25 and 18 N cm held still, at 7.2 V), and the hub weighs 63 g as LEGO gives it plus an estimated 120 g of battery; the other masses are estimates or retail listings. SPIKE robot on the page is a drive base built of them: two large motors pinned to the hub's sides, a wheel on an axle through each motor's crosshole, a color sensor pinned under the front 16 mm off the mat, LEGO's best distance for it, and two axles for skids. Its program written out names its 56 mm wheels and 136 mm track, and the hub's top_side when the hub is not mounted buttons up. A motor's rotating disc is taken to stand still.

The table

The Table button stands the build on a robot-competition table, a mat of 2362 by 1143 mm inside low walls, with balls and crates to push and rings to bring them to. The layout and the mat are Brickyard's own. A robot there runs a program and nobody steers it. The steps are Pybricks' drive base and motor calls, with its units and signs, so the same program runs on the table here and on a real hub:

(straight 500)             ; mm; negative goes back
(turn 90)                  ; degrees; positive is clockwise
(arc 300 :angle 90)        ; radius mm, positive to the right; or :distance mm
(drive 200 -30 1500)       ; mm/s and deg/s, for milliseconds, then stop
(settings :straight-speed 200 :straight-acceleration 400 :turn-rate 90 :turn-acceleration 300)
(arm -25)                  ; degrees at the arm, and hold it there
(arm 40 :speed 300 :motor 9)   ; deg/s, and which motor
(wait 1000)                ; milliseconds
(drive 200 0)              ; keep driving, on to the next step
(wait-until (< (reflection) 30))   ; until a sensor says so
(stop)
(follow-line :until (> (driven) 800) :speed 100 :gain 1 :target 50)
(repeat 4 (straight 300) (turn 90))

Conditions read the sensors and the drive base, as Pybricks does: (reflection) and (color) from a color sensor, (distance) from a distance sensor (2000 when it sees nothing), (force) in newtons and (pressed) (3 N or more) from a force sensor, (heading) in degrees clockwise from the start, and (driven) in millimeters; give a part id, (reflection 12), when there are two of a kind. Compare with < > <= >= = /=, name colors as red yellow green blue white and none (a color sensor calls black none, as Pybricks' does), and join with and or not. Sensor robot on the page has one of each: a color sensor under the plate looking down, a distance sensor on the controller looking ahead, a force sensor's button in front.

The color sensor reads the mat's printed colors where it looks, over a spot a few millimeters across, so an edge reads between its two sides, and weaker the higher it is; the distance sensor sees walls and pieces taller than itself; the force sensor feels what its button touches. follow-line turns at (reflection - target) * gain degrees a second, so a positive gain keeps the line on the sensor's right; the sample robot's sensor is close to its wheels, and holds a line at 100 mm/s but not at 150, as a real one would.

The robot here drives as a Pybricks drive base does: each move follows a trapezoid of speed, the wheels' angles count the distance, the hub's gyroscope keeps the heading, and the settings start at Pybricks' own defaults for the motors (40% of top speed straight, a third of the top turn rate).

The Pybricks button writes the program out as a Pybricks program to save and open in Pybricks Code: the left wheel motor on port A, the right on B, arm motors on C to F, each motor's direction worked out from which way it faces in the build, a gear train as gears=, and the build's wheel diameter and axle track, and each sensor as Pybricks' ColorSensor, UltrasonicSensor or ForceSensor on the next free port, a condition as a while not (...) loop. It assumes the hub lies flat, buttons up, facing the way the robot drives. None of this has yet been run on a real hub.

A motor that turns no wheel on the mat turns an arm: whatever its shaft carries by an axle hole, and everything built on that. An axle across the robot lifts with a positive angle an arm that points ahead, and an upright axle swings it counterclockwise seen from above. Arm robot on the page is a sample.

(brickyard-api:run-on-table '((:straight 500) (:turn 90) (:arm -25))) does the same from Lisp and answers where everything stopped, and (brickyard-api:pybricks-program '((:straight 500))) answers the Pybricks program. The server runs the whole program and the page plays it back. tests/run.lisp checks the driving, the pieces and the program written out: load it after Brickyard and call (brickyard::run-tests).

The build drives as one rigid body in the plane of the mat (source/physics.lisp, source/table.lisp): mass and center come from its parts, what it weighs on each wheel and skid from where they stand, and each motor pushes through its gears and wheels as far as the wheel's grip allows, so a wheel slips against a wall and its count runs on. It faces the way its wheels roll toward +Y, and it needs a motor each side to turn. Motor and friction figures are typical, not those of any product, and a part's mass is an estimate from its box. An arm works against its own weight and what it meets, and the mat stops it, but it passes through the robot it is built on, and its weight does not shift the load on the wheels as it swings. It sweeps and pushes; it does not pick up or carry. An arm above a piece's height passes over it, as does a part of the body more than 64 mm above the mat. The robot does not tip or climb.

For a private beta, load Gendl's invite-gate before Brickyard and close the gate to a roster kept on the server:

(brickyard:private-beta "/path/to/brickyard-invites.sexp" "https://example.com")
(brickyard:mint-invite! "A tester")   ; => token, link to hand out
(brickyard:invites-report)              ; who has one, and how often it was used
(brickyard:revoke-invite! "token")

The link sets a cookie and opens the editor; every page and call under /brickyard then answers only a live invite (the static script and style stay open). Without a roster the gate is open, and without invite-gate there is none.

The operator's page, /brickyard/admin, shows who has an invite and how often they used it, what each visitor did (visits, changes, runs on the table, prompts, exports, imports, build by build), and the builds in memory with their prompts and the model's answers; it deletes from the record a line, or everything kept of a visitor, when asked. The record is Gendl's visitor-log, which also gives the page its look and the pieces its script is built from; one file a visitor, kept only once the operator names a directory: keeping what visitors do is the operator's decision, and something the visitors should be told. Only an invite name on *admins* sees the page; anyone else is told there is no such page.

(brickyard:keep-activity! "/path/to/brickyard-activity/")
(setf brickyard:*admins* '("Name on my invite"))

The page is X_ITE, an X3D browser: each part is its own Transform with a PlaneSensor, so dragging runs in the browser and only the release goes to the server, which answers with the parts that changed.

Part specs

(:type :brick :size (2 4) :at (0 0 0) :up :top :rear :rear :color :red)
:type :size anchor (:at)
:brick (w l [plates-high]), three plates high by default center of the bottom face
:plate, :tile (w l) center of the bottom face
:technic-brick (l): 1 by l, with l-1 holes center of the bottom face
:beam (holes): studless the middle hole
:axle (studs-long) its middle
:pin none: two studs long its collar
:wheel (d [w]): d studs across, w wide (2 by default) its middle
:motor none: 2 by 4 studs, two bricks high, output hub at its rear center of the bottom face
:controller none: 4 by 6 studs, two bricks high center of the bottom face
:gear (teeth): 8, 16, 24, 40 in the bin, any count works its middle
:pin ([2 or 3]): studs long, 2 by default its middle
:half-pin none: a pin on one side of the collar, a stud on the other its collar
:axle-pin ([1 or 2]): studs of pin; one stud of axle where the library has it
:bush none: a stud long, on an axle its middle
:cross-block ([2 or 3]): an axle hole across it, pin holes through it where the library has it
:l-beam (3 5) or (2 4) (its end hole an axle hole) the end hole of the long arm
:t-beam none: 3 by 3 the end hole of the stem
:angle-connector ([1, 2, 3 or 5]): one arm, two in line, or two at 157.5 (#3) or 112.5 degrees (#5) the hub
:axle-joiner ([2 or 3]) studs long its middle
:pin-joiner, :stop-pin, :hole-pin none as the library has them
:pin-block, :axle-block none: the 1 by 3 cross blocks with four pins, and axle/pin/axle the middle hole
:round-tile, :round-plate none: 1 by 1 center of the bottom face
:rim, :tyre none: rim 20 by 30, tyre 48 by 20; the tyre seats on the rim the center
:bent-beam none: the 4 by 6 liftarm bent 53.13 degrees (6629) its end axle hole
:h-beam, :axle-beam none: the 5 by 3 H (14720); a beam 2 with an axle hole (60483) as the library has them
:split-cross-block, :twin-cross-block, :triple-cross-block none: 92907, 41678 and 63869 as the library has them
:axle-hub, :axle-connector, :cross-pin none: two axles at 90 degrees on a pin hole (10197); axle/bush connector (32039); a pin with a pin hole (15100) as the library has them
:stop-axle ([4 or 8]): studs long its middle
:hole-axle ([2 or 3]): an axle with a pin hole across its end or middle the pin hole
:stud-axle, :pin-axle none: an axle 3 with a stud (6587); an axle 2 with a pin (18651) as the library has them
:cheese-slope none: the 1 by 1 slope two plates high (54200) center of the bottom face

The Technic connectors, beams, gears 12 and 20 (double bevel), the round plate and the rim and tyre follow the LDraw library's own parts: their pins, holes, studs and axle ends were placed from the library files and checked against them in every orientation. A rim turns with its axle like a wheel, and a tyre seated on it is what the build stands on. Parts may run into each other by up to 2 LDU (0.8 mm) without counting as an overlap: real parts are chamfered where Brickyard's boxes are square.

The wheel, motor and controller are this program's own designs, sized to the stud grid. A wheel has an axle hole through its hub; a motor has studs on top, sockets below, pin holes across it and an output hub that holds an axle; a controller has studs on its two end rows, a screen, four ports and pin holes down both long sides. ?new=robot opens a small drive base.

A gear's pitch radius is half a millimeter per tooth, so gears whose teeth add up to 32 mesh two studs apart (8 and 24, 16 and 16) and 24 and 40 mesh four apart. Two gears mesh when their axes are parallel, their faces overlap and their centers stand exactly the two radii apart, in any direction across the axes. The build follows power from each motor's output: into the axle it holds, into the gears and wheels on that axle, across each mesh (the driven gear turns the other way, at the driver's teeth over its own), and on through their axles. It reports each wheel a motor turns, how fast against the motor and which way, and calls a train jammed when one motor would turn a part at two speeds. ?new=gearbox opens an 8-to-24 reduction: the wheel turns at a third of the motor's speed, reversed. An axle in a round pin hole turns freely and carries nothing.

Positions are whole LDraw units (LDU): a stud pitch is 20, a plate 8, a brick 24; one LDU is 0.4 mm. Axes follow Gendl: X :right, Y :rear, Z :top. :up and :rear say where the part's own top and rear face, as axis keywords, which gives the 24 square-on rotations.

A part may instead stand at any angle: :matrix, three rows of a rotation, in place of :up and :rear, and an :at that need not be whole -- a steered wheel, a roll-cage strut, anything read from another program at an angle. Square parts compare exactly; joints with an angled part are judged within half an LDU and a hundredth of a radian, and its boxes are oriented with it (overlap by the separating-axis test), allowed 3.5 LDU against 2 for square parts, since round parts are boxed square. Angled parts move, turn a quarter, export and import like any other.

How it is built

  • source/specs.lisp is plain Lisp over the specs: connectors (studs, sockets, pin holes, pins, axles), extents, mates, collisions, the groups of parts held together, and snapping. On the square grid all of it is exact arithmetic; only angled parts and the bent arms of angle connectors #3 and #5 carry floating point.
  • source/parts.lisp draws each part with Gendl primitives, its studs and holes taken from the same connectors the rules use.
  • source/assembly.lisp is the build: its parts, what holds them, and the functions that add, move, snap and remove parts.

License

GNU Affero General Public License, version 3 or later; see LICENSE.

About

A little CAD program for making Lego and Lego Technics assemblies

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages