Skip to content

Prop workshop

The prop workshop is a local authoring tool for the furniture and item packages under public/props/. It opens a generated source mesh, prepares distance LODs from it, and lets you fit compound colliders, interaction sockets, mass and buoyancy before a package is used in settlements. The repository ships 188 packages: the 180 settlement props from the two TRELLIS manifests (see Generated props (TRELLIS)) plus eight berry and fruit-cluster items. All but one (medieval-hay-bale-02, which is draft) are marked prepared.

Terminal window
npm run review:props
# http://127.0.0.1:5190/?asset=medieval-chair-01
npm run test:props

review:props runs scripts/prop-workshop.mjs, which binds to 127.0.0.1 on port 5190. If a workshop is already running there, the command prints its address and exits; open that address rather than starting a second copy. For an intentional second server use npm run review:props -- --port=5191. If another service holds the port, the launcher says so and suggests the next port.

The server bundles the UI with esbuild, loads Rapier from @dimforge/rapier3d-compat (which resolves to the engine’s vendored fork in vendor/rapier3d/) and serves Three.js from an isolated install that is kept out of the game’s package.json. Install that once per checkout:

Terminal window
npm install --prefix .gear-work/prop-compare --no-save --package-lock=false three@0.180.0

There is no hot reload: restart the server and reload the page after editing workshop code. The workshop lists only the 180 jobs in docs/settlement-props-generation.json and docs/settlement-prop-variants-generation.json; the berry and fruit packages are not in either manifest, so it does not open them.

Blender is needed only to prepare new LODs. The workshop and scripts/prop-prep.mjs default to the standard Blender 5.2 install location on Windows; set PROP_BLENDER to your blender.exe otherwise. Blender runs headless with two CPU threads.

The generated sources (under .gear-work/) and the reference images that the manifests’ image paths point to are local workbench files that are not in the repository. Without them the workshop still opens and tunes every package from its model.glb, but Prepare draft and the source preview are unavailable.

The toolbar has Studio and Water test modes, camera presets (Frame, Front, Side, Top), a Mesh selector (Auto LOD, LOD0 to LOD2, High-res source) and toggles for Compare LODs, Wire, Colliders, Sockets and Fit mannequin. The right panel has three tabs: Physics, Colliders and Sockets, plus a LOD transitions section. The refresh button (↻) re-reads generation status: needs image, queued, generating, failed or complete.

  1. For a generated source with no package yet, confirm the height in metres and the LOD0 triangle budget (100 to 20,000), then press Prepare draft. Blender builds the LODs in a candidate folder under .gear-work/props/workshop/; only a successful run is moved into public/props/<id>/ and packed. The draft gets one box collider around the bounds, no sockets and default physics. The raw source is never modified.
  2. Compare the LODs and inspect individual views for seams, holes, thin features and transitions. A triangle budget is not visual approval.
  3. Fit compound colliders (+ Box, + Cylinder). Chairs need separate seat, legs, stretchers and back so the leg space stays open; a closed crate can use one box. Yellow marks the physical centre of mass.
  4. Add sockets (+ Sit, + Sleep, + Use, + Place, + Grip) and check them with the mannequin, then tune mass, friction, restitution and buoyancy. In the water test, Run drop, Step 1/120 s, Simulate 10 s, Reset upright, Tip 65° and Submerge expose bad handling. Raise the mass to confirm a loaded object can sink.
  5. Press Mark prepared, then Save tuning. This records a reviewed package; it does not place the prop in settlements by itself.

A save validates the definition, rejects it if the file changed on disk since you loaded it, copies the previous prop.json to .gear-work/props/workshop-history/<id>/, and replaces the file atomically. A save can change only colliders, sockets, physics, stage and LOD thresholds; mesh names, triangle counts and provenance are kept from disk. Unsaved edits are kept in the browser’s local storage per asset and restored when you return, as long as the file on disk has not changed. Reload tuning discards them.

