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
@@ -0,0 +1,70 @@
1
+ // The shape of a motion-scene entry to a hand (#816): a sphere and a cylinder are round, not the box they sit in. A
2
+ // 7.4 cm ball in its box is off by up to 30% of its radius at the corners, so fingers closing on the box would stop in
3
+ // the air. The shape is derived from what every entry already carries (renderer, half: scene.js), so no field is added
4
+ // to an entry and a saved take source, which keeps whole entries, reads the same shapes. DOM-free: the compile worker
5
+ // runs it. Everything else (GRAB, COLLIDE, landing) keeps the oriented box.
6
+ // sphere renderer "sphere" whose three halves agree within EVEN: r = their mean (an uneven one is an ellipsoid:
7
+ // its box)
8
+ // cylinder renderer "cylinder": its axis along the entry's local +Y, r the smaller cross half, h = 2 half[1]
9
+ // box anything else: the oriented box on the base pivot (qa/geometry.js sdBox)
10
+ // An entry's pos is its base pivot (the bottom centre); its centre is pos + R [0, half[1], 0].
11
+ import { add, sub, scale, len, apply, T } from "./compiler/math.js";
12
+ import { sdBox, boxNormal, segPoints } from "./qa/geometry.js";
13
+
14
+ const EVEN = 0.05;
15
+
16
+ /** { kind: "sphere", r } | { kind: "cylinder", r, h } | { kind: "box" } of a motion-scene entry. */
17
+ export function shapeOf(entry) {
18
+ const h = entry?.half;
19
+ if (!h) return { kind: "box" };
20
+ if (entry.renderer === "sphere") {
21
+ const r = (h[0] + h[1] + h[2]) / 3;
22
+ return Math.max(...h) - Math.min(...h) <= EVEN * r ? { kind: "sphere", r } : { kind: "box" };
23
+ }
24
+ if (entry.renderer === "cylinder") return { kind: "cylinder", r: Math.min(h[0], h[2]), h: 2 * h[1] };
25
+ return { kind: "box" };
26
+ }
27
+
28
+ const local = (entry, p) => { const q = apply(T(entry.R), sub(p, entry.pos)); q[1] -= entry.half[1]; return q; };
29
+ const centre = entry => add(entry.pos, apply(entry.R, [0, entry.half[1], 0]));
30
+
31
+ /** Signed distance (m) from p to the entry's shape: negative inside. */
32
+ export function signedDistance(entry, p) {
33
+ const s = shapeOf(entry);
34
+ if (s.kind === "sphere") return len(sub(p, centre(entry))) - s.r;
35
+ if (s.kind === "cylinder") {
36
+ const q = local(entry, p), radial = Math.hypot(q[0], q[2]) - s.r, axial = Math.abs(q[1]) - s.h / 2;
37
+ return Math.hypot(Math.max(radial, 0), Math.max(axial, 0)) + Math.min(Math.max(radial, axial), 0);
38
+ }
39
+ return sdBox(p, entry);
40
+ }
41
+
42
+ /** Outward unit normal of the entry's shape at p (the nearest surface's from inside). */
43
+ export function normalAt(entry, p) {
44
+ const s = shapeOf(entry);
45
+ if (s.kind === "sphere") { const d = sub(p, centre(entry)), l = len(d); return l > 1e-12 ? scale(d, 1 / l) : apply(entry.R, [0, 1, 0]); }
46
+ if (s.kind === "cylinder") {
47
+ const q = local(entry, p), rq = Math.hypot(q[0], q[2]), radial = rq - s.r, axial = Math.abs(q[1]) - s.h / 2;
48
+ const out = rq > 1e-12 ? [q[0] / rq, 0, q[2] / rq] : [1, 0, 0], cap = [0, Math.sign(q[1]) || 1, 0];
49
+ let n;
50
+ if (radial <= 0 && axial <= 0) n = radial > axial ? out : cap;
51
+ else n = add(scale(out, Math.max(radial, 0)), scale(cap, Math.max(axial, 0)));
52
+ const l = len(n);
53
+ return apply(entry.R, l > 1e-12 ? scale(n, 1 / l) : out);
54
+ }
55
+ return boxNormal(p, entry);
56
+ }
57
+
58
+ export function closestPoint(entry, p) {
59
+ return sub(p, scale(normalAt(entry, p), signedDistance(entry, p)));
60
+ }
61
+
62
+ /**
63
+ * How far a capsule (segment a-b, `radius` m) goes into the entry's shape, m: positive is the depth of its skin inside,
64
+ * negative the gap between its skin and the surface. Sampled on the segment as the QA capsules are (segPoints).
65
+ */
66
+ export function capsuleDepth(entry, a, b, radius) {
67
+ let depth = -Infinity;
68
+ for (const p of segPoints(a, b)) depth = Math.max(depth, radius - signedDistance(entry, p));
69
+ return depth;
70
+ }
@@ -0,0 +1,71 @@
1
+ // catchObject: a hand meets a flying thing where it can reach and gives with it (#816). The thing flies as a free entry
2
+ // does, objects.<id>.fall { at, v } from its pose at `at` (flight.js); the skill samples that flight and picks the first
3
+ // frame its point is in front of the chest (FRONT m ahead, between the hips and the head) and SHELL m from the catching
4
+ // shoulder, no earlier than the hand can get there from f0 (REACH frames). The open hand arrives ARRIVE frames before it,
5
+ // the finger middles on the point (a fit on a world anchor there); the fingers close and the carry takes it at
6
+ // that frame (fills: prop, grab, grasp), so it rests in the hand (seat.js) and the fingers close on it (finger-close.js).
7
+ // Then the hand gives with it: along the incoming velocity by clamp(0.08 + 0.02 x mass x speed, 0.08, 0.15) m over ABSORB
8
+ // frames, slowing to a stop, and eases back. side "both", or a thing over HEAVY kg, adds the left hand on its left side
9
+ // along the same path (world anchors: the right hand holds the carry, the left meets it). A flight that never comes within
10
+ // reach is refused, naming its nearest frame and point; a hand that comes too late (f0 past the reach) misses: QA CATCH.
11
+ import { add, sub, scale, apply, ry, len, norm, dot } from "../compiler/math.js";
12
+ import { freePoseAt } from "../free-motion.js";
13
+ import { massOf } from "../mass.js";
14
+ import { skillError } from "./book.js";
15
+ import { transportKeys } from "./reach-human.js";
16
+
17
+ const SHELL = [0.35, 0.65], FRONT = 0.15, REACH = 10, LONG = 20, ARRIVE = 2, ABSORB = 6, BACK = 8, HEAVY = 1;
18
+ const r3 = v => v.map(x => Math.round(x * 1000) / 1000);
19
+ const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
20
+
21
+ /** The shoulder of side S and the body's forward on the keyed root and yaw of state. */
22
+ function bodyAt(sk, state, S) {
23
+ const P = sk.restWorldPos, hips = state.root ?? P[sk.index.Hips], R = ry(state.yaw ?? 0);
24
+ return { hips, shoulder: add(hips, apply(R, sub(P[sk.index[`${S}Arm`]], P[sk.index.Hips]))), head: add(hips, apply(R, sub(P[sk.index.Head], P[sk.index.Hips]))), fwd: apply(R, [0, 0, 1]) };
25
+ }
26
+
27
+ export function catchObject(ctx) {
28
+ const { args, state, sk, scene, tag } = ctx, id = args.object, side = args.side ?? "r", o = ctx.objects?.[id], fps = ctx.fps ?? 24;
29
+ if (side !== "r" && side !== "both") skillError(tag, `args.side must be "r" or "both", got ${JSON.stringify(side)}`);
30
+ const fall = Array.isArray(o?.fall) ? o.fall[0] : o?.fall;
31
+ if (!Array.isArray(fall?.v)) skillError(tag, `args.object "${id}" must fly: objects.${id}.fall { at, v: [vx, vy, vz] }`);
32
+ const body = bodyAt(sk, state, "Right"), flight = { spec: { objects: { [id]: { fall: o.fall } }, fps }, scene, timeMap: null };
33
+ const at = f => freePoseAt(flight, id, f)?.pos;
34
+ // the first frame of the flight in front of the chest within the shell around the shoulder; else the nearest one
35
+ let pick = null, near = null;
36
+ for (let f = fall.at + 1; f < ctx.frames; f++) {
37
+ const p = at(f), d = len(sub(p, body.shoulder)), ahead = dot(sub(p, body.hips), body.fwd), off = Math.max(0, SHELL[0] - d, d - SHELL[1], FRONT - ahead, body.hips[1] - 0.1 - p[1], p[1] - body.head[1]);
38
+ if (!near || off < near.off) near = { f, p, off };
39
+ if (off === 0) { pick = { f, p }; break; }
40
+ }
41
+ if (!pick) skillError(tag, `${id} never comes within reach of the right hand (in front of the chest, ${SHELL[0]}-${SHELL[1]} m from the shoulder): nearest at f${near.f}, [${r3(near.p).join(", ")}], ${near.off.toFixed(2)} m out; move the body (standIdle at) or the throw`);
42
+ // the hand needs REACH frames from f0: a later pick it would get to in time; else it comes late to where the thing was
43
+ const contact = Math.max(pick.f, ctx.f0 + REACH + ARRIVE), p = pick.p;
44
+ const incoming = scale(sub(at(pick.f), at(pick.f - 1)), fps), speed = len(incoming), mass = massOf({ objects: ctx.objects }, scene, id);
45
+ const give = clamp(0.08 + 0.02 * mass * speed, 0.08, 0.15), u = norm(incoming), ease = t => 1 - (1 - t) ** 3;
46
+ const path = [p, ...Array.from({ length: ABSORB }, (_, i) => add(p, scale(u, give * ease((i + 1) / ABSORB)))), add(p, scale(u, give * 0.4))];
47
+ const start = Math.max(ctx.f0, contact - ARRIVE - LONG), both = side === "both" || mass > HEAVY;
48
+ // the left hand (both hands, or a heavy thing) on its left side, its finger middles across the thing from the right's
49
+ const radius = Math.max(0, ...Object.values(scene.objects).filter(e => e === scene.objects[id] || e.ancestors?.includes(id)).map(e => Math.max(...e.half)));
50
+ const left = apply(ry(state.yaw ?? 0), [1, 0, 0]), part = { keys: [], end: contact + ABSORB + BACK, props: {}, anchors: {}, fits: [], channels: [],
51
+ fills: { [id]: { prop: `${id}Catch`, grab: contact, grasp: "spherical" } }, catch: { contact, point: r3(p), speed: Math.round(speed * 100) / 100, give: Math.round(give * 1000) / 1000, hands: both ? 2 : 1 } };
52
+ for (const k of both ? ["r", "l"] : ["r"]) {
53
+ const S = k === "l" ? "Left" : "Right", prop = k === "r" ? `${id}Catch` : `${id}CatchL`, off = k === "r" ? [0, 0, 0] : scale(left, 2 * radius + 0.04);
54
+ const armw = `armw_${k}`, fg = `fingers_${k}`, open = { ...state[fg], spherical: 0 }, shut = { ...state[fg], spherical: 1 };
55
+ // the finger middles (the prop's origin) on the point at the catch, then on each point of the give: world anchors
56
+ // with a fit each, the weight handed from one to the next frame by frame (the fits solve the hand onto them exactly)
57
+ const names = path.map((_, i) => `${prop}${i}`), w = i => `w_${names[i]}_${k}`;
58
+ part.keys.push(
59
+ // the elbow down and out (body axes), so it swings out round the side, not in across the body
60
+ { f: start, set: { [armw]: state[armw] ?? 0, [w(0)]: 0, [fg]: open, [`elbow_${k}`]: k === "r" ? [-1, -0.5, -0.5] : [1, -0.5, -0.5] } },
61
+ // the reach's early-peaked transport, the arm's weight ahead of the aim's (reach-human.js; as reachGrip)
62
+ ...transportKeys(contact - ARRIVE - start).map(([f, t, ta]) => ({ f: start + f, set: { [w(0)]: t, [armw]: (state[armw] ?? 0) + (1 - (state[armw] ?? 0)) * ta } })),
63
+ { f: contact - ARRIVE, set: { [armw]: 1, [w(0)]: 1, [fg]: open } },
64
+ { f: contact, set: { [w(0)]: 1, [fg]: shut } },
65
+ ...path.slice(1).flatMap((_, j) => { const i = j + 1, f = i <= ABSORB ? contact + i : contact + ABSORB + BACK; return [{ f: i <= ABSORB ? f - 1 : contact + ABSORB, set: { [w(i - 1)]: 1, [w(i)]: 0 } }, { f, set: { [w(i - 1)]: 0, [w(i)]: 1 } }]; }),
66
+ );
67
+ part.props[prop] = { hand: k, origin: [`${S}HandIndex2`, `${S}HandMiddle2`], points: { fingers: [0, 0, 0] } };
68
+ names.forEach((n, i) => { part.anchors[n] = { kind: "world", p: r3(add(path[i], off)) }; part.fits.push({ side: k, anchor: n, effector: { prop, point: "fingers" } }); part.channels.push(w(i)); });
69
+ }
70
+ return part;
71
+ }
@@ -13,8 +13,10 @@ import { sitInSeat, exitCar } from "./car.js";
13
13
  import { smokeCigarette } from "./smoke.js";
14
14
  import { flip } from "./flip.js";
15
15
  import { hitReaction } from "./impact.js";
16
+ import { throwObject } from "./throw.js";
17
+ import { catchObject } from "./catch.js";
16
18
 
17
- const SKILLS = { standIdle, sitInSeat, exitCar, walkTo, reachGrip, smokeCigarette, flip, hitReaction };
19
+ const SKILLS = { standIdle, sitInSeat, exitCar, walkTo, reachGrip, smokeCigarette, flip, hitReaction, throwObject, catchObject };
18
20
  export const SKILL_NAMES = Object.keys(SKILLS);
19
21
  const bad = msg => { throw new Error(`MOTION_SPEC_INVALID: ${msg}`); };
20
22
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
@@ -53,7 +55,7 @@ export function expandUse(spec, { sk, scene, lenient = false, auto = false }) {
53
55
  if (!spec.use?.length) return { spec, used: [] };
54
56
  if (!Array.isArray(spec.use)) bad("spec.use must be an array of { skill, args, f0 }");
55
57
  const own = (spec.keys ?? []).map((k, i) => ({ ...k, src: `keys[${i}]` }));
56
- const merged = { anchors: {}, props: {}, objects: {}, fits: [], checks: {}, channels: [] }, skillKeys = [], used = [];
58
+ const merged = { anchors: {}, props: {}, objects: {}, fits: [], checks: {}, channels: [], fills: {}, goals: [] }, skillKeys = [], used = [];
57
59
  spec.use.forEach((u, i) => {
58
60
  const tag = `use[${i}] ${u?.skill}`;
59
61
  if (!SKILLS[u?.skill]) bad(`use[${i}].skill "${u?.skill}" is not a skill${didYouMean(String(u?.skill), SKILL_NAMES)} (skills: ${SKILL_NAMES.join(", ")})`);
@@ -61,7 +63,7 @@ export function expandUse(spec, { sk, scene, lenient = false, auto = false }) {
61
63
  argsOk(u, i, lenient);
62
64
  if (!Number.isInteger(u.f0) || u.f0 < 0 || u.f0 > spec.frames - 1) bad(`use[${i}].f0 must be an integer frame within 0..${spec.frames - 1}, got ${JSON.stringify(u.f0)}`);
63
65
  const keys = [...skillKeys, ...own];
64
- const ctx = { sk, scene, frames: spec.frames, auto, props: { ...merged.props, ...spec.props }, self: spec.fix !== false && spec.fix?.self !== false, f0: u.f0, args: structuredClone(u.args ?? {}), tag, keys,
66
+ const ctx = { sk, scene, frames: spec.frames, fps: spec.fps ?? 24, auto, props: { ...merged.props, ...spec.props }, self: spec.fix !== false && spec.fix?.self !== false, f0: u.f0, args: structuredClone(u.args ?? {}), tag, keys,
65
67
  use: spec.use, index: i, place: fixOptions(spec.fix).stance, state: structuredClone(stateAt(keys, u.f0)), anchors: { ...merged.anchors, ...spec.anchors }, objects: { ...merged.objects, ...spec.objects } };
66
68
  const part = SKILLS[u.skill](ctx), last = Math.max(part.end ?? u.f0, ...part.keys.map(k => k.f));
67
69
  if (last > spec.frames - 1) bad(`${tag} needs ${last - u.f0} frames from f${u.f0} (to f${last}), past the last frame f${spec.frames - 1}; set frames >= ${last + 1} or frames: "auto"`);
@@ -72,6 +74,9 @@ export function expandUse(spec, { sk, scene, lenient = false, auto = false }) {
72
74
  for (const fit of part.fits ?? []) if (!merged.fits.some(x => same(x, fit))) merged.fits.push(fit);
73
75
  for (const ch of part.channels ?? []) if (!merged.channels.includes(ch)) merged.channels.push(ch);
74
76
  mergeChecks(merged.checks, part.checks);
77
+ // fields a skill fills into an entry when it leaves them out (throwObject: a carry's release and fall)
78
+ for (const [id, f] of Object.entries(part.fills ?? {})) merged.fills[id] = { ...merged.fills[id], ...f };
79
+ merged.goals.push(...(part.goals ?? []));
75
80
  used.push({ skill: u.skill, f0: u.f0, end: part.end, ...(part.paced ? { paced: part.paced } : {}), ...(part.cut ? { cut: part.cut } : {}), ...(part.placed ? { placed: part.placed } : {}) });
76
81
  });
77
82
  const out = { ...spec, keys: [...skillKeys, ...own], anchors: { ...merged.anchors, ...spec.anchors }, props: { ...merged.props, ...spec.props },
@@ -82,6 +87,8 @@ export function expandUse(spec, { sk, scene, lenient = false, auto = false }) {
82
87
  out.fits = [...merged.fits.filter(f => !mine.some(m => m.side === f.side && m.anchor === f.anchor)), ...mine];
83
88
  mergeChecks(merged.checks, spec.checks);
84
89
  out.checks = merged.checks;
90
+ for (const [id, f] of Object.entries(merged.fills)) if (out.objects[id]) out.objects[id] = { ...f, ...out.objects[id] };
91
+ if (merged.goals.length) out.goals = [...merged.goals, ...(spec.goals ?? [])];
85
92
  delete out.use;
86
93
  return { spec: out, used };
87
94
  }
@@ -77,4 +77,17 @@ export const SKILL_INFO = {
77
77
  speed: num("launch speed, m/s (a shove 2, a car at 30 km/h about 8)", { default: 4, exclusiveMinimum: 0 }), land: { enum: ["back", "front"], description: "default: back when hit from the front" } },
78
78
  needs: "a pose; dir or from", frames: "~40", adds: ["keys: thrown to rest"], example: { f0: 10, args: { dir: [0, -1], speed: 3 } },
79
79
  },
80
+ throwObject: {
81
+ args: { object: str("the carried object's id (objects.<id>.prop on the hand's prop)"), side: side("the throwing hand (default r)"), to: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3, description: "[x, y, z] world m: where it lands" },
82
+ arc: { enum: ["low", "high"], description: "launch angle: low 20 deg, high 50 deg (default low)" } },
83
+ required: ["object", "to"], needs: "the object in that hand (its objects entry on the hand's prop)", frames: "24 (wind-up, swing, release at f0+18, follow-through)",
84
+ adds: ["keys: wind-up, swing, follow-through", "objects.<id>: release and fall { v: \"hand\" } when the entry leaves them out", "goals: <id> lands at the target"],
85
+ example: { f0: 40, args: { object: "ball", to: [0, 0, 5] } },
86
+ },
87
+ catchObject: {
88
+ args: { object: str("the flying object's id (objects.<id>.fall { at, v })"), side: { enum: ["r", "both"], description: "the right hand, or both (a thing over 1 kg takes both)" } },
89
+ required: ["object"], needs: "the object flying: objects.<id>.fall { at, v }", frames: "to the catch (the first frame in reach, at least 10 from f0) + 14",
90
+ adds: ["keys: reach, close, give with it", "objects.<id>: prop, grab, grasp when the entry leaves them out", "props.<id>Catch"],
91
+ example: { f0: 0, args: { object: "ball" } },
92
+ },
80
93
  };
@@ -0,0 +1,72 @@
1
+ // throwObject: the hand throws what it carries at a point (#816). From the pose at f0 the arm winds up behind the shoulder,
2
+ // swings forward and lets go, then follows through. The launch is closed form: the arc's launch angle (low LOW deg, high
3
+ // HIGH deg) and the speed that carries the object from where the hand lets it go to args.to under G (free-motion.js). The
4
+ // last frame before the release and the release frame put the wrist one frame's travel apart along that velocity, so the
5
+ // wrist moves at the launch velocity there; the fall the skill writes leaves at that velocity, { at: release, v } (flight.js).
6
+ // Not v: "hand": the hand's turn as the elbow opens adds a whip to the object (about 2 m/s at the release, so the throw
7
+ // carried 2 m long), which only the solved frames know; "hand" stays for an agent's own release. It writes the carry's release and fall (fills: fields the agent's own entry leaves out) and a goal: the
8
+ // object near args.to at its landing. A release with no fall stays where the hand let it go (C3), so the skill writes one.
9
+ import { add, sub, scale, apply, ry, len } from "../compiler/math.js";
10
+ import { G } from "../free-motion.js";
11
+ import { skillError } from "./book.js";
12
+ import { GRASP_FINGERS } from "../compiler/vocab.js";
13
+
14
+ const LOW = 20, HIGH = 50, WIND = 10, SWING = 8, FOLLOW = 10, SETTLE_FRAMES = 8, TOL = 0.25;
15
+ // the wrist at the release: ahead of the shoulder along the throw and up; the thing held rides PALM m ahead of the wrist
16
+ const AHEAD = 0.42, UP = 0.22, PALM = 0.09, BACK = 0.12, SIDE = 0.25, LOWER = 0.4;
17
+ const r3 = v => v.map(x => Math.round(x * 1000) / 1000);
18
+
19
+ /** How far the root of scene body or group `id` sits above its lowest point at rest (m). */
20
+ function liftOf(scene, id) {
21
+ const parts = Object.entries(scene?.objects ?? {}).filter(([k, e]) => k === id || e.ancestors?.includes(id)).map(([, e]) => e);
22
+ const root = scene?.objects?.[id]?.pos ?? parts[0]?.ancestorPoses?.[0]?.pos;
23
+ if (!root || !parts.length) return 0;
24
+ return root[1] - Math.min(...parts.map(e => e.pos[1]));
25
+ }
26
+
27
+ export function throwObject(ctx) {
28
+ const { args, state, sk, scene, tag } = ctx, id = args.object, k = args.side ?? "r", o = ctx.objects?.[id];
29
+ if (k !== "l" && k !== "r") skillError(tag, `args.side must be "l" or "r", got ${JSON.stringify(k)}`);
30
+ if (!o?.prop || ctx.props?.[o.prop]?.hand !== k) skillError(tag, `args.object "${id}" must be carried by the ${k === "r" ? "right" : "left"} hand (objects.${id}.prop on a prop with hand "${k}")`);
31
+ if (!Array.isArray(args.to) || args.to.length !== 3) skillError(tag, "args.to must be [x, y, z] world m (where it lands)");
32
+ const theta = ((args.arc === "high" ? HIGH : LOW) * Math.PI) / 180, fps = ctx.fps ?? 24, S = k === "l" ? "Left" : "Right";
33
+ // the shoulder where the body stands at f0: the bind pose's offset from the hips, turned by the yaw, on the keyed root
34
+ const P = sk.restWorldPos, hips = state.root ?? P[sk.index.Hips], R = ry(state.yaw ?? 0);
35
+ const shoulder = add(hips, apply(R, sub(P[sk.index[`${S}Arm`]], P[sk.index.Hips])));
36
+ const flat = [args.to[0] - shoulder[0], 0, args.to[2] - shoulder[2]], dist = len(flat);
37
+ if (dist < 0.5) skillError(tag, `args.to is ${dist.toFixed(2)} m from the shoulder; throw it at least 0.5 m`);
38
+ const dir = scale(flat, 1 / dist), wrist = add(shoulder, add(scale(dir, AHEAD), [0, UP, 0])), from = add(wrist, scale(dir, PALM));
39
+ const land = args.to[1] + liftOf(scene, id), d = len([args.to[0] - from[0], 0, args.to[2] - from[2]]), dy = land - from[1];
40
+ const den = 2 * Math.cos(theta) ** 2 * (d * Math.tan(theta) - dy);
41
+ if (!(den > 0)) skillError(tag, `args.to is too high for a ${args.arc ?? "low"} arc from the hand; use arc "high" or a lower point`);
42
+ const speed = Math.sqrt((G * d * d) / den), v = add(scale(dir, speed * Math.cos(theta)), [0, speed * Math.sin(theta), 0]);
43
+ const flight = (v[1] + Math.sqrt(v[1] ** 2 + 2 * G * Math.max(0, from[1] - land))) / G;
44
+ const release = ctx.f0 + WIND + SWING, landed = Math.min(ctx.frames - 1, release + Math.ceil(flight * fps) + SETTLE_FRAMES);
45
+ // the wind-up goes back out on the hand's side (not behind the back), the follow-through on down across the body
46
+ const out = scale([dir[2], 0, -dir[0]], k === "r" ? -1 : 1), hand = `hand_${k}`, armw = `armw_${k}`;
47
+ const wind = add(shoulder, add(add(scale(dir, -BACK), scale(out, SIDE)), [0, -0.05, 0]));
48
+ // mid-swing the wrist stays out from the shoulder (a target near it folds the elbow past its stop); after the release it
49
+ // slows over three frames, going on and down, then crosses low in front
50
+ const mid = add(shoulder, add(add(scale(out, 0.32), scale(dir, 0.12)), [0, 0.08, 0]));
51
+ const on = add(wrist, scale(v, 0.45 / fps)), through = add(shoulder, add(scale(dir, 0.46), [0, -0.1, 0])), follow = add(shoulder, add(add(scale(dir, 0.3), scale(out, -0.15)), [0, -LOWER, 0]));
52
+ // the hand keeps its grip on the thing through the swing and opens as it lets go
53
+ const fg = `fingers_${k}`, preset = GRASP_FINGERS[o.grasp] ?? "grip", grip = { ...state[fg], [preset]: 1 }, open = { ...state[fg], [preset]: 0 };
54
+ const keys = [
55
+ { f: ctx.f0, set: { [armw]: state[armw] ?? 0, [fg]: grip, ...(state[hand] ? { [hand]: state[hand] } : {}) } },
56
+ { f: release, set: { [fg]: grip } }, { f: release + 3, set: { [fg]: open } },
57
+ { f: ctx.f0 + WIND, set: { [armw]: 1, [hand]: r3(wind) } },
58
+ { f: ctx.f0 + WIND + Math.round(SWING / 2), set: { [armw]: 1, [hand]: r3(mid) } },
59
+ { f: release - 1, set: { [armw]: 1, [hand]: r3(sub(wrist, scale(v, 1 / fps))) } },
60
+ { f: release, set: { [armw]: 1, [hand]: r3(wrist) } },
61
+ { f: release + 1, set: { [armw]: 1, [hand]: r3(on) } },
62
+ { f: release + 3, set: { [armw]: 1, [hand]: r3(through) } },
63
+ { f: release + FOLLOW, set: { [armw]: 1, [hand]: r3(follow) } },
64
+ ];
65
+ const target = `${id}Target`;
66
+ return {
67
+ keys, end: release + FOLLOW, anchors: { [target]: { kind: "world", p: r3([args.to[0], land, args.to[2]]) } },
68
+ fills: { [id]: { release, fall: { at: release, v: r3(v) } } },
69
+ goals: [{ name: `${id} lands at the target`, near: [`object:${id}`, `anchor:${target}`], at: landed, tol: TOL }],
70
+ throw: { release, speed: Math.round(speed * 100) / 100, v: r3(v), flight: Math.round(flight * 100) / 100 },
71
+ };
72
+ }
@@ -4,7 +4,6 @@ import { EMPTY_KIND, OBJECT_MENU_ENTRIES } from "./scene-objects.js";
4
4
 
5
5
  const GROUP_LABELS_KO = {
6
6
  Primitives: "기본 도형",
7
- "Set pieces": "세트 소품",
8
7
  Lights: "조명",
9
8
  Effects: "효과",
10
9
  Text: "텍스트",
@@ -18,9 +17,6 @@ const OBJECT_LABELS_KO = {
18
17
  Cylinder: "원기둥",
19
18
  Cone: "원뿔",
20
19
  Plane: "평면",
21
- Chair: "의자",
22
- Car: "자동차",
23
- "Plane (aircraft)": "비행기",
24
20
  "Point light": "점 조명",
25
21
  "Spot light": "스포트 조명",
26
22
  Light: "조명",
@@ -1,7 +1,8 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { useFrame, useThree } from "@react-three/fiber";
3
3
  import * as THREE from "three";
4
- import { GIZMO_LAYER, SHOT_ASPECT, fitAspect } from "./dualview.jsx";
4
+ import { GIZMO_LAYER, SHOT_ASPECT } from "./dualview.jsx";
5
+ import { fitAspect } from "./shot-frame.js";
5
6
  import { HANDLE_PROXY_FLAG, claimsPress } from "./gizmo-claim.js";
6
7
  import { CUTOUT_KIND, rotatePatch, scalePatch, screenRotatePatch, translatePatch, wrapAngle } from "./scene-objects.js";
7
8
  import { dragTravel, dragTurn } from "./held-object-pose.js";
@@ -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";
@@ -464,4 +464,4 @@ export function strokeToPathPoints(stroke, simplify, { maxPoints = STROKE_MAX_PO
464
464
  return points.map((point) => ({ x: point.x, y: 0, z: point.z }));
465
465
  }
466
466
 
467
- export { MAX_PATH_POINTS, MAX_PATH_MARKS, MARK_GAP, MAX_LEAN as MAX_PATH_LEAN, MAX_HEIGHT as MAX_PATH_HEIGHT, STROKE_MAX_POINTS };
467
+ export { MAX_PATH_POINTS, MAX_PATH_MARKS, MARK_GAP, MAX_LEAN as MAX_PATH_LEAN, STROKE_MAX_POINTS };
@@ -1,4 +1,7 @@
1
+ import { useLayoutEffect, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
1
3
  import { ko } from "../locale.js";
4
+ import { usePopover } from "../popover.js";
2
5
  import ResourceStatus from "../resource-status.jsx";
3
6
  import { useBus } from "../app-context.js";
4
7
  import "./details.css";
@@ -7,27 +10,45 @@ export default function ProjectPanel({
7
10
  projectMenuOpen, setProjectMenuOpen, projectDirty, projectName, projectStartupOpen, projectManifest,
8
11
  }) {
9
12
  const { run } = useBus();
13
+ const triggerRef = useRef(null);
14
+ const [menuBox, setMenuBox] = useState(null);
15
+ usePopover(projectMenuOpen, () => setProjectMenuOpen(false));
16
+ // The menu is portaled and fixed under the trigger: the glass column clips
17
+ // and blurs its children, so an in-place menu was cut at the column edge
18
+ // and read the column's transparent panel fill. App.jsx closes it on an
19
+ // outside pointerdown via `.closest(".project-menu-wrap")`, so the portal
20
+ // layer carries that class (display:contents, no box of its own).
21
+ useLayoutEffect(() => {
22
+ const rect = projectMenuOpen ? triggerRef.current?.getBoundingClientRect() : null;
23
+ setMenuBox(rect ? { left: rect.left, top: rect.bottom + 6 } : null);
24
+ }, [projectMenuOpen]);
25
+ // Hidden until the layout effect has placed it, so it never paints at the
26
+ // wrong spot; without a document (server render) it stays in place.
27
+ const menu = projectMenuOpen && (
28
+ <div className="project-menu-wrap v2-project-menu project-menu-layer">
29
+ <div className="project-menu" role="menu" style={menuBox ?? { visibility: "hidden" }} onClick={() => setProjectMenuOpen(false)}>
30
+ <button type="button" role="menuitem" onClick={() => run("project.new")}>{ko("New Project", "새 프로젝트")}</button>
31
+ <button type="button" role="menuitem" onClick={() => run("project.browse")}>{ko("Open Project…", "프로젝트 열기…")}</button>
32
+ <button type="button" role="menuitem" onClick={() => run("project.save")}>{ko("Save Project", "프로젝트 저장")}</button>
33
+ <button type="button" role="menuitem" onClick={() => run("project.saveAs")}>{ko("Save Project As…", "다른 이름으로 저장…")}</button>
34
+ <ResourceStatus manifest={projectManifest} compact />
35
+ </div>
36
+ </div>
37
+ );
10
38
  return (
11
- <div className="project-menu-wrap v2-project-menu">
12
- <button
13
- type="button"
14
- className="project-menu-trigger"
15
- aria-expanded={projectMenuOpen}
16
- onClick={() => setProjectMenuOpen((open) => !open)}
17
- >
18
- {projectDirty && <i className="project-dirty-dot" aria-label={ko("Unsaved changes", "저장되지 않은 변경사항")} />}
19
- {projectName ?? (projectStartupOpen ? ko("Choose Project", "프로젝트 선택") : ko("Untitled Project", "제목 없는 프로젝트"))}
20
- <span className="caret">▾</span>
21
- </button>
22
- {projectMenuOpen && (
23
- <div className="project-menu" role="menu" onClick={() => setProjectMenuOpen(false)}>
24
- <button type="button" role="menuitem" onClick={() => run("project.new")}>{ko("New Project", "새 프로젝트")}</button>
25
- <button type="button" role="menuitem" onClick={() => run("project.browse")}>{ko("Open Project…", "프로젝트 열기…")}</button>
26
- <button type="button" role="menuitem" onClick={() => run("project.save")}>{ko("Save Project", "프로젝트 저장")}</button>
27
- <button type="button" role="menuitem" onClick={() => run("project.saveAs")}>{ko("Save Project As…", "다른 이름으로 저장…")}</button>
28
- <ResourceStatus manifest={projectManifest} compact />
29
- </div>
30
- )}
31
- </div>
39
+ <div className="project-menu-wrap v2-project-menu">
40
+ <button
41
+ type="button"
42
+ className="project-menu-trigger"
43
+ ref={triggerRef}
44
+ aria-expanded={projectMenuOpen}
45
+ onClick={() => setProjectMenuOpen((open) => !open)}
46
+ >
47
+ {projectDirty && <i className="project-dirty-dot" aria-label={ko("Unsaved changes", "저장되지 않은 변경사항")} />}
48
+ {projectName ?? (projectStartupOpen ? ko("Choose Project", "프로젝트 선택") : ko("Untitled Project", "제목 없는 프로젝트"))}
49
+ <span className="caret">▾</span>
50
+ </button>
51
+ {menu && (globalThis.document ? createPortal(menu, globalThis.document.body) : menu)}
52
+ </div>
32
53
  );
33
54
  }
@@ -1032,14 +1032,25 @@
1032
1032
  background: var(--select, #e8a33d);
1033
1033
  }
1034
1034
 
1035
+ /* The menu is portaled to <body> (ProjectPanel.jsx); the layer that carries
1036
+ the wrap classes there has no box of its own. Surface and layer are the
1037
+ shared popover tokens (themes.css). */
1038
+ .v2-project-menu.project-menu-layer {
1039
+ display: contents;
1040
+ }
1041
+
1035
1042
  .v2-project-menu.v2-project-menu .project-menu {
1043
+ position: fixed;
1044
+ z-index: var(--z-popover, 90);
1036
1045
  box-sizing: border-box;
1037
1046
  min-width: var(--details-menu-width, 200px);
1038
1047
  padding: var(--details-gap, 4px);
1039
- border: var(--details-border-width, 1px) solid var(--row-divider, #1d1d20);
1040
- border-radius: var(--r-2, 6px);
1041
- background: var(--surface-panel, #151517);
1042
- box-shadow: none;
1048
+ border: var(--details-border-width, 1px) solid var(--popover-border, var(--row-divider, #1d1d20));
1049
+ border-radius: var(--popover-radius, 6px);
1050
+ background: var(--popover-surface, var(--surface-raised, #1e1e21));
1051
+ -webkit-backdrop-filter: var(--popover-blur, none);
1052
+ backdrop-filter: var(--popover-blur, none);
1053
+ box-shadow: var(--popover-shadow, 0 8px 24px rgba(0, 0, 0, .45));
1043
1054
  }
1044
1055
 
1045
1056
  .v2-project-menu.v2-project-menu .project-menu button {
package/src/popover.js ADDED
@@ -0,0 +1,37 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ // Shell popovers (the project menu, dropdowns, the Outliner context menu) are
4
+ // exclusive: opening one announces itself and every other open one closes, so
5
+ // two menus never stack over each other. Their surface and layer are the
6
+ // --popover-* / --z-popover tokens in src/styles/themes.css.
7
+ const POPOVER_OPEN_EVENT = "cozyclay:popover-open";
8
+
9
+ /**
10
+ * Join the exclusive popover group while `open` is truthy. A new truthy
11
+ * `open` value (a context menu re-opened at another row) announces again.
12
+ * With `insideRefs`, a mousedown outside every ref also closes the popover.
13
+ */
14
+ export function usePopover(open, close, insideRefs = null) {
15
+ const live = useRef({ close, insideRefs });
16
+ useEffect(() => {
17
+ live.current = { close, insideRefs };
18
+ });
19
+ useEffect(() => {
20
+ if (!open) return undefined;
21
+ const token = {};
22
+ window.dispatchEvent(new CustomEvent(POPOVER_OPEN_EVENT, { detail: token }));
23
+ const onOtherOpen = (event) => {
24
+ if (event.detail !== token) live.current.close();
25
+ };
26
+ const onDocMouseDown = (event) => {
27
+ const refs = live.current.insideRefs;
28
+ if (refs && !refs.some((ref) => ref.current?.contains(event.target))) live.current.close();
29
+ };
30
+ window.addEventListener(POPOVER_OPEN_EVENT, onOtherOpen);
31
+ document.addEventListener("mousedown", onDocMouseDown);
32
+ return () => {
33
+ window.removeEventListener(POPOVER_OPEN_EVENT, onOtherOpen);
34
+ document.removeEventListener("mousedown", onDocMouseDown);
35
+ };
36
+ }, [open]);
37
+ }
@@ -423,13 +423,10 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
423
423
  // not-yet-focused sphere just took focus (nothing to bake). An
424
424
  // empty-canvas click releases focus too.
425
425
  if (!d.moved) {
426
- if (d.kind === "empty") {
427
- if (focusIdRef.current) focusRef.current?.(null);
428
- return;
429
- }
430
- if (d.wasFocused && !d.axisDir) {
431
- focusRef.current?.(null); // toggle off
432
- }
426
+ // (An empty press is always wasFocused: it only begins while focused.)
427
+ // A gizmo volume can win the raycast over the sphere centre, so a
428
+ // no-move press on the centre (d.centerClick) toggles off too.
429
+ if (d.wasFocused && (!d.axisDir || d.centerClick)) focusRef.current?.(null); // toggle off
433
430
  // axis-arrow clicks and fresh-sphere clicks: nothing to bake
434
431
  return;
435
432
  }
@@ -842,7 +839,7 @@ const CLICK_PX = 4;
842
839
  if (kind === "empty") {
843
840
  // An empty-canvas press: no drag, just a click candidate that
844
841
  // releases focus on pointerup (see onUp).
845
- dragRef.current = { kind: "empty", trackId: "empty", downXY, moved: false };
842
+ dragRef.current = { kind: "empty", trackId: "empty", downXY, moved: false, wasFocused: true };
846
843
  window.addEventListener("pointermove", handlersRef.current.onMove);
847
844
  window.addEventListener("pointerup", handlersRef.current.onUp);
848
845
  window.addEventListener("pointercancel", handlersRef.current.onUp);
@@ -860,6 +857,7 @@ const CLICK_PX = 4;
860
857
  }
861
858
  raycaster.setFromCamera(ndc, camera);
862
859
  let plane;
860
+ const rect = gl.domElement.getBoundingClientRect(), c = origin.clone().project(camera);
863
861
  const ringDrag = !!axisDir && (kind === "fk" || kind === "swing");
864
862
  if (ringDrag) {
865
863
  // A ring turns about its axis: the pointer is read on the ring's own plane.
@@ -892,6 +890,8 @@ const CLICK_PX = 4;
892
890
  // a no-move click toggles focus OFF only then; a click on a fresh
893
891
  // (unfocused) handle must keep the focus it just took.
894
892
  wasFocused: focusIdRef.current === track.id,
893
+ // Press landed within CLICK_PX of the sphere's projected centre.
894
+ centerClick: Math.hypot(downXY[0] - (rect.left + (c.x + 1) / 2 * rect.width), downXY[1] - (rect.top + (1 - c.y) / 2 * rect.height)) <= CLICK_PX,
895
895
  };
896
896
  if (kind === "fk" || kind === "swing" || kind === "body") {
897
897
  // Swing/translate start state, frozen at drag start so moves stay
@@ -944,8 +944,7 @@ const CLICK_PX = 4;
944
944
  handleRefs.current[p.track.id]?.userData.ikExposed !== false
945
945
  );
946
946
  if (focused && !selectionOnly) picks = picks.filter((p) => p.track.id === focused);
947
- else picks = picks.filter((p) => !p.axisDir); // spheres only
948
- if (selectionOnly) picks = picks.filter((p) => !p.axisDir);
947
+ else picks = picks.filter((p) => !p.axisDir); // spheres only
949
948
  if (!picks.length) {
950
949
  if (focused) beginDrag("empty", { id: "empty" }, null, null, downXY);
951
950
  return;
@@ -985,10 +984,16 @@ const CLICK_PX = 4;
985
984
  }
986
985
  setHoverAxis((current) => (current === axis ? current : axis));
987
986
  };
987
+ // Esc releases the focused handle (same path as the toggle-off click).
988
+ const onKey = (ev) => {
989
+ if (ev.key === "Escape" && focusIdRef.current && !/^(INPUT|TEXTAREA|SELECT)$/.test(ev.target?.tagName)) focusRef.current?.(null);
990
+ };
988
991
  raycaster.layers.enable(POSER_LAYER);
989
992
  el.addEventListener("pointerdown", onDown, true);
990
993
  el.addEventListener("pointermove", onHover);
994
+ window.addEventListener("keydown", onKey);
991
995
  return () => {
996
+ window.removeEventListener("keydown", onKey);
992
997
  el.removeEventListener("pointerdown", onDown, true);
993
998
  el.removeEventListener("pointermove", onHover);
994
999
  raycaster.layers.disable(POSER_LAYER);
@@ -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
+ }