diff --git a/.changeset/big-objects-arrive.md b/.changeset/big-objects-arrive.md new file mode 100644 index 0000000..48083b9 --- /dev/null +++ b/.changeset/big-objects-arrive.md @@ -0,0 +1,5 @@ +--- +'@vosjs/core': minor +--- + +Declarative world-space objects: `objects?: ObjectConfig[]` in VosConfigJson — engine-managed 3D props in the main scene (parametric primitives or GLB models by URL, bbox-normalized so `transform.scale` is asset-independent), addressable by id like elements. The editor bridge (protocol 3) gains `SET_OBJECT_PROPS` (ephemeral prop overrides for gesture-time preview) and `OBJECT_HIT_TEST` (a main-camera raycast returning the nearest object id). GLTF objects auto-detect the GLTFLoader addon. Fully additive — configs without `objects` compile byte-identically. diff --git a/packages/core/src/__tests__/editing.test.ts b/packages/core/src/__tests__/editing.test.ts index 41b3a90..28ef0ac 100644 --- a/packages/core/src/__tests__/editing.test.ts +++ b/packages/core/src/__tests__/editing.test.ts @@ -123,7 +123,7 @@ describe('playback bridge v2', () => { const html = generateRenderTemplate('', { mode: 'playback' }) it('advertises the protocol version and editor flag in BRIDGE_READY', () => { - expect(VOS_BRIDGE_PROTOCOL).toBe(2) + expect(VOS_BRIDGE_PROTOCOL).toBe(3) expect(html).toContain( `{ type: 'BRIDGE_READY', protocol: ${VOS_BRIDGE_PROTOCOL}, editor: false }`, ) diff --git a/packages/core/src/__tests__/objects.test.ts b/packages/core/src/__tests__/objects.test.ts new file mode 100644 index 0000000..a605924 --- /dev/null +++ b/packages/core/src/__tests__/objects.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { compileVosConfig } from '../compiler/compileVosConfig' +import { generateRenderTemplate } from '../runtime/renderTemplate' +import type { VosConfigJson } from '../types/vosConfigJson' + +// Declarative world-space objects: schema acceptance, codegen, GLTFLoader +// auto-detection, context/result exposure, and the protocol-3 bridge surface. + +const base: VosConfigJson = { + version: 2, + duration: 4, + camera: { preset: 'perspective', fov: 30 }, + createContent: '(ctx) => ({ objects: [], refs: {} })', + createTimeline: + "(ctx, content, duration) => { const tl = ctx.gsap.timeline(); tl.to({}, { duration, ease: 'none' }); return tl; }", +} + +const withObjects = (objects: Record[]): VosConfigJson => ({ + ...base, + objects, +}) + +describe('objects[] codegen', () => { + it('compiles without objects — empty map + no-op sync (back-compat)', () => { + const code = compileVosConfig(base) + expect(code).toContain('const objects = new Map()') + expect(code).toContain('const __syncObjects = () => {}') + expect(code).not.toContain('objectsConfig') + }) + + it('builds primitives from config and exposes objects on the context', () => { + const code = compileVosConfig( + withObjects([ + { + id: 'p0', + asset: { kind: 'primitive', shape: 'knot', color: '#ffb03a' }, + transform: { x: 1, y: 0.5, z: -2, ry: 40, scale: 0.6 }, + }, + ]), + ) + expect(code).toContain('objectsConfig') + expect(code).toContain('TorusKnotGeometry') + expect(code).toContain('MeshStandardMaterial') + expect(code).toContain('__syncObjects()') + // exposed to createContent/onFrame and on the result for the bridge + expect(code).toMatch(/objects,\s*\n\s*get data\(\)/) + expect(code).toContain('__syncObjects,') + // the MAIN camera must ride the result — OBJECT_HIT_TEST raycasts with it + // (caught by real-browser verification: without this every hit was null) + expect(code).toMatch(/__syncObjects,\s*\n\s*camera,/) + }) + + it('unlit primitives use a basic material (no lights required)', () => { + const code = compileVosConfig( + withObjects([ + { id: 'p0', asset: { kind: 'primitive', shape: 'cube', unlit: true } }, + ]), + ) + expect(code).toContain('MeshBasicMaterial') + }) + + it('a gltf object auto-detects the GLTFLoader addon (objects are data, not code)', () => { + const code = compileVosConfig( + withObjects([{ id: 'm0', asset: { kind: 'gltf', key: 'https://x/m.glb' } }]), + ) + expect(code).toContain('GLTFLoader') + expect(code).toMatch(/import .*GLTFLoader.* from/) + // bbox normalization so scale is asset-independent + expect(code).toContain('__objNorm') + }) + + it('primitive-only configs do not import GLTFLoader', () => { + const code = compileVosConfig( + withObjects([{ id: 'p0', asset: { kind: 'primitive', shape: 'sphere' } }]), + ) + expect(code).not.toMatch(/import .*GLTFLoader.* from/) + }) +}) + +describe('objects bridge (protocol 3)', () => { + it('editor template handles SET_OBJECT_PROPS and OBJECT_HIT_TEST', () => { + const html = generateRenderTemplate('', { mode: 'playback', editor: true }) + expect(html).toContain("case 'SET_OBJECT_PROPS'") + expect(html).toContain("case 'OBJECT_HIT_TEST'") + expect(html).toContain('OBJECT_HIT_RESULT') + expect(html).toContain('objectHitTest') + expect(html).toContain('setObjectProps') + }) + + it('non-editor template exposes no object editor commands', () => { + const html = generateRenderTemplate('', { mode: 'playback' }) + expect(html).not.toContain("case 'SET_OBJECT_PROPS'") + expect(html).not.toContain("case 'OBJECT_HIT_TEST'") + }) +}) diff --git a/packages/core/src/compiler/compileVosConfig.ts b/packages/core/src/compiler/compileVosConfig.ts index 8d9fbaf..c0eee30 100644 --- a/packages/core/src/compiler/compileVosConfig.ts +++ b/packages/core/src/compiler/compileVosConfig.ts @@ -11,6 +11,7 @@ import { generateCleanup, generateDynamicLayerRebuild, generateElementsSetup, + generateObjectsSetup, generateGlobalComposerSetup, generateImports, generateLayerAssignment, @@ -76,6 +77,16 @@ export function compileVosConfig( // Detect which addons are actually needed by scanning function strings const detectedAddons = detectRequiredAddons(config) + // Declarative gltf objects need the loader even though no function string + // mentions it — objects are data, not code. + if ( + config.objects?.some( + (o) => (o as { asset?: { kind?: string } }).asset?.kind === 'gltf', + ) && + !detectedAddons.includes('GLTFLoader') + ) { + detectedAddons.push('GLTFLoader') + } // Cast to any for generators that expect VosConfig // (they only use non-function properties which are identical) @@ -100,6 +111,7 @@ export function compileVosConfig( const resizeHandler = generateResizeHandler(configForGenerators) const cleanup = generateCleanup(configForGenerators) const elementsSetup = generateElementsSetup(configForGenerators) + const objectsSetup = generateObjectsSetup(configForGenerators) const layerAssignment = generateLayerAssignment() const perLayerComposerSetup = generatePerLayerComposerSetup(configForGenerators) @@ -224,6 +236,7 @@ export const initVos = async (container, deps) => { ${cameraSetup} ${postprocessingSetup} ${elementsSetup} + ${objectsSetup} // Playback state let currentTime = 0; @@ -240,6 +253,7 @@ export const initVos = async (container, deps) => { overlayCamera, resolution: { width, height, pixelRatio, drawingBufferWidth, drawingBufferHeight }, elements, + objects, get data() { return __vosData; }, get time() { return currentTime; }, get progress() { return currentProgress; }, @@ -305,6 +319,9 @@ export const initVos = async (container, deps) => { // References into the live instance — read-mostly; property writes go through // each element's props proxy. elements, + objects, + __syncObjects, + camera, overlayCamera, }; }; diff --git a/packages/core/src/compiler/generators/generateObjectsSetup.ts b/packages/core/src/compiler/generators/generateObjectsSetup.ts new file mode 100644 index 0000000..d9edb22 --- /dev/null +++ b/packages/core/src/compiler/generators/generateObjectsSetup.ts @@ -0,0 +1,107 @@ +/** + * Generate world-space objects setup code for the animation template. + * + * Unlike elements (which delegate to the injected element-system bundle), + * objects are fully self-contained codegen: primitives build from parameters, + * GLB models load through the GLTFLoader addon import (auto-detected by the + * compiler when a gltf asset is present). Each object becomes an + * `ObjectInstance` `{ config, root, props, dispose }` in an id-keyed Map that + * is exposed on the context (`ctx.objects`) and the result — the same + * addressable contract as elements, which is what the bridge's + * SET_OBJECT_PROPS / OBJECT_HIT_TEST commands operate on. + * + * `__syncObjects()` mirrors the props bag onto the meshes; the render loop + * calls it every frame so prop changes (editor drags, tweens targeting + * `ctx.objects.get(id).props`) apply without any per-object bookkeeping. + * GLB roots are bbox-normalized at load (largest dimension = 1 world unit) so + * `props.scale` is asset-independent. + */ +export function generateObjectsSetup(config: any): string { + const objects = config.objects + if (!objects || objects.length === 0) { + return ` + // No declarative objects + const objects = new Map(); + const __syncObjects = () => {}; +` + } + + const objectsJson = JSON.stringify(objects, null, 2).replace(/\n/g, '\n ') + + return ` + // World-space objects (declarative) + const objectsConfig = ${objectsJson}; + const objects = new Map(); + const __objBuild = async (cfg) => { + let root; + let dispose = () => {}; + if (cfg.asset.kind === 'gltf') { + const gltf = await new Promise((res, rej) => + new GLTFLoader().load(cfg.asset.key, res, undefined, rej) + ); + root = gltf.scene; + const box = new THREE.Box3().setFromObject(root); + const size = new THREE.Vector3(); + box.getSize(size); + root.userData.__objNorm = 1 / (Math.max(size.x, size.y, size.z) || 1); + root.traverse((n) => { + if (n.isMesh && n.material) { + n.material = n.material.clone(); + n.material.transparent = true; + } + }); + dispose = () => root.traverse((n) => { + if (n.geometry && n.geometry.dispose) n.geometry.dispose(); + if (n.material && n.material.dispose) n.material.dispose(); + }); + } else { + const a = cfg.asset; + const geo = a.shape === 'sphere' ? new THREE.SphereGeometry(0.5, 32, 20) + : a.shape === 'torus' ? new THREE.TorusGeometry(0.4, 0.16, 20, 40) + : a.shape === 'knot' ? new THREE.TorusKnotGeometry(0.35, 0.12, 80, 14) + : new THREE.BoxGeometry(1, 1, 1); + const mat = a.unlit + ? new THREE.MeshBasicMaterial({ color: a.color || '#e4e4e7', transparent: true }) + : new THREE.MeshStandardMaterial({ + color: a.color || '#e4e4e7', + metalness: a.metalness == null ? 0.5 : a.metalness, + roughness: a.roughness == null ? 0.4 : a.roughness, + transparent: true, + }); + root = new THREE.Mesh(geo, mat); + dispose = () => { geo.dispose(); mat.dispose(); }; + } + const t = cfg.transform || {}; + const props = { + x: t.x || 0, y: t.y || 0, z: t.z || 0, + rx: t.rx || 0, ry: t.ry || 0, rz: t.rz || 0, + scale: t.scale == null ? 1 : t.scale, + opacity: 1, + visible: cfg.visible !== false, + }; + root.userData.__vosObjectId = cfg.id; + scene.add(root); + objects.set(cfg.id, { config: cfg, root, props, dispose }); + }; + // Fail-open per object: a bad asset skips its object, never the whole load. + await Promise.all(objectsConfig.map((c) => + __objBuild(c).catch((e) => console.warn('[vos] object failed to build', c && c.id, e)) + )); + const __syncObjects = () => { + objects.forEach((o) => { + const p = o.props; + const norm = o.root.userData.__objNorm || 1; + o.root.visible = p.visible !== false; + o.root.position.set(p.x, p.y, p.z); + o.root.rotation.set(p.rx * Math.PI / 180, p.ry * Math.PI / 180, p.rz * Math.PI / 180); + o.root.scale.setScalar(p.scale * norm); + if (o.root.isMesh) { + if (o.root.material) o.root.material.opacity = p.opacity; + } else { + o.root.traverse((n) => { if (n.isMesh && n.material) n.material.opacity = p.opacity; }); + } + }); + }; + __syncObjects(); +` +} diff --git a/packages/core/src/compiler/generators/generateRenderLoop.ts b/packages/core/src/compiler/generators/generateRenderLoop.ts index 261fc6a..53667b1 100644 --- a/packages/core/src/compiler/generators/generateRenderLoop.ts +++ b/packages/core/src/compiler/generators/generateRenderLoop.ts @@ -92,6 +92,7 @@ export function generateRenderLoop(config: VosConfig): string { let frameId; const animate = () => { frameId = requestAnimationFrame(animate); + __syncObjects(); // Publish the master clock: ctx.time / ctx.progress track the GSAP timeline // (time within the current cycle). Assigned before onFrame so per-frame code diff --git a/packages/core/src/compiler/generators/index.ts b/packages/core/src/compiler/generators/index.ts index c2c4f1f..55bb0ba 100644 --- a/packages/core/src/compiler/generators/index.ts +++ b/packages/core/src/compiler/generators/index.ts @@ -12,6 +12,7 @@ export { generateCameraSetup } from './generateCameraSetup' export { generateRendererSetup } from './generateRendererSetup' export { generatePostprocessingSetup } from './generatePostprocessingSetup' export { generateElementsSetup } from './generateElementsSetup' +export { generateObjectsSetup } from './generateObjectsSetup' export { generateRenderLoop } from './generateRenderLoop' export { generateResizeHandler } from './generateResizeHandler' export { generateCleanup } from './generateCleanup' diff --git a/packages/core/src/runtime/bridge.ts b/packages/core/src/runtime/bridge.ts index ed45285..2ae660d 100644 --- a/packages/core/src/runtime/bridge.ts +++ b/packages/core/src/runtime/bridge.ts @@ -16,7 +16,7 @@ */ /** Bumped whenever the message set changes. Advertised in `BRIDGE_READY`. */ -export const VOS_BRIDGE_PROTOCOL = 2 +export const VOS_BRIDGE_PROTOCOL = 3 /** Element bounds in CSS pixels, relative to the player document's viewport. */ export interface ElementRect { @@ -62,6 +62,14 @@ export type VosBridgeCommand = id: string props: Record } + /** Editor mode: ephemeral world-space object prop override (protocol 3). */ + | { + type: 'SET_OBJECT_PROPS' + id: string + props: Record + } + /** Editor mode: raycast declarative objects at viewport CSS px (response: OBJECT_HIT_RESULT). */ + | { type: 'OBJECT_HIT_TEST'; x: number; y: number; requestId: number } /** Events the player document posts OUT to the host. */ export type VosBridgeEvent = @@ -75,5 +83,7 @@ export type VosBridgeEvent = /** Asset preload progress (posted by the element system during LOAD). */ | { type: 'PRELOAD_PROGRESS'; loaded: number; total: number } | { type: 'HIT_RESULT'; requestId: number; id: string | null } + /** Response to OBJECT_HIT_TEST — nearest declarative object along the ray. */ + | { type: 'OBJECT_HIT_RESULT'; requestId: number; id: string | null } /** Response to GET_ELEMENT_RECTS; also pushed with `requestId: null` on resize. */ | { type: 'ELEMENT_RECTS'; requestId: number | null; rects: ElementRect[] } diff --git a/packages/core/src/runtime/renderTemplate.ts b/packages/core/src/runtime/renderTemplate.ts index 7ff2fce..9ab1922 100644 --- a/packages/core/src/runtime/renderTemplate.ts +++ b/packages/core/src/runtime/renderTemplate.ts @@ -489,6 +489,12 @@ function editorMessageCases(editor: boolean): string { break; case 'SET_ELEMENT_PROPS': __editorApi.setProps(msg.id, msg.props); + break; + case 'SET_OBJECT_PROPS': + __editorApi.setObjectProps(msg.id, msg.props); + break; + case 'OBJECT_HIT_TEST': + __post({ type: 'OBJECT_HIT_RESULT', requestId: msg.requestId ?? null, id: __editorApi.objectHitTest(msg.x, msg.y) }); break;` } @@ -582,11 +588,50 @@ function editorExtension(editor: boolean): string { if (inst && inst.props && props) Object.assign(inst.props, props); }; + // World-space objects (protocol 3): raycast against the MAIN camera + // — objects live in the 3D scene with real depth, so nearest hit + // wins (unlike overlay elements, whose depth is cleared per group). + const objectHitTest = (x, y) => { + const cam = __current && __current.camera; + const objs = __current && __current.objects; + if (!cam || !objs || objs.size === 0) return null; + cam.updateMatrixWorld(); + const rect = canvasRect(); + ndc.set(((x - rect.left) / rect.width) * 2 - 1, -(((y - rect.top) / rect.height) * 2 - 1)); + raycaster.setFromCamera(ndc, cam); + const roots = []; + const byRoot = new Map(); + objs.forEach((inst, id) => { + if (inst && inst.root && inst.root.visible !== false) { + roots.push(inst.root); + byRoot.set(inst.root, id); + } + }); + const hits = raycaster.intersectObjects(roots, true); + for (const hit of hits) { + // Walk up to the registered root (GLB hits land on child meshes). + let node = hit.object; + while (node && !byRoot.has(node)) node = node.parent; + if (node) return byRoot.get(node); + } + return null; + }; + + // Ephemeral object prop override — mirrored onto the mesh by the + // render loop's __syncObjects pass. Cleared by LOAD like element props. + const setObjectProps = (id, props) => { + const inst = __current && __current.objects && __current.objects.get(id); + if (inst && inst.props && props) { + Object.assign(inst.props, props); + if (__current.__syncObjects) __current.__syncObjects(); + } + }; + window.addEventListener('resize', () => { __post({ type: 'ELEMENT_RECTS', requestId: null, rects: getRects() }); }); - return { getRects, hitTest, setProps }; + return { getRects, hitTest, setProps, objectHitTest, setObjectProps }; })();` } diff --git a/packages/core/src/schema/configJsonSchema.ts b/packages/core/src/schema/configJsonSchema.ts index fb5f314..7c4e711 100644 --- a/packages/core/src/schema/configJsonSchema.ts +++ b/packages/core/src/schema/configJsonSchema.ts @@ -15,6 +15,7 @@ export const vosConfigJsonSchema = z.object({ dynamicLayers: z.boolean().optional(), // Element validation is complex with many subtypes; start permissive elements: z.array(z.record(z.string(), z.any())).optional(), + objects: z.array(z.record(z.string(), z.any())).optional(), // Arbitrary, app-defined input data exposed as ctx.data (no shape imposed) data: z.record(z.string(), z.unknown()).optional(), // Functions as strings diff --git a/packages/core/src/schema/configSchema.ts b/packages/core/src/schema/configSchema.ts index b0e0fd1..fcb3e01 100644 --- a/packages/core/src/schema/configSchema.ts +++ b/packages/core/src/schema/configSchema.ts @@ -21,6 +21,7 @@ export const vosConfigSchema = z.object({ dynamicLayers: z.boolean().optional(), // Element validation is complex with many subtypes; start permissive elements: z.array(z.record(z.string(), z.any())).optional(), + objects: z.array(z.record(z.string(), z.any())).optional(), setup: fnSchema.optional(), createContent: fnSchema, createTimeline: fnSchema, diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index bd1982e..aef8c43 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -45,6 +45,14 @@ export type { ElementProps, ElementInstance, } from './elements' +export type { + ObjectAssetConfig, + ObjectConfig, + ObjectInstance, + ObjectPrimitiveShape, + ObjectProps, + ObjectTransformConfig, +} from './objects' // Export types export type { diff --git a/packages/core/src/types/objects.ts b/packages/core/src/types/objects.ts new file mode 100644 index 0000000..e833a05 --- /dev/null +++ b/packages/core/src/types/objects.ts @@ -0,0 +1,88 @@ +import type * as THREE from 'three' + +/** + * Declarative world-space objects — the 3D sibling of `elements[]`. + * + * Objects are engine-managed meshes placed in the MAIN scene (not the 2D + * overlay): primitives built from parameters, or GLB models loaded by key. + * Transforms are RAW world units (the engine imposes no layout convention — + * apps map their own coordinate spaces onto world units before authoring the + * config). Like elements, objects are addressable by id for editor tooling: + * the bridge exposes `SET_OBJECT_PROPS` (ephemeral prop overrides during a + * drag) and `OBJECT_HIT_TEST` (a raycast against the main camera). + * + * Lighting is the config author's responsibility: standard materials need + * lights (add them in `createContent`), or set `unlit: true` for a + * light-independent basic material. + */ +export type ObjectPrimitiveShape = 'cube' | 'sphere' | 'torus' | 'knot' + +export type ObjectAssetConfig = + | { + kind: 'primitive' + shape: ObjectPrimitiveShape + /** CSS color (default '#e4e4e7'). */ + color?: string + /** Standard-material params (ignored when unlit). */ + metalness?: number + roughness?: number + /** Use an unlit basic material (no lights required). */ + unlit?: boolean + } + | { + /** A GLB/GLTF model fetched from `key` (URL). Loaded via the GLTFLoader addon. */ + kind: 'gltf' + key: string + } + +export interface ObjectTransformConfig { + /** World units. */ + x?: number + y?: number + z?: number + /** Euler degrees. */ + rx?: number + ry?: number + rz?: number + /** + * Uniform scale in world units. GLB models are bbox-NORMALIZED at load + * (largest dimension = 1 world unit), so `scale` means the same thing for + * every asset. + */ + scale?: number +} + +export interface ObjectConfig { + /** Stable identity — the bridge addresses objects by id. */ + id: string + asset: ObjectAssetConfig + transform?: ObjectTransformConfig + /** Initial visibility (default true). */ + visible?: boolean +} + +/** + * GSAP/spec-animatable prop bag mirrored onto the mesh each frame — the same + * ephemeral-override contract as element props: `SET_OBJECT_PROPS` writes + * here for gesture-time preview and a LOAD clears it (durable state is the + * config). + */ +export interface ObjectProps { + x: number + y: number + z: number + rx: number + ry: number + rz: number + scale: number + opacity: number + visible: boolean +} + +export interface ObjectInstance { + config: ObjectConfig + /** The root object added to the scene (a Mesh for primitives, a Group for GLB). */ + root: THREE.Object3D + props: ObjectProps + dispose: () => void +} diff --git a/packages/core/src/types/vos.ts b/packages/core/src/types/vos.ts index a94c810..94e1daa 100644 --- a/packages/core/src/types/vos.ts +++ b/packages/core/src/types/vos.ts @@ -4,6 +4,7 @@ import type * as THREE from 'three' import type gsap from 'gsap' import type { ElementConfig, ElementInstance } from './elements' +import type { ObjectConfig, ObjectInstance } from './objects' /** * Resolution configuration passed to animations @@ -252,6 +253,9 @@ export interface VosConfig { /** 2D Elements rendered as textured planes */ elements?: ElementConfig[] + /** Declarative world-space 3D objects (primitives / GLB) in the main scene */ + objects?: ObjectConfig[] + /** Arbitrary input data exposed as `ctx.data` (overridable by `deps.data` at runtime). */ data?: Record @@ -325,6 +329,8 @@ export interface VosResult { * instance's `props` proxy); durable element edits are config edits (T3). */ elements?: Map + /** Engine-managed world-space objects by id (present when config.objects is set). */ + objects?: Map /** The 2D overlay camera (pixel-space orthographic) — for bounds projection. */ overlayCamera?: THREE.OrthographicCamera } diff --git a/packages/core/src/types/vosConfigJson.ts b/packages/core/src/types/vosConfigJson.ts index 7548704..dd09ac7 100644 --- a/packages/core/src/types/vosConfigJson.ts +++ b/packages/core/src/types/vosConfigJson.ts @@ -44,6 +44,8 @@ export interface VosConfigJson { /** 2D Elements rendered as textured planes (loosely typed for JSON transport) */ elements?: Record[] + /** Declarative world-space 3D objects (primitives / GLB). */ + objects?: Record[] /** * Arbitrary input data made available to functions as `ctx.data`.