public/props/<id>/ holds three files: prop.json (the runtime definition), preparation.json (how the LODs were built, including the licensing toolchain block) and model.glb. The schema and validator are in src/props/prop-definition.ts.

  • Metres, kilograms, seconds; Y-up, +Z forward, origin at the floor centre.
  • Up to five LODs and 32 colliders. Colliders are box, cylinder or sphere, with local centres, half extents and XYZ Euler degrees. Cylinders use [radius, halfHeight, radius]; spheres use three equal radii.
  • Mass is distributed by collider volume times each collider’s relative massWeight.
  • LOD thresholds (minScreenHeight) are fractions of viewport height and must descend; all LODs share one origin and scale.
  • Up to 32 sockets, each of kind sit, sleep, use, place or grip, with an optional role token.

Socket kinds:

  • sit and sleep positions are pelvis pivots. Do not feed one straight into the player’s capsule origin. For sleep, yaw points from pelvis to head and the body lies face up.
  • use positions are feet on the floor; yaw is the facing (for example, behind a counter).
  • place positions are contact points on a surface where an item can go (a plate in front of a seat, a tool on a bench, books on a shelf); yaw is the item facing and radius the free surface radius.
  • grip positions are hand-IK pick-up points: the palm centre on the handle, yaw the direction the palm faces, radius the handle thickness. Grips may have no approaches.
  • Every other socket lists approaches: one to eight alternative feet positions to enter or serve from (a bed from either side or the foot end, a counter from both ends). The runtime picks a reachable one. Extra approaches never add capacity.
  • A place socket with role light marks an emitter: position is the flame, radius the glass clearance. The LOD builder exports faces within that radius as a separate <node>.noshadow glTF node (lamp glass), which the runtime keeps out of shadow casting and draws in the glass pass.
  • Items on tables face the diner (+Z) with the handle on the diner’s right (+X in the package frame).
  • Role setting is a diner’s place on a table and implies a chair 0.3 m beyond the edge along the spot’s yaw. Same-side settings must leave room for two chairs (test:props checks 0.70 m between them) and place spots must not overlap.

These are the roles the shipped packages use (kind:role).

TypeSockets
chair, benchsit per seat, approaches in front and at the sides; backless benches and hay bales have a sit on both long sides
bedone sleep (head toward the headboard) with side and foot approaches, two on the double beds (the -02 variants); sit on the free edges
tableplace:setting per diner, 0.25 m in from the edge, with a serving approach; round variants have four
counteruse:vendor in the aisle behind (approaches at the ends), use:buyer in front, place:goods on the top, place:stock on the rear tops
workbenchuse:worker on both long sides; place:work and place:vise on the top, and place:tools on most
shelfuse:browse in front; place:storage spots on each level
hearth (including braziers and fire bowls)use:warm, place:fire; depending on the model place:cook, place:mantel or place:logs
campfireuse:warm on four sides, place:fire, place:cook
barreluse:open on four sides; grip:carry handles on all but medieval-barrel-01; the -02 variants add place:top, some also a grip:lid
cratethe variant has use:take on four sides, place:cargo and grip:carry; medieval-crate-01 has no sockets
planterplace:plant
lampplace:light (four approaches on standing lamps, three on the wall lamps, which have no grip); some have a grip:carry
vessel and loose items (food, books, cutlery, plates, fruit)one grip:carry, no approaches
welluse:draw at the crank and rim, place:rest on the rim
troughuse:drink twice per long side, use:fill at each end, place:water
cartuse:pull between the shafts (not on the variant), use:push behind, place:cargo on the bed, grip:shaft at the shaft ends
signboarduse:read front and back
woodpileuse:take front and back (four sides on the variant), place:logs on top; the variant adds grip:carry
anviluse:worker on both long sides, place:work, place:tools
forgeuse:worker, use:bellows with a grip:bellows on the lever, place:fire, place:tools; medieval-forge-01 also has place:work
cauldronthe large one has use:stir on four sides, place:cook and grip:carry on the rings; the small pot has one grip
chestuse:open in front and on both sides (the lid hinges at the back), grip:carry; the variant adds place:top

src/props/prop-physics.ts creates the colliders (attachPropColliders), socket transforms and distributed buoyancy samples on the Rapier fork. Samples are rebuilt only when tuning changes. Wet samples apply forces at their own positions, so buoyancy produces torque without forced uprighting. displacementScale models enclosed air, or the wood fraction of a hollow object represented by a solid box. Overlapping colliders count their shared volume twice: avoid large overlaps or compensate deliberately.

