cozyclay 3.0.0 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/THIRD_PARTY_NOTICES.md +10 -0
  3. package/bin/agent/agent-routes.mjs +3 -3
  4. package/bin/agent/agent-runner.mjs +5 -5
  5. package/bin/agent/pi-tools.mjs +12 -1
  6. package/bin/agent/session-store.mjs +2 -2
  7. package/bin/agent/studio-context-budget.mjs +99 -0
  8. package/bin/agent/studio-prompt.mjs +1 -1
  9. package/bin/agent/studio-tools.mjs +1 -1
  10. package/dist/app/index.html +4 -4
  11. package/dist/assets/analytics-DbKG0NgD.js +6 -0
  12. package/dist/assets/app-34i-iUy1.js +5088 -0
  13. package/dist/assets/compile-worker-DYpa2nvO.js +5 -0
  14. package/dist/assets/{first-shot-handoff-CC4xe4HM.js → first-shot-handoff-CrGZ3n_O.js} +1 -1
  15. package/dist/assets/helvetiker-bold.typeface-BUlyMz0-.js +13 -0
  16. package/dist/assets/{landing-DjHD8u7k.js → landing-mq5bnqFE.js} +1 -1
  17. package/dist/assets/scene-asset-sync-7J-w5oNh.js +20 -0
  18. package/dist/assets/workflow-dUhurYUc.js +179 -0
  19. package/dist/cozyclay-package.json +1 -1
  20. package/dist/index.html +3 -3
  21. package/dist/workflow/index.html +3 -3
  22. package/package.json +1 -1
  23. package/src/App.jsx +1 -1
  24. package/src/agent-progress.js +3 -2
  25. package/src/agent-turn-mark.js +33 -0
  26. package/src/app-context.js +7 -2
  27. package/src/command-bus.js +10 -11
  28. package/src/commands/elements/character.js +2 -1
  29. package/src/commands/elements/motion.js +7 -1
  30. package/src/commands/elements/scene.js +3 -0
  31. package/src/commands/elements/stage.js +2 -1
  32. package/src/commands/elements/take-read.js +31 -0
  33. package/src/commands/elements.js +16 -2
  34. package/src/commands/motion3.js +15 -6
  35. package/src/commands/objects.js +2 -2
  36. package/src/commands/shot.js +6 -2
  37. package/src/domains/motion/take-source.js +4 -2
  38. package/src/draft-ghost.js +3 -2
  39. package/src/motion/beat-board-model.js +1 -1
  40. package/src/motion/beat-board.jsx +2 -1
  41. package/src/motion3/compile-job.js +4 -0
  42. package/src/motion3/compiler/anchors.js +68 -4
  43. package/src/motion3/compiler/compile.js +5 -1
  44. package/src/motion3/compiler/grip.js +8 -2
  45. package/src/motion3/compiler/limbs.js +35 -8
  46. package/src/motion3/compiler/pose.js +17 -2
  47. package/src/motion3/compiler/socket-grip.js +107 -0
  48. package/src/motion3/compiler/validate-objects.js +3 -2
  49. package/src/motion3/compiler/validate-parts.js +9 -2
  50. package/src/motion3/compiler/validate.js +3 -3
  51. package/src/motion3/compiler/vocab.js +23 -16
  52. package/src/motion3/describe.js +5 -2
  53. package/src/motion3/docs.js +1 -1
  54. package/src/motion3/free-motion.js +54 -15
  55. package/src/motion3/object-keys.js +36 -5
  56. package/src/motion3/object-pose.js +39 -6
  57. package/src/motion3/people.js +82 -0
  58. package/src/motion3/qa/checks.js +6 -2
  59. package/src/motion3/qa/hints-contact.js +17 -1
  60. package/src/motion3/qa/index.js +6 -4
  61. package/src/motion3/qa/people-checks.js +37 -0
  62. package/src/motion3/qa/style.js +14 -4
  63. package/src/motion3/refs.js +10 -1
  64. package/src/motion3/scene.js +4 -3
  65. package/src/motion3/schema/fields.js +4 -4
  66. package/src/motion3/schema/topics.js +9 -6
  67. package/src/motion3/schema/types.js +11 -9
  68. package/src/motion3/settle.js +136 -0
  69. package/src/motion3/skills/basic.js +10 -3
  70. package/src/motion3/skills/flip.js +200 -0
  71. package/src/motion3/skills/impact.js +109 -0
  72. package/src/motion3/skills/index.js +3 -1
  73. package/src/motion3/skills/info.js +19 -4
  74. package/src/motion3/sockets.js +104 -0
  75. package/src/motion3/take-edit.js +1 -1
  76. package/src/motion3/take.js +6 -4
  77. package/src/object-catalog.jsx +3 -1
  78. package/src/object-grasp.js +38 -0
  79. package/src/object-look.js +2 -0
  80. package/src/object-travel.js +6 -3
  81. package/src/panels/ObjectLookPanel.jsx +24 -3
  82. package/src/props.jsx +5 -5
  83. package/src/qa-label.js +25 -0
  84. package/src/scene-objects.js +19 -11
  85. package/src/shell/details-selection.js +2 -1
  86. package/src/studio-actions.js +4 -0
  87. package/src/studio-agent-protocol.js +11 -12
  88. package/src/studio-app-binding.js +3 -3
  89. package/src/studio-elements.js +3 -1
  90. package/src/studio-error-codes.js +8 -1
  91. package/src/text/fonts/helvetiker-LICENSE.txt +13 -0
  92. package/src/text/fonts/helvetiker-bold.typeface.json +1 -0
  93. package/src/text/text-object.jsx +106 -0
  94. package/src/text/text-record.js +122 -0
  95. package/src/workflow/AgentProgressCard.jsx +6 -4
  96. package/tools/run-tests.mjs +2 -2
  97. package/dist/assets/analytics-C9sUnndd.js +0 -1
  98. package/dist/assets/app-CCh--1Hc.js +0 -5087
  99. package/dist/assets/compile-worker-CkxiD6lH.js +0 -5
  100. package/dist/assets/scene-asset-sync-Hx_FzdOH.js +0 -20
  101. package/dist/assets/workflow-C4yKH1p4.js +0 -179
@@ -0,0 +1,109 @@
1
+ // hitReaction: a body thrown by a hit (a car, a shove, a blow) and coming to rest on the floor, from the pose at f0.
2
+ // Deterministic physics on the root: launched along the push at `speed` (m/s) and up (from the speed), it flies under gravity, lands
3
+ // when the hips reach lying height, then slides to a stop on floor friction. The body turns to fall along the push
4
+ // (on its back when pushed from the front, "land"), the trunk and head whip on a damped spring against the shove,
5
+ // the arms flail and trail, the legs follow the hips (feet held off the floor) and the whole body lies still at the end.
6
+ import { add, apply, ry, rx } from "../compiler/math.js";
7
+ import { tiltFrame } from "../compiler/pose.js";
8
+ import { personJoint } from "../people.js";
9
+ import { skillError } from "./book.js";
10
+
11
+ // LIE: the root's height lying on the back or face down, the trunk's skin just on the floor (X/Y Bot)
12
+ const G = 9.81, FPS = 24, MU = 0.75, LIE = { back: 0.16, front: 0.11 }, TURN = 0.25, ONSET = 0.12, LIFT_OFF = 0.25;
13
+ /** Distance covered by a speed that builds up over ONSET s (a hit pushes for about a tenth of a second, not one frame). */
14
+ const ramp = t => t - ONSET * (1 - Math.exp(-t / ONSET));
15
+ /** A foot held off the floor eases onto it (softplus over w m) instead of being cut at it. */
16
+ const softFloor = (y, lie, w) => lie + w * Math.log1p(Math.exp((y - lie) / w));
17
+ /** The landing: the fall stops over CUSHION s at one deceleration (spread over frames, not one), the trunk sinking SINK m
18
+ * into its own give past lying height, and springs back to it over SETTLE s. */
19
+ const CUSHION = 3 / FPS, SINK = 0.03, SETTLE = 0.25;
20
+ const smooth = x => { const t = Math.min(1, Math.max(0, x)); return t * t * t * (t * (6 * t - 15) + 10); };
21
+ const wrap = d => ((d + 540) % 360) - 180;
22
+ const r3 = v => v.map(x => Math.round(x * 1000) / 1000);
23
+ const r1 = x => Math.round(x * 10) / 10;
24
+
25
+ /** Where the earlier keys have a vec3 channel at frame f, read straight between the keys around it (state holds the last key). */
26
+ function track(keys, ch, f) {
27
+ let a = null, b = null;
28
+ for (const k of keys) {
29
+ const v = k.set?.[ch];
30
+ if (!Array.isArray(v)) continue;
31
+ if (k.f <= f && (!a || k.f >= a.f)) a = { f: k.f, v };
32
+ if (k.f > f && (!b || k.f < b.f)) b = { f: k.f, v };
33
+ }
34
+ if (!a || !b) return a?.v;
35
+ const w = (f - a.f) / (b.f - a.f);
36
+ return a.v.map((x, i) => x + (b.v[i] - x) * w);
37
+ }
38
+
39
+ /** The push direction [x, z] (unit) from args.dir, or from args.from (object:<id> | person:<id>) toward the hips. */
40
+ function pushOf(ctx, root) {
41
+ const { args, scene } = ctx;
42
+ let d = args.dir;
43
+ if (!d && typeof args.from === "string") {
44
+ const [kind, id] = args.from.split(/:(.*)/s);
45
+ const at = kind === "object" ? scene?.objects?.[id]?.pos : kind === "person" ? personJoint(scene, id, "Hips", ctx.f0) : null;
46
+ if (!at) skillError(ctx.tag, `args.from "${args.from}" must be object:<id> of a scene body or person:<id> of a character with an installed take`);
47
+ d = [root[0] - at[0], root[2] - at[2]];
48
+ }
49
+ if (!Array.isArray(d) || d.length !== 2 || !(Math.hypot(d[0], d[1]) > 1e-6)) skillError(ctx.tag, "needs args.dir: [x, z] (the way the body is thrown) or args.from: object:<id> | person:<id> (what hits it)");
50
+ const n = Math.hypot(d[0], d[1]);
51
+ return [d[0] / n, d[1] / n];
52
+ }
53
+
54
+ export function hitReaction(ctx) {
55
+ const { sk, args, state } = ctx, I = sk.index, P = sk.restWorldPos;
56
+ if (!state.root) skillError(ctx.tag, "needs a pose keyed before f0 (standIdle, a walk, ...)");
57
+ // the hit takes the body where the earlier motion has it at f0, a foot in the air going on at its speed
58
+ const root0 = track(ctx.keys, "root", ctx.f0) ?? state.root.slice(), from = {};
59
+ for (const k of ["l", "r"]) {
60
+ const at = track(ctx.keys, `foot_${k}`, ctx.f0), was = track(ctx.keys, `foot_${k}`, ctx.f0 - 1);
61
+ if (at) from[k] = { at, v: was ? at.map((x, i) => x - was[i]) : [0, 0, 0] };
62
+ }
63
+ const yaw0 = state.yaw ?? 0, [dx, dz] = pushOf(ctx, root0);
64
+ const speed = args.speed ?? 4;
65
+ if (!(Number.isFinite(speed) && speed > 0)) skillError(ctx.tag, `args.speed must be a launch speed above 0 m/s, got ${JSON.stringify(args.speed)}`);
66
+ const up = Math.min(2.5, Math.max(0.8, 0.3 * speed));
67
+ // pushed against the way it faces (a hit from the front) it lands on its back; "land" overrides
68
+ const facing = apply(ry(yaw0), [0, 0, 1]), back = (args.land ?? (dx * facing[0] + dz * facing[2] <= 0 ? "back" : "front")) === "back";
69
+ const head = Math.atan2(dx, dz) * 180 / Math.PI, yawT = back ? head + 180 : head, turn = wrap(yawT - yaw0), pitchT = back ? -90 : 90;
70
+ const lie = back ? LIE.back : LIE.front, y0 = root0[1], fall = t => y0 + up * ramp(t) - 0.5 * G * t * t, vel = t => up * (1 - Math.exp(-t / ONSET)) - G * t;
71
+ // the cushion starts at t1 where the fall, slowed evenly to rest over CUSHION, ends just at lying height
72
+ let lo = 0, hi = (up + Math.sqrt(up * up + 2 * G * Math.max(0, y0 - lie))) / G + 1;
73
+ for (let i = 0; i < 60; i++) { const m = (lo + hi) / 2; if (fall(m) + vel(m) * CUSHION / 2 > lie - SINK || vel(m) > 0) lo = m; else hi = m; }
74
+ const t1 = lo, v1 = Math.min(0, vel(t1)), y1 = fall(t1), T = t1 + CUSHION, tSlide = speed / (MU * G), tRest = T + tSlide;
75
+ const height = t => t < t1 ? fall(t) : t < T ? y1 + v1 * (t - t1) - v1 / (2 * CUSHION) * (t - t1) ** 2 : lie - SINK * (1 - smooth((t - T) / SETTLE));
76
+ const ankle = P[I.LeftFoot][1], hipsY = P[I.Hips][1], sign = back ? 1 : -1, LEG = 0.9 * Math.hypot(...[0, 1, 2].map(i => P[I.LeftFoot][i] - P[I.Hips][i]));
77
+ const end = ctx.f0 + Math.ceil((tRest + 0.6) * FPS), keys = [];
78
+ for (let f = ctx.f0; f <= end; f++) {
79
+ const t = (f - ctx.f0) / FPS, flying = t < T, ts = Math.min(Math.max(0, t - T), tSlide);
80
+ const run = flying ? speed * ramp(t) : speed * ramp(T) + speed * ts - 0.5 * MU * G * ts * ts;
81
+ const y = height(t);
82
+ const root = [root0[0] + dx * run, y, root0[2] + dz * run];
83
+ const tilt = Math.max(0.35, 0.9 * T), yaw = yaw0 + turn * smooth(t / Math.max(TURN, tilt)), pitch = pitchT * smooth(t / tilt), R = (v => apply(ry(yaw), apply(rx(pitch), v)));
84
+ // the trunk and head whip against the shove (a damped spring), the arms flail and trail, all settling by the rest
85
+ const ease = smooth(t / ONSET), whip = ease * Math.exp(-t / 0.18) * Math.sin(2 * Math.PI * t / 0.55), flail = smooth(t / 0.3) * Math.exp(-t / 0.45) * Math.sin(2 * Math.PI * t / 0.6);
86
+ const lying = smooth((t - T) / 0.3);
87
+ // legs bend in the air and lie out: on the back a little bent, knees up, toes up; face down straight, toes down
88
+ const legY = -(hipsY - ankle) + 0.06 * (1 - lying) + (back ? 0.03 : 0.01) * lying, off = smooth(t / LIFT_OFF);
89
+ // face down the feet ride a little off the floor while the body slides, so they are not taken for planted feet
90
+ const floor = ankle + 0.01 + (!back && !flying && ts < tSlide ? 0.04 : 0);
91
+ // on the back the heels stay below the hips (a soft cap), so the legs slope down to the feet and the knees bend up
92
+ const foot = (k, x, z) => { const p = add(root, R([x, legY, z])), cap = root[1] - 0.05; if (back) p[1] = cap - 0.05 * Math.log1p(Math.exp((cap - p[1]) / 0.05)); p[1] = softFloor(p[1], floor, 0.03); const o = from[k]; if (!o) return p; const q = o.at.map((x, i) => x + o.v[i] * (f - ctx.f0) * (1 - off)); q[1] = Math.max(o.at[1], q[1]); return reach(p.map((v, i) => q[i] + (v - q[i]) * off)); };
93
+ // a foot left behind is drawn in within the leg's reach of the hips (a soft cap, in as the foot lifts), so the leg is pulled, not torn
94
+ const reach = p => { const d = p.map((v, i) => v - root[i]), n = Math.hypot(...d), m = LEG - 0.02 * Math.log1p(Math.exp((LEG - n) / 0.02)); return n > 1e-9 ? root.map((v, i) => v + d[i] * (1 + (m / n - 1) * off)) : p; };
95
+ // the knee points along the body's forward pitched with it, square to the leg (no flip lying down); a pole is read
96
+ // in the yaw frame, tilted past 60 degrees of pitch (pose.js tiltFrame), so that tilt (lean) is taken back out of
97
+ // the knee poles (and half of it, face down, out of the foot pitch: the toes rest on the floor, not in it)
98
+ const tf = tiltFrame(0, pitch, 0), back0 = tf ? apply(tf.R, [0, 1, 0]) : [0, 1, 0], lean = Math.atan2(back0[2], back0[1]) * 180 / Math.PI;
99
+ const knee = apply(rx(-lean), [0, -Math.sin(pitch * Math.PI / 180), Math.max(0.05, Math.cos(pitch * Math.PI / 180))]);
100
+ const set = { root: r3(root), yaw: r1(yaw), pitch: r1(pitch), roll: 0,
101
+ foot_l: r3(foot("l", 0.11, 0.04 + 0.1 * flail * off - (back ? 0.1 : 0) * lying)), foot_r: r3(foot("r", -0.11, 0.04 - 0.1 * flail * off - (back ? 0.1 : 0) * lying)), fyaw_l: r1(yaw), fyaw_r: r1(yaw), fpitch_l: r1(back ? -50 * lying : -0.5 * lean), fpitch_r: r1(back ? -50 * lying : -0.5 * lean),
102
+ knee_l: r3([0.05, knee[1], knee[2]]), knee_r: r3([-0.05, knee[1], knee[2]]),
103
+ spine_pitch: r1(sign * 22 * whip * Math.min(1, speed / 4)), spine_yaw: 0, spine_roll: r1(10 * flail * (1 - lying)), head_pitch: r1(sign * 30 * whip * Math.min(1, speed / 4) + (back ? -8 : 8) * lying), head_roll: r1(12 * lying),
104
+ arm_rest: r1(72 - 22 * lying), aswing_l: r1(sign * 45 * flail + 8 * lying), aswing_r: r1(-sign * 35 * flail - 8 * lying), abend_l: r1(25 + 30 * flail * flail), abend_r: r1(30 + 25 * flail * flail) };
105
+ if (f === ctx.f0) Object.assign(set, { armw_l: 0, armw_r: 0, fingers_l: { relaxed: 1 }, fingers_r: { relaxed: 1 } });
106
+ keys.push({ f, set });
107
+ }
108
+ return { keys, end };
109
+ }
@@ -10,8 +10,10 @@ import { SKILL_INFO } from "./info.js";
10
10
  import { standIdle, walkTo, reachGrip } from "./basic.js";
