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.
npm run review:props# http://127.0.0.1:5190/?asset=medieval-chair-01npm run test:propsreview: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:
npm install --prefix .gear-work/prop-compare --no-save --package-lock=false three@0.180.0There 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.
Review one asset
Section titled “Review one asset”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.
- 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 intopublic/props/<id>/and packed. The draft gets one box collider around the bounds, no sockets and default physics. The raw source is never modified. - Compare the LODs and inspect individual views for seams, holes, thin features and transitions. A triangle budget is not visual approval.
- 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.
- 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.
- 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.
Shared package contract
Section titled “Shared package contract”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,cylinderorsphere, 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,placeorgrip, with an optionalroletoken.
Socket kinds:
sitandsleeppositions are pelvis pivots. Do not feed one straight into the player’s capsule origin. Forsleep, yaw points from pelvis to head and the body lies face up.usepositions are feet on the floor; yaw is the facing (for example, behind a counter).placepositions 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 andradiusthe free surface radius.grippositions are hand-IK pick-up points: the palm centre on the handle, yaw the direction the palm faces,radiusthe 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
placesocket with rolelightmarks an emitter: position is the flame,radiusthe glass clearance. The LOD builder exports faces within that radius as a separate<node>.noshadowglTF 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
settingis 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:propschecks 0.70 m between them) and place spots must not overlap.
Socket conventions by prop type
Section titled “Socket conventions by prop type”These are the roles the shipped packages use (kind:role).
| Type | Sockets |
|---|---|
| chair, bench | sit per seat, approaches in front and at the sides; backless benches and hay bales have a sit on both long sides |
| bed | one sleep (head toward the headboard) with side and foot approaches, two on the double beds (the -02 variants); sit on the free edges |
| table | place:setting per diner, 0.25 m in from the edge, with a serving approach; round variants have four |
| counter | use:vendor in the aisle behind (approaches at the ends), use:buyer in front, place:goods on the top, place:stock on the rear tops |
| workbench | use:worker on both long sides; place:work and place:vise on the top, and place:tools on most |
| shelf | use: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 |
| campfire | use:warm on four sides, place:fire, place:cook |
| barrel | use:open on four sides; grip:carry handles on all but medieval-barrel-01; the -02 variants add place:top, some also a grip:lid |
| crate | the variant has use:take on four sides, place:cargo and grip:carry; medieval-crate-01 has no sockets |
| planter | place:plant |
| lamp | place: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 |
| well | use:draw at the crank and rim, place:rest on the rim |
| trough | use:drink twice per long side, use:fill at each end, place:water |
| cart | use:pull between the shafts (not on the variant), use:push behind, place:cargo on the bed, grip:shaft at the shaft ends |
| signboard | use:read front and back |
| woodpile | use:take front and back (four sides on the variant), place:logs on top; the variant adds grip:carry |
| anvil | use:worker on both long sides, place:work, place:tools |
| forge | use:worker, use:bellows with a grip:bellows on the lever, place:fire, place:tools; medieval-forge-01 also has place:work |
| cauldron | the large one has use:stir on four sides, place:cook and grip:carry on the rings; the small pot has one grip |
| chest | use:open in front and on both sides (the lid hinges at the back), grip:carry; the variant adds place:top |
Physics and the water test
Section titled “Physics and the water test”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.
How packages reach the game
Section titled “How packages reach the game”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.placedPropCollidersinsrc/props/prop-placement.tscomposes the same frame asattachPropColliders, andtest:propschecks the two agree.src/gpu/prop-package-pipeline.tsdraws packages instanced per package through the mesh-prop visibility pipeline: LOD by screen height, shadows from LOD1 only and only within the LOD1 reach,.noshadowglass drawn in the glass pass and lit when the lamp is on.- A package that changes LOD crossfades for
propPackageLodFadeseconds (0.35 by default; call__propPackageLodFade(0)in the console for a hard cut). - Shopkeepers walk to a counter’s
vendorsocket;src/structures/package-stations.tsresolves the station and the route to it. - Package metadata ships in
src/props/prop-index.generated.ts. Runnpm run props:indexafter any package change;npm run test:furnishingfails while the index is stale.
Building LODs
Section titled “Building LODs”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,sourceYawand 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/.--yawturns a source whose front does not face +Z; the angle is recorded assourceYawinpreparation.jsonand reused bymeasureandshell.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 bydz: every LOD from the hires originals, colliders andplacespots bydz, vendor stations and aisle approaches bydz/2. It refuses a plane that cuts a triangle, and is recorded asaisleShiftinpreparation.json.renderunpacksmodel.glbto an uncompressedmodel-plain.glb(Blender cannot import meshopt) and renders one LOD node withscripts/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:
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 (remeshdoes this for you).python scripts/prop-pack.py pack <id>mergeslod0/1/2.glb(moved to.gear-work/props/lods/<id>/) intomodel.glband rewrites the LOD entries in both JSON files.prop-prep.mjs remeshandshelland 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.
Prepared examples
Section titled “Prepared examples”From each package’s prop.json:
| Package | LOD triangles | Notes |
|---|---|---|
medieval-chair-01 Oak tavern chair | 2,500 / 1,074 / 366 | 17 colliders; sit pelvis 0.551 m; 18.5 kg. |
medieval-crate-01 Ironbound oak crate | 1,250 / 634 / 424 | One closed box; 0.70 m tall; 32 kg; displacement scale 0.15. |
medieval-bench-01 Oak settle bench | 2,950 / 1,184 / 120 | 10 colliders; two sit pelvises at 0.595 m; 72 kg; shell LOD2. |
medieval-bed-01 Oak sunburst bed | 3,500 / 1,398 / 156 | 13 colliders; sleep pelvis 0.682 m with three approaches, edge sit on each side; 120 kg; shell LOD2. |
medieval-table-01 Oak refectory table | 2,514 / 1,202 / 192 | 16 colliders; top 0.755 m; six settings, 0.80 m pitch on the long sides; 140 kg; shell LOD2. |
medieval-counter-01 Oak shop counter | 5,000 / 2,844 / 48 | Four boxes; vendor, buyer, goods and stock sockets; aisle shifted; 380 kg; shell LOD2. |
medieval-workbench-01 Oak craft workbench | 4,350 / 2,418 / 144 | 12 colliders; two worker stations; work, vise and tools spots; 150 kg; shell LOD2. |
medieval-shelf-01 Oak sunburst bookcase | 3,750 / 1,496 / 96 | 8 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.