cozyclay 3.1.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agent/agent-routes.mjs +8 -7
  3. package/bin/agent/plan-tool.mjs +114 -0
  4. package/bin/agent/script-tool.mjs +2 -0
  5. package/bin/agent/sculpt-sketch.mjs +190 -0
  6. package/bin/agent/session-store.mjs +2 -1
  7. package/bin/agent/sketch-code.mjs +151 -0
  8. package/bin/agent/sketch-sandbox.mjs +102 -0
  9. package/bin/agent/studio-prompt.mjs +1 -1
  10. package/bin/agent/turn-frames.mjs +4 -0
  11. package/dist/app/index.html +5 -5
  12. package/dist/assets/TaskBoard-ngdqiu7u.js +1 -0
  13. package/dist/assets/app-DmR7jBkz.js +5144 -0
  14. package/dist/assets/{motion3-compile-B887n77J.js → motion3-compile-BB3_1-EB.js} +1 -1
  15. package/dist/assets/{scene-asset-sync-COGOlF5T.css → scene-asset-sync-CBx7XRti.css} +1 -1
  16. package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +13 -0
  17. package/dist/assets/task-board-ErQvfoaz.js +14 -0
  18. package/dist/assets/workflow-zQM-EKW1.js +179 -0
  19. package/dist/cozyclay-package.json +1 -1
  20. package/dist/workflow/index.html +4 -4
  21. package/mcp/tool-handlers.mjs +2 -2
  22. package/package.json +3 -1
  23. package/src/App.jsx +2 -2
  24. package/src/commands/elements.js +4 -3
  25. package/src/commands/objects.js +1 -1
  26. package/src/material-look.js +189 -0
  27. package/src/material-presets.js +78 -0
  28. package/src/material-textures.js +157 -0
  29. package/src/object-look.js +9 -5
  30. package/src/primitive-look.js +38 -0
  31. package/src/props.jsx +20 -28
  32. package/src/render-passes.js +7 -1
  33. package/src/scene-objects.js +2 -2
  34. package/src/sculpt-colours.js +65 -0
  35. package/src/sculpt-compare.js +13 -50
  36. package/src/sculpt-csg.js +112 -0
  37. package/src/sculpt-curve.js +123 -0
  38. package/src/sculpt-cutters.js +43 -0
  39. package/src/sculpt-detail.js +207 -0
  40. package/src/sculpt-geometry-loft-convex.js +100 -0
  41. package/src/sculpt-geometry-shapes.js +250 -0
  42. package/src/sculpt-geometry.js +33 -15
  43. package/src/sculpt-loft-rings.js +225 -0
  44. package/src/sculpt-noise.js +65 -0
  45. package/src/sculpt-part-fields.js +27 -0
  46. package/src/sculpt-recipe-read.js +65 -0
  47. package/src/sculpt-recipe.js +33 -75
  48. package/src/sculpt-run.js +1 -1
  49. package/src/sculpt-scatter-items.js +183 -0
  50. package/src/sculpt-scatter-shape.js +57 -0
  51. package/src/sculpt-scatter.js +124 -0
  52. package/src/sculpt-shapes-curved.js +211 -0
  53. package/src/sculpt-shapes-loft-convex.js +218 -0
  54. package/src/sculpt-shapes.js +138 -0
  55. package/src/sculpt-sketch-state.js +76 -0
  56. package/src/sculpt-sketch.jsx +124 -0
  57. package/src/sculpt-surface-fields.js +65 -0
  58. package/src/studio-elements.js +5 -1
  59. package/src/workflow/AgentPanel.jsx +4 -4
  60. package/src/workflow/AgentPlanCard.jsx +41 -0
  61. package/src/workflow/agent-client.js +14 -11
  62. package/src/workflow/agent-plan.css +93 -0
  63. package/src/workflow/agent-plan.js +59 -0
  64. package/tools/run-tests.mjs +3 -3
  65. package/dist/assets/TaskBoard-DdDXMgZm.js +0 -1
  66. package/dist/assets/app-B2BpfCl2.js +0 -5088
  67. package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
  68. package/dist/assets/task-board-BKaM7d-9.js +0 -14
  69. package/dist/assets/workflow-B8Dhzexj.js +0 -179
@@ -1 +1 @@
1
- {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.1.0\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"a82f58c8669edb7800fcdb79939b1cfc242616a17675fdf278927d7f4f5d016e\"}","signature":"EmIwI46B/uZyNSY0QYgaR43ElVC4MFt2ROKPMaDioTSwJytnF2kwqj3qWkaJIPvqeVR4sN5qglBO6Ya+mOprCQ=="}
1
+ {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.1.1\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"65554e5162b89f817b066016390471333bfcabcee80e0ae0ee46939bdc759599\"}","signature":"M4R6haq7YzLWxDPcdy5h4zI/w7nVEBubTszIj4UzgWEKB4XZgZYtrjsQJmjt2rF3IooyCKnvMlzNiYB/arAGDg=="}
@@ -8,13 +8,13 @@
8
8
  <meta name="theme-color" content="#101116" />
9
9
  <meta name="description" content="CozyClay visual workflow builder" />
10
10
  <title>CozyClay Workflow</title>
11
- <script type="module" crossorigin src="/assets/workflow-B8Dhzexj.js"></script>
11
+ <script type="module" crossorigin src="/assets/workflow-zQM-EKW1.js"></script>
12
12
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
13
13
  <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-P2Xu9kJm.js">
14
- <link rel="modulepreload" crossorigin href="/assets/task-board-BKaM7d-9.js">
14
+ <link rel="modulepreload" crossorigin href="/assets/task-board-ErQvfoaz.js">
15
15
  <link rel="modulepreload" crossorigin href="/assets/fflate.module-KbGj5-H6.js">
16
- <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-CQzEdsqu.js">
17
- <link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-COGOlF5T.css">
16
+ <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-Cgdtr1Yw.js">
17
+ <link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-CBx7XRti.css">
18
18
  <link rel="stylesheet" crossorigin href="/assets/workflow-DRdwoDBT.css">
19
19
  </head>
20
20
  <body>
@@ -1155,10 +1155,10 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
1155
1155
  title: "Sculpt a clay prop from a recipe of soft parts",
1156
1156
  description:
1157
1157
  "Unlike place_object, import_mesh and compare_sculpt, sculpt_object builds or changes a clay prop from a recipe of parts you write, typically after reading a reference image. It needs no file and no catalogue entry. " +
