nurb
Agentic CAD for 3D printing.
A part is a Python function. Its keyword defaults are its parameters. nurb dev
watches your parts, rebuilds them on save, and pushes new geometry to a browser
without moving your camera.
Built on build123d (OCCT), so parts are real B-rep solids with working chamfers, fillets, and STEP export.
Try it
uv run nurb new dispenser
uv run nurb dev # http://127.0.0.1:7373, or the next free port
Edit parts/dispenser.py and watch it update.
A part
from nurb import *
@part
def dispenser(width=80.0, height=120.0, wall=2.0, draft=False):
body = Box(width, height, wall)
if draft:
return body
bed = body.bounding_box().min.Z
keep = body.edges().filter_by(lambda e: e.bounding_box().min.Z > bed)
return polish(body, keep, 1.0)
draft is optional and passed by the runtime, not the caller. When it's true the
part should skip its polish pass. nurb dev builds in draft by default: on this
trivial part it's 18ms polished vs 1ms draft, and on a real one the saving is
nearer 20%.
Commands
nurb new <name> create parts/<name>.py and its card
nurb dev watch, rebuild, serve the viewer
nurb build [part] build once and report size
nurb check [part] run the printability rules
nurb rules print the design doctrine
nurb card [part] regenerate a card's AUTO block
nurb verify [part] run the doctrine's verification list
nurb render [part] write a PNG into build/
nurb export [part] write STL and STEP into build/, --formats for GLB
nurb extract find duplication across parts
A project is any directory with a parts/ folder. There's no init step, and there's
no such thing as being outside a project: mkdir -p thing/parts && cd thing && nurb new clip is the whole setup for a one-off.
nurb dev serves one project, so two projects means two of them. It takes 7373 if that
is free and walks up if it is not, printing where it landed, and the sidebar and the
browser tab both carry the project name so two of them are not mistakable for each
other.
Names are deliberately boring. The primary user is a language model, and a model that
has never seen this tool can guess build, check and export. It cannot guess a
themed alias.
Why a long-lived process
Importing build123d costs 45s cold and 2.3s warm, and that is the whole argument: the dev server pays it once instead of on every save.
What a rebuild costs after that depends on the part. A simple one is 29ms to build and
1ms to tessellate. The heaviest part in examples/ is 401ms and 30ms. Draft mode is
not the lever it looks like: chamfers are 23% of that build, not most of it.
Tessellation used to be the larger half, at 620ms on that part, and almost none of it
was geometry. Shape.tessellate reads its triangles with for t in poly.Triangles(),
and OCP's iterator over that array costs 536ms where reading the same 7790 triangles by
index costs 6.8ms. builder._triangulate does the latter and returns bit-identical
vertices and faces. It is worth knowing before optimising the wrong thing.
Layout
parts/<name>.py the part
parts/<name>.md its card: what it is, why, what not to retry
system.py optional: shared constants and geometry, importable from a part
measurements.toml optional: real-world dimensions with how they were obtained
printer.toml optional: which machine this project prints on
build/ generated, gitignored
Cards are colocated with parts and share a basename. That's the whole link; a
rename is git mv on two files.
Checks
nurb check runs the printability rules against the solid rather than an exported
mesh, so it sees real faces with exact areas and normals instead of triangles.
overhang downward faces past 45 degrees, bridges told from cantilevers
min_wall thinnest section, ray cast corrected by an inscribed sphere
sliver faces too small to print as anything but a smear
concave_cosmetic polish laid into an inside corner
bed_bevel polish laid on the edges that meet the build plate
stability center of mass outside the footprint
projection_ratio reach over height, for a part cantilevered off a wall
build_volume does it fit the printer at all
min_wall's ray is exact on flat parallel walls and measures the slant through a skewed
one, so any chord thin enough to change the verdict is corrected by the largest sphere
tangent at that point, computed against the solid with exact kernel distances. A sphere
whose far contact is a graze rather than a wall is rejected by the same 0.3 cosine floor
the ray's exit filter uses, which is what keeps a detent dimple's bowl from reading as a
thin section of the web it is pressed into.
The bed size belongs to the machine, not to a part, so it is not written on cards.
A project picks a shipped profile once, in printer.toml at the root:
profile = "bambu_a1_mini"
Any check setting can be overridden in the same file, machine-wide. A card still wins
for what its part has justified. nurb check --printer prusa_mk4s answers "does this
fit that machine" without touching the file, and naming a profile that does not exist
lists the ones that do.
Every part carries what it has already justified on its card, so a known finding is silent and a new one is a regression:
[part]
min_wall = 1.0
[accepted]
sliver = 6
It reports by default and takes --strict for CI, on the grounds that a warning which
blocks work gets switched off. Findings also show up in nurb dev, with a pin on the
geometry at each one.
Variants
Some parts in a catalog are the same function flexed rather than new geometry. Those ship as variants on the card, not as copies of the file:
[variants.shelf_gridfinity_3x2.params]
grid_x = 3
bracket_count = 6
[variants.shelf_gridfinity_3x2.accepted]
sliver = 26
build, check, card and export all walk a part's variants the same way they walk
its default, so a variant gets its own STL, its own baselines and its own line in the
card's generated block. Four of the sixteen parts in examples/notch are variants; the
alternative was four near-copies of two files, free to drift.
For an agent
The doctrine lives in the package and prints with nurb rules: printability, load paths,
the polish pass, the kernel traps, and what to verify. SKILL.md and AGENTS.md are ten
lines each pointing at it, so there is one copy and it cannot drift.
A part explains itself in a card next to it, same basename. Most of it is written by
hand, including a ## Don't section that records what was tried and rejected, which is
the only place that information exists. One fenced block is generated:
nurb card
That block holds what only a build can tell you: bounding box, volume, solid count,
sliver count against the accepted baseline, projection ratio, check verdict. It carries
no timestamp, so regenerating it on unchanged geometry produces no diff and a stale card
shows up in git diff. It deliberately does not repeat the parameters, because the
signature is the parameters and copying them would be the drift the contract forbids.
Dimensions an agent cannot derive go in measurements.toml with how they were obtained:
[bracket_pitch]
value = 25.16
unit = "mm"
how = "on-center spacing across a run of brackets, measured on the wall"
from nurb import measured
pitch = measured("bracket_pitch")
Asking for something that isn't there raises and says so. That failure is the point: a guessed dimension produces a part that builds, checks clean, and prints.
nurb render <part> writes build/<part>.png by screenshotting the viewer, so the image
is what a human would see. It needs the optional extra, which is the only part of nurb
that wants a browser:
uv sync --extra render && uv run playwright install chromium
Tests
uv run pytest
The parts in examples/ are part of the suite, asserted against the dimensions and
baselines their catalog cards recorded in Fusion. tests/test_notch_fit.py is the
hanging interface: every channel floor on exact pitch, at full span, one per bracket and
no more, for every shipped configuration. Its numbers are literals rather than imports
from the part's own constants, because a fit test that reads the same constant the part
built from agrees with the part however wrong the constant is.
The viewer is the configurator
A part's parameters were always introspectable, so the sliders come from the signature
and nothing else. The stl and step buttons build the part at whatever the sliders
are holding, at full polish whatever the preview economy, and hand back the file: what
is on screen is what lands in the slicer. Point somebody at your nurb dev and they
can configure and download a part without touching Python.
Not built yet
- A hosted configurator.
nurb devalready is one for anybody who can reach it, but publishing without a running kernel is a different problem: MakerWorld's customizer runs OpenSCAD, which build123d does not transpile to. - Measurement tools in the viewer. The section view shows an interior; it does not yet measure it.
min_wallprobes sample faces, so a pinch nothing lands near is still missed. A clean result means "no thin walls found", not "no thin walls".
Debugging the viewer
window.__nurb exposes { THREE, scene, camera, controls, mesh, ready }.
The URL takes ?part=<name> to open a part, ?view=iso|front|back|left|right|top to
frame it deterministically, and ?bare to hide the chrome. nurb render drives exactly
that, and waits on ready.
three.js is vendored in src/nurb/vendor/three, so the viewer needs no network. See the
README beside it before changing versions: the import graph has grown since r169 and the
files it added fail as a blank canvas rather than as an error.
License
FSL-1.1-MIT. Source-available for any purpose except building a competing product, and converts to plain MIT two years after each release.
Copyright 2026 Ordinary Systems LLC.
Third-party notices
nurb uses Open CASCADE Technology (OCCT) for all B-rep geometry, reached through
build123d (Apache-2.0) and the OCP bindings
(Apache-2.0). OCCT is licensed under
LGPL-2.1 with an additional exception.
nurb does not redistribute OCCT. It is installed separately as a dependency, and dynamically linked at runtime. If you ever bundle nurb into a single-file distribution that embeds the OCCT binaries, ship a copy of the OCCT license with it and keep the library replaceable, per LGPL.
nurb does redistribute three.js r169 (MIT), vendored in
src/nurb/vendor/three so the viewer works without a network. Its LICENSE ships
beside it and the @license header stays on the build file, which is what MIT asks for.
Other dependencies: trimesh (MIT), watchdog (Apache-2.0), websockets (BSD-3-Clause),
numpy (BSD-3-Clause). Optional, for nurb render only: playwright (Apache-2.0), which
downloads its own browser build.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file nurb-0.1.0.tar.gz.
File metadata
- Download URL: nurb-0.1.0.tar.gz
- Upload date:
- Size: 276.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c8dc35fde9e49fbf37196f4cf55202ecdf3f39257015b4819dc29e6c92a02987
|
|
| MD5 |
88b44632e76b78a8560a01cfd10fdb0c
|
|
| BLAKE2b-256 |
9c9219cbfc4a2071898e8a0dd22b07f91545cf06a197bf50a7f611ef1de689c0
|
File details
Details for the file nurb-0.1.0-py3-none-any.whl.
File metadata
- Download URL: nurb-0.1.0-py3-none-any.whl
- Upload date:
- Size: 287.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a27909982c0e4ca9e35efbccf476a751392b34487752c98c14379e39936a3fdb
|
|
| MD5 |
a3937c82c1c123e841b2245f6546db79
|
|
| BLAKE2b-256 |
becf1878d568b0b9967c69048ce727514547a3bbaadb09d42fd065a80d1e603a
|