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
Binary file added ui/public/scene3d/cafe-back.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added ui/public/scene3d/cafe-facade.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added ui/public/scene3d/cafe-floor.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 15 additions & 1 deletion ui/src/features/scene3d/Scene3DStage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { TextureLoader } from 'three'
import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js'
import { loadCafeMaps } from './cafeSet.ts'
import { syncDressing } from './dressing.ts'
import {
applyLight,
Expand Down Expand Up @@ -161,7 +162,20 @@ export const Scene3DStage = forwardRef<Scene3DStageHandle, Props>(function Scene
useEffect(() => {
const world = worldRef.current
if (!world) return
syncDressing(world, document.dressing)
if (document.dressing !== 'cafe') {
world.dressingReady = true
syncDressing(world, document.dressing)
return
}
world.dressingReady = false
syncDressing(world, 'cafe')
let gone = false
void loadCafeMaps().then(maps => {
if (gone || worldRef.current !== world) return
syncDressing(world, 'cafe', maps)
world.dressingReady = true
})
return () => { gone = true }
}, [document.dressing])

useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion ui/src/features/scene3d/Scene3DWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { applyScene3DTemplate, patchScene3DSlot, SCENE3D_TEMPLATES, type Scene3D
import type { Scene3DCameraFamily, Scene3DClipCatalogEntry, Scene3DDocument, Scene3DLoop, Scene3DSlot } from './types.ts'
import { documentFromWorld3DRequest, listenForWorld3DWorkflow } from './world3dAgent.ts'

const FAMILIES = ['establishment', 'orbit', 'follow', 'pursuit', 'side', 'product', 'reveal', 'encounter', 'musical'] as const satisfies readonly Scene3DCameraFamily[]
const FAMILIES = ['establishment', 'orbit', 'follow', 'pursuit', 'side', 'front', 'product', 'reveal', 'encounter', 'musical'] as const satisfies readonly Scene3DCameraFamily[]

type Props = {
width: number
Expand Down
86 changes: 86 additions & 0 deletions ui/src/features/scene3d/cafeSet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
BoxGeometry,
ClampToEdgeWrapping,
Group,
LinearFilter,
Mesh,
MeshBasicMaterial,
MeshStandardMaterial,
PlaneGeometry,
RepeatWrapping,
SRGBColorSpace,
Texture,
TextureLoader,
type Object3D,
} from 'three'

export const CAFE_TEXTURE_URLS = {
facade: '/scene3d/cafe-facade.jpg',
floor: '/scene3d/cafe-floor.jpg',
back: '/scene3d/cafe-back.jpg',
} as const

export type CafeMaps = {
facade: Texture | null
floor: Texture | null
back: Texture | null
}

function prep(texture: Texture, tile: boolean) {
texture.colorSpace = SRGBColorSpace
texture.minFilter = LinearFilter
texture.magFilter = LinearFilter
texture.generateMipmaps = false
texture.wrapS = tile ? RepeatWrapping : ClampToEdgeWrapping
texture.wrapT = tile ? RepeatWrapping : ClampToEdgeWrapping
if (tile) texture.repeat.set(8, 8)
texture.needsUpdate = true
return texture
}

function loadOne(loader: TextureLoader, url: string, tile: boolean): Promise<Texture | null> {
return new Promise(resolve => {
loader.load(url, texture => resolve(prep(texture, tile)), undefined, () => resolve(null))
})
}

export function loadCafeMaps(): Promise<CafeMaps> {
const loader = new TextureLoader()
return Promise.all([
loadOne(loader, CAFE_TEXTURE_URLS.facade, false),
loadOne(loader, CAFE_TEXTURE_URLS.floor, true),
loadOne(loader, CAFE_TEXTURE_URLS.back, false),
]).then(([facade, floor, back]) => ({ facade, floor, back }))
}

export function cafeGroup(maps: CafeMaps): Object3D {
const root = new Group()
const plaster = new MeshStandardMaterial({ color: 0xc9b896, roughness: 0.9 })
const roof = new MeshStandardMaterial({ color: 0x4a4338, roughness: 0.86 })
const front = new MeshStandardMaterial({
map: maps.facade ?? undefined,
color: maps.facade ? 0xffffff : 0xd4c48a,
roughness: 0.68,
})
const building = new Mesh(new BoxGeometry(6.6, 4.1, 2.1), [plaster, plaster, roof, plaster, front, plaster])
building.position.set(0, 2.05, -5.35)
root.add(building)
const slab = new Mesh(
new PlaneGeometry(18, 18),
new MeshStandardMaterial({
map: maps.floor ?? undefined,
color: maps.floor ? 0xffffff : 0xc4b59a,
roughness: 0.92,
}),
)
slab.rotation.x = -Math.PI / 2
slab.position.y = 0.01
root.add(slab)
const backdrop = new Mesh(
new PlaneGeometry(24, 12),
new MeshBasicMaterial({ map: maps.back ?? undefined, color: maps.back ? 0xffffff : 0x3a3550 }),
)
backdrop.position.set(0, 5.1, -9.6)
root.add(backdrop)
return root
}
5 changes: 4 additions & 1 deletion ui/src/features/scene3d/camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function cameraLookAtTime(
duration: number,
slots: readonly Scene3DSlot[] = [],
): Vec3 {
if (camera.family === 'follow' || camera.family === 'pursuit' || camera.family === 'side') {
if (camera.family === 'follow' || camera.family === 'pursuit' || camera.family === 'side' || camera.family === 'front') {
return slotLook(slots, 'subject_1', camera.look)
}
if (camera.family === 'encounter') {
Expand Down Expand Up @@ -98,6 +98,9 @@ export function cameraEyeAtTime(
const musicalTurns = camera.family === 'musical' ? turns * 2 : turns
return orbitEye(look, radius, productHeight, s * musicalTurns * Math.PI * 2)
}
if (camera.family === 'front') {
return [look[0], look[1] - 0.08, look[2] + 4.05]
}
if (camera.family === 'side') {
return [look[0], look[1] - 0.18, look[2] + 3.55]
}
Expand Down
2 changes: 1 addition & 1 deletion ui/src/features/scene3d/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function parseScene3DDocument(raw: unknown): Scene3DDocument | null {
&& (SCENE3D_TEMPLATE_IDS as readonly string[]).includes(value.templateId)
? value.templateId as Scene3DTemplateId
: 'two-shot'
const dressing = value.dressing === 'street' || value.dressing === 'space' || value.dressing === 'treadmill'
const dressing = value.dressing === 'street' || value.dressing === 'space' || value.dressing === 'treadmill' || value.dressing === 'cafe'
? value.dressing
: undefined
return { ...value, slots, templateId, dressing } as Scene3DDocument
Expand Down
6 changes: 4 additions & 2 deletions ui/src/features/scene3d/dressing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
MeshStandardMaterial,
type Object3D,
} from 'three'
import { cafeGroup, type CafeMaps } from './cafeSet.ts'
import type { GpuWorld } from './gpu.ts'
import type { Scene3DDressing } from './types.ts'

Expand Down Expand Up @@ -54,10 +55,11 @@ function spaceGroup(): Object3D {
return root
}

export function syncDressing(world: GpuWorld, kind: Scene3DDressing | undefined) {
export function syncDressing(world: GpuWorld, kind: Scene3DDressing | undefined, maps?: CafeMaps) {
dropDressing(world)
world.floor.visible = kind !== 'space' && kind !== 'treadmill'
world.floor.visible = kind !== 'space' && kind !== 'treadmill' && kind !== 'cafe'
if (kind === 'street') world.dressing = streetGroup()
if (kind === 'space') world.dressing = spaceGroup()
if (kind === 'cafe') world.dressing = cafeGroup(maps ?? { facade: null, floor: null, back: null })
if (world.dressing) world.scene.add(world.dressing)
}
4 changes: 3 additions & 1 deletion ui/src/features/scene3d/gpu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type GpuWorld = {
dir: DirectionalLight
floor: Mesh
dressing: Object3D | null
dressingReady: boolean
slots: Map<string, SlotGpu>
}

Expand Down Expand Up @@ -242,6 +243,7 @@ export function placeSlot(
}

export function worldAssetsReady(world: GpuWorld, slots: readonly Scene3DSlot[]): boolean {
if (!world.dressingReady) return false
return slots.every(slot => {
if (!slot.sourceUrl) return true
const gpu = world.slots.get(slot.id)
Expand Down Expand Up @@ -332,7 +334,7 @@ export function createWorld(host: HTMLDivElement, light: Scene3DLight, fov: numb
)
floor.rotation.x = -Math.PI / 2
scene.add(floor)
return { renderer, scene, camera, dir, floor, dressing: null, slots: new Map() }
return { renderer, scene, camera, dir, floor, dressing: null, dressingReady: true, slots: new Map() }
}

export function disposeWorld(world: GpuWorld) {
Expand Down
6 changes: 6 additions & 0 deletions ui/src/features/scene3d/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const SCENE3D_TEMPLATES: readonly Scene3DTemplate[] = [
{ id: 'walk-void', camera: 'establishment', duration: 6, slots: ['subject_1', 'background'] },
{ id: 'dance-orbit', camera: 'orbit', duration: 6, slots: ['subject_1', 'background'] },
{ id: 'dance-stage', camera: 'musical', duration: 8, slots: ['subject_1', 'background'] },
{ id: 'cafe-dance', camera: 'front', duration: 8, slots: ['subject_1'] },
]

const LAYOUTS: Record<Scene3DTemplateId, Partial<Record<Scene3DSlotId, Pick<Scene3DSlot, 'position' | 'rotationY' | 'scale'>>>> = {
Expand Down Expand Up @@ -86,6 +87,9 @@ const LAYOUTS: Record<Scene3DTemplateId, Partial<Record<Scene3DSlotId, Pick<Scen
subject_1: { position: [0, 0, 0], rotationY: 0.2, scale: 1 },
background: { position: [0, 0, 0], rotationY: 0, scale: 1 },
},
'cafe-dance': {
subject_1: { position: [0, 0, 0.35], rotationY: 0, scale: 1 },
},
}

function emptySlot(id: Scene3DSlotId): Scene3DSlot {
Expand All @@ -111,6 +115,7 @@ export function applyScene3DTemplate(id: Scene3DTemplateId): Scene3DDocument {
...document.camera,
family: template.camera,
...(template.camera === 'side' ? { fov: 38 } : {}),
...(template.camera === 'front' ? { fov: 42 } : {}),
}
document.slots = template.slots.map(slotId => {
const slot = emptySlot(slotId)
Expand Down Expand Up @@ -142,6 +147,7 @@ const DRESSING_BY_TEMPLATE: Partial<Record<Scene3DTemplateId, Scene3DDocument['d
'block-street': 'treadmill',
'space-float': 'space',
'dance-stage': 'street',
'cafe-dance': 'cafe',
}

export function patchScene3DSlot(
Expand Down
4 changes: 3 additions & 1 deletion ui/src/features/scene3d/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type Scene3DCameraFamily =
| 'product'
| 'musical'
| 'side'
| 'front'

export type Scene3DSlotId = 'subject_1' | 'subject_2' | 'background' | 'prop'

Expand All @@ -28,6 +29,7 @@ export const SCENE3D_TEMPLATE_IDS = [
'walk-void',
'dance-orbit',
'dance-stage',
'cafe-dance',
] as const

export type Scene3DTemplateId = (typeof SCENE3D_TEMPLATE_IDS)[number]
Expand All @@ -44,7 +46,7 @@ export type Scene3DLoop = {
speed: number
}

export type Scene3DDressing = 'none' | 'street' | 'space' | 'treadmill'
export type Scene3DDressing = 'none' | 'street' | 'space' | 'treadmill' | 'cafe'

export type Scene3DSlot = {
id: string
Expand Down
4 changes: 3 additions & 1 deletion ui/src/i18n/locales/en/scene3d.json
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,7 @@
"encounter": "Two-shot / OTS",
"pursuit": "Pursuit / run",
"side": "Side profile",
"front": "Front set",
"musical": "Musical orbit"
},
"template": {
Expand All @@ -966,7 +967,8 @@
"space-float": "Space float",
"walk-void": "Walk in the void",
"dance-orbit": "Dance orbit",
"dance-stage": "Dance stage"
"dance-stage": "Dance stage",
"cafe-dance": "Cafe dance"
}
},
"agent": {
Expand Down
4 changes: 3 additions & 1 deletion ui/src/i18n/locales/es/scene3d.json
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,7 @@
"encounter": "Plano de dos / OTS",
"pursuit": "Persecución / carrera",
"side": "Perfil de lado",
"front": "Set de frente",
"musical": "Órbita musical"
},
"template": {
Expand All @@ -966,7 +967,8 @@
"space-float": "Flotar en el espacio",
"walk-void": "Caminar en el vacío",
"dance-orbit": "Órbita de baile",
"dance-stage": "Escenario de baile"
"dance-stage": "Escenario de baile",
"cafe-dance": "Baile en la cafetería"
}
},
"agent": {
Expand Down
17 changes: 17 additions & 0 deletions ui/tests/scene3dStage.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,23 @@ test('music-video templates keep clips unbound and dress the world', () => {
assert.equal(dance.slots[0].clip, null)
})

test('cafe-dance is a front set with a textured building, not a cylinder', () => {
const document = applyScene3DTemplate('cafe-dance')
assert.equal(document.templateId, 'cafe-dance')
assert.equal(document.dressing, 'cafe')
assert.equal(document.camera.family, 'front')
assert.equal(document.camera.fov, 42)
assert.equal(document.slots[0].clip, null)
assert.equal(document.slots[0].rotationY, 0)
assert.equal(document.slots.some(slot => slot.slot === 'background'), false)
const look = cameraLookAtTime(document.camera, 1, document.duration, document.slots)
const eye = cameraEyeAtTime(document.camera, 1, document.duration, document.slots)
assert.ok(eye[2] > look[2])
assert.ok(Math.abs(eye[1] - look[1]) < 0.2)
const restored = parseScene3DDocument(JSON.parse(JSON.stringify(document)))
assert.equal(restored?.dressing, 'cafe')
})

test('world3d export plan is independent of compositor layers', () => {
assert.equal(evenDim(1281), 1280)
assert.deepEqual(world3dExportSize(1920, 1080), { width: 1280, height: 720 })
Expand Down
Loading