Plexus is a simple tool for defining cross sections and extruding them in a piece-wise fashion using egocentric reference frames.
You can specify 3D models by providing a series of extrusions to a set of cross sections, which are then composited using CSG operations.
This library is built on top of clj-manifold3d, a traditional CSG library in the style of OpenSCAD.
A beginner friendly introduction to developing with Plexus is available here.

org.clojars.cartesiantheatrics/plexus {:mvn/version "2.0.0"}
On the JVM, also add the native Manifold 2.2.0 artifact for your platform
(for Linux x86_64, org.clojars.cartesiantheatrics/manifold3d$linux-x86_64).
See the release notes for the rebuild evaluation change in 2.0.0.
This library uses java bindings to the native library Manifold. The platform specific dependency needs to be included separately from plexus.
The modeling API, schemas, transforms, and geometry helpers now live in .cljc.
Operations such as frame, forward, branch, loft, and result are ordinary
functions returning validated form maps. Existing call syntax, nested sequences,
for, threading, and CSG result trees work on both platforms. Operations can also
be passed to map or apply, and accept an options map:
(p/extrude
(p/frame {:name :body :cross-section (m/square 10 10 true)})
(map p/forward [{:length 2} {:length 3}]))
The published clj-manifold3d 1.2.0 dependency supports both JVM and JavaScript
and includes matching WASM assets. No sibling checkout is required. Copy the
classpath resources clj_manifold3d/wasm/manifold.js and manifold.wasm to your
application's public asset directory. For this checkout's Node tests:
clojure -M:cljs scripts/prepare-cljs.clj
Initialize the WASM backend once before modeling. In a browser, load
the copied manifold.js, serve its matching manifold.wasm, and:
(require '[clj-manifold3d.core :as m] '[plexus.core :as p])
(-> (m/init! {:wasm-url "/wasm/manifold.wasm"})
(.then (fn [_]
(p/extrude
(p/frame :name :body :cross-section (m/square 10 10))
(p/forward :length 5)))))
Node hosts can pass an Emscripten :factory and :wasm-binary to m/init!;
see test/cljs/plexus/test_runner.cljs. Modeling is synchronous after initialization.
JS geometry owns WASM resources: use m/dispose! when finished, or
m/with-disposal around a synchronous computation that returns only copied data.
Use p/get-model to pass an extrusion's geometry to the JS Manifold API.
export delegates to the backend; formats, return values, and asynchronous I/O
follow that backend. export-models (namespace scanning and JVM filesystem I/O)
is JVM-only. The optional defmodel convenience macro is JVM-only; portable
code uses (def name (p/extrude ...)).
SCI support is opt-in. Add org.babashka/sci (tested with 0.15.58) and compile
plexus.sci into your host. The adapter exposes the function API and a
source-aware macro DSL on both JVM and JavaScript:
(require '[sci.core :as sci]
'[plexus.sci :as ps]
'[clj-manifold3d.core :as m])
;; Initialize WASM first in a JS host. Expose the geometry functions you need.
(def ctx
(sci/init
(assoc-in ps/config [:namespaces 'clj-manifold3d.core]
{'square m/square})))
(sci/eval-string* ctx
"(require '[plexus.core :as p] '[clj-manifold3d.core :as m])
(p/extrude
(p/frame :name :body :cross-section (m/square 10 10))
(for [i (range 3)] (p/forward :length 2)))")
Interpreted models can use plexus.source for automatic source locations. Use
ordinary require inside SCI, including in JS hosts; :require-macros is only
needed for compiled ClojureScript. Bind the interpreted filename on the host:
(sci/binding [sci/file "model.clj"]
(sci/eval-string* ctx
"(require '[plexus.core :as p] '[plexus.source :as ps]
'[clj-manifold3d.core :as m])
(p/extrude
(ps/frame :name :body :cross-section (m/square 10 10))
(ps/forward :length 5))"))
The adapter registers SCI macro vars backed by portable expansion functions.
Their &form provides line/column and the original expression; sci/file supplies
the filename. These macros evaluate arguments once and support the same map
options, nesting, result expressions, and explicit deferred rebuilding as the
compiled plexus.source macros. The at wrapper is also available in SCI.
plexus.core remains a macro-free API in the same context.
This is a compiled host integration using SCI's namespace copying API, not a promise that all implementation source can be loaded directly into a bare SCI/nbb environment. Normal Plexus use does not require SCI.
Function arguments are evaluated once at form construction. Previously the
operation macros evaluated them during validation and again during execution,
and captured implicit thunks for rebuild. To rebuild with fresh values, defer
those forms explicitly. This works with dynamic bindings, atoms, and SCI:
(def length (atom 5))
(def model
(p/extrude
(p/frame :name :body :cross-section (m/square 2 2))
(p/deferred #(p/forward :length @length))))
(reset! length 10)
(p/rebuild model)
A deferred function may return a single form, nested sequences, or nil. Wrap an
entire for in deferred to regenerate its sequence on every build. Ordinary
forms retain their captured values. This explicit thunk is the one syntax change
needed for implicit re-evaluation: ordinary functions cannot capture unevaluated
arguments. Applications that invoked the internal callable Form objects should
instead treat forms as data; the internal Form type and defop macro are removed.
Malli validates options when each operation is constructed, including inside SCI.
Failures carry the operation and form in ex-data. This preserves the runtime
checks the old macros emitted; those macros did not statically validate values.
Exported clj-kondo hooks
add editor/CI checks without changing the runtime API. They catch missing required
options, missing keyword values, conflicting forward axes, and invalid literal
numbers, names, normals, step counts, and enum values. Import the library's config
with clj-kondo's normal dependency scan (clj-kondo --lint "$(clojure -Spath)" --dependencies --copy-configs).
For this checkout, the hook directory can be used directly:
clj-kondo --lint your-model.clj \
--config-dir resources/clj-kondo.exports/org.clojars.cartesiantheatrics/plexus
The hooks leave ordinary symbol analysis intact and do not execute model code. Computed options maps and nonliteral values still need runtime validation; the hooks are not a general type checker. Open-ended defaults and metadata remain supported.
The macro-free plexus.core functions cannot automatically capture their caller's
source syntax. Errors raised while applying DSL operations or evaluating result
expressions now include the offending form in :form, the operation in :key /
:op, and the original exception as their cause.
For automatic file/line/column and the original source expression, use the
optional plexus.source operation macros. They delegate to the core functions
and evaluate arguments once; they do not restore implicit re-evaluation:
;; JVM
(require '[plexus.core :as p] '[plexus.source :as ps])
(p/extrude
(ps/frame :name :body)
(ps/left :angle 1)) ; Missing curve radius: error points to this exact call.
In ClojureScript, require the macros explicitly:
(ns my.model
(:require [plexus.core :as p])
(:require-macros [plexus.source :as ps]))
Existing unqualified operation calls can retain their syntax by referring those
operations from plexus.source, while referring extrude, get-model, etc.
from plexus.core. Alternatively, wrap selected expressions with
(ps/at (p/left :angle 1)).
The error's ex-data includes :file, :line, :column, and :source-form.
Locations survive nested branches, result trees, deferred forms, and rebuilding.
Editors, generated code, and SCI hosts can supply the same information without
macros:
(p/with-source {:file "model.clj" :line 12 :column 3}
#(p/left :angle 1))
with-source calls its builder immediately. Use p/deferred inside it when
re-evaluation is intended. SCI supports both this explicit form and the automatic
plexus.source macro DSL when configured through plexus.sci/config.
clojure -M:dev:test # Published JVM dependencies
clojure -M:dev:test:sci # Also test interpreted models
clojure -M:local-dev:test:sci # Sibling bindings and local native jar
clojure -M:cljs scripts/prepare-cljs.clj
clojure -M:cljs -m shadow.cljs.devtools.cli release test
node target/cljs-tests.js # Real WASM geometry and SCI, advanced compilation
python3 scripts/test-lint.py # Requires clj-kondo on PATH
The JS runner loads target/wasm/manifold.js and the matching WASM binary
extracted from the resolved dependency by scripts/prepare-cljs.clj. Shared tests cover CSG, branches, all four curves, gaps, loft sampling,
hulls, insertion, targeting, points, transforms, validation, and deferred rebuilding.
The existing JVM tests, including the forward sampling regressions, remain enabled.
The scad-etc compatibility audit compares against the pre-migration behavior, including known pre-existing failures.
A Simple rapidly printable hydroponic tower: https://github.com/SovereignShop/spiralized-hydroponic-tower
Kossel delta printer: https://github.com/SovereignShop/kossel-printer/
In the following example, our outer cross section is a circle with radius of 6. The mask cross section is a circle of radius 4. We then specify a series of egocentric transformations to the outer and inner cross sections.
(require
'[clj-manifold3d.core :as m]
'[plexus.core
:refer [result frame left right forward up down hull extrude set branch
rotate translate difference union intersection points export insert
loft trim-by-plane offset]])
(-> (extrude
(result :name :pipes
:expr (difference :body :mask))
(frame :cross-section (m/circle 6) :name :body)
(frame :cross-section (m/circle 4) :name :mask)
(set :curve-radius 20 :to [:body]) (set :curve-radius 20 :to [:mask])
(left :angle (/ Math/PI 2) :to [:body])
(left :angle (/ Math/PI 2) :to [:mask])
(right :angle (/ Math/PI 2) :to [:body])
(right :angle (/ Math/PI 2) :to [:mask])
(forward :length 10 :to [:body])
(forward :length 10 :to [:mask])
(up :angle (/ Math/PI 2) :to [:body])
(up :angle (/ Math/PI 2) :to [:mask]))
(export "test.glb"))
Obviously there is a lot of code duplication here. After providing the cross section for the inner and outer forms, the transformations we apply to each are equivalent. We can get rid of that duplication by only providing one transforming both cross sections with each segment:
(-> (extrude
(result :name :pipes
:expr (difference :body :mask))
(frame :cross-section (m/circle 6) :name :body)
(frame :cross-section (m/circle 4) :name :mask)
(set :curve-radius 20 :to [:body :mask])
(left :angle (/ Math/PI 2) :to [:body :mask])
(right :angle (/ Math/PI 2) :to [:body :mask])
(forward :length 10 :to [:body :mask])
(up :angle (/ Math/PI 2) :to [:body :mask]))
(export "pipes.glb"))

This is equivalent to the one above, but we can still see there is a lot of duplication. The :to [:outer :inner] is repeated in each segment.
We can elide this, as by default each segment will reply to every frame you have defined:
(-> (extrude
(result :name :pipes
:expr (difference :body :mask))
(frame :cross-section (m/circle 6) :name :body)
(frame :cross-section (m/circle 4) :name :mask)
(set :curve-radius 20)
(left :angle (/ Math/PI 2))
(right :angle (/ Math/PI 2))
(forward :length 10)
(up :angle (/ Math/PI 2)))
(export "pipes.glb"))
This extrude is equivalent to the one above.
Hulls are often a great way to transform between cross sections. You can wrap any sequence of extrusions in a hull form to make a convex hull out of those segments:
(-> (extrude
(result :name :pipes
:expr (difference :body :mask))
(frame :cross-section (m/circle 6) :name :body)
(frame :cross-section (m/circle 4) :name :mask)
(set :curve-radius 20)
(hull
(hull
(forward :length 20)
(set :cross-section (m/square 20 20 true) :to [:body])
(set :cross-section (m/square 16 16 true) :to [:mask])
(forward :length 20))
(set :cross-section (m/circle 6) :to [:body])
(set :cross-section (m/circle 4) :to [:mask])
(forward :length 20)))
(export "hull.glb"))

You can loft between a sequence of cross-sections with loft. Edges are constructed between corresponding vertices of each cross-section.
(-> (extrude
(result :name :pipes :expr :body)
(frame :cross-section (m/difference (m/circle 20) (m/circle 18)) :name :body)
(loft
(forward :length 1)
(for [i (range 3)]
[(translate :x 8)
(forward :length 20)
(translate :x -8)
(forward :length 20)])))
(export "loft.glb"))

Lofted sections don't need to be isomorphic.
(-> (extrude
(result :name :pipes :expr :body)
(frame :cross-section (m/difference (m/circle 20) (m/circle 18)) :name :body)
(loft
(forward :length 1)
(for [i (range 3)]
[(translate :x 8)
(set :cross-section (m/difference (m/square 30 30 true) (m/square 26 26 true)))
(forward :length 20)
(translate :x -8)
(set :cross-section (m/difference (m/circle 20) (m/circle 18)))
(forward :length 20)])))
(export "monomorphic-loft.glb" (m/material :color [0 0.7 0.7 1.0] :metalness 0.2)))

Branches work as you'd expect.
(def pi|2 (/ Math/PI 2))
(-> (extrude
(result :name :pipes
:expr (difference :body :mask))
(frame :cross-section (m/circle 6) :name :body)
(frame :cross-section (m/circle 4) :name :mask)
(set :curve-radius 10)
(branch :from :body (left :angle pi|2) (right :angle pi|2) (forward :length 20))
(branch :from :body (right :angle pi|2) (left :angle pi|2) (forward :length 20)))
(export "branch.glb"))

The body of the branch is just another extrude. The required ":from" property determines the starting coordinate frame of the branch. There's also an optional :with parameter that specifies which frames to include in the branch.
(-> (extrude
(result :name :pipes
:expr (difference :body :mask))
(frame :cross-section (m/circle 6) :name :body)
(frame :cross-section (m/circle 4) :name :mask)
(set :curve-radius 10)
(branch :from :body (left :angle pi|2) (right :angle pi|2) (forward :length 20))
(branch
:from :body
:with [:body]
(right :angle pi|2)
(left :angle pi|2)
(forward :length 20)))
(export "branch-with.glb" (m/material :color [0. 0.7 0.7 1.0] :metalness 0.2)))

Note that you can introduce new frames at any point. The starting transform of any new frame is inherited from the previously introduced frame.
You can make any segment a gap with the :gap parameter:
(-> (extrude
(frame :cross-section (m/circle 6) :name :body :curve-radius 10)
(for [i (range 3)]
[(left :angle (/ Math/PI 2) :gap true)
(right :angle (/ Math/PI 2))]))
(export "gaps.glb"))

You can also specify which subset of active frames should be a gap by supplying a vector frame names.
(-> (extrude
(frame :cross-section (m/circle 6) :name :body :curve-radius 10)
(for [i (range 3)]
[(left :angle (/ Math/PI 2) :gap [:body])
(right :angle (/ Math/PI 2))]))
(export "gaps.glb"))
This is equivalent to above. :gap true is equivalent to gapping all active frames.
The easiest way to compose extrusions is with insert.
(let [pipe (extrude
(frame :cross-section (m/circle 6) :name :outer :curve-radius 10)
(frame :cross-section (m/circle 4) :name :inner)
(forward :length 30))]
(-> (extrude
(result :name :pipes
:expr (->> (difference :pipe/outer :pipe/inner)
(trim-by-plane :normal [-1 0 0])
(translate :z 30)))
(frame :name :origin)
(for [i (range 4)]
(branch
:from :origin
(rotate :x (* i 1/2 Math/PI))
(insert :extrusion pipe
:models [:outer :inner]
:ns :pipe
:end-frame :outer))))
(export "insert.glb" (m/material :color [0 0.7 0.7 1.0] :metalness 0.2))))

Here we're inserting the models :outer and :inner at four different locations. We're also namespacing the inserted models with :pipe. :end-frame specifies the frame to continue on in the next segment.
You can "move" without extruding using translate.
(-> (extrude
(result :name :pipes
:expr (difference :body :mask))
(frame :name :body :cross-section (m/circle 6))
(frame :name :mask :cross-section (m/circle 4))
(forward :length 10)
(translate :x 5 :z 10)
(forward :length 10))
(export "translate.glb" (m/material :color [0 0.7 0.7 1.0] :metalness 0.2)))

You can rotate in place without extruding using rotate.
(-> (extrude
(result :name :pipes
:expr (difference :body :mask))
(frame :name :body :cross-section (m/circle 6))
(frame :name :mask :cross-section (m/circle 4))
(forward :length 15)
(rotate :x (/ Math/PI 2))
(forward :length 15))
(export "rotate.glb" (m/material :color [0 0.7 0.7 1.0] :metalness 0.2)))

Result expressions have been demonstrated in every example so far. As you can see, they represent an arbitrarily nested CSG expression. Result expressions can reference other results by name. the set of operations that can be appear in result expressions are: union, difference, intersection, hull, translate, rotate, mirror, and trim-by-plane.
points is similar to extrude except you use it to define 2D polygons. Here's an example of how to define a circle.
(-> (m/cross-section
(points
:axes [:x :z]
(frame :name :origin)
(translate :x 50)
(left :angle (* 2 Math/PI) :curve-radius 50 :cs 20)))
(m/extrude 1)
(export "circle.glb"))

You can access extrusion models using get-model.
(m/difference (get-model extrusion :body)
(get-model extrusion :mask))
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |