Skip to content

Commit ec5d0ce

Browse files
committed
feat: compose semantic Plainform hero shots
1 parent 92dbee2 commit ec5d0ce

5 files changed

Lines changed: 118 additions & 0 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
const clean = value => value.trim().replace(/[.;]+$/u, '').trim();
2+
const slug = value => clean(value).toLowerCase().replace(/^(?:the|a|an)\s+/u, '').replace(/[^a-z0-9]+/gu, '-').replace(/^-+|-+$/gu, '');
3+
function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
4+
function resolveSubject(scene, value) {
5+
const wanted = slug(value); const matches = Object.values(scene.entities).filter(entity => entity.id === clean(value) || slug(entity.name) === wanted || slug(entity.id.split('/').at(-1)) === wanted);
6+
if (matches.length !== 1) fail(matches.length ? 'plainform_composition_subject_ambiguous' : 'plainform_composition_subject_missing', `Composition subject “${value}” must resolve exactly once.`);
7+
return matches[0];
8+
}
9+
const lensFov = millimetres => 2 * Math.atan(36 / (2 * millimetres)) * 180 / Math.PI;
10+
11+
export class CompositionPlainformCompiler {
12+
compile(source, { project } = {}) {
13+
const scene = project?.scenes?.[project.activeSceneId]; if (!scene) fail('plainform_project_required', 'Composition Plainform requires an active scene.');
14+
const match = source.replace(/\s+/gu, ' ').trim().match(/^frame the whole (.+?) from (slightly below|eye level|slightly above) at a (\d+(?:\.\d+)?) millimetre lens\. use late afternoon sun from camera left, soft blue sky fill, a (.+?) ground, and enough depth of field to keep the (.+?) and (.+?) sharp\.?$/iu);
15+
if (!match) fail('plainform_composition_unsupported', 'Use the bounded hero-composition sentence with subject, angle, lens, ground, and sharp semantic extents.');
16+
const subject = resolveSubject(scene, match[1]); const lens = Number(match[3]); if (!(lens >= 12 && lens <= 300)) fail('plainform_composition_lens', 'Composition lens must be 12 to 300 millimetres.');
17+
const compositionSlug = slug(subject.name); const cameraId = scene.settings.activeCameraId ?? `camera/composition/${compositionSlug}`;
18+
const rigId = `entity/composition/${compositionSlug}/light-rig`; const groundId = `entity/composition/${compositionSlug}/ground`;
19+
const groundGeometryId = `geometry/composition/${compositionSlug}/ground`; const groundMaterialId = `material/composition/${compositionSlug}/ground`;
20+
const assetId = `asset/composition/${compositionSlug}`; const elevation = ({ 'slightly below': -0.12, 'eye level': 0, 'slightly above': 0.18 })[match[2].toLowerCase()];
21+
const diagnostics = [
22+
{ code: 'PLAINFORM_COMPOSITION_DOF_FALLBACK', severity: 'information', hosts: ['native', 'browser'], message: 'Depth-of-field intent is stored canonically; raster preview keeps the resolved trunk-to-crown focus range sharp without a post-process blur.' },
23+
{ code: 'PLAINFORM_COMPOSITION_ENVIRONMENT_FALLBACK', severity: 'information', hosts: ['browser'], message: 'Browser preview uses the same outdoor light entities and linear background; environment importance sampling remains a native capability difference.' },
24+
];
25+
const presentation = {
26+
formatVersion: 1, kind: 'presentation', subject: { entityId: subject.id, semanticBounds: ['whole', slug(match[5]), slug(match[6])] },
27+
camera: { cameraId, angle: match[2].toLowerCase(), lensMillimetres: lens, fieldOfViewDegrees: lensFov(lens), lookTarget: 'semantic-bounds-center', aspect: 16 / 9 },
28+
lighting: { rigId, timeOfDay: 'lateAfternoon', key: { role: 'sun', direction: 'cameraLeft', unit: 'lux', illuminance: 32000 }, fill: { role: 'sky', color: [0.45, 0.65, 1], unit: 'relative', intensity: 0.75 } },
29+
environment: { ground: { entityId: groundId, description: clean(match[4]) }, backdrop: 'softBlueSky', fog: { mode: 'linear', near: 80, far: 450 }, exposure: 1 },
30+
depthOfField: { mode: 'semanticRange', nearSemantic: slug(match[5]), farSemantic: slug(match[6]), fallback: 'keep-range-sharp' }, diagnostics,
31+
};
32+
const operations = [];
33+
const createResources = [];
34+
if (!project.resources.geometries?.[groundGeometryId]) createResources.push({ resourceType: 'geometries', resource: { id: groundGeometryId, recipe: { kind: 'box', width: 30, height: 0.1, depth: 30 } } });
35+
if (!project.resources.materials?.[groundMaterialId]) createResources.push({ resourceType: 'materials', resource: { id: groundMaterialId, recipe: { kind: 'physical', color: '#8a7a49', roughness: 0.96, metalness: 0 } } });
36+
if (!project.resources.assets?.[assetId]) createResources.push({ resourceType: 'assets', resource: { id: assetId, kind: 'presentation', name: `${subject.name} Hero Composition`, presentation } });
37+
if (createResources.length) operations.push({ op: 'resource.createMany', items: createResources });
38+
else operations.push({ op: 'resource.patch', resourceType: 'assets', resourceId: assetId, patch: { presentation } });
39+
if (!scene.entities[cameraId]) operations.push({ op: 'entity.create', sceneId: scene.id, entity: { id: cameraId, kind: 'perspectiveCamera', name: `${subject.name} Hero Camera`, components: { camera: { fov: lensFov(lens), near: 0.1, far: 1000 } }, metadata: { compositionAssetId: assetId } } });
40+
else operations.push({ op: 'entity.patch', entityId: cameraId, patch: { components: { camera: { ...scene.entities[cameraId].components?.camera, fov: lensFov(lens) } }, metadata: { ...scene.entities[cameraId].metadata, compositionAssetId: assetId } } });
41+
if (!scene.entities[groundId]) operations.push({ op: 'entity.create', sceneId: scene.id, entity: { id: groundId, kind: 'mesh', name: `${clean(match[4])} ground`, transform: { position: [0, -0.05, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }, components: { mesh: { geometryId: groundGeometryId, materialId: groundMaterialId } }, metadata: { compositionAssetId: assetId } } });
42+
if (!scene.entities[rigId]) operations.push({ op: 'lighting.rig.create', sceneId: scene.id, rigId, preset: 'outdoor', center: [0, 0, 0], scale: 1, intensity: 1, rtx: 'auto' });
43+
operations.push({ op: 'camera.frame', cameraId, target: { targetIds: [subject.id] }, aspect: 16 / 9, padding: 1.12, view: { azimuth: -0.55, elevation, distanceScale: 1.15, targetOffset: [0, 0, 0], minHeight: 0.2 }, lockPreviewAspect: true });
44+
operations.push({ op: 'scene.setActiveCamera', sceneId: scene.id, cameraId });
45+
operations.push({ op: 'scene.settings.patch', sceneId: scene.id, patch: { background: { mode: 'color', color: [0.32, 0.52, 0.78], colorSpace: 'linear-srgb' }, fog: { mode: 'linear', color: [0.42, 0.58, 0.76], near: 80, far: 450 }, presentation: { assetId, exposure: 1, depthOfField: presentation.depthOfField } } });
46+
return Object.freeze({ language: 'plainform-v1', dialect: 'composition', source, operations: Object.freeze(operations), interpretation: Object.freeze([`Frame ${subject.id} at ${lens} mm from ${match[2].toLowerCase()}.`, 'Create a typed late-afternoon outdoor presentation with explicit host fallbacks.']), aliases: Object.freeze({}), requestedPreview: true, composition: Object.freeze({ assetId, cameraId, subjectId: subject.id, diagnostics: Object.freeze(diagnostics) }) });
47+
}
48+
}

src/plainform/index.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export { PlainformPrefabContext } from './prefab-context.mjs';
77
export { ShaderPlainformCompiler, ShaderPlainformError } from './shader-plainform-compiler.mjs';
88
export { EventPlainformCompiler } from './event-plainform-compiler.mjs';
99
export { FormPlainformCompiler } from './form-plainform-compiler.mjs';
10+
export { CompositionPlainformCompiler } from './composition-plainform-compiler.mjs';
1011
export { interpretShaderFeel, SHADER_FEEL_VOCABULARY } from './shader-feel-vocabulary.mjs';
1112
export { DesignPlainformCompiler } from './design-plainform-compiler.mjs';
1213
export { DesignExpressionError, evaluateDesignExpression, evaluateDesignVector } from './design-expression.mjs';

src/plainform/plainform-compiler.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ShaderPlainformCompiler } from './shader-plainform-compiler.mjs';
1010
import { DesignPlainformCompiler } from './design-plainform-compiler.mjs';
1111
import { EventPlainformCompiler } from './event-plainform-compiler.mjs';
1212
import { FormPlainformCompiler } from './form-plainform-compiler.mjs';
13+
import { CompositionPlainformCompiler } from './composition-plainform-compiler.mjs';
1314
import { parsePlainformProgram } from './plainform-front-end.mjs';
1415

