diff --git a/ui/src/features/scene3d/Scene3DWorkspace.tsx b/ui/src/features/scene3d/Scene3DWorkspace.tsx index 2f0411798..ee18110b9 100644 --- a/ui/src/features/scene3d/Scene3DWorkspace.tsx +++ b/ui/src/features/scene3d/Scene3DWorkspace.tsx @@ -3,7 +3,7 @@ import { SceneFxControls } from '../sceneFx/SceneFxControls' import { SceneFxOverlay } from '../sceneFx/SceneFxOverlay' import { adoptPreparedSceneDocument, withFxShowcase } from '../sceneFx/showcase' import { WorldSfxControls } from '../sceneFx/WorldSfxControls' -import { worldSfxAudioCues, parseWorldSfx, worldAnchorOffsetFromWorldPoint, type WorldSfx } from '../sceneFx/world' +import { worldSfxAudioCues, parseWorldSfx, applyWorldSfxTranslate, type WorldSfx } from '../sceneFx/world' import { worldSfxDemoDocument } from '../sceneFx/worldDemo' import { WORLD_SFX_SELECT_PREFIX } from './transformGizmo' import { Scene3DMotionControls } from './Scene3DMotionControls' @@ -36,6 +36,7 @@ import { Scene3DInteraction } from './Scene3DInteraction' import type { TransformMode } from './transformGizmo' import { clipBindingError, resolveScene3DClip, retainSlotClipCatalogs } from './clips.ts' import { scene3dFrameCount, scene3dFrameTime, scene3dPlaybackSpeed } from './clock.ts' +import { slotPoseAtTime } from './performance.ts' import { parseScene3DDocument } from './document.ts' import { canMutateWorld3DScene } from './exportLock.ts' import { exportWorld3DDocument } from './exportFlow.ts' @@ -390,14 +391,10 @@ export function Scene3DWorkspace({ width, height, initialDocument }: Props) { const DEG = 180 / Math.PI return { ...current, worldSfx: parseWorldSfx((current.worldSfx ?? []).map(cue => { if (cue.id !== cueId) return cue - const next: WorldSfx = { ...cue } + let next: WorldSfx = { ...cue } if (patch.position) { const slot = cue.anchor?.slotId ? current.slots.find(item => item.id === cue.anchor!.slotId) : undefined - if (slot) { - next.anchor = { slotId: slot.id, offset: worldAnchorOffsetFromWorldPoint(slot, patch.position) } - } else { - next.position = { x: patch.position[0], y: patch.position[1], z: patch.position[2] } - } + next = applyWorldSfxTranslate(next, patch.position, slot ? slotPoseAtTime(slot, seconds, current.duration) : undefined, patch.anchorOffset) } if (patch.worldRotation) next.rotation = { x: patch.worldRotation[0] * DEG, y: patch.worldRotation[1] * DEG, z: patch.worldRotation[2] * DEG } if (patch.scale !== undefined) next.scale = patch.scale diff --git a/ui/src/features/scene3d/transformGizmo.ts b/ui/src/features/scene3d/transformGizmo.ts index b8f02585a..d386e7c5d 100644 --- a/ui/src/features/scene3d/transformGizmo.ts +++ b/ui/src/features/scene3d/transformGizmo.ts @@ -1,6 +1,6 @@ import { Object3D, Raycaster, Vector2 } from 'three' import { TransformControls } from 'three/addons/controls/TransformControls.js' -import { worldSfxIdFromObject } from '../sceneFx/worldRuntime' +import { worldAnchorOffsetFromSlotRoot, worldSfxIdFromObject } from '../sceneFx/worldRuntime' import type { GpuWorld } from './gpu' import type { Scene3DSlot } from './types' import type { WorldSfx } from '../sceneFx/world' @@ -8,6 +8,7 @@ import type { WorldSfx } from '../sceneFx/world' export type TransformMode = 'translate' | 'rotate' | 'scale' export type TransformPatch = Partial> & { worldRotation?: [number, number, number] + anchorOffset?: { x: number; y: number; z: number } } export const WORLD_SFX_SELECT_PREFIX = 'wsfx:' @@ -35,12 +36,20 @@ export function createTransformGizmo(world: GpuWorld, onChange: (id: string, pat let worldAxes = false let allowed = true let mode: TransformMode = 'translate' + let attachedAnchorSlotId: string | undefined const redraw = () => world.renderer.render(world.scene, world.camera) let uniformScale = 1 const objectChange = () => { if (!allowed || !selectedId) return const patch = transformPatch(proxy, mode, controls.axis, worldAxes) if (patch.scale !== undefined) uniformScale = patch.scale + if (attachedAnchorSlotId && patch.position) { + const root = world.slots.get(attachedAnchorSlotId)?.root + if (root) { + const local = worldAnchorOffsetFromSlotRoot(root, patch.position) + if ([local.x, local.y, local.z].every(Number.isFinite)) patch.anchorOffset = local + } + } onChange(selectedId, patch) } const finishDrag = () => { if (mode === 'scale') proxy.scale.setScalar(uniformScale) } @@ -74,6 +83,7 @@ export function createTransformGizmo(world: GpuWorld, onChange: (id: string, pat controls.enabled = enabled if ((!slot && !worldCue) || slot?.media === 'image' || !enabled) { controls.pointerUp(null); controls.detach(); return } worldAxes = Boolean(worldCue) + attachedAnchorSlotId = worldCue?.anchor?.slotId const id = worldCue ? WORLD_SFX_SELECT_PREFIX + worldCue.id : slot!.id if (selectedId !== id) controls.pointerUp(null) selectedId = id diff --git a/ui/src/features/sceneFx/world.ts b/ui/src/features/sceneFx/world.ts index a06baf3c7..fd572f5fa 100644 --- a/ui/src/features/sceneFx/world.ts +++ b/ui/src/features/sceneFx/world.ts @@ -51,6 +51,25 @@ export function worldAnchorOffsetFromWorldPoint( return { x: dx * cos - dz * sin, y: dy, z: dx * sin + dz * cos } } +/** Persist a gizmo drag in the same frame the cue is drawn (live pose / GPU local). */ +export function applyWorldSfxTranslate( + cue: WorldSfx, + worldPoint: readonly [number, number, number], + posedAnchor?: { position: readonly [number, number, number]; rotationY: number }, + localOffset?: WorldVec3, +): WorldSfx { + if (cue.anchor?.slotId && (localOffset || posedAnchor)) { + return { + ...cue, + anchor: { + slotId: cue.anchor.slotId, + offset: localOffset ?? worldAnchorOffsetFromWorldPoint(posedAnchor!, worldPoint), + }, + } + } + return { ...cue, position: { x: worldPoint[0], y: worldPoint[1], z: worldPoint[2] } } +} + export function worldVec3(raw: unknown, fallback: WorldVec3, min: number, max: number): WorldVec3 { const value = raw && typeof raw === 'object' ? raw as Record : {} return { diff --git a/ui/src/features/sceneFx/worldRuntime.ts b/ui/src/features/sceneFx/worldRuntime.ts index 3fe8c2b19..bc0a8ef8a 100644 --- a/ui/src/features/sceneFx/worldRuntime.ts +++ b/ui/src/features/sceneFx/worldRuntime.ts @@ -237,6 +237,15 @@ function build(kind: WorldSfx['kind'], color: string) { return root } +export function worldAnchorOffsetFromSlotRoot( + root: Object3D, + point: readonly [number, number, number], +): WorldVec3 { + root.updateMatrixWorld(true) + const local = root.worldToLocal(new Vector3(point[0], point[1], point[2])) + return { x: local.x, y: local.y, z: local.z } +} + function resolvePoint(anchor: WorldSfxAnchor | undefined, fallback: WorldVec3, slots: readonly WorldSlotPose[]): { point: Vector3; missing: boolean } { if (!anchor?.slotId) return { point: new Vector3(fallback.x, fallback.y, fallback.z), missing: false } const slot = slots.find(item => item.id === anchor.slotId) diff --git a/ui/tests/worldSfxRuntime.test.ts b/ui/tests/worldSfxRuntime.test.ts index ba11100a8..1c0e76371 100644 --- a/ui/tests/worldSfxRuntime.test.ts +++ b/ui/tests/worldSfxRuntime.test.ts @@ -1,8 +1,10 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { Object3D, Scene } from 'three' -import { parseWorldSfx, worldAnchorOffsetFromWorldPoint } from '../src/features/sceneFx/world' -import { syncWorldSfx } from '../src/features/sceneFx/worldRuntime' +import { Object3D, Scene, Vector3 } from 'three' +import { applyWorldSfxTranslate, parseWorldSfx, worldAnchorOffsetFromWorldPoint } from '../src/features/sceneFx/world' +import { syncWorldSfx, worldAnchorOffsetFromSlotRoot } from '../src/features/sceneFx/worldRuntime' +import { slotPoseAtTime } from '../src/features/scene3d/performance.ts' +import { worldSfxDuelDocument } from '../src/features/sceneFx/worldDemo' import { transformPatch } from '../src/features/scene3d/transformGizmo.ts' test('world SFX occupy the scene graph and hide outside their window', () => { @@ -78,6 +80,39 @@ test('gizmo offset for an anchored cue is the slot-local displacement', () => { assert.ok(Math.abs(offset.z) < 1e-6) }) +test('anchored gizmo writes use the live pose, not the rest slot', () => { + const duel = worldSfxDuelDocument() + const slot = duel.slots.find(item => item.id === 'subject_1') + const cue = duel.worldSfx?.find(item => item.id === 'duel-circle') + assert.ok(slot && cue) + const mid = slotPoseAtTime(slot, 5, duel.duration) + const worldPoint: [number, number, number] = [mid.position[0] + 0.15, mid.position[1] + 0.02, mid.position[2]] + const restOffset = worldAnchorOffsetFromWorldPoint(slot, worldPoint) + const live = applyWorldSfxTranslate(cue, worldPoint, mid) + assert.ok(Math.abs(restOffset.x - (live.anchor?.offset?.x ?? 0)) > 0.4, 'rest-pose inverse teleports the cue on a moving slot') + assert.ok(Math.abs(live.anchor?.offset?.x ?? 99) < 0.2) + assert.equal(Number((live.anchor?.offset?.y ?? 0).toFixed(2)), 0.02) +}) + +test('GPU local offset inverts a scaled moving slot root', () => { + const root = new Object3D() + root.position.set(-0.4, 0, 0.3) + root.rotation.y = 0.4 + root.scale.setScalar(0.35) + root.updateMatrixWorld(true) + const world: [number, number, number] = [-0.1, 0.9, 0.55] + const local = worldAnchorOffsetFromSlotRoot(root, world) + const cue = parseWorldSfx([{ + id: 'orb', kind: 'energy_orb', start: 0, end: 4, + anchor: { slotId: 'subject_1' }, + }])[0] + const next = applyWorldSfxTranslate(cue, world, undefined, local) + const back = root.localToWorld(new Vector3(next.anchor?.offset?.x ?? 0, next.anchor?.offset?.y ?? 0, next.anchor?.offset?.z ?? 0)) + assert.ok(Math.abs(back.x - world[0]) < 1e-6) + assert.ok(Math.abs(back.y - world[1]) < 1e-6) + assert.ok(Math.abs(back.z - world[2]) < 1e-6) +}) + test('world gizmo exposes XYZ rotation instead of yaw-only', () => { const proxy = new Object3D() proxy.rotation.set(0.2, 0.4, -0.1)