From 10e71fd028d2ef7af98e8c778cc269ee006f5a13 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 00:35:13 +0300 Subject: [PATCH 1/4] [feat] author-kit p0: primitives, light kinds, physical/toon materials, object flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build() gains capsule / plane / ring / icosahedron / dodecahedron (core three geometries, rebuilt from their parameters by ObjectLoader) and box `bevel` (+ bevelSegments): a port of three/examples RoundedBoxGeometry baked into a PLAIN BufferGeometry, because toJSON writes a subclass type ObjectLoader cannot rebuild (core Box params carry no bevel) - light `kind`: spot (angle, penumbra, target) / directional (castShadow, shadowMapSize, frustum FITTED to the built meshes in the light's own frame; `fit: false` opts out) / hemisphere (groundColor); spot + directional follow createLight (cast by default, the V-1 bias pair) and aim by ROTATION at `target` (24-E1). point (no kind) is untouched - materials: `physical: true` or any physical-only field (clearcoat, clearcoatRoughness, transmission, thickness, ior, sheen, sheenColor, sheenRoughness, iridescence, specularIntensity) -> MeshPhysicalMaterial; `toon: true` -> MeshToonMaterial; `flatShading`, `side: 'double'|'back'`; emissiveIntensity as before - flags: `pick: 'through'` -> userData.pick; `origin: [x,y,z]` -> userData.origin; `particles: '' | {preset, ...patch}` -> userData.particles from particlePresets; `anim: '' | [..]` -> animationPreview.applyPreset (by key or name), an ordinary authored clip (not playing — a Play Animation node runs it) - loader: `--def ` adds defs from JSON (one or an array); a same-slug def REPLACES the DEFS entry. Unknown object types now throw instead of silently becoming cones - suite author-kit (new): authors a def-under-test via --def/--only into a temp dir, loads it back through sessions.readSessionZip + applySession, one check per feature: 40 PASS - byte-identity: level-blockout/towers/stars-room/untangle/waves authored before and after, scripts/compare-authored.cjs reports SAME for all five (thumbs identical in size) - counterfactual: 'clearcoat' dropped from PHYSICAL_KEYS + the pick line removed -> exactly "physical: clearcoat 0.8 survives (0)" and "pick: 'through' lands on userData.pick" FAIL - held: templates-modal 47 / template-modules 29 PASS lines at base (no src change in this commit); svelte-check 336/47 identical list; vitest 196; build green Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/author-templates.cjs | 251 ++++++++++++++++++++++++++++++++-- tests/e2e/author-kit.test.cjs | 244 +++++++++++++++++++++++++++++++++ 2 files changed, 487 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/author-kit.test.cjs diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index 9d244779..3a9a0b6a 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -95,6 +95,22 @@ const ONLY = .split(',') .filter(Boolean) : null; +// 30 author-kit: `--def ` adds defs from JSON files — each file holds +// ONE def or an ARRAY of them. A file def whose slug matches a DEFS entry REPLACES it (so a +// lane can iterate on a def without editing DEFS); any other slug is appended. Combine with +// `--only ` to author just that def. This is also how the author-kit suite authors its +// def-under-test into a scratch folder. +const defFlag = process.argv.indexOf('--def'); +const FILE_DEFS = + defFlag !== -1 + ? String(process.argv[defFlag + 1] ?? '') + .split(',') + .filter(Boolean) + .flatMap((file) => { + const parsed = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); + return Array.isArray(parsed) ? parsed : [parsed]; + }) + : []; // ---- declarative scene definitions ------------------------------------------ // objects: {type:'box'|'cylinder'|'sphere'|'cone', name, color, pos, rot?, ...dims, @@ -1332,7 +1348,11 @@ const DEFS = [ /** @type {Record} */ const built = {}; - const defs = ONLY ? DEFS.filter((d) => ONLY.includes(d.slug)) : DEFS; + // 30 author-kit: `--def` files replace a same-slug DEFS entry in place, or append + const allDefs = DEFS.map((d) => FILE_DEFS.find((f) => f.slug === d.slug) ?? d).concat( + FILE_DEFS.filter((f) => !DEFS.some((d) => d.slug === f.slug)) + ); + const defs = ONLY ? allDefs.filter((d) => ONLY.includes(d.slug)) : allDefs; if (ONLY && defs.length !== ONLY.length) console.log(' WARN --only names a slug DEFS does not have: ' + ONLY.join(',')); // a module-owned def whose file is absent must not quietly drop its row from a full @@ -1393,6 +1413,90 @@ const DEFS = [ s.objectsGroup.subscribe((g) => (group = g))(); /** @param {number} n */ const hex = (n) => '#' + Number(n).toString(16).padStart(6, '0'); + // 30 author-kit: material fields only MeshPhysicalMaterial has (a def using any of + // them gets one), the directional lights whose shadow frustum is fitted once the + // whole scene is built, and the animation presets applied once objects have uuids + const PHYSICAL_KEYS = ['clearcoat', 'clearcoatRoughness', 'transmission', 'thickness', 'ior', 'sheen', 'sheenColor', 'sheenRoughness', 'iridescence', 'specularIntensity']; + /** @type {any[]} */ const fitShadows = []; + /** @type {{object: any, anim: any}[]} */ const animQueue = []; + // 30 author-kit: a ROUNDED box. Core's Box params carry no bevel, so this is a port of + // three/examples' RoundedBoxGeometry (MIT) — same vertex placement, normals and UVs — + // BAKED into a plain BufferGeometry: toJSON writes a subclass's `type` and ObjectLoader + // cannot rebuild 'RoundedBoxGeometry', while a plain BufferGeometry round-trips its + // buffers. @param {number} width @param {number} height @param {number} depth + // @param {number} segments @param {number} radius + const roundedBox = (width, height, depth, segments, radius) => { + const total = segments * 2 + 1; + radius = Math.min(width / 2, height / 2, depth / 2, radius); + const src = new T.BoxGeometry(1, 1, 1, total, total, total).toNonIndexed(); + const positions = src.attributes.position.array; + const normals = src.attributes.normal.array; + const uvs = src.attributes.uv.array; + const position = new T.Vector3(); + const normal = new T.Vector3(); + const temp = new T.Vector3(); + const faceDir = new T.Vector3(); + const box = new T.Vector3(width, height, depth).divideScalar(2).subScalar(radius); + const faceTris = positions.length / 6; + const half = 0.5 / total; + /** @param {string} uvAxis @param {string} projAxis @param {number} side */ + const getUv = (uvAxis, projAxis, side) => { + const arc = (2 * Math.PI * radius) / 4; + const centre = Math.max(side - 2 * radius, 0); + temp.copy(normal); + /** @type {any} */ (temp)[projAxis] = 0; + temp.normalize(); + const arcUv = (0.5 * arc) / (arc + centre); + const arcAngle = 1.0 - temp.angleTo(faceDir) / (Math.PI / 4); + if (Math.sign(/** @type {any} */ (temp)[uvAxis]) === 1) return arcAngle * arcUv; + return centre / (arc + centre) + arcUv + arcUv * (1.0 - arcAngle); + }; + for (let i = 0, j = 0; i < positions.length; i += 3, j += 2) { + position.fromArray(positions, i); + normal.copy(position); + normal.x -= Math.sign(normal.x) * half; + normal.y -= Math.sign(normal.y) * half; + normal.z -= Math.sign(normal.z) * half; + normal.normalize(); + positions[i] = box.x * Math.sign(position.x) + normal.x * radius; + positions[i + 1] = box.y * Math.sign(position.y) + normal.y * radius; + positions[i + 2] = box.z * Math.sign(position.z) + normal.z * radius; + normals[i] = normal.x; + normals[i + 1] = normal.y; + normals[i + 2] = normal.z; + const face = Math.floor(i / faceTris); + if (face === 0) { + faceDir.set(1, 0, 0); + uvs[j] = getUv('z', 'y', depth); + uvs[j + 1] = 1.0 - getUv('y', 'z', height); + } else if (face === 1) { + faceDir.set(-1, 0, 0); + uvs[j] = 1.0 - getUv('z', 'y', depth); + uvs[j + 1] = 1.0 - getUv('y', 'z', height); + } else if (face === 2) { + faceDir.set(0, 1, 0); + uvs[j] = 1.0 - getUv('x', 'z', width); + uvs[j + 1] = getUv('z', 'x', depth); + } else if (face === 3) { + faceDir.set(0, -1, 0); + uvs[j] = 1.0 - getUv('x', 'z', width); + uvs[j + 1] = 1.0 - getUv('z', 'x', depth); + } else if (face === 4) { + faceDir.set(0, 0, 1); + uvs[j] = 1.0 - getUv('x', 'y', width); + uvs[j + 1] = 1.0 - getUv('y', 'x', height); + } else { + faceDir.set(0, 0, -1); + uvs[j] = getUv('x', 'y', width); + uvs[j + 1] = 1.0 - getUv('y', 'x', height); + } + } + const geo = new T.BufferGeometry(); + geo.setAttribute('position', new T.BufferAttribute(positions, 3)); + geo.setAttribute('normal', new T.BufferAttribute(normals, 3)); + geo.setAttribute('uv', new T.BufferAttribute(uvs, 2)); + return geo; + }; // 28-G: ONE recursive builder. The four original primitives take exactly the // steps they always did, in the same order, so every earlier def is byte-identical. // `mirror` reflects across x = 0: position x negated, yaw and roll NEGATED — a @@ -1409,6 +1513,29 @@ const DEFS = [ if (o.type === 'group' || o.type === 'empty') { object = new T.Group(); for (const child of o.children ?? []) object.add(build(child, opts)); + } else if (o.type === 'light' && o.kind && o.kind !== 'point') { + // 30 author-kit: spot / directional / hemisphere. Spot and directional follow + // createLight's convention (cast shadows by default, the V-1 bias pair) and aim + // by ROTATION (24-E1: they shine along local -Z; lightHelpers places the target + // on that forward) — `target` is a WORLD point applied with lookAt once the + // position is set, below. A directional's ortho frustum is FITTED to the built + // scene after every object exists (`fitShadows`), unless `fit: false`. + if (o.kind === 'hemisphere') { + object = new T.HemisphereLight(o.color ?? 0xffffff, o.groundColor ?? 0x444444, o.intensity ?? 1); + } else if (o.kind === 'spot' || o.kind === 'directional') { + object = + o.kind === 'spot' + ? new T.SpotLight(o.color ?? 0xffffff, o.intensity ?? 1, o.distance ?? 0, o.angle ?? Math.PI / 6, o.penumbra ?? 0.3, o.decay ?? 2) + : new T.DirectionalLight(o.color ?? 0xffffff, o.intensity ?? 1); + object.castShadow = o.castShadow ?? true; + object.shadow.bias = -0.0002; + object.shadow.normalBias = 0.02; + if (o.shadowMapSize) { + object.userData.shadowMapSize = o.shadowMapSize; + object.shadow.mapSize.set(o.shadowMapSize, o.shadowMapSize); + } + if (o.kind === 'directional' && object.castShadow && o.fit !== false) fitShadows.push(object); + } else throw new Error('light: unknown kind "' + o.kind + '" (point | spot | directional | hemisphere)'); } else if (o.type === 'light') { // a point light: a viewpoint's worth of scenery it lights, no shadow map object = new T.PointLight(o.color ?? 0xffffff, o.intensity ?? 1, o.distance ?? 0, o.decay ?? 2); @@ -1449,16 +1576,44 @@ const DEFS = [ ); } else { let geo; - if (o.type === 'box') geo = new T.BoxGeometry(o.size[0], o.size[1], o.size[2]); + if (o.type === 'box' && o.bevel > 0) geo = roundedBox(o.size[0], o.size[1], o.size[2], o.bevelSegments ?? 3, o.bevel); + else if (o.type === 'box') geo = new T.BoxGeometry(o.size[0], o.size[1], o.size[2]); else if (o.type === 'cylinder') geo = new T.CylinderGeometry(o.r, o.r2 ?? o.r, o.h, 24); else if (o.type === 'sphere') geo = new T.SphereGeometry(o.r, 24, 16); else if (o.type === 'torus') geo = new T.TorusGeometry(o.r, o.tube ?? o.r * 0.2, 16, 40); - else geo = new T.ConeGeometry(o.r, o.h, 24); - const mat = new T.MeshStandardMaterial({ - color: o.color, - roughness: o.roughness ?? 0.85, - metalness: o.metalness ?? 0 - }); + // 30 author-kit: four more primitives, all core three geometries ObjectLoader + // rebuilds from their parameters (so the .tpscene stays small). A plane faces +Z + // (rotate it -90deg on x to lie flat); a ring is the flat annulus, also +Z. + else if (o.type === 'capsule') geo = new T.CapsuleGeometry(o.r, o.h ?? o.length ?? 1, 8, 16); + else if (o.type === 'plane') geo = new T.PlaneGeometry(o.size[0], o.size[1]); + else if (o.type === 'ring') geo = new T.RingGeometry(o.inner ?? o.r * 0.5, o.r, 48); + else if (o.type === 'icosahedron') geo = new T.IcosahedronGeometry(o.r, o.detail ?? 0); + else if (o.type === 'dodecahedron') geo = new T.DodecahedronGeometry(o.r, o.detail ?? 0); + else if (o.type === 'cone') geo = new T.ConeGeometry(o.r, o.h, 24); + else throw new Error('object "' + o.name + '": unknown type "' + o.type + '"'); + // 30 author-kit: MeshPhysicalMaterial when a def asks for `physical` or uses any + // field only it has; MeshToonMaterial for `toon`. Absent all of those it is the + // same MeshStandardMaterial as ever (byte-identical defs). + const physical = o.physical || PHYSICAL_KEYS.some((k) => o[k] != null); + /** @type {any} */ + let mat; + if (o.toon) mat = new T.MeshToonMaterial({ color: o.color }); + else if (physical) { + mat = new T.MeshPhysicalMaterial({ color: o.color, roughness: o.roughness ?? 0.85, metalness: o.metalness ?? 0 }); + for (const k of PHYSICAL_KEYS) { + if (o[k] == null) continue; + if (k === 'sheenColor') mat.sheenColor = new T.Color(o[k]); + else mat[k] = o[k]; + } + } else + mat = new T.MeshStandardMaterial({ + color: o.color, + roughness: o.roughness ?? 0.85, + metalness: o.metalness ?? 0 + }); + if (o.flatShading) mat.flatShading = true; + if (o.side === 'double') mat.side = T.DoubleSide; + else if (o.side === 'back') mat.side = T.BackSide; // B8: material EMISSIVE + opacity, so a game can glow a pad or float a // translucent marker without a shader doc (which the user found "strange"). // emissiveIntensity multiplies the emissive COLOUR, so both are needed. @@ -1482,6 +1637,35 @@ const DEFS = [ if (pos && o.type !== 'spline') object.position.set(pos[0], pos[1], pos[2]); if (rot) object.rotation.set(rot[0], rot[1], rot[2]); if (o.physics && !mirror) object.userData.physics = o.physics; + // 30 author-kit: object FLAGS. Each lands where the app itself keeps it, so the + // .tpscene carries it the ordinary way (userData rides toJSON; a clip rides the + // animations block). A mirror ghost takes none of them (it is decoration). + if (!mirror) { + // select-through: a shell (wall, ceiling, glass) the editor's pick passes by + if (o.pick === 'through') object.userData.pick = 'through'; + // the transform ORIGIN (objectOrigin's local pivot offset) — a Door preset + // swings about it, so a hinge is authored here + if (Array.isArray(o.origin)) object.userData.origin = o.origin.map(Number); + // a particle emitter from a preset (particlePresets), optionally patched — + // the config addParticlesPreset writes to userData.particles + if (o.particles) { + const spec = typeof o.particles === 'string' ? { preset: o.particles } : o.particles; + if (!s.particlePresets.PARTICLE_PRESETS.some((/** @type {any} */ p) => p.key === spec.preset)) + throw new Error('object "' + o.name + '": no particle preset "' + spec.preset + '"'); + const base = s.particlePresets.particlePreset(spec.preset); + if (!base) throw new Error('object "' + o.name + '": no particle preset "' + spec.preset + '"'); + const { preset: _preset, ...patch } = spec; + object.userData.particles = { ...structuredClone(base), ...patch }; + } + // animation presets are applied once the object has a uuid in the scene + if (o.anim) animQueue.push({ object, anim: o.anim }); + } + // a spot/directional aims by rotation at a WORLD point (see the light branch) + if (o.target && (object.isSpotLight || object.isDirectionalLight)) { + const t = o.target; + object.updateMatrixWorld(true); + object.lookAt(mirror ? -t[0] : t[0], t[1], t[2]); + } if (o.shadow === false || opts.shadow === false) { // shadowDefaults sweeps cast/receive back ON unless the object opts out object.castShadow = false; @@ -1508,6 +1692,41 @@ const DEFS = [ } group.add(build(o)); } + // 30 author-kit: FIT each shadow-casting directional light's ortho frustum to the + // meshes just built — the 8 corners of their world box carried into the light's own + // frame (it looks down -Z, and the shadow camera is posed from the light toward its + // target on that same forward). Saved with the light: LightShadow.toJSON carries the + // camera, so the fitted frustum is what the file (and every peer) renders with. + if (fitShadows.length) { + group.updateMatrixWorld(true); + const bounds = new T.Box3(); + group.traverse((/** @type {any} */ n) => { + if (n.isMesh && !n.userData?.camera) bounds.expandByObject(n); + }); + if (!bounds.isEmpty()) { + const corners = []; + for (const x of [bounds.min.x, bounds.max.x]) + for (const y of [bounds.min.y, bounds.max.y]) + for (const z of [bounds.min.z, bounds.max.z]) corners.push(new T.Vector3(x, y, z)); + for (const light of fitShadows) { + light.updateMatrixWorld(true); + const inv = light.matrixWorld.clone().invert(); + const local = new T.Box3().setFromPoints(corners.map((c) => c.clone().applyMatrix4(inv))); + const pad = Math.max(local.max.x - local.min.x, local.max.y - local.min.y) * 0.05 + 0.5; + const cam = light.shadow.camera; + // symmetric about the light's axis (the shadow camera is centred on it) + const rx = Math.max(Math.abs(local.min.x), Math.abs(local.max.x)) + pad; + const ry = Math.max(Math.abs(local.min.y), Math.abs(local.max.y)) + pad; + cam.left = -rx; + cam.right = rx; + cam.bottom = -ry; + cam.top = ry; + cam.near = Math.max(0.1, -local.max.z - pad); + cam.far = Math.max(cam.near + 1, -local.min.z + pad); + cam.updateProjectionMatrix(); + } + } + } s.objectsGroup.update((v) => v); // ---- C5.3: the scene DATA a game carries beyond its objects ------------- @@ -1542,6 +1761,22 @@ const DEFS = [ } s.animationPreview.animationsRestore(sets, false); } + // 30 author-kit: `anim: ''` (or a list of them) — through applyPreset, the + // Animation window's own preset path, so each is an ORDINARY authored clip keyed + // from where the object stands (and, for a Door, about its `origin`). A clip is + // authored, not playing: a Play Animation node (or the Animation window) runs it. + if (animQueue.length && s.animationPreview) { + const presets = s.animationPreview.PRESETS; + for (const { object, anim } of animQueue) { + for (const name of Array.isArray(anim) ? anim : [anim]) { + const key = Object.keys(presets).find( + (k) => k === String(name).toLowerCase() || presets[k].name.toLowerCase() === String(name).toLowerCase() + ); + if (!key) throw new Error('object "' + object.name + '": no animation preset "' + name + '" (' + Object.keys(presets).join(', ') + ')'); + s.animationPreview.applyPreset(key, object.uuid, object); + } + } + } // 28-G: THE TRACK. Into the Explorer (content-hashed, so re-runs dedupe) and into // the scene's music slot with playing OFF — the graph plays it from Start. The // hash is what a Sound node addresses, hence the `'$music'` remap below. diff --git a/tests/e2e/author-kit.test.cjs b/tests/e2e/author-kit.test.cjs new file mode 100644 index 00000000..e205a0bc --- /dev/null +++ b/tests/e2e/author-kit.test.cjs @@ -0,0 +1,244 @@ +// 30 author-kit: what a template/game DEF can say. The builder (scripts/author-templates.cjs) +// grew primitives, light kinds, physical/toon materials and object flags; each is only real +// if it survives the .tpscene round trip, so this suite AUTHORS a def-under-test through the +// real script (`--def --only --out `), then LOADS the written file back +// through sessions.readSessionZip + applySession — the Templates modal's own read path — and +// reads each object's geometry / material / userData / clip. One check per feature. +// +// Run: APP_URL=https://theprototype.app:5233/ npm run e2e -- author-kit +// (the authoring pass drives its own headless browser against the same APP_URL) +const h = require('./helpers.cjs'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const SLUG = 'author-kit-test'; + +/** The def-under-test: every new field once, on a named object. */ +const DEF = { + kind: 'template', + slug: SLUG, + title: 'Author kit test', + description: 'every author-kit field, once', + license: 'CC0-1.0', + author: 'theprototype', + objects: [ + { type: 'box', name: 'Floor', color: 0x808890, size: [20, 0.2, 20], pos: [0, -0.1, 0] }, + { type: 'box', name: 'Rounded', color: 0xd97706, size: [2, 1, 1.2], bevel: 0.2, pos: [-4, 0.5, 0] }, + { type: 'capsule', name: 'Capsule', color: 0x3b82f6, r: 0.4, h: 1.2, pos: [-2, 1, 0] }, + { type: 'plane', name: 'Plane', color: 0x22c55e, size: [2, 3], pos: [0, 1.5, -3] }, + { type: 'ring', name: 'Ring', color: 0xeab308, r: 1, inner: 0.6, pos: [2, 1.5, -3] }, + { type: 'icosahedron', name: 'Ico', color: 0xa855f7, r: 0.6, detail: 1, pos: [2, 0.6, 0] }, + { type: 'dodecahedron', name: 'Dodeca', color: 0xec4899, r: 0.6, pos: [4, 0.6, 0] }, + { + type: 'sphere', + name: 'Glass', + color: 0xffffff, + r: 0.6, + pos: [0, 0.6, 2], + clearcoat: 0.8, + clearcoatRoughness: 0.15, + transmission: 0.9, + thickness: 0.5, + ior: 1.45, + sheen: 0.4, + sheenColor: 0xff0000, + roughness: 0.1 + }, + { type: 'sphere', name: 'Plain physical', color: 0x999999, r: 0.3, pos: [-2, 0.3, 2], physical: true }, + { type: 'box', name: 'Glow', color: 0x111111, size: [0.5, 0.5, 0.5], pos: [2, 0.25, 2], emissive: 0x00ffcc, emissiveIntensity: 2.5 }, + { type: 'cone', name: 'Faceted', color: 0x999999, r: 0.4, h: 0.8, pos: [4, 0.4, 2], flatShading: true, side: 'double' }, + { type: 'sphere', name: 'Toon', color: 0x44aa88, r: 0.5, pos: [-4, 0.5, 2], toon: true }, + { type: 'box', name: 'Shell', color: 0xffffff, size: [8, 3, 0.1], pos: [0, 1.5, -5], opacity: 0.1, pick: 'through' }, + { type: 'cylinder', name: 'Coin', color: 0xfacc15, r: 0.3, h: 0.05, pos: [-4, 1.5, -2], rot: [Math.PI / 2, 0, 0], anim: 'Turntable' }, + { type: 'box', name: 'Door', color: 0x8b5a2b, size: [1, 2, 0.1], pos: [4, 1, -2], origin: [-0.5, 0, 0], anim: ['door', 'Pulse'] }, + { type: 'sphere', name: 'Fountain', color: 0x999999, r: 0.2, pos: [0, 0.2, 4], particles: { preset: 'sparkles', count: 33 } }, + { type: 'box', name: 'Brazier', color: 0x333333, size: [0.4, 0.4, 0.4], pos: [-2, 0.2, 4], particles: 'fire' }, + { type: 'light', kind: 'spot', name: 'Spot', color: 0xffeecc, intensity: 20, angle: 0.5, penumbra: 0.4, pos: [0, 6, 4], target: [0, 0, 0] }, + { type: 'light', kind: 'directional', name: 'Key', color: 0xffffff, intensity: 2, pos: [10, 14, 8], target: [0, 0, 0], shadowMapSize: 1024 }, + { type: 'light', kind: 'hemisphere', name: 'Fill', color: 0xbbddff, groundColor: 0x332211, intensity: 0.6, pos: [0, 5, 0] }, + { type: 'light', name: 'Bulb', color: 0xffaa55, intensity: 3, distance: 8, pos: [3, 2, 3] } + ] +}; + +/** Author the def through the real script into a fresh temp folder; returns the .tpscene + * bytes and the thumbnail bytes. @param {any} def @param {string} tag */ +function author(def, tag) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'author-kit-' + tag + '-')); + const file = path.join(dir, 'def.json'); + fs.writeFileSync(file, JSON.stringify(def)); + const out = path.join(dir, 'out'); + const script = path.join(__dirname, '../../scripts/author-templates.cjs'); + const log = execFileSync('node', [script, '--def', file, '--only', def.slug, '--out', out], { + env: { ...process.env, APP_URL: h.URL }, + encoding: 'utf8', + timeout: 240000 + }); + console.log(log.trim().split('\n').filter((l) => /B, thumb|WARN|FATAL|PAGEERROR/.test(l)).join('\n')); + const section = def.kind === 'game' ? 'games' : def.kind === 'template' ? 'templates' : 'examples'; + const base = path.join(out, section, def.slug); + return { + scene: fs.readFileSync(path.join(base, 'scene.tpscene')), + thumb: fs.existsSync(path.join(base, 'thumb.webp')) ? fs.readFileSync(path.join(base, 'thumb.webp')) : null + }; +} + +/** Load a .tpscene into the page through the Templates modal's own read path. */ +async function load(page, bytes) { + return page.evaluate(async (b64) => { + const s = window.__stores; + const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + const payload = await s.sessions.readSessionZip(bin.buffer); + if (!payload) return false; + await s.sessions.applySession(payload, { backup: false, replicate: false }); + return true; + }, bytes.toString('base64')); +} + +h.run(async () => { + // ---- P0: primitives, lights, materials, flags ------------------------------------- + const authored = author(DEF, 'p0'); + check(authored.scene.length > 1000, 'the def authored a .tpscene (' + authored.scene.length + ' B)'); + + const browser = await h.launch({ args: h.GPU_ARGS }); + const peer = await h.setupPage(browser, 'author-kit', { context: { viewport: { width: 1540, height: 774 } } }); + const page = peer.page; + await page.waitForFunction(() => !!window.__stores?.sessions && !!window.__stores?.animationPreview, { timeout: 30000 }); + check(await load(page, authored.scene), 'the authored file loads through readSessionZip + applySession'); + await page.waitForTimeout(800); + + const r = await page.evaluate(() => { + const s = window.__stores; + /** @type {any} */ let group; + s.objectsGroup.subscribe((g) => (group = g))(); + const by = (/** @type {string} */ n) => group.getObjectByName(n); + const geo = (/** @type {string} */ n) => by(n)?.geometry; + const mat = (/** @type {string} */ n) => by(n)?.material; + /** @type {any} */ let anims; + s.animationPreview.animations.subscribe((v) => (anims = v))(); + const clipNames = (/** @type {string} */ n) => Object.values(anims[by(n)?.uuid]?.clips ?? {}).map((/** @type {any} */ c) => c.name); + const rounded = geo('Rounded'); + rounded.computeBoundingBox(); + const bb = rounded.boundingBox; + // a rounded box's corner is CUT: no vertex reaches the sharp corner's |x|+|y|+|z| + const pos = rounded.attributes.position.array; + let cornerMax = 0; + for (let i = 0; i < pos.length; i += 3) cornerMax = Math.max(cornerMax, Math.abs(pos[i]) + Math.abs(pos[i + 1]) + Math.abs(pos[i + 2])); + const aim = (/** @type {string} */ n, /** @type {number[]} */ t) => { + const l = by(n); + l.updateMatrixWorld(true); + const wp = l.getWorldPosition(new s.THREE.Vector3()); + const fwd = new s.THREE.Vector3(0, 0, -1).applyQuaternion(l.getWorldQuaternion(new s.THREE.Quaternion())); + return fwd.dot(new s.THREE.Vector3(...t).sub(wp).normalize()); + }; + const key = by('Key'); + const cam = key?.shadow?.camera; + return { + roundedType: rounded.type, + roundedVerts: rounded.attributes.position.count, + roundedSize: [bb.max.x - bb.min.x, bb.max.y - bb.min.y, bb.max.z - bb.min.z], + cornerMax, + capsule: geo('Capsule')?.type, + capsuleRadius: geo('Capsule')?.parameters?.radius, + plane: geo('Plane')?.type, + ring: geo('Ring')?.type, + ringInner: geo('Ring')?.parameters?.innerRadius, + ico: geo('Ico')?.type, + icoDetail: geo('Ico')?.parameters?.detail, + dodeca: geo('Dodeca')?.type, + glass: { + physical: !!mat('Glass')?.isMeshPhysicalMaterial, + clearcoat: mat('Glass')?.clearcoat, + clearcoatRoughness: mat('Glass')?.clearcoatRoughness, + transmission: mat('Glass')?.transmission, + thickness: mat('Glass')?.thickness, + ior: mat('Glass')?.ior, + sheen: mat('Glass')?.sheen, + sheenColor: mat('Glass')?.sheenColor?.getHexString() + }, + plainPhysical: !!mat('Plain physical')?.isMeshPhysicalMaterial, + floorStandard: mat('Floor')?.type, + glow: { emissive: mat('Glow')?.emissive?.getHexString(), intensity: mat('Glow')?.emissiveIntensity }, + faceted: { flat: mat('Faceted')?.flatShading, side: mat('Faceted')?.side, double: s.THREE.DoubleSide }, + toon: !!mat('Toon')?.isMeshToonMaterial, + shellPick: by('Shell')?.userData?.pick, + floorPick: by('Floor')?.userData?.pick ?? null, + coinClips: clipNames('Coin'), + doorClips: clipNames('Door'), + doorOrigin: by('Door')?.userData?.origin, + fountain: by('Fountain')?.userData?.particles, + brazier: by('Brazier')?.userData?.particles?.preset, + spot: { + is: !!by('Spot')?.isSpotLight, + angle: by('Spot')?.angle, + penumbra: by('Spot')?.penumbra, + shadow: by('Spot')?.castShadow, + aim: aim('Spot', [0, 0, 0]) + }, + key: { + is: !!key?.isDirectionalLight, + shadow: key?.castShadow, + aim: aim('Key', [0, 0, 0]), + frustum: cam ? [cam.left, cam.right, cam.bottom, cam.top, cam.near, cam.far] : null, + mapSize: key?.shadow?.mapSize?.x + }, + hemi: { is: !!by('Fill')?.isHemisphereLight, ground: by('Fill')?.groundColor?.getHexString() }, + bulb: { is: !!by('Bulb')?.isPointLight, distance: by('Bulb')?.distance } + }; + }); + console.log(JSON.stringify(r)); + + // primitives + check(r.roundedType === 'BufferGeometry', 'bevel: the rounded box is baked into a plain BufferGeometry (ObjectLoader rebuilds it) — ' + r.roundedType); + check(r.roundedVerts > 36, 'bevel: it carries the rounded tessellation (' + r.roundedVerts + ' verts)'); + check(r.roundedSize.every((v, i) => Math.abs(v - [2, 1, 1.2][i]) < 1e-3), 'bevel: its outer size is still the authored size ' + r.roundedSize.map((v) => v.toFixed(3))); + check(r.cornerMax < 2.1 - 0.05, 'bevel: the corners are cut (max |x|+|y|+|z| ' + r.cornerMax.toFixed(3) + ' < a sharp 2.1)'); + check(r.capsule === 'CapsuleGeometry' && Math.abs(r.capsuleRadius - 0.4) < 1e-6, 'capsule: CapsuleGeometry r=0.4 (' + r.capsule + ')'); + check(r.plane === 'PlaneGeometry', 'plane: PlaneGeometry (' + r.plane + ')'); + check(r.ring === 'RingGeometry' && Math.abs(r.ringInner - 0.6) < 1e-6, 'ring: RingGeometry inner 0.6 (' + r.ring + ')'); + check(r.ico === 'IcosahedronGeometry' && r.icoDetail === 1, 'icosahedron: IcosahedronGeometry detail 1 (' + r.ico + ')'); + check(r.dodeca === 'DodecahedronGeometry', 'dodecahedron: DodecahedronGeometry (' + r.dodeca + ')'); + // materials + check(r.glass.physical, 'physical: a physical-only field makes a MeshPhysicalMaterial'); + check(Math.abs(r.glass.clearcoat - 0.8) < 1e-6, 'physical: clearcoat 0.8 survives (' + r.glass.clearcoat + ')'); + check(Math.abs(r.glass.clearcoatRoughness - 0.15) < 1e-6, 'physical: clearcoatRoughness 0.15 survives'); + check(Math.abs(r.glass.transmission - 0.9) < 1e-6, 'physical: transmission 0.9 survives (' + r.glass.transmission + ')'); + check(Math.abs(r.glass.thickness - 0.5) < 1e-6, 'physical: thickness 0.5 survives'); + check(Math.abs(r.glass.ior - 1.45) < 1e-6, 'physical: ior 1.45 survives (' + r.glass.ior + ')'); + check(Math.abs(r.glass.sheen - 0.4) < 1e-6 && r.glass.sheenColor === 'ff0000', 'physical: sheen 0.4 + sheenColor #ff0000 survive (' + r.glass.sheenColor + ')'); + check(r.plainPhysical, 'physical: `physical: true` alone makes a MeshPhysicalMaterial'); + check(r.floorStandard === 'MeshStandardMaterial', 'an object using none of it stays MeshStandardMaterial (' + r.floorStandard + ')'); + check(r.glow.emissive === '00ffcc' && Math.abs(r.glow.intensity - 2.5) < 1e-6, 'emissive + emissiveIntensity 2.5 survive (' + JSON.stringify(r.glow) + ')'); + check(r.faceted.flat === true, 'flatShading survives'); + check(r.faceted.side === r.faceted.double, "side: 'double' survives as DoubleSide"); + check(r.toon, 'toon: MeshToonMaterial'); + // flags + check(r.shellPick === 'through', "pick: 'through' lands on userData.pick"); + check(r.floorPick === null, 'an unflagged object carries no pick key'); + check(r.coinClips.includes('Turntable'), 'anim: the Turntable preset is an authored clip on the coin (' + r.coinClips + ')'); + check(r.doorClips.includes('Door') && r.doorClips.includes('Pulse'), 'anim: a list of presets, by key or by name (' + r.doorClips + ')'); + check(Array.isArray(r.doorOrigin) && r.doorOrigin[0] === -0.5, 'origin: the hinge offset lands on userData.origin (' + JSON.stringify(r.doorOrigin) + ')'); + check(r.fountain?.preset === 'sparkles' && r.fountain?.count === 33 && r.fountain?.sprite === 'star', 'particles: a preset + a patch (count 33) on userData.particles'); + check(r.brazier === 'fire', "particles: a bare preset name ('fire')"); + // lights + check(r.spot.is && Math.abs(r.spot.angle - 0.5) < 1e-6 && Math.abs(r.spot.penumbra - 0.4) < 1e-6, 'spot: SpotLight with angle 0.5 + penumbra 0.4'); + check(r.spot.shadow === true, 'spot: casts shadows by default (createLight convention)'); + check(r.spot.aim > 0.999, 'spot: aimed by rotation at its target (forward . to-target = ' + r.spot.aim.toFixed(4) + ')'); + check(r.key.is && r.key.shadow === true, 'directional: DirectionalLight casting shadows'); + check(r.key.aim > 0.999, 'directional: aimed at its target (' + r.key.aim.toFixed(4) + ')'); + check( + !!r.key.frustum && r.key.frustum[1] > 5.5 && r.key.frustum[1] < 40 && r.key.frustum[4] > 0.1 && r.key.frustum[5] > r.key.frustum[4] + 10, + 'directional: its shadow frustum is FITTED to the scene, not three\'s default ±5 (' + (r.key.frustum ?? []).map((v) => v.toFixed(1)) + ')' + ); + check(r.key.mapSize === 1024, 'directional: shadowMapSize 1024 survives (' + r.key.mapSize + ')'); + check(r.hemi.is && r.hemi.ground === '332211', 'hemisphere: HemisphereLight with its ground colour'); + check(r.bulb.is && r.bulb.distance === 8, 'point: the existing point light is unchanged'); + + await h.finish(browser); +}); + +/** @param {boolean} ok @param {string} label */ +function check(ok, label) { + h.check(ok, label); +} From bafc4191cd5065418640d958acb1e0efc9e58177 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 00:59:30 +0300 Subject: [PATCH 2/4] =?UTF-8?q?[feat]=20author-kit=20p1:=20a=20custom=20sk?= =?UTF-8?q?y=20=E2=80=94=20gradient=20background,=20fog,=20ground,=20sun,?= =?UTF-8?q?=20hemi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a def `env` may be {preset:'custom' | , base?, exposure, background:'#hex' | {top, bottom}, fog:{color, near, far} | null, ground:{color, roughness?}, sun:{color, intensity, dir | position} | null, hemi:{sky, ground, intensity} | null}; colours may be numbers. It builds a payload from a base preset and commits it through the environment module's own custom path (applyCustomPreset), so the scene saves it as customPreset and a peer / late joiner gets it on the existing environment singleton. The payload's own exposure stays 1 and the def's exposure is the STATE multiplier (applyCustomPreset would otherwise square it). A plain preset env takes the old line verbatim - environment.js (ADDITIVE, absent on every stock preset and every older save): `gradient: {top, bottom}` -> a cached 2x256 CanvasTexture background (`background` keeps the flat horizon colour for the backgroundColor store and older peers); `ground: {color, roughness?}` -> an `env-ground` disc in ENV_ROOT (scene root, never synced or saved as an object) that receives the sun's shadows, so the ShadowMaterial catcher stands down while it shows; hidden in wireframe and in passthrough (it would cover the real room). Read only through skyGradientOf / skyGroundOf (a malformed value reads as absent) - the per-def reset uses environmentRestore(null) — setEnvironment('studio') kept a custom payload in the state, which environmentSnapshot would have saved into the NEXT def - suite author-kit: + a sky section (state, texture background, fog, ground visible, catcher down, sun dir, hemi, exposure not squared) and a PIXEL check on the loaded scene: sky bands R 206 vs 81 (upper redder), B 206 vs 81 (lower bluer) — 53 PASS - counterfactuals: the builder ignoring the gradient -> 4 FAIL (saved gradient, texture background, both pixel bands 34/34, 255/255); environment.js skyGradientOf -> null -> 3 FAIL (texture background + both pixel bands) - byte-identity: level-blockout/towers/stars-room/untangle/waves SAME vs the pre-lane build - held: templates-modal 47/0, template-modules 29/0 (= base); svelte-check 336/47 with an identical list; vitest 196; build green on this environment.js Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/author-templates.cjs | 69 ++++++++++++++++++++++++++- src/lib/environment.js | 89 +++++++++++++++++++++++++++++++++- tests/e2e/author-kit.test.cjs | 90 +++++++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 4 deletions(-) diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index 3a9a0b6a..ddbfc442 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -1732,7 +1732,70 @@ const DEFS = [ // ---- C5.3: the scene DATA a game carries beyond its objects ------------- // Each of these lands through the app's own write path, so what the script // produces is exactly what a user authoring by hand would have saved. - if (d.env) s.environment.setEnvironment(d.env.preset ?? d.env, d.env.exposure ?? 1); + // 30 author-kit: a CUSTOM sky. `env` is still a preset name or {preset, exposure} + // (unchanged path, byte-identical); a def that says preset:'custom' or overrides any + // sky field builds a custom payload from a base preset (`base`, else the named + // preset, else studio) and commits it through the environment module's own custom + // path (applyCustomPreset), so the scene saves it as `customPreset` and a peer or a + // late joiner receives it on the environment singleton like any authored sky. + // The payload's own `exposure` stays 1 — the def's exposure is the STATE multiplier + // (applyCustomPreset would otherwise square it). + const SKY_KEYS = ['background', 'fog', 'ground', 'sun', 'hemi']; + const env = d.env && typeof d.env === 'object' ? d.env : null; + /** @param {any} c */ + const col = (c) => (typeof c === 'number' ? hex(c) : c); + if (env && (env.preset === 'custom' || SKY_KEYS.some((k) => env[k] !== undefined))) { + const presets = s.environment.ENVIRONMENT_PRESETS; + const baseKey = env.base ?? (env.preset && env.preset !== 'custom' ? env.preset : 'studio'); + if (!presets[baseKey]) throw new Error('env: no base preset "' + baseKey + '"'); + const payload = JSON.parse(JSON.stringify(presets[baseKey])); + payload.label = env.label ?? 'Custom'; + payload.exposure = 1; + if (env.background !== undefined) { + if (env.background && typeof env.background === 'object') { + // a GRADIENT sky: `background` keeps a flat colour beside it (the horizon) + // for the backgroundColor store and for an older peer that ignores it + payload.gradient = { top: col(env.background.top), bottom: col(env.background.bottom) }; + payload.background = col(env.background.bottom); + } else payload.background = col(env.background); + } + if (env.fog !== undefined) + payload.fog = + env.fog === null + ? null + : { + ...(payload.fog ?? { color: payload.background, near: 30, far: 160 }), + ...env.fog, + ...(env.fog.color != null ? { color: col(env.fog.color) } : {}) + }; + if (env.ground) payload.ground = { color: col(env.ground.color), ...(env.ground.roughness != null ? { roughness: env.ground.roughness } : {}) }; + if (env.sun !== undefined) { + if (env.sun === null) payload.sun = null; + else { + const prev = payload.sun ?? { color: '#ffffff', intensity: 1.8, position: [6, 10, 4] }; + // `dir` points FROM the scene TOWARD the sun; the rig wants a position + const dir = env.sun.dir ? new T.Vector3(...env.sun.dir).normalize().multiplyScalar(16) : null; + payload.sun = { + color: env.sun.color != null ? col(env.sun.color) : prev.color, + intensity: env.sun.intensity ?? prev.intensity, + position: dir ? dir.toArray().map((v) => Math.round(v * 1000) / 1000) : env.sun.position ?? prev.position + }; + } + } + if (env.hemi !== undefined) { + if (env.hemi === null) payload.hemi = null; + else { + const prev = payload.hemi ?? { sky: '#ffffff', ground: '#4c525c', intensity: 1 }; + payload.hemi = { + sky: env.hemi.sky != null ? col(env.hemi.sky) : prev.sky, + ground: env.hemi.ground != null ? col(env.hemi.ground) : prev.ground, + intensity: env.hemi.intensity ?? prev.intensity + }; + } + } + s.environment.applyCustomPreset(payload); + s.environment.setEnvironment('custom', env.exposure ?? 1); + } else if (d.env) s.environment.setEnvironment(d.env.preset ?? d.env, d.env.exposure ?? 1); if (typeof d.gravity === 'number') s.scenePhysics.setSceneGravity(d.gravity); // B8: the whole scenePhysics block (ground/bounds/material/damping/play), not // just gravity — a physics GAME is authored in these numbers. setScenePhysics @@ -1966,7 +2029,9 @@ const DEFS = [ // leave no look or rule behind for the next def — a leaked sky or gravity is // exactly the bug A6 exists to fix, and it would be baked into the next scene s.commandsHandler.sceneCommand('/clear all'); - s.environment.setEnvironment('studio', 1); + // 30 author-kit: the FULL reset — setEnvironment('studio') keeps a custom payload + // in the state, and environmentSnapshot would then save it into the NEXT def + s.environment.environmentRestore(null, false); s.scenePhysics.resetSceneGravity(); s.restoreGraphs({}); // B8: every singleton a game def can now set — a null/empty restore is the diff --git a/src/lib/environment.js b/src/lib/environment.js index 8eaedb53..607d8a6d 100644 --- a/src/lib/environment.js +++ b/src/lib/environment.js @@ -88,6 +88,7 @@ export const ENV_ROOT = 'environment-root'; const RIG_HEMI = 'env-rig-hemi'; const RIG_SUN = 'env-rig-sun'; const CATCHER = 'env-shadow-catcher'; +const GROUND = 'env-ground'; const EXTRA_PREFIX = 'env-extra-'; let userLightFactor = 1; @@ -159,6 +160,73 @@ function shadowCatcher(scene, create) { return disc; } +/** + * 30 author-kit: a custom payload may carry two ADDITIVE sky fields, both absent on every + * stock preset and every older save, so those render exactly as before: + * gradient: {top, bottom} — a vertical background gradient. `background` stays a plain + * colour beside it (the `backgroundColor` store and an older peer, which ignores the + * gradient, read that one — authors set it to the horizon colour). + * ground: {color, roughness?} — a solid ground disc under the scene that RECEIVES the sun's + * shadows (so the ShadowMaterial catcher stands down while it shows). + * Both are read through these two normalizers only — a malformed value reads as absent. + * @param {any} preset @returns {{top: string, bottom: string} | null} */ +export function skyGradientOf(preset) { + const g = preset?.gradient; + return g && typeof g.top === 'string' && typeof g.bottom === 'string' ? { top: g.top, bottom: g.bottom } : null; +} + +/** @param {any} preset @returns {{color: string, roughness: number} | null} */ +export function skyGroundOf(preset) { + const g = preset?.ground; + if (!g || typeof g.color !== 'string') return null; + return { color: g.color, roughness: Number.isFinite(g.roughness) ? g.roughness : 1 }; +} + +/** @type {{key: string, texture: THREE.CanvasTexture} | null} one cached gradient texture */ +let gradientCache = null; + +/** A 2x256 vertical gradient as a screen-filling background texture (three stretches a + * plain texture background over the viewport). Cached by its two colours. + * @param {{top: string, bottom: string}} gradient */ +function gradientTexture(gradient) { + const key = gradient.top + '|' + gradient.bottom; + if (gradientCache?.key === key) return gradientCache.texture; + if (typeof document === 'undefined') return null; + const canvas = document.createElement('canvas'); + canvas.width = 2; + canvas.height = 256; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + const fill = ctx.createLinearGradient(0, 0, 0, 256); + fill.addColorStop(0, gradient.top); + fill.addColorStop(1, gradient.bottom); + ctx.fillStyle = fill; + ctx.fillRect(0, 0, 2, 256); + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + gradientCache?.texture.dispose(); + gradientCache = { key, texture }; + return texture; +} + +/** The solid ground disc (a scene-root mesh in ENV_ROOT, like the catcher, so it never + * enters objectsGroup, sync or a save — the payload's `ground` field is what travels). + * @param {any} scene @param {boolean} create */ +function groundDisc(scene, create) { + const root = envRoot(scene); + let disc = scene.getObjectByName(GROUND); + if (!disc && create) { + disc = new THREE.Mesh(new THREE.CircleGeometry(1, 64), new THREE.MeshStandardMaterial({ color: '#808080', roughness: 1 })); + disc.name = GROUND; + disc.rotation.x = -Math.PI / 2; + // under the catcher's plane and a hair under y = 0, so a floor authored AT 0 wins + disc.position.y = -0.01; + disc.receiveShadow = true; + root.add(disc); + } + return disc; +} + /** Create/update/remove `env-extra-*` lights to mirror state.lights @param {any} scene @param {any[]} defs */ function reconcileExtraLights(scene, defs) { const root = envRoot(scene); @@ -215,7 +283,9 @@ export function applyEnvironment() { scene.background = null; scene.fog = null; } else { - scene.background = new THREE.Color(preset.background); + // 30 author-kit: a gradient sky when the payload carries one, else the flat colour + const gradient = skyGradientOf(preset); + scene.background = (gradient && gradientTexture(gradient)) || new THREE.Color(preset.background); // fog never swallows a big scene: its reach grows with the scene bounds scene.fog = preset.fog ? new THREE.Fog( @@ -283,9 +353,24 @@ export function applyEnvironment() { // object to a real table (the sky/fog lift above is the whole AR stand-down; // the sun rig keeps casting untouched) const shadowsOff = shadowsDisabled(); + // 30 author-kit: an authored solid ground receives the shadows itself, so the catcher + // (which only darkens) stands down while the ground shows + // (and, like the sky, the ground lifts in passthrough: it would cover the real room) + const ground = skyGroundOf(preset); + const groundShown = !!ground && !wireframeActive() && !get(passthroughActive); + const disc = groundDisc(scene, !!ground); + if (disc) { + disc.visible = groundShown; + if (ground) { + disc.material.color.set(ground.color); + disc.material.roughness = ground.roughness; + } + const span = Math.max(200, sceneRadius() * 4); + disc.scale.set(span, span, span); + } const catcher = shadowCatcher(scene, !!(preset.sun && !shadowsOff)); if (catcher) { - catcher.visible = !!(preset.sun && !shadowsOff) && !wireframeActive(); + catcher.visible = !!(preset.sun && !shadowsOff) && !groundShown && !wireframeActive(); const span = Math.max(60, sceneRadius() * 2); catcher.scale.set(span, span, span); } diff --git a/tests/e2e/author-kit.test.cjs b/tests/e2e/author-kit.test.cjs index e205a0bc..df89df06 100644 --- a/tests/e2e/author-kit.test.cjs +++ b/tests/e2e/author-kit.test.cjs @@ -235,6 +235,96 @@ h.run(async () => { check(r.hemi.is && r.hemi.ground === '332211', 'hemisphere: HemisphereLight with its ground colour'); check(r.bulb.is && r.bulb.distance === 8, 'point: the existing point light is unchanged'); + // ---- P1: a custom sky --------------------------------------------------------------- + const SKY = { + kind: 'template', + slug: 'author-kit-sky', + title: 'Author kit sky', + description: 'a custom env: gradient sky, fog, ground, sun, hemi', + license: 'CC0-1.0', + author: 'theprototype', + env: { + preset: 'custom', + exposure: 1.2, + background: { top: '#ff2020', bottom: 0x2020ff }, + fog: { color: '#6070a0', near: 20, far: 90 }, + ground: { color: '#3a5a2a' }, + sun: { color: '#fff0dd', intensity: 2.2, dir: [1, 2, 0.5] }, + hemi: { sky: '#cfe0ff', ground: 0x303820, intensity: 0.7 } + }, + // looking steeply UP, so the whole frame is sky (no grid, no ground, no object) + view: { pos: [0, 1.6, 6], target: [0, 40, -12] }, + objects: [{ type: 'box', name: 'Block', color: 0x999999, size: [1, 1, 1], pos: [0, 0.5, 0] }] + }; + const sky = author(SKY, 'p1'); + check(await load(page, sky.scene), 'the custom-sky file loads'); + await page.waitForTimeout(1200); + const e = await page.evaluate(() => { + const s = window.__stores; + /** @type {any} */ let state; + s.environment.environment.subscribe((v) => (state = v))(); + /** @type {any} */ let scene; + s.globalScene.subscribe((v) => (scene = v))(); + /** @type {any} */ let renderer; + s.globalRenderer.subscribe((v) => (renderer = v))(); + const ground = scene.getObjectByName('env-ground'); + const catcher = scene.getObjectByName('env-shadow-catcher'); + return { + preset: state.preset, + exposure: state.exposure, + custom: state.customPreset, + backgroundIsTexture: !!scene.background?.isTexture, + fog: scene.fog ? { color: scene.fog.color.getHexString(), near: scene.fog.near } : null, + groundVisible: !!ground?.visible, + groundColor: ground?.material?.color?.getHexString(), + catcherVisible: !!catcher?.visible, + rendererExposure: renderer?.toneMappingExposure + }; + }); + console.log(JSON.stringify(e)); + const c = e.custom ?? {}; + check(e.preset === 'custom' && Math.abs(e.exposure - 1.2) < 1e-9, "env: preset 'custom' with the def's exposure 1.2 as the STATE multiplier"); + check(c.exposure === 1 && Math.abs(e.rendererExposure - 1.2) < 1e-6, 'env: exposure is applied once, not squared (renderer ' + e.rendererExposure + ')'); + check(c.gradient?.top === '#ff2020' && c.gradient?.bottom === '#2020ff', 'env: the gradient {top, bottom} is saved (numbers become #hex)'); + check(c.background === '#2020ff', 'env: `background` keeps a flat horizon colour beside the gradient (older peers read it)'); + check(e.backgroundIsTexture, 'env: the live scene background is the gradient texture'); + check(c.fog?.color === '#6070a0' && c.fog?.near === 20 && e.fog?.color === '6070a0', 'env: fog {color, near, far} is applied'); + check(c.ground?.color === '#3a5a2a' && e.groundVisible && e.groundColor === '3a5a2a', 'env: the ground disc shows in the authored colour'); + check(!e.catcherVisible, 'env: the shadow catcher stands down while the ground receives the shadows'); + const sunDir = c.sun?.position ? c.sun.position.map((v) => v / Math.hypot(...c.sun.position)) : []; + const want = [1, 2, 0.5].map((v) => v / Math.hypot(1, 2, 0.5)); + check(sunDir.length === 3 && sunDir.every((v, i) => Math.abs(v - want[i]) < 1e-3) && c.sun.intensity === 2.2 && c.sun.color === '#fff0dd', 'env: sun {color, intensity, dir} -> the rig sun position along dir'); + check(c.hemi?.sky === '#cfe0ff' && c.hemi?.ground === '#303820' && c.hemi?.intensity === 0.7, 'env: hemi {sky, ground, intensity}'); + + // the pixels: the sky's upper band is red-heavy and its lower band blue-heavy + const frame = await h.grabFrame(peer); + const bands = await page.evaluate( + async ({ b64 }) => { + const img = new Image(); + img.src = 'data:image/png;base64,' + b64; + await img.decode(); + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + // clear of the Connect pill (top centre) and the Controls HUD (bottom centre) + const band = (/** @type {number} */ y0, /** @type {number} */ y1) => { + const x0 = Math.round(img.width * 0.08); + const w = Math.round(img.width * 0.22); + const d = ctx.getImageData(x0, Math.round(img.height * y0), w, Math.round(img.height * (y1 - y0))).data; + const sum = [0, 0, 0]; + for (let i = 0; i < d.length; i += 4) for (let k = 0; k < 3; k++) sum[k] += d[i + k]; + return sum.map((v) => v / (d.length / 4)); + }; + return { top: band(0.18, 0.26), bottom: band(0.74, 0.82) }; + }, + { b64: frame.toString('base64') } + ); + console.log('sky bands', JSON.stringify(bands)); + check(bands.top[0] - bands.bottom[0] > 60, 'pixels: the upper sky is redder than the lower (R ' + bands.top[0].toFixed(0) + ' vs ' + bands.bottom[0].toFixed(0) + ')'); + check(bands.bottom[2] - bands.top[2] > 60, 'pixels: the lower sky is bluer than the upper (B ' + bands.bottom[2].toFixed(0) + ' vs ' + bands.top[2].toFixed(0) + ')'); + await h.finish(browser); }); From c87fbb4bfad57f614ca1e45d3bf8cf984f6aa5a6 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 01:22:40 +0300 Subject: [PATCH 3/4] [feat] author-kit p2: game cards rendered with the game's own look, on the real GPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the card renders with the SCENE's look: the live background (flat or the p1 gradient texture), its fog, the environment rig cloned from the scene root (hemi + sun + catcher / ground + extra env lights, at the intensities the viewport uses) and the authored lights (spot/directional targets seated on their -Z forward, the lightHelpers rule); shadows on (PCF). The private hemi + directional pair is the fallback ONLY for a scene with no light - tone mapping = the post stack's Tone mapping curve when it has one, else NONE — what a composed desktop frame shows (the default Shaded+AO view keeps the composer running, and the renderer's own ACES never reaches a composed frame — the documented gotcha). MEASURED: ACES darkened the Waves card to 0.040 centre luminance against the live frame's 0.11-0.13; none reads 0.086. `thumb.toneMapping: 'agx'|'aces'|'neutral'|'reinhard'|'cineon'|'linear'| 'none'` overrides per def - camera: `thumb.camera`, else the def's `view` (NEW — the view the file opens on), else a 3/4 PROJECTED fit of the content box (camera markers and floor-like slabs excluded — a thin mesh covering over a quarter of the footprint), so a 30x40 m ground no longer shrinks every game piece to a speck. Camera markers are hidden in every card now (chrome, not scenery) - the authoring browser uses the per-platform ANGLE backend (vulkan on Linux, the helpers.cjs GPU_ARGS): `--use-angle=gl` fell back to SwiftShader, so every card was a software render. The run now prints its GPU line and WARNs on SwiftShader - suite author-kit: + card checks — the sky def's card shows the authored gradient (top R 242 / bottom B 242), the studio kit card reads (0.416 > 0.2), and the Waves def from the sibling modules checkout shows its OWN sunset sky (51,32,48 vs the scene's 51,32,47) and grew past the old card (2110 B vs 1690). 58 PASS - DEVIATION (QUESTIONS-30-author-kit fork 1, recommendation taken): the brief's "Waves card > 6 KB with centre luminance > 0.2" is not reachable truthfully — the Waves scene is dark (its live frames read 0.11-0.13 on the same metric) and the OLD grey-lit card read 0.198, so a > 0.2 check would pass the wrong card. The 0.2 bar is asserted on a lit controlled scene - counterfactual: HEAD's old thumbnail block restored (private lights, grey, whole-bounds) -> 3 FAIL: the gradient card (top R 80 / bottom B 80), the Waves size (1696 B) and the Waves sky (34,42,51 = the private grey) - scene content: level-blockout/towers/stars-room/untangle/waves canonical session.json SAME as the pre-lane build (only the card and the inline thumbnail sizes differ — new renderer, and the inline one now comes from a real GPU) - no src change in this commit: svelte-check 336/47 and build as at p1; held suites as at p1 Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/author-templates.cjs | 156 ++++++++++++++++++++++++++++++---- tests/e2e/author-kit.test.cjs | 92 ++++++++++++++++++-- 2 files changed, 223 insertions(+), 25 deletions(-) diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index ddbfc442..06ef52e7 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -1330,9 +1330,21 @@ const DEFS = [ ]; (async () => { + // 30 author-kit: the REAL GPU. `--use-angle=gl` fell back to SwiftShader on Linux, so every + // card was a software render. The ANGLE backend is per platform — tests/e2e/helpers.cjs + // GPU_ARGS, which is where the measurements behind it live. + const angle = process.platform === 'win32' ? 'd3d11' : process.platform === 'darwin' ? 'metal' : 'vulkan'; const browser = await chromium.launch({ headless: true, - args: ['--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--use-gl=angle', '--use-angle=gl'] + args: [ + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--use-gl=angle', + '--use-angle=' + angle, + ...(angle === 'vulkan' ? ['--enable-features=Vulkan'] : []), + '--enable-gpu', + '--ignore-gpu-blocklist' + ] }); const ctx = await browser.newContext({ ignoreHTTPSErrors: true }); await ctx.addInitScript(() => { @@ -1345,6 +1357,14 @@ const DEFS = [ await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction(() => window.__stores && !!window.__stores.sessions, { timeout: 40000 }); await page.waitForTimeout(2000); + // 30 author-kit: say which GPU the thumbnails are rendered on — a SwiftShader card is + // not the one a user's display would show, and nothing else in the run would tell + const gpu = await page.evaluate(() => { + const gl = document.createElement('canvas').getContext('webgl2'); + const info = gl?.getExtension('WEBGL_debug_renderer_info'); + return info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : 'unknown'; + }); + console.log('GPU: ' + gpu + (/swiftshader/i.test(gpu) ? ' (WARN software rendering)' : '')); /** @type {Record} */ const built = {}; @@ -1974,40 +1994,136 @@ const DEFS = [ const bytes = await s.sessions.exportSessionZip(payload, { assets: !!music || sounds.length > 0, packs: false, flow: true }); // fitted offscreen thumbnail — the sessions.js renderSceneThumbnail - // approach at card size (480x270 webp) + // approach at card size (480x270 webp). + // 30 author-kit P2: THE CARD LOOKS LIKE THE GAME. It used to light the scene with a + // private hemisphere + directional pair on a fixed grey, which is why a sunset game + // read as a grey box. Now it renders with the scene's OWN look: the live background + // (flat colour or the gradient texture), its fog, the environment rig cloned from the + // scene root (hemi + sun + shadow catcher / ground + extra env lights, at the + // intensities the viewport uses) and the authored lights; the private pair is the + // fallback ONLY when the scene has no light at all. Shadows on (PCF). Tone mapping + // follows the post stack's Tone mapping curve when it has one, else the renderer's + // own ACES Filmic — what a play-mode frame shows — at the environment's exposure. + // The camera is `thumb.camera`, else `view`, else a 3/4 framing of the CONTENT: the + // objects' bounds without the camera markers and without floor-like slabs (a 30x40 m + // ground framed whole leaves every game piece a speck — the 1690-byte Waves card). let thumb = null; try { const T = s.THREE; + /** @type {any} */ let liveScene; + s.globalScene.subscribe((v) => (liveScene = v))(); + /** @type {any} */ let liveRenderer; + s.globalRenderer.subscribe((v) => (liveRenderer = v))(); const renderer = new T.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(480, 270); + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = T.PCFShadowMap; + /** @type {any} */ let post; + s.scenePost?.scenePost?.subscribe((/** @type {any} */ v) => (post = v))(); + const tonemap = post?.enabled !== false ? (post?.effects ?? []).find((/** @type {any} */ fx) => fx.kind === 'tonemapping' && fx.enabled !== false) : null; + const CURVES = { AGX: T.AgXToneMapping, ACES_FILMIC: T.ACESFilmicToneMapping, NEUTRAL: T.NeutralToneMapping, REINHARD: T.ReinhardToneMapping, CINEON: T.CineonToneMapping, LINEAR: T.LinearToneMapping }; + // what the DESKTOP frame shows: a composed frame (the default Shaded+AO view mode + // keeps the composer running) never receives the renderer's own ACES — only a stack + // Tone mapping entry maps it (the "renderer.toneMapping NEVER REACHES A COMPOSED + // FRAME" gotcha). So: the stack's curve, else none. `thumb.toneMapping` overrides. + const PICK = { agx: 'AGX', aces: 'ACES_FILMIC', neutral: 'NEUTRAL', reinhard: 'REINHARD', cineon: 'CINEON', linear: 'LINEAR' }; + const forced = d.thumb?.toneMapping; + renderer.toneMapping = + forced === 'none' + ? T.NoToneMapping + : forced + ? (/** @type {any} */ (CURVES)[/** @type {any} */ (PICK)[forced] ?? ''] ?? T.NoToneMapping) + : tonemap + ? (/** @type {any} */ (CURVES)[tonemap.params?.mode ?? 'AGX'] ?? T.AgXToneMapping) + : T.NoToneMapping; + renderer.toneMappingExposure = liveRenderer?.toneMappingExposure ?? 1; const scene = new T.Scene(); - scene.background = new T.Color('#232a33'); - scene.add(new T.HemisphereLight(0xffffff, 0x444466, 2.2)); - const sun = new T.DirectionalLight(0xffffff, 1.4); - sun.position.set(6, 10, 4); - scene.add(sun); + const bg = liveScene?.background; + scene.background = bg?.isColor ? bg.clone() : bg ?? new T.Color('#232a33'); + if (liveScene?.fog) scene.fog = liveScene.fog.clone(); + const envRoot = liveScene?.getObjectByName('environment-root'); + if (envRoot) scene.add(envRoot.clone(true)); const clone = new T.ObjectLoader().parse(group.toJSON()); scene.add(clone); // 21-C C6-b: a module's WORLD lives at the scene root (golden rule 5), so a // card rendered from objectsGroup alone shows a dungeon template as a lone // arch. `thumb.sceneGroups` names scene-root groups to include — cloned into // the offscreen scene, never moved; absent, the picture is what it always was. - /** @type {any} */ let liveScene; - s.globalScene.subscribe((v) => (liveScene = v))(); for (const name of d.thumb?.sceneGroups ?? []) { const live = liveScene?.getObjectByName(name); if (live) clone.add(live.clone(true)); else console.log(' WARN thumb.sceneGroups: no scene-root group named ' + name); } + // camera markers are chrome, not scenery + clone.traverse((/** @type {any} */ n) => { + if (n.userData?.camera) n.visible = false; + }); + scene.updateMatrixWorld(true); + // a spot/directional shines along its -Z (24-E1) — lightHelpers seats its target + // there every frame in the live app; nothing does in this offscreen scene + let lights = 0; + scene.traverse((/** @type {any} */ n) => { + if (!n.isLight || !n.visible || !(n.intensity > 0)) return; + let shown = true; + for (let p = n.parent; p; p = p.parent) if (!p.visible) shown = false; + if (!shown) return; + lights++; + if ((n.isSpotLight || n.isDirectionalLight) && n.parent !== scene.getObjectByName('environment-root')) { + const at = n.getWorldPosition(new T.Vector3()); + const fwd = new T.Vector3(0, 0, -1).applyQuaternion(n.getWorldQuaternion(new T.Quaternion())); + n.target.position.copy(at).add(fwd.multiplyScalar(10)); + scene.add(n.target); + n.target.updateMatrixWorld(true); + } + }); + if (!lights) { + scene.add(new T.HemisphereLight(0xffffff, 0x444466, 2.2)); + const sun = new T.DirectionalLight(0xffffff, 1.4); + sun.position.set(6, 10, 4); + scene.add(sun); + } const box = new T.Box3().setFromObject(clone); - const size = Math.max(box.getSize(new T.Vector3()).length(), 1); - const center = box.getCenter(new T.Vector3()); - let camera = new T.PerspectiveCamera(40, 480 / 270, size / 100, size * 10); - camera.position.copy(center).add(new T.Vector3(size * 0.55, size * 0.42, size * 0.72)); + // the CONTENT box: every visible mesh except floor-like slabs (thin, and covering + // over a quarter of the whole footprint); the whole box when nothing else is left + const whole = box.getSize(new T.Vector3()); + const footprint = Math.max(whole.x * whole.z, 1e-6); + const content = new T.Box3(); + clone.traverse((/** @type {any} */ n) => { + if (!n.isMesh || !n.visible || n.userData?.camera) return; + const b = new T.Box3().setFromObject(n); + if (b.isEmpty()) return; + const sz = b.getSize(new T.Vector3()); + const slab = sz.y < 0.05 * Math.max(sz.x, sz.z) && (sz.x * sz.z) / footprint > 0.25; + if (!slab) content.union(b); + }); + const frame = content.isEmpty() ? box : content; + const center = frame.getCenter(new T.Vector3()); + // FIT the box to the 16:9 frame from the 3/4 direction: every corner q (relative + // to the centre) needs |q.right| <= tanH * depth and |q.up| <= tanV * depth, where + // depth = dist - q.dir — so the distance is the max over the eight corners (a + // bounding sphere wastes the card's width on a box that is long and low) + const fov = 40; + const dir = new T.Vector3(0.55, 0.42, 0.72).normalize(); + const fwd = dir.clone().negate(); + const right = new T.Vector3().crossVectors(fwd, new T.Vector3(0, 1, 0)).normalize(); + const up = new T.Vector3().crossVectors(right, fwd); + const tanV = Math.tan(((fov / 2) * Math.PI) / 180); + const tanH = tanV * (480 / 270); + let dist = 1; + for (const x of [frame.min.x, frame.max.x]) + for (const y of [frame.min.y, frame.max.y]) + for (const z of [frame.min.z, frame.max.z]) { + const q = new T.Vector3(x, y, z).sub(center); + const along = q.dot(dir); + dist = Math.max(dist, Math.abs(q.dot(right)) / tanH + along, Math.abs(q.dot(up)) / tanV + along); + } + dist *= 1.08; + const span = Math.max(whole.length(), dist * 2); + let camera = new T.PerspectiveCamera(fov, 480 / 270, Math.max(dist / 200, 0.01), dist + span * 4); + camera.position.copy(center).add(dir.clone().multiplyScalar(dist)); camera.lookAt(center); // 28-G: `thumb.camera` renders the card THROUGH a named camera object — the - // hero shot the def already authored — instead of the fitted 3/4 view. Camera - // markers are chrome, not scenery, so they stay out of that picture. + // hero shot the def already authored — instead of the fitted 3/4 view. const hero = d.thumb?.camera ? group.getObjectByName(d.thumb.camera) : null; if (hero) { group.updateMatrixWorld(true); @@ -2015,9 +2131,13 @@ const DEFS = [ camera = new T.PerspectiveCamera(spec.fov ?? 50, 480 / 270, spec.near ?? 0.1, spec.far ?? 1000); hero.getWorldPosition(camera.position); hero.getWorldQuaternion(camera.quaternion); - clone.traverse((/** @type {any} */ n) => { - if (n.userData?.camera) n.visible = false; - }); + } else if (d.view) { + // the editor view the file opens on — the author already chose it + /** @type {any} */ let editorCam; + s.globalCamera.subscribe((v) => (editorCam = v))(); + camera = new T.PerspectiveCamera(editorCam?.fov ?? 50, 480 / 270, 0.1, 2000); + camera.position.set(d.view.pos[0], d.view.pos[1], d.view.pos[2]); + camera.lookAt(d.view.target[0], d.view.target[1], d.view.target[2]); } renderer.render(scene, camera); thumb = renderer.domElement.toDataURL('image/webp', 0.82); diff --git a/tests/e2e/author-kit.test.cjs b/tests/e2e/author-kit.test.cjs index df89df06..666a0d10 100644 --- a/tests/e2e/author-kit.test.cjs +++ b/tests/e2e/author-kit.test.cjs @@ -63,21 +63,29 @@ const DEF = { }; /** Author the def through the real script into a fresh temp folder; returns the .tpscene - * bytes and the thumbnail bytes. @param {any} def @param {string} tag */ + * bytes and the thumbnail bytes. A STRING is a slug the script already knows (a DEFS entry + * or a module-owned def from the sibling modules checkout). + * @param {any} def @param {string} tag */ function author(def, tag) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'author-kit-' + tag + '-')); - const file = path.join(dir, 'def.json'); - fs.writeFileSync(file, JSON.stringify(def)); + const slug = typeof def === 'string' ? def : def.slug; + const args = ['--only', slug, '--out', path.join(dir, 'out')]; + if (typeof def !== 'string') { + const file = path.join(dir, 'def.json'); + fs.writeFileSync(file, JSON.stringify(def)); + args.unshift('--def', file); + } const out = path.join(dir, 'out'); const script = path.join(__dirname, '../../scripts/author-templates.cjs'); - const log = execFileSync('node', [script, '--def', file, '--only', def.slug, '--out', out], { + const log = execFileSync('node', [script, ...args], { env: { ...process.env, APP_URL: h.URL }, encoding: 'utf8', timeout: 240000 }); - console.log(log.trim().split('\n').filter((l) => /B, thumb|WARN|FATAL|PAGEERROR/.test(l)).join('\n')); - const section = def.kind === 'game' ? 'games' : def.kind === 'template' ? 'templates' : 'examples'; - const base = path.join(out, section, def.slug); + console.log(log.trim().split('\n').filter((l) => /B, thumb|WARN|FATAL|PAGEERROR|GPU/.test(l)).join('\n')); + const section = ['games', 'templates', 'examples', 'contests'].find((d) => fs.existsSync(path.join(out, d, slug))); + if (!section) return { scene: Buffer.alloc(0), thumb: null }; + const base = path.join(out, section, slug); return { scene: fs.readFileSync(path.join(base, 'scene.tpscene')), thumb: fs.existsSync(path.join(base, 'thumb.webp')) ? fs.readFileSync(path.join(base, 'thumb.webp')) : null @@ -325,6 +333,76 @@ h.run(async () => { check(bands.top[0] - bands.bottom[0] > 60, 'pixels: the upper sky is redder than the lower (R ' + bands.top[0].toFixed(0) + ' vs ' + bands.bottom[0].toFixed(0) + ')'); check(bands.bottom[2] - bands.top[2] > 60, 'pixels: the lower sky is bluer than the upper (B ' + bands.bottom[2].toFixed(0) + ' vs ' + bands.top[2].toFixed(0) + ')'); + // ---- P2: a thumbnail that looks like the game ----------------------------------------- + // The card is rendered with the SCENE's look — its background (flat or gradient), fog, + // environment rig and authored lights — not a private grey studio. Three measurements: + // · the SKY def's card (its `view` looks straight up) shows the authored gradient; + // · the P0 def's card (studio + its own lights) reads (centre luminance > 0.2); + // · the Waves def from the sibling modules checkout — the 1690-byte near-blank case to + // beat — shows its OWN sunset sky, not the old grey, and carries more picture. + // (Waves itself is dark: its live frames read 0.11-0.13 on this metric, so a truthful card + // cannot clear 0.2 until its look is raised — QUESTIONS-30-author-kit fork 1.) + const stats = (/** @type {Buffer | null} */ webp) => + page.evaluate(async (b64) => { + if (!b64) return null; + const img = new Image(); + img.src = 'data:image/webp;base64,' + b64; + await img.decode(); + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + const mean = (/** @type {number} */ x, /** @type {number} */ y, /** @type {number} */ w, /** @type {number} */ hh) => { + const d = ctx.getImageData(Math.round(x), Math.round(y), Math.round(w), Math.round(hh)).data; + const sum = [0, 0, 0]; + for (let i = 0; i < d.length; i += 4) for (let k = 0; k < 3; k++) sum[k] += d[i + k]; + return sum.map((v) => v / (d.length / 4)); + }; + const W = img.width; + const H = img.height; + const c = mean(W / 4, H / 4, W / 2, H / 2); + return { + w: W, + h: H, + // the centre clip: the middle half on each axis, Rec.709 luma in 0..1 + lum: (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255, + top: mean(0, 0, W, H * 0.12), + bottom: mean(0, H * 0.88, W, H * 0.12), + corner: mean(4, 4, 24, 16) + }; + }, webp ? webp.toString('base64') : null); + + const skyCard = await stats(sky.thumb); + console.log('sky card', JSON.stringify(skyCard)); + check(!!skyCard && skyCard.w === 480 && skyCard.h === 270, 'card: 480x270'); + check(!!skyCard && skyCard.top[0] - skyCard.bottom[0] > 60 && skyCard.bottom[2] - skyCard.top[2] > 60, + 'card: the sky def card shows the authored GRADIENT (top R ' + skyCard?.top[0].toFixed(0) + ' / bottom B ' + skyCard?.bottom[2].toFixed(0) + ')'); + const kitCard = await stats(authored.thumb); + console.log('kit card', JSON.stringify(kitCard)); + check(!!kitCard && kitCard.lum > 0.2, 'card: a lit studio scene reads (centre luminance ' + kitCard?.lum.toFixed(3) + ' > 0.2)'); + + const wavesDef = [path.resolve(__dirname, '../../../modules'), path.resolve(__dirname, '../../../theprototype.app-modules')] + .map((root) => path.join(process.env.MODULES_REPO || root, 'modules/waves/waves.def.json')) + .find((f) => fs.existsSync(f)); + if (!wavesDef) console.log('SKIP waves card: no modules/waves/waves.def.json in the sibling modules checkout'); + else { + const wavesEnv = JSON.parse(fs.readFileSync(wavesDef, 'utf8')).env; + const waves = author('waves', 'p2'); + const size = waves.thumb?.length ?? 0; + const card = await stats(waves.thumb); + const want = await page.evaluate((preset) => { + const hexc = window.__stores.environment.ENVIRONMENT_PRESETS[preset]?.background ?? '#000000'; + const n = parseInt(hexc.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + }, wavesEnv?.preset ?? wavesEnv); + console.log('waves card', size, JSON.stringify(card), 'scene background', want); + check(size > 1690 * 1.2, 'waves: the card carries more picture than the old 1690-byte render (' + size + ' B)'); + check(!!card && card.corner.every((v, i) => Math.abs(v - want[i]) < 12), + "waves: the card's sky is the scene's OWN background " + JSON.stringify(want) + ', not a private grey (' + card?.corner.map((v) => v.toFixed(0)) + ')'); + if (process.env.AUTHOR_KIT_SAVE && waves.thumb) fs.writeFileSync(process.env.AUTHOR_KIT_SAVE, waves.thumb); + } + await h.finish(browser); }); From 713aabadf53b0ced3d7e4454530689ffee089035 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 01:34:50 +0300 Subject: [PATCH 4/4] [docs] author-kit p3: the def schema, every field one line each - a "THE DEF SCHEMA" block at the top of scripts/author-templates.cjs: def-level fields (kind, identity, modules/installModules, generate/layout, objects, env, gravity/physics, post, graphs/hud/shaders/animations, music/sounds, view, thumb {camera, sceneGroups, toneMapping}, contest), every object type with its params and defaults, the material fields (standard, physical, toon), the object flags (physics, shadow, pick, origin, anim, particles), the custom sky, the loader flags (--out, --only, --def, APP_URL, MODULES_REPO) and the card rules - comment-only: node --check clean; the modules repo AUTHORING.md table is handed to the integrator (this lane may not edit the modules repo) Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/author-templates.cjs | 72 ++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index 06ef52e7..8560ae11 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -39,6 +39,78 @@ // editor camera the file opens on) · `thumb.camera` (render the card through a named // camera object). A def with `music` exports WITH assets, so the bytes ride the .tpscene. // +// ==== THE DEF SCHEMA (30 author-kit) — every field, one line each ====================== +// Colours are 0xRRGGBB numbers or '#rrggbb' strings; positions/rotations are [x, y, z] +// (rotation in radians, Euler XYZ); lengths in metres. Every field is OPTIONAL unless marked *; +// an absent field is the old behaviour, so a def only states what it means to change. +// +// DEF (the file / the card): +// kind* 'template' | 'example' | 'game' | 'contest' — decides the folder + index section +// slug* title* description identity + card text; author, license ('CC0-1.0'), tags [] +// modules [{id, version}] — the card's module list (games only; must match installModules) +// installModules [''] — zips installed from MODULES_REPO before the build (the game shows) +// generate a /command, {menu, moduleId?, waitMs?}, or a list of them — run after the build +// generateWaitMs default wait after each generate step (2500) +// layout [{kind, index?, pos, yaw?}] — place generated devices by userData.device.kind +// objects* the scene: OBJECTS below +// env '' | {preset, exposure} | a CUSTOM sky (ENV below) +// gravity number (m/s², negative = down) · physics — a scenePhysics block, merged +// post a scenePost document (effects: AO, tone mapping, bloom, SMAA, ...) +// graphs {'scene' | : {nodes, edges}} — node data strings naming a def +// object become its uuid (not label/format/text/placeholder/name); '$music' and +// '$sound:' become content hashes +// hud a hudDocs map · shaders {'scene' | : shader graph document} +// animations {: an authored animation set (clips of tracks of keys)} +// music {url | file, sha256, name, volume?} — the scene's background track (+ Explorer) +// sounds [{key, url | file, sha256, name}] — one-shot assets for Sound nodes +// view {pos, target} — the editor camera the file opens on (also the card's camera) +// thumb {camera?: , sceneGroups?: [''], +// toneMapping?: 'agx'|'aces'|'neutral'|'reinhard'|'cineon'|'linear'|'none'} +// contest (kind contest) {brief, rules, durationDays, opensAfterDays, judging, credits} +// +// OBJECTS — {type*, name*, pos?, rot?, ...}: +// box size [w, h, d]; bevel (radius → a ROUNDED box, baked), bevelSegments (3) +// sphere r · cylinder r (top), r2 (bottom, = r), h +// cone r, h · torus r, tube (r × 0.2) +// capsule r, h (the straight part; `length` alias) +// plane size [w, h] — faces +Z (rot [-π/2, 0, 0] to lie flat) +// ring r (outer), inner (r × 0.5) — a flat annulus facing +Z +// icosahedron / dodecahedron r, detail (0) +// light kind 'point' (default: color, intensity, distance, decay — no shadow) +// | 'spot' (angle π/6, penumbra 0.3, distance, decay, target) | 'directional' +// (target; its shadow frustum is FITTED to the built meshes — `fit: false` to +// keep three's) | 'hemisphere' (color = sky, groundColor, intensity). +// spot/directional: castShadow (true), shadowMapSize, `target` = a WORLD point +// aimed by rotation — place a directional OUTSIDE the scene on its sun side +// camera lookAt, fov, aspect — the app's own /create Camera marker +// spline points [{pos, radius}], closed, color +// group / empty children [objects] (names resolve inside groups too) +// mirror of (a named object/group), opacity (0.15), prefix — reflected across x = 0 +// MATERIAL (every mesh type): color, roughness (0.85), metalness (0), emissive + +// emissiveIntensity (1), opacity (< 1 → transparent), flatShading, side ('double' | 'back'), +// toon (MeshToonMaterial), physical (MeshPhysicalMaterial — also implied by any of: +// clearcoat, clearcoatRoughness, transmission, thickness, ior, sheen, sheenColor, +// sheenRoughness, iridescence, specularIntensity) +// FLAGS (any object): physics {mode, mass, restitution, friction, ...} (userData.physics) · +// shadow false (no cast/receive) · pick 'through' (select-through shells: walls, glass) · +// origin [x, y, z] (the local pivot a Door preset swings about) · anim '' | [..] +// (door, drawer, elevator, turntable, pulse, fade — key or name; an AUTHORED clip, run it with +// a Play Animation node) · particles '' | {preset, ...overrides} (sparkles, fire, +// smoke, dust, confetti, sparks) +// ENV — a custom sky: {preset: 'custom' | '', base?: '', exposure, +// background: '#hex' | {top, bottom} (a gradient; `background` keeps the bottom colour), +// fog: {color, near, far} | null, ground: {color, roughness?} (a solid ground disc that takes +// the shadows), sun: {color, intensity, dir (FROM the scene TOWARD the sun) | position} | null, +// hemi: {sky, ground, intensity} | null}. Any of those keys (or preset 'custom') builds a custom +// payload from the base preset (default: the named preset, else studio); exposure is applied once. +// LOADER FLAGS: --out (scenes-repo tree + index.json) · --only (a subset, index +// MERGED) · --def (defs from JSON; a same-slug def REPLACES the built-in one) · +// env APP_URL, MODULES_REPO (the sibling modules checkout: zips + modules//.def.json) +// THE CARD: rendered with the scene's own look (background, fog, environment rig, authored +// lights, shadows), the post stack's tone curve else none, through thumb.camera, else view, +// else a 3/4 fit of the content (floor slabs excluded); the private studio pair only lights a +// scene with no light at all. +// // 24-A A3 (the PR #192 follow-up): the node/edge helpers are ONE module-scope // `graphBuilder()` and `remapData` walks every own string field. Both are meant to leave // every earlier def byte-identical, and that is CHECKED, not believed — build the same def