Skip to main content

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 dev already 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_wall probes 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

nurb-0.1.0.tar.gz (276.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nurb-0.1.0-py3-none-any.whl (287.5 kB view details)

Uploaded Python 3

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

Hashes for nurb-0.1.0.tar.gz
Algorithm Hash digest
SHA256 c8dc35fde9e49fbf37196f4cf55202ecdf3f39257015b4819dc29e6c92a02987
MD5 88b44632e76b78a8560a01cfd10fdb0c
BLAKE2b-256 9c9219cbfc4a2071898e8a0dd22b07f91545cf06a197bf50a7f611ef1de689c0

See more details on using hashes here.

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

Hashes for nurb-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a27909982c0e4ca9e35efbccf476a751392b34487752c98c14379e39936a3fdb
MD5 a3937c82c1c123e841b2245f6546db79
BLAKE2b-256 becf1878d568b0b9967c69048ce727514547a3bbaadb09d42fd065a80d1e603a

See more details on using hashes here.

Release history Release notifications | RSS feed

0.23.0

2 files

0.22.2

2 files

0.22.1

2 files

0.22.0

2 files

0.21.0

2 files

0.20.0

2 files

0.19.2

2 files

0.19.1

2 files

0.19.0

2 files

0.18.2

2 files

0.18.1

2 files

0.18.0

2 files

0.17.0

2 files

0.16.0

2 files

0.15.1

2 files

0.15.0

2 files

0.14.0

2 files

0.13.0

2 files

0.12.0

2 files

0.11.0

2 files

0.10.0

2 files

0.9.0

2 files

0.8.0

2 files

0.7.0

2 files

0.6.1

2 files

0.6.0

2 files

0.5.0

2 files

0.4.1

2 files

0.4.0

2 files

0.3.0

2 files

0.2.0

2 files

0.1.1

2 files

This release

0.1.0 This release

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page