cozyclay 3.0.1 → 3.0.3

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 (83) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/THIRD_PARTY_NOTICES.md +10 -0
  3. package/bin/agent/pi-tools.mjs +12 -1
  4. package/bin/agent/studio-context-budget.mjs +8 -2
  5. package/bin/agent/studio-prompt.mjs +1 -1
  6. package/bin/agent/studio-tools.mjs +1 -1
  7. package/dist/app/index.html +4 -4
  8. package/dist/assets/analytics-BibyE5ar.js +6 -0
  9. package/dist/assets/app-DhRJ-PBz.js +5088 -0
  10. package/dist/assets/{compile-worker-BxKXh07s.js → compile-worker-DqiA4AaN.js} +3 -3
  11. package/dist/assets/{first-shot-handoff-DNO8kv_N.js → first-shot-handoff-Qx7SKl38.js} +1 -1
  12. package/dist/assets/helvetiker-bold.typeface-BUlyMz0-.js +13 -0
  13. package/dist/assets/{landing-yfZaJkWc.js → landing-DYg87MDY.js} +1 -1
  14. package/dist/assets/scene-asset-sync-DBYQBR0T.js +20 -0
  15. package/dist/assets/{workflow-D78eiKTa.js → workflow-BSA22Ofr.js} +1 -1
  16. package/dist/cozyclay-package.json +1 -1
  17. package/dist/index.html +3 -3
  18. package/dist/workflow/index.html +3 -3
  19. package/package.json +1 -1
  20. package/src/agent-turn-mark.js +33 -0
  21. package/src/bus-task-scopes.js +69 -0
  22. package/src/bus-task-targets.js +48 -0
  23. package/src/bus-tasks.js +175 -0
  24. package/src/command-bus.js +21 -47
  25. package/src/command-history.js +36 -0
  26. package/src/commands/cast.js +10 -3
  27. package/src/commands/elements/character.js +2 -1
  28. package/src/commands/elements/motion.js +7 -1
  29. package/src/commands/elements/scene.js +3 -0
  30. package/src/commands/elements/stage.js +2 -1
  31. package/src/commands/elements/take-read.js +31 -0
  32. package/src/commands/elements.js +16 -2
  33. package/src/commands/motion.js +2 -1
  34. package/src/commands/motion3.js +13 -5
  35. package/src/commands/objects.js +2 -2
  36. package/src/commands/shared.js +9 -0
  37. package/src/motion3/compiler/anchors.js +89 -5
  38. package/src/motion3/compiler/compile.js +25 -4
  39. package/src/motion3/compiler/grip.js +16 -4
  40. package/src/motion3/compiler/socket-grip.js +131 -0
  41. package/src/motion3/compiler/validate-parts.js +7 -2
  42. package/src/motion3/compiler/vocab.js +6 -0
  43. package/src/motion3/docs.js +1 -1
  44. package/src/motion3/fix/index.js +5 -3
  45. package/src/motion3/free-motion.js +12 -4
  46. package/src/motion3/object-keys.js +36 -5
  47. package/src/motion3/object-pose.js +38 -5
  48. package/src/motion3/pipeline.js +2 -0
  49. package/src/motion3/qa/spec-checks.js +12 -1
  50. package/src/motion3/scene.js +4 -3
  51. package/src/motion3/schema/fields.js +1 -1
  52. package/src/motion3/schema/topics.js +4 -3
  53. package/src/motion3/schema/types.js +3 -2
  54. package/src/motion3/skills/basic.js +68 -14
  55. package/src/motion3/skills/index.js +4 -2
  56. package/src/motion3/skills/info.js +5 -5
  57. package/src/motion3/skills/reach-human.js +139 -0
  58. package/src/motion3/skills/stance.js +92 -0
  59. package/src/motion3/sockets.js +121 -0
  60. package/src/motion3/take-edit.js +1 -1
  61. package/src/motion3/take.js +6 -4
  62. package/src/motion3/views.js +28 -5
  63. package/src/object-catalog.jsx +3 -1
  64. package/src/object-grasp.js +38 -0
  65. package/src/object-look.js +2 -0
  66. package/src/object-travel.js +6 -3
  67. package/src/panels/ObjectLookPanel.jsx +24 -3
  68. package/src/props.jsx +5 -5
  69. package/src/scene-objects.js +19 -11
  70. package/src/shell/details-selection.js +2 -1
  71. package/src/studio-agent-protocol.js +5 -6
  72. package/src/studio-app-binding.js +3 -3
  73. package/src/studio-elements.js +3 -1
  74. package/src/studio-error-codes.js +1 -1
  75. package/src/studio-motion-visual.js +4 -4
  76. package/src/text/fonts/helvetiker-LICENSE.txt +13 -0
  77. package/src/text/fonts/helvetiker-bold.typeface.json +1 -0
  78. package/src/text/text-object.jsx +106 -0
  79. package/src/text/text-record.js +122 -0
  80. package/tools/run-tests.mjs +1 -1
  81. package/dist/assets/analytics-BWCqdgWA.js +0 -1
  82. package/dist/assets/app-DfTCxaKk.js +0 -5087
  83. package/dist/assets/scene-asset-sync-U3kS2rvK.js +0 -20
@@ -0,0 +1,139 @@
1
+ // How a person's reach to grasp unfolds in time (reachGrip's keys), from the prehension literature:
2
+ // transport the wrist's speed is a single bell whose peak comes early, at about a third of the movement, and the
3
+ // longer deceleration (about two thirds) is the slow approach onto the object (time to peak velocity 33 %
4
+ // of movement time: Paulignan et al., Exp Brain Res 1991, doi:10.1007/BF00231858). A minimum-jerk bell
5
+ // (Flash & Hogan 1985) on time warped by t^WARP puts the peak at 0.5^(1/WARP) of the reach.
6
+ // grasp the hand stays open wider than the object through the transport and closes onto it in the slow
7
+ // approach: the maximum grip aperture comes at about two thirds to three quarters of the movement (Jeannerod,
8
+ // J Mot Behav 1984; Hu et al., Exp Brain Res 2005), and it scales with the object's size (Marteniuk et al.,
9
+ // Hum Mov Sci 1990), a couple of centimetres wider than the object (MARGIN). These rigs' relaxed hands are
10
+ // already open (thumb tip to index tip: Y Bot 11.5 cm, X Bot 9.7 cm) and there is no wider preset: a thing
11
+ // that fits that opening with the margin (a can, a cup) is reached with the hand held open to CLOSE of the
12
+ // reach, then closed on it; a wider one closes along the reach as before, or the open fingers would sweep
13
+ // into it on the way (a 10 cm box: 6 cm deep). So does a thing that sits more than OVER m in from the near
14
+ // edge of what it stands on: the open fingertips hang below the hand and dragged across the table top on the
15
+ // way (a cup 12 cm in, bench S8). CLOSE is 0.4, earlier than a person's: the hand comes straight in, and the
16
+ // open fingertips (about 8 cm past the finger middles) went into a cup wrapped from the side when they
17
+ // stayed open later (1.5 cm at 0.7); a hand that plans an arc around the thing is the next step.
18
+ // gaze the eyes go to the grasp point before the hand gets there and stay on it until the hand has it
19
+ // (Johansson et al., J Neurosci 2001;21:6917). The rigs have no eyes: the head turns HEAD_SHARE of the
20
+ // way, done by GAZE of the reach, held through the hold, back by the end of the release.
21
+ import { add, apply, len, sub } from "../compiler/math.js";
22
+ import { restState } from "../compiler/skeleton.js";
23
+ import { fingers } from "../compiler/limbs.js";
24
+ import { sdBox } from "../qa/geometry.js";
25
+
26
+ const WARP = 0.65, ARM_WARP = 0.75, HEAD_SHARE = 0.75, MARGIN = 0.025, KNEE = 0.1, OVER = 0.08, BELOW = 0.12;
27
+ export const CLOSE = 0.4, GAZE = 0.3;
28
+ const TRANSPORT = [0.15, 0.3, 0.45, 0.6, 0.7, 0.8, 0.9];
29
+ const minJerk = t => t * t * t * (10 - 15 * t + 6 * t * t);
30
+ /** The share of the way the hand has come at fraction t of the reach. */
31
+ // two phases: the fast transport covers NEAR of the way by SLOW of the time, the low-velocity approach the rest while the
32
+ // fingers close (about half the movement time is that slow approach after peak deceleration, Paulignan et al. 1991)
33
+ const NEAR = 0.9, SLOW = 0.7;
34
+ const transport = (t, warp) => {
35
+ const u = Math.min(1, Math.max(0, t));
36
+ return u < SLOW ? NEAR * minJerk(Math.pow(u / SLOW, warp)) : NEAR + (1 - NEAR) * minJerk((u - SLOW) / (1 - SLOW));
37
+ };
38
+ /**
39
+ * Inner keys of the transport for a reach of n frames: [frame from f0, the target's share, the arm's IK share]. The arm's
40
+ * IK weight follows a milder bell (ARM_WARP): the hand is the blend of the two, and the target's bell alone on an arm
41
+ * blending in at its plain ease peaked at mid-reach, while both at WARP pushed a cup reach past the QA's one-frame pop.
42
+ */
43
+ export const transportKeys = n => [...new Map(TRANSPORT.map(t => Math.round(t * n)).filter(f => f > 0 && f < n).map(f => [f, [transport(f / n, WARP), transport(f / n, ARM_WARP)]])).entries()].map(([f, [t, a]]) => [f, t, a]);
44
+
45
+ const DEG = 180 / Math.PI, clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
46
+ /**
47
+ * True when the reach to anchor a takes the early-peaked transport: a thing at rest (a world point or a scene object's;
48
+ * a reach to a body, a bone or a prop chases a moving point) above the knees (KNEE m over the rest knee). A person
49
+ * reaching lower bends or crouches, which these keys do not plan: the arm alone, sped up early, swept the thigh and
50
+ * shin on the way to a vending tray (QA 5 -> 7), so a low reach keeps the plain ease.
51
+ */
52
+ export function earlyTransport(ctx, a) {
53
+ const p = restPoint(ctx, a), I = ctx.sk.index;
54
+ return !!p && p[1] >= ctx.sk.restWorldPos[I.LeftLeg][1] + KNEE;
55
+ }
56
+
57
+ /** The world point an anchor rests at before the grab (world, or a scene object's point), or null for one that moves with a body. */
58
+ function restPoint(ctx, a) {
59
+ if (a?.kind === "world" && Array.isArray(a.p)) return a.p.map(Number); // a copy, as in bodyOf
60
+ const o = a?.kind === "object" ? ctx.scene?.objects?.[a.object] : null;
61
+ if (!o || [a.object, ...(o.ancestors ?? [])].some(id => movesBy(ctx, id))) return null;
62
+ return add(o.pos, apply(o.R, a.local ?? [0, o.half?.[1] ?? 0, 0]));
63
+ }
64
+
65
+ /**
66
+ * True when spec.objects moves record `id` before the reach is done (ctx.until): a follow or a hinge, or keys or a fall
67
+ * from before that frame (a carry whose keys start at a grab after the hand arrives rests until then).
68
+ */
69
+ function movesBy(ctx, id) {
70
+ const o = ctx.objects?.[id];
71
+ if (!o || typeof o !== "object") return false;
72
+ if (o.follow !== undefined || o.hinge !== undefined) return true;
73
+ const from = Math.min(...(o.keys ?? []).map(k => k.f).filter(Number.isFinite), o.fall?.at ?? Infinity);
74
+ return from < (ctx.until ?? Infinity);
75
+ }
76
+
77
+ const opened = new WeakMap();
78
+ /** The rig's relaxed hand opening, thumb tip to index tip (m). */
79
+ function openHand(sk) {
80
+ if (!opened.has(sk)) {
81
+ const s = restState(sk), I = sk.index;
82
+ fingers(sk, s, "Right", {});
83
+ opened.set(sk, len(sub(s.P[I.RightHandThumb4], s.P[I.RightHandIndex4])));
84
+ }
85
+ return opened.get(sk);
86
+ }
87
+
88
+ /** The scene body anchor a reaches for (its object, or the body its world point lies in), or null. */
89
+ function bodyOf(ctx, a) {
90
+ const objects = ctx.scene?.objects ?? {};
91
+ if (a?.kind === "object") return objects[a.object]?.half ? objects[a.object] : null;
92
+ if (a?.kind !== "world" || !Array.isArray(a.p)) return null;
93
+ // a copy: the spec's own (frozen) array passed into the shared vector math deoptimised it for the whole compile (2x)
94
+ const p = a.p.map(Number);
95
+ return Object.values(objects).filter(o => o.half && sdBox(p, o) <= 0.01).sort((x, y) => sdBox(p, x) - sdBox(p, y))[0] ?? null;
96
+ }
97
+
98
+ /** True when, OVER m short of point p toward the character (root r), the hand is still above a surface within BELOW m under p. */
99
+ function overSupport(ctx, p, r, skip) {
100
+ const dx = r[0] - p[0], dz = r[2] - p[2], d = Math.hypot(dx, dz);
101
+ if (d < 1e-6) return false;
102
+ const q = [p[0] + (dx / d) * OVER, p[1], p[2] + (dz / d) * OVER];
103
+ return Object.entries(ctx.scene?.objects ?? {}).some(([id, o]) => {
104
+ if (!o.half || skip.has(id) || skip.has(o.parent)) return false;
105
+ const top = sub(q, [0, BELOW, 0]);
106
+ return sdBox(q, o) > 0 && sdBox(top, o) <= 0;
107
+ });
108
+ }
109
+
110
+ /**
111
+ * True when the hand stays open on the way to the thing anchor a reaches for, from the pose at f0 (state): it fits the
112
+ * rig's open hand with MARGIN, and the hand's last OVER m to it are not over the surface it rests on.
113
+ */
114
+ export function holdsOpen(ctx, a, state) {
115
+ const o = bodyOf(ctx, a), p = restPoint(ctx, a) ?? (o && add(o.pos, apply(o.R, [0, o.half[1], 0])));
116
+ if (!o || !p || 2 * Math.min(...o.half) + MARGIN > openHand(ctx.sk)) return false;
117
+ const skip = new Set(Object.entries(ctx.scene.objects).filter(([, x]) => x === o).map(([id]) => id));
118
+ return !(Array.isArray(state.root) && overSupport(ctx, p, state.root, skip));
119
+ }
120
+
121
+ /**
122
+ * The head's yaw and pitch (deg, the head's own channels) that turn the gaze HEAD_SHARE of the way onto anchor a from the
123
+ * pose at f0 (state), or null when the anchor moves with a body or the pose has no root.
124
+ */
125
+ export function gazeAt(ctx, a, state) {
126
+ const p = restPoint(ctx, a), r = state.root;
127
+ if (!p || !Array.isArray(r)) return null;
128
+ const P = ctx.sk.restWorldPos, I = ctx.sk.index, eye = [r[0], r[1] + P[I.Head][1] - P[I.Hips][1], r[2]];
129
+ const d = [p[0] - eye[0], p[1] - eye[1], p[2] - eye[2]], flat = Math.hypot(d[0], d[2]);
130
+ if (flat < 0.05) return null;
131
+ let yaw = Math.atan2(d[0], d[2]) * DEG - (state.yaw ?? 0) - (state.spine_yaw ?? 0);
132
+ while (yaw > 180) yaw -= 360;
133
+ while (yaw < -180) yaw += 360;
134
+ const down = Math.atan2(-d[1], flat) * DEG - (state.pitch ?? 0) - (state.spine_pitch ?? 0);
135
+ return { head_yaw: clamp(HEAD_SHARE * yaw, -60, 60), head_pitch: clamp(HEAD_SHARE * down, -30, 50) };
136
+ }
137
+
138
+ /** True when keys before this skill already move the head within [a, b]: a keyed look wins. */
139
+ export const headKeyed = (keys, a, b) => keys.some(k => k.f >= a && k.f <= b && (k.set?.head_yaw !== undefined || k.set?.head_pitch !== undefined));
@@ -0,0 +1,92 @@
1
+ // Where a standing body stands (standIdle): clear of the scene's bodies, and within reach of what it reaches for.
2
+ // Session 7f15b4ae: every cup and ball attempt stood with a foot in the table (COLLIDE foot_r x cube over the whole
3
+ // take) and the arm at full reach, while QA's hint already said how far to move the body; the agent kept guessing
4
+ // `at` by hand. Two rules:
5
+ // clear a stance whose legs, feet or hips are inside a body (a table, a box on the floor) slides out of it, the
6
+ // least distance that clears it by MARGIN: straight back first, then sideways, then the diagonals
7
+ // reach standIdle with no `at` (and no root keyed) before a reachGrip in the same spec keeps its heading and steps
8
+ // up to the anchor: the reaching shoulder in line with it, as near as COMFORT of the arm's length allows,
9
+ // then back out of whatever is in the way (it stands at a table's edge)
10
+ // The body is the rest pose's legs, feet and hips as capsules (qa/geometry.js radii); a body whose top is under
11
+ // SUPPORT m (a floor, a mat, a low step) is stood on, not stood clear of.
12
+ import { add, sub, len, apply, ry } from "../compiler/math.js";
13
+ import { sdBox } from "../qa/geometry.js";
14
+ import { partsOf } from "../object-pose.js";
15
+
16
+ const MARGIN = 0.01, SUPPORT = 0.12, COMFORT = 0.8, SLIDE = 0.6, STEP = 0.01, NEAREST = 0.15, HANG = 0.62;
17
+ // [from, to, radius] of the standing body, as in qa/geometry.js CAPSULES (the foot: ankle to the toe tip)
18
+ const PARTS = ["Left", "Right"].flatMap(S => [[`${S}UpLeg`, `${S}Leg`, 0.07], [`${S}Leg`, `${S}Foot`, 0.05], [`${S}Foot`, `${S}ToeBase`, 0.045], [`${S}ToeBase`, `${S}Toe_End`, 0.04]])
19
+ .concat([["LeftUpLeg", "RightUpLeg", 0.095], ["Hips", "Spine2", 0.12]]);
20
+ const DIRS = [[0, -1], [1, 0], [-1, 0], [Math.SQRT1_2, -Math.SQRT1_2], [-Math.SQRT1_2, -Math.SQRT1_2]];
21
+
22
+ /** The standing body's sample points [p, r] at ground point c = [x, z] facing heading h (deg): legs, feet, hips and the arms hanging at the sides (standIdle's FK arms down). */
23
+ function bodyPoints(sk, c, h) {
24
+ const I = sk.index, P = sk.restWorldPos, hip = P[I.Hips], R = ry(h), at = p => add([c[0], 0, c[1]], apply(R, [p[0] - hip[0], p[1], p[2] - hip[2]]));
25
+ const seg = (A, B, r) => [0, 0.25, 0.5, 0.75, 1].map(t => [add(A, sub(B, A).map(v => v * t)), r]);
26
+ const arms = ["Left", "Right"].flatMap(S => { const sh = P[I[`${S}Arm`]], side = Math.sign(sh[0] - hip[0]) * 0.04; return seg(at(sh), at([sh[0] + side, sh[1] - HANG, sh[2]]), 0.045); });
27
+ return PARTS.filter(([a, b]) => I[a] !== undefined && I[b] !== undefined).flatMap(([a, b, r]) => seg(at(P[I[a]]), at(P[I[b]]), r)).concat(arms);
28
+ }
29
+
30
+ /** The bodies a stance must stay clear of: every scene body whose top is above SUPPORT m. */
31
+ const obstacles = scene => Object.values(scene?.objects ?? {}).filter(o => o.half && o.pos && o.R && o.pos[1] + 2 * o.half[1] * o.R[1][1] > SUPPORT);
32
+
33
+ /** How far the body at c, h is clear of the obstacles (m; negative: inside one by that much, past MARGIN). */
34
+ function clearance(sk, boxes, c, h) {
35
+ let worst = Infinity;
36
+ for (const [p, r] of bodyPoints(sk, c, h)) for (const b of boxes) worst = Math.min(worst, sdBox(p, b) - r - MARGIN);
37
+ return worst;
38
+ }
39
+
40
+ /** A stance at c ([x, z]) facing h moved the least distance out of the scene's bodies: { at, moved } or null when it is clear. */
41
+ export function clearStance(sk, scene, c, h) {
42
+ const boxes = obstacles(scene);
43
+ if (!boxes.length || clearance(sk, boxes, c, h) >= 0) return null;
44
+ const R = ry(h);
45
+ for (let d = STEP; d <= SLIDE + 1e-9; d += STEP) {
46
+ for (const [x, z] of DIRS) {
47
+ const v = apply(R, [x * d, 0, z * d]), at = [c[0] + v[0], c[1] + v[2]];
48
+ if (clearance(sk, boxes, at, h) >= 0) return { at: at.map(n => +n.toFixed(4)), moved: +d.toFixed(2) };
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * The world point an anchor definition names on the still scene, as anchors.js resolves it: world p; an object's (or a
56
+ * group's) `local` point, its base pivot without one; with `grasp` and no `local`, the centre of its parts (where a
57
+ * thin part's socket sits). Null for any other kind.
58
+ */
59
+ function anchorPoint(scene, a) {
60
+ if (a?.kind === "world" && Array.isArray(a.p)) return a.p;
61
+ if (a?.kind !== "object" || !scene) return null;
62
+ const o = scene.objects?.[a.object] ?? scene.groups?.[a.object];
63
+ if (!o) return null;
64
+ if (!a.grasp || a.local) return add(o.pos, apply(o.R, a.local ?? [0, 0, 0]));
65
+ const parts = partsOf(scene, a.object).map(id => scene.objects[id]).filter(p => p?.half);
66
+ const centres = parts.map(p => add(p.pos, apply(p.R, [0, p.half[1], 0])));
67
+ return centres.reduce((m, p) => add(m, p.map(v => v / centres.length)), [0, 0, 0]);
68
+ }
69
+
70
+ /**
71
+ * The stance for a standIdle with no `at`: the first reachGrip after it in `use` (index i) and its anchor. The body
72
+ * keeps its facing (args.heading, else the keyed yaw, else 0: the agent turns it by heading) and steps up to the
73
+ * anchor: the reaching shoulder lined up with it, COMFORT of the arm's length from it (at least NEAREST m ahead), then
74
+ * straight back out of any body in the way (a table: the body stands at its edge). { at, heading, anchor, moved } or
75
+ * null when no reachGrip follows or its anchor has no still point.
76
+ */
77
+ export function reachStance(ctx) {
78
+ const { sk, scene, use = [], index = -1 } = ctx, next = use.slice(index + 1).find(u => u?.skill === "reachGrip");
79
+ const a = next && (next.args?.define ?? ctx.anchors?.[next.args?.anchor]), T = anchorPoint(scene, a);
80
+ if (!T) return null;
81
+ const I = sk.index, P = sk.restWorldPos, S = next.args.side === "l" ? "Left" : "Right", hip = P[I.Hips], sh = P[I[`${S}Arm`]];
82
+ const arm = len(sub(P[I[`${S}ForeArm`]], sh)) + len(sub(P[I[`${S}Hand`]], P[I[`${S}ForeArm`]])) + len(sub(P[I[`${S}HandMiddle2`]], P[I[`${S}Hand`]]));
83
+ const dy = sh[1] - T[1], ahead = Math.max(NEAREST, Math.sqrt(Math.max(0, (COMFORT * arm) ** 2 - dy * dy)));
84
+ const h = ctx.args.heading ?? ctx.state?.yaw ?? 0, R = ry(h), boxes = obstacles(scene);
85
+ const off = apply(R, [sh[0] - hip[0], 0, sh[2] - hip[2] + ahead]), c0 = [T[0] - off[0], T[2] - off[2]];
86
+ let moved = 0, c = c0;
87
+ for (; moved <= SLIDE + 1e-9 && clearance(sk, boxes, c, h) < 0; moved += STEP) {
88
+ const back = apply(R, [0, 0, -(moved + STEP)]);
89
+ c = [c0[0] + back[0], c0[1] + back[2]];
90
+ }
91
+ return { at: c.map(n => +n.toFixed(4)), heading: h, anchor: next.args.anchor, moved: +moved.toFixed(2) };
92
+ }
@@ -0,0 +1,121 @@
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
+ // A disc (a cylinder whose height is under THIN m and whose two sides are wider: a steering wheel) is a rim socket
15
+ // instead: `rim` its radius, `normal` its axis; the hand takes the rim where it is nearest the anchor's `local` point
16
+ // or the hand's shoulder, the knuckle line along the rim (session 2ad2760a: the wheel was held by points in the
17
+ // driver's frame, 15 cm off the rim, and they did not follow the truck). anchorSocket fixes that point once per hand.
18
+ import { add, sub, scale, norm, cross, dot, len, apply, ry, T } from "./compiler/math.js";
19
+ import { partsOf } from "./object-pose.js";
20
+
21
+ const THIN = 0.045, AXES = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
22
+ const flat = (v, n) => sub(v, scale(n, dot(v, n)));
23
+
24
+ /** The long axis index of a thin body's half sizes, else -1. */
25
+ function thinAxis(half) {
26
+ if (!half) return -1;
27
+ const long = [0, 1, 2].reduce((a, i) => (half[i] > half[a] ? i : a), 0);
28
+ return [0, 1, 2].filter(i => i !== long).every(i => half[i] < THIN) ? long : -1;
29
+ }
30
+
31
+ /** A cylinder flat along its axis (its height under THIN m) and round across it, both sides over THIN m. */
32
+ const isDisc = (renderer, half) => renderer === "cylinder" && !!half && half[1] < THIN && Math.min(half[0], half[2]) >= THIN && Math.abs(half[0] - half[2]) < 0.1 * half[0];
33
+
34
+ /** The record's stored sockets: a body's in scene.objects, a group's (an Empty) in scene.others. */
35
+ const stored = (scene, id) => scene?.objects?.[id]?.grasp ?? scene?.others?.[id]?.grasp ?? null;
36
+
37
+ /**
38
+ * Every socket of body or group `id`: [{ name, owner, at, axis?, from?, thumb? }], each in its owner's axes. The record's
39
+ * own sockets by name; a part's as "<part>.<name>"; a thin part's derived one by the part id.
40
+ */
41
+ export function socketsOf(scene, id) {
42
+ const out = [], own = (owner, prefix) => Object.entries(stored(scene, owner) ?? {}).forEach(([name, s]) => out.push({ ...s, name: prefix + name, owner }));
43
+ own(id, "");
44
+ for (const part of partsOf(scene, id)) {
45
+ if (part !== id) own(part, `${part}.`);
46
+ const { half, renderer } = scene.objects[part], long = thinAxis(half);
47
+ // a thin body's radius: the thinner of the two half sizes across the axis (how far the palm sits off the axis)
48
+ if (long >= 0) out.push({ name: part, owner: part, at: [0, half[1], 0], axis: AXES[long], radius: Math.min(...half.filter((_, i) => i !== long)) });
49
+ else if (isDisc(renderer, half)) out.push({ name: part, owner: part, at: [0, half[1], 0], rim: half[0], normal: AXES[1] });
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /** The socket of `id` named `name`, or undefined. */
55
+ export const socketNamed = (scene, id, name) => socketsOf(scene, id).find(s => s.name === name);
56
+
57
+ /** A socket in world space on its owner's pose { pos, R }: { p, axis?, from?, thumb? }. */
58
+ export function socketWorld(socket, pose) {
59
+ const dir = v => (v ? norm(apply(pose.R, v)) : undefined);
60
+ 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), radius: socket.radius,
61
+ ...(socket.rim ? { rim: socket.rim, normal: dir(socket.normal) } : {}) };
62
+ }
63
+
64
+ /** The point of world rim socket w nearest world point q, and the rim's direction there: { p, axis }. */
65
+ export function rimPoint(w, q) {
66
+ const v = flat(sub(q, w.p), w.normal), r = len(v) > 1e-6 ? norm(v) : norm(flat(Math.abs(w.normal[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0], w.normal));
67
+ return { p: add(w.p, scale(r, w.rim)), axis: norm(cross(w.normal, r)) };
68
+ }
69
+
70
+ /** The outward normal of the face of the box (of boxes [{ pos, R, half }], base pivots) nearest world point p, or undefined. */
71
+ export function faceFrom(boxes, p) {
72
+ let best;
73
+ for (const b of boxes) {
74
+ 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]);
75
+ const i = [0, 1, 2].reduce((m, j) => (out[j] > out[m] ? j : m), 0), gap = Math.max(...out);
76
+ 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) };
77
+ }
78
+ return best?.n;
79
+ }
80
+
81
+ /** How far world point q is from a world socket: from its rim, else its axis line through p, else from p. */
82
+ export function socketDistance(w, q) {
83
+ const v = sub(q, w.p);
84
+ if (w.rim) return Math.hypot(dot(v, w.normal), len(flat(v, w.normal)) - w.rim);
85
+ return len(w.axis ? flat(v, w.axis) : v);
86
+ }
87
+
88
+ /**
89
+ * 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
90
+ * 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,
91
+ * the body turned yawDeg. Along an axis (not a flat palm): with `from` the palm faces against it and the knuckle line
92
+ * runs along the axis; without, the fingers stay on the forearm's line across the axis (a power grip turns the forearm,
93
+ * not the wrist) and the palm closes on it. The thumb takes `thumb`'s end, else the end-state-comfort end (people take
94
+ * the grip that ends comfortable, Rosenbaum): up when the forearm is level, toward the midline when it points down or
95
+ * up. Without an axis the palm faces against `from` (anchorSocket gives a box face's), the fingers on the forearm's line.
96
+ */
97
+ export function socketPalm(side, w, type, fwd, yawDeg = 0) {
98
+ const right = side === "Right", sign = v => (right ? v : scale(v, -1));
99
+ // a side along the axis leaves no knuckle line across it: the palm then just faces against the side (below)
100
+ if (w.axis && type !== "palm" && !(w.from && len(flat(w.axis, w.from)) < 1e-3)) {
101
+ if (w.from) {
102
+ const n = sign(w.from), l0 = norm(flat(w.axis, n)), pick = l => (w.thumb ? dot(l, w.thumb) : dot(cross(l, n), fwd));
103
+ const l = pick(l0) >= pick(scale(l0, -1)) ? l0 : scale(l0, -1);
104
+ return cols(cross(l, n), l, n);
105
+ }
106
+ const d = across(fwd, w.axis), medial = apply(ry(yawDeg), [right ? 1 : -1, 0, 0]), vy = Math.abs(d[1]);
107
+ const want = w.thumb ?? flat(add(scale([0, 1, 0], 1 - vy), scale(medial, vy)), d), l0 = norm(flat(w.axis, d));
108
+ const l = dot(l0, want) >= 0 ? l0 : scale(l0, -1);
109
+ return cols(d, l, cross(d, l));
110
+ }
111
+ const n = sign(w.from ?? scale(fwd, -1)), d = across(fwd, n);
112
+ return cols(d, cross(n, d), n);
113
+ }
114
+
115
+ /** fwd made perpendicular to v (unit); any perpendicular when fwd runs along v. */
116
+ function across(fwd, v) {
117
+ const d = flat(fwd, v);
118
+ return norm(len(d) > 1e-3 ? d : flat(Math.abs(v[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0], v));
119
+ }
120
+
121
+ 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
  }