1516
const TAU = Math.PI * 2;
@@ -368,6 +369,7 @@ export class PlainformCompiler {
368369
if (program.dialect === 'design') return new DesignPlainformCompiler().compile(source, { project });
369370
if (program.dialect === 'event') return new EventPlainformCompiler().compile(source, { project });
370371
if (program.dialect === 'form') return new FormPlainformCompiler().compile(source, { project });
372+
if (program.dialect === 'composition') return new CompositionPlainformCompiler().compile(source, { project });
371373
if (!project) fail('plainform_project_required', 'Plainform compilation requires the canonical project document.');
372374
const statements = source.split(/\r?\n/u).map(cleanStatement).filter(Boolean);
373375
if (statements.length > MAX_STATEMENTS) fail('plainform_statement_limit', `Plainform accepts at most ${MAX_STATEMENTS} statements.`);

src/plainform/plainform-front-end.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,13 @@ const DEFINITIONS = Object.freeze([
361361
examples: ['Create a modal Save Game dialog with a multiline notes box. Enter adds a line; Control+Enter confirms only when a slot is selected.'],
362362
semanticKey: match => `form.${semanticPart(match[1])}`, fields: match => ({ name: match[1], notesField: match[2] }),
363363
}),
364+
definition({
365+
id: 'composition.hero', dialect: 'composition', domain: 'composition', kind: 'composition.heroFrame', priority: 900,
366+
pattern: /^frame the whole (.+?) from (slightly below|eye level|slightly above) at a (\d+(?:\.\d+)?) millimetre lens\. use late afternoon sun from camera left, soft blue sky fill, a (.+?) ground, and enough depth of field to keep the (.+?) and (.+?) sharp$/iu,
367+
summary: 'Create a bounded semantic hero composition with camera, outdoor rig, ground, atmosphere, and explicit fallbacks.', inputs: ['subject', 'angle', 'lens', 'ground', 'nearSemantic', 'farSemantic'], outputs: ['presentation', 'camera', 'lightRig'],
368+
examples: ['Frame the whole pine from slightly below at a 50 millimetre lens. Use late afternoon sun from camera left, soft blue sky fill, a dry grass ground, and enough depth of field to keep the trunk and crown sharp.'],
369+
semanticKey: match => `composition.${semanticPart(match[1])}.hero`, fields: match => ({ subject: match[1], angle: match[2], lensMillimetres: Number(match[3]), ground: match[4], nearSemantic: match[5], farSemantic: match[6] }),
370+
}),
364371
definition({
365372
id: 'shader.property.set', dialect: 'shader', domain: 'shader', kind: 'shader.setProperty',
366373
pattern: /^(?:set|drive) (?:the )?(.+?) (?:to|with) (.+)$/iu,
@@ -377,6 +384,7 @@ function inferDialect(source) {
377384
if (/^\s*(?:begin\s+)?design\b/iu.test(source)) return 'design';
378385
if (/^\s*(?:for .+?,\s*when |when (?:the )?.+? (?:collides|receives|is destroyed))/imu.test(source)) return 'event';
379386
if (/^\s*create (?:an? .+? window|a modal .+? dialog)\b/imu.test(source)) return 'form';
387+
if (/^\s*frame the whole .+? from /imu.test(source)) return 'composition';
380388
return 'object';
381389
}
382390

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
4+
import { createProjectDocument } from '../src/core/index.mjs';
5+
import { operationSchema } from '../src/mcp/tool-schemas.mjs';
6+
import { PlainformCompiler } from '../src/plainform/index.mjs';
7+
8+
function project() {
9+
return createProjectDocument({
10+
projectId: 'project/composition',
11+
scenes: [{ id: 'scene/main', entities: [
12+
{ id: 'entity/pine', kind: 'group', name: 'Pine', children: ['entity/pine/trunk', 'entity/pine/crown'] },
13+
{ id: 'entity/pine/trunk', kind: 'mesh', name: 'Trunk', parentId: 'entity/pine', components: { mesh: { geometryId: 'geometry/trunk' } } },
14+
{ id: 'entity/pine/crown', kind: 'mesh', name: 'Crown', parentId: 'entity/pine', transform: { position: [0, 7, 0] }, components: { mesh: { geometryId: 'geometry/crown' } } },
15+
] }],
16+
resources: { geometries: [
17+
{ id: 'geometry/trunk', recipe: { kind: 'cylinder', radius: 0.5, height: 8 } },
18+
{ id: 'geometry/crown', recipe: { kind: 'sphere', radius: 3 } },
19+
] },
20+
});
21+
}
22+
23+
const source = 'Frame the whole pine from slightly below at a 50 millimetre lens. Use late afternoon sun from camera left, soft blue sky fill, a dry grass ground, and enough depth of field to keep the trunk and crown sharp.';
24+
25+
test('Composition Plainform lowers one semantic hero sentence to typed camera, lighting, ground, and appearance operations', () => {
26+
const compiled = new PlainformCompiler().compile(source, { project: project() });
27+
assert.equal(compiled.dialect, 'composition');
28+
assert.equal(compiled.requestedPreview, true);
29+
assert.ok(compiled.operations.every(operation => operationSchema.safeParse(operation).success));
30+
const frame = compiled.operations.find(operation => operation.op === 'camera.frame');
31+
assert.deepEqual(frame.target, { targetIds: ['entity/pine'] });
32+
assert.equal(frame.view.elevation, -0.12);
33+
const rig = compiled.operations.find(operation => operation.op === 'lighting.rig.create');
34+
assert.equal(rig.preset, 'outdoor');
35+
const settings = compiled.operations.find(operation => operation.op === 'scene.settings.patch').patch;
36+
assert.equal(settings.fog.mode, 'linear');
37+
assert.equal(settings.presentation.depthOfField.mode, 'semanticRange');
38+
const resources = compiled.operations[0].items.map(item => item.resource);
39+
const presentation = resources.find(resource => resource.kind === 'presentation').presentation;
40+
assert.deepEqual(presentation.subject.semanticBounds, ['whole', 'trunk', 'crown']);
41+
assert.ok(Math.abs(presentation.camera.fieldOfViewDegrees - 39.5978) < 0.001);
42+
});
43+
44+
test('Composition Plainform is deterministic across hosts and documents explicit renderer differences', () => {
45+
const first = new PlainformCompiler().compile(source, { project: project() });
46+
const second = new PlainformCompiler().compile(source, { project: project() });
47+
assert.deepEqual(first.operations, second.operations);
48+
assert.deepEqual(first.composition.diagnostics.map(item => item.code), [
49+
'PLAINFORM_COMPOSITION_DOF_FALLBACK',
50+
'PLAINFORM_COMPOSITION_ENVIRONMENT_FALLBACK',
51+
]);
52+
assert.deepEqual(first.composition.diagnostics[1].hosts, ['browser']);
53+
});
54+
55+
test('Composition Plainform rejects ambiguous subjects and unsafe lenses before mutation', () => {
56+
const duplicate = project(); duplicate.scenes['scene/main'].entities['entity/other-pine'] = { ...duplicate.scenes['scene/main'].entities['entity/pine'], id: 'entity/other-pine', children: [], name: 'Pine' };
57+
assert.throws(() => new PlainformCompiler().compile(source, { project: duplicate }), error => error.code === 'plainform_composition_subject_ambiguous');
58+
assert.throws(() => new PlainformCompiler().compile(source.replace('50 millimetre', '5 millimetre'), { project: project() }), error => error.code === 'plainform_composition_lens');
59+
});

0 commit comments

Comments
 (0)