Skip to content

Repository files navigation

ClojCAD

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.

DSL Reference

defmodel

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))

Primitives

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 depth

Fonts

The 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 nil on failure). After this call returns, the font is immediately available for text3d with 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. If text3d is called before the promise resolves, it returns nil.
    (-> (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.

Boolean Operations

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) ∩ c

Examples 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)))

Transforms

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 degrees

tessellate

Convert 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 deviation

show

Add 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 options

hide-model / show-model

Toggle 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*"})

toggle-model

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-all / hide-all

Show or hide every model in the scene.

(sm/show-all)
(sm/hide-all)

set-opacity

Change a model’s opacity after it has been added to the scene.

(sm/set-opacity 'my-box 0.5)

remove-model

Remove a model from the scene entirely.

(sm/remove-model 'my-box)

tag

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}}))

add-tags / remove-tags

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).

list-objects

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*"})

list-tags

Return all unique tag labels across all models as a set of keywords.

(api/list-tags)   ;; => #{:sphere :box :base}

Export

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")

Export via UI

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 files

The value is the max-deviation parameter passed to BRepMesh_IncrementalMesh (lower = finer).

Default Shape Color

The default color for rendered shapes can be set three ways:

Persistent — public/config.edn

{:default-shape-color 0x00ff00}

Edit the file and hard refresh the browser (Ctrl+Shift+R).

REPL — set-default-shape-color!

(ClojCAD.viewport.config/set-default-shape-color! 0xff0000)

Browser console — setShapeColor

setShapeColor(0xff0000)       // hex integer
setShapeColor("#ff0000")      // CSS hex string

NOTE: Changing the default color only affects shapes built after the change. Already-rendered shapes keep their original color.

track / destroy / destroy-all

Manual lifecycle management for OpenCascade objects.

(kernel/destroy some-shape)
(kernel/destroy-all)   ;; frees every tracked object

REPL Workflow

1. Install dependencies

npm install

A Justfile is also provided — run just install instead to perform all install steps (including the patch script and WASM/CSS copies) in one command.

2. Start the shadow-cljs dev server

npm run dev

Or with the Justfile:

just dev          # default port 8777
just dev 9999     # custom nREPL port

This starts:

  • An nREPL server on **port 8777** (override via NREPL_PORT env var or just dev <port>)
  • An HTTP dev server on **port 8700** serving public/

3. Open the page in a browser

Navigate to http://localhost:8700.

This loads the compiled CLJS application, which:

  1. Calls ClojCAD.core/init
  2. Initializes the three-cad-viewer Display/Viewer
  3. Loads the OpenCascade.js WASM module
  4. Runs the demo

The JavaScript runtime (including the OCCT kernel) is now live.

4. Connect a REPL

From VS Code (Calva)

  1. Run the Calva: Connect to a Running REPL in the Project command
  2. Select shadow-cljs
  3. Host: localhost, Port: 8777
  4. Select the :dev build when prompted

Alternatively, connect directly to the REPL port printed in the shadow-cljs output.

From the terminal

npx shadow-cljs cljs-repl :dev

This starts an interactive ClojureScript REPL in the terminal connected to the running browser tab.

From Emacs with CIDER

  1. M-x cider-connect-clj
  2. Host: localhost
  3. Port: 8777

CIDER will connect to the shadow-cljs nREPL.

5. Switch to the CLJS REPL

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.

6. Browse documentation at the REPL

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)

Exploring available namespaces and functions

Since all-ns and ns-publics are not available in ClojureScript’s browser runtime, use JavaScript interop in three steps:

  1. List top-level namespace groups:
    (js/Object.keys js/ClojCAD)
    ;; => #js ["model" "kernel" "viewport" "scene" "demo" "core"]
        
  2. 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"]
        
  3. 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.

Troubleshooting

Closure compilation fails with “import.meta” errors

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 dev

Architecture

The 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 BRepPrimAPI constructors for primitive shapes. Also exposes translate via gp_Trsf.
ClojCAD.kernel.booleans
Wraps OCCT OCJS.BooleanFuse, BooleanCommon, BooleanCut helpers 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 params atom, drives tessellation and viewer updates. Provides overloaded show-model=/=hide-model with filter-map dispatch, toggle-model, show-all=/=hide-all.
ClojCAD.scene.api
Query API for the scene: list-objects (with optional :tag, :visibility, :name-matching filters) and list-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) and setViewerTheme("light"|"dark") on window for the browser console.
ClojCAD.kernel.export
Wraps STL and STEP export via OCCT StlAPI_Writer (binary STL written directly from mesh data) and STEPControl_Writer_1 (AP203 STEP via OCCT WASM).

Data flow:

  1. Model definitions register themselves in the model registry.
  2. The params atom holds current parameter values for every model.
  3. When params changes (via =reset! = swap! =), a watch fires and recomputes only the models whose parameter keys changed.
  4. Each recomputed model is tessellated and pushed to the three-cad-viewer scene.

Testing

Running Tests

Run the full test suite (Node.js based, no browser needed):

npm test

This compiles and runs all ClojureScript tests via shadow-cljs’s :target :node-script build.

Test Organization

Tests live in test/ mirroring the src/ structure. Each source file has a corresponding test file:

Source fileTest file
src/ClojCAD/kernel/lifecycle.cljstest/ClojCAD/kernel/lifecycle_test.cljs
src/ClojCAD/kernel/font.cljstest/ClojCAD/kernel/font_test.cljs
src/ClojCAD/model/registry.cljstest/ClojCAD/model/registry_test.cljs
src/ClojCAD/model/tag.cljstest/ClojCAD/model/tag_test.cljs
src/ClojCAD/model/core.cljstest/ClojCAD/model/core_test.cljs
src/ClojCAD/scene/manager.cljstest/ClojCAD/scene/manager_test.cljs
src/ClojCAD/scene/api.cljstest/ClojCAD/scene/api_test.cljs
src/ClojCAD/viewport/shape-adapter.cljstest/ClojCAD/viewport/shape_adapter_test.cljs
src/ClojCAD/viewport/config.cljstest/ClojCAD/viewport/config_test.cljs

WASM-dependent Tests

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.cljs
  • test/ClojCAD/kernel/primitives_test.cljs
  • test/ClojCAD/kernel/booleans_test.cljs
  • test/ClojCAD/kernel/mesh_test.cljs
  • test/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:

  1. Start the dev server: npm run dev
  2. Connect a REPL (see REPL Workflow above)
  3. Evaluate:
(require '[cljs.test :refer [run-tests]])
(run-tests 'ClojCAD.kernel.init-test)
(run-tests 'ClojCAD.kernel.primitives-test)
;; etc.

Writing New Tests

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.

About

A parameteric CAD writen in a Clojure DSL

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages