cozyclay 3.0.3 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/INSTALL_FOR_AGENTS.md +146 -0
  3. package/README.md +74 -192
  4. package/THIRD_PARTY_NOTICES.md +0 -21
  5. package/bin/agent/agent-routes.mjs +29 -62
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/agent/main-agent.mjs +70 -0
  8. package/bin/agent/main-prompt.mjs +17 -0
  9. package/bin/agent/plan-tool.mjs +114 -0
  10. package/bin/agent/script-tool.mjs +2 -0
  11. package/bin/agent/sculpt-sketch.mjs +190 -0
  12. package/bin/agent/session-store.mjs +4 -3
  13. package/bin/agent/sketch-code.mjs +151 -0
  14. package/bin/agent/sketch-sandbox.mjs +102 -0
  15. package/bin/agent/studio-prompt.mjs +1 -1
  16. package/bin/agent/studio-task-routes.mjs +185 -0
  17. package/bin/agent/studio-tasks.mjs +156 -0
  18. package/bin/agent/studio-tools.mjs +3 -2
  19. package/bin/agent/studio-turn-transaction.mjs +76 -0
  20. package/bin/agent/turn-frames.mjs +4 -0
  21. package/bin/cozyclay.mjs +5 -7
  22. package/dist/app/index.html +7 -6
  23. package/dist/assets/TaskBoard-ngdqiu7u.js +1 -0
  24. package/dist/assets/app-BlwOpest.css +1 -0
  25. package/dist/assets/app-DmR7jBkz.js +5144 -0
  26. package/dist/assets/compile-worker-D0pKioa0.js +5 -0
  27. package/dist/assets/landing-y3EqDgSZ.js +1 -0
  28. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  29. package/dist/assets/motion3-compile-BB3_1-EB.js +1 -0
  30. package/dist/assets/scene-asset-sync-CBx7XRti.css +1 -0
  31. package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +13 -0
  32. package/dist/assets/task-board-ErQvfoaz.js +14 -0
  33. package/dist/assets/workflow-zQM-EKW1.js +179 -0
  34. package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
  35. package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
  36. package/dist/cozyclay-package.json +1 -1
  37. package/dist/greybox-to-video/index.html +2 -3
  38. package/dist/index.html +7 -57
  39. package/dist/llms.txt +2 -1
  40. package/dist/previs-software/index.html +2 -2
  41. package/dist/privacy/index.html +3 -7
  42. package/dist/seedance-camera-control/index.html +2 -2
  43. package/dist/sitemap.xml +5 -5
  44. package/dist/sw.js +5 -1
  45. package/dist/thumbs/blank-stage-dark.webp +0 -0
  46. package/dist/thumbs/blank-stage-light.webp +0 -0
  47. package/dist/workflow/index.html +5 -4
  48. package/mcp/mesh-file.mjs +3 -1
  49. package/mcp/server.mjs +3 -0
  50. package/mcp/tool-handlers.mjs +2 -2
  51. package/package.json +5 -3
  52. package/src/App.jsx +51 -282
  53. package/src/analytics.js +3 -148
  54. package/src/app-stage.jsx +1 -1
  55. package/src/asset-pane.jsx +5 -10
  56. package/src/auto-flat.js +10 -0
  57. package/src/bus-tasks.js +11 -5
  58. package/src/command-bus.js +1 -1
  59. package/src/commands/elements.js +4 -3
  60. package/src/commands/motion3-compile.js +51 -27
  61. package/src/commands/motion3.js +3 -3
  62. package/src/commands/objects.js +1 -1
  63. package/src/commands/project.js +1 -1
  64. package/src/commands/shot.js +16 -4
  65. package/src/controls.jsx +0 -11
  66. package/src/cuts.js +11 -0
  67. package/src/domains/cast.js +3 -2
  68. package/src/domains/motion.js +3 -10
  69. package/src/domains/playback.js +32 -0
  70. package/src/domains/scenes.js +3 -22
  71. package/src/domains/shots.js +5 -5
  72. package/src/dualview.jsx +11 -15
  73. package/src/hierarchy-panel.css +18 -4
  74. package/src/hierarchy-panel.jsx +12 -11
  75. package/src/kits/held.js +3 -2
  76. package/src/light-blockers.js +2 -2
  77. package/src/main.jsx +5 -0
  78. package/src/material-look.js +189 -0
  79. package/src/material-presets.js +78 -0
  80. package/src/material-textures.js +157 -0
  81. package/src/motion/ground.js +5 -14
  82. package/src/motion/playback-frame-store.js +79 -0
  83. package/src/motion/playback-probe.js +16 -0
  84. package/src/motion/playback-subject.js +7 -0
  85. package/src/motion/timeline.css +43 -5
  86. package/src/motion/timeline.jsx +4 -3
  87. package/src/motion3/body.js +37 -5
  88. package/src/motion3/carried.js +11 -3
  89. package/src/motion3/compile-job.js +1 -1
  90. package/src/motion3/compiler/compile.js +7 -1
  91. package/src/motion3/compiler/finger-close.js +137 -0
  92. package/src/motion3/compiler/limbs.js +18 -3
  93. package/src/motion3/compiler/pose.js +6 -0
  94. package/src/motion3/compiler/validate-objects.js +17 -2
  95. package/src/motion3/compiler/validate-parts.js +2 -1
  96. package/src/motion3/compiler/vocab.js +4 -4
  97. package/src/motion3/describe.js +2 -2
  98. package/src/motion3/fix/index.js +5 -3
  99. package/src/motion3/flight.js +38 -0
  100. package/src/motion3/free-motion.js +10 -7
  101. package/src/motion3/load.js +125 -0
  102. package/src/motion3/mass.js +23 -0
  103. package/src/motion3/object-keys.js +11 -6
  104. package/src/motion3/object-pose.js +11 -3
  105. package/src/motion3/pipeline.js +3 -0
  106. package/src/motion3/qa/explain.js +3 -1
  107. package/src/motion3/qa/grasp-checks.js +123 -0
  108. package/src/motion3/qa/hints-grasp.js +71 -0
  109. package/src/motion3/qa/index.js +3 -1
  110. package/src/motion3/qa/object-checks.js +7 -2
  111. package/src/motion3/qa/patch.js +2 -1
  112. package/src/motion3/qa/spec-checks.js +10 -1
  113. package/src/motion3/schema/fields.js +3 -3
  114. package/src/motion3/schema/topics.js +9 -8
  115. package/src/motion3/schema/types.js +4 -1
  116. package/src/motion3/seat.js +84 -0
  117. package/src/motion3/settle.js +11 -6
  118. package/src/motion3/shapes.js +70 -0
  119. package/src/motion3/skills/catch.js +71 -0
  120. package/src/motion3/skills/index.js +10 -3
  121. package/src/motion3/skills/info.js +13 -0
  122. package/src/motion3/skills/throw.js +72 -0
  123. package/src/object-catalog.jsx +0 -4
  124. package/src/object-gizmo.jsx +2 -1
  125. package/src/object-look.js +9 -5
  126. package/src/object-path.js +1 -1
  127. package/src/panels/ProjectPanel.jsx +42 -21
  128. package/src/panels/details.css +15 -4
  129. package/src/popover.js +37 -0
  130. package/src/posestudio.jsx +15 -10
  131. package/src/primitive-look.js +38 -0
  132. package/src/project-browser-art.jsx +19 -0
  133. package/src/project-browser.css +74 -121
  134. package/src/project-browser.jsx +36 -60
  135. package/src/props.jsx +24 -35
  136. package/src/render-passes.js +7 -1
  137. package/src/retired-kinds.js +37 -0
  138. package/src/scene-objects.js +5 -9
  139. package/src/scenes.js +4 -5
  140. package/src/sculpt-colours.js +65 -0
  141. package/src/sculpt-compare.js +13 -50
  142. package/src/sculpt-csg.js +112 -0
  143. package/src/sculpt-curve.js +123 -0
  144. package/src/sculpt-cutters.js +43 -0
  145. package/src/sculpt-detail.js +207 -0
  146. package/src/sculpt-geometry-loft-convex.js +100 -0
  147. package/src/sculpt-geometry-shapes.js +250 -0
  148. package/src/sculpt-geometry.js +33 -15
  149. package/src/sculpt-loft-rings.js +225 -0
  150. package/src/sculpt-noise.js +65 -0
  151. package/src/sculpt-part-fields.js +27 -0
  152. package/src/sculpt-recipe-read.js +65 -0
  153. package/src/sculpt-recipe.js +33 -75
  154. package/src/sculpt-run.js +1 -1
  155. package/src/sculpt-scatter-items.js +183 -0
  156. package/src/sculpt-scatter-shape.js +57 -0
  157. package/src/sculpt-scatter.js +124 -0
  158. package/src/sculpt-shapes-curved.js +211 -0
  159. package/src/sculpt-shapes-loft-convex.js +218 -0
  160. package/src/sculpt-shapes.js +138 -0
  161. package/src/sculpt-sketch-state.js +76 -0
  162. package/src/sculpt-sketch.jsx +124 -0
  163. package/src/sculpt-surface-fields.js +65 -0
  164. package/src/settings-menu.jsx +1 -20
  165. package/src/shell/BottomDock.jsx +9 -9
  166. package/src/shell/MenuBar.jsx +1 -9
  167. package/src/shell/PreferencesDialog.jsx +3 -4
  168. package/src/shell/ViewportToolbar.jsx +3 -3
  169. package/src/shell/add-camera-shot.js +11 -0
  170. package/src/shell/glass-regions.css +3 -2
  171. package/src/shell/glass.css +0 -5
  172. package/src/shell/lens-scrub-math.js +51 -0
  173. package/src/shell/lens-scrub.css +73 -0
  174. package/src/shell/lens-scrub.jsx +98 -0
  175. package/src/shell/mode.css +6 -5
  176. package/src/shell/preferences.css +1 -32
  177. package/src/shell/shell.css +18 -1
  178. package/src/shell/studio-shell-context.js +5 -0
  179. package/src/shot-frame.js +26 -0
  180. package/src/stage-canvas-rigs.jsx +7 -8
  181. package/src/stage-characters.jsx +26 -0
  182. package/src/stage-gizmo.js +43 -0
  183. package/src/stage-labels-ko.js +0 -6
  184. package/src/stage-startup-defaults.js +3 -9
  185. package/src/starter-scenes.js +5 -11
  186. package/src/studio-agent-commands.js +2 -2
  187. package/src/studio-agent-protocol.js +2 -7
  188. package/src/studio-app-binding.js +1 -1
  189. package/src/studio-elements.js +6 -2
  190. package/src/styles/themes.css +13 -0
  191. package/src/styles.css +6 -674
  192. package/src/text/text-object.jsx +1 -1
  193. package/src/ui.jsx +8 -16
  194. package/src/use-embed-bridge.js +3 -6
  195. package/src/use-render-activity.js +3 -1
  196. package/src/view-ghosts.jsx +5 -3
  197. package/src/viewport-visible-rect.js +37 -0
  198. package/src/workflow/AgentPanel.jsx +79 -46
  199. package/src/workflow/AgentPlanCard.jsx +41 -0
  200. package/src/workflow/AgentProgressCard.jsx +1 -1
  201. package/src/workflow/TaskBoard.jsx +102 -0
  202. package/src/workflow/TaskInline.jsx +114 -0
  203. package/src/workflow/agent-client.js +25 -36
  204. package/src/workflow/agent-panel.css +14 -1
  205. package/src/workflow/agent-plan.css +93 -0
  206. package/src/workflow/agent-plan.js +59 -0
  207. package/src/workflow/attachment-image.js +10 -3
  208. package/src/workflow/attachment-video.js +91 -0
  209. package/src/workflow/task-board.css +98 -0
  210. package/src/workflow/task-board.js +169 -0
  211. package/tools/check-eslint.mjs +2 -1
  212. package/tools/check-knip.mjs +2 -1
  213. package/tools/dev/pages/greybox-to-video.html +2 -3
  214. package/tools/dev/pages/previs-software.html +2 -2
  215. package/tools/dev/pages/privacy.html +3 -7
  216. package/tools/dev/pages/seedance-camera-control.html +2 -2
  217. package/tools/dev-full.mjs +11 -2
  218. package/tools/eslint-baseline.json +6 -6
  219. package/tools/knip-baseline.json +0 -11
  220. package/tools/loc-baseline.json +30 -31
  221. package/tools/metrics-snapshot.mjs +43 -5
  222. package/tools/render-start-thumbs.mjs +175 -0
  223. package/tools/run-tests.mjs +10 -17
  224. package/tools/sitemap.mjs +1 -1
  225. package/dist/assets/analytics-BibyE5ar.js +0 -6
  226. package/dist/assets/app-DhRJ-PBz.js +0 -5088
  227. package/dist/assets/app-f-fkV5uP.css +0 -1
  228. package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
  229. package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
  230. package/dist/assets/landing-DYg87MDY.js +0 -1
  231. package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
  232. package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
  233. package/dist/assets/workflow-BSA22Ofr.js +0 -179
  234. package/dist/scenes/city-block.cclayproject +0 -1
  235. package/src/burn-in.js +0 -91
  236. package/src/camera-tutorial.jsx +0 -520
  237. package/src/facing-marks.js +0 -88
  238. package/src/first-shot-handoff.js +0 -44
  239. package/src/panels/motion.css +0 -197
  240. package/src/set-pieces.jsx +0 -183
  241. package/src/tutorial-analytics.js +0 -86
  242. package/src/tutorial-walk.js +0 -17
  243. package/src/use-camera-tutorial.js +0 -50
  244. package/src/use-semantic-state.js +0 -12
  245. package/tools/metrics/archive-traffic.mjs +0 -138
  246. package/tools/perf/ik-camera-drag.mjs +0 -138