The water test exposes water height, density, gravity, wave and current as temporary controls that are not saved. It steps physics at a fixed 120 Hz and renders on demand while paused. It approximates buoyancy; water does not enter or slosh inside a container. The mannequin is a fit aid, not the game’s animated character.

  • src/props/prop-furnishing.ts (planFurnishing) decides which package stands where in and around a settlement building. Packages take the layout’s furniture slots so residents keep their baked approaches; decoration looks for free floor. A slot that no package fits keeps its procedural furniture.
  • src/structures/blueprint-furnishing.ts (furnishFromPackages) merges the placed colliders into the building blueprint. placedPropColliders in src/props/prop-placement.ts composes the same frame as attachPropColliders, and test:props checks the two agree.
  • src/gpu/prop-package-pipeline.ts draws packages instanced per package through the mesh-prop visibility pipeline: LOD by screen height, shadows from LOD1 only and only within the LOD1 reach, .noshadow glass drawn in the glass pass and lit when the lamp is on.
  • A package that changes LOD crossfades for propPackageLodFade seconds (0.35 by default; call __propPackageLodFade(0) in the console for a hard cut).
  • Shopkeepers walk to a counter’s vendor socket; src/structures/package-stations.ts resolves the station and the route to it.
  • Package metadata ships in src/props/prop-index.generated.ts. Run npm run props:index after any package change; npm run test:furnishing fails while the index is stale.

model.glb holds every LOD as a root node (LOD0, LOD1, LOD2, plus LOD0.noshadow for lamp glass), compressed with gltfpack -c -noq -kn -km: meshopt stream compression without quantisation, so positions stay float32 for the engine’s parser. prop.json lods[i] carries file: "model.glb" and node: "LODi". The game reads only the base-colour texture, through the standard images[].mimeType / textures[].source fields, so WebP must be embedded that way and never as bare EXT_texture_webp. Materials are exported with metallicFactor 0.

scripts/prop-prep.mjs wraps the preparation steps and talks to a workshop running on port 5190 (it does not follow --port). It knows only the 180 manifest ids.

node scripts/prop-prep.mjs prepare <id> [height] [budget] [--yaw 90]
node scripts/prop-prep.mjs reprepare <id> [height] [budget] [--planar 6]
node scripts/prop-prep.mjs remesh <id> [height] [budget] [--yaw 0] [--voxel 0.006] [--resolution 2048]
[--normals smooth|weighted|transfer] [--lod-targets <lod1>,<lod2>]
node scripts/prop-prep.mjs measure <id> [--map] [name:xmin,xmax,ymin,ymax,zmin,zmax ...]
node scripts/prop-prep.mjs save <id> <tuning.json>
node scripts/prop-prep.mjs shell <id> [--level 2] [--resolution 512] [--bevel 0] [--lod1 0.055]
node scripts/prop-prep.mjs render <id> <lod ...> [--resolution 640]
node scripts/prop-prep.mjs aisle <id> --plane <z> --dz <metres>
node scripts/prop-prep.mjs finish <id> --label "Label" [--stage prepared]
  • remesh (scripts/blender/remesh-bake-lods.py) is the route every shipped package took: voxel remesh, manifold decimation to budget, smart UV, source albedo, normal and roughness baked on, LOD1 and LOD2 decimated from the same mesh. Use --normals weighted (face-area weighted normals): the package shader lights from the interpolated vertex normal and never reads the baked normal map, so smooth normals across long decimated triangles paint dark fans over flat seats and table tops. A rebuild keeps the package frame (height, sourceYaw and voxel size default to the installed package’s), keeps the existing tuning and a collider-shell LOD2, re-applies a recorded aisle shift, and parks the old package and its .gear-work/props/hires/<id>/ originals under .gear-work/props/workshop/.
  • --yaw turns a source whose front does not face +Z; the angle is recorded as sourceYaw in preparation.json and reused by measure and shell.
  • shell (scripts/blender/bake-collider-lod.py) builds a closed far LOD from the saved colliders in the same frame and bakes the source maps onto it. Fit the colliders first. Six medieval packages use a shell LOD2 (bench, bed, table, counter, workbench, shelf).
  • aisle (scripts/blender/shift-mesh-region.py) moves everything behind a plane (the rear piece of a two-piece counter) back by dz: every LOD from the hires originals, colliders and place spots by dz, vendor stations and aisle approaches by dz/2. It refuses a plane that cuts a triangle, and is recorded as aisleShift in preparation.json.
  • render unpacks model.glb to an uncompressed model-plain.glb (Blender cannot import meshopt) and renders one LOD node with scripts/blender/render-prop-review.py.

