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,170 @@
1
+ // walkTo's fit (C21): to with steps walks where the steps walk, so the steps are fitted to end at to in a natural
2
+ // gait for the rig (skills/basic.js walkTo calls fitTo when correct.js fitWalks asks), or the walk is refused.
3
+ import { len, sub } from "../compiler/math.js";
4
+ import { walk, stateAt } from "../compiler/walk.js";
5
+ import { partsOf } from "../object-pose.js";
6
+ import { standClearance } from "./stance.js";
7
+
8
+ /** The feet's midpoint [x, z] once the walk's keys have played out. */
9
+ export function walkEndAt(keys) {
10
+ const st = stateAt(keys, Infinity);
11
+ return [(st.foot_l[0] + st.foot_r[0]) / 2, (st.foot_l[2] + st.foot_r[2]) / 2];
12
+ }
13
+ export const r2 = v => Math.round(v * 100) / 100, xzText = p => `[${p.map(r2).join(", ")}]`;
14
+ // A natural gait for the rig (fitTo): a step STEP of the leg (hip to ankle: 0.25..0.7 m on the Y Bot), the first of a
15
+ // two-step walk FIRST of it (a short shuffle is a natural reposition), CADENCE steps a second, and the step over the cadence
16
+ // within RATIO of WALK_RATIO legs x s: a person's walk ratio (about 6.5 mm per step a minute, 0.39 m x s on a 0.83 m leg)
17
+ // holds from a slow walk to a brisk one, so longer steps come with quicker ones. A stride fitted at a fixed cadence went to
18
+ // 0.87 m at 1.2 steps a second in the agent sessions (a lunge); the walk generator slides or pops in most cadences from a
19
+ // 0.75 m step, and pops above 1.8 steps a second (31 of 47 fitted walks at period 12-13, none at 14-18; a person's brisk walk
20
+ // reaches 2.1).
21
+ const STEP = [0.3, 0.85], FIRST = [0.18, 0.85], CADENCE = [1, 1.8], WALK_RATIO = 0.47, RATIO = [0.7, 1.4], CHANGE = 0.1, HEADING_TOL = 20;
22
+ export const REACH_TOL = 0.1;
23
+ // A walk whose to lies within CLEAR legs (0.41 m on the Y Bot) of a body ahead of it (inside it, or its nearest point within
24
+ // 60 deg of the way the walk goes) stops before the body instead: a low body (top under LOW legs: a box on the floor, a crate)
25
+ // CLEAR from its near side, the feet's midpoint to its footprint, room to bend over it; a higher one (a table, a counter, a
26
+ // wall) where the standing body (stance.js: legs, feet, hips, arms down) clears it by GAP. The bench replay (n=692): steps
27
+ // fitted onto a to the agent had set 0.22-0.30 m from a box it then picked up walked the body into it (COLLIDE frames +58%,
28
+ // first-compile passes 28 -> 10); the build before the fit had stopped those walks 0.40-0.56 m from the box. One clearance
29
+ // for every body (0.41 m from a table too) cut COLLIDE frames as far but cost 59 goals and 127 REACH groups (a cup on the table
30
+ // out of reach); this split keeps the goals the fit gained (996, as 582d0501) with COLLIDE frames 80,853 -> 63,350 and
31
+ // first-compile passes 10 -> 19. A body taller than HEAD m at its foot (a shelf overhead), lower than SUPPORT (a mat), carried
32
+ // during the walk or sat on next is not in the way.
33
+ const CLEAR = 0.5, SUPPORT = 0.12, HEAD = 1.7, AHEAD = Math.cos(Math.PI / 3), LOW = 0.65, GAP = 0.05;
34
+ const legOf = sk => { const P = sk.restWorldPos, I = sk.index; return len(sub(P[I.LeftLeg], P[I.LeftUpLeg])) + len(sub(P[I.LeftFoot], P[I.LeftLeg])); };
35
+ // the bodies a walk may end against, as the still scene has them: what a hand carries during the walk (spec.objects with
36
+ // a grab before its end, not released before it starts), the seat a later sitInSeat sits on, its back, the car, and
37
+ // every part of their groups
38
+ function passable(ctx, end) {
39
+ const scene = ctx.scene, ids = new Set(), add = id => { const top = scene?.objects?.[id]?.ancestors?.at(-1) ?? id; for (const part of partsOf(scene, top)) ids.add(part); };
40
+ for (const u of (ctx.use ?? []).filter((x, j) => j !== ctx.index && x?.f0 >= ctx.f0 && x?.skill === "sitInSeat"))
41
+ for (const id of [u.args?.seat, u.args?.seatBack, u.args?.car].filter(x => typeof x === "string")) add(id);
42
+ for (const [id, o] of Object.entries(ctx.objects ?? {})) if (Number.isFinite(o?.grab) && o.grab <= end && !(o.release < ctx.f0)) for (const part of partsOf(scene, id)) ids.add(part);
43
+ return ids;
44
+ }
45
+ // the feet's midpoint p = [x, z] to body o's footprint: [distance, the nearest point]
46
+ function footprint(o, p) {
47
+ const c = o.R, dx = p[0] - o.pos[0], dz = p[1] - o.pos[2], lx = c[0][0] * dx + c[2][0] * dz, lz = c[0][2] * dx + c[2][2] * dz;
48
+ const qx = Math.max(-o.half[0], Math.min(o.half[0], lx)), qz = Math.max(-o.half[2], Math.min(o.half[2], lz));
49
+ const q = [o.pos[0] + c[0][0] * qx + c[0][2] * qz, o.pos[2] + c[2][0] * qx + c[2][2] * qz];
50
+ return [Math.hypot(p[0] - q[0], p[1] - q[1]), q];
51
+ }
52
+ // the point of the approach from c0 (unit u) where a walk toward a to (at d) stops clear of the bodies ahead: { t, at, near }, or null
53
+ function clearStop(ctx, c0, u, d, skip, clear, h) {
54
+ const to = [c0[0] + u[0] * d, c0[1] + u[1] * d], leg = legOf(ctx.sk);
55
+ const ahead = Object.entries(ctx.scene?.objects ?? {}).filter(([id, o]) => !skip.has(id) && o.half && o.pos && o.R && o.pos[1] < HEAD && o.pos[1] + 2 * o.half[1] * o.R[1][1] > SUPPORT)
56
+ .map(([id, o]) => { const [dist, q] = footprint(o, to); return { id, o, dist, q, low: o.pos[1] + 2 * o.half[1] * o.R[1][1] < LOW * leg }; })
57
+ .filter(b => b.dist < clear && (b.dist < 1e-6 || ((b.q[0] - to[0]) * u[0] + (b.q[1] - to[1]) * u[1]) >= AHEAD * b.dist)).sort((a, b) => a.dist - b.dist);
58
+ if (!ahead.length) return null;
59
+ const at = s => [c0[0] + u[0] * s, c0[1] + u[1] * s], high = new Set(Object.keys(ctx.scene.objects).filter(id => !ahead.some(b => b.id === id && !b.low)));
60
+ const blocked = s => ahead.some(b => b.low && footprint(b.o, at(s))[0] < clear) || (ahead.some(b => !b.low) && standClearance(ctx.sk, ctx.scene, at(s), h, high) < GAP);
61
+ if (!blocked(d)) return null;
62
+ let t = d;
63
+ while (t > 0 && blocked(t)) t -= 0.01;
64
+ return t > REACH_TOL ? { t, at: at(t), near: ahead[0].id, gap: footprint(ahead[0].o, at(t))[0] } : null;
65
+ }
66
+ // the last frame a walk whose stepping ends at `from` may end at: the first later frame another skill starts at, a key of the
67
+ // spec (or an object key, grab or release: ctx.fit.busy) after its stepping, or the take's last frame (ctx.fit.last)
68
+ const roomAfter = (ctx, from) => Math.min(ctx.fit.last ?? Infinity, ...(ctx.use ?? []).filter((x, j) => j !== ctx.index && x?.f0 > ctx.f0).map(x => x.f0),
69
+ ...ctx.keys.filter(k => k.f > from && /^keys\[/.test(k.src ?? "")).map(k => k.f), ...(ctx.fit.busy ?? []).filter(f => f > from));
70
+
71
+ /**
72
+ * to with steps (C21): the steps walk where they walk, and to only turns the heading, so a walk with both is fitted
73
+ * when ctx.fit asks (correct.js fitWalks, before the spec is stored), in a natural gait (the bands above):
74
+ * the stride (steps >= 3, the first step keeping its share of it) or the first step (steps 2) that makes the steps end
75
+ * at to, when it stays in its band and in the walk ratio band at the walk's cadence;
76
+ * else the step count whose stride is nearest the walk ratio, its stride and cadence in their bands: in the walk's own
77
+ * stepping time (steps x period frames, the period the one that fits, never ending later; fewer steps stand at the end),
78
+ * or at the walk's own period when nothing comes after it in a frames "auto" spec (ctx.fit.stretch: it may end later);
79
+ * else more steps that end later, into frames nothing uses (roomAfter), at the walk's own cadence or a quicker natural one;
80
+ * else the walk's own steps at its band's edge when they still end within REACH_TOL of to (a walk that near is not fitted);
81
+ * else, or for a heading that points away from to, a line in ctx.fit.refusals with what the walk would do and needs.
82
+ * The last two were refusals until the bench (S7, S10: 3 of 7 walkTo refusals in 30 merged runs, 4 walks): each had one in
83
+ * the bands (step 0.7 m at 1.33-1.5 steps a second ending 0.05-0.07 m short, or 4 steps at 1.71 steps a second ending 2
84
+ * frames later into free frames) whose whole take had no foot, knee or float problem in the walk the in-band walks lacked.
85
+ * Deterministic: a walk at a candidate count is estimated (its end is linear in the stride) and the chosen one walked
86
+ * once more to set its stride. -> { gen, adjust } with the fitted gen.
87
+ */
88
+ export function fitTo(ctx, gen, c0, d, bearing) {
89
+ const { args, tag, sk } = ctx, steps = gen.steps, h = Array.isArray(args.heading) ? args.heading[1] : args.heading;
90
+ const off = h === undefined ? 0 : Math.abs(((h - bearing) % 360 + 540) % 360 - 180);
91
+ if (off > HEADING_TOL) {
92
+ ctx.fit.refusals.push(`${tag}: heading ${h} points ${Math.round(off)} deg away from to ${xzText(args.to)} (it lies at heading ${Math.round(bearing)}), so the walk would not go there; with to the walk turns toward it by itself: leave heading out, or give heading without to to walk that way`);
93
+ return { gen };
94
+ }
95
+ const period = gen.period ?? 20, swing = gen.swing ?? 14, was = gen.step ?? 0.5, first = gen.firstStep ?? 0.4;
96
+ if (args.steps === undefined || args.arrive !== undefined || !(steps >= 2) || ![steps, period, swing, was, first].every(Number.isFinite)) return { gen };
97
+ const u = [Math.sin(bearing * Math.PI / 180), Math.cos(bearing * Math.PI / 180)];
98
+ // to against a body: the steps end at the nearest point of the approach where the body stands clear of it; when no
99
+ // natural gait ends there, the fit to to itself (and its refusal) as before
100
+ // (ctx.fit.noStop: the walks correct.js fitWalks keeps on to, as a stop would make a later walk refuse; ctx.fit.stopped: the walks that stopped)
101
+ const stop = !ctx.fit.noStop?.has(ctx.index) && clearStop(ctx, c0, u, d, passable(ctx, ctx.f0 + 16 + steps * period + 18), CLEAR * legOf(sk), bearing);
102
+ let r = stop && fitSteps(ctx, gen, c0, u, stop.t, stop);
103
+ if (r && !r.refuse) ctx.fit.stopped?.add(ctx.index);
104
+ if (!r || r.refuse) r = fitSteps(ctx, gen, c0, u, d, null);
105
+ if (r.refuse) ctx.fit.refusals.push(r.refuse);
106
+ return { gen: r.gen, ...(r.adjust ? { adjust: r.adjust } : {}) };
107
+ }
108
+
109
+ /** fitTo's stride, step count and cadence for a walk ending d along u from c0 (stop: the clear point it ends at) -> { gen, adjust? } or { gen, refuse } */
110
+ function fitSteps(ctx, gen, c0, u, d, stop) {
111
+ const { args, tag, sk } = ctx, steps = gen.steps, period = gen.period ?? 20, swing = gen.swing ?? 14, was = gen.step ?? 0.5, first = gen.firstStep ?? 0.4;
112
+ const target = stop ? `${xzText(stop.at)}, ${r2(stop.gap)} m before ${stop.near} (to ${xzText(args.to)} is against it)` : `to ${xzText(args.to)}`;
113
+ const reach = g => { const e = walkEndAt(walk(g, ctx.keys, sk, tag)); return { end: e, along: (e[0] - c0[0]) * u[0] + (e[1] - c0[1]) * u[1] }; };
114
+ const { end, along } = reach(gen);
115
+ if (Math.abs(d - along) <= REACH_TOL) return { gen };
116
+ const fps = ctx.fps ?? 24, leg = legOf(sk), nat = WALK_RATIO * leg, span = steps * period;
117
+ const band = n => (n >= 3 ? STEP : FIRST).map(x => r2(x * leg)), pLo = Math.ceil(fps / CADENCE[1]), pHi = Math.floor(fps / CADENCE[0]);
118
+ const stretch = ctx.fit.stretch === true && !(ctx.use ?? []).some((x, j) => j !== ctx.index && x?.f0 > ctx.f0) && !ctx.keys.some(k => k.f > ctx.f0 && /^keys\[/.test(k.src ?? ""));
119
+ // n steps: the period (its own, or the one that keeps the stepping time), the stride that ends them at to (the end moves
120
+ // by the first step and a stride a middle step, rest the closing step's share; with steps 3 and up the first step keeps
121
+ // its share of the stride unless args give it, as a person's first step is a little shorter), whether they stay in their bands
122
+ const rest = along - first - (steps - 2) * was, share = args.firstStep === undefined ? first / was : 0;
123
+ const at = (n, pAt) => {
124
+ const p = pAt ?? (stretch || n === steps ? period : Math.min(Math.max(pHi, period), Math.floor(span / n))), name = n >= 3 ? "step" : "firstStep";
125
+ const value = n >= 3 ? (d - rest - (share ? 0 : first)) / (share + n - 2) : d - rest, lead = n >= 3 && share ? share * value : first;
126
+ const [lo, hi] = band(n), ratio = value * p / fps / nat, inBand = (x, [a, b]) => x >= a && x <= b;
127
+ return { n, p, name, value, ratio, ok: inBand(value, [lo, hi]) && inBand(lead, band(2)) && (p === period || inBand(p, [pLo, pHi])) };
128
+ };
129
+ const own = at(steps), most = Math.min(60, Math.ceil(d / band(3)[0]) + 2);
130
+ let pick = own.ok && own.ratio >= RATIO[0] && own.ratio <= RATIO[1] ? own : null, room, short;
131
+ // nearest the walk ratio, a step count further from the one asked costing CHANGE of it a step
132
+ const cost = c => Math.abs(Math.log(c.ratio)) + CHANGE * Math.abs(c.n - steps), nearest = list => list.filter(c => c.ok).sort((a, b) => cost(a) - cost(b) || a.n - b.n)[0];
133
+ const counts = Array.from({ length: most - 1 }, (_, i) => i + 2);
134
+ if (!pick) pick = nearest(counts.map(n => at(n)));
135
+ if (!pick && !stretch) {
136
+ // more steps, ending later into frames nothing uses: the walk's own period, or the slowest natural one that ends in time
137
+ room = roomAfter(ctx, ctx.f0 + 16 + span);
138
+ pick = nearest(counts.filter(n => n > steps).map(n => at(n, Math.min(period, Math.floor((room - ctx.f0 - 34) / n)))).filter(c => c.p >= pLo && c.n * c.p > span));
139
+ }
140
+ if (!pick) {
141
+ // the walk's own steps at its band's edge, when they end within REACH_TOL of to
142
+ const [lo, hi] = band(steps), value = Math.min(hi, Math.max(lo, own.value)), ratio = value * period / fps / nat, lead = steps >= 3 && share ? share * value : first;
143
+ const g = { ...gen, [own.name]: r2(value), ...(steps >= 3 && share ? { firstStep: r2(lead) } : {}) };
144
+ const left = ratio >= RATIO[0] && ratio <= RATIO[1] && lead >= band(2)[0] && lead <= band(2)[1] ? d - reach(g).along : Infinity;
145
+ if (Math.abs(left) <= REACH_TOL) ({ pick, short } = { pick: { ...own, value: r2(value), ratio }, short: left });
146
+ }
147
+ const [sLo, sHi] = band(3), gait = `a natural gait (step ${sLo}..${sHi} m, ${CADENCE[0]}..${CADENCE[1]} steps a second: period ${pLo}..${pHi} at ${fps} fps`;
148
+ const miss = `${r2(Math.abs(d - along))} m ${along < d ? "short of" : "past"} ${stop ? `the point ${xzText(stop.at)} clear of ${stop.near} before to ${xzText(args.to)}` : `to ${xzText(args.to)}`} (${r2(d)} m away)`;
149
+ if (!pick) {
150
+ // short: the fewest steps at the longest natural step, at the quickest natural cadence (the least stepping time that reaches)
151
+ const n = 2 + Math.ceil((d - rest - first) / sHi - 1e-9), e = ctx.f0 + 16 + n * pLo + 18;
152
+ return { gen, refuse: `${tag}: steps ${steps} at period ${period} (${span} frames of stepping) walk ${r2(along)} m and end at ${xzText(end)}, ${miss}; ${along < d
153
+ ? `in ${gait}) that takes ${n} steps and ${n * pLo} frames of stepping or more (the walk would end at f${e} or later, not f${ctx.f0 + 16 + span + 18}): leave steps${args.step === undefined ? "" : " and step"} out (walkTo picks the count), or give steps: ${n} and room for the walk before what follows`
154
+ : `no natural step count ends there: leave steps out (walkTo picks the count)`}` };
155
+ }
156
+ const timed = pick.p === period ? {} : { period: pick.p, swing: Math.max(2, Math.min(pick.p - 1, Math.round(swing * pick.p / period))) };
157
+ const lead = pick.n >= 3 && share > 0, strides = v => ({ [pick.name]: r2(v), ...(lead ? { firstStep: r2(share * v) } : {}) });
158
+ let fitted = { ...gen, steps: pick.n, ...timed, ...strides(pick.value) };
159
+ const left = short ?? d - reach(fitted).along;
160
+ if (short === undefined && Math.abs(left) > 0.02) fitted = { ...fitted, ...strides(pick.value + left / (pick.n >= 3 ? share + pick.n - 2 : 1)) };
161
+ const v = fitted[pick.name], dflt = pick.name === "step" ? was : first, e0 = ctx.f0 + 16 + span + 18, e1 = ctx.f0 + 16 + pick.n * pick.p + 18;
162
+ const changes = [pick.n !== steps && `steps ${steps} -> ${pick.n}`, timed.period && `period ${period} -> ${timed.period} (swing ${swing} -> ${timed.swing})`, `${pick.name} ${dflt} -> ${v} m`,
163
+ lead && fitted.firstStep !== first && `firstStep ${first} -> ${fitted.firstStep} m`].filter(Boolean).join(", ");
164
+ const when = e1 === e0 ? "" : `; it ends f${e1}, not f${e0} (${stretch ? "nothing follows it" : e1 > e0 ? `nothing uses f${e0 + 1}-f${e1}` : `the pose holds to f${e0}`})`;
165
+ const needs = `steps ${steps} would need a ${own.name} of ${r2(own.value)} m at ${r2(fps / period)} steps a second`;
166
+ const why = short !== undefined ? ` (${gait}; ${needs})` : pick.n === steps && !timed.period ? ` (at ${pick.name} ${dflt} they walk ${r2(along)} of ${r2(d)} m)` : ` in ${gait}; ${needs})${when}`;
167
+ const ends = short === undefined ? `end at ${target}` : `end ${r2(Math.abs(short))} m ${short > 0 ? "short of" : "past"} ${target}, the ${short > 0 ? "longest" : "shortest"} natural step`;
168
+ const adjust = { args: { ...(pick.n === steps ? {} : { steps: pick.n }), ...timed, [pick.name]: v, ...(lead && fitted.firstStep !== first ? { firstStep: fitted.firstStep } : {}) }, note: `${tag}: ${changes}, so steps ${pick.n} ${ends}${why}` };
169
+ return { gen: fitted, adjust };
170
+ }
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { normalizeObjectColor } from "./object-colors.js";
19
+ import { objectMaterialFields, objectMaterialPatch } from "./material-presets.js";
19
20
 
20
21
  export const LIGHT_KIND = "light";
21
22
 
@@ -94,8 +95,8 @@ export function normalizeObjectGlow(value) {
94
95
  }
95
96
 
96
97
  /** The light/glow fields a normalized record carries. A light renderer always
97
- * has a light; any record keeps a glow only when it has one, so records written
98
- * before glow existed come back byte-for-byte unchanged. */
98
+ * has a light; any record keeps a glow (and a primitive its surface preset)
99
+ * only when it has one, so records written before come back unchanged. */
99
100
  export function objectLookFields(record, entry) {
100
101
  const out = {};
101
102
  if ((entry.renderer ?? entry.kind) === LIGHT_KIND) out.light = normalizeObjectLight(record.light, record.light?.type ?? entry.light?.type);
@@ -103,12 +104,14 @@ export function objectLookFields(record, entry) {
103
104
  if (glow) out.glow = glow;
104
105
  // A kit part a take switches on (a muzzle flash and its light) stays hidden without one (object-travel.js trackShown).
105
106
  if (record.showOnlyInTake === true) out.showOnlyInTake = true;
106
- return out;
107
+ // A primitive's surface preset (material-presets.js).
108
+ return Object.assign(out, objectMaterialFields(record, entry.renderer ?? entry.kind));
107
109
  }
108
110
 
109
111
  /** The light/glow part of an object.update patch: each field merges over the
110
112
  * record's own and is repaired; `glow: null` removes the glow. Only changes are
111
- * returned. A light on a record that is not a light is ignored. */
113
+ * returned. A light on a record that is not a light is ignored. A primitive's
114
+ * material and finish ride along (material-presets.js). */
112
115
  export function objectLookPatch(object, patch) {
113
116
  const update = {};
114
117
  if (patch?.light && typeof patch.light === "object" && object.renderer === LIGHT_KIND) {
@@ -119,7 +122,7 @@ export function objectLookPatch(object, patch) {
119
122
  const next = patch.glow === null ? null : normalizeObjectGlow({ ...(object.glow ?? {}), ...patch.glow });
120
123
  if (JSON.stringify(next) !== JSON.stringify(object.glow ?? null)) update.glow = next;
121
124
  }
122
- return update;
125
+ return Object.assign(update, objectMaterialPatch(object, patch));
123
126
  }
124
127
 
125
128
  /** 32-bit FNV-1a of the record id: the flicker's seed. */
@@ -196,3 +199,4 @@ export function withShadowCapWarnings(result, objects) {
196
199
  }
197
200
  // A record's grasp sockets ride the same normalize and patch paths (object-grasp.js).
198
201
  export { objectGraspFields, objectGraspPatch } from "./object-grasp.js";
202
+ export { objectMaterialKeys } from "./material-presets.js";
@@ -4,6 +4,7 @@ import Foldout from "./Foldout.jsx";
4
4
  import { ko, isKo } from "../locale.js";
5
5
  import { readReferenceImage } from "./ReferenceImageField.jsx";
6
6
  import { createHttpTransport } from "../workflow/agent-client.js";
7
+ import { saveExport } from "../export-workspace.js";
7
8
  import "./pose-image.css";
8
9
 
9
10
  // The sidecar accepts at most 6 references per request, shared with the scene's identity and
@@ -145,7 +146,7 @@ export default function PoseImagePanel({ hidden, capturePoseImage }) {
145
146
  <img src={result.dataUrl} alt={result.prompt} />
146
147
  </button>
147
148
  <div className="pose-image-result-actions">
148
- <a className="btn ghost small" href={result.dataUrl} download={`cozyclay-pose-${result.at}.png`}>{ko("Download", "다운로드")}</a>
149
+ <a className="btn ghost small" href={result.dataUrl} download={`cozyclay-pose-${result.at}.png`} onClick={(event) => { event.preventDefault(); void saveExport(result.dataUrl, `cozyclay-pose-${result.at}.png`); }}>{ko("Download", "다운로드")}</a>
149
150
  <button type="button" className="btn ghost small" disabled={refs.length >= USER_REFERENCES_MAX}
150
151
  onClick={() => setPoseImageState({ refs: [...store.refs, result.dataUrl].slice(0, USER_REFERENCES_MAX) })}>
151
152
  {ko("Use as reference", "참조로 쓰기")}
@@ -194,7 +195,7 @@ function PoseImageViewer({ results, selected, onSelect, onClose }) {
194
195
  <figcaption>
195
196
  <span className="pose-image-viewer-prompt">{entry.prompt}</span>
196
197
  {results.length > 1 && <span className="pose-image-viewer-count">{results.length - selected}/{results.length}</span>}
197
- <a className="btn ghost small" href={entry.dataUrl} download={`cozyclay-pose-${entry.at}.png`}>{ko("Download", "다운로드")}</a>
198
+ <a className="btn ghost small" href={entry.dataUrl} download={`cozyclay-pose-${entry.at}.png`} onClick={(event) => { event.preventDefault(); void saveExport(entry.dataUrl, `cozyclay-pose-${entry.at}.png`); }}>{ko("Download", "다운로드")}</a>
198
199
  <button type="button" className="btn ghost small" data-testid="pose-image-viewer-close" onClick={onClose}>{ko("Close", "닫기")}</button>
199
200
  </figcaption>
200
201
  </figure>
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import Foldout from "./Foldout.jsx";
3
3
  import { ko, isKo } from "../locale.js";
4
- import { effortOptions } from "../workflow/agent-client.js";
4
+ import { effortOptions } from "../workflow/agent-client.js"; import { saveExport } from "../export-workspace.js";
5
5
  import "./sculpt.css";
6
6
 
7
7
  // Sculpt mode side panel (#739). Pure view: the run controller owns the run state and the
@@ -241,7 +241,7 @@ function BlueprintSection({ blueprint, disabled }) {
241
241
  </a>
242
242
  <figcaption>
243
243
  <span>{sheet.width}×{sheet.height}{Number.isFinite(sheet.ms) ? ` · ${Math.round(sheet.ms / 1000)}s` : ""}</span>
244
- <a href={sheet.dataUrl} download="sculpt-blueprint.png" data-testid="sculpt-blueprint-download">{ko("Download", "내려받기")}</a>
244
+ <a href={sheet.dataUrl} download="sculpt-blueprint.png" data-testid="sculpt-blueprint-download" onClick={(event) => { event.preventDefault(); void saveExport(sheet.dataUrl, "sculpt-blueprint.png"); }}>{ko("Download", "내려받기")}</a>
245
245
  </figcaption>
246
246
  </figure>
247
247
  )}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A primitive's surface preset (object.material / object.finish) as the
3
+ * material props.jsx hangs on its mesh, and the polygon offset every prop
4
+ * material takes for its depth rank.
5
+ */
6
+
7
+ import { useEffect, useMemo } from "react";
8
+ import { lookMaterial } from "./material-look.js";
9
+
10
+ // Parts of an assembly can share a plane with an earlier part (hood top and
11
+ // grille top). The depth test can't order them and the winner flips as the
12
+ // camera moves, so a ranked part is pulled toward the camera by `rank` steps
13
+ // (negative = nearer). Rank 0 gets nothing: those materials stay untouched.
14
+ export function depthRankProps(rank) {
15
+ return rank > 0 ? { polygonOffset: true, polygonOffsetFactor: -rank, polygonOffsetUnits: -rank } : null;
16
+ }
17
+
18
+ /** Each primitive's extent in its mesh frame at scale 1 (props.jsx builds
19
+ * them); the rest are unit sized. The shader multiplies the record's scale in. */
20
+ const PRIMITIVE_SIZE = { capsule: [0.7, 1.4, 0.7], plane: [2, 2, 0.01] };
21
+
22
+ /**
23
+ * The preset material for a primitive record, or null when the record keeps
24
+ * the plain clay look (no material and no finish) or auto-colour paints it
25
+ * flat: the caller then draws exactly what it drew before presets. Rebuilt
26
+ * when the look, the colour or the rank changes; the program and the pattern
27
+ * textures are shared, so a rebuild is cheap.
28
+ */
29
+ export function usePrimitiveLook(object, kind, side, depthRank) {
30
+ const { material, finish, color, autoColor } = object;
31
+ const active = !autoColor && Boolean(material || finish);
32
+ const look = useMemo(
33
+ () => (active ? lookMaterial({ material, finish, color, side, size: PRIMITIVE_SIZE[kind] }, depthRankProps(depthRank)) : null),
34
+ [active, material, finish, color, side, kind, depthRank],
35
+ );
36
+ useEffect(() => () => look?.dispose(), [look]);
37
+ return look;
38
+ }
package/src/props.jsx CHANGED
@@ -19,6 +19,7 @@ import { CUTOUT_KIND, MESH_KIND, EMPTY_KIND, EMPTY_MARKER_SIZE, SCULPT_KIND } fr
19
19
  import { TEXT_KIND } from "./text/text-record.js";
20
20
  import { TextObject } from "./text/text-object.jsx";
21
21
  import { buildSculptGroup, disposeSculptGroup } from "./sculpt-geometry.js";
22
+ import { depthRankProps, usePrimitiveLook } from "./primitive-look.js";
22
23
  import { subscribeToAssetTexture } from "./scene-asset-cache.js";
23
24
  import { ImportedMesh } from "./imported-mesh.jsx";
24
25
  import { EffectMarker, EffectsLayer } from "./effects/EffectsLayer.jsx";
@@ -30,29 +31,23 @@ import { LIGHT_KIND, capPracticalShadows } from "./object-look.js";
30
31
  import { SceneLight, useLookRefresh } from "./scene-lights.jsx";
31
32
  import { placeObjectLook } from "./scene-light-place.js";
32
33
 
33
- // Parts of an assembly can share a plane with an earlier part (hood top and
34
- // grille top). The depth test can't order them and the winner flips as the
35
- // camera moves, so a ranked part is pulled toward the camera by `rank` steps
36
- // (negative = nearer). Rank 0 gets nothing: those materials stay untouched.
37
- function depthRankProps(rank) {
38
- return rank > 0 ? { polygonOffset: true, polygonOffsetFactor: -rank, polygonOffsetUnits: -rank } : null;
39
- }
40
-
41
34
  /**
42
35
  * The creatable primitives, Unity's 3D Object menu in clay. Each one is built
43
36
  * with its base on the local floor (y = 0), so an object's `y` reads as height
44
- * above the deck rather than "half of me is underground".
37
+ * above the deck rather than "half of me is underground". A surface preset
38
+ * (primitive-look.js) replaces the clay; glass casts no shadow.
45
39
  */
46
- function Primitive({ kind, color, autoColor, depthRank = 0 }) {
40
+ function Primitive({ kind, object, color, autoColor, depthRank = 0 }) {
47
41
  const side = kind === "plane" ? THREE.DoubleSide : THREE.FrontSide;
48
- const material = autoColor ? (
42
+ const look = usePrimitiveLook(object, kind, side, depthRank), cast = !(look?.transmission > 0);
43
+ const material = look ? <primitive object={look} attach="material" /> : autoColor ? (
49
44
  <meshStandardMaterial {...autoFlat(autoColor)} side={side} {...depthRankProps(depthRank)} />
50
45
  ) : (
51
46
  <meshStandardMaterial color={color} roughness={0.82} side={side} {...depthRankProps(depthRank)} />
52
47
  );
53
48
  if (kind === "sphere") {
54
49
  return (
55
- <mesh position={[0, 0.5, 0]} castShadow receiveShadow>
50
+ <mesh position={[0, 0.5, 0]} castShadow={cast} receiveShadow>
56
51
  <sphereGeometry args={[0.5, 28, 18]} />
57
52
  {material}
58
53
  </mesh>
@@ -60,7 +55,7 @@ function Primitive({ kind, color, autoColor, depthRank = 0 }) {
60
55
  }
61
56
  if (kind === "capsule") {
62
57
  return (
63
- <mesh position={[0, 0.7, 0]} castShadow receiveShadow>
58
+ <mesh position={[0, 0.7, 0]} castShadow={cast} receiveShadow>
64
59
  <capsuleGeometry args={[0.35, 0.7, 6, 18]} />
65
60
  {material}
66
61
  </mesh>
@@ -68,7 +63,7 @@ function Primitive({ kind, color, autoColor, depthRank = 0 }) {
68
63
  }
69
64
  if (kind === "cylinder") {
70
65
  return (
71
- <mesh position={[0, 0.5, 0]} castShadow receiveShadow>
66
+ <mesh position={[0, 0.5, 0]} castShadow={cast} receiveShadow>
72
67
  <cylinderGeometry args={[0.5, 0.5, 1, 26]} />
73
68
  {material}
74
69
  </mesh>
@@ -76,7 +71,7 @@ function Primitive({ kind, color, autoColor, depthRank = 0 }) {
76
71
  }
77
72
  if (kind === "cone") {
78
73
  return (
79
- <mesh position={[0, 0.5, 0]} castShadow receiveShadow>
74
+ <mesh position={[0, 0.5, 0]} castShadow={cast} receiveShadow>
80
75
  <coneGeometry args={[0.5, 1, 26]} />
81
76
  {material}
82
77
  </mesh>
@@ -91,7 +86,7 @@ function Primitive({ kind, color, autoColor, depthRank = 0 }) {
91
86
  );
92
87
  }
93
88
  return (
94
- <mesh position={[0, 0.5, 0]} castShadow receiveShadow>
89
+ <mesh position={[0, 0.5, 0]} castShadow={cast} receiveShadow>
95
90
  <boxGeometry args={[1, 1, 1]} />
96
91
  {material}
97
92
  </mesh>
@@ -162,20 +157,17 @@ function Cutout({ object }) {
162
157
 
163
158
  /**
164
159
  * A sculpt: the clay parts its recipe describes, rebuilt only when the recipe
165
- * or the look changes. Each part keeps its own clay colour; auto-colour paints
166
- * the whole sculpt flat like any other prop, because it is one object.
160
+ * or the look changes. The shaded look is buildSculptGroup's own clay (part
161
+ * colours, gradients, scatter tints); auto-colour paints the whole sculpt flat
162
+ * like any other prop, because it is one object. Depth rank rides on either.
167
163
  */
168
164
  function Sculpt({ object, depthRank = 0 }) {
169
165
  const { recipe, autoColor } = object;
170
- const group = useMemo(
171
- () => buildSculptGroup(recipe, {
172
- materialFor: (part) => new THREE.MeshStandardMaterial({
173
- ...(autoColor ? autoFlat(autoColor) : { color: part.color, roughness: 0.82, metalness: 0 }),
174
- ...depthRankProps(depthRank),
175
- }),
176
- }),
177
- [recipe, autoColor, depthRank],
178
- );
166
+ const group = useMemo(() => {
167
+ const built = buildSculptGroup(recipe, autoColor ? { materialFor: () => new THREE.MeshStandardMaterial(autoFlat(autoColor)) } : {});
168
+ built.traverse((node) => node.material && Object.assign(node.material, depthRankProps(depthRank)));
169
+ return built;
170
+ }, [recipe, autoColor, depthRank]);
179
171
  useEffect(() => () => disposeSculptGroup(group), [group]);
180
172
  return <primitive object={group} />;
181
173
  }
@@ -242,7 +234,7 @@ function SceneObjectContent({ object, depthRank = 0 }) {
242
234
  if (renderer === MESH_KIND) return <ImportedMesh object={object} />;
243
235
  if (renderer === SCULPT_KIND) return <Sculpt object={object} depthRank={depthRank} />;
244
236
  if (renderer === TEXT_KIND) return <TextObject object={object} />;
245
- if (PRIMITIVE_KINDS.has(renderer)) return <Primitive kind={renderer} color={color} autoColor={autoColor} depthRank={depthRank} />;
237
+ if (PRIMITIVE_KINDS.has(renderer)) return <Primitive kind={renderer} object={object} color={color} autoColor={autoColor} depthRank={depthRank} />;
246
238
  return null;
247
239
  }
248
240
 
@@ -47,10 +47,16 @@ export function passFileName(kind) {
47
47
  // it spreads the value over four channels and reads as colour noise). This
48
48
  // shader is the plain depth-to-grey the deliverable wants, and the range it
49
49
  // normalises over is the stage's, not the projection's.
50
+ // An InstancedMesh (a sculpt's scatter) draws every instance through its
51
+ // instanceMatrix; without it all of them would land on the mesh's origin.
50
52
  const DEPTH_VERTEX = /* glsl */ `
51
53
  varying float vViewDepth;
52
54
  void main() {
53
- vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
55
+ vec4 local = vec4(position, 1.0);
56
+ #ifdef USE_INSTANCING
57
+ local = instanceMatrix * local;
58
+ #endif
59
+ vec4 viewPosition = modelViewMatrix * local;
54
60
  vViewDepth = -viewPosition.z;
55
61
  gl_Position = projectionMatrix * viewPosition;
56
62
  }
@@ -73,7 +73,7 @@ export default function ResultModal({ result, copied, recordedVideoName, onClose
73
73
  </button>
74
74
  {result.frame && (
75
75
  <button type="button" className="btn" onClick={onDownload} disabled={downloadDisabled}>
76
- {result.downloaded ? ko("Download requested", "다운로드 요청됨") : result.frameB ? ko("Download start and end frames", "시작·끝 프레임 다운로드") : ko("Download frame", "프레임 다운로드")}
76
+ {result.downloaded ? ko("Exported", "내보냄") : result.frameB ? ko("Export start and end frames", "시작·끝 프레임 내보내기") : ko("Export frame", "프레임 내보내기")}
77
77
  </button>
78
78
  )}
79
79
  </div>
@@ -89,8 +89,8 @@ export default function ResultModal({ result, copied, recordedVideoName, onClose
89
89
  <span>{copied ? ko("Copied. Paste it into your AI service's prompt box.", "복사됐어요. AI 서비스의 입력창에 붙여 넣으세요.") : ko("Press “Copy prompt” above, then paste it into your AI service.", "위의 ‘프롬프트 복사’를 누른 뒤 AI 서비스 입력창에 붙여 넣으세요.")}</span>
90
90
  </li>
91
91
  <li>
92
- <strong>{ko("Download the frame", "프레임 다운로드")}</strong>
93
- <span>{result.frameB ? ko("Press “Download start and end frames” to save both PNGs.", "‘시작·끝 프레임 다운로드’를 눌러 두 PNG를 저장하세요.") : hasFrame ? ko("Press “Download frame” to save the PNG.", "‘프레임 다운로드’를 눌러 PNG를 저장하세요.") : ko("This result has no frame to download.", "이 결과에는 내려받을 프레임이 없어요.")}</span>
92
+ <strong>{ko("Export the frame", "프레임 내보내기")}</strong>
93
+ <span>{result.frameB ? ko("Press “Export start and end frames” to save both PNGs.", "‘시작·끝 프레임 내보내기’를 눌러 두 PNG를 저장하세요.") : hasFrame ? ko("Press “Export frame” to save the PNG.", "‘프레임 내보내기’를 눌러 PNG를 저장하세요.") : ko("This result has no frame to export.", "이 결과에는 내보낼 프레임이 없어요.")}</span>
94
94
  </li>
95
95
  <li>
96
96
  <strong>{ko("Attach the image to your AI", "AI에 이미지 첨부")}</strong>
@@ -28,7 +28,7 @@ import { retireObjectKind } from "./retired-kinds.js";
28
28
  import { objectSize } from "./object-size.js";
29
29
  import { EFFECT_ENTRY, effectFields } from "./effects/effect-record.js";
30
30
  import { TEXT_ENTRY, TEXT_KIND, textFields, textPatch } from "./text/text-record.js";
31
- import { LIGHT_KIND, LIGHT_MENU_ENTRIES, lightLibraryEntry, objectLookFields, objectLookPatch, objectGraspFields, objectGraspPatch } from "./object-look.js";
31
+ import { LIGHT_KIND, LIGHT_MENU_ENTRIES, lightLibraryEntry, objectLookFields, objectLookPatch, objectGraspFields, objectGraspPatch, objectMaterialKeys } from "./object-look.js";
32
32
  export { OBJECT_COLORS, normalizeObjectColor, rememberObjectColor, readStoredObjectColors, writeStoredObjectColors } from "./object-colors.js";
33
33
  export { isEffectivelyHidden };
34
34
 
@@ -547,7 +547,7 @@ const TRANSFORM_LIMITS = {
547
547
  /** The patch keys updateSceneObject reads for this object; any other key is
548
548
  * silently ignored there, so callers that must not be silent check against this. */
549
549
  export function objectPatchFields(object) {
550
- const fields = [...Object.keys(TRANSFORM_LIMITS), "name", "color", "hidden", "opacity", "path", "glow", "grasp"];
550
+ const fields = [...Object.keys(TRANSFORM_LIMITS), "name", "color", "hidden", "opacity", "path", "glow", "grasp", ...objectMaterialKeys(object?.renderer)];
551
551
  if (object?.renderer === LIGHT_KIND) fields.push("light");
552
552
  if (object?.renderer === CUTOUT_KIND) fields.push("assetId", "sourceAssetId", "matteAssetId", "matteScale", "height", "aspect", "width", "stretch");
553
553
  if (object?.renderer === MESH_KIND) fields.push("clay", "assetId", "height");
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Sculpt colours for the shaded look, applied once per part by
3
+ * buildSculptGroup after the part's material exists:
4
+ *
5
+ * - a `gradient` (sculpt-surface-fields.js) becomes a per-vertex `color`
6
+ * attribute blending the part's colour into the gradient's;
7
+ * - a scatter's items each get a small seeded hue and brightness jitter (and
8
+ * the gradient, by where the item sits), so a field is not one flat swatch.
9
+ *
10
+ * The colour then lives in the vertex or instance colours, so the material's
11
+ * own colour goes white and `vertexColors` turns on (a texture map still
12
+ * multiplies in). A flat display mode (auto-colour, through materialFor)
13
+ * skips this, so it ignores gradients and jitter.
14
+ */
15
+
16
+ import * as THREE from "three";
17
+ import { isMirrorCopy, partSeed, seededRandom } from "./sculpt-noise.js";
18
+
19
+ const AXES = { x: 0, y: 1, z: 2 };
20
+ /** Item-to-item spread: hue ± this (of the wheel), brightness × 1 ± this. */
21
+ const JITTER_HUE = 0.025;
22
+ const JITTER_SHADE = 0.16;
23
+
24
+ /** How far a local point is along the gradient: 0 at `from` (part colour), 1 at `to`. */
25
+ function blend(part, x, y, z) {
26
+ const { axis, from, to } = part.gradient;
27
+ const i = AXES[axis];
28
+ // A mirrored copy reads its x reversed, so an x gradient reflects with the part.
29
+ const along = [isMirrorCopy(part) ? -x : x, y, z][i] / part.size[i] + 0.5;
30
+ if (to === from) return along >= to ? 1 : 0;
31
+ return THREE.MathUtils.clamp((along - from) / (to - from), 0, 1);
32
+ }
33
+
34
+ export function applySculptColours(mesh, part) {
35
+ if (!part.gradient && !mesh.isInstancedMesh) return;
36
+ const base = new THREE.Color(part.color);
37
+ const end = part.gradient ? new THREE.Color(part.gradient.color) : null;
38
+ const colour = new THREE.Color();
39
+ if (mesh.isInstancedMesh) {
40
+ const random = seededRandom(partSeed(part, part.seed + 1));
41
+ const matrix = new THREE.Matrix4(), at = new THREE.Vector3();
42
+ for (let i = 0; i < mesh.count; i += 1) {
43
+ colour.copy(base);
44
+ if (end) {
45
+ at.setFromMatrixPosition(mesh.getMatrixAt(i, matrix));
46
+ colour.lerp(end, blend(part, at.x, at.y, at.z));
47
+ }
48
+ colour.offsetHSL((random() * 2 - 1) * JITTER_HUE, 0, 0).multiplyScalar(1 + (random() * 2 - 1) * JITTER_SHADE);
49
+ mesh.setColorAt(i, colour);
50
+ }
51
+ mesh.instanceColor.needsUpdate = true;
52
+ } else {
53
+ const position = mesh.geometry.getAttribute("position");
54
+ const colours = new Float32Array(position.count * 3);
55
+ for (let i = 0; i < position.count; i += 1) {
56
+ colour.copy(base).lerp(end, blend(part, position.getX(i), position.getY(i), position.getZ(i))).toArray(colours, 3 * i);
57
+ }
58
+ mesh.geometry.setAttribute("color", new THREE.BufferAttribute(colours, 3));
59
+ }
60
+ for (const material of [mesh.material].flat()) {
61
+ material.color?.set(0xffffff);
62
+ material.vertexColors = !!mesh.geometry.getAttribute("color");
63
+ material.needsUpdate = true;
64
+ }
65
+ }