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.
Files changed (154) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/bin/agent/agent-routes.mjs +12 -11
  3. package/bin/agent/main-agent.mjs +21 -7
  4. package/bin/agent/main-prompt.mjs +7 -6
  5. package/bin/agent/plan-tool.mjs +114 -0
  6. package/bin/agent/script-tool.mjs +4 -1
  7. package/bin/agent/sculpt-sketch.mjs +190 -0
  8. package/bin/agent/session-store.mjs +2 -1
  9. package/bin/agent/sketch-code.mjs +151 -0
  10. package/bin/agent/sketch-sandbox.mjs +102 -0
  11. package/bin/agent/studio-prompt.mjs +1 -1
  12. package/bin/agent/studio-rebase.mjs +5 -4
  13. package/bin/agent/studio-task-routes.mjs +1 -1
  14. package/bin/agent/studio-tasks.mjs +116 -25
  15. package/bin/agent/studio-tools.mjs +3 -2
  16. package/bin/agent/turn-frames.mjs +4 -0
  17. package/bin/cozyclay.mjs +4 -2
  18. package/bin/live-endpoint.mjs +2 -0
  19. package/dist/app/index.html +6 -6
  20. package/dist/assets/{TaskBoard-DdDXMgZm.js → TaskBoard-GR8xZm5c.js} +1 -1
  21. package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
  22. package/dist/assets/app-C4QTcDcb.js +5144 -0
  23. package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
  24. package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
  25. package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
  26. package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
  27. package/dist/assets/task-board-BqReHzOi.js +15 -0
  28. package/dist/assets/workflow-C87p6E1E.js +179 -0
  29. package/dist/cozyclay-package.json +1 -1
  30. package/dist/index.html +1 -1
  31. package/dist/sitemap.xml +1 -1
  32. package/dist/workflow/index.html +4 -4
  33. package/mcp/README.md +1 -1
  34. package/mcp/live-hub.mjs +5 -3
  35. package/mcp/tool-handlers.mjs +7 -7
  36. package/package.json +3 -1
  37. package/src/App.jsx +44 -66
  38. package/src/bus-task-scopes.js +59 -11
  39. package/src/bus-task-targets.js +35 -9
  40. package/src/bus-tasks.js +23 -10
  41. package/src/command-bus.js +7 -4
  42. package/src/commands/ai.js +1 -1
  43. package/src/commands/elements.js +4 -3
  44. package/src/commands/export.js +1 -1
  45. package/src/commands/motion3.js +27 -7
  46. package/src/commands/objects.js +1 -1
  47. package/src/export-workspace.js +244 -0
  48. package/src/keyframe-pack.js +5 -1
  49. package/src/live-control.js +14 -7
  50. package/src/material-look.js +189 -0
  51. package/src/material-presets.js +78 -0
  52. package/src/material-textures.js +157 -0
  53. package/src/motion3/compile-job.js +15 -3
  54. package/src/motion3/compiler/channels.js +74 -24
  55. package/src/motion3/compiler/compile.js +2 -1
  56. package/src/motion3/compiler/idle.js +2 -0
  57. package/src/motion3/compiler/overlap.js +180 -0
  58. package/src/motion3/compiler/pose.js +6 -4
  59. package/src/motion3/compiler/validate-common.js +1 -0
  60. package/src/motion3/compiler/validate-parts.js +5 -1
  61. package/src/motion3/compiler/validate.js +30 -10
  62. package/src/motion3/correct.js +236 -0
  63. package/src/motion3/docs.js +8 -6
  64. package/src/motion3/face-lean.js +31 -0
  65. package/src/motion3/fix/index.js +5 -1
  66. package/src/motion3/fix/reach-body.js +172 -0
  67. package/src/motion3/hint-check.js +44 -0
  68. package/src/motion3/json-patch.js +120 -12
  69. package/src/motion3/lenient.js +46 -4
  70. package/src/motion3/object-keys.js +17 -5
  71. package/src/motion3/pipeline.js +46 -9
  72. package/src/motion3/qa/arm-checks.js +96 -0
  73. package/src/motion3/qa/asked.js +113 -0
  74. package/src/motion3/qa/goals.js +37 -8
  75. package/src/motion3/qa/hints-goal.js +6 -5
  76. package/src/motion3/qa/index.js +5 -2
  77. package/src/motion3/qa/key-checks.js +4 -3
  78. package/src/motion3/qa/motion-life.js +243 -0
  79. package/src/motion3/qa/spec-checks.js +15 -9
  80. package/src/motion3/qa/verdict.js +50 -0
  81. package/src/motion3/refs.js +38 -14
  82. package/src/motion3/repair-watch.js +132 -0
  83. package/src/motion3/repair.js +52 -19
  84. package/src/motion3/report.js +50 -9
  85. package/src/motion3/schema/topics.js +6 -4
  86. package/src/motion3/schema/types.js +1 -1
  87. package/src/motion3/skills/basic.js +15 -8
  88. package/src/motion3/skills/index.js +52 -18
  89. package/src/motion3/skills/info.js +1 -1
  90. package/src/motion3/skills/stance.js +6 -0
  91. package/src/motion3/skills/walk-fit.js +170 -0
  92. package/src/object-look.js +9 -5
  93. package/src/panels/PoseImagePanel.jsx +3 -2
  94. package/src/panels/SculptPanel.jsx +2 -2
  95. package/src/primitive-look.js +38 -0
  96. package/src/props.jsx +20 -28
  97. package/src/render-passes.js +7 -1
  98. package/src/result-modal.jsx +3 -3
  99. package/src/scene-objects.js +2 -2
  100. package/src/sculpt-colours.js +65 -0
  101. package/src/sculpt-compare.js +13 -50
  102. package/src/sculpt-csg.js +112 -0
  103. package/src/sculpt-curve.js +123 -0
  104. package/src/sculpt-cutters.js +43 -0
  105. package/src/sculpt-detail.js +207 -0
  106. package/src/sculpt-geometry-loft-convex.js +100 -0
  107. package/src/sculpt-geometry-shapes.js +250 -0
  108. package/src/sculpt-geometry.js +33 -15
  109. package/src/sculpt-loft-rings.js +225 -0
  110. package/src/sculpt-noise.js +65 -0
  111. package/src/sculpt-part-fields.js +27 -0
  112. package/src/sculpt-recipe-read.js +65 -0
  113. package/src/sculpt-recipe.js +33 -75
  114. package/src/sculpt-run.js +1 -1
  115. package/src/sculpt-scatter-items.js +183 -0
  116. package/src/sculpt-scatter-shape.js +57 -0
  117. package/src/sculpt-scatter.js +124 -0
  118. package/src/sculpt-shapes-curved.js +211 -0
  119. package/src/sculpt-shapes-loft-convex.js +218 -0
  120. package/src/sculpt-shapes.js +138 -0
  121. package/src/sculpt-sketch-state.js +76 -0
  122. package/src/sculpt-sketch.jsx +124 -0
  123. package/src/sculpt-surface-fields.js +65 -0
  124. package/src/shell/ExportFolderPreference.jsx +103 -0
  125. package/src/shell/PreferencesDialog.jsx +3 -166
  126. package/src/shell/StatusBar.jsx +1 -1
  127. package/src/shell/preference-controls.jsx +114 -0
  128. package/src/shell/preference-pages.jsx +72 -0
  129. package/src/shell/preferences.css +46 -0
  130. package/src/shot-prompt.js +29 -9
  131. package/src/studio-actions.js +2 -2
  132. package/src/studio-agent-motion.js +19 -9
  133. package/src/studio-agent-protocol.js +1 -1
  134. package/src/studio-app-binding.js +3 -2
  135. package/src/studio-elements.js +5 -1
  136. package/src/workflow/AgentPanel.jsx +6 -6
  137. package/src/workflow/AgentPlanCard.jsx +41 -0
  138. package/src/workflow/CozySceneNode.jsx +1 -1
  139. package/src/workflow/WorkflowBuilder.jsx +2 -2
  140. package/src/workflow/agent-client.js +14 -11
  141. package/src/workflow/agent-plan.css +93 -0
  142. package/src/workflow/agent-plan.js +59 -0
  143. package/src/workflow/keyframe-pack-request.js +3 -10
  144. package/src/workflow/task-board.css +1 -1
  145. package/src/workflow/task-board.js +7 -5
  146. package/tools/loc-baseline.json +1 -2
  147. package/tools/run-tests.mjs +8 -8
  148. package/dist/assets/app-B2BpfCl2.js +0 -5088
  149. package/dist/assets/compile-worker-D0pKioa0.js +0 -5
  150. package/dist/assets/motion3-compile-B887n77J.js +0 -1
  151. package/dist/assets/scene-asset-sync-COGOlF5T.css +0 -1
  152. package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
  153. package/dist/assets/task-board-BKaM7d-9.js +0 -14
  154. 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
- const t0 = performance.now(), fixed = repair ? repairSpec(asked, repair, once) : { spec: asked, result: once(asked) };
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 => (/^spine_/.test(ch) ? "spine" : /^head_/.test(ch) ? "head" : /^(aswing|abend)_[lr]$/.test(ch) ? "arms" : null);
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
- /** spec.flow -> { smooth, lag: { spine, head, arms } }: undefined is the default, false turns both off. */
47
- export function flowOptions(flow) {
48
- if (flow === false) return { smooth: false, lag: { spine: 0, head: 0, arms: 0 } };
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
- return { smooth: f.smooth !== false, lag: each };
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 byChannel) {
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
- // the trail eases out over the last 2 * lag frames, so the take ends on the last value
95
- const at = f => (lag > 0 ? f - lag * Math.min(1, (frameCount - 1 - f) / (2 * lag)) : f);
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
- out[ch] = frames;
113
- if (shape === "num" && (smooth || lag > 0) && ikSides(ch).length) { const e = evalSeries(series[0], false); plain[ch] = Array.from({ length: frameCount }, (_, f) => e(f)); }
114
- }
115
- // flow fades out as the arm goes onto its IK (the spine with either arm)
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
  }