docs(architecture): add item-authoring guide for paintable GLBs

Documents the content-author contract for catalog item GLBs: slot_
material naming + deriveSlotId rules, authored defaults and
pascal_material extras, the cutout reserved mesh, the ~1 UV unit/m world
scale, vertex colours for fixed detail, and the validated Blender + glTF
export recipe (Custom Properties export gotcha, gltfpack -ke extras
preservation). Indexes the page in the architecture README and links it
from materials-and-themes.md (previously a dangling "phase-6 Blender
recipe" reference).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-19 09:04:42 -04:00
co-authored by Claude Opus 4.8
parent 36ad0f9933
commit c726721ec6
3 changed files with 89 additions and 1 deletions
+1
View File
@@ -11,6 +11,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa
| [renderers](renderers.md) | Node renderer pattern in `packages/viewer` | | [renderers](renderers.md) | Node renderer pattern in `packages/viewer` |
| [node-definitions](node-definitions.md) | Three-checkbox composition model for registry-driven kinds (`geometry` / `renderer` / `system`) | | [node-definitions](node-definitions.md) | Three-checkbox composition model for registry-driven kinds (`geometry` / `renderer` / `system`) |
| [materials-and-themes](materials-and-themes.md) | Surface colour: surface roles, colour presets, the textures axis, and scene themes (appearance / ground / clay tints) | | [materials-and-themes](materials-and-themes.md) | Surface colour: surface roles, colour presets, the textures axis, and scene themes (appearance / ground / clay tints) |
| [item-authoring](item-authoring.md) | Content-author contract for catalog item GLBs: `slot_` material naming, authored defaults + `pascal_material` extras, the `cutout` reserved mesh, UV world scale, and the validated Blender/export recipe |
| [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 | | [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 |
| [tools](tools.md) | Editor tools structure, 2D↔3D behavioral parity, manipulation constraints, and Shift bypass defaults | | [tools](tools.md) | Editor tools structure, 2D↔3D behavioral parity, manipulation constraints, and Shift bypass defaults |
| [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic | | [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic |
+87
View File
@@ -0,0 +1,87 @@
# Item authoring (paintable GLBs)
How to author a catalog item GLB so the editor keeps its materials as the default
appearance **and** exposes named parts as paintable **slots**. This is the content-author
contract for the file format; the runtime side (how a slot's colour is resolved and
re-applied) lives in [materials-and-themes](materials-and-themes.md), and procedural
slot declarations live in [node-definitions](node-definitions.md).
The whole convention is opt-in by material name — an item with no slot materials renders
its authored/baked look untouched and exposes nothing to paint.
## The slot contract
A glTF **material** whose name starts with `slot_` (case-insensitive) marks a paintable
part. The canonical rules live in `packages/core/src/lib/slots.ts` and are shared by both
the upload scan and the renderer, so authored names, stored slot metadata, and runtime
meshes can never drift:
- **One material per paintable part, named `slot_<part>`.** e.g. `slot_frame`,
`slot_seat`, `slot_bed_frame`.
- **The slot id is derived** by `deriveSlotId`: strip the `slot_` prefix, drop a Blender
numeric-dedupe suffix (`.001`), lowercase the rest. So `slot_Bed_Frame` and
`slot_bed_frame.001` both resolve to the slot `bed_frame`. Splitting one logical part
across several Blender materials that dedupe to the same id **merges** them into a
single slot — intentional, so a part split for modelling reasons still paints as one.
- **Unmarked materials stay authored, forever.** A material without the `slot_` prefix
renders exactly as authored and exposes no slot. This is the right choice for fixed
labels, decals, signage, and baked detail (e.g. a fire-alarm sign, an AC front panel).
- **Paintable by naming alone.** A solid-colour slot samples no UVs, so a flat-colour
item becomes fully paintable just by renaming its materials — no re-unwrap needed.
## Default appearance
The default look of a slot is **the authored material's own data** (its
`baseColorFactor` / maps), never encoded in the slot name. Painting a slot overrides that
default; resetting returns to it.
Curated cross-item defaults (e.g. "this seat defaults to the catalog linen") travel
**with the asset** as `pascal_material` glTF material extras, read at runtime from
`material.userData.pascal_material`. They're optional — with none present, the authored
material is the default.
## Reserved names
- **`cutout`** — a **mesh** (not a material) named `cutout` is treated as a boolean-cut
helper: it is hidden at runtime and never becomes a slot or a visible surface. Use it
for the negative volume a host opening subtracts, not for geometry you want shown.
## UV world scale (~1 unit per metre)
Tileable finishes assume the same world-scale UV contract as procedural surfaces: **1 UV
unit = 1 m** (see [materials-and-themes](materials-and-themes.md) → *Texture world
scale*). This is an **authoring requirement, not a render-time correction** — the slot
validator's UV-presence check flags slots that need UVs and don't have them. Flat-colour
slots need no UVs at all.
For fixed multi-colour detail *within* one slot, bake it into **vertex colours**: painting
swaps the slot's material but vertex colours ride along, so a two-tone part stays two-tone
under any finish.
## Blender recipe (validated)
1. **Apply scale**`Ctrl+A` → Scale, so 1 Blender unit exports as 1 m and the UV
world-scale promise holds.
2. **Rename materials** to `slot_<part>` for each paintable part.
3. **Hard-surface slots** — UV → **Cube Projection** with **Cube Size = 1.0**. This gives
exactly 1 UV unit/m by construction; overlapping islands are fine because finishes tile
and nothing is baked.
4. **Curved / soft slots** — use the **Texel Density** addon at **10.24 px/cm @ 1024 px**.
5. **Check** with a UV grid texture before exporting.
## Export (critical)
- **Custom Properties must be enabled.** In the Blender glTF exporter, turn on
*Include → Custom Properties*, or `pascal_material` extras never reach the GLB and
curated defaults are silently lost.
- **Preserve extras through optimisation.** Run an extras-preserving optimiser — gltfpack
with `-ke` (keep extras). Author-side extras are read once at upload, so any stripping
between export and upload loses them; the validator warns when extras look stripped.
## How the editor reads the result
On load, the renderer keeps the GLB's authored materials and, for every material whose
name derives a slot id, captures that slot on the instance so the paint tool can target
`(nodeId, slotId)`. Items authored with no `slot_` materials simply render their authored
look and expose no slots — there is no separate "mode" to set; the behaviour follows from
the material names in the file.
+1 -1
View File
@@ -87,7 +87,7 @@ Every procedural surface generates UVs in metres: 1 UV unit = 1 m.
This contract is shared by wall `systems/wall/wall-system.tsx` (`ExtrudeGeometry`), slab `systems/slab/slab-system.tsx` (`generatePositiveSlabGeometry`, and `generatePoolGeometry`), ceiling `systems/ceiling/ceiling-system.tsx`, roof `systems/roof/roof-system.tsx`, and chimney/dormer `nodes/src/chimney/geometry.ts`. This contract is shared by wall `systems/wall/wall-system.tsx` (`ExtrudeGeometry`), slab `systems/slab/slab-system.tsx` (`generatePositiveSlabGeometry`, and `generatePoolGeometry`), ceiling `systems/ceiling/ceiling-system.tsx`, roof `systems/roof/roof-system.tsx`, and chimney/dormer `nodes/src/chimney/geometry.ts`.
GLB item slots follow the same ~1 UV unit/m authoring convention, enforced by the slot validator's UV-presence check and the phase-6 Blender recipe. This is an authoring requirement, not a render-time correction. GLB item slots follow the same ~1 UV unit/m authoring convention, enforced by the slot validator's UV-presence check and the Blender recipe in [item-authoring](item-authoring.md). This is an authoring requirement, not a render-time correction.
A catalog material's `repeat` (`mapProperties.repeatX/repeatY` in `packages/core/src/material-library.ts`) is therefore a per-material world-scale setting: tiles per metre. A catalog material's `repeat` (`mapProperties.repeatX/repeatY` in `packages/core/src/material-library.ts`) is therefore a per-material world-scale setting: tiles per metre.