1158
- "recipe = { version: 1, parts: [{ id, shape, size: [x, y, z], position?, rotation?, color?, parent?, mirror?, roundness?, taper?, border? }] }. " +
1158
+ "recipe = { version: 1, parts: [{ id, shape, size: [x, y, z], position?, rotation?, color?, parent?, mirror?, ...shape fields, material?, finish?, detail?, gradient?, op? }] }. " +
1159
1159
  "Units are metres and degrees. The floor is y = 0, front is +z, and the subject's own left is +x. size is the full extent; position is the part's centre, in its parent part's frame when parent is set. " +
1160
1160
  "shape: blob (soft superellipsoid: roundness 0 is an ellipsoid, 1 is a rounded box; taper narrows the top when positive and the bottom when negative), box (roundness bevels the edges), " +
1161
- "cylinder (taper 1 is a cone), torus (size [outer, outer, tube]), frame (a rectangular ring in x/y with a border width, for glasses rims, windows and picture frames). " +
1161
+ "cylinder (taper 1 is a cone), torus (size [outer, outer, tube]), frame (a rectangular ring in x/y with a border width, for glasses rims, windows and picture frames), " + "lathe { profile: [[r 0..1 of half-width, y 0..1 of height], ...] bottom to top, smooth? } for turned objects (pot, vase, bottle, bowl, lampshade, mushroom), " + "tube { path: [[x, y, z] each -0.5..0.5 of size], thickness (m, diameter), taper?, closed? } for ropes, vines, cables, handles, branches and bike frames, " + "extrude { outline: [[x, y] -0.5..0.5 of size], holes?, roundness? } for flat cut shapes pushed through size z (sign, leaf, gable, gear, star), " + "loft { sections: [{ y 0..1, points: [[x, z]...] } or { y, w, d, x?, z?, roundness? }] bottom to top (first y 0, last y 1), smooth? } for cross-sections that change (hull, car body, shoe, loaf; lay it down with rotation [-90,0,0]), " + "convex { points: [[x, y, z]...] or seed + facetCount?, facets? } for rocks, boulders, gems and crystals, " + "scatter { item: blade|tuft|flower|leaf|pebble|twig, count <= 4000, spread: ground|shell, seed? } for thousands of small items as ONE part (grass, flower beds, gravel; shell covers a crown or bush it is parented to; 12000 items per recipe). " + "Any part also takes material clay|wood|straw|stone|brick|concrete|metal|glass|ceramic|plastic|rubber|fabric|paper|foliage (real texture and shine; color tints it; never fake glass with opacity or thatch with stripes) and finish matte|satin|gloss; detail { amount 0..1, scale?, seed? } for a lumpy surface (clouds, bushes, rocks, hay, bread), gradient { color, axis? y|x|z, from?, to? } to blend into a second colour (sky-lit clouds, fruit, sunset walls), " + "and op: subtract|intersect, which makes the part an undrawn cutter of its parent (windows, doors, arches; hollow a lathe with a smaller lathe of the same profile; cut deeper than the wall; mirror cuts both sides). " +
1162
1162
  "mirror: true adds a copy reflected across x = 0 of the parent frame, with its children; author the subject's left (+x) side once. color is #rrggbb. At most 48 parts after mirrors. " +
1163
1163
  "To fix a sculpt, pass its id with edits instead of a whole recipe: [{ set: partId, ...fields }, { add: part }, { remove: partId }] (parent: null and mirror: false clear; remove takes the part's children). " +
1164
1164
  "capture: true also returns the editor's frame (connected editor only), so one call is one round. " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozyclay",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "Open source previs software in the browser: block a scene, pose characters, author camera moves and cuts, then take the same shots to an AI video model.",
5
5
  "scripts": {
6
6
  "dev": "node tools/dev-full.mjs --host 127.0.0.1 --port 5180",
@@ -82,6 +82,8 @@
82
82
  "react-icons": "^5.7.0",
83
83
  "reactflow": "^11.11.4",
84
84
  "three": "^0.185.1",
85
+ "three-bvh-csg": "^0.0.18",
86
+ "three-mesh-bvh": "^0.9.16",
85
87
  "vite": "^8.2.0"
86
88
  },
87
89
  "bin": {
package/src/App.jsx CHANGED
@@ -137,7 +137,7 @@ import {
137
137
  pathMetrics,
138
138
  strokeToPathPoints,
139
139
  } from "./object-path.js";
140
- import { carriedPointAt, sceneObjectsAt } from "./object-travel.js"; import { objectTracksOf } from "./motion3/object-tracks.js"; import { ViewGhosts } from "./view-ghosts.jsx"; import { DraftGhost } from "./draft-ghost.jsx";
140
+ import { carriedPointAt, sceneObjectsAt } from "./object-travel.js"; import { objectTracksOf } from "./motion3/object-tracks.js"; import { ViewGhosts } from "./view-ghosts.jsx"; import { DraftGhost } from "./draft-ghost.jsx"; import { SculptSketch } from "./sculpt-sketch.jsx";
141
141
  import { routeOwnerFor } from "./route-owner.js";
142
142
  import {
143
143
  analyticsActive,
@@ -4791,7 +4791,7 @@ export default function App() {
4791
4791
  />
4792
4792
  </ObjectCarrier>
4793
4793
  ))}
4794
- <ViewGhosts rig={rigs[activeChar.id]} take={motion} frame={tlFrame} objects={sceneObjects} propTake={{ frameCount: tlFrameCount, fps: tlFps, objectTracks }} /><DraftGhost rigs={rigs} objects={sceneObjects} />
4794
+ <ViewGhosts rig={rigs[activeChar.id]} take={motion} frame={tlFrame} objects={sceneObjects} propTake={{ frameCount: tlFrameCount, fps: tlFps, objectTracks }} /><DraftGhost rigs={rigs} objects={sceneObjects} /><SculptSketch objects={sceneObjects} />
4795
4795
 
4796
4796
  {/* Selection marker: XYZ tripod + ring on the picked cast
4797
4797
  member; the X/Z arrows drag it across the deck. */}
@@ -111,9 +111,10 @@ export function elementPatchArgs(kind, args) {
111
111
  });
112
112
  return collection ? ops.length === 1 ? ops[0] : { ops } : ops.reduce((patch, op) => mergeElementSet(patch, op.set), {});
113
113
  }
114
- // Unasked, an optional structured value (a glow, a light) is listed only where
115
- // the item has one, so every plain object's receipt stays as short as before.
116
- const listed = (element, item, paths) => paths ? paths.includes(element.path) : element.type !== 'object' || readElement(item, element.path) != null;
114
+ // Unasked, an optional structured value (a glow, a light) or a `sparse` field (a
115
+ // surface preset) is listed only where the item has one, so every plain
116
+ // object's receipt stays as short as before.
117
+ const listed = (element, item, paths) => paths ? paths.includes(element.path) : (element.type !== 'object' && !element.sparse) || readElement(item, element.path) != null;
117
118
  export function elementReadback(kind, item, paths, document) {
118
119
  return patchElements(projectionKind(kind)).filter(element => listed(element, item, paths)).map(element => {
119
120
  const path = element.path;
@@ -28,7 +28,7 @@ const semantic = [
28
28
  // A clay prop from a recipe of soft parts (docs/sculpt-from-image.md). With `id` it re-sculpts that
29
29
  // sculpt in place (one undo); without, it creates one. The recipe is checked by sculpt-recipe.js.
30
30
  { ...mutation('object.sculpt', 'Sculpt a clay prop from a recipe', input({ recipe: { type: 'object', properties: {}, additionalProperties: true }, edits: { type: 'array', minItems: 1, maxItems: 48, items: { type: 'object', properties: {}, additionalProperties: true } }, id, name: { type: 'string', maxLength: 240 }, placement, parent: id }, [])),
31
- description: 'Create a sculpt (or re-sculpt one by id) from a recipe, or patch a sculpt by id with edits [{ set: partId, ...fields } | { add: part } | { remove: partId }]. Recipe { version: 1, parts: [{ id, shape: blob|box|cylinder|torus|frame, size: [x,y,z] m, position, rotation (deg), color, parent, mirror, roundness, taper, border }] }. Floor is y=0, front +z, the subject\'s left +x; mirror reflects across x=0.' },
31
+ description: 'Create a sculpt (or re-sculpt one by id) from a recipe, or patch a sculpt by id with edits [{ set: partId, ...fields } | { add: part } | { remove: partId }]. Recipe { version: 1, parts: [{ id, shape, size: [x,y,z] m, position, rotation (deg), color, parent, mirror, ...shape fields, material, finish, detail, gradient, op }] }. Shapes: blob {roundness, taper} soft forms; box {roundness}; cylinder {taper}; torus; frame {border} rims; lathe {profile [[r 0..1, y 0..1]...], smooth} turned objects (pot, vase, bottle, bowl, lampshade); tube {path [[x,y,z] each -0.5..0.5 of size], thickness m, taper, closed} ropes, vines, cables, handles, bike frames; extrude {outline [[x,y] -0.5..0.5], holes, roundness} flat cut shapes (sign, leaf, gable, star); loft {sections [{ y 0..1, points [[x,z]] } | { y, w, d, x?, z?, roundness? }], smooth} cross-sections that change (hull, car body, shoe, loaf); convex {points | seed, facetCount, facets} rocks, boulders, gems; scatter {item blade|tuft|flower|leaf|pebble|twig, count <= 4000, spread ground|shell, seed} thousands of small items as ONE part (grass, flower beds, gravel, leaf crowns). Any part: material clay|wood|straw|stone|brick|concrete|metal|glass|ceramic|plastic|rubber|fabric|paper|foliage (real texture and shine; color tints it; never fake glass with opacity or thatch with stripes) and finish matte|satin|gloss; detail {amount 0..1, scale, seed} lumpy surface (clouds, bushes, rocks, hay); gradient {color, axis, from, to} two-tone blend; op subtract|intersect turns the part into a cutter of its parent (windows, doors, arches; hollow a lathe with a smaller lathe of the same profile). Floor is y=0, front +z, the subject\'s left +x; mirror reflects across x=0.' },
32
32
  mutation('object.remove', 'Remove objects', input({ ids })),
33
33
  mutation('object.rename', 'Rename object', input({ id, name: { type: 'string', maxLength: 240 } })),
34
34
  mutation('object.group', 'Group objects', input({ parent: id, children: ids })),
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Material presets as three.js materials (the table is material-presets.js),
3
+ * one factory for sculpt parts and plain primitives. `lookMaterial(look)`
4
+ * mints a material per call, which the caller owns and disposes; its detail
5
+ * comes from pattern textures (material-textures.js) generated once per
6
+ * pattern on first draw and shared by every material that uses it, so no one
7
+ * owner may free them (material.dispose() leaves uniforms alone).
8
+ *
9
+ * Mapping is triplanar in the mesh's own frame, measured in metres (the
10
+ * frame's scale is divided back out): the pattern rides with a moving prop,
11
+ * keeps its size on a stretched cube and never reads a geometry's UVs, which
12
+ * on a blob's remapped UV sphere would pinch at the poles. A directional
13
+ * pattern (grain, thatch, brick courses) runs along the axis the vertex
14
+ * shader picks from the part's size and its tilt in the world. The shader
15
+ * text is the same for every preset (only uniforms differ), so all of them
16
+ * share one compiled program per material class.
17
+ *
18
+ * Reflections: the studio stage look has no scene environment, and a metal
19
+ * there would reflect black. While the scene has none, the reflective presets
20
+ * take a RoomEnvironment prefiltered once per renderer; on a stage with an
21
+ * environment they reflect that one, at its intensity, like everything else.
22
+ */
23
+
24
+ import * as THREE from "three";
25
+ import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
26
+ import { FINISH_ROUGHNESS, MATERIAL_LOOKS, lookTint } from "./material-presets.js";
27
+ import { PATTERN_SIZE, patternPixels } from "./material-textures.js";
28
+
29
+ const VERTEX_HEAD = `
30
+ uniform vec3 lookExtent;
31
+ uniform float lookTile;
32
+ uniform float lookGrain;
33
+ varying vec3 vLookPos;
34
+ varying vec3 vLookNormal;`;
35
+
36
+ // lookGrain: 0 the part's own y, 1 its longest side, 2 its most upright side
37
+ // (a slab's thickness never counts). A downward axis is flipped so a thatch
38
+ // course's lower edge stays at the bottom.
39
+ const VERTEX_BODY = `
40
+ mat3 lookFrame = mat3( modelMatrix );
41
+ #ifdef USE_INSTANCING
42
+ lookFrame = lookFrame * mat3( instanceMatrix );
43
+ #endif
44
+ vec3 lookSize = max( vec3( length( lookFrame[ 0 ] ), length( lookFrame[ 1 ] ), length( lookFrame[ 2 ] ) ), vec3( 1e-6 ) );
45
+ vec3 lookExt = lookExtent * lookSize;
46
+ vec3 lookUp = vec3( lookFrame[ 0 ].y, lookFrame[ 1 ].y, lookFrame[ 2 ].y ) / lookSize;
47
+ int lookAxis = 1;
48
+ if ( lookGrain > 1.5 ) {
49
+ vec3 score = abs( lookUp );
50
+ float thin = min( lookExt.x, min( lookExt.y, lookExt.z ) );
51
+ float mid = lookExt.x + lookExt.y + lookExt.z - thin - max( lookExt.x, max( lookExt.y, lookExt.z ) );
52
+ if ( thin < 0.35 * mid ) score -= 2.0 * vec3( equal( lookExt, vec3( thin ) ) );
53
+ lookAxis = score.x >= score.y && score.x >= score.z ? 0 : ( score.y >= score.z ? 1 : 2 );
54
+ } else if ( lookGrain > 0.5 ) {
55
+ lookAxis = lookExt.x >= lookExt.y && lookExt.x >= lookExt.z ? 0 : ( lookExt.y >= lookExt.z ? 1 : 2 );
56
+ }
57
+ vec3 lookP = transformed * lookSize / lookTile;
58
+ vec3 lookN = objectNormal / lookSize;
59
+ if ( lookAxis == 0 ) { lookP = lookP.yxz; lookN = lookN.yxz; }
60
+ else if ( lookAxis == 2 ) { lookP = lookP.xzy; lookN = lookN.xzy; }
61
+ if ( lookGrain > 1.5 && lookUp[ lookAxis ] < 0.0 ) { lookP.y = - lookP.y; lookN.y = - lookN.y; }
62
+ vLookPos = lookP;
63
+ vLookNormal = lookN;`;
64
+
65
+ // Bump is Mikkelsen's surface gradient with unnormalized screen derivatives,
66
+ // so `lookAmount.y` is a real relief depth in metres at any distance.
67
+ const FRAGMENT_HEAD = `
68
+ uniform sampler2D lookMap;
69
+ uniform vec4 lookAmount;
70
+ uniform vec3 lookMask;
71
+ varying vec3 vLookPos;
72
+ varying vec3 vLookNormal;
73
+ vec4 lookTriplanar( vec3 q, vec3 w ) {
74
+ return texture2D( lookMap, q.zy ) * w.x + texture2D( lookMap, q.xz ) * w.y + texture2D( lookMap, q.xy ) * w.z;
75
+ }
76
+ vec3 lookPerturb( vec3 pos, vec3 n, vec2 dH, float face ) {
77
+ vec3 sx = dFdx( pos ), sy = dFdy( pos );
78
+ vec3 r1 = cross( sy, n ), r2 = cross( n, sx );
79
+ float det = dot( sx, r1 ) * face;
80
+ if ( abs( det ) < 1e-14 ) return n;
81
+ return normalize( abs( det ) * n - sign( det ) * ( dH.x * r1 + dH.y * r2 ) );
82
+ }`;
83
+
84
+ const FRAGMENT_COLOR = `
85
+ vec3 lookW = pow( abs( normalize( vLookNormal ) ), vec3( 4.0 ) );
86
+ lookW /= lookW.x + lookW.y + lookW.z;
87
+ vec4 lookTex = lookTriplanar( vLookPos, lookW );
88
+ vec2 lookDH = lookAmount.y * ( vec2( lookTriplanar( vLookPos + dFdx( vLookPos ), lookW ).g, lookTriplanar( vLookPos + dFdy( vLookPos ), lookW ).g ) - lookTex.g );
89
+ diffuseColor.rgb *= max( 0.0, 1.0 + ( lookTex.r - 0.5 ) * 2.0 * lookAmount.x );
90
+ diffuseColor.rgb = mix( diffuseColor.rgb, lookMask, lookTex.a * lookAmount.w );`;
91
+
92
+ const FRAGMENT_ROUGHNESS = "\nroughnessFactor = clamp( roughnessFactor + ( lookTex.b - 0.5 ) * 2.0 * lookAmount.z, 0.03, 1.0 );";
93
+ const FRAGMENT_NORMAL = "\nnormal = lookPerturb( - vViewPosition, normal, lookDH, faceDirection );";
94
+
95
+ const after = (marker, code) => (source) => source.replace(marker, `${marker}${code}`);
96
+ const patchVertex = [after("#include <common>", VERTEX_HEAD), after("#include <begin_vertex>", VERTEX_BODY)];
97
+ const patchFragment = [after("#include <common>", FRAGMENT_HEAD), after("#include <color_fragment>", FRAGMENT_COLOR),
98
+ after("#include <roughnessmap_fragment>", FRAGMENT_ROUGHNESS), after("#include <normal_fragment_maps>", FRAGMENT_NORMAL)];
99
+ const GRAIN = { up: 0, long: 1, hang: 2 };
100
+
101
+ const patternTextures = new Map();
102
+ /** The shared tile for a pattern, made on first use. Never disposed: every
103
+ * material of the preset, now and later, samples the same one. */
104
+ function patternTexture(pattern) {
105
+ let texture = patternTextures.get(pattern);
106
+ if (!texture) {
107
+ texture = new THREE.DataTexture(patternPixels(pattern), PATTERN_SIZE, PATTERN_SIZE);
108
+ texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
109
+ texture.magFilter = THREE.LinearFilter;
110
+ texture.minFilter = THREE.LinearMipmapLinearFilter;
111
+ texture.generateMipmaps = true;
112
+ texture.anisotropy = 8;
113
+ texture.needsUpdate = true;
114
+ patternTextures.set(pattern, texture);
115
+ }
116
+ return texture;
117
+ }
118
+
119
+ /** Hooks the pattern into a material. The texture is fetched at compile time,
120
+ * which only a renderer does, so Node never generates one. */
121
+ function addPattern(material, preset, size) {
122
+ const uniforms = {
123
+ lookMap: { value: null },
124
+ lookTile: { value: preset.tile },
125
+ lookExtent: { value: new THREE.Vector3(...size) },
126
+ lookGrain: { value: GRAIN[preset.grain ?? "up"] },
127
+ lookAmount: { value: new THREE.Vector4(preset.contrast ?? 0, preset.bump ?? 0, preset.roughVar ?? 0, preset.mask ? 0.9 : 0) },
128
+ lookMask: { value: new THREE.Color(preset.mask ?? "#000000") },
129
+ };
130
+ material.onBeforeCompile = (shader) => {
131
+ uniforms.lookMap.value ??= patternTexture(preset.pattern);
132
+ Object.assign(shader.uniforms, uniforms);
133
+ shader.vertexShader = patchVertex.reduce((source, patch) => patch(source), shader.vertexShader);
134
+ shader.fragmentShader = patchFragment.reduce((source, patch) => patch(source), shader.fragmentShader);
135
+ };
136
+ material.customProgramCacheKey = () => "cozyclay-look-1";
137
+ }
138
+
139
+ const studioMaps = new WeakMap();
140
+ /** A neutral room to reflect, prefiltered once per renderer and kept. */
141
+ function studioReflections(renderer) {
142
+ let map = studioMaps.get(renderer);
143
+ if (!map) {
144
+ const generator = new THREE.PMREMGenerator(renderer), room = new RoomEnvironment();
145
+ map = generator.fromScene(room, 0.04).texture;
146
+ room.dispose();
147
+ generator.dispose();
148
+ studioMaps.set(renderer, map);
149
+ }
150
+ return map;
151
+ }
152
+
153
+ /** Runs before each draw: the room while the scene has no environment, the
154
+ * scene's own (envMap null) once it has one. three swaps the program itself. */
155
+ function reflectWhenBare(intensity) {
156
+ return function onBeforeRender(renderer, scene) {
157
+ const own = scene?.environment ? null : studioReflections(renderer);
158
+ if (this.envMap === own) return;
159
+ this.envMap = own;
160
+ this.envMapIntensity = intensity;
161
+ };
162
+ }
163
+
164
+ const WHITE = new THREE.Color("#ffffff");
165
+
166
+ /**
167
+ * A material for `look` = { material, finish, color, size, side }: the preset
168
+ * name (unknown or absent is clay), the finish, the authored colour, the
169
+ * surface's extent in its mesh frame (metres; sizes the glass thickness and
170
+ * steers the grain) and an optional side. `extra` is set last (polygon offset).
171
+ */
172
+ export function lookMaterial(look, extra = null) {
173
+ const name = Object.hasOwn(MATERIAL_LOOKS, look.material ?? "") ? look.material : "clay";
174
+ const preset = MATERIAL_LOOKS[name], size = look.size ?? [1, 1, 1];
175
+ const roughness = FINISH_ROUGHNESS[look.finish] ?? preset.roughness;
176
+ const color = new THREE.Color(lookTint(look.color, name));
177
+ const params = { color, roughness, metalness: preset.metalness ?? 0, ...(look.side === undefined ? {} : { side: look.side }), ...extra };
178
+ let material;
179
+ if (preset.physical) {
180
+ const physical = { ...preset.physical };
181
+ if (physical.transmission) physical.thickness = Math.min(0.5, Math.max(0.002, Math.min(...size) / 2));
182
+ if (physical.sheen) physical.sheenColor = color.clone().lerp(WHITE, 0.25);
183
+ if (physical.clearcoat && look.finish) Object.assign(physical, { clearcoat: look.finish === "matte" ? 0 : 1, clearcoatRoughness: roughness / 2 });
184
+ material = new THREE.MeshPhysicalMaterial({ ...params, ...physical });
185
+ } else material = new THREE.MeshStandardMaterial(params);
186
+ if (preset.pattern) addPattern(material, preset, size);
187
+ if (preset.env) material.onBeforeRender = reflectWhenBare(preset.env);
188
+ return material;
189
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Material presets: named surface looks for sculpt parts (`material`, `finish`)
3
+ * and plain primitives (`object.material`, `object.finish`). Pure data and the
4
+ * record rules, no three.js, so the recipe reader, the scene record and Node
5
+ * tests share one list; material-look.js turns an entry into a material.
6
+ *
7
+ * A record keeps only the names. `color` stays the base tint ("#c24a3b" with
8
+ * wood is red-painted wood), and a colour left at the blockout grey takes the
9
+ * preset's own (brick with no colour draws brick red). `finish` replaces the
10
+ * preset's roughness: chrome is metal + gloss, frosted glass is glass + matte.
11
+ * Clay with no finish is exactly the look every prop wore before presets.
12
+ *
13
+ * An entry (every field optional): roughness, metalness, color (its natural
14
+ * tint), physical (MeshPhysicalMaterial fields), env (reflection strength on
15
+ * a stage with no environment of its own), and a pattern with its tile in
16
+ * metres, the axis its grain runs along (up: the part's own y; long: its
17
+ * longest side; hang: its most upright side, skipping a slab's thickness),
18
+ * albedo contrast, bump depth in metres, roughness variation and a mask
19
+ * colour (brick mortar).
20
+ */
21
+
22
+ import { GREY_BOX } from "./object-colors.js";
23
+
24
+ export const MATERIAL_LOOKS = Object.freeze({
25
+ clay: { roughness: 0.82 },
26
+ wood: { roughness: 0.68, color: "#9a6b3f", pattern: "wood", tile: 0.5, grain: "long", contrast: 0.32, bump: 0.0015, roughVar: 0.1 },
27
+ straw: { roughness: 0.88, color: "#c8a85c", pattern: "straw", tile: 0.6, grain: "hang", contrast: 0.5, bump: 0.02, roughVar: 0.08 },
28
+ stone: { roughness: 0.9, color: "#8d8a83", pattern: "stone", tile: 1, contrast: 0.32, bump: 0.008, roughVar: 0.1 },
29
+ brick: { roughness: 0.88, color: "#a5503a", pattern: "brick", tile: 0.5, grain: "hang", contrast: 0.22, bump: 0.008, roughVar: 0.08, mask: "#b9b3a8" },
30
+ concrete: { roughness: 0.92, color: "#a7a49d", pattern: "concrete", tile: 1.2, contrast: 0.2, bump: 0.0015, roughVar: 0.08 },
31
+ metal: { roughness: 0.32, metalness: 1, color: "#c9ccd1", env: 1, pattern: "brushed", tile: 0.4, contrast: 0.06, bump: 0.0001, roughVar: 0.12 },
32
+ glass: { roughness: 0.04, color: "#f2f7f7", env: 1, physical: { transmission: 1, ior: 1.5 } },
33
+ ceramic: { roughness: 0.35, color: "#efece4", env: 0.45, physical: { clearcoat: 1, clearcoatRoughness: 0.06 } },
34
+ plastic: { roughness: 0.3, env: 0.35 },
35
+ rubber: { roughness: 0.92, color: "#2d2d2f", pattern: "stipple", tile: 0.15, contrast: 0.08, bump: 0.0003, roughVar: 0.06 },
36
+ fabric: { roughness: 0.95, pattern: "weave", tile: 0.12, contrast: 0.22, bump: 0.0008, roughVar: 0.05, physical: { sheen: 1, sheenRoughness: 0.6 } },
37
+ paper: { roughness: 0.88, color: "#ebe6da", pattern: "paper", tile: 0.25, contrast: 0.08, bump: 0.0002 },
38
+ foliage: { roughness: 0.62, color: "#4f7d34", pattern: "leaves", tile: 0.6, contrast: 0.45, bump: 0.015, roughVar: 0.15, physical: { sheen: 0.8, sheenRoughness: 0.45 } },
39
+ });
40
+
41
+ export const MATERIAL_PRESETS = Object.freeze(Object.keys(MATERIAL_LOOKS));
42
+ /** What a finish sets the roughness to, whatever the preset's own. */
43
+ export const FINISH_ROUGHNESS = Object.freeze({ matte: 0.85, satin: 0.45, gloss: 0.12 });
44
+ export const MATERIAL_FINISHES = Object.freeze(Object.keys(FINISH_ROUGHNESS));
45
+
46
+ /** The colour a surface draws in: the authored one, or the preset's own while
47
+ * the authored one is still the blockout grey. */
48
+ export function lookTint(color, preset) {
49
+ const own = MATERIAL_LOOKS[preset]?.color;
50
+ return own && (!color || color.toLowerCase() === GREY_BOX) ? own : color ?? GREY_BOX;
51
+ }
52
+
53
+ /** The record renderers that draw a preset (props.jsx Primitive). */
54
+ const LOOK_RENDERERS = new Set(["cube", "sphere", "capsule", "cylinder", "cone", "plane"]);
55
+ const LOOK_KEYS = [["material", MATERIAL_PRESETS], ["finish", MATERIAL_FINISHES]];
56
+
57
+ /** The patch keys object.update reads for the look, on the renderers that draw one. */
58
+ export const objectMaterialKeys = (renderer) => (LOOK_RENDERERS.has(renderer) ? LOOK_KEYS.map(([key]) => key) : []);
59
+
60
+ /** The material/finish a normalized record keeps: known names on a primitive,
61
+ * nothing otherwise, so records written before presets come back unchanged. */
62
+ export function objectMaterialFields(record, renderer) {
63
+ if (!LOOK_RENDERERS.has(renderer)) return {};
64
+ return Object.fromEntries(LOOK_KEYS.filter(([key, names]) => names.includes(record?.[key])).map(([key]) => [key, record[key]]));
65
+ }
66
+
67
+ /** The material/finish part of an object.update patch: a known name sets it,
68
+ * null removes it (back to the preset default), anything else is ignored. */
69
+ export function objectMaterialPatch(object, patch) {
70
+ if (!LOOK_RENDERERS.has(object?.renderer)) return {};
71
+ const update = {};
72
+ for (const [key, names] of LOOK_KEYS) {
73
+ const value = patch?.[key];
74
+ if (value === undefined || (value !== null && !names.includes(value))) continue;
75
+ if ((value ?? undefined) !== object[key]) update[key] = value;
76
+ }
77
+ return update;
78
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Procedural surface detail for the material presets: one small seamless tile
3
+ * per pattern, from integer hashes, so every machine draws the same pixels.
4
+ * Pure arithmetic, no three.js and no DOM; material-look.js wraps the bytes in
5
+ * a DataTexture the first time a preset is drawn, and never in Node.
6
+ *
7
+ * Channels, each 0..255: R albedo detail (128 is the authored colour), G
8
+ * height (0..255 spans the preset's bump depth), B roughness offset (128 is
9
+ * none), A where a second colour shows (brick mortar). Each pattern is drawn
10
+ * in tile coordinates u (across) and v (along the grain, or up), both 0..1;
11
+ * R and B are re-centred on their mean afterwards, so a preset never shifts
12
+ * the colour the author picked.
13
+ */
14
+
15
+ export const PATTERN_SIZE = 256;
16
+
17
+ function hash(x, y, seed) {
18
+ let h = Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1) ^ Math.imul(seed, 0x9e3779b1);
19
+ h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
20
+ h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
21
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
22
+ }
23
+ const wrap = (i, n) => ((i % n) + n) % n;
24
+ const smooth = (t) => t * t * (3 - 2 * t);
25
+ const step = (a, b, x) => smooth(Math.min(1, Math.max(0, (x - a) / (b - a))));
26
+
27
+ /** Value noise on a px by py lattice over the tile, seamless at its edges. */
28
+ function noise(u, v, px, py, seed) {
29
+ const x = u * px, y = v * py, ix = Math.floor(x), iy = Math.floor(y);
30
+ const fx = smooth(x - ix), fy = smooth(y - iy);
31
+ const x0 = wrap(ix, px), x1 = wrap(ix + 1, px), y0 = wrap(iy, py), y1 = wrap(iy + 1, py);
32
+ const a = hash(x0, y0, seed), b = hash(x1, y0, seed), c = hash(x0, y1, seed), d = hash(x1, y1, seed);
33
+ return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy;
34
+ }
35
+
36
+ function fbm(u, v, px, py, octaves, seed) {
37
+ let sum = 0, amp = 0.5, total = 0;
38
+ for (let o = 0; o < octaves; o += 1) {
39
+ sum += amp * noise(u, v, px << o, py << o, seed + o);
40
+ total += amp;
41
+ amp /= 2;
42
+ }
43
+ return sum / total;
44
+ }
45
+
46
+ /** Worley cells on an n by n jittered grid, seamless: the nearest and second
47
+ * nearest feature distances (tile units) and the nearest cell's hash. */
48
+ function cells(u, v, n, seed) {
49
+ const x = u * n, y = v * n, ix = Math.floor(x), iy = Math.floor(y);
50
+ let f1 = 9, f2 = 9, id = 0;
51
+ for (let dy = -1; dy <= 1; dy += 1) {
52
+ for (let dx = -1; dx <= 1; dx += 1) {
53
+ const cx = ix + dx, cy = iy + dy, wx = wrap(cx, n), wy = wrap(cy, n);
54
+ const d = Math.hypot(cx + hash(wx, wy, seed) - x, cy + hash(wx, wy, seed + 1) - y) / n;
55
+ if (d < f1) {
56
+ f2 = f1;
57
+ f1 = d;
58
+ id = hash(wx, wy, seed + 2);
59
+ } else if (d < f2) f2 = d;
60
+ }
61
+ }
62
+ return { f1, f2, id };
63
+ }
64
+
65
+ /** Each pattern: (u, v) to [albedo, height, roughness, mask]. */
66
+ const PATTERNS = {
67
+ // Ten rings across the tile, wandering along it, over fine fibres.
68
+ wood(u, v) {
69
+ const late = (0.5 + 0.5 * Math.sin(2 * Math.PI * (u * 10 + fbm(u, v, 2, 1, 3, 11) * 2.2))) ** 6;
70
+ const fibre = noise(u, v, 128, 4, 12), fleck = fbm(u, v, 16, 2, 2, 13);
71
+ return [0.5 - 0.45 * late + 0.25 * fibre + 0.2 * fleck, 0.6 * fibre - 0.5 * late, 0.5 + 0.4 * late - 0.2 * fibre, 0];
72
+ },
73
+ // Four overlapping courses of strands; each course is thickest at its ragged
74
+ // lower edge and shaded just under the edge of the course above.
75
+ straw(u, v) {
76
+ const strand = 0.6 * noise(u, v, 192, 3, 21) + 0.4 * noise(u, v, 96, 2, 22);
77
+ const course = v * 4 + 0.12 * noise(u, v, 48, 1, 23), cv = course - Math.floor(course);
78
+ const shade = step(0.55, 1, cv);
79
+ return [0.5 * strand - 0.45 * shade + 0.1 * fbm(u, v, 6, 2, 2, 24), 0.7 * (1 - cv) + 0.3 * strand, 0.5 + 0.2 * shade, 0];
80
+ },
81
+ // Weathered fieldstone: lumpy noise, stones of their own tone, dark joints.
82
+ stone(u, v) {
83
+ const lump = fbm(u, v, 4, 4, 5, 31), cell = cells(u, v, 5, 32), grain = noise(u, v, 128, 128, 33);
84
+ const edge = cell.f2 - cell.f1, crack = 1 - step(0, 0.012, edge);
85
+ return [0.35 * lump + 0.25 * cell.id + 0.12 * grain - 0.35 * crack, 0.5 * lump + 0.35 * step(0, 0.08, edge) + 0.1 * grain, 0.25 * lump + 0.15 * crack, 0];
86
+ },
87
+ // Running bond: six 83 mm courses of two 250 mm bricks on the 0.5 m tile,
88
+ // 10 mm recessed mortar joints (the mask), each brick its own tone.
89
+ brick(u, v) {
90
+ const row = Math.floor(v * 6), cv = v * 6 - row;
91
+ const across = u * 2 + (row % 2 ? 0.5 : 0), col = Math.floor(across), cu = across - col;
92
+ const gap = Math.min(Math.min(cu, 1 - cu) / 2, Math.min(cv, 1 - cv) / 6), joint = 0.01;
93
+ const grit = noise(u, v, 96, 96, 42), mortar = 1 - step(joint * 0.6, joint, gap);
94
+ return [0.35 * hash(wrap(col, 2), row, 41) + 0.15 * grit + 0.15 * fbm(u, v, 8, 8, 3, 43), 0.85 * step(joint * 0.6, joint * 2.2, gap) + 0.15 * grit, 0.1 * grit + 0.15 * mortar, mortar];
95
+ },
96
+ // Cast concrete: soft mottling, fine grit, scattered air pits.
97
+ concrete(u, v) {
98
+ const mottle = fbm(u, v, 3, 3, 5, 51), grit = noise(u, v, 160, 160, 52), pit = step(0.86, 0.95, noise(u, v, 48, 48, 53));
99
+ return [0.3 * mottle + 0.1 * grit - 0.25 * pit, 0.4 * mottle + 0.25 * grit - 0.6 * pit, 0.2 * mottle, 0];
100
+ },
101
+ // Brushed metal: fine lines across the tile and faint smudges in the sheen.
102
+ brushed(u, v) {
103
+ const brush = 0.6 * noise(u, v, 3, 220, 61) + 0.4 * noise(u, v, 2, 110, 62), smudge = fbm(u, v, 3, 3, 4, 63);
104
+ return [0.15 * brush + 0.1 * smudge, brush, 0.35 * brush + 0.4 * smudge, 0];
105
+ },
106
+ stipple(u, v) {
107
+ const fine = noise(u, v, 128, 128, 71), wear = fbm(u, v, 6, 6, 3, 72);
108
+ return [0.2 * wear + 0.1 * fine, fine, 0.3 * wear, 0];
109
+ },
110
+ // Plain weave: 32 threads each way, over and under, each thread its own tone.
111
+ weave(u, v) {
112
+ const x = u * 32, y = v * 32, i = Math.floor(x), j = Math.floor(y);
113
+ const across = Math.sin(Math.PI * (x - i)), along = Math.sin(Math.PI * (y - j)), warp = (i + j) % 2 === 0;
114
+ const height = warp ? across * (0.75 + 0.25 * along) : along * (0.75 + 0.25 * across);
115
+ return [0.3 * height + 0.2 * (warp ? hash(i, 0, 81) : hash(0, j, 82)) + 0.1 * noise(u, v, 256, 256, 83), height, -0.2 * height, 0];
116
+ },
117
+ paper(u, v) {
118
+ const pulp = fbm(u, v, 8, 8, 4, 91), fibre = noise(u, v, 64, 8, 92) * noise(u, v, 8, 64, 93);
119
+ return [0.12 * pulp + 0.1 * fibre, pulp + 0.5 * fibre, 0.1 * pulp, 0];
120
+ },
121
+ // Two layers of leaf cells; the leaf nearer its own centre lies on top, and
122
+ // where neither covers the surface a dark gap shows through.
123
+ leaves(u, v) {
124
+ const big = cells(u, v, 6, 101), small = cells(u, v, 9, 104);
125
+ const domeBig = Math.max(0, 1 - big.f1 * 6 * 1.6), domeSmall = 0.9 * Math.max(0, 1 - small.f1 * 9 * 1.6);
126
+ const top = domeBig >= domeSmall ? big : small, dome = Math.max(domeBig, domeSmall), gap = 1 - step(0, 0.15, dome);
127
+ return [0.45 * top.id + 0.2 * dome - 0.5 * gap + 0.05 * noise(u, v, 64, 64, 107), dome, 0.3 * top.id + 0.3 * gap, 0];
128
+ },
129
+ };
130
+
131
+ export const PATTERN_NAMES = Object.freeze(Object.keys(PATTERNS));
132
+
133
+ /** The RGBA bytes of one pattern's tile, PATTERN_SIZE square, row 0 at v = 0. */
134
+ export function patternPixels(name) {
135
+ const draw = PATTERNS[name];
136
+ if (!draw) throw new Error(`unknown material pattern "${name}"`);
137
+ const count = PATTERN_SIZE * PATTERN_SIZE, raw = new Float32Array(count * 4);
138
+ for (let y = 0; y < PATTERN_SIZE; y += 1) {
139
+ for (let x = 0; x < PATTERN_SIZE; x += 1) raw.set(draw((x + 0.5) / PATTERN_SIZE, (y + 0.5) / PATTERN_SIZE), (y * PATTERN_SIZE + x) * 4);
140
+ }
141
+ let albedo = 0, rough = 0, low = Infinity, high = -Infinity;
142
+ for (let i = 0; i < count; i += 1) {
143
+ albedo += raw[i * 4] / count;
144
+ rough += raw[i * 4 + 2] / count;
145
+ low = Math.min(low, raw[i * 4 + 1]);
146
+ high = Math.max(high, raw[i * 4 + 1]);
147
+ }
148
+ const span = high - low || 1, byte = (value) => Math.round(255 * Math.min(1, Math.max(0, value)));
149
+ const out = new Uint8Array(count * 4);
150
+ for (let i = 0; i < count; i += 1) {
151
+ out[i * 4] = byte(raw[i * 4] - albedo + 0.5);
152
+ out[i * 4 + 1] = byte((raw[i * 4 + 1] - low) / span);
153
+ out[i * 4 + 2] = byte(raw[i * 4 + 2] - rough + 0.5);
154
+ out[i * 4 + 3] = byte(raw[i * 4 + 3]);
155
+ }
156
+ return out;
157
+ }
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { normalizeObjectColor } from "./object-colors.js";
19
+ import { objectMaterialFields, objectMaterialPatch } from "./material-presets.js";
19
20
 
20
21
  export const LIGHT_KIND = "light";
21
22
 
@@ -94,8 +95,8 @@ export function normalizeObjectGlow(value) {
94
95
  }
95
96
 
96
97
  /** The light/glow fields a normalized record carries. A light renderer always
97
- * has a light; any record keeps a glow only when it has one, so records written
98
- * before glow existed come back byte-for-byte unchanged. */
98
+ * has a light; any record keeps a glow (and a primitive its surface preset)
99
+ * only when it has one, so records written before come back unchanged. */
99
100
  export function objectLookFields(record, entry) {
100
101
  const out = {};
101
102
  if ((entry.renderer ?? entry.kind) === LIGHT_KIND) out.light = normalizeObjectLight(record.light, record.light?.type ?? entry.light?.type);
@@ -103,12 +104,14 @@ export function objectLookFields(record, entry) {
103
104
  if (glow) out.glow = glow;
104
105
  // A kit part a take switches on (a muzzle flash and its light) stays hidden without one (object-travel.js trackShown).
105
106
  if (record.showOnlyInTake === true) out.showOnlyInTake = true;
106
- return out;
107
+ // A primitive's surface preset (material-presets.js).
108
+ return Object.assign(out, objectMaterialFields(record, entry.renderer ?? entry.kind));
107
109
  }
108
110
 
109
111
  /** The light/glow part of an object.update patch: each field merges over the
110
112
  * record's own and is repaired; `glow: null` removes the glow. Only changes are
111
- * returned. A light on a record that is not a light is ignored. */
113
+ * returned. A light on a record that is not a light is ignored. A primitive's
114
+ * material and finish ride along (material-presets.js). */
112
115
  export function objectLookPatch(object, patch) {
113
116
  const update = {};
114
117
  if (patch?.light && typeof patch.light === "object" && object.renderer === LIGHT_KIND) {
@@ -119,7 +122,7 @@ export function objectLookPatch(object, patch) {
119
122
  const next = patch.glow === null ? null : normalizeObjectGlow({ ...(object.glow ?? {}), ...patch.glow });
120
123
  if (JSON.stringify(next) !== JSON.stringify(object.glow ?? null)) update.glow = next;
121
124
  }
122
- return update;
125
+ return Object.assign(update, objectMaterialPatch(object, patch));
123
126
  }
124
127
 
125
128
  /** 32-bit FNV-1a of the record id: the flicker's seed. */
@@ -196,3 +199,4 @@ export function withShadowCapWarnings(result, objects) {
196
199
  }
197
200
  // A record's grasp sockets ride the same normalize and patch paths (object-grasp.js).
198
201
  export { objectGraspFields, objectGraspPatch } from "./object-grasp.js";
202
+ export { objectMaterialKeys } from "./material-presets.js";
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A primitive's surface preset (object.material / object.finish) as the
3
+ * material props.jsx hangs on its mesh, and the polygon offset every prop
4
+ * material takes for its depth rank.
5
+ */
6
+
7
+ import { useEffect, useMemo } from "react";
8
+ import { lookMaterial } from "./material-look.js";
9
+
10
+ // Parts of an assembly can share a plane with an earlier part (hood top and
11
+ // grille top). The depth test can't order them and the winner flips as the
12
+ // camera moves, so a ranked part is pulled toward the camera by `rank` steps
13
+ // (negative = nearer). Rank 0 gets nothing: those materials stay untouched.
14
+ export function depthRankProps(rank) {
15
+ return rank > 0 ? { polygonOffset: true, polygonOffsetFactor: -rank, polygonOffsetUnits: -rank } : null;
16
+ }
17
+
18
+ /** Each primitive's extent in its mesh frame at scale 1 (props.jsx builds
19
+ * them); the rest are unit sized. The shader multiplies the record's scale in. */
20
+ const PRIMITIVE_SIZE = { capsule: [0.7, 1.4, 0.7], plane: [2, 2, 0.01] };
21
+
22
+ /**
23
+ * The preset material for a primitive record, or null when the record keeps
24
+ * the plain clay look (no material and no finish) or auto-colour paints it
25
+ * flat: the caller then draws exactly what it drew before presets. Rebuilt
26
+ * when the look, the colour or the rank changes; the program and the pattern
27
+ * textures are shared, so a rebuild is cheap.
28
+ */
29
+ export function usePrimitiveLook(object, kind, side, depthRank) {
30
+ const { material, finish, color, autoColor } = object;
31
+ const active = !autoColor && Boolean(material || finish);
32
+ const look = useMemo(
33
+ () => (active ? lookMaterial({ material, finish, color, side, size: PRIMITIVE_SIZE[kind] }, depthRankProps(depthRank)) : null),
34
+ [active, material, finish, color, side, kind, depthRank],
35
+ );
36
+ useEffect(() => () => look?.dispose(), [look]);
37
+ return look;
38
+ }