cozyclay 3.1.0 → 3.1.2
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 +65 -0
- package/bin/agent/agent-routes.mjs +12 -11
- package/bin/agent/main-agent.mjs +21 -7
- package/bin/agent/main-prompt.mjs +7 -6
- package/bin/agent/plan-tool.mjs +114 -0
- package/bin/agent/script-tool.mjs +4 -1
- 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/studio-rebase.mjs +5 -4
- package/bin/agent/studio-task-routes.mjs +1 -1
- package/bin/agent/studio-tasks.mjs +116 -25
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/turn-frames.mjs +4 -0
- package/bin/cozyclay.mjs +4 -2
- package/bin/live-endpoint.mjs +2 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/{TaskBoard-DdDXMgZm.js → TaskBoard-GR8xZm5c.js} +1 -1
- package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
- package/dist/assets/app-C4QTcDcb.js +5144 -0
- package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
- package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
- package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
- package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
- package/dist/assets/task-board-BqReHzOi.js +15 -0
- package/dist/assets/workflow-C87p6E1E.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +1 -1
- package/dist/sitemap.xml +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +5 -3
- package/mcp/tool-handlers.mjs +7 -7
- package/package.json +3 -1
- package/src/App.jsx +44 -66
- package/src/bus-task-scopes.js +59 -11
- package/src/bus-task-targets.js +35 -9
- package/src/bus-tasks.js +23 -10
- package/src/command-bus.js +7 -4
- package/src/commands/ai.js +1 -1
- package/src/commands/elements.js +4 -3
- package/src/commands/export.js +1 -1
- package/src/commands/motion3.js +27 -7
- package/src/commands/objects.js +1 -1
- package/src/export-workspace.js +244 -0
- package/src/keyframe-pack.js +5 -1
- package/src/live-control.js +14 -7
- package/src/material-look.js +189 -0
- package/src/material-presets.js +78 -0
- package/src/material-textures.js +157 -0
- package/src/motion3/compile-job.js +15 -3
- package/src/motion3/compiler/channels.js +74 -24
- package/src/motion3/compiler/compile.js +2 -1
- package/src/motion3/compiler/idle.js +2 -0
- package/src/motion3/compiler/overlap.js +180 -0
- package/src/motion3/compiler/pose.js +6 -4
- package/src/motion3/compiler/validate-common.js +1 -0
- package/src/motion3/compiler/validate-parts.js +5 -1
- package/src/motion3/compiler/validate.js +30 -10
- package/src/motion3/correct.js +236 -0
- package/src/motion3/docs.js +8 -6
- package/src/motion3/face-lean.js +31 -0
- package/src/motion3/fix/index.js +5 -1
- package/src/motion3/fix/reach-body.js +172 -0
- package/src/motion3/hint-check.js +44 -0
- package/src/motion3/json-patch.js +120 -12
- package/src/motion3/lenient.js +46 -4
- package/src/motion3/object-keys.js +17 -5
- package/src/motion3/pipeline.js +46 -9
- package/src/motion3/qa/arm-checks.js +96 -0
- package/src/motion3/qa/asked.js +113 -0
- package/src/motion3/qa/goals.js +37 -8
- package/src/motion3/qa/hints-goal.js +6 -5
- package/src/motion3/qa/index.js +5 -2
- package/src/motion3/qa/key-checks.js +4 -3
- package/src/motion3/qa/motion-life.js +243 -0
- package/src/motion3/qa/spec-checks.js +15 -9
- package/src/motion3/qa/verdict.js +50 -0
- package/src/motion3/refs.js +38 -14
- package/src/motion3/repair-watch.js +132 -0
- package/src/motion3/repair.js +52 -19
- package/src/motion3/report.js +50 -9
- package/src/motion3/schema/topics.js +6 -4
- package/src/motion3/schema/types.js +1 -1
- package/src/motion3/skills/basic.js +15 -8
- package/src/motion3/skills/index.js +52 -18
- package/src/motion3/skills/info.js +1 -1
- package/src/motion3/skills/stance.js +6 -0
- package/src/motion3/skills/walk-fit.js +170 -0
- package/src/object-look.js +9 -5
- package/src/panels/PoseImagePanel.jsx +3 -2
- package/src/panels/SculptPanel.jsx +2 -2
- package/src/primitive-look.js +38 -0
- package/src/props.jsx +20 -28
- package/src/render-passes.js +7 -1
- package/src/result-modal.jsx +3 -3
- 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/shell/ExportFolderPreference.jsx +103 -0
- package/src/shell/PreferencesDialog.jsx +3 -166
- package/src/shell/StatusBar.jsx +1 -1
- package/src/shell/preference-controls.jsx +114 -0
- package/src/shell/preference-pages.jsx +72 -0
- package/src/shell/preferences.css +46 -0
- package/src/shot-prompt.js +29 -9
- package/src/studio-actions.js +2 -2
- package/src/studio-agent-motion.js +19 -9
- package/src/studio-agent-protocol.js +1 -1
- package/src/studio-app-binding.js +3 -2
- package/src/studio-elements.js +5 -1
- package/src/workflow/AgentPanel.jsx +6 -6
- package/src/workflow/AgentPlanCard.jsx +41 -0
- package/src/workflow/CozySceneNode.jsx +1 -1
- package/src/workflow/WorkflowBuilder.jsx +2 -2
- 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/src/workflow/keyframe-pack-request.js +3 -10
- package/src/workflow/task-board.css +1 -1
- package/src/workflow/task-board.js +7 -5
- package/tools/loc-baseline.json +1 -2
- package/tools/run-tests.mjs +8 -8
- package/dist/assets/app-B2BpfCl2.js +0 -5088
- package/dist/assets/compile-worker-D0pKioa0.js +0 -5
- package/dist/assets/motion3-compile-B887n77J.js +0 -1
- package/dist/assets/scene-asset-sync-COGOlF5T.css +0 -1
- 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
|
@@ -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
|
+
}
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
// name, the motion scene (scene.js motionScene of the editor's objects: oriented boxes; shapes.js reads a sphere or a cylinder round from them), the spec as sent, and
|
|
4
4
|
// what to do with it (probes, checks, lenient, repair, bake). The answer is plain data and typed arrays
|
|
5
5
|
// only (the compiler's out holds closures), so it crosses a worker boundary: QA, observations, the stored
|
|
6
|
-
// spec, and the baked take when asked.
|
|
6
|
+
// spec, and the baked take when asked. The hint patches QA offers are compiled first (hint-check.js).
|
|
7
7
|
// A refusal is an Error with a code (INVALID_ARGUMENT, MOTION_SPEC_INVALID turned into it), which the
|
|
8
8
|
// client turns back into the Studio's protocol error.
|
|
9
9
|
import { compileSpec, liftRequest } from "./pipeline.js";
|
|
10
|
+
import { prepareSpec } from "./correct.js";
|
|
10
11
|
import { repairSpec } from "./repair.js";
|
|
12
|
+
import { checkHints } from "./hint-check.js";
|
|
11
13
|
import { bakeTake } from "./take.js";
|
|
12
14
|
import { skeletonFor } from "./rigs/index.js";
|
|
13
15
|
import { keptPeople } from "./people.js";
|
|
@@ -49,7 +51,17 @@ export function runCompileJob({ model, scene, spec: asked, probes: sentProbes, c
|
|
|
49
51
|
const r = asArgument(() => compileSpec(sent, { sk, scene }, { ...opts, probes }));
|
|
50
52
|
return { ...r, stored: r.spec === sent ? spec : r.spec };
|
|
51
53
|
};
|
|
52
|
-
|
|
54
|
+
// repair patches address the stored spec, so a repair starts from it corrected (correct.js), and says what was
|
|
55
|
+
const corrected = [], start = repair && !lenient ? prepareSpec(asked, { sk, scene }, corrected) : asked;
|
|
56
|
+
// every compile's QA by its spec, so a hint patch repair already tried is not compiled again (hint-check.js)
|
|
57
|
+
const seen = new Map(), measured = s => { const r = once(s); seen.set(JSON.stringify(s), r.qa); return r; };
|
|
58
|
+
const t0 = performance.now(), fixed = repair ? repairSpec(start, repair, measured) : { spec: asked, result: measured(asked) };
|
|
59
|
+
// a hint's patch is offered only when its compile helped (hint-check.js), and only by a compile that repairs: repair has
|
|
60
|
+
// compiled most of the patches already (seen), where a plain compile paid two more compiles for them (bench replay: first
|
|
61
|
+
// compile 1.6 -> 5.2 s median) and the agent applied an offered patch as sent once in the 60 bench runs (374 direct
|
|
62
|
+
// compiles, 319 with repair; a plain compile is mostly a variant in a sweep); a plain compile keeps the hint's words only
|
|
63
|
+
if (repair) checkHints(fixed.spec, fixed.result.qa, s => seen.get(JSON.stringify(s)) ?? measured(s).qa);
|
|
64
|
+
else for (const g of fixed.result.qa?.groups ?? []) delete g.patch;
|
|
53
65
|
// ms.repair: the whole repair's wall time, so a sweep that repairs every variant shows what it costs
|
|
54
66
|
const r = fixed.result, label = fixed.spec?.label ?? null, ms = fixed.repair ? { ...r.ms, repair: Math.round(performance.now() - t0) } : r.ms;
|
|
55
67
|
const take = bake ? bakeTake(sk, r.out, { position: bake.position, yaw: bake.yaw }, { spec: r.stored, prompt: ("prompt" in bake ? bake.prompt : label) ?? "" }) : undefined;
|
|
@@ -57,7 +69,7 @@ export function runCompileJob({ model, scene, spec: asked, probes: sentProbes, c
|
|
|
57
69
|
const people = take && keptPeople(scene, [r.expanded, r.stored]);
|
|
58
70
|
if (people) take.people = people;
|
|
59
71
|
return { stored: r.stored, qa: r.qa, observations: r.observations, used: r.used, fixed: r.fixed, search: r.search, timeline: r.timeline,
|
|
60
|
-
...(r.probes ? { probes: r.probes } : {}), ...(r.notes ? { notes: r.notes } : {}), ms, frameCount: r.out.frameCount, label,
|
|
72
|
+
...(r.probes ? { probes: r.probes } : {}), ...(r.notes || corrected.length ? { notes: [...corrected, ...(r.notes ?? [])] } : {}), ms, frameCount: r.out.frameCount, label,
|
|
61
73
|
...(fixed.repair ? { repair: fixed.repair } : {}), ...(take ? { take } : {}) };
|
|
62
74
|
}
|
|
63
75
|
|
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
// flow is gone by the time the arm is a quarter of the way onto it: an arm's swing and bend by its
|
|
13
13
|
// own armw, the spine (which carries the shoulders) and the head (the hands work near it) by the
|
|
14
14
|
// larger of the two; a reach or a held prop lands as keyed. A hold (two equal keys in a row) stays
|
|
15
|
-
// exactly flat either way.
|
|
15
|
+
// exactly flat either way. Overlap (overlap.js) breaks each change along the chains on top of that: the
|
|
16
|
+
// lower spine, the chest (chest_*: the top spine bone's share, baked beside spine_*), the head, the upper
|
|
17
|
+
// arm, the forearm and the wrist each take a later delay, one side of the body trails the other, and the
|
|
18
|
+
// arm's swing and bend overshoot and settle where they stop.
|
|
19
|
+
import { overlapDelay, sideTrail, settleBump, ikGap, contactGap, OVERLAP_SLOPE } from "./overlap.js";
|
|
16
20
|
|
|
17
21
|
function pchipSlopes(x, y) {
|
|
18
22
|
const n = y.length, m = new Array(n).fill(0);
|
|
@@ -37,18 +41,24 @@ function pchipEval(x, y, m, q) {
|
|
|
37
41
|
return (2 * t3 - 3 * t2 + 1) * y[i] + (t3 - 2 * t2 + t) * h * m[i] + (-2 * t3 + 3 * t2) * y[i + 1] + (t3 - t2) * h * m[i + 1];
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
const FLOW_SMOOTH = /^(spine|head)_(yaw|pitch|roll)$|^(aswing|abend)_[lr]$/;
|
|
44
|
+
const FLOW_SMOOTH = /^(spine|chest|head)_(yaw|pitch|roll)$|^(aswing|abend)_[lr]$/;
|
|
41
45
|
const FLOW_LAG = { spine: 0, head: 2, arms: 3 };
|
|
46
|
+
const ARM = /^(hand|armw|elbow|aswing|abend|wflex|wtwist)_[lr]$/; // one side's arm: trails as one (overlap.js sideTrail)
|
|
42
47
|
const OVER_FRAC = 0.15, OVER_MAX = 6; // a smooth segment's overshoot: at most this share of its change, and degrees
|
|
43
|
-
const ikSides = ch => (/^(spine|head)_/.test(ch) ? ["l", "r"] : /^(aswing|abend)_([lr])$/.test(ch) ? [ch.slice(-1)] : []);
|
|
44
|
-
const lagFamily = ch => (/^
|
|
48
|
+
const ikSides = ch => (/^(spine|chest|head)_/.test(ch) ? ["l", "r"] : /^(aswing|abend|wflex|wtwist)_([lr])$/.test(ch) ? [ch.slice(-1)] : []);
|
|
49
|
+
const lagFamily = ch => (/^(spine|chest)_/.test(ch) ? "spine" : /^head_/.test(ch) ? "head" : /^(aswing|abend)_[lr]$/.test(ch) ? "arms" : null);
|
|
45
50
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
/**
|
|
52
|
+
* spec.flow -> { smooth, lag: { spine, head, arms }, overlap, seed, hands }: undefined is the default, false turns all off.
|
|
53
|
+
* overlap: true (1) | false (0) | a scale of its delays and settle (overlap.js); seed (the spec's label) picks which side trails;
|
|
54
|
+
* hands: the frames each hand works on something (overlap.js handBusy), where a free IK hand's trail stops.
|
|
55
|
+
*/
|
|
56
|
+
export function flowOptions(flow, seed = "", hands = { l: [], r: [] }) {
|
|
57
|
+
if (flow === false) return { smooth: false, lag: { spine: 0, head: 0, arms: 0 }, overlap: 0, seed, hands };
|
|
49
58
|
const f = flow ?? {}, lag = f.lag === false ? 0 : f.lag;
|
|
50
59
|
const each = typeof lag === "number" ? { spine: lag, head: lag, arms: lag } : { ...FLOW_LAG, ...(lag ?? {}) };
|
|
51
|
-
|
|
60
|
+
const overlap = f.overlap === false ? 0 : typeof f.overlap === "number" ? f.overlap : 1;
|
|
61
|
+
return { smooth: f.smooth !== false, lag: each, overlap, seed, hands };
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
/** Catmull-Rom slopes, flat at a hold and at the ends, each segment's overshoot bounded. */
|
|
@@ -78,11 +88,47 @@ export function bakeChannels(keys, frameCount, flow = flowOptions()) {
|
|
|
78
88
|
for (const key of [...keys].sort((a, b) => a.f - b.f)) {
|
|
79
89
|
for (const [ch, value] of Object.entries(key.set ?? {})) {
|
|
80
90
|
if (!byChannel.has(ch)) byChannel.set(ch, []);
|
|
81
|
-
byChannel.get(ch).push({ f: key.f, value, step: key.step?.includes(ch) });
|
|
91
|
+
byChannel.get(ch).push({ f: key.f, value, step: key.step?.includes(ch), src: key.src });
|
|
82
92
|
}
|
|
83
93
|
}
|
|
94
|
+
// the top spine bone's share breaks after the two below it (pose.js), so it is baked as its own channel
|
|
95
|
+
const overlap = flow.overlap ?? 0, entries = [...byChannel];
|
|
96
|
+
if (overlap > 0) for (const [ch, list] of byChannel) if (/^spine_/.test(ch)) entries.push([ch.replace("spine", "chest"), list]);
|
|
97
|
+
// an arm's IK weights first: a link's own delay runs out before an arm goes onto its IK and comes back after it
|
|
98
|
+
// leaves (overlap.js), so the fade below never snaps a delayed move onto its keyed frames. Before them where each
|
|
99
|
+
// hand touches: its anchor and fit weights (never delayed) and its carries and hand goals (flow.hands); a side's
|
|
100
|
+
// trail stops short of those
|
|
101
|
+
const pre = new Map(), near = new Map(), touch = {}, trails = {};
|
|
102
|
+
if (overlap > 0) {
|
|
103
|
+
for (const [ch, list] of entries) if (/^w_.+_[lr]$/.test(ch)) pre.set(ch, bakeOne(ch, list));
|
|
104
|
+
for (const k of ["l", "r"]) touch[k] = contactGap(flow.hands?.[k] ?? [], [...pre].filter(([ch]) => ch.endsWith(`_${k}`)).map(([, b]) => b.frames), frameCount);
|
|
105
|
+
// the whole arm of a side trails as one (overlap.js sideTrail), its FK swing with its IK weight, so a blend between
|
|
106
|
+
// them never sets a late weight over an on-time swing
|
|
107
|
+
for (const k of ["l", "r"]) trails[k] = sideTrail([...byChannel].filter(([ch]) => ARM.test(ch) && ch.endsWith(`_${k}`)).map(([ch, list]) => Object.assign([...list], { weight: /^armw_/.test(ch) })), k, overlap, flow.seed ?? "", touch[k]);
|
|
108
|
+
for (const [ch, list] of entries) if (/^armw_[lr]$/.test(ch)) pre.set(ch, bakeOne(ch, list));
|
|
109
|
+
}
|
|
110
|
+
const gapOf = ch => {
|
|
111
|
+
const sides = ikSides(ch).filter(k => pre.has(`armw_${k}`)), key = sides.join("");
|
|
112
|
+
if (!sides.length) return null;
|
|
113
|
+
if (!near.has(key)) near.set(key, ikGap(sides.map(k => pre.get(`armw_${k}`).frames), frameCount));
|
|
114
|
+
return near.get(key);
|
|
115
|
+
};
|
|
84
116
|
const out = {}, plain = {};
|
|
85
|
-
for (const [ch, list] of
|
|
117
|
+
for (const [ch, list] of entries) {
|
|
118
|
+
const baked = pre.get(ch) ?? bakeOne(ch, list, gapOf(ch));
|
|
119
|
+
out[ch] = baked.frames;
|
|
120
|
+
if (baked.plain) plain[ch] = baked.plain;
|
|
121
|
+
}
|
|
122
|
+
// flow fades out as the arm goes onto its IK (the spine with either arm)
|
|
123
|
+
for (const [ch, keyed] of Object.entries(plain)) {
|
|
124
|
+
const ws = ikSides(ch).map(k => out[`armw_${k}`]).filter(Boolean);
|
|
125
|
+
if (!ws.length) continue;
|
|
126
|
+
out[ch] = out[ch].map((v, f) => { const w = Math.min(1, 4 * Math.max(0, ...ws.map(a => a[f]))); return v + (keyed[f] - v) * w * (2 - w); });
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
|
|
130
|
+
/** One channel's frames (and, for a flowing body channel, its keyed frames: plain); gap: frames to the nearest IK frame. */
|
|
131
|
+
function bakeOne(ch, list, gap = null) {
|
|
86
132
|
// later keys at the same frame win
|
|
87
133
|
const pts = [...new Map(list.map(k => [k.f, k])).values()];
|
|
88
134
|
const shape = shapeOf(pts[0].value);
|
|
@@ -91,15 +137,24 @@ export function bakeChannels(keys, frameCount, flow = flowOptions()) {
|
|
|
91
137
|
: shape === "vec" ? Array.from({ length: comps }, (_, i) => pts.map(p => p.value[i]))
|
|
92
138
|
: [...new Set(pts.flatMap(p => Object.keys(p.value)))].map(name => ({ name, y: pts.map(p => p.value[name] ?? 0) }));
|
|
93
139
|
const x = pts.map(p => p.f), smooth = flow.smooth && FLOW_SMOOTH.test(ch), lag = flow.lag[lagFamily(ch)] ?? 0;
|
|
94
|
-
|
|
95
|
-
const
|
|
140
|
+
const delay = shape === "num" ? overlapDelay(ch, pts, overlap, flow.seed ?? "", gap) : null;
|
|
141
|
+
const trail = ARM.test(ch) ? trails[ch.slice(-1)] : null, most = lag + (delay?.max ?? 0);
|
|
142
|
+
const delayAt = f => (delay ? Math.min(delay.at(f), gap ? OVERLAP_SLOPE * gap[Math.round(f)] : Infinity) : 0);
|
|
143
|
+
const end = (f, d) => Math.min(1, (frameCount - 1 - f) / (2 * d));
|
|
144
|
+
// the trail eases out over the last 2 * (its most) frames, so the take ends on the last value (the side's trail over
|
|
145
|
+
// its own: a short ease there would quicken the arm's last move); the side's trail rides the keyed curve too (plain),
|
|
146
|
+
// which an arm on its IK goes back to
|
|
147
|
+
const keyedAt = f => (trail ? f - trail.at(f) * end(f, trail.max) : f);
|
|
148
|
+
const at = f => keyedAt(f) - (most > 0 ? (lag + delayAt(f)) * end(f, most) : 0);
|
|
149
|
+
// a settle near an arm's IK would be cut by the fade below: none within its span and a few frames
|
|
150
|
+
const clear = (a, b) => { if (!gap) return true; for (let f = Math.max(0, Math.floor(a)); f <= Math.min(frameCount - 1, Math.ceil(b + most)); f++) if (gap[f] < 2) return false; return true; };
|
|
96
151
|
const evalSeries = (y, flowing = true) => {
|
|
97
|
-
const m = smooth && flowing ? smoothSlopes(x, y) : pchipSlopes(x, y);
|
|
152
|
+
const m = smooth && flowing ? smoothSlopes(x, y) : pchipSlopes(x, y), bump = flowing && shape === "num" ? settleBump(ch, pts, y, overlap, frameCount - 1, clear) : null;
|
|
98
153
|
return q => {
|
|
99
|
-
const f = flowing ? at(q) : q;
|
|
154
|
+
const f = flowing ? at(q) : keyedAt(q);
|
|
100
155
|
// a step key holds the previous value right up to its frame
|
|
101
156
|
for (let i = 1; i < pts.length; i++) if (pts[i].step && f >= x[i - 1] && f < x[i]) return y[i - 1];
|
|
102
|
-
return pchipEval(x, y, m, f);
|
|
157
|
+
return pchipEval(x, y, m, f) + (bump ? bump(f) : 0);
|
|
103
158
|
};
|
|
104
159
|
};
|
|
105
160
|
const frames = new Array(frameCount);
|
|
@@ -109,16 +164,11 @@ export function bakeChannels(keys, frameCount, flow = flowOptions()) {
|
|
|
109
164
|
const es = series.map(({ name, y }) => [name, evalSeries(y)]);
|
|
110
165
|
for (let f = 0; f < frameCount; f++) frames[f] = Object.fromEntries(es.map(([n, e]) => [n, e(f)]));
|
|
111
166
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
for (const [ch, keyed] of Object.entries(plain)) {
|
|
117
|
-
const ws = ikSides(ch).map(k => out[`armw_${k}`]).filter(Boolean);
|
|
118
|
-
if (!ws.length) continue;
|
|
119
|
-
out[ch] = out[ch].map((v, f) => { const w = Math.min(1, 4 * Math.max(0, ...ws.map(a => a[f]))); return v + (keyed[f] - v) * w * (2 - w); });
|
|
167
|
+
if (!(shape === "num" && (smooth || most > 0) && ikSides(ch).length)) return { frames };
|
|
168
|
+
// an arm's own channels keep the side's trail on its IK too (plain), only the links' own delays and settle fade
|
|
169
|
+
const e = evalSeries(series[0], false);
|
|
170
|
+
return { frames, plain: Array.from({ length: frameCount }, (_, f) => e(f)) };
|
|
120
171
|
}
|
|
121
|
-
return out;
|
|
122
172
|
}
|
|
123
173
|
|
|
124
174
|
/** Frame f's channel dict. */
|
|
@@ -26,6 +26,7 @@ import { pose, SIDES } from "./pose.js";
|
|
|
26
26
|
import { graspAnchor } from "./socket-grip.js";
|
|
27
27
|
import { gripHand } from "./grip.js";
|
|
28
28
|
import { bakeChannels, channelsAt, flowOptions } from "./channels.js";
|
|
29
|
+
import { handBusy } from "./overlap.js";
|
|
29
30
|
import { idleLayer, withIdle, busyHands } from "./idle.js";
|
|
30
31
|
import { resolveAnchor, measureProps, armReach } from "./anchors.js";
|
|
31
32
|
import { expandGenerators } from "./walk.js";
|
|
@@ -208,7 +209,7 @@ export function compileMotion(spec, { sk, scene }, opts = {}) {
|
|
|
208
209
|
const engine = {
|
|
209
210
|
rebake(keys) {
|
|
210
211
|
out.keys = keys;
|
|
211
|
-
out.baked = bakeChannels(keys, frameCount, flowOptions(spec.flow));
|
|
212
|
+
out.baked = bakeChannels(keys, frameCount, flowOptions(spec.flow, spec.label ?? "", handBusy(spec)));
|
|
212
213
|
ctx.socketTurns = new Map();
|
|
213
214
|
ctx.graspPlans = new Map();
|
|
214
215
|
steadyHead(out.baked, keys, frameCount);
|
|
@@ -109,5 +109,7 @@ export function withIdle(c, layer, f) {
|
|
|
109
109
|
if (!d) return c;
|
|
110
110
|
const out = { ...c };
|
|
111
111
|
for (const [ch, v] of Object.entries(d)) out[ch] = Array.isArray(v) ? (Array.isArray(c[ch]) ? c[ch].map((x, i) => x + v[i]) : c[ch]) : (Number(c[ch]) || 0) + v;
|
|
112
|
+
// the top spine bone's own share (chest_*, channels.js overlap) breathes and sways with the rest of the spine
|
|
113
|
+
for (const ch of ["chest_pitch", "chest_roll"]) if (c[ch] !== undefined && d[ch.replace("chest", "spine")] !== undefined) out[ch] = c[ch] + d[ch.replace("chest", "spine")];
|
|
112
114
|
return out;
|
|
113
115
|
}
|