@@ -1,6 +1,7 @@
1
1
  // C11 own views: the cameras of a verify_result contact_sheet with `views`. They are the
2
2
  // agent's own cameras around the subject, never the user's shot camera. A view stays at one
3
- // place for every frame of the sheet and frames what matters over those frames (the focus
3
+ // place for every frame of the sheet (or rides with the focus when it travels farther than its
4
+ // size) and frames what matters over those frames (the focus
4
5
  // refs, else the whole character, plus any trail), so a small action fills the tile and
5
6
  // motion reads as change inside one still frame. Pure over a compile result (compileSpec .out).
6
7
  // front in front of the character, 10 degrees up
@@ -78,7 +79,8 @@ function fit(name, points, back, upHint, aspect) {
78
79
  * Deterministic: the same compile, frames and arguments give the same cameras.
79
80
  */
80
81
  export function viewCameras(out, { views, frames, focus = [], trails = [], aspect = 1 }) {
81
- let points = frames.flatMap(f => focus.flatMap(ref => refPoints(out, f, ref)));
82
+ const ride = rideOf(out, frames, focus);
83
+ let points = frames.flatMap(f => focus.flatMap(ref => refPoints(out, f, ref)).map(p => (ride ? sub(p, ride[f]) : p)));
82
84
  const body = frames.flatMap(f => jointsAt(out, f));
83
85
  if (!points.length) points = body;
84
86
  points = [...points, ...trails.flatMap(t => t.points.map(q => q.p))];
@@ -86,12 +88,33 @@ export function viewCameras(out, { views, frames, focus = [], trails = [], aspec
86
88
  const { left, forward } = facingOf(out, frames), hips = mean(frames.map(f => jointsAt(out, f)[out.ctx.sk.index.Hips]).filter(Boolean));
87
89
  const side = focus.length && dot(sub(mean(points), hips), left) > 0.02 ? left : scale(left, -1);
88
90
  return views.map(name => {
89
- if (name === "top") return fit(name, points, UP, forward, aspect);
90
- const e = rad(ELEVATION[name]), level = name === "front" ? forward : name === "side" ? side : norm(add(forward, side));
91
- return fit(name, points, add(scale(level, Math.cos(e)), scale(UP, Math.sin(e))), UP, aspect);
91
+ const cam = name === "top" ? fit(name, points, UP, forward, aspect)
92
+ : (e => fit(name, points, add(scale(name === "front" ? forward : name === "side" ? side : norm(add(forward, side)), Math.cos(e)), scale(UP, Math.sin(e))), UP, aspect))(rad(ELEVATION[name]));
93
+ return ride ? { ...cam, ride } : cam;
92
94
  });
93
95
  }
94
96
 
97
+ /**
98
+ * The focus's travel over the frames: { [frame]: its centre there less its mean centre } when it moves farther than its
99
+ * own size (a wheel in a driving truck, session 2ad2760a: one camera for the whole drive framed the road and showed the
100
+ * hands too small to read), else null. A riding view keeps its place relative to the focus (cameraAt).
101
+ */
102
+ function rideOf(out, frames, focus) {
103
+ if (!focus.length) return null;
104
+ const at = frames.map(f => ({ f, points: focus.flatMap(ref => refPoints(out, f, ref)) })).filter(x => x.points.length);
105
+ if (at.length < 2) return null;
106
+ const centres = at.map(x => mean(x.points)), mid = mean(centres);
107
+ const size = Math.max(MIN_HALF, ...at.map((x, i) => Math.max(...x.points.map(p => Math.hypot(...sub(p, centres[i]))))));
108
+ if (Math.max(...centres.map(c => Math.hypot(...sub(c, mid)))) <= size) return null;
109
+ return Object.fromEntries(at.map((x, i) => [x.f, sub(centres[i], mid)]));
110
+ }
111
+
112
+ /** View camera `cam` at `frame`: a riding view moved by the focus's travel there (rideOf), else cam itself. */
113
+ export function cameraAt(cam, frame) {
114
+ const d = cam.ride?.[frame];
115
+ return d ? { ...cam, eye: add(cam.eye, d), target: add(cam.target, d) } : cam;
116
+ }
117
+
95
118
  // A picture hides distance along its own line of sight, so a sheet holds two views at least APART
96
119
  // degrees apart. Front and three-quarter (45 degrees apart) look along nearly one line: the
97
120
  // guitarist's right hand 20 cm in front of the guitar (session df388a9b) showed in neither.
@@ -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}>