package/src/cuts.js CHANGED
@@ -127,6 +127,17 @@ export function addShotAtFrame(shots, frame, frameCount, currentFraming, duratio
127
127
  return [...list, createShot(`Shot ${list.length + 1}`, target, endFrame, keys)].sort((a, b) => a.startFrame - b.startFrame);
128
128
  }
129
129
 
130
+ /** Add Shot when the take has no free time left: the take grows by one default
131
+ * shot at its end and the new shot fills that, so the button never silently does
132
+ * nothing. Existing shots are untouched. Null past maxFrameCount. */
133
+ export function appendShotGrowingTake(shots, frameCount, currentFraming, maxFrameCount) {
134
+ const list = Array.isArray(shots) ? shots : [];
135
+ const startFrame = Math.max(frameNumber(frameCount, 1), ...list.map((shot) => shot.endFrame + 1));
136
+ const grownCount = startFrame + DEFAULT_SHOT_FRAMES;
137
+ if (grownCount > maxFrameCount) return null;
138
+ return { shots: addShotAtFrame(list, startFrame, grownCount, currentFraming), frameCount: grownCount };
139
+ }
140
+
130
141
  export function resizeShot(shots, shotId, edge, rawFrame, frameCount) {
131
142
  const shot = shotById(shots, shotId);
132
143
  if (edge !== "start" && edge !== "end") return shots;
@@ -546,7 +546,7 @@ export function useCast(appContext) {
546
546
  return current.length;
547
547
  }
548
548
 
549
- /** ARDY-demo style authoring: each empty-floor press in the Shot view drops
549
+ /** Click-to-place authoring: each empty-floor press in the Shot view drops
550
550
  the next waypoint where it was clicked; the frame gap comes from walking
551
551
  distance. The bird's-eye board selects and drags existing waypoints. */
552
552
  function addFloorWaypoint(point) {
@@ -715,7 +715,8 @@ export function useCast(appContext) {
715
715
  }
716
716
  function undoScene() {
717
717
  const receipt = appContext.bus.run('edit.undo');
718
- if (receipt.status === 'noop') appContext.notify(ko("Nothing to undo", "실행 취소할 작업이 없어요"));
718
+ // A refusal says why: the last edit belongs to a running task (parallel tasks), which its own card undoes.
719
+ if (receipt.status === 'noop' || receipt.ok === false) appContext.notify(receipt.ok === false ? ko(receipt.message ?? "Undo was refused", `실행 취소 불가: ${receipt.message ?? ""}`) : ko("Nothing to undo", "실행 취소할 작업이 없어요"));
719
720
  return receipt;
720
721
  }
721
722
  function redoScene() {
@@ -29,8 +29,8 @@ import {
29
29
  } from "../motion/ik.js";
30
30
  import * as THREE from "three";
31
31
  import { StudioProtocolError } from "../studio-agent-protocol.js";
32
-
33
32
  import { applyTakeFrame } from "../motion3/playback.js";
33
+ import { advancePlayback, installPlaybackApplier } from "./playback.js";
34
34
  import { isKo, ko } from "../locale.js";
35
35
  import { isProxyFigure } from "../scenes.js";
36
36
  import { refuseRigOnly } from "../character-kind.js";
@@ -625,15 +625,7 @@ export function useMotion(appContext) {
625
625
 
626
626
 
627
627
  function advanceFrame(steps = 1) {
628
- const count = Math.max(1, Math.floor(steps));
629
- const previewEnd = appContext.shared.cameraPreviewEndRef.current;
630
- if (previewEnd != null && appContext.shared.tlFrameRef.current + count >= previewEnd) {
631
- appContext.shared.cameraPreviewEndRef.current = null;
632
- appContext.shared.setTlFrame(previewEnd);
633
- appContext.shared.setTlPlaying(false);
634
- return;
635
- }
636
- appContext.shared.setTlFrame((f) => (f + count) % appContext.shared.frameCountRef.current);
628
+ advancePlayback(appContext.shared, steps);
637
629
  }
638
630
 
639
631
  function stepFrame(delta) {
@@ -677,6 +669,7 @@ export function useMotion(appContext) {
677
669
  appContext.shared.poseMemberAtFrame(appContext.shared.rigs[entry.id], entry.sessionMotion, appContext.shared.ikStatesRef.current.get(entry.id), frame, IK_CORRECTION_BLEND_FRAMES);
678
670
  }
679
671
  };
672
+ installPlaybackApplier(appContext, motion, poseOtherCastMembers, ikEvaluate, IK_CORRECTION_BLEND_FRAMES);
680
673
 
681
674
  function toggleIkMode() {
682
675
  const next = !ikMode;
@@ -0,0 +1,32 @@
1
+ import { advancePlaybackFrame, registerPlaybackFrameApplier } from "../motion/playback-frame-store.js";
2
+
3
+ function createPlaybackApplier(appContext, motion, poseOtherCastMembers, ikEvaluate, blendFrames) {
4
+ return (frame) => {
5
+ appContext.shared.tlFrameRef.current = frame;
6
+ appContext.shared.propFrameRef.current = frame;
7
+ appContext.patchTimeline({ currentFrame: frame });
8
+ if (appContext.shared.activeRig) appContext.shared.poseMemberAtFrame(appContext.shared.activeRig, motion, null, frame);
9
+ poseOtherCastMembers(frame);
10
+ const state = appContext.shared.ikStateRef.current;
11
+ if (!appContext.shared.posing && state?.chains && (appContext.shared.ikMode || state.keys.size > 0)) {
12
+ ikEvaluate(state.chains, state, frame, state.fkJoints, motion ? blendFrames : 0);
13
+ }
14
+ };
15
+ }
16
+
17
+ export function installPlaybackApplier(...args) {
18
+ return registerPlaybackFrameApplier(createPlaybackApplier(...args));
19
+ }
20
+
21
+ // A camera-shot preview stops at its shot's end; otherwise the playhead wraps.
22
+ export function advancePlayback(shared, steps) {
23
+ const count = Math.max(1, Math.floor(steps));
24
+ const previewEnd = shared.cameraPreviewEndRef.current;
25
+ if (previewEnd != null && shared.tlFrameRef.current + count >= previewEnd) {
26
+ shared.cameraPreviewEndRef.current = null;
27
+ shared.setTlFrame(previewEnd);
28
+ shared.setTlPlaying(false);
29
+ return;
30
+ }
31
+ advancePlaybackFrame(shared.frameCountRef.current, count);
32
+ }
@@ -208,9 +208,8 @@ export function useScenes(appContext) {
208
208
  // A first-run author should choose a document (or explicitly start a named
209
209
  // local draft). Keep this as a light startup sheet so the studio remains
210
210
  // inspectable while the choice is pending; it never traps the topbar.
211
- // ?tutorial=camera opens the starter scene itself (#209), so the chooser is
212
- // suppressed the same way a ?scene= launch suppresses it.
213
- const [projectStartupOpen, setProjectStartupOpen] = useState(() => !appContext.shared.playgroundMode && !appContext.shared.cameraTutorialQuery && !playgroundSceneUrl(globalThis.location?.search) && !loadProjectSession()?.name);
211
+ // A ?scene= launch opens its starter instead of the chooser.
212
+ const [projectStartupOpen, setProjectStartupOpen] = useState(() => !appContext.shared.playgroundMode && !playgroundSceneUrl(globalThis.location?.search) && !loadProjectSession()?.name);
214
213
 
215
214
  const [projectManifest, setProjectManifest] = useState({ items: [], totals: { embedded: 0, external: 0, missing: 0, bytes: 0 }, missing: [] });
216
215
 
@@ -400,10 +399,6 @@ export function useScenes(appContext) {
400
399
  if (!["valid", "migrated"].includes(checked.status)) throw new StudioProtocolError("INVALID_ARGUMENT", "Invalid project scene document.");
401
400
  project = { ...project, scenesDocument: checked.document };
402
401
  appContext.shared.studioDocumentEpochRef.current = crypto.randomUUID();
403
- appContext.shared.tutorialProjectEpochRef.current += 1;
404
- appContext.shared.tutorialSeedEpochRef.current = null;
405
- appContext.shared.setTutorialSeedPending(false);
406
- appContext.shared.setCameraTutorialHandoff(null);
407
402
  appContext.shared.exportShotIdRef.current = null;
408
403
  const source = project.scenesDocument;
409
404
  // A project FILE carries its own scene document and never passes the
@@ -425,25 +420,16 @@ export function useScenes(appContext) {
425
420
  domain.setDirty(false);
426
421
  storeProjectSession(project.name, projectPrevisMode);
427
422
  setProjectStartupOpen(false);
428
- // Whatever document this is, it is no longer the scene the tutorial opened
429
- // for itself; startCameraTutorial re-arms the flag after its own open.
430
- appContext.shared.tutorialStarterRef.current = false;
431
423
  setProjectManifest(resourceManifest({ scenesDocument: doc, workflow: resolvedWorkflow, poseLibrary: mergedCustomPoses, assets: project.assets ?? [], workflowOutputRefs }));
432
424
  track("project:opened", { age_bucket: bucketProjectAge(Date.now() - (project.savedAt ?? Date.now())) });
433
425
  }
434
426
 
435
427
  /** Open a bundled starter scene as a fresh, saveable project. Used by the
436
- * first-run dialog and by `npx cozyclay --scene <id>` (`?scene=`), which is
437
- * how the landing-page tutorial hands people into the local studio. */
428
+ * first-run dialog and by `npx cozyclay --scene <id>` (`?scene=`). */
438
429
  async function openStarterScene(id, source = "starter", context = null, name = null) {
439
430
  if (source === "starter" && domain.dirtyStore.read("projectDirty") && !window.confirm(ko("Discard unsaved changes and start a new project?", "저장되지 않은 변경사항을 버리고 새 프로젝트를 시작할까요?"))) return false;
440
- const before = source === "tutorial" ? collectProjectSnapshot("Tutorial") : null;
441
- const epoch = appContext.shared.tutorialProjectEpochRef.current;
442
431
  const url = playgroundSceneUrl(`?scene=${encodeURIComponent(id)}`);
443
432
  const project = url ? await fetchSceneProject(url) : null;
444
- // A pending tutorial fetch has no authority over work authored/opened
445
- // while it was loading, including an unnamed project.
446
- if (source === "tutorial" && (epoch !== appContext.shared.tutorialProjectEpochRef.current || before !== collectProjectSnapshot("Tutorial"))) return false;
447
433
  if (!project) {
448
434
  appContext.notify(ko("That starter scene is not in this build", "이 빌드에는 그 시작 장면이 없어요"));
449
435
  return false;
@@ -596,11 +582,6 @@ export function useScenes(appContext) {
596
582
  }
597
583
 
598
584
  function openScene(scene, nextScenes) {
599
- appContext.shared.tutorialProjectEpochRef.current += 1;
600
- appContext.shared.tutorialSeedEpochRef.current = null;
601
- appContext.shared.setTutorialSeedPending(false);
602
- appContext.shared.setCameraTutorial(false);
603
- appContext.shared.setCameraTutorialHandoff(null);
604
585
  appContext.shared.exportShotIdRef.current = null;
605
586
  appContext.shared.studioSceneEpochRef.current = crypto.randomUUID();
606
587
  const shotState = restoredShotState(scene);
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
2
  import { aimAt } from '../controls.jsx';
3
3
  import { PRESETS, DEFAULT_DURATION_S, TIMELINE_FPS } from '../app-stage.jsx';
4
+ import { usePlaybackFrameState } from '../motion/playback-frame-store.js';
4
5
  import { CAMERA_MOVES, SUBJECT_HEIGHT_M, focalMmToFov, fovToFocalMm } from '../shot.js';
5
6
  import { CAMERA_PRESETS, cameraPresetFraming } from '../camera-move.js';
6
7
  import { createDocumentStore } from '../document-store.js';
@@ -236,9 +237,8 @@ export function useShots(appContext) {
236
237
  const [moveFollow] = useState(true);
237
238
  const [railDraw, setRailDraw] = useState(false);
238
239
  const [craneSelectedIndex, setCraneSelectedIndex] = useState(null);
239
- const [tlFrame, setTlFrame] = useState(0);
240
- const [tlFps, setTlFps] = useState(TIMELINE_FPS);
241
- const activeShotIdx = shotIndexAtFrame(shots, tlFrame), activeShot = shots[activeShotIdx] ?? null;
240
+ const [tlFrame, setTlFrameState] = useState(0), [tlFps, setTlFps] = useState(TIMELINE_FPS);
241
+ const { setTlFrame, commitPlaybackFrame, activeShotIdx, activeShot } = usePlaybackFrameState({ frameCount: tlFrameCount, setReactFrame: setTlFrameState, shots });
242
242
  const cameraKeys = activeShot?.cameraKeys ?? [], activeCamera = createCameraBlock(activeShot?.camera);
243
243
  const craneActive = !!activeCamera.craneHeight, cameraRail = activeCamera.cameraRail;
244
244
  const activeShotDuration = activeShot ? activeShot.endFrame - activeShot.startFrame + 1 : 0;
@@ -364,7 +364,7 @@ export function useShots(appContext) {
364
364
  const travel = Math.ceil(curve.length / speed * tlFps) + Math.round(tlFps * 0.5);
365
365
  const endFrame = Math.min(tlFrameCount - 1, activeShot.startFrame + Math.max(travel, activeShot.endFrame - activeShot.startFrame));
366
366
  setTimelineShotRange(activeShot.id, activeShot.startFrame, endFrame);
367
- // The landing tutorial's last step is pressing ▶ (#602): open the player
367
+ // In the landing playground a drawn rail opens the player
368
368
  // on the shot camera, but leave playback to the visitor.
369
369
  appContext.shared.enterPreview(); appContext.shared.setTlPlaying(false); setTlFrame(activeShot.startFrame);
370
370
  appContext.notify(isKo ? '레일 완성 — 샷 카메라 시점으로 전환했습니다. ▶ 로 재생, Esc 로 복귀' : 'Rail drawn — you are looking through the shot camera. Press ▶ to ride it; Esc goes back to flying.'); return;
@@ -418,7 +418,7 @@ export function useShots(appContext) {
418
418
  }
419
419
  return { ...domain, shots, setShots, editShots, fovDeg, setFovDeg, cameraMove, customMove, startupShotState,
420
420
  movePlaying, setMovePlaying, moveFollow, railDraw, setRailDraw, craneSelectedIndex, setCraneSelectedIndex,
421
- tlFrame, setTlFrame, tlFrameCount, setTlFrameCount, tlFps, setTlFps, activeShotIdx, activeShot, cameraKeys,
421
+ tlFrame, setTlFrame, commitPlaybackFrame, tlFrameCount, setTlFrameCount, tlFps, setTlFps, activeShotIdx, activeShot, cameraKeys,
422
422
  activeCamera, craneActive, cameraRail, activeShotDuration, hasCameraKeys, changeActiveCamera, changeShotTargetModel,
423
423
  syncActiveCameraFraming, commitManualCameraFraming, beginCameraFramingGesture, beginTimelineEditGesture,
424
424
  setShotCameraRail, clearShotCameraRail, changeCameraRail, addCameraKeyframe, moveCameraKeyframe, removeCameraKeyframe, clearMove,
package/src/dualview.jsx CHANGED
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useMemo, useRef } from "react";
2
2
  import { useFrame, useThree } from "@react-three/fiber";
3
3
  import * as THREE from "three";
4
+ import { fitAspect } from "./shot-frame.js";
5
+ import { updateVisibleViewOffsets } from "./viewport-visible-rect.js";
4
6
 
5
7
  /**
6
8
  * Two live viewports out of one scene.
@@ -104,17 +106,6 @@ const EDGE_FRAGMENT = `
104
106
  }
105
107
  `;
106
108
 
107
- /** largest centred sub-rect of `rect` with the given aspect */
108
- export function fitAspect(rect, aspect) {
109
- let w = rect.w;
110
- let h = w / aspect;
111
- if (h > rect.h) {
112
- h = rect.h;
113
- w = h * aspect;
114
- }
115
- return { x: rect.x + (rect.w - w) / 2, y: rect.y + (rect.h - h) / 2, w, h };
116
- }
117
-
118
109
  export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCamRef, planCamRef, poserCamRef, editorCamRef, ikMode = false, planIsMain, playMode = false, lookThrough = false, insetCollapsed = false, planZoom = 1, shotAspect = SHOT_ASPECT, editorLook = false }) {
119
110
  const invalidate = useThree((state) => state.invalidate);
120
111
  const lastPoserPose = useRef({ position: new THREE.Vector3(), quaternion: new THREE.Quaternion(), valid: false });
@@ -245,6 +236,7 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
245
236
  return { x: r.left - base.left, y: r.top - base.top, w: r.width, h: r.height };
246
237
  };
247
238
  const mainRect = rectOf(mainRef.current);
239
+ updateVisibleViewOffsets({ editorCam, poserCam, canvas: gl.domElement.getBoundingClientRect(), aspect: mainRect.w / mainRect.h });
248
240
  // A collapsed inset is just its tag pill: skip the whole inset pass
249
241
  // instead of drawing a squished viewport into it.
250
242
  const insetRect = insetCollapsed ? { x: 0, y: 0, w: 0, h: 0 } : rectOf(insetRef.current);
@@ -451,8 +443,6 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
451
443
  // IK mode: the main pane is the poser working view (free navigation,
452
444
  // handle layer visible); the inset is the FROZEN shot camera — the
453
445
  // separate camera-placement screen the framing lives on.
454
- poserCam.aspect = mainRect.w / mainRect.h;
455
- poserCam.updateProjectionMatrix();
456
446
  drawEditorPane(poserCam, mainRect, null, !navigatingCamera);
457
447
  if (!navigatingCamera || redrawFrozenPanes) drawVisibleInset(shotCam, insetRect, fitAspect(insetRect, shotAspect), !navigatingCamera);
458
448
  drawPreviewCard();
@@ -465,8 +455,6 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
465
455
  // navigation that never touches the recording. The shot camera only
466
456
  // appears in its own preview pane, framed to the export aspect and
467
457
  // stripped of editing chrome, exactly like an exported frame.
468
- editorCam.aspect = mainRect.w / mainRect.h;
469
- editorCam.updateProjectionMatrix();
470
458
  // A right-drag is a camera gesture even with IK off. Keep the main
471
459
  // image readable while the camera is moving by deferring the expensive
472
460
  // edge/ink pass until the gesture ends; the plan inset stays frozen.
@@ -479,6 +467,14 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
479
467
  // can fly the recording lens with the same bindings as the free camera.
480
468
  // The QA hook (`window.__cozyclay.setLookThrough`) draws the same path.
481
469
  // Look-through is the recording lens: it keeps the authored stage.
470
+ // The pane is the open stage less the rails (shell.css), so the
471
+ // canvas around it is bars too: clear it, or the last full-canvas
472
+ // draw stays there and reads as picture the export does not have.
473
+ if (lookThrough) {
474
+ gl.setScissorTest(false);
475
+ gl.setClearColor(letterbox, 1);
476
+ gl.clear(true, true, false);
477
+ }
482
478
  draw(shotCam, shotPane, fitAspect(shotPane, shotAspect), !navigatingCamera);
483
479
  if (!navigatingCamera || redrawFrozenPanes) drawVisibleInset(planCam, planPane, null, false);
484
480
  drawPreviewCard();
@@ -326,13 +326,27 @@
326
326
  outline: var(--outliner-border-width, 1px) solid var(--select, #e8a33d);
327
327
  }
328
328
 
329
+ /* The context menu is portaled to <body> inside this layer, which keeps the
330
+ `.v2-outliner` style scope without a box of its own (hierarchy-panel.jsx).
331
+ It and the scene switcher's list share the popover tokens (themes.css). */
332
+ .v2-outliner.v2-outliner-layer {
333
+ display: contents;
334
+ }
335
+
336
+ .v2-outliner .hierarchy-context-menu,
337
+ .dropdown-menu.hierarchy-scene-menu {
338
+ z-index: var(--z-popover, 90);
339
+ border: var(--outliner-border-width, 1px) solid var(--popover-border, var(--row-divider, #1d1d20));
340
+ border-radius: var(--popover-radius, 6px);
341
+ background: var(--popover-surface, var(--surface-raised, #1e1e21));
342
+ -webkit-backdrop-filter: var(--popover-blur, none);
343
+ backdrop-filter: var(--popover-blur, none);
344
+ box-shadow: var(--popover-shadow, 0 8px 24px rgba(0, 0, 0, .45));
345
+ }
346
+
329
347
  .v2-outliner .hierarchy-context-menu {
330
348
  min-width: var(--outliner-menu-width, 220px);
331
349
  padding: var(--outliner-menu-padding, 4px);
332
- border: var(--outliner-border-width, 1px) solid var(--row-divider, #1d1d20);
333
- border-radius: var(--r-2, 6px);
334
- background: var(--surface-raised, #1e1e21);
335
- box-shadow: 0 var(--sp-1, 8px) var(--sp-3, 16px) var(--overlay, rgba(12,12,13,.78));
336
350
  }
337
351
 
338
352
  .v2-outliner .hierarchy-context-item {
@@ -1,9 +1,11 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
2
3
  import { ko, isKo } from "./locale.js";
3
4
  import { buildHierarchyNodes } from "./hierarchy-model.js";
4
5
  import { sceneObjectIdFromHierarchy } from "./scene-objects.js";
5
6
  import { CatalogueEntries, displayObjectLabel } from "./object-catalog.jsx";
6
7
  import { Dropdown } from "./ui.jsx";
8
+ import { usePopover } from "./popover.js";
7
9
  import { isImeComposing } from "./ime.js";
8
10
  import "./hierarchy-panel.css";
9
11
 
@@ -97,6 +99,7 @@ function ScenePill({ scenes, activeSceneId, onSceneSelect, onSceneCreate, onRena
97
99
  value={activeSceneId}
98
100
  options={options}
99
101
  ariaLabel={ko("Select scene", "장면 선택")}
102
+ menuClassName="hierarchy-scene-menu"
100
103
  onChange={(value) => {
101
104
  if (value === NEW_SCENE_OPTION) onSceneCreate?.();
102
105
  else if (value !== activeSceneId) onSceneSelect?.(value);
@@ -194,11 +197,9 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
194
197
  const [deleteArmed, setDeleteArmed] = useState(false);
195
198
  useEffect(() => setDeleteArmed(false), [menu]);
196
199
 
200
+ usePopover(menu, onClose, [rootRef]);
197
201
  useEffect(() => {
198
202
  if (!menu) return undefined;
199
- const onDocDown = (event) => {
200
- if (rootRef.current && !rootRef.current.contains(event.target)) onClose();
201
- };
202
203
  const onKey = (event) => {
203
204
  if (event.key !== "Escape") return;
204
205
  // Capture phase + stopImmediatePropagation: closing the menu must
@@ -206,12 +207,8 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
206
207
  event.stopImmediatePropagation();
207
208
  onClose();
208
209
  };
209
- document.addEventListener("mousedown", onDocDown);
210
210
  window.addEventListener("keydown", onKey, true);
211
- return () => {
212
- document.removeEventListener("mousedown", onDocDown);
213
- window.removeEventListener("keydown", onKey, true);
214
- };
211
+ return () => window.removeEventListener("keydown", onKey, true);
215
212
  }, [menu, onClose]);
216
213
 
217
214
  if (!menu) return null;
@@ -221,8 +218,11 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
221
218
  left: Math.max(4, Math.min(menu.x, window.innerWidth - 244)),
222
219
  top: Math.max(4, Math.min(menu.y, window.innerHeight - menu.height)),
223
220
  };
224
- return (
225
- <div className="hierarchy-context-menu" role="menu" style={style} ref={rootRef}>
221
+ // Portaled: the glass column clips and blurs its children, so an in-place
222
+ // menu was cut at the column edge and sat under body-level popovers. The
223
+ // display:contents layer keeps the `.v2-outliner` style scope.
224
+ return createPortal(
225
+ <div className="v2-outliner v2-outliner-layer"><div className="hierarchy-context-menu" role="menu" style={style} ref={rootRef}>
226
226
  {menu.kind === "scene" ? (
227
227
  <>
228
228
  <button type="button" role="menuitem" className="hierarchy-context-item" onClick={() => onAction("rename", menu.id)}>
@@ -292,7 +292,8 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
292
292
  ) : (
293
293
  <CatalogueEntries onPick={onAddObject} />
294
294
  )}
295
- </div>
295
+ </div></div>,
296
+ document.body,
296
297
  );
297
298
  }
298
299
 
package/src/kits/held.js CHANGED
@@ -38,9 +38,10 @@ export const bat = {
38
38
 
39
39
  export const ball = {
40
40
  label: "Baseball", prop: "ballGrip",
41
- description: "7.4 cm baseball held in the right fingers",
41
+ description: "7.4 cm baseball held in the right palm and fingers",
42
42
  props: { ballGrip: { hand: "r", origin: RIGHT_FINGERS, points: { centre: [0, 0, -0.035] } } },
43
- carry: { prop: "ballGrip", at: [0, 0, -0.035], along: [0, 1, 0] },
43
+ // no pinned point: it rests against the palm where the hand takes it and the fingers close round it (motion3 seat.js)
44
+ carry: { prop: "ballGrip", grasp: "spherical" },
44
45
  parts: [{ role: "body", kind: "sphere", at: [0, -0.037, 0], scale: [0.074, 0.074, 0.074], color: "#f1ede3" }],
45
46
  };
46
47
 
@@ -19,9 +19,9 @@
19
19
  import { practicalShadows, withShadowCapWarnings } from "./object-look.js";
20
20
  import { isEffectivelyHidden } from "./object-hidden.js";
21
21
 
22
- /** Renderers whose meshes cast shadows (props.jsx, set-pieces.jsx, sculpts, imported meshes).
22
+ /** Renderers whose meshes cast shadows (props.jsx primitives, sculpts, imported meshes).
23
23
  * A plane and a cutout are cards: nothing can be inside them. */
24
- const CASTERS = new Set(["cube", "sphere", "capsule", "cylinder", "cone", "car", "chair", "small-plane", "sculpt", "mesh"]);
24
+ const CASTERS = new Set(["cube", "sphere", "capsule", "cylinder", "cone", "sculpt", "mesh"]);
25
25
  const DEG = Math.PI / 180;
26
26
  const EPS = 1e-6;
27
27
  const MAX_WARNINGS = 4;
package/src/main.jsx CHANGED
@@ -12,6 +12,7 @@ import { registerPwa } from "./pwa.js";
12
12
  import { LOCALE } from "./locale.js";
13
13
  import { initAnalytics } from "./analytics.js";
14
14
  import { fetchPlaygroundProject, isPlaygroundEmbed, playgroundSceneUrl, stashPlaygroundProject } from "./playground.js";
15
+ import { preloadBodies } from "./motion3/body.js";
15
16
 
16
17
  registerPwa();
17
18
  void initAnalytics();
@@ -34,6 +35,10 @@ async function boot() {
34
35
  </ErrorBoundary>
35
36
  </StrictMode>,
36
37
  );
38
+ // The motion compiler's body fits are not in the first-load bundle (motion3/body.js): fetch them once
39
+ // the first screen is up, so a saved take compiled on this thread does not have to wait for them.
40
+ const idle = globalThis.requestIdleCallback ?? (run => setTimeout(run, 200));
41
+ idle(() => preloadBodies().catch(error => console.warn("[cozyclay] could not preload the motion body fits", error)));
37
42
  }
38
43
 
39
44
  void boot();
@@ -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
+ }