Diagnostics: scripts/blender/diagnose-prop-mesh.py reports the shells and non-manifold edges that stall plain decimation; scripts/blender/diagnose-lod-shading.py renders one package’s LOD0 against its source; scripts/blender/render-lod-sheet.py makes contact sheets of many packages in one Blender session.

The Blender bake writes 2048 px PNGs. Two steps turn them into a package:

  1. python scripts/prop-slim-textures.py <id> converts to WebP at 1024 px for LOD0 (512 px when the prop’s largest extent is under 0.3 m), halved per LOD and never under 256 px. The original 2048 px LOD files are copied to .gear-work/props/hires/<id>/ on the first run and are the source for every later re-slim, so they override a newer bake unless moved away (remesh does this for you).
  2. python scripts/prop-pack.py pack <id> merges lod0/1/2.glb (moved to .gear-work/props/lods/<id>/) into model.glb and rewrites the LOD entries in both JSON files. prop-prep.mjs remesh and shell and the workshop’s Prepare draft run this step themselves.

Both bake scripts bake albedo and roughness by emitting the source shader input (EMIT), never as a diffuse pass. TRELLIS marks surfaces metallic with a noisy map, and a diffuse bake multiplies base colour by (1 minus metallic), which turns props black.

If a mesh cannot meet its budget, preparation fails without touching the raw source. Raise the budget or retopologise; do not accept shredded geometry.

From each package’s prop.json:

PackageLOD trianglesNotes
medieval-chair-01 Oak tavern chair2,500 / 1,074 / 36617 colliders; sit pelvis 0.551 m; 18.5 kg.
medieval-crate-01 Ironbound oak crate1,250 / 634 / 424One closed box; 0.70 m tall; 32 kg; displacement scale 0.15.
medieval-bench-01 Oak settle bench2,950 / 1,184 / 12010 colliders; two sit pelvises at 0.595 m; 72 kg; shell LOD2.
medieval-bed-01 Oak sunburst bed3,500 / 1,398 / 15613 colliders; sleep pelvis 0.682 m with three approaches, edge sit on each side; 120 kg; shell LOD2.
medieval-table-01 Oak refectory table2,514 / 1,202 / 19216 colliders; top 0.755 m; six settings, 0.80 m pitch on the long sides; 140 kg; shell LOD2.
medieval-counter-01 Oak shop counter5,000 / 2,844 / 48Four boxes; vendor, buyer, goods and stock sockets; aisle shifted; 380 kg; shell LOD2.
medieval-workbench-01 Oak craft workbench4,350 / 2,418 / 14412 colliders; two worker stations; work, vise and tools spots; 150 kg; shell LOD2.
medieval-shelf-01 Oak sunburst bookcase3,750 / 1,496 / 968 colliders; browse station, two storage spots per level; 130 kg; shell LOD2.

npm run test:props runs real Rapier simulations and raycasts, checks each LOD node’s triangle count against prop.json, rejects malformed tuning, and tests floating, sinking and tilting, socket placement rules and collider parity with the world placement. Visual checks and in-game testing are still necessary.

A DevTools error that is not the workshop’s

Section titled “A DevTools error that is not the workshop’s”

With Chromium DevTools open you may see an anonymous VM… error from reportAllChanges reading an undefined startTime. It comes from the Web Vitals monitor that DevTools injects, triggered by soft navigation (the workshop updates its ?asset= query string); see https://github.com/GoogleChrome/web-vitals/issues/792. The workshop does not import web-vitals. Close DevTools and reload to remove it. Do not silence page errors globally or change collider or buoyancy code to hide it.

Written by release:site-sync from docs/prop-workshop.md at v2026.09.22. Edit the engine document, not this page.