Skip to content

Repository files navigation

ClaySpaceDesktop

A 3D sculpting desktop application in Rust, rendering with WebGPU and driving the ClayCore SDF + voxel engine through its C ABI. macOS and Linux.

The application as it opens: the menu bar, the options bar carrying the stroke's settings and symmetry, the scene tree and layer stack, the representation bar over the viewport on its starting form, the brush shelf, and the material, geometry and brush inspectors

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.


Contents


Running it

just run          # or: cargo run -p clayspace-app --release

Opens a window on a starting form. just lists everything else this repository routinely does; see Common tasks.

Pointer and keys

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.


Driving it from an agent

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 84213

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

Features

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.

The shelf follows the active layer

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:

the SDF shelf: Standard, Inflate, Smooth, Move, Topological Move, Pinch, Planar, Layer, Mask, Snake Hook, Polish, Relax, Trim, Clay, Crease

the voxel shelf: Standard, Inflate, Smooth, Move, Pinch, Scrape, Planar, Fill, Layer, Mask, Nudge, Paint, Erase

the mesh shelf, which carries the engine's sixteen fixed-topology brushes plus Mask

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.

Sculpting: a stroke is one call and one undo

two clay passes across the starting form, mirrored by symmetry about X

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.

Focus mode, and the regions that move

focus mode: the menu bar, the view presets, the sculpt, and a floating readout naming the brush, the representation, and the stroke's size, intensity and flow

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.

Masking

the Masks menu open over a painted mask, with the mask inspector showing the frozen-cell count and the extrude controls

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.

Cutting with a shape you draw over the model

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.

Retopology, UV and baking

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.

A scene is a list of subtools

a torus inserted as a second subtool, active, with the move manipulator on it drawn faint where the clay stands in front of it

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.

Inserting a form, and where it goes

the Shapes panel: insert as a new subtool or into the active one, the shape chooser on Torus with its major and minor radius, import a mesh as a subtool, and copy an existing subtool

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.

A boolean between two subtools

the Boolean panel: subtraction, base Forma, tool Toro, the cost of the crossing, and the warning that the result is resolved rather than live

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.

Crossing between representations

the Convert panel stating what the crossing costs: how far the surface moves, what thickness vanishes, how many cells, and what does not come back

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

Voxel grids

a voxel subtool: the grid's own shelf, the cell size and occupied-cell count, the draw mode, and the record-pass control under the layer stack

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
a voxel subtool drawn as its smoothed form the same subtool drawn as its cells

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.

Mesh layers, and rebuilding one

a mesh subtool: the mesh shelf, the fixed-topology note, and the rebuild controls — resolution, remove loose pieces, follow the current form, sharp edges

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.

Subdivision hierarchies

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.

Deformers, a cage and a curve

a lattice of control points around the form, with the form drawn through it so the handles behind can still be reached

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.

ZSphere armatures

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;
  • Delete removes 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
the skinned membrane over the rig the ZSpheres and the membrane between them

Reference images

the reference panel with a drawing loaded on the front plane: opacity, height, offsets and depth per plane, and the model's own opacity above them

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.

Viewport and shading

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.

Geometry in and out

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.

Documents, history and sessions

  • 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 .clayspace on 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_HOME on 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
Loading

Three languages

the same menu bar and options bar in English, Portuguese and Spanish

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.

Acceleration and diagnostics

the diagnostics report: application and engine versions, the vendored revision, the platform, registered and active backends, the adapter, the stall list, and the frame budget

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.

What is not built yet

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.


Architecture

docs/architecture.md has the decisions and the measurements behind them; this is the shape.

The layers

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
Loading
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-view cannot reach the engine, directly or transitively. A View reads ViewModel state and emits commands; it has no other way to affect anything.
  • clayspace-vm cannot reach egui, wgpu or winit, 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.

What happens when you sculpt

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
Loading

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.

How a document is arranged

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
Loading

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.

What the pointer means

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

What a build actually does

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
Loading

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.


Prerequisites

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.

Getting it

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 build

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

Common tasks

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.

Trying an engine fix before it ships

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.

Testing

just test                         # the whole suite, no display or GPU required
just test-one visual_brushes      # one target, with output

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

Choosing backends explicitly

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 both

Note 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 diagnostics
engine 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.

Layout

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

Working on it

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

Implementation follows the tasks.md under each change in openspec/changes/. Behaviour changes go into the specification first.

Documentation

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.

Licence

MIT. ClayCore is MIT with an all-permissive dependency manifest, so static linking imposes no copyleft obligation.

About

Rust Sculpting desktop app written in Rust and using WebGPU

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages