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
5 changes: 5 additions & 0 deletions .changeset/big-objects-arrive.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/core/src/__tests__/editing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }`,
)
Expand Down
95 changes: 95 additions & 0 deletions packages/core/src/__tests__/objects.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[]): 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'")
})
})
17 changes: 17 additions & 0 deletions packages/core/src/compiler/compileVosConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
generateCleanup,
generateDynamicLayerRebuild,
generateElementsSetup,
generateObjectsSetup,
generateGlobalComposerSetup,
generateImports,
generateLayerAssignment,
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -224,6 +236,7 @@ export const initVos = async (container, deps) => {
${cameraSetup}
${postprocessingSetup}
${elementsSetup}
${objectsSetup}

// Playback state
let currentTime = 0;
Expand All @@ -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; },
Expand Down Expand Up @@ -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,
};
};
Expand Down
107 changes: 107 additions & 0 deletions packages/core/src/compiler/generators/generateObjectsSetup.ts
Original file line number Diff line number Diff line change
@@ -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();
`
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/compiler/generators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/runtime/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -62,6 +62,14 @@ export type VosBridgeCommand =
id: string
props: Record<string, number | boolean>
}
/** Editor mode: ephemeral world-space object prop override (protocol 3). */
| {
type: 'SET_OBJECT_PROPS'
id: string
props: Record<string, number | boolean>
}
/** 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 =
Expand All @@ -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[] }
47 changes: 46 additions & 1 deletion packages/core/src/runtime/renderTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;`
}

Expand Down Expand Up @@ -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 };
})();`
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/schema/configJsonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading