Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions ui/src/features/scene3d/Scene3DWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion ui/src/features/scene3d/transformGizmo.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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'

export type TransformMode = 'translate' | 'rotate' | 'scale'
export type TransformPatch = Partial<Pick<Scene3DSlot, 'position' | 'rotationY' | 'scale'>> & {
worldRotation?: [number, number, number]
anchorOffset?: { x: number; y: number; z: number }
}

export const WORLD_SFX_SELECT_PREFIX = 'wsfx:'
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions ui/src/features/sceneFx/world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> : {}
return {
Expand Down
9 changes: 9 additions & 0 deletions ui/src/features/sceneFx/worldRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 38 additions & 3 deletions ui/tests/worldSfxRuntime.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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)
Expand Down
Loading