11
11
  import { sitInSeat, exitCar } from "./car.js";
12
12
  import { smokeCigarette } from "./smoke.js";
13
+ import { flip } from "./flip.js";
14
+ import { hitReaction } from "./impact.js";
13
15
 
14
- const SKILLS = { standIdle, sitInSeat, exitCar, walkTo, reachGrip, smokeCigarette };
16
+ const SKILLS = { standIdle, sitInSeat, exitCar, walkTo, reachGrip, smokeCigarette, flip, hitReaction };
15
17
  export const SKILL_NAMES = Object.keys(SKILLS);
16
18
  const bad = msg => { throw new Error(`MOTION_SPEC_INVALID: ${msg}`); };
17
19
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
@@ -34,10 +34,10 @@ export const SKILL_INFO = {
34
34
  },
35
35
  reachGrip: {
36
36
  args: { anchor: str("not ending in _l/_r"), side: side("the hand"), reach: int("frames to reach", { default: 20 }), hold: int("frames held", { default: 22 }),
37
- release: int("frames to let go", { default: 12 }), elbow: ref("vec3", "elbow pole"), fingers: { enum: FINGER_PRESETS, default: "grip" },
38
- define: { type: "object", description: "an anchor (topic anchors) named args.anchor" }, orient: { type: "boolean", description: "false: no grip turn" } },
39
- required: ["anchor", "side"], needs: "args.anchor in spec.anchors or args.define", frames: "reach + hold + release (54 by default)",
40
- adds: ["props.<anchor>Grip (fingers point)", "fits: fingers onto the anchor", "checks.contacts \"fingers on <anchor>\"", "checks.allow hand on the anchor's object",
37
+ release: int("frames to let go", { default: 12 }), elbow: ref("vec3", "pole"), fingers: { enum: FINGER_PRESETS, default: "grip" },
38
+ define: { type: "object", description: "defines anchor args.anchor" }, orient: { type: "boolean", description: "false: no grip turn" } },
39
+ required: ["anchor", "side"], needs: "args.anchor in anchors or define", frames: "reach + hold + release (54)",
40
+ adds: ["props.<anchor>Grip (fingers point)", "fits: fingers on the anchor", "checks.contacts \"fingers on <anchor>\"", "checks.allow hand on its object",
41
41
  "keys: w_<anchor>_<side>, fingers_<side>, armw_<side>"],
42
42
  example: { f0: 10, args: { anchor: "bar", side: "r", define: { kind: "world", p: [-0.2, 1.2, 0.33] } } },
43
43
  tune: { reach: 2, release: 2 },
@@ -53,6 +53,16 @@ export const SKILL_INFO = {
53
53
  adds: ["objects: door and handle hinges", "anchors handle, lthigh, rthigh", "props.grip", "fits, checks.contacts, checks.allow"],
54
54
  example: { f0: 0, args: { car: "Car" } },
55
55
  },
56
+ flip: {
57
+ args: { kind: { enum: ["back", "front", "cartwheel"], default: "back", description: "a standing back or front tuck, or a cartwheel" },
58
+ travel: num("back/front: m along the heading at landing (default back -0.25, front 0.4)"),
59
+ side: side("cartwheel: toward the character's l (default) or r"), heading: num("degrees (yaw); default the keyed yaw") },
60
+ needs: "a standing pose keyed before f0", frames: "back/front 58 (one turn, the hips 0.5 m up); cartwheel 68",
61
+ adds: ["keys every frame: back/front crouch, push-off, a ballistic tucked flight turning the whole body (pitch to -360 back, +360 front) and a landing that stands up",
62
+ "cartwheel: a side step, then the whole body rolls 360 like a wheel on the straddled legs and the arms overhead, the hands planted in turn",
63
+ "whole-body pitch and roll take full turns in any key (no range); past 60 deg the knee and elbow poles and the feet turn with the body"],
64
+ example: { f0: 10, args: { kind: "back" } },
65
+ },
56
66
  smokeCigarette: {
57
67
  args: { until: int("hold to this frame; default the last frame; under f0+264 the beats are paced to fit"),
58
68
  kit: { type: "object", properties: { cig: str("the cigarette kit's rootId (object.createKit)"), lighter: str("the lighter kit's rootId"), exhale: str("the exhale kit's rootId (or an effect): it follows the mouth") },
@@ -62,4 +72,9 @@ export const SKILL_INFO = {
62
72
  "checks contacts, orient, below, appear, mouthPoints"],
63
73
  example: { f0: 0, args: {} },
64
74
  },
75
+ hitReaction: {
76
+ args: { dir: xz("[x, z] the way the body is thrown"), from: str("or what hits it: object:<id> | person:<id> (thrown away from it)"),
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
+ needs: "a pose; dir or from", frames: "~40", adds: ["keys: thrown to rest"], example: { f0: 10, args: { dir: [0, -1], speed: 3 } },
79
+ },
65
80
  };
@@ -0,0 +1,104 @@
1
+ // Grasp sockets (lane socket): where and how a hand takes hold of an object, so the agent names the part and the
2
+ // grasp and the compiler turns the hand. The grasp is the object's (sessions 02263581, 38322b5d: the agent computed
3
+ // the wrist by hand and held a barbell 90 deg off, the bar through the palm; a rifle's second hand was measured off
4
+ // the first). A socket is, in its record's own axes (metres from the base pivot, as an object anchor's `local`):
5
+ // at the point the fingers close on
6
+ // axis the line that runs across the palm, the knuckle line along it (a bar, a handle, a grip); none: any
7
+ // from the side the hand comes from: the palm faces against it; none: the roll the arm holds most easily
8
+ // (compiler/socket-grip.js), or on a body with no axis the face nearest the point
9
+ // thumb the end of the axis the thumb takes; none: the end-state-comfort end (socketPalm)
10
+ // A record stores named sockets in `grasp` (src/object-grasp.js); every thin body (two half sizes under THIN m: a
11
+ // bar, a can, a handle) is one too, named by its id, through its centre along its long axis. An object anchor with
12
+ // `grasp` (power, pinch, hook, palm) takes the hand by a socket of its object: `socket` names it, else the one
13
+ // nearest the anchor's `local` point or, without one, nearest the shoulder of the hand, picked once per compile.
14
+ import { add, sub, scale, norm, cross, dot, len, apply, ry, T } from "./compiler/math.js";
15
+ import { partsOf } from "./object-pose.js";
16
+
17
+ const THIN = 0.045, AXES = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
18
+ const flat = (v, n) => sub(v, scale(n, dot(v, n)));
19
+
20
+ /** The long axis index of a thin body's half sizes, else -1. */
21
+ function thinAxis(half) {
22
+ if (!half) return -1;
23
+ const long = [0, 1, 2].reduce((a, i) => (half[i] > half[a] ? i : a), 0);
24
+ return [0, 1, 2].filter(i => i !== long).every(i => half[i] < THIN) ? long : -1;
25
+ }
26
+
27
+ /** The record's stored sockets: a body's in scene.objects, a group's (an Empty) in scene.others. */
28
+ const stored = (scene, id) => scene?.objects?.[id]?.grasp ?? scene?.others?.[id]?.grasp ?? null;
29
+
30
+ /**
31
+ * Every socket of body or group `id`: [{ name, owner, at, axis?, from?, thumb? }], each in its owner's axes. The record's
32
+ * own sockets by name; a part's as "<part>.<name>"; a thin part's derived one by the part id.
33
+ */
34
+ export function socketsOf(scene, id) {
35
+ const out = [], own = (owner, prefix) => Object.entries(stored(scene, owner) ?? {}).forEach(([name, s]) => out.push({ ...s, name: prefix + name, owner }));
36
+ own(id, "");
37
+ for (const part of partsOf(scene, id)) {
38
+ if (part !== id) own(part, `${part}.`);
39
+ const half = scene.objects[part].half, long = thinAxis(half);
40
+ if (long >= 0) out.push({ name: part, owner: part, at: [0, half[1], 0], axis: AXES[long] });
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /** The socket of `id` named `name`, or undefined. */
46
+ export const socketNamed = (scene, id, name) => socketsOf(scene, id).find(s => s.name === name);
47
+
48
+ /** A socket in world space on its owner's pose { pos, R }: { p, axis?, from?, thumb? }. */
49
+ export function socketWorld(socket, pose) {
50
+ const dir = v => (v ? norm(apply(pose.R, v)) : undefined);
51
+ return { p: add(pose.pos, apply(pose.R, socket.at ?? [0, 0, 0])), axis: dir(socket.axis), from: dir(socket.from), thumb: dir(socket.thumb) };
52
+ }
53
+
54
+ /** The outward normal of the face of the box (of boxes [{ pos, R, half }], base pivots) nearest world point p, or undefined. */
55
+ export function faceFrom(boxes, p) {
56
+ let best;
57
+ for (const b of boxes) {
58
+ const q = apply(T(b.R), sub(p, add(b.pos, apply(b.R, [0, b.half[1], 0])))), out = [0, 1, 2].map(i => Math.abs(q[i]) - b.half[i]);
59
+ const i = [0, 1, 2].reduce((m, j) => (out[j] > out[m] ? j : m), 0), gap = Math.max(...out);
60
+ if (!best || gap < best.gap) best = { gap, n: scale([b.R[0][i], b.R[1][i], b.R[2][i]], q[i] < 0 ? -1 : 1) };
61
+ }
62
+ return best?.n;
63
+ }
64
+
65
+ /** How far world point q is from a world socket: from its axis line through p, else from p. */
66
+ export function socketDistance(w, q) {
67
+ const v = sub(q, w.p);
68
+ return len(w.axis ? flat(v, w.axis) : v);
69
+ }
70
+
71
+ /**
72
+ * The palm frame (columns d, l, n of compiler/limbs.js palmAxes: d to the middle finger, l to the thumb side, n = d x
73
+ * l; the palm is -n on the right hand, +n on the left) a grasp of `type` takes on world socket w, the forearm along fwd,
74
+ * the body turned yawDeg. Along an axis (not a flat palm): with `from` the palm faces against it and the knuckle line
75
+ * runs along the axis; without, the fingers stay on the forearm's line across the axis (a power grip turns the forearm,
76
+ * not the wrist) and the palm closes on it. The thumb takes `thumb`'s end, else the end-state-comfort end (people take
77
+ * the grip that ends comfortable, Rosenbaum): up when the forearm is level, toward the midline when it points down or
78
+ * up. Without an axis the palm faces against `from` (anchorSocket gives a box face's), the fingers on the forearm's line.
79
+ */
80
+ export function socketPalm(side, w, type, fwd, yawDeg = 0) {
81
+ const right = side === "Right", sign = v => (right ? v : scale(v, -1));
82
+ // a side along the axis leaves no knuckle line across it: the palm then just faces against the side (below)
83
+ if (w.axis && type !== "palm" && !(w.from && len(flat(w.axis, w.from)) < 1e-3)) {
84
+ if (w.from) {
85
+ const n = sign(w.from), l0 = norm(flat(w.axis, n)), pick = l => (w.thumb ? dot(l, w.thumb) : dot(cross(l, n), fwd));
86
+ const l = pick(l0) >= pick(scale(l0, -1)) ? l0 : scale(l0, -1);
87
+ return cols(cross(l, n), l, n);
88
+ }
89
+ const d = across(fwd, w.axis), medial = apply(ry(yawDeg), [right ? 1 : -1, 0, 0]), vy = Math.abs(d[1]);
90
+ const want = w.thumb ?? flat(add(scale([0, 1, 0], 1 - vy), scale(medial, vy)), d), l0 = norm(flat(w.axis, d));
91
+ const l = dot(l0, want) >= 0 ? l0 : scale(l0, -1);
92
+ return cols(d, l, cross(d, l));
93
+ }
94
+ const n = sign(w.from ?? scale(fwd, -1)), d = across(fwd, n);
95
+ return cols(d, cross(n, d), n);
96
+ }
97
+
98
+ /** fwd made perpendicular to v (unit); any perpendicular when fwd runs along v. */
99
+ function across(fwd, v) {
100
+ const d = flat(fwd, v);
101
+ return norm(len(d) > 1e-3 ? d : flat(Math.abs(v[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0], v));
102
+ }
103
+
104
+ const cols = (a, b, c) => [[a[0], b[0], c[0]], [a[1], b[1], c[1]], [a[2], b[2], c[2]]];
@@ -148,7 +148,7 @@ export function renderTakeEdit(take) {
148
148
  if (track.pos) { next.pos = new Float32Array(D * 3); at.forEach(({ i0, i1, t }, f) => lerp(next.pos, f * 3, track.pos, i0 * 3, track.pos, i1 * 3, t, 3)); }
149
149
  if (track.quat) { next.quat = new Float32Array(D * 4); at.forEach(({ i0, i1, t }, f) => nlerp(next.quat, f * 4, track.quat, i0 * 4, track.quat, i1 * 4, t)); }
150
150
  if (track.lvl) { next.lvl = new Float32Array(D); at.forEach(({ i0, i1, t }, f) => lerp(next.lvl, f, track.lvl, i0, track.lvl, i1, t, 1)); }
151
- for (const key of ["vis", "hold"]) if (track[key]) { next[key] = new Uint8Array(D); at.forEach(({ near }, f) => { next[key][f] = track[key][near]; }); }
151
+ for (const key of ["vis", "hold", "world"]) if (track[key]) { next[key] = new Uint8Array(D); at.forEach(({ near }, f) => { next[key][f] = track[key][near]; }); }
152
152
  return [id, next];
153
153
  }));
154
154
  return { ...take, frameCount: D, frames: D, rot, hips, objects, edit, editRendered: true };
@@ -6,12 +6,13 @@
6
6
  // world space: one track per spec.objects key (a group: its root only). A switch entry (C15)
7
7
  // bakes only vis and lvl, no pos/quat: its record keeps its own place (object-travel.js). A follow
8
8
  // entry (C16) bakes pos/quat with hold on every frame: its track is world space throughout. A free entry
9
- // (C17) bakes pos/quat (and vis/lvl when it names them), world space, no hold.
9
+ // (C17) bakes pos/quat (and vis/lvl when it names them), world space, no hold. A carry that rides a moving body after
10
+ // its release (object-pose.js riderOf) marks those frames `world`: world space too, though no hand holds it.
10
11
  import { toLocal } from "./compiler/skeleton.js";
11
12
  import { sub, apply, ry, quatFromMat, mul } from "./compiler/math.js";
12
13
  import { isCarried, isFollow, isSwitch } from "./carried.js";
13
14
  import { isFree } from "./free-motion.js";
14
- import { rootPoseAt, spanOf } from "./object-pose.js";
15
+ import { rootPoseAt, spanOf, riderOf } from "./object-pose.js";
15
16
 
16
17
  const TAKE_SCHEMA = "cozyclay.take.v3";
17
18
 
@@ -36,7 +37,7 @@ export function bakeTake(sk, out, placement = { position: [0, 0, 0], yaw: 0 }, m
36
37
  const pos = moves ? new Float32Array(F * 3) : null, quat = moves ? new Float32Array(F * 4) : null;
37
38
  const named = rides || !moves || isFree(spec), vis = named && spec.visible ? new Uint8Array(F) : null, hold = rides ? new Uint8Array(F) : null;
38
39
  const lvl = named && spec.level ? new Float32Array(F) : null;
39
- const [grab, release] = carried ? spanOf(out, spec) : [0, 0];
40
+ const [grab, release] = carried ? spanOf(out, spec) : [0, 0], world = carried && riderOf(out, id) ? new Uint8Array(F) : null;
40
41
  for (let f = 0; f < F; f++) {
41
42
  if (vis) vis[f] = (out.baked?.[spec.visible]?.[f + f0] ?? 0) > 0.5 ? 1 : 0;
42
43
  if (lvl) lvl[f] = Math.max(0, Math.min(4, out.baked?.[spec.level]?.[f + f0] ?? 1));
@@ -45,8 +46,9 @@ export function bakeTake(sk, out, placement = { position: [0, 0, 0], yaw: 0 }, m
45
46
  if (!o) continue;
46
47
  pos.set(o.pos, f * 3); quat.set(quatFromMat(o.R), f * 4);
47
48
  if (hold) hold[f] = follows || (f + f0 >= grab && f + f0 <= release) ? 1 : 0;
49
+ if (world) world[f] = f + f0 > release ? 1 : 0;
48
50
  }
49
- objects[id] = { ...(moves ? { pos, quat } : {}), ...(vis ? { vis } : {}), ...(hold ? { hold } : {}), ...(lvl ? { lvl } : {}) };
51
+ objects[id] = { ...(moves ? { pos, quat } : {}), ...(vis ? { vis } : {}), ...(hold ? { hold } : {}), ...(world ? { world } : {}), ...(lvl ? { lvl } : {}) };
50
52
  }
51
53
  return { schema: TAKE_SCHEMA, fps: out.fps, frameCount: F, rig: sk.model, bones: sk.names.slice(), rot, hips, objects, placement, ...meta };
52
54
  }
@@ -7,6 +7,7 @@ const GROUP_LABELS_KO = {
7
7
  "Set pieces": "세트 소품",
8
8
  Lights: "조명",
9
9
  Effects: "효과",
10
+ Text: "텍스트",
10
11
  };
11
12
 
12
13
  const OBJECT_LABELS_KO = {
@@ -24,6 +25,7 @@ const OBJECT_LABELS_KO = {
24
25
  "Spot light": "스포트 조명",
25
26
  Light: "조명",
26
27
  Effect: "효과",
28
+ Text: "텍스트",
27
29
  };
28
30
 
29
31
  function displayObjectGroupName(name) {
@@ -73,7 +75,7 @@ export function CatalogueEntries({ onPick }) {
73
75
  <span className={`add-object-swatch ${entry.kind}`} style={{ background: entry.color }} aria-hidden="true" />
74
76
  <span>{displayObjectLabel(entry.label)}</span>
75
77
  {/* An empty has no size to quote: say what it is instead of "0 × 0 m". */}
76
- <small>{entry.light ? ko("practical light", "실제 조명") : entry.kind === EMPTY_KIND ? ko("node only", "노드 전용") : entry.kind === "effect" ? ko("smoke, fire, rain…", "연기, 불, 비…") : `${entry.footprint.width} × ${entry.footprint.depth} m`}</small>
78
+ <small>{entry.light ? ko("practical light", "실제 조명") : entry.kind === EMPTY_KIND ? ko("node only", "노드 전용") : entry.kind === "effect" ? ko("smoke, fire, rain…", "연기, 불, 비…") : entry.kind === "text" ? ko("words, signs, logos", "글자, 간판, 로고") : `${entry.footprint.width} × ${entry.footprint.depth} m`}</small>
77
79
  </button>
78
80
  ))}
79
81
  </div>
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Grasp sockets on a record (`grasp`): where and how a hand takes hold of it, in the record's own axes, so they move
3
+ * with it and save with it. { <name>: { at: [x, y, z] m from the base pivot, axis?: the line across the palm (a bar,
4
+ * a handle), from?: the side the hand comes from (the palm faces against it), thumb?: the axis end the thumb takes } }.
5
+ * A motion spec's object anchor with `grasp` takes a hand by one (src/motion3/sockets.js), and a thin body is a socket
6
+ * of its own without one. At most MAX sockets, names of letters, digits, _ and -.
7
+ */
8
+ const MAX = 8, NAME = /^[A-Za-z0-9_-]{1,32}$/, REACH = 10;
9
+
10
+ const vec = (v, limit) => (Array.isArray(v) && v.length === 3 && v.every(Number.isFinite) && v.every(x => Math.abs(x) <= limit) ? v.map(x => Math.round(x * 1e4) / 1e4) : null);
11
+ const dir = v => { const u = vec(v, 1e3), n = u && Math.hypot(...u); return n > 1e-6 ? u.map(x => Math.round((x / n) * 1e4) / 1e4) : null; };
12
+
13
+ /** The repaired sockets of a record, or null for none (invalid entries are dropped). */
14
+ function normalizeObjectGrasp(value) {
15
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
16
+ const out = {};
17
+ for (const [name, s] of Object.entries(value)) {
18
+ if (Object.keys(out).length >= MAX || !NAME.test(name) || !s || typeof s !== "object") continue;
19
+ const at = vec(s.at, REACH);
20
+ if (!at) continue;
21
+ out[name] = { at };
22
+ for (const k of ["axis", "from", "thumb"]) { const d = dir(s[k]); if (d) out[name][k] = d; }
23
+ }
24
+ return Object.keys(out).length ? out : null;
25
+ }
26
+
27
+ /** The grasp field of a stored record (normalizeSceneObject): kept when it has a socket. */
28
+ export function objectGraspFields(record) {
29
+ const grasp = normalizeObjectGrasp(record?.grasp);
30
+ return grasp ? { grasp } : {};
31
+ }
32
+
33
+ /** The grasp part of an object.update patch: the sockets replace the record's own; `grasp: null` removes them. Only a change is returned. */
34
+ export function objectGraspPatch(object, patch) {
35
+ if (patch?.grasp === undefined) return {};
36
+ const next = patch.grasp === null ? null : normalizeObjectGrasp(patch.grasp);
37
+ return JSON.stringify(next) === JSON.stringify(object.grasp ?? null) ? {} : { grasp: next };
38
+ }
@@ -194,3 +194,5 @@ export function withShadowCapWarnings(result, objects) {
194
194
  const warnings = shadowCapWarnings(objects, result?.affectedIds ?? []);
195
195
  return warnings.length ? { ...result, warnings: [...(result.warnings ?? []), ...warnings] } : result;
196
196
  }
197
+ // A record's grasp sockets ride the same normalize and patch paths (object-grasp.js).
198
+ export { objectGraspFields, objectGraspPatch } from "./object-grasp.js";
@@ -115,12 +115,15 @@ export function trackLevel(id, frame, take) {
115
115
  }
116
116
 
117
117
  /**
118
- * True while a compiled take holds the record in a hand (a grabbed cup): its track is in world
119
- * space then, so the carry from its own parents (the table it stood on) is cut, like `attach`.
118
+ * True while a compiled take holds the record in a hand (a grabbed cup), or the record rides a
119
+ * moving body after its release (`world`, take.js): its track is in world space then, so the
120
+ * carry from its own parents (the table it stood on) is cut, like `attach`.
120
121
  */
121
122
  function held(id, frame, take) {
122
123
  const track = take?.objectTracks?.get?.(id);
123
- return !!track?.hold && track.hold[trackFrame(track, frame)] === 1;
124
+ if (!track) return false;
125
+ const at = trackFrame(track, frame);
126
+ return track.hold?.[at] === 1 || track.world?.[at] === 1;
124
127
  }
125
128
 
126
129
  /** The record's own route sample (or compiled track) as a matrix, or null when it does not travel. */
@@ -5,11 +5,13 @@ import Foldout from "./Foldout.jsx";
5
5
  import { Field, Slider } from "../ui.jsx";
6
6
  import { ko } from "../locale.js";
7
7
  import { LIGHT_KIND, practicalShadows } from "../object-look.js";
8
+ import { TEXT_KIND } from "../text/text-record.js";
8
9
 
9
10
  /**
10
- * Light and glow for the selected record: a practical light's type, colour,
11
- * brightness, reach, cone, shadow and flicker, and any record's glow. A drag is
12
- * one open transaction (one undo); a switch is one atomic edit.
11
+ * Words, light and glow for the selected record: a text's words and letter
12
+ * size, a practical light's type, colour, brightness, reach, cone, shadow and
13
+ * flicker, and any record's glow. A drag or a typing run is one open
14
+ * transaction (one undo); a switch is one atomic edit.
13
15
  */
14
16
  export default function ObjectLookPanel() {
15
17
  const { selectedSceneObject: object, sceneObjects, beginSceneTransaction, endSceneTransaction } = useStudioShell();
@@ -35,6 +37,7 @@ export default function ObjectLookPanel() {
35
37
  if (!object) return null;
36
38
  const light = object.renderer === LIGHT_KIND ? object.light : null;
37
39
  const glow = object.glow ?? null;
40
+ const text = object.renderer === TEXT_KIND ? object.text : null;
38
41
  const capped = light?.shadow && practicalShadows(sceneObjects).capped.includes(object.id);
39
42
  const ends = { onPointerUp: commit, onPointerCancel: commit, onKeyUp: commit, onBlur: commit };
40
43
  const lightSlider = (field, label, min, max, step, unit = "") => (
@@ -42,6 +45,24 @@ export default function ObjectLookPanel() {
42
45
  );
43
46
  return (
44
47
  <>
48
+ {text && (
49
+ <Foldout title={ko("Text", "텍스트")}>
50
+ <Field label={ko("Words", "글자")}>
51
+ <textarea rows={2} value={text.value} onChange={(event) => drag("text.value", { text: { value: event.target.value } })} onBlur={commit} />
52
+ </Field>
53
+ <div {...ends}>
54
+ <Slider label={ko("Letter height", "글자 높이")} min={0.02} max={3} step={0.01} unit=" m" softMax value={text.size} onChange={(value) => drag("text.size", { text: { size: value } })} />
55
+ <Slider label={ko("Depth (0 = flat)", "두께 (0 = 평면)")} min={0} max={0.5} step={0.005} unit=" m" softMax value={text.depth} onChange={(value) => drag("text.depth", { text: { depth: value } })} />
56
+ <Field label={ko("Align", "정렬")}>
57
+ <select value={text.align} onChange={(event) => set({ text: { align: event.target.value } })}>
58
+ <option value="left">{ko("Left", "왼쪽")}</option>
59
+ <option value="center">{ko("Centre", "가운데")}</option>
60
+ <option value="right">{ko("Right", "오른쪽")}</option>
61
+ </select>
62
+ </Field>
63
+ </div>
64
+ </Foldout>
65
+ )}
45
66
  {light && (
46
67
  <Foldout title={ko("Light", "조명")}>
47
68
  <div {...ends}>
package/src/props.jsx CHANGED
@@ -16,6 +16,8 @@ import { sceneObjectCarryMatrixAt, sceneObjectTravelMatrixAt, trackShown, travel
16
16
  import * as THREE from "three";
17
17
  import { GIZMO_LAYER } from "./dualview.jsx";
18
18
  import { CUTOUT_KIND, MESH_KIND, EMPTY_KIND, EMPTY_MARKER_SIZE, SCULPT_KIND } from "./scene-objects.js";
19
+ import { TEXT_KIND } from "./text/text-record.js";
20
+ import { TextObject } from "./text/text-object.jsx";
19
21
  import { buildSculptGroup, disposeSculptGroup } from "./sculpt-geometry.js";
20
22
  import { subscribeToAssetTexture } from "./scene-asset-cache.js";
21
23
  import { ImportedMesh } from "./imported-mesh.jsx";
@@ -199,8 +201,7 @@ const EMPTY_AXES = [
199
201
  function EmptyMarker() {
200
202
  const half = EMPTY_MARKER_SIZE / 2;
201
203
  const axes = useMemo(() => {
202
- const positions = [];
203
- const colors = [];
204
+ const positions = [], colors = [];
204
205
  const color = new THREE.Color();
205
206
  for (const axis of EMPTY_AXES) {
206
207
  positions.push(-axis.to[0] * half, -axis.to[1] * half, -axis.to[2] * half, axis.to[0] * half, axis.to[1] * half, axis.to[2] * half);
@@ -213,9 +214,7 @@ function EmptyMarker() {
213
214
  return geometry;
214
215
  }, [half]);
215
216
  useEffect(() => () => axes.dispose(), [axes]);
216
- const onLayer = (node) => {
217
- if (node) node.layers.set(GIZMO_LAYER);
218
- };
217
+ const onLayer = (node) => node?.layers.set(GIZMO_LAYER);
219
218
  return (
220
219
  <group userData={{ emptyMarker: true }}>
221
220
  <lineSegments ref={onLayer} geometry={axes} renderOrder={997} frustumCulled={false}>
@@ -242,6 +241,7 @@ function SceneObjectContent({ object, depthRank = 0 }) {
242
241
  if (renderer === CUTOUT_KIND) return <Cutout object={object} />;
243
242
  if (renderer === MESH_KIND) return <ImportedMesh object={object} />;
244
243
  if (renderer === SCULPT_KIND) return <Sculpt object={object} depthRank={depthRank} />;
244
+ if (renderer === TEXT_KIND) return <TextObject object={object} />;
245
245
  if (renderer === "car") return <Car color={autoColor ?? color} autoColor={autoColor} />;
246
246
  if (renderer === "small-plane") return <SmallPlane autoColor={autoColor} />;
247
247
  if (renderer === "chair") return <Chair autoColor={autoColor} />;
@@ -0,0 +1,25 @@
1
+ // How a QA result reads to a person. QA records one issue per frame and groups them by kind and subject (qa/index.js),
2
+ // so one goal missed on 321 frames is 321 issues but one problem: the user saw "QA 549" for two long groups. People read
3
+ // problems (the groups); qa.total stays the frame-level count the agent, repair and search work with.
4
+ import { ko } from "./locale.js";
5
+
6
+ /** Problems in a QA result: its groups (qa.problems from runMotionQa; an older result counts its groups). A failed result
7
+ * with neither, or with no groups, falls back to its frame-level count, so it never reads as clean. */
8
+ export function qaProblems(qa) {
9
+ const n = qa?.problems ?? (Array.isArray(qa?.groups) ? qa.groups.length : 0);
10
+ return n || (qa?.ok ? 0 : (qa?.total ?? 0));
11
+ }
12
+
13
+ /** "QA clean" | "2 problems" ("QA 통과" | "문제 2개"). */
14
+ export function qaLabel(qa) {
15
+ const n = qaProblems(qa);
16
+ if (qa?.ok || !n) return ko("QA clean", "QA 통과");
17
+ return ko(`${n} problem${n === 1 ? "" : "s"}`, `문제 ${n}개`);
18
+ }
19
+
20
+ /** A receipt's QA phrase: "clean" | "2 problems (321 frame issues)". */
21
+ export function qaSay(qa) {
22
+ if (qa?.ok) return "clean";
23
+ const n = qaProblems(qa), total = qa?.total ?? 0;
24
+ return `${n} problem${n === 1 ? "" : "s"} (${total} frame issue${total === 1 ? "" : "s"})`;
25
+ }
@@ -26,7 +26,8 @@ import { GREY_BOX } from "./object-colors.js";
26
26
  import { isEffectivelyHidden } from "./object-hidden.js";
27
27
  import { objectSize } from "./object-size.js";
28
28
  import { EFFECT_ENTRY, effectFields } from "./effects/effect-record.js";
29
- import { LIGHT_KIND, LIGHT_MENU_ENTRIES, lightLibraryEntry, objectLookFields, objectLookPatch } from "./object-look.js";
29
+ import { TEXT_ENTRY, TEXT_KIND, textFields, textPatch } from "./text/text-record.js";
30
+ import { LIGHT_KIND, LIGHT_MENU_ENTRIES, lightLibraryEntry, objectLookFields, objectLookPatch, objectGraspFields, objectGraspPatch } from "./object-look.js";
30
31
  export { OBJECT_COLORS, normalizeObjectColor, rememberObjectColor, readStoredObjectColors, writeStoredObjectColors } from "./object-colors.js";
31
32
  export { isEffectivelyHidden };
32
33
 
@@ -175,10 +176,10 @@ export function isEmptyObject(object) {
175
176
  return object?.renderer === EMPTY_KIND || object?.renderer === LIGHT_KIND;
176
177
  }
177
178
 
178
- /** The catalogue grouped for the create menu: the library plus the empty, the
179
- * two practical lights and the effect emitter, which are creatable but have no
180
- * footprint to satisfy the library's contract. */
181
- export const OBJECT_MENU_ENTRIES = Object.freeze([EMPTY_ENTRY, ...OBJECT_LIBRARY, ...LIGHT_MENU_ENTRIES, EFFECT_ENTRY]);
179
+ /** The catalogue grouped for the create menu: the library plus the empty, a
180
+ * text, the two practical lights and the effect emitter, which are creatable
181
+ * but have no fixed footprint to satisfy the library's contract. */
182
+ export const OBJECT_MENU_ENTRIES = Object.freeze([EMPTY_ENTRY, ...OBJECT_LIBRARY, TEXT_ENTRY, ...LIGHT_MENU_ENTRIES, EFFECT_ENTRY]);
182
183
 
183
184
  /** Every kind that can exist in a scene: the catalogue you can create from,
184
185
  * plus the kinds that arrive by import and so are deliberately absent from the
@@ -190,6 +191,7 @@ function objectLibraryEntry(kind) {
190
191
  if (kind === SCULPT_KIND) return SCULPT_ENTRY;
191
192
  if (kind === EMPTY_KIND) return EMPTY_ENTRY;
192
193
  if (kind === EFFECT_ENTRY.kind) return EFFECT_ENTRY;
194
+ if (kind === TEXT_KIND) return TEXT_ENTRY;
193
195
  return OBJECT_LIBRARY.find((entry) => entry.kind === kind) ?? lightLibraryEntry(kind);
194
196
  }
195
197
 
@@ -300,8 +302,9 @@ export function sceneObjectIdFromHierarchy(hierarchyId) {
300
302
  * A fresh object of `kind`, named and identified uniquely against `existing`.
301
303
  * `placement` seeds the floor position (the caller drops it in front of the
302
304
  * camera); everything else starts neutral so the first drag is predictable.
305
+ * `text` seeds a text record's words (text-record.js); other kinds ignore it.
303
306
  */
304
- export function createSceneObject(kind, existing = [], placement = {}) {
307
+ export function createSceneObject(kind, existing = [], placement = {}, text = undefined) {
305
308
  // Cutouts and meshes come from an import, never from the catalogue:
306
309
  // without an asset id the record has nothing to draw.
307
310
  if (kind === CUTOUT_KIND || kind === MESH_KIND || kind === SCULPT_KIND) return null;
@@ -342,6 +345,8 @@ export function createSceneObject(kind, existing = [], placement = {}) {
342
345
  supportY: Number.isFinite(entry.supportY) ? entry.supportY : entry.height,
343
346
  ...objectLookFields({}, entry),
344
347
  ...effectFields({ renderer: kind }),
348
+ // A text's words and the box measured from them (src/text/text-record.js).
349
+ ...textFields({ renderer: kind, text }),
345
350
  };
346
351
  }
347
352
 
@@ -547,11 +552,12 @@ const TRANSFORM_LIMITS = {
547
552
  /** The patch keys updateSceneObject reads for this object; any other key is
548
553
  * silently ignored there, so callers that must not be silent check against this. */
549
554
  export function objectPatchFields(object) {
550
- const fields = [...Object.keys(TRANSFORM_LIMITS), "name", "color", "hidden", "opacity", "path", "glow"];
555
+ const fields = [...Object.keys(TRANSFORM_LIMITS), "name", "color", "hidden", "opacity", "path", "glow", "grasp"];
551
556
  if (object?.renderer === LIGHT_KIND) fields.push("light");
552
557
  if (object?.renderer === CUTOUT_KIND) fields.push("assetId", "sourceAssetId", "matteAssetId", "matteScale", "height", "aspect", "width", "stretch");
553
558
  if (object?.renderer === MESH_KIND) fields.push("clay", "assetId", "height");
554
559
  if (object?.renderer === SCULPT_KIND) fields.push("recipe");
560
+ if (object?.renderer === TEXT_KIND) fields.push("text");
555
561
  return fields;
556
562
  }
557
563
 
@@ -856,8 +862,8 @@ export function updateSceneObject(objects, id, patch) {
856
862
  Object.assign(update, sculptStandingBox(normalized.recipe));
857
863
  }
858
864
  }
859
- // Light and glow merge over the record's own and are repaired (object-look.js).
860
- Object.assign(update, objectLookPatch(object, patch));
865
+ // Light, glow and a text's words merge over the record's own and are repaired (object-look.js, text-record.js); grasp sockets replace (object-grasp.js).
866
+ Object.assign(update, objectLookPatch(object, patch), textPatch(object, patch), objectGraspPatch(object, patch));
861
867
  if (!Object.keys(update).length) return object;
862
868
  changed = true;
863
869
  return { ...object, ...update };
@@ -1086,10 +1092,12 @@ export function normalizeSceneObject(record) {
1086
1092
  height: Math.max(MESH_HEIGHT_MIN, pick(record.height, entry.height)),
1087
1093
  }
1088
1094
  : { footprint: { ...entry.footprint }, height: entry.height, supportY: Number.isFinite(entry.supportY) ? entry.supportY : entry.height }),
1089
- // A light's light, and a glow on any record (object-look.js).
1090
- ...objectLookFields(record, entry),
1095
+ // A light's light, and a glow on any record (object-look.js); grasp sockets (object-grasp.js).
1096
+ ...objectLookFields(record, entry), ...objectGraspFields(record),
1091
1097
  // An effect record's emitter (src/effects/effect-record.js).
1092
1098
  ...effectFields(record),
1099
+ // A text's words; its box is measured from them (src/text/text-record.js).
1100
+ ...textFields(record),
1093
1101
  };
1094
1102
  }
1095
1103