ClojCAD is a browser-based parametric CAD environment written in ClojureScript. It uses OpenCascade.js as its geometry kernel and three-cad-viewer for interactive 3D rendering. Models are defined declaratively via a Clojure DSL and update in real-time as parameters change.
Define a parametric model. The macro accepts an optional metadata map
with :opacity.
(defmodel ^{:opacity 0.5} my-box [w d h]
(kernel/make-box w d h))All primitives return an OpenCascade TopoDS_Shape.
(kernel/make-sphere 10) ;; radius
(kernel/make-box 15 15 15) ;; dx dy dz
(kernel/make-box 15 15 15 true) ;; dx dy dz centered? - centers at origin
(kernel/make-cylinder 5 20) ;; radius height
(kernel/make-cylinder 5 20 true) ;; radius height centered? - centers at origin
(kernel/make-cone 5 10 15) ;; radius1 radius2 height
(kernel/make-circle 5) ;; radius -> face (for Extrude)
(kernel/make-circle 5 true) ;; radius wire? -> wire (for Loft/Pipe)
(kernel/make-polygon [[0 0] [10 0] [10 10] [0 10]]) ;; points -> face
(kernel/make-polygon [[0 0] [10 0] [10 10] [0 10]] true) ;; points wire? -> wire
(kernel/extrude face [0 0 10]) ;; extrude face along vector -> solid
(kernel/text3d "Hello" 36) ;; 3D text, defaults to Cousine font
(kernel/text3d "Hello" 36 :font "Cousine-Bold" :height 0.2) ;; custom font and depthThe system bundles the Cousine font (Regular, Bold, Italic, BoldItalic)
under Apache 2.0 license. Additional fonts can be loaded from URLs or
placed in the public/fonts/ directory and referenced by URL path.
(kernel/list-fonts) ;; list all registered font names
(kernel/font-info "Cousine") ;; inspect font metadata
(kernel/load-font! "MyFont" "/fonts/MyFont.ttf") ;; synchronous — blocks until ready
(kernel/register-font! "MyFont" "https://...") ;; async — returns a promise
(kernel/register-font! "MyFont" "/fonts/MyFont.ttf") ;; async version (same for URL or path)Two loading options:
- **=load-font!=( recommended)** — Uses a synchronous XHR request. Blocks until the font is
fetched and parsed. Returns the font object (or
nilon failure). After this call returns, the font is immediately available fortext3dwith no async gap. The font also persists to IndexedDB in the background, surviving page reloads.(kernel/load-font! "Mine" "/fonts/Mine.ttf") (kernel/text3d "Hi" 36 :font "Mine") ;; works immediately
- **=register-font!=( promise-based)** — Uses
fetch, returns a promise. The font is stored in IndexedDB and survives page reloads. Iftext3dis called before the promise resolves, it returnsnil.(-> (kernel/register-font! "Mine" "/fonts/Mine.ttf") (.then (fn [_] (kernel/text3d "Hi" 36 :font "Mine"))))
Use load-font! when you need the font immediately and don’t want to chain promises.
Use register-font! when you can work with promises.
Combine two or more shapes into a single shape via CSG operations. All
functions accept variadic arguments = (a b & more)= and chain
left-to-right. If any step fails (e.g. non-overlapping common) they
return nil.
(kernel/fuse a b) ;; union: a ∪ b
(kernel/fuse a b c) ;; union: (a ∪ b) ∪ c
(kernel/cut a b) ;; difference: a − b
(kernel/cut a b c) ;; difference: (a − b) − c
(kernel/common a b) ;; intersection: a ∩ b
(kernel/common a b c) ;; intersection: (a ∩ b) ∩ cExamples with primitives:
(defmodel with-hole [r w d h]
(let [box (kernel/make-box w d h)
cylinder (kernel/make-cylinder r (* 2 h))]
(kernel/cut box cylinder)))
(defmodel intersected [r w d h]
(let [sphere (kernel/make-sphere r)
box (kernel/make-box w d h)]
(kernel/common sphere box)))
(defmodel combined [r]
(let [a (kernel/make-sphere r)
b (kernel/translate (kernel/make-sphere r) (* 1.5 r) 0 0)]
(kernel/fuse a b)))Move and rotate shapes in 3D space. Both return a new shape; the original is unchanged.
(kernel/translate shape x y z) ;; move by (x, y, z)
(kernel/rotate shape axis-x axis-y axis-z degrees) ;; rotate around axis by degreesConvert a TopoDS_Shape into renderable mesh data (typed arrays),
including edge line segments for the viewer’s edge overlay.
(kernel/tessellate shape) ;; default deviation 0.1
(kernel/tessellate shape 0.05) ;; custom max deviationAdd a model to the scene. Accepts optional parameter overrides and
display options (:opacity). When no display opts are given it falls
back to the metadata set in defmodel.
(sm/show my-box) ;; uses current params
(sm/show my-box {:w 20 :d 20 :h 20}) ;; with overrides
(sm/show my-box {:w 20} {:opacity 0.3}) ;; with display optionsToggle model or tag visibility. Accepts either a model name (existing
behavior) or a filter map for tag-level operations. show-tag and
hide-tag are removed — use the map form instead.
;; Whole model visibility (unchanged)
(sm/hide-model 'my-box)
(sm/show-model 'my-box)
;; Tag visibility — show/hide :sphere tag on all models that have it
(sm/show-model {:tag :sphere})
(sm/hide-model {:tag :sphere})
;; Tag visibility on a specific model (replaces show-tag / hide-tag)
(sm/show-model {:tag :sphere :model 'joined})
(sm/hide-model {:tag :sphere :model 'joined})
;; Tag visibility restricted to name-matching models
(sm/show-model {:tag :sphere :name-matching "foo*"})Invert visibility. Accepts the same filter map format.
;; Toggle a tag on all matching models
(sm/toggle-model {:tag :sphere})
;; Toggle a tag on name-matching models only
(sm/toggle-model {:tag :sphere :name-matching "foo*"})
;; Toggle whole model visibility
(sm/toggle-model {:model 'my-box})Show or hide every model in the scene.
(sm/show-all)
(sm/hide-all)Change a model’s opacity after it has been added to the scene.
(sm/set-opacity 'my-box 0.5)Remove a model from the scene entirely.
(sm/remove-model 'my-box)Tag a sub-shape within a model body. Only works inside a defmodel
body (uses a dynamic binding).
(defmodel joined [r]
(let [sph (kernel/make-sphere r)
box (kernel/make-box (* 2 r) (* 2 r) (* 2 r))]
{:shape sph
:tags {:sphere sph
:box box}}))Dynamically add or remove tagged sub-shapes on displayed models at
runtime. Accepts either a model name (keyword or symbol) or a filter
map (:tag, :model, :name-matching) as the first argument.
;; Add tags by model name
(sm/add-tags 'my-model {:warning (kernel/make-sphere 1)})
;; Add multiple tags atomically
(sm/add-tags 'my-model {:label (kernel/make-sphere 1)
:base (kernel/make-box 10 10 10)})
;; Remove tags by model name
(sm/remove-tags 'my-model :warning)
(sm/remove-tags 'my-model :sphere :box)
;; Add tags to all models matching a filter
(sm/add-tags {:name-matching "temp*"} {:badge (kernel/make-sphere 0.5)})
;; Remove tags from all models matching a filter
(sm/remove-tags {:tag :sphere :name-matching "temp*"} :sphere)
;; Return value: single model returns updated :tags map
(def result (sm/add-tags 'my-model {:a (kernel/make-sphere 1)}))
;; => {:a <mesh-data>, ...existing-tags...}
;; Return value: filter map returns {model-name -> tags-map}
(sm/add-tags {:name-matching "temp*"} {:a (kernel/make-sphere 1)})
;; => {"temp-a" {:a <mesh-data>}, "temp-b" {:a <mesh-data>}}Added tags are ephemeral — they are lost when the model is re-evaluated
(e.g. due to parameter changes). Tags added via add-tags inherit the
model’s display options (color, opacity).
Query all objects in the scene with optional filtering.
;; All objects
(api/list-objects)
;; Filter by tag
(api/list-objects {:tag :sphere})
;; Filter by visibility
(api/list-objects {:visibility :visible})
(api/list-objects {:visibility :hidden})
;; Filter by name-matching (glob pattern or regex)
(api/list-objects {:name-matching "foo*"})
(api/list-objects {:name-matching #"^test-\d+"})
;; Combined filters
(api/list-objects {:tag :sphere :visibility :visible :name-matching "foo*"})Return all unique tag labels across all models as a set of keywords.
(api/list-tags) ;; => #{:sphere :box :base}Export a shape to STL (binary) or STEP (AP203) from the REPL:
;; STL export (binary, default max-deviation 0.02)
(kernel/export-stl shape "output.stl")
;; Finer STL resolution
(kernel/export-stl shape "output.stl" {:max-deviation 0.01})
;; STEP export (AP203 format)
(kernel/export-step shape "output.step")An Export button (download arrow icon) in the viewer toolbar provides one-click STL / STEP export of the currently displayed model. The filename is auto-generated from the model name.
Export resolution is adjustable from the browser console:
setExportQuality(0.01) ;; finer mesh = rounder shapes, larger files
setExportQuality(0.02) ;; default
setExportQuality(0.1) ;; coarser mesh, smaller filesThe value is the max-deviation parameter passed to
BRepMesh_IncrementalMesh (lower = finer).
The default color for rendered shapes can be set three ways:
{:default-shape-color 0x00ff00}Edit the file and hard refresh the browser (Ctrl+Shift+R).
(ClojCAD.viewport.config/set-default-shape-color! 0xff0000)setShapeColor(0xff0000) // hex integer
setShapeColor("#ff0000") // CSS hex stringNOTE: Changing the default color only affects shapes built after the change. Already-rendered shapes keep their original color.
Manual lifecycle management for OpenCascade objects.
(kernel/destroy some-shape)
(kernel/destroy-all) ;; frees every tracked objectnpm installA Justfile is also provided — run just install instead to perform all
install steps (including the patch script and WASM/CSS copies) in one
command.
npm run devOr with the Justfile:
just dev # default port 8777
just dev 9999 # custom nREPL portThis starts:
- An nREPL server on **port 8777** (override via
NREPL_PORTenv var orjust dev <port>) - An HTTP dev server on **port 8700** serving
public/
Navigate to http://localhost:8700.
This loads the compiled CLJS application, which:
- Calls
ClojCAD.core/init - Initializes the three-cad-viewer Display/Viewer
- Loads the OpenCascade.js WASM module
- Runs the demo
The JavaScript runtime (including the OCCT kernel) is now live.
- Run the
Calva: Connect to a Running REPL in the Projectcommand - Select
shadow-cljs - Host:
localhost, Port:8777 - Select the
:devbuild when prompted
Alternatively, connect directly to the REPL port printed in the shadow-cljs output.
npx shadow-cljs cljs-repl :devThis starts an interactive ClojureScript REPL in the terminal connected to the running browser tab.
M-x cider-connect-clj- Host:
localhost - Port:
8777
CIDER will connect to the shadow-cljs nREPL.
In the *cider-repl* buffer, switch to the ClojureScript evaluation
target:
(shadow.cljs.devtools.api/nrepl-select :dev)Now you can evaluate ClojureScript forms against the running browser tab. For example:
(require '[ClojCAD.kernel.api :as kernel])
(require '[ClojCAD.scene.manager :as sm])
(require '[ClojCAD.scene.api :as api])
(require '[ClojCAD.demo :as demo])
(kernel/make-sphere 5)
(sm/show demo/sphere {:r 20})
;; Query and bulk visibility
(api/list-objects)
(api/list-tags)
(sm/show-all)
(sm/hide-model {:tag :sphere})The viewer in the browser will update in real time.
Every public function and macro has a docstring. Access it with doc:
(require '[cljs.repl :refer-macros [doc find-doc apropos]])
;; Find all functions matching a pattern:
(apropos tessellate)
;; Switch to a namespace, then doc any local symbol:
(in-ns 'ClojCAD.kernel.api)
(doc fuse)
(doc make-sphere)
;; Or fully qualify from anywhere:
(doc ClojCAD.kernel.primitives/make-sphere)
(doc ClojCAD.scene.manager/toggle-model)
(doc ClojCAD.scene.manager/show)
(doc ClojCAD.kernel.booleans/cut)
(doc ClojCAD.model.defmodel/defmodel)Since all-ns and ns-publics are not available in ClojureScript’s
browser runtime, use JavaScript interop in three steps:
- List top-level namespace groups:
(js/Object.keys js/ClojCAD) ;; => #js ["model" "kernel" "viewport" "scene" "demo" "core"]
- Drill into a group to find its sub-namespaces:
(js/Object.keys (js* "ClojCAD.kernel")) ;; => #js ["api" "booleans" "export" "font" "import" "init" ;; "lifecycle" "mesh" "primitives" "text3d"]
- List all public functions within a namespace:
(sort (js/Object.keys (js* "ClojCAD.kernel.api")))
Then pass any symbol to doc to read its documentation.
If npm run dev fails with:
Closure compilation failed with 3 errors
--- node_modules/opencascade_DOT_js/dist/cascadestudio.js
This code cannot be transpiled. import.meta.
The postinstall script that patches cascadestudio.js likely didn’t run.
This happens when node_modules is restored from a cache
or copied from another checkout without running npm install.
Run the following commands manually:
node scripts/patch-opencascade.js
cp node_modules/opencascade.js/dist/cascadestudio.wasm public/
cp node_modules/three-cad-viewer/dist/three-cad-viewer.css public/Then clean the shadow-cljs cache and rebuild:
rm -rf .shadow-cljs
npm run devThe application is organized into several namespaces:
ClojCAD.core- Entry point. Initializes the viewer and geometry kernel, then starts the demo.
ClojCAD.kernel.init- Loads the OpenCascade.js WASM module and exposes the OCCT instance.
ClojCAD.kernel.api- Public API surface — re-exports primitives, booleans, tessellation, lifecycle, and transform functions.
ClojCAD.kernel.primitives- Wraps OCCT
BRepPrimAPIconstructors for primitive shapes. Also exposestranslateviagp_Trsf. ClojCAD.kernel.booleans- Wraps OCCT
OCJS.BooleanFuse,BooleanCommon,BooleanCuthelpers for CSG operations on shapes. ClojCAD.kernel.mesh- Tessellates B-Rep shapes into vertex/normal/index/edge buffers for rendering.
ClojCAD.kernel.lifecycle- Tracks OpenCascade heap-allocated objects so they can be freed.
ClojCAD.model.defmodel- Macro that defines a parametric model with reactive caching.
ClojCAD.model.core- Creates a reactive model wrapper with caching and tag support.
ClojCAD.model.registry- Global registry of all defined models.
ClojCAD.model.tag- Dynamic-binding-based sub-shape tagging within model bodies.
ClojCAD.scene.manager- Manages the scene: watches the
paramsatom, drives tessellation and viewer updates. Provides overloadedshow-model=/=hide-modelwith filter-map dispatch,toggle-model,show-all=/=hide-all. ClojCAD.scene.api- Query API for the scene:
list-objects(with optional:tag,:visibility,:name-matchingfilters) andlist-tags. Forwards visibility functions from manager. ClojCAD.viewport.viewer- Wraps the three-cad-viewer Display and Viewer classes.
ClojCAD.viewport.shape-adapter- Converts tessellation data into the viewer’s shape format.
ClojCAD.viewport.config- Default shape color atom, REPL API
(
set-default-shape-color!), browser console(setShapeColor), and EDN config file loader. ClojCAD.viewport.export-ui- Export dropdown button mounted in the
toolbar. Exposes
setExportQuality(deviation)andsetViewerTheme("light"|"dark")onwindowfor the browser console. ClojCAD.kernel.export- Wraps STL and STEP export via
OCCT
StlAPI_Writer(binary STL written directly from mesh data) andSTEPControl_Writer_1(AP203 STEP via OCCT WASM).
Data flow:
- Model definitions register themselves in the model registry.
- The
paramsatom holds current parameter values for every model. - When
paramschanges (via =reset! = swap! =), a watch fires and recomputes only the models whose parameter keys changed. - Each recomputed model is tessellated and pushed to the three-cad-viewer scene.
Run the full test suite (Node.js based, no browser needed):
npm testThis compiles and runs all ClojureScript tests via shadow-cljs’s
:target :node-script build.
Tests live in test/ mirroring the src/ structure. Each source
file has a corresponding test file:
| Source file | Test file |
|---|---|
src/ClojCAD/kernel/lifecycle.cljs | test/ClojCAD/kernel/lifecycle_test.cljs |
src/ClojCAD/kernel/font.cljs | test/ClojCAD/kernel/font_test.cljs |
src/ClojCAD/model/registry.cljs | test/ClojCAD/model/registry_test.cljs |
src/ClojCAD/model/tag.cljs | test/ClojCAD/model/tag_test.cljs |
src/ClojCAD/model/core.cljs | test/ClojCAD/model/core_test.cljs |
src/ClojCAD/scene/manager.cljs | test/ClojCAD/scene/manager_test.cljs |
src/ClojCAD/scene/api.cljs | test/ClojCAD/scene/api_test.cljs |
src/ClojCAD/viewport/shape-adapter.cljs | test/ClojCAD/viewport/shape_adapter_test.cljs |
src/ClojCAD/viewport/config.cljs | test/ClojCAD/viewport/config_test.cljs |
Tests for the kernel layer (primitives, booleans, mesh, export, import) require the OpenCascade.js WASM module. These tests are defined in:
test/ClojCAD/kernel/init_test.cljstest/ClojCAD/kernel/primitives_test.cljstest/ClojCAD/kernel/booleans_test.cljstest/ClojCAD/kernel/mesh_test.cljstest/ClojCAD/kernel/text3d_test.cljs
Currently, these tests must be run from a browser-connected REPL
rather than via npm test, because the WASM module’s file
resolution differs between Node.js and the browser. To run them:
- Start the dev server:
npm run dev - Connect a REPL (see REPL Workflow above)
- Evaluate:
(require '[cljs.test :refer [run-tests]])
(run-tests 'ClojCAD.kernel.init-test)
(run-tests 'ClojCAD.kernel.primitives-test)
;; etc.Tests use cljs.test (bundled with ClojureScript).
(ns ClojCAD.my-ns-test
(:require [cljs.test :refer [deftest is]]))
(deftest my-test-name
(is (= 1 1)))After creating a new test file, add its namespace to
test/clojcad/runner.cljs so it runs with npm test.
