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.
- package/CHANGELOG.md +26 -0
- package/bin/agent/agent-routes.mjs +8 -7
- package/bin/agent/plan-tool.mjs +114 -0
- package/bin/agent/script-tool.mjs +2 -0
- package/bin/agent/sculpt-sketch.mjs +190 -0
- package/bin/agent/session-store.mjs +2 -1
- package/bin/agent/sketch-code.mjs +151 -0
- package/bin/agent/sketch-sandbox.mjs +102 -0
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/turn-frames.mjs +4 -0
- package/dist/app/index.html +5 -5
- package/dist/assets/TaskBoard-ngdqiu7u.js +1 -0
- package/dist/assets/app-DmR7jBkz.js +5144 -0
- package/dist/assets/{motion3-compile-B887n77J.js → motion3-compile-BB3_1-EB.js} +1 -1
- package/dist/assets/{scene-asset-sync-COGOlF5T.css → scene-asset-sync-CBx7XRti.css} +1 -1
- package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +13 -0
- package/dist/assets/task-board-ErQvfoaz.js +14 -0
- package/dist/assets/workflow-zQM-EKW1.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/tool-handlers.mjs +2 -2
- package/package.json +3 -1
- package/src/App.jsx +2 -2
- package/src/commands/elements.js +4 -3
- package/src/commands/objects.js +1 -1
- package/src/material-look.js +189 -0
- package/src/material-presets.js +78 -0
- package/src/material-textures.js +157 -0
- package/src/object-look.js +9 -5
- package/src/primitive-look.js +38 -0
- package/src/props.jsx +20 -28
- package/src/render-passes.js +7 -1
- package/src/scene-objects.js +2 -2
- package/src/sculpt-colours.js +65 -0
- package/src/sculpt-compare.js +13 -50
- package/src/sculpt-csg.js +112 -0
- package/src/sculpt-curve.js +123 -0
- package/src/sculpt-cutters.js +43 -0
- package/src/sculpt-detail.js +207 -0
- package/src/sculpt-geometry-loft-convex.js +100 -0
- package/src/sculpt-geometry-shapes.js +250 -0
- package/src/sculpt-geometry.js +33 -15
- package/src/sculpt-loft-rings.js +225 -0
- package/src/sculpt-noise.js +65 -0
- package/src/sculpt-part-fields.js +27 -0
- package/src/sculpt-recipe-read.js +65 -0
- package/src/sculpt-recipe.js +33 -75
- package/src/sculpt-run.js +1 -1
- package/src/sculpt-scatter-items.js +183 -0
- package/src/sculpt-scatter-shape.js +57 -0
- package/src/sculpt-scatter.js +124 -0
- package/src/sculpt-shapes-curved.js +211 -0
- package/src/sculpt-shapes-loft-convex.js +218 -0
- package/src/sculpt-shapes.js +138 -0
- package/src/sculpt-sketch-state.js +76 -0
- package/src/sculpt-sketch.jsx +124 -0
- package/src/sculpt-surface-fields.js +65 -0
- package/src/studio-elements.js +5 -1
- package/src/workflow/AgentPanel.jsx +4 -4
- package/src/workflow/AgentPlanCard.jsx +41 -0
- package/src/workflow/agent-client.js +14 -11
- package/src/workflow/agent-plan.css +93 -0
- package/src/workflow/agent-plan.js +59 -0
- package/tools/run-tests.mjs +3 -3
- package/dist/assets/TaskBoard-DdDXMgZm.js +0 -1
- package/dist/assets/app-B2BpfCl2.js +0 -5088
- package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
- package/dist/assets/task-board-BKaM7d-9.js +0 -14
- package/dist/assets/workflow-B8Dhzexj.js +0 -179
|
@@ -1 +1 @@
|
|
|
1
|
-
{"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.1.
|
|
1
|
+
{"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.1.1\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"65554e5162b89f817b066016390471333bfcabcee80e0ae0ee46939bdc759599\"}","signature":"M4R6haq7YzLWxDPcdy5h4zI/w7nVEBubTszIj4UzgWEKB4XZgZYtrjsQJmjt2rF3IooyCKnvMlzNiYB/arAGDg=="}
|
package/dist/workflow/index.html
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
17
|
-
<link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-
|
|
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>
|
package/mcp/tool-handlers.mjs
CHANGED
|
@@ -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?,
|
|
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.
|
|
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. */}
|
package/src/commands/elements.js
CHANGED
|
@@ -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)
|
|
115
|
-
// the item has one, so every plain
|
|
116
|
-
|
|
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;
|
package/src/commands/objects.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/object-look.js
CHANGED
|
@@ -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
|
|
98
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|