A 3D sculpting desktop application in Rust, rendering with WebGPU and driving the ClayCore SDF + voxel engine through its C ABI. macOS and Linux.
Sculpt on a signed distance field, on a sparse voxel grid, on a fixed-topology mesh, or on a subdivision hierarchy where a wrinkle cut at the finest level rides on a jaw moved at the coarsest; freeze regions with a mask; rig with ZSpheres; place primitives as separate subtools and resolve two of them into a third with a boolean; import and export meshes; save, reopen, and recover the work a crash took. The interface ships in English, Brazilian Portuguese and Spanish.
Status. The specification lives in openspec/ and its tasks files are the
authority on what is done. openspec list reports each change still open with
its ticked and total tasks; a change whose work has landed is archived under
openspec/changes/archive/, which is where add-clayspace-desktop — the
original five milestones — now stands. This page no longer quotes those
counts: it quoted thirty-three changes and one open task for a week in which
both had moved, and the command cannot go stale.
docs/roadmap.md carries the milestone table, the work in
flight, what the engine currently gets wrong, and what the pinned engine
already offers that this application has not taken up.
The engine pins now stand at ClayCore v0.120.1 and CyberRemesher v0.10.0. ClayCore narrows operator and undo bounds, repairs smooth-seam invalidation, and makes voxel-layer edits undoable. CyberRemesher changes its shared C ABI to 2.1; the bake wrapper supplies the required struct size and checks the default initializer before using it. Its new mesh maps and retopology operations remain engine capabilities until this application exposes them.
The next two paragraphs describe the earlier v0.120.0 pin.
A mesh grab now reaches the whole drag, and it does not change a mark made here: the change is in the stroke consumers, and this application sends Grab as one stamp at the anchor carrying the whole gesture, for the reason those call sites spell out — a resolved stroke walks the brush centre along the path, so a drag that leaves the surface reaches no material at all.
A voxel drag's falloff now means the curve it is named after, and that one does. The grid's grab had been passing the falloff where a curve index was expected, so the hard edge this application asks for arrived as a linear taper and the drag tapered by accident. A falloff is not a coverage control for a drag — the grab is an inverse map, so the weight decides the pull, and a hard one shoves the whole ball rather than drawing a bulge out of it. Measured on the pin move, the rim rose 5 cells against the centre's 6 where it had risen 1 against 4. The drag now asks for the taper by name, and the mark is the one that shipped.
The two paragraphs that follow belong to the v0.113.0 move and are kept because they are the largest thing any pin has bought here. v0.120.0 carries no measurement of its own on this machine beyond the drag above: its four performance changes are each measured against their own merge base upstream, they do not multiply, and none of them is a device number.
What that move gave a sculptor arrived before any of this repository's code did. Picking on a worked form is roughly halved — 39.30 ms to 19.35 ms for 256 raycasts at seven Move dabs — and the declared Lipschitz bound is about 1.5x less pessimistic, because the engine stopped over-declaring what a deformed brick can reach.
What it let this repository delete is the more interesting half. The whole field was re-meshed on every stroke release, purely to hide sliver triangles the brick mesher emitted; the engine stopped emitting them, so that went — and with it a second re-mesh nobody had counted, because a whole-document mesh cannot be patched incrementally and forced the next edit to rebuild every brick anyway. The field was being meshed twice per stroke. What the upgrade deliberately does not take up is in docs/roadmap.md.
The largest thing the v0.78.0 pin brought is a fourth way of holding a surface. A subdivision hierarchy — a cage, levels over it, and detail stored per level in a frame carried up from the level below — stands beside the field, the grid and the carried mesh: a wrinkle cut at level 4 rides on a jaw moved at level 1 instead of being smeared, which is the one thing none of the other three can express. It arrives through a cage rather than from nothing, it carries a stack of named passes under its layer row, and its sculpt is written in a file beside the document, because the engine's format carries the cage and not what stands on it.
Two smaller things had been waiting on the engine and are now taken up. A live Suavizar composes with the rest of the scene instead of refusing to open where a second field subtool is visible (#378), and a whole subtool stretches per axis the way a placed object already did (#373). Two more arrive without anything here asking: an intersecting object is now bounded by the layer it cuts rather than dirtying the whole cache (#319), and the memory report says which part of a document a byte belongs to. docs/roadmap.md carries what the upgrade left standing.
| Tests | About 2,700 #[test] functions, all headless — four ignored timing or measuring aids. A macOS visual tripwire keeps the known long-session speck defect visible. cargo test --workspace prints the exact count |
| Visual captures | Some 640 PNGs written to target/visual/ for looking at — not golden images; the visual tests assert properties, because a pixel-exact golden fails on every driver |
| Dab latency | 2.1 ms median, 4.2 ms p95 on the reference scene · budget 50 / 100 |
| Startup to first document | 11.4 ms |
| Engine | ClayCore 0.120.1, pinned to the release tag as a submodule |
| Sculpting tools | 21 across five representations · 15 SDF, 13 voxel, 17 mesh, 16 on a subdivision hierarchy, 16 on an adaptive surface |
| Languages | English, Português do Brasil, Español latinoamericano |
The timing figures are benchmarks/archive/linux-x86_64-cuda-engine-0.52.2.json:
Linux x86_64 on CUDA, recorded against ClayCore 0.52.2 on a quiet workstation —
0.13 load per core, stamped in the file beside the rest of the conditions. It
was the Linux gate's baseline until #189 moved both gates onto baselines the CI
runners recorded, and it is kept because it is still the quietest whole run
anyone has taken. The nine subtool.*
figures were added to that file from a later whole run on the same machine, at
0.08 load per core, rather than by re-recording it: a re-recording moves the
value every future run is judged against, and the comparison that run made
against the baseline reported no regression in any of the other 125 figures. So
the rest of the file still holds the drift history it had.
Three figures have been spliced in the same way since, from a whole run at 0.07
load per core: subtool.activate.mesh.mean and .p95, which went from 159.7
and 169.6 ms to a lookup when the document started holding a mesh sculptor per
subtool rather than one; and convert.mesh_to_voxel.ms, which the file used to
carry as a skip — "the source layer states no bounds to convert within" —
because a mesh layer answered no bounds and every crossing out of one was
refused as unbounded. It runs, so it is measured rather than excused. The
comparison that run made found nothing else moved by more than 1.19x, inside
the 1.5 tolerance.
Performance is gated on both platforms. CI's Performance job runs the whole
suite on macos-14 and on ubuntu-24.04 and compares each against
benchmarks/baseline-macos-aarch64.json or benchmarks/baseline-linux-x86_64.json,
both recorded on those runner images at the current pin by the dispatchable
Record a baseline job; each file's conditions.machine names the processor,
cores, memory, OS and runner image that produced it. The job fails on a figure
worse than its tolerance times a per-platform scale — 3 on Linux (4.5x for
a mean) and 10 on macOS (15x for a mean, 20x for a p95 or a one-shot
figure) — on a figure the baseline measured that stopped being measured, and on
a baseline it refuses to compare against. The scales are measured, not chosen:
a hosted Mac moves single figures by up to 10x between two runs of an unchanged
tree, a Linux runner by under 3x, and benchmarks/ci-gate.md has the runs
behind both numbers. So macOS CI catches an order-of-magnitude regression,
Linux CI a several-fold one, and both catch a measurement that went missing;
the workstation tolerances (1.5x a mean) are for a quiet machine, with
just bench-to and just bench-against. The baselines were recorded
before the performance work in epic #150 lands, as a floor, and each change
from it re-records them with the reason.
Two things about reading a comparison against either file. Each figure now
carries the spread it was reduced from — the sample count, the minimum, the
median, the 95th percentile and the maximum — so a change landing inside the
range the baseline's own samples covered is marked as such rather than read as
movement; and the conditions name the vendored engine's git revision beside its
version, because two builds can both say 0.120.0 and differ by a commit. A
comparison across two engine pins is announced above the table rather than
refused: refusing would leave an upgrade with no instrument at all. The
twenty-three figures the v0.78.0 pin added — the hierarchy's own group, the deferred
normal flush against the same stroke without it, and the drain between two
strokes — are in no baseline and report as new.
- Running it · Pointer and keys · Driving it from an agent
- Features — the shelf follows the active layer · sculpting · focus mode · masking · retopology, UV and baking · cutting · subtools · shapes · booleans · conversions · voxel grids · mesh layers · subdivision hierarchies · deformers, cage and curves · armatures · reference images · viewport · geometry in and out · documents · languages · acceleration and diagnostics · not built yet
- Architecture — the layers · a stroke · how a document is arranged · what the pointer means · the build
- Prerequisites · Getting it · Common tasks · trying an engine fix · Testing · Backends · Layout · Working on it
just run # or: cargo run -p clayspace-app --releaseOpens a window on a starting form. just lists everything else this
repository routinely does; see Common tasks.
| Input | Action |
|---|---|
| Left-drag on the model | Sculpt with the active tool |
| Left-drag off the model | Orbit — ZBrush's rule, and the only one a trackpad can reach |
| Right-drag, or Option-drag | Orbit |
| Middle-drag | Pan |
| Wheel | Zoom at what is under the pointer, stopping short of the surface rather than going through it |
1–4 |
Perspective, front, side, top |
⌘Z / ⇧⌘Z |
Undo / redo — one stroke is one undo, and so is every other command that changes the document |
X Y S |
Symmetry — X is on by default, as the design asks |
[ ] |
Brush smaller / larger |
M / ⇧M |
Mask painting on / off — Blender's key — and cycle materials, which M used to do |
F / Esc |
Frame / quit |
Tab |
Focus mode — the chrome clears and the sculpt fills the window |
⌘S ⇧⌘S ⌘O ⌘N |
Save, save as, open, new |
| Click a form | Make its subtool the one being sculpted — the next dab lands there, and the shelf offers that subtool's representation |
| Click a placed shape | Select it — clicking the wall of a hole selects the shape that cut it — and activate the subtool it stands in |
| Drag the manipulator | Move, turn or scale what is selected: a whole subtool, an object, a mesh or a curve |
W E R |
Move, turn, scale — Maya's and Unity's keys; pressed with nothing selected, the widget goes up on the whole subtool |
A / ⇧A |
Skin preview / enter rigging — see ZSphere armatures |
⌘ here is the platform's primary modifier: Command on macOS, Control on
Windows and Linux. One table covers all three, because the binding is written
once against that modifier rather than once per platform — see
crates/clayspace-view/src/shortcuts.rs, which is the only place a binding is
written down, and crates/clayspace-app/src/keys.rs, which is what the event
loop asks.
While rigging, a deformation cage is up, or a mask gesture is armed, the pointer means something else — see what the pointer means.
The application listens for the whole time it is open, so an agent works the session a sculptor is already in rather than starting one of its own. It speaks the Model Context Protocol over Streamable HTTP, bound to loopback and nothing else.
The port and a secret are written to the session directory when the application opens, and taken away when it closes:
cat "$HOME/Library/Application Support/ClaySpaceDesktop/agente.acesso" # macOS
cat "${XDG_STATE_HOME:-$HOME/.local/state}/clayspace/agente.acesso" # Linux
# porta 7457
# chave f2c1…
# processo 84213Point a client at http://127.0.0.1:<porta>/mcp with Authorization: Bearer <chave>. The application prints the URL on the way up, and Window › Agent
address and key… shows both. The same menu shuts the door and opens it again,
and a door shut by hand stays shut when the application is opened next.
What an agent gets. Twenty-nine tools grouped by the domains the interface
already has panels for — tool, brush, stroke, mask, cut, curve, shape,
object, transform, lattice, subtool, boolean, layer, passes,
hierarchy, document, exchange, repair, convert, retopo, uv,
conform, bake, deform, armature, history, view, reference,
session — plus describe, state,
viewport, wait and measure. Every action dispatches the same command a
menu item does, so an agent's edit is one history entry and one undo away, and
is refused wherever the interface would refuse it.
Those refusals live in the model rather than where the pointer is handled, so the door meets them too. While a gesture is open — a person's or the agent's own — a command that changes the document is refused unless it continues that gesture, and a measurement is refused outright; a stroke cannot begin on a layer whose deformation cage is up. A refusal reaches the caller on the channel it was raised on, and a value the application clamped rather than refused — a shape radius past what the brick cache can hold, say — comes back as a remark naming the number it used.
It can check its own work. state answers in twenty named sections —
StateQuery::NAMES in crates/clayspace-mcp/src/session.rs is the list —
covering what a command can change: the brush, the stroke's and the
placement's combine, a raised cage and the deform settings, the placed forms by
node id, the last rebuild, retopology and crossing, the reference images, the
exchange settings, a grid's passes and a hierarchy's levels. Its history
section names next_undo and next_redo: the step a Cmd+Z would actually
take back, rather than the last thing that happened.
measure includes geometry work owed by the command, and wait drains pending
geometry and changed mask attributes. Neither forces a full surface rebuild
when there is no pending work.
Both return uploaded_bytes, the tracked GPU upload delta for the operation.
A deferred surface settle blocked by an open gesture remains in outstanding;
wait returns without spinning on the interface thread. A measured answer
lists the work it left running, such as a retopology it started, so the figure
reads as the time to start it. measure runs only what describe offers, and
describe lists every command withheld from agents with the reason. Stroke-end settlement
runs when stored triangles still combine separate partial meshing requests; an
already complete replacement, including a mask-only edit on a consistent surface,
does not force another rebuild.
Expensive region brushes and required settlement still contribute their actual costs.
The armature is driven too: armature.add grows a ZSphere out of another at a
point, where the pointer needs a press, a drag and a release to say the same
thing, and move takes a point rather than a displacement.
It can see. Any group call takes capture: "viewport" or "window" and
returns the frame after the change in the same answer, taken after the edit has
reached the surface. viewport.compare reads a difference against the
difference two renders of an unchanged subject already produce on that machine
— zero on Linux and not on macOS — so a rasteriser is not read as a change.
What it cannot do without you. Saving over a file, exporting, opening a document, starting a new one and quitting are gated. The request appears at the window naming the operation, the client and the path, and can be allowed once, allowed always, or refused. The connection secret is not consent. Opening the door, shutting it and answering that request are the only three commands in the application an agent cannot reach at all.
Each gated operation takes its path and opens no file panel: document.save_as,
document.open, exchange.run_import and exchange.run_export all take
path. An ask nobody has answered comes back as consent_timed_out inside the
ten-second call bound and stays up at the window, so calling again picks up an
answer given in between. Nothing over the door opens a native dialog: saving a
document that has never been saved, opening or quitting over unsaved work, and
switching layers away from a dragged cage are refused with the call that gives
the answer up front — save_as, document.new, or layer.select with
cage: "apply" or "discard". The crash-recovery offer is a window in the
application rather than an alert, so a session with work on offer still serves
the door.
The door has a suite of its own: just test-agent runs the agent-facing
crate's tests (clayspace-mcp), which need no display, no GPU and no engine built, and just test-agent-e2e drives the real application over loopback. The second is asked
for rather than run by just test — it starts a window and a GPU device of its
own, and doing that beside the visual suite makes both flaky.
The blast radius, stated. Any process running as this user can read
agente.acesso and drive the session. That is the same boundary that already
protects the autosave and the recovery marker, and it is why the destructive
verbs are gated behind something the file cannot supply. The secret is new
every run, the listener binds loopback only, the Origin and Host headers
are checked so a page in a browser cannot reach it through a name that resolves
to loopback, and the diagnostics report carries the address but never the key.
The status area says whether the application is listening, whether a client is connected, and when an agent last changed the document — so a surface that moved while nobody touched the window has a cause in the report.
The full list, tool by tool and control by control, is in docs/features.md. Every tool there names the ClayCore entry point it invokes, so a binding can be checked against the engine's own documentation without reading the implementation. A tool with no engine counterpart is not offered.
SDF layers, sparse voxel grids, fixed-topology meshes, subdivision hierarchies and adaptive surfaces are equals here. They stand above the viewport as cards with the crossings beside them, and the tool shelf offers what the active layer has rather than one list with most of it greyed out. Twenty-one tools are bound across the four: fifteen have an SDF verb, thirteen a voxel one, seventeen a mesh one — a mesh layer alone carries the engine's sixteen fixed-topology brushes — and sixteen a hierarchy one: the mesh list less the two colour brushes, because a hierarchy stores where a vertex went and not what colour it is, plus Erase, which on a hierarchy takes the selected pass's detail back toward zero.
A fifth card, Dynamic, is an adaptive surface: triangles whose connectivity follows the brush, so a large Move makes the triangles it needs instead of stretching the ones there are. Its shelf is the mesh's less Layer — whose ceiling is measured against vertices that existed when the stroke began, which an adaptive stroke creates as it goes — and the shelf says so rather than leaving a gap. It is reported, saved and offered as itself, never as a mesh. A stroke redraws only the chunks it touched — about 175–195 KB a dab whether the surface holds 100 thousand triangles or a million — rather than the whole surface. See features.md.
The same shelf on a field, on a grid and on a mesh. The filter column on the left switches between what the active layer can run, each representation's own list, and the brushes that have been starred; the row scrolls where the vocabulary is longer than the window:
There is no fourth picture because there is no fourth shelf to photograph: a hierarchy draws the mesh shelf less Paint and Smear, plus Erase. That is the engine's doing rather than a convenience — one brush runtime serves every representation, so the same verb, falloff, mask and alpha reach a hierarchy's active level.
Which tool reaches which representation is a declared table rather than a rule
written per tool, and the shelf, the availability check and the tests all read
it — so the list you see and the list that works cannot drift apart. Each cell
of that table is a typed binding rather than a bare entry-point name: the call,
what the tool means by it, the family of calls it belongs to, and how faithfully
it keeps the label's promise — Native, Specialized, Approximation or
Recipe. So "on a field, Inflate's call is the faithful one and Standard's is
an approximation" is a value the tests read, not a comment beside the row. A tool
with no verb on the active representation is absent rather than shown and
greyed: with four vocabularies a single list would be mostly disabled rows all
saying the same sentence. A tool that does have a verb and still cannot be
used — the layer is locked, hidden, or missing an attribute the tool needs — is
shown disabled and says which of those it is.
Changing the active layer keeps the active tool where the new representation has it and substitutes one where it does not, saying so in the status line rather than resetting silently. Brush settings are held per tool and per representation: a size that suits a grid's cells is not the size that suits a field.
Every swatch carries a mark saying what the brush does — a hump for Standard, a swollen ring for Inflate, ripples dying to a line for Smooth, a hatch for Mask, a planed-off hump for Scrape, a tendril for Snake Hook — drawn with one pen at one weight, in the ground's ink on the lit clay. A row of identical grey balls told apart by the word under each is a shelf the eye has to read one by one; ZBrush's is read by shape first. A brush can be starred from its own menu, and the shortlist is kept between sessions and spans every representation, because its purpose is finding a brush again rather than describing the active layer.
The whole gesture reaches the engine as one call, so the stroke engine decides stamp spacing from arc length rather than from how many samples the device happened to deliver — and so it undoes as one step, mirrored halves included.
The stroke's settings are on one bar. Intensity, size, flow, the smoothing and the symmetry axes sit together on the options bar, with an engaged axis in the soft accent rather than a raised grey: symmetry is state a sculptor needs to see without looking for it, and a mirrored stroke they did not expect is the most expensive surprise on the bar.
| Control | Maps to | Range |
|---|---|---|
| Intensity | stroke strength | 0–1 |
| Size | stroke radius, in document units | 0.005–1 |
| Flow | stamp spacing | 0.01–1 |
| Smoothing | lazy-mouse lag | 0–0.95 |
| Noise | positional jitter | 0–1 |
| Edge | falloff: hard, linear, smooth, Gaussian | — |
| Accumulate | buildup against clamped accumulation | on/off |
The bar carries what the edit is beside what the brush is: thirteen combine operations and five blend profiles on an SDF layer, with the distance control beside them. That distance is two quantities under one slider — the amplitude the surface displaces by for the relief family, the width of the join for everything else — so the label follows the operation, and for the seven operations whose whole effect is the distance the slider's floor is lifted off zero. There, zero is not a hard join; it is no operation at all.
A brush colour is shown for the two tools that read one, Paint and Smear, and hidden for the ones that do not, because a control that does nothing is worse than an absent one. Unlike every other brush setting it is shared across tools — it is what you are painting with right now. A grid stores palette indices, so the adapter resolves the colour to an entry before painting and only a genuinely new colour adds one.
Symmetry about X, Y and Z reaches all four representations, and belongs to the subtool rather than to the document: switching subtools restores that subtool's own axes. A new subtool starts with X on.
Tab, or Window → Focus mode, clears the chrome. The tool rail, the
options bar, the representation bar, both inspectors, the shelf and the status
area go, and the sculpt stays. The menu bar stays with it, because a mode
nobody can find their way out of is worse than no mode and Tab is not
discoverable from an empty window; and a floating readout keeps the brush's
ball and mark, its name, the representation a stroke would land on, and its
size, intensity and flow, since hiding the bar that carries those without
replacing them would be focus in name only.
It is a presentation override rather than a layout: it hides the regions without touching the sizes and collapse states a sculptor chose, so leaving it puts everything back, and it is deliberately not remembered — an application that opened with its panels gone would look broken.
The regions move, and are remembered. The left region, the right region and the shelf are resizable, clamped so that none can vanish or swallow the viewport; each can be put away and brought back from the Window menu, with a reset that returns every one to the design's own size; and the arrangement is stored beside the recent documents and the chosen locale, so it is as it was left when the application opens again. The viewport's quality profile — under View → Viewport quality, as performance, sculpt or presentation — is remembered too.
M paints a mask and M again puts the tool you were using back in your
hand — Blender's key, and a toggle because freezing a region is a detour rather
than a mode to live in. Frozen clay reads as a dark neutral over the shading at
roughly three quarters strength, so the form underneath stays legible; masking
protects the surface almost completely, and a sculptor who cannot see the mask
cannot tell a protected stroke from a failed one.
The mask can also be drawn round rather than painted: a lasso or a rectangle on the view frame, freezing or thawing what it encloses. The Masks menu carries invert, clear, expand, contract, smooth, the bounded complement and extrude — outward, inward or centred. A mask belongs to the subtool it was painted on and is written with the document, so it is still there when the file is opened again.
Since v0.73.0 a mask protects a region from an operation and not only from
a brush. clay_item_set_gate had been accepted and inert since v0.39.0 —
measured, filed as
ClayCore#394, and held
open by a tripwire test written to fail the day it started working. It fired on
the upgrade.
Dinâmica → Aparar — Trim on the shelf — removes material with a shape drawn on the view rather than a stroke across the surface. The options bar picks which shape the next gesture is: a line, a lasso or a rectangle.
The direction you draw in says which half goes. A line drawn left to right takes what is below it and the same line drawn back takes what is above; a lasso wound clockwise takes what it encloses and wound the other way takes everything else. One rule underneath: what lies to the right of your travel is the half that goes. While you drag, a barb at the middle of the stroke points at the side about to be removed. A rectangle has no travel to read and takes what is inside it.
The cut is a straight prism rather than a wedge under the camera, it passes all the way through the form, and what it leaves is an item — one undo entry, adjustable afterwards, resolved when a sculptor chooses to. It is not reflected by the layer's symmetry: a trim is drawn where the sculptor is looking, and mirroring it would remove material on the far side of the form. Field subtools only, because a cut resolves to a field item.
The pipeline is sculpt -> retopo -> UV -> bake. This application owns the
first stage; CyberRemesher v0.10.0 owns the rest, vendored beside ClayCore as
a second engine. Four operations reach a mesh subtool, all of them off the
interface thread with progress and a cancel:
- Retopologise to quads — five methods including the ZRemesher track, which makes edge-loop structure an explicit artifact rather than a consequence of the field. It rebuilds the subtool in place, in one undo entry — the same place ZBrush's ZRemesher, its Dynamesh and this application's own Rebuild all put their result.
- UV layout — islands, conformal unwrap, minimum-area re-orientation and packing, reporting charts, distortion and coverage.
- Bake maps from the field — normal, ambient occlusion, curvature and cavity, sampled from ClayCore's field with no high-poly mesh. The cage ray is sphere-traced through the actual surface and normals come from exact gradients. This is the half of the pipeline neither engine could fill alone: the retopologiser's field evaluator had always had nothing to ask.
- Conform to the field — re-snaps a retopologised mesh onto a sculpt that moved since, keeping its topology exactly, and reports how far the worst vertex had to travel rather than only that it finished.
Sculpting latency is held by four decisions rather than hoped for: the second engine is built CPU-only so ClayCore keeps the GPU, its worker pool is capped at startup, every operation runs through the job runner that already discards a stale result, and each is refused while a gesture is open.
Each form in the scene is a layer of its own, with its own transform, its own representation, its own mask, its own symmetry axes and its own rig — ZBrush's word for the arrangement, and the arrangement the engine has always had underneath.
Click a form in the viewport or a row in the stack and that subtool is the one being sculpted: the next dab lands on it, the shelf offers what it is made of, and it is tinted or outlined so which one is active can be read off the viewport. A layer row is renamed with a double-click on its name and carries its own menu on a right-click, with rename and delete — the latter disabled, with the reason on it, for the last layer a document has. The row's menu also shows one subtool alone and brings the rest back.
The manipulator moves, turns and scales a whole subtool the way it already moved a placed shape, and it is seen through the form it stands on: every arm and ring is drawn faint where the clay is in front of it, so a rotate hoop reads as a hoop the form passes through rather than a flat circle painted on the frame. Faint is a cue and not a state — the hit test walks every handle by ray and ignores depth, so a handle drawn faint is as easy to grab as one drawn bright.
While the manipulator is on a placed object its transform stands in the
viewport's lower-leading corner — position, an axis and one angle for rotation,
and scale — because a widget shows that something moved and never by how much.
A placed object stretches per axis, and so does a whole subtool: the engine's
layer transform has taken a factor per axis since ClayCore 0.74.0, so the
manipulator is one widget with the same three boxes on it wherever it stands.
A .clayspace written by this build carries that stretch, at container minor
19 — which a build older than v0.113.0 refuses to open rather than misreading,
the direction the format is designed to fail in. The minor moved twice inside
one of the releases this pin crossed, which is why a single release's notes do
not describe the jump.
When a subtool has become costly to evaluate the objects panel says so and offers to consolidate it, with the cost of doing that stated.
File → Shapes inserts a form and asks where it goes. New subtool — the default — makes it a layer of its own, active on arrival and standing where the pointer was, so the next dab lands on it; Into the active subtool puts it into the layer being worked, which is how the parts of one form are built.
Three sources: the fourteen bounded primitives, a mesh read from a file,
and a copy of a subtool already in the scene. Each arrives as one undo step.
A primitive's size is priced before it reaches the document: every parameter is
bounded by what the brick cache can hold rather than by a round number, and the
placed shape's box is priced as a whole, because two radii each inside the bound
can still make a torus four times as wide. A value that had to be clamped is
reported with the number actually used. A copy is a copy, so sculpting it
cannot reach the original. The engine can now instance a layer instead
(clay_document_instance_layer, which closed ClayCore
#364); this application
has not taken it up yet. The layer stack's add control asks the same question: a
new layer declares whether it is a field or a grid rather than being crossed to
one afterwards. Not a carried mesh, because there is no way to make an empty one
— a mesh subtool comes from the import above, which brings its own.
File → Boolean between subtools combines two whole forms into a third. Pick which is cut and which cuts, pick union, subtraction or intersection, read what it costs, and confirm. What arrives is an ordinary subtool: sculptable, movable, an operand again.
It is a resolved boolean and the panel says so — the engine composes layers
by hard union, so moving an operand afterwards does not update the result —
which is why the operands are kept, hidden, and one ⌘Z takes the whole
operation back with them visible again. Every representation can be an operand,
with the crossing each one needs performed as part of the operation rather than
demanded beforehand.
ClayCore carries SDF, voxel and mesh side by side, and the intended workflow uses more than one: block out and hard-surface on SDF, free-form sculpt on voxels, refine on a mesh when the topology is one you want to keep. Each of those three reaches both of the others, so six crossings are offered — from File → Convert, or from the row beside the representation cards, which offers exactly the ones the active layer declares.
A fourth representation, the engine's subdivision hierarchy, joins them —
eight crossings in all — and it arrives through a cage rather than from
nothing, so its two are the only ones that sample nothing at all. mesh → multires takes the triangles vertex for vertex and refuses rather than
repairing a mesh that cannot be a cage; multires → mesh bakes the display
level back out. See subdivision hierarchies.
A fifth, the adaptive surface, arrives the same way — mesh → dynamic refuses
a mesh with degenerate or non-manifold faces rather than repairing it, and
dynamic → mesh bakes it back to fixed topology — for ten crossings in all.
Both are priced by the engine's preflight before they run and refused past the
memory budget with the estimate and the limit named; each is one undo, and the
result keeps the source's transform and visibility. No brush ever crosses a
layer on its own.
graph LR
SDF["SDF field"]
VOX["voxel grid"]
MESH["fixed-topology mesh"]
MRES["subdivision hierarchy"]
DYN["adaptive surface"]
SDF -->|"rasterize into cells"| VOX
SDF -->|"march into triangles"| MESH
VOX -->|"read occupancy back, redistanced"| SDF
VOX -->|"exposed faces as merged quads"| MESH
MESH -->|"sample triangles onto a lattice"| SDF
MESH -->|"straight from the triangles"| VOX
MESH -->|"take the mesh as a cage"| MRES
MRES -->|"bake the display level"| MESH
MESH -->|"read into an adaptive surface"| DYN
DYN -->|"bake to fixed topology"| MESH
style SDF fill:#2E3238,stroke:#C9C4BD,color:#C9C4BD
style VOX fill:#2E3238,stroke:#C9C4BD,color:#C9C4BD
style MESH fill:#2E3238,stroke:#C9C4BD,color:#C9C4BD
style MRES fill:#2E3238,stroke:#C9C4BD,color:#C9C4BD
style DYN fill:#2E3238,stroke:#C9C4BD,color:#C9C4BD
| From | To | What it does |
|---|---|---|
| SDF | voxel | Rasterizes the field into cells over the layer's bounds |
| SDF | mesh | Marches the field into triangles — watertight and 2-manifold by construction |
| voxel | SDF | Reads occupancy back, redistanced, as an ordinary operand — one volume item per palette entry, which is what carries the colour |
| voxel | mesh | The grid's exposed faces as merged quads, with the palette colour on the face |
| mesh | voxel | Straight from the triangles in one sampling, so a feature thinner than a cell survives where a field detour loses it |
| mesh | SDF | Resamples the triangles onto a lattice as a volume item |
| mesh | multires | Takes the mesh as the cage of a subdivision hierarchy, vertex for vertex — refusing rather than repairing a mesh that cannot be one |
| multires | mesh | Bakes the display level out as an ordinary mesh |
| mesh | dynamic | Reads the mesh into an adaptive surface whose edges the brush may split and collapse; quads do not survive |
| dynamic | mesh | Bakes the adaptive surface out as an ordinary mesh |
The panel states what the crossing costs before it runs, computed from the cell size rather than written down, so the figures move as the slider moves: how far the surface can travel, what thickness of feature vanishes, how many cells the region holds, and whether sharp edges, colour and the parametric history survive. A crossing into a mesh states one more — the topology is the sampling lattice's and nothing here re-flows it, or, for an exact crossing, the source's own, frozen. The adaptive crossings state what the engine says they will peak at beside the budget, and the crossing in says quads become triangles.
A card converts nothing. Crossing costs work and is not always reversible, so it stays behind the panel where the cost is stated and confirmed.
A grid is drawn, framed and picked by its own routes — the engine is explicit that a voxel layer carries no SDF content — and it is drawn as its smoothed form rather than as the boxes it is, with the box view and the filtering both still reachable:
| smoothed | as the cells it is |
|---|---|
![]() |
![]() |
Recorded passes — ZBrush's layers, on a grid — are nested under the layer in the panel: record a pass, keep sculpting, and dial its strength back afterwards. Not undo, which is a stack you pop; this is a slider you keep, and it survives a save and a reload. Pre-bake repair is in File → Repair: a sealed void is invisible until something needs the model to be solid, so the panel reports what is wrong before offering to change anything.
A mesh layer is fixed topology: the sixteen brushes move the vertices that are there and neither add nor remove any. When a form has been pulled somewhere its triangles could not follow, Rebuild the mesh — DynaMesh, in the vocabulary most sculptors bring with them — throws the geometry into a voxel grid and marches a new surface out of it. Overlapping shells fuse into one skin, self-intersections resolve, stretched triangles disappear, and the density comes out even.
One number and three switches: the resolution is cells across the form's longest dimension, so it means the same thing on a thumbnail and on a bust; remove loose pieces discards fragments too small for that resolution to have described; follow the current form pulls the new surface most of the way back onto the one it replaces; and sharp edges holds corners instead of rounding them, at the cost of the watertight guarantee.
It says what it destroyed. Every rebuild is destructive — vertex and polygon identity are gone, and texture coordinates are dropped rather than reprojected — so the triangle counts before and after stay beside the button, along with the number of separate pieces the form is now in, which is the answer to the question a sculptor actually asks: did those two actually join? Nothing is written until the rebuild has succeeded and validated, so a resolution the form turns out not to survive gives a sentence and leaves the layer byte-identical.
A hierarchy is a cage, subdivision levels over it, and detail stored per level in a frame carried up from the level below. That last clause is the whole of why it is a representation rather than a mode: a wrinkle cut at level 4 and a jaw moved at level 1 are edits to two different arrays, and moving the jaw moves the frames the wrinkles are stored in, so the wrinkles ride on it instead of being smeared. Measured on a flat cage with a wrinkle cut at level 3 and the form under it dabbed five times at level 0, the wrinkle comes back the same height to seven significant figures, on the same vertex, pointing forty-two degrees elsewhere — a displacement stored in world space would come back at no rotation at all, lying flat across a form that has rolled underneath it.
Two levels, not one. Where the brush writes and what the viewport draws are independent numbers, and that is the workflow rather than an implementation detail: dropping to the cage to move a jaw while still watching the pores is what the representation is for. While the two are apart the inspector says so, because a dab landing on a coarse level while a fine one is drawn otherwise reads as a brush that has stopped working.
Adding a level is priced, and refused rather than attempted. A level multiplies faces by four, so a 20k-quad cage is 5.1M faces at level 4 and 20.5M at level 5. The face count and the peak during the build stand beside the button — the peak rather than what is left afterwards, because on a constrained machine it is the high-water mark that ends the session — and a refused level leaves the hierarchy exactly as deep as it was.
A stack of named passes hangs under the layer row, the way a grid's recorded passes do, and the next stroke goes into whichever row is selected: a pass, or the row for the form beneath them, which is how the anatomy under a set of wrinkles is corrected without disturbing them. A pass's strength stays dialable long after the pointer came up. The two stacks share the word and none of the addressing — a hierarchy's pass is named by an id the engine minted rather than by its position, because passes here commute, so reordering is organisation and never geometry.
The sculpt is saved beside the document. A .clayspace carries a
hierarchy's cage and nothing standing on it — the engine's own ownership
boundary, stated in its header — so the levels go into a .multires file next
to it, and a save that cannot write that file fails, which is the opposite
of what the placed-object side-car does. Copy a document without its companion
and it still opens, with the row showing as the mesh layer it now is rather
than as a hierarchy that has silently lost every level.
Exporting is the one place the fourth representation is not yet an equal: the engine combines a document's mesh layers on the way out, and a hierarchy's layer holds the cage, so what leaves is the cage. Bake a level out to a mesh first — it is one crossing and it says what it gives up.
Dynamics → Deformation cage puts a lattice around the form and deforms it by dragging control points, with a move/turn/scale manipulator on the selection. ZBrush spells it the Gizmo Lattice, Blender the Lattice modifier, Maya an FFD. The form is drawn through while the cage is up, so the handles behind it can still be reached, and the cage's manipulator is a share of the camera's distance like every other, so it keeps its size to the hand.
File → Deform carries the whole-form deformers, taper and twist, each one undo step.
Dynamics → Tube along a curve places a tube through control points — Nomad's tubes rather than a snakehook's single pull. What makes it different from a brush is not the shape it leaves but that it can be gone back to: a stroke is over when the pointer comes up, and a curve is a set of points that stay where they were put. Thickness, join — corners, through the points, or rounded — and profile — circle, square, hexagon, triangle — are the controls, and Apply leaves the swept form and takes the curve down. Thickness is priced against the same budget as a placed shape: a radius the cache cannot hold is refused, and the guide is left exactly as it was.
Sculpt → New armature starts one, on a layer of its own with the sculpt hidden, because a ZSphere armature is its own tool rather than something added to what you were sculpting. Then the pointer means something else:
- drag out of a sphere to grow the next one;
- drag the membrane between two to insert a joint there;
- Option-drag to move a sphere and everything under it — in the tree and in the surface, so lifting a shoulder brings the arm;
- ⌘-drag to resize;
Deleteremoves a branch, and Negative sphere makes a sphere cut into the rig instead of adding to it — the membrane along its links goes with it, it may still carry a limb, and the sign survives a save;- a press on empty space still orbits, so a rig can be turned to look at without leaving the mode.
Mirrored authoring follows the subtool's own symmetry axes: one drag makes two limbs, and the reflection hangs off the parent's reflection, so two arms end up on two shoulders. A rig belongs to the subtool that holds its nodes, and a document may carry one per subtool.
A toggles the skin preview, as it does in ZBrush. With it off you see the
ZSpheres and the translucent membrane between them, which is what you want
while building:
| skin preview on | skin preview off |
|---|---|
![]() |
![]() |
View → Reference images hangs a PNG or a JPEG behind the form on each of the three orthogonal planes — the drawing pinned to the wall beside the monitor, put where it belongs.
One picture a plane, each with its own opacity, height, offset across and up, and how far back it sits; width follows the image's own proportions, so a reference is never squashed. The clay is always in front from every angle — references are drawn first and write no depth, because a guide that occludes the form it is guiding has stopped being a guide. Photographs taken sideways are turned the right way up: all eight EXIF orientations are applied, once, to the pixels. The same panel carries the model's own opacity, so the clay turns translucent and the reference shows through it.
MatCap and studio shading, ambient occlusion, cavity shading, a polyframe, and five materials, with the quality profile — performance, sculpt or presentation — chosen under View and remembered. The symmetry plane is an outline, not a lattice: six lines at a fifth of the accent, saying where the mirror is and putting nothing across the clay. The navigation gizmo draws each half-axis as a bundle of lines so it reads as a rod rather than a hairline.
The wheel zooms at what is under the pointer and stops a little short of it, which is Blender's behaviour: it stops against the clay rather than clamping to an absolute distance, and it aims at the surface rather than at the scene's centre.
A surface the device cannot hold is drawn coarser, not fatally. A subtool scaled up a few times is ten million vertices at the field's fixed resolution; the device is asked for the adapter's own ceiling, a validation error is reported rather than fatal, and a layout that would still not fit drops the viewport to the coarse level, which the geometry panel says.
Importing asks one real question — reference or clay — because it cannot be asked afterwards. A reference keeps the triangles verbatim on a layer of its own; clay resamples into a field and is sculptable from then on. OBJ, PLY and FBX go in; GLB is export-only, so the import dialog does not offer it and a GLB passed in anyway is refused by name. A vertex and triangle ceiling is checked against the file's declared counts before anything is allocated, because a malformed file can claim a billion triangles.
Exporting meshes the field and every visible mesh layer — meshing the field alone would silently leave every imported reference out of the file. Mesher, cell size and decimation are chosen in the panel, and what the write will give up is said beforehand rather than discovered in the file: PLY has no texture coordinates, FBX does not carry vertex colour, the fast mesher is not manifold.
And what the file turned out to be is said afterwards. The written mesh is validated, and a result that is not watertight or not 2-manifold is reported with the count — a handful of pinched edges in a large mesh is usually worth shipping and thousands are not, and "not manifold" alone cannot tell a sculptor which they have. That is the class of defect a slicer or a boolean engine refuses while a viewport shows nothing wrong, so it is not left to be found in the file. The panel stays open when a finding is raised, because closing it is how the application says the write went fine.
- Save, open, new, save-as, and a File menu carrying all of it plus Open recent, which prunes documents that are no longer there.
- Replacing the document puts every panel back. A new or opened document refreshes the shelf, the scene, the mask, the rig, the placed objects — and the deformation cage, the curve a tube follows and the boolean's operands, which for a while it did not: choosing New left a raised cage and a drawn curve standing over a document that had never had either. What is checked is not that a cage comes down but that every view model offering a refresh is refreshed, so one added later fails on the row that was added rather than going quiet until someone meets it.
- Autosave every two minutes, and only while there is something to lose. The status area says which it is — nothing to save, or the time until the next one — so whether the work is safe is readable rather than assumed.
- A marker file written when a session opens and removed when it closes is what tells the next run whether the last one crashed. Closing over unsaved edits asks first; a session that ends any other way offers its work back on the next run. Recovered work is unsaved work — it does not take the recovery file's path, and it is marked modified.
- Undo and redo over the engine's own vocabulary. A stroke of any length is one entry. An edit that changed nothing adds no entry and does not mark the document modified — the engine documents several verbs as legitimately able to change nothing, so a successful call is not evidence that anything happened.
- One Cmd+Z takes back one command, whatever it cost the engine. A subtool added, a shape inserted, a cage applied, a crossing, a repair, a rebuild, a mask edit, a change to a grid's pass: each measures how many engine entries it spent and banks that count as one action. Before that, a command that banked nothing left the next undo to spend the previous command's count on entries that were not its own — one undo after a cage apply deleted the subtool before it. Removing a grid's pass and merging one down stay outside the history, because the engine records neither. A rebuild asked for mid-stroke is refused.
- Which shapes were placed live in a side-car,
<name>.clayspace.objects, beside the document: the container is the engine's, and which nodes a sculptor put there is this application's own bookkeeping. Send someone the.clayspaceon its own and it opens and sculpts with every boolean intact. - A subdivision hierarchy's levels live in a second side-car,
<name>.clayspace.multires. That one is not bookkeeping — it is the work — so a save that cannot write it fails rather than reporting to stderr and carrying on, and a document opened without it comes back as the cage its layer holds. - Each rig's skin thickness lives in
<name>.clayspace.rigs, one line per rig whose thickness is not the default. The document holds the rig's radii already scaled, so this is bookkeeping: without it a rig reopens at the default thickness over the same surface. - Session state — the recent list, the chosen language, the panel arrangement,
the viewport profile, the starred brushes and each reference plane's path and
placement — lives in Application Support on macOS and
$XDG_STATE_HOMEon Linux. State, not cache: losing it costs work.
stateDiagram-v2
[*] --> Untitled: launch with no recovery marker
[*] --> Recovered: a marker from the last session
Untitled --> Modified: first edit
Recovered --> Modified: arrives modified, with no path
Modified --> Saved: save or save as
Saved --> Modified: next edit
Modified --> Asked: close
Asked --> Saved: save first
Asked --> [*]: discard
Saved --> [*]: close
Modified --> Autosaved: every two minutes
Autosaved --> Modified: keep sculpting
View → Language chooses between three complete translations: Português (Brasil), English (US) and Español (Latinoamérica). Each is named in itself, which is the one rule a language menu has — a reader who cannot read the current interface can still find their own. The choice is written to the session directory, so it survives a restart.
The interface opens in English, which is not the design's own language and is deliberate: it has to open in something a first-time reader can make sense of. A system language still wins over that default on a first run where it is one of the three.
Some vocabulary inside the option bar and the viewport bar is still Portuguese whatever the menu says — see docs/features.md, which names the enums it comes from and the ratchet that stops the list growing.
Backends are discovered at runtime and ranked per platform — metal → cpu on
macOS, cuda → vulkan → opencl → cpu on Linux. The CPU backend is always
compiled in, so selection never fails for want of a candidate, and where a
backend declines an operation the fallback is per operation: the selected
backend stays active for everything it does support.
Refill is routed per batch, and the routing is measured. A brick refill goes to the CPU below sixteen bricks whatever the machine — what that avoids is the fixed cost of a device submission, a property of the call rather than of the hardware. Above it, the first large refill of a session is split into a warm-up and two timed slices, and every refill after that is timed too, so the routing keeps following the machine. This replaced a constant measured on an M-series Mac: on a 24-thread Linux box with an RTX 5060, CUDA is 3.5x slower than the CPU at every batch size from 8 bricks to 7600.
A refill has a budget, and the interface thread keeps its frame. A refill used to drain the brick cache until it was empty, on the interface thread, so the window stopped for as long as the region took — cancelling a thick tube held it for over thirty minutes. The application now gives each drain half a frame and pumps what is left at the top of the next one; nothing is dropped, only spread. A document built headless keeps the whole drain, because a caller with nothing waiting on it needs the exact answer before it returns. Saving uses the same bracket to write the visibility the sculptor set without refilling the scene around a solo, and the autosave interval now counts from when a save ends.
An idle application does nothing. Three costs that grew with the sculpture and were paid whether or not anything changed are gone. The status area's memory meter walked the whole brick cache on every frame, which kept a worked document at 185–200% CPU with nobody touching it; it reads once a second. Hidden grids are no longer re-meshed for a display change nobody can see. And the surface's GPU buffers are reused and grown rather than replaced on every settle, its writes merged, and the device polled once a frame so staging memory is released — an audited session had climbed to 26 GB and never came down.
Help → Diagnostics carries the application version, the engine version, the vendored engine's git revision, the platform, every registered backend, the active one and why, the graphics adapter, anything that fell back this session, and anything that held the interface thread longer than one frame — one line per operation, keeping the worst time and counting the occurrences. It also says what the document costs, split three ways rather than totalled: a sculptor who has just been told memory is short is asking which part they may let go of, not how big the file is, so the work itself, what could be rebuilt, and undo depth are three figures. A fourth counts the mesh-sculpting sessions the application asked, because a document with no surfaces and a host that never asked report the same zero. One button puts the lot on the clipboard.
A stroke's milliseconds are split, and the split is exported. The report
used to say re-malha 42 ms — a figure spanning an engine call and this
application's work around it, which neither party can act on because neither
can tell whose it was. It now carries five rows: the engine's stroke and brick
refill, the engine's clay_brick_cache_mesh, and our copy, split and upload,
each with a median, a worst and a sample count. Four of those had been measured
on every dab of every real stroke and thrown away; the engine's own edit was
not measured at all. Beside them, the measured cost per brick of a refill on
each backend — the evidence the routing decides on every batch, and the figure
behind CUDA being 3.5x slower than the CPU here.
Help → Export profile… writes that as one JSON file for the engine's
authors, with the whole distribution behind every phase — count, retained
window, median, p95, worst, per tool as well as across tools — and everything
this project has ever reconstructed by hand in an upstream issue around it:
engine version and revision, backends, fallbacks, adapter, stalls, per-pass GPU
time, memory by category, and the shape of what was being sculpted. A follow-up
question is a round trip, and a round trip is where a performance report dies.
Nothing unmeasured is written as a zero, nothing the sculptor named comes out,
and a debug build stamps its own timings as not comparable and asks before
writing — it runs this work about two and a half times slower, so just profile is the way to produce one that can be quoted.
An agent reads the same split through state with the strokes section —
no panel, no file, nothing changed, and every figure marked as coming from a
live session so it cannot be mistaken for a baseline.
Measuring costs nothing, and that is measured. Recording a phase is 20 ns
against a two-millisecond dab, which is why there is no switch for it.
Summarising a session is 0.9 ms, and the report is rebuilt every frame — so the
summary is assembled only where something is going to read it. profile_overhead.rs
holds both figures.
Help → Attributions shows the attribution manifest, generated from cargo metadata and embedded in the binary rather than shipped beside it.
One engine unit is a centimetre and lengths read in millimetres; switching the display unit is presentation only and changes no geometry.
Shortcuts are fixed. Bezier handles on a curve, reopening a curve after it is applied, colour on a field, and the rest of the domain's vocabulary in more than one language are named with their reasons in docs/features.md. Every engine issue those reasons once cited is now closed upstream, which leaves two different kinds of gap:
- Answered, not yet taken up. A live preview under a voxel drag
(#393) has its
gesture in the pinned header —
clay_voxel_grab_begin,_update,_commit— and the grid's drag still lands when the pointer comes up. docs/roadmap.md lists the rest of what the pin offers and nothing here calls yet. - Closed, and still reproducing. Alpha stamps on an SDF stroke
(#392) stay refused:
the test written to fail when the engine carries a stroke's alpha into each
stamp,
a_stroke_does_not_carry_the_chain_into_each_stamp, still passes at v0.120.0. A voxel crease has no engine verb at all.
An SDF pinch was once on this list
(#391) and is done:
clay_layer_magnify_surface is the assembled-surface resolver it wanted, and
Pinch is on the field's shelf.
Soft-body dynamics and mesh-surface booleans are deliberately absent rather than pending, with the reasons under Deliberately absent. What is offered and does the wrong thing is called out under Known-degraded rather than quietly omitted.
docs/architecture.md has the decisions and the measurements behind them; this is the shape.
Five layers, with the dependency direction enforced by CI rather than by review.
graph TD
APP["clayspace-app: composition root"]
VIEW["clayspace-view: widgets and renderer"]
VM["clayspace-vm: ViewModels"]
MODEL["clayspace-model: the domain"]
MCP["clayspace-mcp: the agent door"]
ENGINE["clayspace-engine: engine adapter"]
SAFE["claycore: safe wrapper"]
SYS["claycore-sys: generated FFI"]
CLAY["ClayCore: C++20 engine"]
RSAFE["cyberremesh: safe wrapper"]
RSYS["cyberremesh-sys: generated FFI"]
REMESH["CyberRemesher: retopology, UV, bake"]
APP --> VIEW
APP --> VM
APP --> MCP
APP --> MODEL
APP --> ENGINE
VIEW --> VM
VIEW --> MODEL
MCP --> VM
MCP --> MODEL
VM --> MODEL
ENGINE --> MODEL
ENGINE --> SAFE
ENGINE --> RSAFE
SAFE --> SYS
SYS --> CLAY
RSAFE --> RSYS
RSYS --> REMESH
style MODEL fill:#2E3238,stroke:#C9C4BD,color:#C9C4BD
style APP fill:#D9744A,stroke:#D9744A,color:#23262B
style CLAY fill:#3A3E45,stroke:#C9C4BD,color:#C9C4BD
style REMESH fill:#3A3E45,stroke:#C9C4BD,color:#C9C4BD
| Crate | Holds | Must not reach |
|---|---|---|
claycore-sys |
Generated FFI to clay.h. No hand-written declarations |
— |
claycore |
Safe wrapper: ownership, errors, threading | — |
cyberremesh-sys |
Generated FFI to CyberRemesher's C header | — |
cyberremesh |
Safe wrapper over the retopology, UV and bake engine | — |
clayspace-model |
The domain: tools, interfaces, types | egui, wgpu, CyberRemesher, serde — and ClayCore, by having no dependencies at all rather than by a rule |
clayspace-engine |
The ClayCore- and CyberRemesher-backed implementations of those interfaces | — |
clayspace-vm |
ViewModels: observable state and commands | egui, wgpu, winit, ClayCore, CyberRemesher, serde |
clayspace-view |
Widgets and the renderer | ClayCore and CyberRemesher, directly or transitively; serde_json |
clayspace-mcp |
The agent door: MCP over loopback, answering through the ViewModels | both engines, the engine adapter, the View, egui, wgpu, winit |
clayspace-app |
Composition root, window, event loop | — |
The "must not reach" column is FORBIDDEN in tools/check_layering.py, which
fails CI on any of those edges. The two that carry the design:
clayspace-viewcannot reach the engine, directly or transitively. A View reads ViewModel state and emits commands; it has no other way to affect anything.clayspace-vmcannot reachegui,wgpuorwinit, so every ViewModel is exercised in a test with no window and no GPU.
The domain and the engine adapter are separate crates for exactly this reason: a single crate holding both would put ClayCore in every layer's transitive dependencies, and no arrangement of the others could satisfy the isolation rule. A useful side effect is that the ViewModel tests run without compiling the C++ engine at all.
unsafe lives in the two engines' bridge crates — claycore-sys, claycore,
cyberremesh-sys and cyberremesh — and nowhere else; every other crate
declares #![forbid(unsafe_code)], and the layering check fails if one drops
the declaration.
sequenceDiagram
participant U as Pointer
participant V as View
participant M as ViewModel
participant D as Document
participant E as ClayCore
participant G as GPU
U->>V: drag across the surface
V->>M: BeginStroke, ContinueStroke, EndStroke
M->>D: apply_stroke with the whole gesture
D->>E: resolve stroke, apply once
E-->>D: nodes created
D->>E: mark dirty by node, drain, refill
E-->>D: the keys that were dirty
D-->>M: changed, dirty brick count
M-->>V: observable state moved
V->>D: sync geometry
D->>E: mesh the dirty subset
E-->>D: mesh plus per-key ranges
D->>G: upload
The whole gesture reaches the engine as one call, so the stroke engine decides stamp spacing from arc length rather than from how many samples the device happened to deliver — and so it undoes as one step.
Only the dirty subset is re-meshed. Details, and the three mistakes that made the first version cost 267 ms, are in docs/architecture.md.
A document is a list of subtools. Each is a layer of its own and carries everything that belongs to that form rather than to the scene.
graph TD
DOC["Document · one .clayspace file"]
SIDE["Side-car · name.clayspace.objects"]
MRES["Side-car · name.clayspace.multires"]
ST["Subtool · one layer"]
REP["Representation: SDF, voxel, mesh or hierarchy"]
XF["Transform · moved, turned, scaled whole"]
MASK["Mask · frozen cells, saved with the file"]
SYM["Symmetry axes · X on by default"]
RIG["Rig · ZSpheres, at most one"]
OBJ["Placed objects · primitives and their booleans"]
PASS["Passes · a grid's recorded ones, a hierarchy's stack"]
LEVELS["Levels · a hierarchy's cage and the detail over it"]
DOC --> ST
DOC --> SIDE
DOC --> MRES
SIDE --> OBJ
MRES --> LEVELS
ST --> REP
ST --> XF
ST --> MASK
ST --> SYM
ST --> RIG
ST --> OBJ
ST --> PASS
style DOC fill:#D9744A,stroke:#D9744A,color:#23262B
style ST fill:#2E3238,stroke:#C9C4BD,color:#C9C4BD
style SIDE fill:#3A3E45,stroke:#C9C4BD,color:#C9C4BD
style MRES fill:#3A3E45,stroke:#C9C4BD,color:#C9C4BD
The object side-car is why a .clayspace sent on its own still opens and
sculpts with every boolean intact — the hole a cylinder cut is still a hole —
while none of its shapes is offered as an object any more. The hierarchy
side-car is the other kind: what it holds is the work rather than a record of
it, which is why a save that cannot write it is a failed save, and why a
document that arrives without it says so in the diagnostics report rather than
opening a flat sheet under a row still calling itself a hierarchy.
One button, several meanings, decided by what is under it and which mode is open. This is the rule that lets a trackpad reach everything.
stateDiagram-v2
direction LR
[*] --> Sculpting
Sculpting --> Orbiting: press off the model
Orbiting --> Sculpting: release
Sculpting --> Masking: M
Masking --> Sculpting: M again
Sculpting --> Manipulating: click a form
Manipulating --> Sculpting: put it away
Sculpting --> Caged: cage
Caged --> Sculpting: dismiss
Sculpting --> Rigging: new armature
Rigging --> Sculpting: leave the layer
| Mode | A press means | Reached by |
|---|---|---|
| Sculpting | a dab with the active tool, or an orbit where it misses | the default |
| Orbiting | turn the camera | a press that lands off the model, right-drag, or Option-drag |
| Masking | paint, lasso or rectangle, freezing or thawing | M, or a gesture from the Masks menu |
| Manipulating | grab an arm, a ring or a box of the widget | clicking a form or a placed shape |
| Caged | drag a lattice point, with a manipulator on the selection | Dynamics → Deformation cage |
| Rigging | grow a sphere, insert a joint, move or resize one — and empty space still orbits | Sculpt → New armature |
cargo build compiles the C++ engine. That is the part worth knowing before
the first one takes a few minutes.
graph TD
CARGO["cargo build"]
BS["claycore-sys build script"]
CMAKE["CMake configures ClayCore"]
FETCH["Fetches meshoptimizer, ufbx, xsimd"]
DETECT["Detects toolchains"]
CPU["CPU backend · always compiled in"]
ACCEL["Metal, CUDA, Vulkan, OpenCL where found"]
LIB["Static library"]
BINDGEN["bindgen reads clay.h"]
SYS["claycore-sys"]
REST["The Rust crates above it"]
CARGO --> BS
BS --> CMAKE
CMAKE --> FETCH
CMAKE --> DETECT
DETECT --> CPU
DETECT --> ACCEL
CPU --> LIB
ACCEL --> LIB
LIB --> SYS
BINDGEN --> SYS
SYS --> REST
style CPU fill:#2E3238,stroke:#C9C4BD,color:#C9C4BD
style LIB fill:#3A3E45,stroke:#C9C4BD,color:#C9C4BD
style CARGO fill:#D9744A,stroke:#D9744A,color:#23262B
Naming a feature whose toolchain is missing is a hard error at configure time, with the reason. Silently dropping it would produce a binary that cannot register the backend you asked for. Later builds only recompile the engine when the submodule's sources change.
| Minimum | Why | |
|---|---|---|
| Rust | 1.82 | workspace edition |
| CMake | 3.24 | ClayCore is a CMake project, built as part of cargo build |
| C++ compiler | C++20 | same |
| Network (first build only) | — | ClayCore fetches meshoptimizer, ufbx and xsimd at configure time |
just |
optional | Every recipe has its cargo equivalent; just is convenience, not a dependency |
Accelerated backends are optional and detected automatically. macOS picks up
Metal from the Xcode command line tools; Linux picks up CUDA (nvcc on PATH
or CUDA_PATH) and Vulkan (VULKAN_SDK, or pkg-config). The CPU backend is
always compiled in, so a machine with none of them still builds and runs.
The engine is a submodule, pinned to an exact commit:
git clone --recurse-submodules https://github.com/CyberdyneCorp/ClaySpaceDesktop.git
cd ClaySpaceDesktop
just setup # or: cargo buildAlready cloned without it? git submodule update --init --recursive. Forgetting
this is the most likely first failure, and the build says so by name rather than
letting CMake or the linker produce a worse message.
The first build compiles the C++ engine and takes a few minutes. Later builds only recompile it when the submodule's sources change.
Everything routine is a just recipe, so the
long-form commands live in one place. just on its own lists them.
just setup |
Fetch the pinned engine and build. What a fresh clone needs |
just run |
Open the application |
just run-cpu |
Open it with the CPU backend only, whatever the machine offers |
just test |
The whole suite, no display or GPU required |
just check |
Everything CI checks, in the order that fails fastest |
just visual |
Render every visual test and open the captures |
just bench |
The performance table: every brush, operation, conversion and bake |
just bench-only brush |
One group of it, for when the whole table is too long to wait for |
just bench-compare |
Against the recorded baseline for this platform, which the CI runner recorded. CI runs it on macOS (--tolerance-scale 10) and Linux (--tolerance-scale 3) — see the note on the facts table |
just bench-to run.json |
Record a whole run somewhere that is not the committed baseline |
just bench-against run.json |
Compare against a run recorded elsewhere — the other half of an engine A/B |
just segments |
Per-segment cost of every brush, which is what a sculptor feels as lag |
just budget |
Where one stroke segment's milliseconds go |
just bundle |
The distributable: a .app on macOS, a tarball on Linux |
just engine |
Which ClayCore this build is pinned to |
just engine-pin v0.60.0 |
Move the pin to a release tag |
just engine-main |
Build against ClayCore's main, to try a fix before it ships |
just engine-restore |
Back to the pinned release |
just diagnostics |
What this build is and what it decided to run on |
just profile |
The application, optimised, so an exported profile can be quoted |
just check runs seven gates that are seven different tools — the engine pin,
formatting, the layering rules, clippy, the suite, the specification and the
packaging scripts. Knowing to run all seven should not depend on having read
this file recently.
If a local CyberRemesher build reports a stale CMake cache after an Xcode SDK,
CMake installation or worktree change, run cargo clean -p cyberremesh-sys and
retry. The build now names a missing cached SDK, CMake executable or previous
source directory before CMake starts. A clean macOS checkout runs
cargo clippy --workspace -- -D warnings with the CMake available on PATH.
The engine is pinned to a release tag, because a release stays still and their
main is where they are still working. When a fix lands there and the question
is whether it reaches this application, just engine-main checks the submodule
out at main and just engine-restore puts the release back. The build says
it is ahead of the pin and carries on; nothing needs cleaning, because the
engine rebuilds itself when its sources change.
What must not follow from an afternoon's investigation is a changed engine.
git commit -a stages a moved submodule pointer along with everything else,
and CI builds from that pointer — so just check refuses a pin that is not a
release tag, reading what is staged rather than what is committed, so it
catches the mistake before it lands rather than after. Being checked out at
main is free; committing it is what gets stopped.
just test # the whole suite, no display or GPU required
just test-one visual_brushes # one target, with outputVisual tests render real frames into target/visual/, and just visual
renders them all and opens the directory. The captures there — some 640 — are how the
screenshots above were checked against what the tests assert.
long_mixed_session_render_defect_tripwire runs in the macOS visual suite and
currently expects the known pinholes or dark specks. When it stops finding
them, the test fails so its assertion can be changed to require a clean frame.
These are meant to be looked at. Several real bugs were invisible to the assertions and obvious in the picture — see docs/architecture.md.
Detection is automatic, but can be overridden:
just run-cpu # CPU only, any platform
CLAYCORE_CPU_ONLY=1 cargo build # the same, longhand
cargo build -p claycore --features metal # require Metal
cargo build -p claycore --features cuda,vulkan # require bothNote that CPU-only is an environment variable and not a cargo feature:
CLAYCORE_CPU_ONLY reaches the C++ configure step, which is where backends
are actually chosen. Every crate here declares default = [], so
--no-default-features would build exactly what a plain build does.
Backend choice affects speed, never results. The engine holds every GPU backend
to 1e-4 relative against the CPU scalar reference, and
every_registered_backend_agrees_with_cpu asserts it here too.
just diagnostics # or: cargo run -p claycore --example diagnosticsengine version : 0.120.1
expected ABI : 0.120.1
compiled backends: metal
registered : cpu, metal
Those two lines answer different questions. Compiled is what the build selected; registered is what the engine found at runtime. A backend can be compiled in and still fail to register, so only the second is trustworthy.
crates/
claycore-sys/ generated FFI to clay.h; no hand-written declarations
claycore/ safe wrapper — the only crate that calls claycore-sys
clayspace-model/ the domain: tools, interfaces, types. No engine dependency
clayspace-engine/ the ClayCore-backed implementations of those interfaces
clayspace-vm/ ViewModels: observable state + commands, no egui/wgpu
clayspace-view/ widgets and renderer; cannot reach the engine at all
clayspace-mcp/ the agent door: MCP over loopback, through the ViewModels
clayspace-app/ composition root, window, event loop
cyberremesh-sys/ generated FFI to CyberRemesher
cyberremesh/ safe wrapper — retopology, UV layout and baking
benchmarks/ one recorded baseline per platform
docs/ architecture, features, roadmap, and the screenshots above
tools/ the layering check, packaging and attribution scripts
vendor/ClayCore/ the engine, pinned
vendor/CyberRemesherAndUV/ the retopology engine, pinned
openspec/ the specification this is built against
This project is specified before it is built. The specification lives in
openspec/ and is the source of truth for what the application should do.
openspec list # what is still open, with its task counts
openspec show <change> # one change's proposal and deltas
openspec validate --all --strictImplementation follows the tasks.md under each change in
openspec/changes/. Behaviour changes go into the specification first.
| docs/architecture.md | How the layers fit, and the decisions behind them |
| docs/features.md | What works today, tool by tool |
| docs/roadmap.md | Milestones, what is left, open decisions |
| docs/why-move-was-slow.md | Why the SDF Move brush was slow and looked frozen — two defects reported as one, and what the investigation itself got wrong |
| ATTRIBUTION.md | The dependency manifest, generated from cargo metadata |
The screenshots in this file live in docs/images/. They are of the running
application on Linux with the CUDA backend, against ClayCore 0.73.0 — five
pins ago, so older than the shelf counts and the hierarchy above — at a
1280×800 window. They are not the visual-test captures;
those live in target/visual/, are written by cargo test for looking at, and
are compared against nothing — see the note on the facts table above.
MIT. ClayCore is MIT with an all-permissive dependency manifest, so static linking imposes no copyleft obligation.




















