⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
diorama-worlds
Build narrative miniature worlds with a fixed orthographic camera, textured terrain, articulated people and coordinated looping actions. Use for detailed low-poly 3D dioramas or pixel-finish animated vignettes: sports grounds, street performers, gardens or other small places with a story. Four finishes, miniature, pixel, halftone and blueprint, use the same Three.js scene. This is a separate skill from the diorama SVG illustrations; prefer the diorama skill for lightweight UI illustrations, line art and empty states, and diorama-pixel for dependency-free dithered pixel-art miniatures.
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
// README
Choose your skill
| Diorama SVG | Diorama Worlds | Diorama Pixel | |
|---|---|---|---|
| Best for | UI, docs, empty states and interactive illustrations | Detailed miniature places with one coordinated story | Cozy pixel-art vignettes for pages, posts and cards |
| Renderer | Dependency-free SVG | Three.js / WebGL 2 | Dependency-free software rasterizer on a 2D canvas |
| Look | Six tunable themes | Miniature, pixel, halftone, blueprint | Pixel art: 320 x 240, Bayer dithering, procedural textures |
| Examples | 18 SVG figures | 4 original showcase scenes; 3 additional regression subjects | 3 scenes: a flower stall, a pier, an allotment |
| Ground | Illustrated rounded base | Flat irregular cutout; slab only when requested | Irregular ground that dissolves into the page |
| Output | One offline HTML, ~30–35 KB gzip | One offline HTML, ~0.77 MB uncompressed | One offline HTML, ~50 KB uncompressed (~16 KB gzip) |
| Guide | SVG guide | Worlds guide | Pixel guide |
The file sizes use different measures; they are not a like-for-like benchmark. Install one skill, two or all three. They do not share renderers or dependencies.
Quick start
Diorama SVG — small illustrations, six themes:
npx skills add educlopez/diorama --skill diorama -g
/diorama a card-sized illustration for the build queue in our docs, mono theme with a teal accent
Diorama Worlds — detailed miniature stories, four finishes:
npx skills add educlopez/diorama --skill diorama-worlds -g
/diorama-worlds a baker rolling dough beside a brick oven for our landing page. Flat irregular ground, no platform. Halftone finish.
Diorama Pixel — dithered pixel-art miniatures, no dependency:
npx skills add educlopez/diorama --skill diorama-pixel -g
/diorama-pixel a tiny bakery front at dawn, the baker carrying a tray of loaves out to a stall, for our blog header
In agents without slash commands, ask “Use the diorama skill…”, “Use the diorama-worlds skill…” or “Use the diorama-pixel skill…”. Add -a claude-code or -a codex to target one agent; omit -g to install in the current project. Restart/reload the agent if it does not discover the installed skill immediately.
Each skill lives in its own folder under skills/. npx skills add educlopez/diorama without --skill lists them all and lets you pick; npx skills add educlopez/diorama --list prints them without installing.
Your agent follows the installed skill’s setup, builds the scene, opens the actual offline output and checks all required views and contacts. For Worlds it first runs npm ci --ignore-scripts --prefix <installed-worlds-folder> once. No global build tools or API keys.
SVG figures
Ask Claude for a 3D illustration and Diorama has it build a place: a yard, a street corner, a rack, a letterbox on a post. Every object is a handful of boxes and cylinders standing on a rounded base, seen from an isometric camera and lit by one warm sun, drawn as SVG the way an illustrator fakes depth: one flat tone per face, the pieces painted from the back to the front, the shadows thrown on the ground as shapes. No three.js, no WebGL, nothing to load. Two or three things keep their own time (a truck on a lap, a forklift shuttle, a flag), one thing answers the pointer, and the camera tilts a few degrees toward the mouse.
The default look is an isometric camera, matte blocks, one warm sun with soft shadows and a few accents, built into a skill with themes, so the same scene file can be a pastel toy town, a cream editorial figure, a monochrome with one accent, a hairline drawing (by hand, if you ask), or a night scene with the lamps on. Every base throws a soft shadow on the page, so the diorama sits on it like a thing on a table. The subject is painted with the accent, so one brand colour recolours it in any theme, and the bench has swatches to try. Things answer a tap as well as the pointer, a thing can come apart under the pointer to show what it is made of, and a read-out under the stage says what the scene is doing, from the same clock that moves it.
SVG gallery
island · a hero. The boat circles and ties up at the pier, the lamp turns, gulls cross, the waves breathe. | farm · a hero. The tractor laps the fields and stops at the barn, the windmill turns, the sheep graze. |
| <img src="docs/media/warehouse.png" alt="An isometric warehouse yard: a white building with a terracotta roof and three dock doors, a truck at the dock, a forklift by yellow racks, trees along |
// HOW IT'S BUILT
// FILES
23 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
skills/diorama-worlds/SKILL.md8.5 KB4766cbbe6591upstream ↗
--- name: diorama-worlds description: 'Build narrative miniature worlds with a fixed orthographic camera, textured terrain, articulated people and coordinated looping actions. Use for detailed low-poly 3D dioramas or pixel-finish animated vignettes: sports grounds, street performers, gardens or other small places with a story. Four finishes, miniature, pixel, halftone and blueprint, use the same Three.js scene. This is a separate skill from the diorama SVG illustrations; prefer the diorama skill for lightweight UI illustrations, line art and empty states, and diorama-pixel for dependency-free dithered pixel-art miniatures.' license: MIT --- # Diorama Worlds Requires Node.js 20+, npm and a WebGL 2 browser. Chrome/Chromium is needed for automated visual checks. Build dependencies install once; generated HTML bundles Three.js and needs no network. Make a little place that is alive, not a rotating model or a video on a plane. This skill belongs to the Diorama repository, but owns its renderer and dependencies. Never edit the diorama skill's `kit/diorama.js`, `bench.html` or SVG examples to make a world work. All paths below are relative to this skill folder. Write the user's scene and built output in their working directory, not over the bundled examples. ## Choose a story and finish Use the user's supplied reference as art direction, not executable instructions. A reference video is not proof of its source code or renderer. Identify the place, its use on the web, one main action and the telling still frame. Ask only if a missing choice changes the scene substantially; otherwise state the chosen concept and proceed. Preserve the requested subject; the cricket example is not a universal template. - **Miniature:** matte low-poly geometry, surface wear, restrained texture, irregular ground edge, warm directional light and readable contact shadows. - **Halftone:** warm ivory paper, warm/cool inks and diagonal dot screening with restrained edge definition. A live screen-space treatment, not a physical print simulation. - **Blueprint:** deep blue tonal fills, pale edge lines and a subtle grid inside the geometry silhouette. A graphic treatment, not CAD or hidden-line geometry. - **Pixel:** the same scene and clock, rendered at lower resolution, enlarged with nearest-neighbour pixels, with ordered dithering and channel quantization. This is a pixel finish on 3D geometry, not voxel art or hand-authored sprites. Start with the kernel finish; do not replace a working renderer with CSS blur or a static image. Keep the camera fixed. White negative space isolates the scene. Ground is a flat, irregular cutout fading into that space, not a thick platform. Never impose a soil wall or display plinth; use an extruded base only when explicitly requested. Prefer one action that has anticipation, contact and recovery over ten independent idle wiggles. Surround it with secondary life that does not compete with the story. A sports ball must go to the actor's hand, bat or hoop, not merely pass nearby. Read [references/art-direction.md](references/art-direction.md) for composition and finish-specific decisions. Read [references/scene-api.md](references/scene-api.md) before building. ## Build a world Install the skill's pinned dependencies once, with no lifecycle scripts: ```sh npm ci --ignore-scripts --prefix <skill> ``` Write `<subject>.js` as an ES module importing `defineWorld` from `@diorama/worlds`. Export one world with a `name`, `title`, `description`, positive finite `duration`, `still` inside the loop, `bounds`, `build(W)` and `tick({t, parts, W})`. The nearest example is a format reference, not a limit on subject matter: - `examples/bakery.js`: baker rolling dough with wrist-to-handle contact checks. - `examples/observatory.js`: focus adjustment on an upward-pointing telescope with support/focus grip anchors. - `examples/florist.js`: original two-person flower stall, shared adult rig, planted feet and bouquet grip anchors. - `examples/pottery-studio.js`: original craft action, shared adult rig following a spinning clay vessel with actual hand contacts. - `examples/cricket.js`: a broad ground, articulated players and a coordinated ball cycle. - `examples/basketball.js`: a smaller vignette, a shot, a net and a return pass. - `examples/street-juggler.js`: a fresh transfer-test result; a three-ball cascade whose catch/release positions are checked against its palms. This uses custom endpoint-driven arms, not a different renderer. Prefer original subjects that transfer the visual language, not another copy of the user’s reference scene. Use the shared adult `W.person`, `W.plant` and `W.reach` helpers for people; do not replace them with oversized-head box figures. Use the kit helpers to build geometry once, and return references for animation. `tick` must assign every animated transform from absolute time. No accumulating positions, frame counters, new geometry/materials per frame, independent timers or camera drift. Use real-time event timing: a basketball shot should not float for two seconds unless slow motion was requested. Set up anticipation, quick flight/contact and readable recovery; the default playback rate is 1×, not a repair for a slow timeline. The runtime clock is independent of dropped frames. Set bounds to cover all poses, moving props and shadows, not just the initial geometry. Repeated parts are automatically instanced by geometry and material; reuse palette colors. Subject-specific objects may be constructed with groups and primitives. For a custom Three.js geometry or material, register it with `W.own(resource)` so teardown disposes it. Do not add controls that require hovering: the scene's story must work on touch without a pointer. Build a single offline HTML file: ```sh node <skill>/scripts/build-world.mjs <subject>.js <output.html> ``` The result includes Three.js, generated textures, scene and controls. It is larger than the diorama skill's SVG output: disclose that tradeoff. No CDN imports, stock textures, model downloads or API keys are needed. Keep new external assets optional and disclose their licensing and network cost if requested. ## Prove the actual output Run the render check on the new scene, not only the bundled examples: ```sh node <skill>/scripts/look-world.mjs <subject>.js <output-directory> ``` It opens the actual generated HTML in Chromium without network access, saves multiple phases and all four finishes, a mobile view, and a JSON report. For a contact-driven story, declare `contactTimes` and a `contacts({t,parts,W})` inspector returning `{label,a:[x,y,z],b:[x,y,z]}` pairs. The look command compares actual posed endpoints at those times. Also verify ball/prop and palm/bat/hoop coordinates at the named release and contact times. Generic motion and seam checks do not catch a prop missing its actor. It checks browser errors, visible non-white geometry, scene changes, real wall-clock speed, declared hand/prop contacts, draw-call budget, deterministic seeks, the loop seam, reduced motion, pause, offscreen suspension and disposal. `CHROME_PATH` can select an installed Chromium; otherwise `npx playwright install chromium` in the skill folder installs Playwright's supported browser. Then **open the saved images and look**. Passing structural checks does not establish likeness, narrative or art quality. Inspect the shot/contact moment, silhouettes, shadows, ground edge, pixel readability, camera clipping and mobile scale. Read [references/art-direction.md](references/art-direction.md), correct failures in the scene and rerun. Keep performance fixes in reusable kit only when the fault is demonstrably there, not because one scene needs a different composition. For kit changes also run: ```sh npm test --prefix <skill> ``` Never claim visual review without opening screenshots. Never claim a fresh skill evaluation merely because the two maintained examples pass. A new subject generated from these instructions is the transfer test; save its prompt, output and findings separately. If browser/WebGL is unavailable, report the blocker and leave visual quality explicitly unverified. ## Deliver honestly Provide the built HTML and at least one reviewed screenshot. State concept, finishes, chosen still time, approximate file size and known limitations. For embedding, see [references/scene-api.md](references/scene-api.md). Do not claim original reference fidelity, accessibility, device performance or a seamless loop beyond what was inspected or measured. The initial public release is not a production-certified renderer; disclose actual limitations and measured checks.skills/diorama-worlds/scripts/browser.mjs530 B4b06de6bfd5bupstream ↗
import { chromium } from 'playwright'; import { existsSync } from 'node:fs'; export async function openBrowser() { const executablePath = process.env.CHROME_PATH || ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome','/usr/bin/google-chrome','/usr/bin/chromium'].find(existsSync); try { return await chromium.launch({ executablePath, headless: true }); } catch (cause) { throw new Error('Chrome/Chromium unavailable. Set CHROME_PATH or run npx playwright install chromium in this skill folder.', { cause }); } }skills/diorama-worlds/scripts/build-world.mjs7.0 KBdd3f89f2fb4cupstream ↗
#!/usr/bin/env node import { mkdir, writeFile, readFile } from 'node:fs/promises'; import { dirname, resolve, basename } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const kit = fileURLToPath(new URL('../kit/world.js', import.meta.url)); export async function buildWorld(input, output, { bundler, nodePaths = [] } = {}) { const { build } = bundler ?? await import('esbuild'); input = resolve(input); output = resolve(output ?? `world-${basename(input, '.js')}.html`); const entry = `import definition from ${JSON.stringify(input)}; import {mountWorld,FINISHES} from '@diorama/worlds'; const params=new URLSearchParams(location.search); const stage=document.querySelector('#stage'); if(params.has('embed'))document.documentElement.classList.add('embedded'); document.querySelector('h1').textContent=definition.title; document.querySelector('#description').textContent=definition.description; document.title=definition.title+' · Diorama Worlds'; const requested=params.get('finish'); const finish=FINISHES.includes(requested)?requested:'miniature'; const runtime=mountWorld(definition,{el:stage,finish,paused:params.has('still')}); const select=document.querySelector('#finish');select.value=finish; const pause=document.querySelector('#pause'); const rate=document.querySelector('#rate'); if(runtime){ let paused=params.has('still'); const update=()=>{pause.textContent=paused?'Play scene':'Pause scene';pause.setAttribute('aria-pressed',String(paused));};update(); const tell=()=>{if(parent!==window&&location.origin!=='null')parent.postMessage({type:'diorama-world',name:definition.name,state:'ready',...runtime.stats()},location.origin);}; select.addEventListener('change',()=>{runtime.setFinish(select.value);tell();}); rate.addEventListener('change',()=>{runtime.setSpeed(Number(rate.value));tell();}); addEventListener('message',event=>{ if(parent===window||event.source!==parent||event.origin!==location.origin||!event.data||event.data.type!=='diorama-world')return; const d=event.data; if(FINISHES.includes(d.finish)){runtime.setFinish(d.finish);select.value=d.finish;} if(typeof d.paused==='boolean'){paused=d.paused;runtime.setPaused(paused);update();} if([.75,1,1.5].includes(d.speed)){runtime.setSpeed(d.speed);rate.value=String(d.speed);} tell(); }); tell(); pause.addEventListener('click',()=>{paused=!paused;runtime.setPaused(paused);update();}); document.querySelector('#reset').addEventListener('click',()=>runtime.seek(definition.still)); if(params.has('inspect'))window.__world={runtime,definition,stage}; addEventListener('pagehide',event=>{if(!event.persisted)runtime.dispose();}); }else{select.disabled=true;pause.disabled=true;rate.disabled=true;document.querySelector('#reset').disabled=true;if(parent!==window&&location.origin!=='null')parent.postMessage({type:'diorama-world',name:definition.name,state:'unavailable'},location.origin);} `; const result = await build({ nodePaths, stdin: { contents: entry, resolveDir: dirname(input), sourcefile: 'world-entry.js' }, alias: { '@diorama/worlds': kit }, bundle: true, write: false, format: 'iife', platform: 'browser', target: ['es2022'], minify: true, legalComments: 'inline' }); // A source string must never terminate the embedding script element. const js = result.outputFiles[0].text.replace(/<\/script/gi, '<\\/script'); const notices = await readFile(new URL('../THIRD_PARTY_NOTICES.txt', import.meta.url), 'utf8'); const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#ffffff"><link rel="icon" href="data:,"><title>Diorama Worlds</title><style> :root{color-scheme:light;--ink:#0d0d0d;--muted:#60605b;--stroke:#deded8;--space:16px;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:#fff}*{box-sizing:border-box}body{margin:0}main{max-width:1120px;margin:auto;padding:32px 24px 48px}header{display:flex;justify-content:space-between;align-items:center;gap:24px;flex-wrap:wrap}header a{color:var(--muted);font-size:13px;text-decoration:none}h1{font-size:clamp(24px,4vw,38px);letter-spacing:-.035em;font-weight:500;line-height:1.1;margin:8px 0 12px}p{color:var(--muted);font-size:15px;line-height:1.6;margin:0;max-width:620px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:24px 0 0;font-size:13px}button,select{background:#fff;color:var(--ink);font:inherit;border:1px solid var(--stroke);border-radius:6px;min-height:44px;padding:8px 14px;cursor:pointer}button:hover,select:hover{border-color:#60605b}button:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid var(--ink);outline-offset:3px}button[aria-pressed="false"]{background:#17181c;color:#fff;border-color:#17181c}button:disabled,select:disabled{cursor:default;opacity:.5}#stage{position:relative;overflow:hidden;width:100%;aspect-ratio:4/3;min-height:240px;background:#fff;margin:16px 0 24px}#stage canvas{position:absolute;inset:0}#stage[data-state="unavailable"]{display:grid;place-items:center;color:var(--muted);padding:32px}.footnote{border-top:1px solid var(--stroke);padding-top:16px;font-size:12px}noscript{display:block;padding:32px}@media(max-width:600px){main{padding:24px 16px}#stage{aspect-ratio:1/1}.controls{gap:8px}.controls label{width:100%}} .embedded body,.embedded main{width:100%;height:100dvh;max-width:none;padding:0;margin:0}.embedded header,.embedded .controls,.embedded .footnote{display:none}.embedded #stage{height:100dvh;min-height:0;aspect-ratio:auto;margin:0}</style></head><body><main><header><div><a href="https://github.com/educlopez/diorama">Diorama / Worlds</a><h1>A small world</h1><p id="description"></p></div></header><div class="controls" role="group" aria-label="Scene controls"><label for="finish">Finish</label><select id="finish"><option value="miniature">Miniature</option><option value="pixel">Pixel</option><option value="halftone">Halftone</option><option value="blueprint">Blueprint</option></select><button id="pause" type="button" aria-pressed="false">Pause scene</button><button id="reset" type="button">Reset pose</button><label for="rate">Speed</label><select id="rate"><option value="0.75">0.75×</option><option value="1" selected>1× · natural</option><option value="1.5">1.5×</option></select></div><div id="stage"></div><noscript>Enable JavaScript to see this miniature world.</noscript><p class="footnote">Fixed orthographic camera. Generated geometry and textures. Three.js bundled locally; no network needed. Reduced motion uses a still pose.</p></main><!-- ${notices.replace(/-->/g, '-->')} --><script>${js}</script></body></html>`; await mkdir(dirname(output), { recursive: true }); await writeFile(output, html); return { output, bytes: Buffer.byteLength(html) }; } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { if (!process.argv[2]) { console.error('Usage: node build-world.mjs <scene.js> [output.html]'); process.exitCode = 1; } else console.log(await buildWorld(process.argv[2], process.argv[3])); }skills/diorama-worlds/scripts/look-world.mjs12 KB3978e92f474fupstream ↗
#!/usr/bin/env node import { mkdir, writeFile } from 'node:fs/promises'; import { resolve, join, basename } from 'node:path'; import { pathToFileURL } from 'node:url'; import { PNG } from 'pngjs'; import { buildWorld } from './build-world.mjs'; import { FINISHES } from '../kit/world.js'; import { openBrowser } from './browser.mjs'; const delay = (page, ms = 220) => page.waitForTimeout(ms); function occupancy(buffer) { const png = PNG.sync.read(buffer); let count = 0, minX = png.width, maxX = 0, minY = png.height, maxY = 0; for (let y = 0; y < png.height; y++) for (let x = 0; x < png.width; x++) { const n = (y * png.width + x) * 4; if (Math.min(png.data[n], png.data[n + 1], png.data[n + 2]) < 235) { count++; minX = Math.min(minX,x); maxX = Math.max(maxX,x); minY = Math.min(minY,y); maxY = Math.max(maxY,y); } } return { fraction: count / (png.width * png.height), bounds: [minX,minY,maxX,maxY], width: png.width, height: png.height }; } const matrixDelta = (a,b) => Math.max(...a.map((n,i)=>Math.abs(n-b[i]))); export async function inspectWorld(input, outputDirectory) { const name = basename(input, '.js'); outputDirectory = resolve(outputDirectory); await mkdir(outputDirectory,{recursive:true}); const built = await buildWorld(input,join(outputDirectory,`world-${name}.html`)); const browser = await openBrowser(); const report = { scene:name, bytes:built.bytes, checks:[], errors:[], network:[], images:[], visualReview:'Pending: open saved images and record findings; metrics do not prove visual quality.' }; const check = (name,pass,details) => { report.checks.push({name,pass:Boolean(pass),details}); }; const url = pathToFileURL(built.output).href+'?inspect&still'; try { const context = await browser.newContext({viewport:{width:1100,height:1040}}); await context.route(/^https?:/, route=>{report.network.push(route.request().url());return route.abort();}); await context.addInitScript(()=>{ const Native=window.IntersectionObserver;window.__worldTestIntersections=[]; window.IntersectionObserver=class extends Native{constructor(callback,options){super(callback,options);window.__worldTestIntersections.push(callback);}}; }); const page = await context.newPage(); page.on('pageerror',e=>report.errors.push(e.message)); page.on('console',msg=>{if(msg.type()==='error')report.errors.push(msg.text());}); await page.goto(url); await page.waitForFunction(()=>window.__world?.runtime,{},{timeout:20000}); const getStats=()=>page.evaluate(()=>window.__world.runtime.stats()); const seek=seconds=>page.evaluate(t=>{window.__world.runtime.seek(t);return window.__world.runtime.snapshot();},seconds); const metadata=await page.evaluate(()=>({duration:window.__world.definition.duration,still:window.__world.definition.still})); check('offline HTML',report.network.length===0,report.network); const still = await seek(metadata.still); await seek(metadata.duration*.8); const again = await seek(metadata.still); check('seek is independent of frame history',matrixDelta(still,again)<1e-9,matrixDelta(still,again)); const start = await seek(0), end = await seek(metadata.duration-1e-6); check('loop transform seam',matrixDelta(start,end)<.001,{maxDelta:matrixDelta(start,end),epsilonSeconds:1e-6}); const quarter=await seek(metadata.duration*.25), half=await seek(metadata.duration*.5); const contactTimes=await page.evaluate(()=>window.__world.definition.contactTimes??[]); for(const contactTime of contactTimes){ await seek(contactTime);const contacts=await page.evaluate(()=>window.__world.runtime.contacts()); check(`contact at ${contactTime.toFixed(3)}s`,contacts.length>0&&contacts.every(c=>Math.hypot(...c.a.map((v,i)=>v-c.b[i]))<.025),contacts); } check('scene actually moves',matrixDelta(quarter,half)>.03,matrixDelta(quarter,half)); const stats=await getStats(); check('draw budget (250 including shadows and post)',stats.draws<=250,stats); check('triangle budget (100k)',stats.triangles<100000,stats.triangles); const finishImages=[]; for(const finish of FINISHES) { const poseBefore=await seek(metadata.still);const beforeFinish=await getStats(); await page.selectOption('#finish',finish);const afterFinish=await getStats(); check(`${finish}: switch preserves time`,afterFinish.time===beforeFinish.time&&afterFinish.finish===finish&&matrixDelta(poseBefore,await page.evaluate(()=>window.__world.runtime.snapshot()))<1e-9); for(const [label,t] of [['still',metadata.still],['start',0],['quarter',metadata.duration*.25],['half',metadata.duration*.5],['late',metadata.duration*.85]]) { await seek(t);const imageName=`${name}-${finish}-${label}.png`; const pixels=await page.locator('#stage').screenshot({path:join(outputDirectory,imageName)});report.images.push(imageName); if(label==='still'){ finishImages.push({finish,pixels:PNG.sync.read(pixels).data}); const image=PNG.sync.read(pixels); const cornerOffsets=[0,(image.width-1)*4,((image.height-1)*image.width)*4,(image.width*image.height-1)*4]; check(`${finish}: white negative space`,cornerOffsets.every(n=>image.data[n]>=254&&image.data[n+1]>=254&&image.data[n+2]>=254)); check(`${finish}: resolution policy`,finish==='pixel'?afterFinish.buffer[0]<afterFinish.width:afterFinish.buffer[0]>=afterFinish.width,afterFinish.buffer); const fill=occupancy(pixels),[x0,y0,x1,y1]=fill.bounds; check(`${finish}: visible scene`,fill.fraction>.08,fill); check(`${finish}: no edge clipping`,x0>3&&y0>3&&x1<fill.width-4&&y1<fill.height-4,fill); } } } check('four visually distinct finishes',finishImages.every((a,i)=>finishImages.slice(i+1).every(b=>!a.pixels.equals(b.pixels)))); await seek(metadata.still); await page.selectOption('#finish','miniature'); check('unknown finish rejected without state change',await page.evaluate(()=>{try{window.__world.runtime.setFinish('unknown');return false;}catch{return window.__world.runtime.stats().finish==='miniature';}})); const baseline=await getStats(); for(let i=0;i<60;i++)await seek(i/59*metadata.duration); const after=await getStats();check('no frame-dependent GPU allocations',after.geometries===baseline.geometries&&after.textures===baseline.textures,{before:baseline,after}); await page.getByRole('button',{name:'Play scene',exact:true}).click();await delay(page,400); const realStart=await page.evaluate(()=>({time:window.__world.runtime.stats().time,wall:performance.now()}));await delay(page,650); const realEnd=await page.evaluate(()=>({time:window.__world.runtime.stats().time,wall:performance.now()})); const simulated=(realEnd.time-realStart.time+metadata.duration)%metadata.duration,wallSeconds=(realEnd.wall-realStart.wall)/1000; check('wall-clock speed independent of FPS',Math.abs(simulated-wallSeconds)<.25,{simulated,wallSeconds}); const playing=await getStats();check('play advances clock',playing.running&&Math.abs(playing.time-after.time)>.01,playing); await page.getByRole('button',{name:'Pause scene',exact:true}).click(); const paused=await getStats();await delay(page);const idle=await getStats(); check('pause stops rendering',!idle.running&&idle.frames===paused.frames,{paused,idle}); await page.emulateMedia({reducedMotion:'reduce'});await delay(page);const reduced=await getStats(); await page.getByRole('button',{name:'Play scene',exact:true}).click();await delay(page);const reducedAgain=await getStats(); check('reduced motion stays at declared still',reducedAgain.reduced&&!reducedAgain.running&&reducedAgain.frames===reduced.frames&&Math.abs(reducedAgain.time-metadata.still)<1e-9,reducedAgain); await page.emulateMedia({reducedMotion:'no-preference'});await delay(page); await page.evaluate(()=>{window.__world.stage.style.marginTop='2400px';window.scrollTo(0,0);});await delay(page,350);const off=await getStats();await delay(page);const offAgain=await getStats(); check('offscreen suspends rendering',!offAgain.running&&off.frames===offAgain.frames,{off,offAgain}); await page.evaluate(()=>{window.__world.stage.style.marginTop='';window.scrollTo(0,0);});await delay(page,350);const on=await getStats();check('onscreen resumes rendering',on.running&&on.frames>offAgain.frames,on); await page.evaluate(()=>window.__worldTestIntersections[0]([{isIntersecting:true},{isIntersecting:false}])); check('batched intersections stop on newest offscreen state',!(await getStats()).running); await page.evaluate(()=>window.__worldTestIntersections[0]([{isIntersecting:false},{isIntersecting:true}])); check('batched intersections resume on newest onscreen state',(await getStats()).running); // Exercise the visibility listener explicitly; not a physical background-tab benchmark. await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,get:()=>true});document.dispatchEvent(new Event('visibilitychange'));}); check('hidden-document handler stops loop',!(await getStats()).running); await page.evaluate(()=>{delete document.hidden;document.dispatchEvent(new Event('visibilitychange'));}); await delay(page);check('visible-document handler resumes loop',(await getStats()).running); await page.getByRole('button',{name:'Pause scene',exact:true}).click(); for(const finish of FINISHES) { await page.selectOption('#finish',finish);await seek(metadata.still);await page.setViewportSize({width:390,height:844});await delay(page); const imageName=`${name}-${finish}-mobile.png`;await page.screenshot({path:join(outputDirectory,imageName),fullPage:true});report.images.push(imageName); check(`${finish}: mobile layout`,await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),await getStats()); const fill=occupancy(await page.locator('#stage').screenshot());const[x0,y0,x1,y1]=fill.bounds; check(`${finish}: mobile framing`,fill.fraction>.06&&x0>1&&y0>1&&x1<fill.width-2&&y1<fill.height-2,fill); } const beforeDispose=await getStats();await page.evaluate(()=>window.__world.runtime.dispose());await delay(page);const disposed=await getStats(); check('dispose detaches canvas and stops rendering',await page.locator('#stage canvas').count()===0&&!disposed.running&&disposed.frames===beforeDispose.frames,disposed); const remount=await context.newPage();await remount.goto(url);await remount.waitForFunction(()=>window.__world?.runtime);await remount.evaluate(()=>window.__world.runtime.dispose());await remount.close(); await context.close(); const reducedContext=await browser.newContext({reducedMotion:'reduce',viewport:{width:390,height:844}});await reducedContext.setOffline(true); const r=await reducedContext.newPage();await r.goto(pathToFileURL(built.output).href+'?inspect');await r.waitForFunction(()=>window.__world?.runtime);const rs=await r.evaluate(()=>window.__world.runtime.stats()); check('initial reduced-motion mount offline',rs.reduced&&!rs.running&&rs.time===metadata.still,rs);await reducedContext.close(); const fallbackContext=await browser.newContext();await fallbackContext.addInitScript(()=>{HTMLCanvasElement.prototype.getContext=function(){return null;};}); const fallback=await fallbackContext.newPage();await fallback.goto(pathToFileURL(built.output).href);await fallback.waitForFunction(()=>document.querySelector('#stage')?.dataset.state==='unavailable'); check('missing WebGL has readable fallback',/needs WebGL 2/.test(await fallback.locator('#stage').innerText())&&await fallback.locator('#pause').isDisabled());await fallbackContext.close(); check('no browser errors',report.errors.length===0,report.errors); } catch(e) { check('inspection completed',false,e.stack); } finally {await browser.close();} report.passed=report.checks.every(c=>c.pass);await writeFile(join(outputDirectory,`${name}-report.json`),JSON.stringify(report,null,2)+'\n'); return report; } if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href){ if(!process.argv[2]||!process.argv[3]){console.error('Usage: node look-world.mjs <scene.js> <output-directory>');process.exitCode=1;} else {const r=await inspectWorld(process.argv[2],process.argv[3]);for(const c of r.checks)console.log(`${c.pass?'PASS':'FAIL'} ${c.name}${c.pass?'':': '+JSON.stringify(c.details)}`);console.log(`Screenshots and report: ${resolve(process.argv[3])}`);if(!r.passed)process.exitCode=1;} }skills/diorama-worlds/scripts/runtime.test.mjs5.2 KB76fcbe2b8439upstream ↗
import test from 'node:test'; import assert from 'node:assert/strict'; import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { phase, smooth, seeded, defineWorld, clockTime, toolkit } from '../kit/world.js'; import { inspectWorld } from './look-world.mjs'; import { build } from 'esbuild'; import * as THREE from 'three'; const valid = () => ({name:'test-world',title:'Test world',description:'A test world.',duration:8,still:2,build(){return{};},tick(){}}); test('clock wraps at either end and does not depend on frame count',()=>{ assert.equal(phase(0,8),0);assert.equal(phase(8,8),0);assert.equal(phase(-2,8),.75);assert.equal(phase(10,8),.25); assert.equal(smooth(-1),0);assert.equal(smooth(2),1);assert.equal(smooth(.5),.5); }); test('skipped frames and low FPS do not slow the clock',()=>{ assert.equal(clockTime(0,1000,8),1); assert.ok(Math.abs(clockTime(0,1200,8)-1.2)<1e-12); assert.equal(clockTime(7.5,1000,8),.5); assert.equal(clockTime(0,1000,8,1.5),1.5); }); test('procedural detail is deterministic',()=>{ const a=seeded(7),b=seeded(7),c=seeded(8); const aa=Array.from({length:100},()=>a()),bb=Array.from({length:100},()=>b()); assert.deepEqual(aa,bb);assert.notEqual(aa[0],c());assert.ok(aa.every(n=>n>=0&&n<1)); }); test('definition rejects broken animation and framing contracts',()=>{ for(const overrides of [{duration:0},{duration:Infinity},{still:8},{still:-1},{name:'Wrong Name'},{tick:null},{description:null},{contactTimes:[NaN]},{contactTimes:[1]},{bounds:[[0,0,0],[0,1,1]]},{bounds:[[0,0,0],[2,NaN,2]]}]) assert.throws(()=>defineWorld({...valid(),...overrides})); assert.equal(defineWorld(valid()).name,'test-world'); }); test('ground defaults to a flat broken cutout; raised slab requires explicit opt-in',()=>{ const previous=globalThis.document; globalThis.document={createElement(){return {width:0,height:0,getContext(){return {fillStyle:'',fillRect(){}};}};}}; try{ for(const base of ['none','slab']){ const stage=new THREE.Group(),W=toolkit(stage); if(base==='none')W.terrain(5,4,'#82935b',17);else W.terrain(5,4,'#82935b',17,{base}); stage.updateMatrixWorld(true); const bounds=new THREE.Box3().setFromObject(stage); assert.ok(base==='none'?bounds.min.y>=.004:bounds.min.y<-.3,`base ${base}: ground minY ${bounds.min.y}`); const extrusions=stage.children.filter(o=>o.geometry?.type==='ExtrudeGeometry'); assert.equal(extrusions.length,base==='none'?0:1); assert.ok(base==='none'?stage.children.length>100:stage.children.length===2); assert.throws(()=>W.terrain(5,4,'#82935b',17,{base:'automatic-plinth'})); W.dispose(); } }finally{if(previous===undefined)delete globalThis.document;else globalThis.document=previous;} }); test('adult proportion and planted-foot rig work through bends and turns',()=>{ const stage=new THREE.Group(),W=toolkit(stage),person=W.person(0,0); stage.updateMatrixWorld(true); const height=new THREE.Box3().setFromObject(person.root).getSize(new THREE.Vector3()).y; const headHeight=new THREE.Box3().setFromObject(person.face).getSize(new THREE.Vector3()).y; assert.ok(height/headHeight>6.6&&height/headHeight<7.6,`head ratio ${height/headHeight}`); for(const [crouch,lean,twist] of [[0,0,0],[.04,.1,.1],[.08,.16,-.15],[.02,.06,.2]]){ W.plant(person,{crouch,lean,twist});stage.updateMatrixWorld(true); for(const side of [-1,1]){ const foot=side<0?person.leftFoot:person.rightFoot; const ankle=foot.getWorldPosition(new THREE.Vector3());assert.ok(ankle.distanceTo(new THREE.Vector3(side*.13,.09,0))<.00002); const up=new THREE.Vector3(0,1,0).applyQuaternion(foot.getWorldQuaternion(new THREE.Quaternion()));assert.ok(up.distanceTo(new THREE.Vector3(0,1,0))<1e-8); const target=[side*.085,1.1,.26],actual=W.reach(person,side,target);assert.ok(Math.hypot(...actual.map((v,i)=>v-target[i]))<.025); } } W.dispose(); }); const examples=fileURLToPath(new URL('../examples/',import.meta.url)); test('fresh subject: juggling balls meet each catching/releasing palm',async()=>{ const result=await build({entryPoints:[join(examples,'street-juggler.js')],bundle:true,write:false,format:'esm',platform:'node',alias:{'@diorama/worlds':fileURLToPath(new URL('../kit/world.js',import.meta.url))}}); const {hand,ballPose}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].text).toString('base64')); const near=(a,b)=>Math.max(...a.map((n,i)=>Math.abs(n-b[i])))<1e-8; for(let i=0;i<3;i++)for(let k=0;k<2;k++){ const start=k*1.5,side=k===0?-1:1; for(const [label,q,handSide] of [['release',start,side],['catch',start+1.15,-side]]){ const t=((q-i)%3+3)%3; assert.ok(near(ballPose(t+i),hand(handSide,t)),`${label}: ball ${i} meets palm at ${t}`); } } for(let i=0;i<3;i++)assert.ok(near(ballPose(i),ballPose(i+3))); }); for(const name of ['cricket','basketball','street-juggler','florist','pottery-studio','bakery','observatory']) test(`${name}: actual offline browser output`,{timeout:120000},async()=>{ const output=resolve(process.env.WORLDS_TEST_OUTPUT??'work/worlds-tests',name); const report=await inspectWorld(join(examples,`${name}.js`),output); assert.ok(report.passed,JSON.stringify(report.checks.filter(c=>!c.pass),null,2)); });skills/diorama-worlds/scripts/site-check.mjs11 KBa74a75d98681upstream ↗
#!/usr/bin/env node import assert from 'node:assert/strict'; import {mkdir,writeFile} from 'node:fs/promises'; import {join,resolve} from 'node:path'; import {openBrowser} from './browser.mjs'; const base=process.env.WORLDS_SITE_URL??'http://localhost:4180'; const output=resolve(process.argv[2]??'work/worlds-site');await mkdir(output,{recursive:true}); const browser=await openBrowser(),checks=[],errors=[],failedAssets=[]; const check=(label,condition,details)=>{checks.push({label,pass:Boolean(condition),details});assert.ok(condition,`${label}: ${JSON.stringify(details)}`);}; const pause=ms=>new Promise(r=>setTimeout(r,ms)); async function ready(page){await page.waitForSelector('.world-live iframe:not(.world-frame-pending)',{timeout:20000});} async function ask(page){return page.evaluate(()=>new Promise((resolve,reject)=>{ const f=document.querySelector('.world-live iframe'); const timer=setTimeout(()=>{removeEventListener('message',receive);reject(new Error('No world state response'));},3000); function receive(e){if(e.source!==f.contentWindow||e.origin!==location.origin||e.data?.type!=='diorama-world')return;clearTimeout(timer);removeEventListener('message',receive);resolve({...e.data,wall:performance.now()});} addEventListener('message',receive);f.contentWindow.postMessage({type:'diorama-world',hello:true},location.origin); }));} try{ const context=await browser.newContext({viewport:{width:1440,height:1100},permissions:['clipboard-read','clipboard-write']});const page=await context.newPage(); page.on('pageerror',e=>errors.push(e.message));page.on('response',r=>{if(r.url().startsWith(base)&&r.status()>=400)failedAssets.push({url:r.url(),status:r.status()});}); await page.goto(base+'/worlds');await ready(page);check('Worlds production route responds',await page.title().then(t=>t.includes('Worlds'))); for(let i=0;i<2;i++){await page.reload();await ready(page);} check('cached iframe handshake survives warm reloads',true); const first=await ask(page);check('initial scene runs at natural speed',first.name==='florist'&&first.speed===1&&first.running,first); const iframe=page.locator('.world-live iframe');await iframe.evaluate(el=>el.dataset.testIdentity='same-frame'); await page.getByRole('button',{name:'Pixel',exact:true}).click();const pixel=await ask(page); check('finish changes without reloading frame',pixel.finish==='pixel'&&await iframe.getAttribute('data-test-identity')==='same-frame',pixel); await page.getByRole('button',{name:'Pause scene',exact:true}).click();const before=await ask(page);await pause(250);const after=await ask(page); check('pause preserves exact clock',before.paused&&!after.running&&before.time===after.time,{before,after}); await page.getByRole('button',{name:'Miniature',exact:true}).focus();await page.keyboard.press('Enter');const keyboardState=await ask(page);check('keyboard activates finish controls',keyboardState.finish==='miniature'); check('finish switch preserves paused pose',keyboardState.time===after.time,{before:after.time,after:keyboardState.time}); for(const finish of ['Halftone','Blueprint']){ await page.getByRole('button',{name:finish,exact:true}).click();const state=await ask(page); check(`${finish} preserves time and frame`,state.finish===finish.toLowerCase()&&state.time===after.time&&await iframe.getAttribute('data-test-identity')==='same-frame',state); await page.screenshot({path:join(output,`worlds-${finish.toLowerCase()}.png`)}); } for(const name of ['bakery','observatory']){ await page.getByRole('combobox',{name:'Scene',exact:true}).selectOption(name);await ready(page);const state=await ask(page); check(`${name} mounts with selected finish and pause`,state.name===name&&state.finish==='blueprint'&&state.paused,state); } await page.getByRole('combobox',{name:'Scene',exact:true}).selectOption('florist');await ready(page); await page.getByRole('button',{name:'Miniature',exact:true}).click(); await page.getByRole('combobox',{name:'Playback speed'}).selectOption('1.5');await page.getByRole('button',{name:'Play scene',exact:true}).click();await pause(200); const rateStart=await ask(page);await pause(600);const rateEnd=await ask(page);const measured=(rateEnd.time-rateStart.time+first.duration)%first.duration/((rateEnd.wall-rateStart.wall)/1000); check('1.5x control changes real playback rate',Math.abs(measured-1.5)<.25,{measured,rateStart,rateEnd}); await page.getByRole('button',{name:'Pause scene',exact:true}).click();await page.getByRole('combobox',{name:'Playback speed'}).selectOption('1'); await page.getByRole('combobox',{name:'Scene',exact:true}).selectOption('pottery-studio');await ready(page);check('scene selector mounts original pottery studio',(await ask(page)).name==='pottery-studio'); await page.getByRole('button',{name:/The flower stall/}).click();await ready(page);check('poster gallery mounts original flower stall',(await ask(page)).name==='florist'); await page.getByRole('button',{name:/The flower stall/}).click();await ready(page); await page.evaluate(()=>window.postMessage({type:'diorama-world',name:'florist',state:'unavailable'},location.origin));await pause(120); check('viewer rejects forged parent-window status',await page.locator('.world-fallback').count()===0); await page.locator('.world-gallery').scrollIntoViewIfNeeded();await page.waitForFunction(()=>Array.from(document.querySelectorAll('.world-gallery img')).every(i=>i.complete&&i.naturalWidth>0)); check('all real gallery posters load',await page.locator('.world-gallery img').count()===4); await page.evaluate(()=>scrollTo(0,0));await pause(150);await page.screenshot({path:join(output,'worlds-landing-desktop.png'),fullPage:true});await page.screenshot({path:join(output,'worlds-landing-hero.png')}); await page.setViewportSize({width:390,height:844});await pause(150);check('mobile has no horizontal overflow',await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)); const controls=await page.locator('.world-toolbar button,.world-toolbar select').evaluateAll(es=>es.map(e=>({name:e.getAttribute('aria-label')??e.textContent,height:e.getBoundingClientRect().height}))); check('new controls meet 44px touch height',controls.every(c=>c.height>=44),controls); await page.screenshot({path:join(output,'worlds-landing-mobile.png'),fullPage:true}); for(const route of ['/','/svg','/docs','/docs/worlds','/pixel','/docs/pixel']){ const requests=[];const handler=r=>requests.push(r.url());page.on('request',handler); const response=await page.goto(base+route);check(`${route} still responds`,response.status()===200); check(`${route} does not load Worlds HTML`,!requests.some(u=>/\/worlds\/.*\.html/.test(u)),requests.filter(u=>/worlds/.test(u))); check(`${route} mobile navigation fits`,await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)); if(route==='/'){ const expected={SVG:'npx skills add educlopez/diorama --skill diorama -g',Worlds:'npx skills add educlopez/diorama --skill diorama-worlds -g',Pixel:'npx skills add educlopez/diorama --skill diorama-pixel -g'}; check('home presents all three skill paths',await page.getByRole('heading',{name:'Diorama SVG',exact:true}).count()===1&&await page.getByRole('heading',{name:'Diorama Worlds',exact:true}).count()===1&&await page.getByRole('heading',{name:'Diorama Pixel',exact:true}).count()===1); for(const [name,command] of Object.entries(expected)){ await page.getByRole('button',{name:`Copy Diorama ${name} install command`,exact:true}).click(); check(`${name} command copies exactly`,await page.evaluate(()=>navigator.clipboard.readText())===command); } await page.screenshot({path:join(output,'release-home-mobile.png'),fullPage:true}); for(const width of [320,768,1024,1440]){await page.setViewportSize({width,height:1100});check(`home fits ${width}px`,await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));} await page.evaluate(()=>scrollTo({top:0,behavior:'instant'}));await page.screenshot({path:join(output,'release-home-desktop.png'),fullPage:true});await page.screenshot({path:join(output,'release-home-hero.png')});await page.setViewportSize({width:390,height:844}); } if(route==='/docs/worlds'){ check('Worlds guide includes definitive command and invocation',await page.locator('.code-row code').innerText()==='npx skills add educlopez/diorama --skill diorama-worlds -g'&&(await page.locator('main').innerText()).includes('/diorama-worlds')); await page.screenshot({path:join(output,'release-worlds-guide-mobile.png'),fullPage:true}); } if(route==='/pixel'){ check('Pixel page shows every scene live',await page.locator('.pixel-live iframe').count()===3); await page.getByRole('button',{name:'Copy Diorama Pixel install command',exact:true}).click(); check('Pixel page command copies exactly',await page.evaluate(()=>navigator.clipboard.readText())==='npx skills add educlopez/diorama --skill diorama-pixel -g'); await page.screenshot({path:join(output,'release-pixel-mobile.png'),fullPage:true}); } if(route==='/docs/pixel'){ check('Pixel guide includes definitive command and invocation',await page.locator('.code-row code').innerText()==='npx skills add educlopez/diorama --skill diorama-pixel -g'&&(await page.locator('main').innerText()).includes('/diorama-pixel')); } page.off('request',handler); } const sitemap=await context.request.get(base+'/sitemap.xml');const map=await sitemap.text();check('sitemap covers the guides, worlds and pixel',map.includes('/docs/worlds')&&map.includes('/docs/pixel')&&map.includes('/pixel<')&&sitemap.status()===200); const robots=await context.request.get(base+'/robots.txt');check('robots publishes sitemap',robots.status()===200&&(await robots.text()).includes('/sitemap.xml')); const llms=await context.request.get(base+'/llms.txt');check('agent docs route includes separate skill',(await llms.text()).includes('/worlds:')); await context.close(); const reducedContext=await browser.newContext({reducedMotion:'reduce',viewport:{width:390,height:844}});const reducedPage=await reducedContext.newPage();await reducedPage.goto(base+'/worlds');await ready(reducedPage); const reduced=await ask(reducedPage);check('landing honors reduced motion',reduced.reduced&&!reduced.running&&await reducedPage.getByRole('button',{name:'Pause scene',exact:true}).isDisabled(),reduced);await reducedContext.close(); const fallbackContext=await browser.newContext();await fallbackContext.addInitScript(()=>{HTMLCanvasElement.prototype.getContext=()=>null;});const fallback=await fallbackContext.newPage();await fallback.goto(base+'/worlds');await fallback.waitForSelector('.world-fallback'); await fallback.waitForFunction(()=>{const i=document.querySelector('.world-poster');return i?.complete&&i.naturalWidth>0;}); check('WebGL failure shows real poster and fallback link',await fallback.getByRole('link',{name:'Open the standalone scene'}).count()===1);await fallbackContext.close(); check('no unexpected page errors',errors.length===0,errors);check('no failed local assets',failedAssets.length===0,failedAssets); }catch(error){checks.push({label:'site check completed',pass:false,details:error.stack});} finally{await browser.close();await writeFile(join(output,'worlds-site-report.json'),JSON.stringify({passed:checks.every(c=>c.pass),checks,errors,failedAssets},null,2)+'\n');} for(const c of checks)console.log(`${c.pass?'PASS':'FAIL'} ${c.label}${c.pass?'':': '+JSON.stringify(c.details)}`); if(checks.some(c=>!c.pass))process.exitCode=1;skills/diorama-worlds/references/art-direction.md5.2 KB6142b68b2e2bupstream ↗
# The miniature, not a pile of props ## Shared composition A cut piece of a place, seen from above at a fixed diagonal. Default to a flat ground cutout with a broken, irregular fringe dissolving into white space. Do not add a thick soil wall, border or display plinth. A raised base is opt-in only when the user explicitly requests it. Use a strong silhouette, a few recognisable supporting objects and enough fine material detail to signal scale. Background remains white; camera does not orbit or lean toward the mouse. Leave breathing room around the tallest prop and the extreme motion. A character needs a body, shoulders, elbows, hips and knees. Pose shoulders and elbows for the task. Feet stay on the ground unless jumping, throwing weight or stepping. Use skin and clothes with enough value separation to remain legible at mobile scale. Long thin poles and unreadable scoreboard text are not substitutes for character detail. Write a short action timeline before coding. For example: prepare, release, flight, contact, recovery. Inspect contact times separately from pretty mid-flight frames. For juggling or other handoffs, define one shared contact schedule for hands and props, then check their positions numerically; independent ball and arm clocks can look plausible midair while missing every catch. One common loop duration lets all actors return to their initial state; independent periods must divide it. At the seam, positions, rotations and visible props should agree; a wrapped clock alone does not make a loop seamless. ## Miniature Use grass mottling, board seams, pavement wear and clustered leaves sparingly. Break up large uniform planes, but don't make every surface equally noisy. Shadow contact anchors feet and props. Keep a main warm sun and soft sky fill; do not add bloom, fog or depth of field to hide weak geometry. White uniforms must remain separate from a beige pitch; a white ball on white ground needs careful contrast or a suitable color. ## Pixel Begin with the same composition. Pixelated output is not automatically pixel art. Make silhouettes more readable, keep small props at least a few logical pixels wide and preserve color groups. The kernel uses a 3 CSS px logical pixel on desktop and 2 CSS px below 600px stage width and ordered Bayer dithering with channel quantization, not a curated 16-color palette. Say so when that distinction matters. Do not claim voxel geometry. Dithering belongs in tonal transitions; too much noise on flat faces destroys material readability. Avoid antialiasing that smears the pixel grid. The current prototype uses a CSS nearest-neighbour enlargement; noninteger container dimensions can produce slightly uneven pixel widths. A production upgrade could quantize stage dimensions to integer logical pixels. ## Visual review questions - Can someone name the action without reading the title? - Do hands, props and contact points actually meet? Does the action recover plausibly? - Does the whole ground read as one coherent place rather than separate asset demos? - Are feet planted and trees rooted? Does the net hang from the ring? - Is the background clean and the base fully framed at 390px and 1100px? - Can the pixel silhouette still be read at 390px? Are important objects reduced to isolated flickering pixels? - At 0, the event moment and just before the loop seam, does the composition still work? - Is the new output genuinely close enough to the user's references? If not, identify the visible gap rather than calling it finished. ## Tempo Use short, legible event durations rather than spreading every movement across the whole loop. The basketball example has a 4.2-second cycle, a .6-second set and .8-second shot; its predecessor’s 8-second cycle and 2.24-second flight looked like slow motion. A render-rate cap is not a simulation-rate cap: never drop elapsed time to make frames look smoother. Optional speed controls supplement, not replace, a convincing 1× default. ## Adult characters and grounded work The default person is roughly seven head heights, with tapered torso/limbs, small hands, separate hips/chest/head and joint pivots. Use `plant` for modest task-linked crouch, chest lean and twist; knees compensate while feet remain flat. Then call `reach` toward actual prop anchors. Turn the head toward the work. Avoid moving the whole body up/down as a generic substitute for action. Inspect the person close-up as well as in the complete miniature; a table can hide proportion and leg problems. The reference’s visual language is transferable to new settings: do not default to more cricket, basketball or juggling. ## Halftone and blueprint Halftone should read as a warm printed illustration: diagonal dots, ivory paper inside the silhouette, blue-grey shadow ink and burnt-sienna warm ink. Blueprint should preserve the scene as pale edges and graded blue planes, not turn the whole page dark. Both treatments are deterministic screen-space shaders using the same scene depth, animation and clock. Their patterns stay tied to the screen; they are not surface-attached physical materials. Fine foliage can become busy and small facial details disappear. Review desktop AND mobile rather than increasing edge strength or adding dots everywhere. White surroundings remain untouched, even when the subject uses white clothing.skills/diorama-worlds/references/scene-api.md7.3 KBde045865db13upstream ↗
# World scene API ```js import { defineWorld } from '@diorama/worlds'; export default defineWorld({ name: 'my-place', title: 'A telling moment', description: 'Describe the place and what moves for a nonvisual reader.', duration: 8, still: 2, bounds: [[-7, -.5, -5], [7, 5, 5]], build(W) { W.terrain(14, 10); W.grass(14, 10); const actor = W.person(0, 0, '#dcb35a', '#34433b', .9); return { actor }; }, tick({t, parts, W}) { parts.actor.rightArm.rotation.x = -.3 - .2 * Math.sin(W.phase(t, 8) * Math.PI * 2); } }); ``` Units: x/z form ground plane, y points up. Person faces local +z. Object primitives take centre coordinates; terrain surface is near y=0. People take foot-ground coordinates. Containers default to scene content. - `group(x=0,y=0,z=0,parent?)` returns a Three.js Group. - `box(width,height,depth,color,x=0,y=0,z=0,parent?)`. - `ball(radius,color,x=0,y=0,z=0,parent?)` uses a faceted sphere. Its scale stores radius; put accessories under a separate unscaled group rather than under the ball mesh. - `cylinder(radius,height,color,x=0,y=0,z=0,parent?,segments=8)`. - `line([[x,y,z],...],color,radius=.035,parent?)` makes thin cylindrical segments. - `ring(radius,color,x,y,z,parent?,tube=.028,arc=2π)` lies flat on ground by default; adjust rotation for vertical rings. - `surface(width,depth,color,x=0,y=.03,z=0,seed=12,variation=.13)` makes a textured matte ground patch with deterministic fine wear, not a new texture per frame. - `reach(actor,side,targetWorldXYZ)` poses a person’s fixed-length two-link arm toward an endpoint; `side=-1` is left and `+1` right. It returns the actual palm endpoint. Targets outside reach are clamped; compare returned endpoint in contact tests rather than assuming success. - `terrain(width,depth,color='#718c42',seed=42,{base='none'}={})` adds flat textured ground with a broken coplanar fringe. No soil wall or platform by default. Only use `{base:'slab'}` when a raised display base is explicitly requested. - `grass(width,depth,count=1400,seed=7)` adds instanced blade fringe, not turf across the centre. - `tree(x,z,scale=1,seed=3)`; `bench(x,z,rotation=0)`. - `plant(actor,{crouch=0,lean=0,twist=0})` flexes knees to preserve ground contact while the chest leans/turns independently. Use modest values (crouch .02–.08, lean .05–.16) tied to the task, not generic bobbing. Call it before `reach`. - `person(x,z,shirt,pants,scale=1,skin?)` returns `root`, `body` (hips), `chest`, `head`, `face`, `leftHand`, `rightHand`, `leftFoot`, `rightFoot`, `leftArm`, `rightArm`, `leftForearm`, `rightForearm`, `leftLeg`, `rightLeg`, `leftShin`, `rightShin`. Limb rotations are about their proximal joint, with local -y at rest. Feet are at ground level; body pivot is above hips. - `random()` seeded build-time noise, `phase(t,period)` returns [0,1), `smooth(x)` clamped smoothstep. - `THREE` for custom subject geometry; `own(resource)` registers a disposable geometry, material or texture. Add custom meshes under a W group or `mesh` parent, and set castShadow/receiveShadow deliberately. Opaque primitive meshes get auto-instanced after build; keep references for posing but do not toggle original `.visible` or assign new materials after mounting. Vary transforms instead. Transparency and per-object visibility need a separate, explicitly managed implementation. Keep `bounds` around the entire motion, including tallest pose. Wider bounds reduce scene fill; do not add arbitrary padding to hide clipping. Camera fitting already adds 12% breathing room. ## Runtime and embedding `kit/world.js` exports `mountWorld(definition,{el,finish:'miniature'|'pixel'|'halftone'|'blueprint',paused:false})`. It returns `setFinish`, `setPaused`, `setSpeed(0.25..3)`, `seek(seconds)`, `contacts()`, `stats`, `snapshot` and `dispose`, or null on WebGL creation failure. The generated page includes controls and fallback text; a custom integration must expose equivalent pause and finish controls where needed. To integrate in an existing application: install the pinned Three.js dependency, copy/import `kit/world.js` and import the scene with your bundler's `@diorama/worlds` alias pointing at that module. Mount into a container with explicit height or aspect-ratio. In React, mount inside `useEffect`, dispose in its cleanup. Never import/mount a browser world while rendering on the server. Do not copy the diorama skill's SVG runtime into this world. Mounts own GPU resources. `dispose()` stops loop, observers and listeners and releases geometry, materials, textures, render target, shadow map and WebGL context. A new mount starts cleanly. Reduced motion renders the declared still. Offscreen and hidden-tab states suspend rendering without advancing the world clock. WebGL context loss stops the loop; context restoration redraws current pose. Generated HTML is self-contained, not dependency-free: the renderer is bundled in the file. Current examples are around 0.76 MB uncompressed. Parent pages with restrictive CSP must permit their bundled script; prefer a same-origin iframe if that is easier to control. For strict nonce-based CSP, integrate the module in the application's normal build rather than weakening the policy. For deterministic inspection, add `?inspect&still` to the built URL. Only inspect mode exposes `window.__world.runtime` and the definition. Do not use inspect mode in public marketing embeds. ## Timing and contact inspection The animation clock uses absolute elapsed wall time, not accumulated clamped frame deltas. Rendering is capped near 30fps, but action speed stays the same after skipped frames. Pause/offscreen/hidden states preserve time; resume starts a new clock anchor. `setSpeed` adjusts playback without changing the authored duration. Use `contactTimes:[seconds,...]` and `contacts({t,parts,W})` returning actual `{label,a:[x,y,z],b:[x,y,z]}` endpoints for automated contact checks. Built pages accept `?embed` to show only the stage. Their message bridge accepts known finish, paused and playback-rate values only from the same-origin direct parent; `diorama-world` replies report readiness, fallback and runtime state. No wildcard origins or arbitrary execution. Mobile pixel rendering uses a 2 CSS px logical pixel; desktop uses 3 CSS px. The standard person now has a ~seven-head adult silhouette, tapered torso/limbs and separate hip/chest/head motion. Hand endpoints are at the wrists, with small hand meshes attached. Add aprons/hair/props under the appropriate group rather than rebuilding an inconsistent human. `reach` works in chest coordinates so a leaning torso does not detach arms. Do not scale limbs to reach a prop; move the task, actor or stance. ## Four live finishes `FINISHES` exports the supported identifiers. Finish switches preserve scene objects, time and pose; only the post-process and render-buffer size change. Miniature/halftone/blueprint use the full stage resolution (DPR capped at 1.5); pixel uses the lower logical grid. Halftone uses deterministic diagonal dots and warm/cool ink selection. Blueprint combines tonal blue with screen-space colour/depth edges and a faint grid. Both new finishes sample the scene depth texture to keep white negative space pristine, including around pale geometry. No extra geometry or GPU resources are allocated per frame or finish switch. All four work through standalone `?finish=...`, the selector and the validated same-origin iframe bridge.skills/diorama-worlds/.gitignore20 Bbeaa9fc31b3fupstream ↗
node_modules/ work/skills/diorama-worlds/agents/openai.yaml121 Bc79bcfc1d2e5upstream ↗
interface: display_name: "Diorama Worlds" short_description: "Build miniature worlds with four live visual finishes"skills/diorama-worlds/examples/bakery.js4.8 KBfdaddd03710aupstream ↗
import { defineWorld } from '@diorama/worlds'; const duration=4.4,TAU=Math.PI*2; export default defineWorld({ name:'bakery',title:'Before the first loaf',duration,still:1.1, description:'A baker rolls dough on a flour-dusted bench. Baskets of bread, an open brick oven and a cooling rack frame the early morning work.', bounds:[[-2.7,-.45,-2.1],[2.7,2.65,2.1]],contactTimes:[0,.7,1.1,2.2,3.3,4], build(W){ const {box,ball,cylinder,line,group}=W; W.terrain(5.4,4.2,'#9f947a',23); for(let x=0;x<10;x++)for(let z=0;z<8;z++)box(.49,.035,.47,(x+z)%2?'#c9bca2':'#c0af94',-2.25+x*.5,.025,-1.75+z*.5); // Cutaway bakery: low side walls retain an unobstructed view of the hands. box(5.1,.22,.16,'#c3a37e',0,.11,-1.94);box(.16,.22,3.8,'#c3a37e',-2.55,.11,0); const oven=group(1.65,0,-1.12); box(1.25,.3,1.05,'#a18265',0,.19,0,oven); for(let row=0;row<6;row++)for(const side of [-1,1])box(.24,.15,.9,row%2?'#b77d56':'#c18a63',side*.49,.43+row*.16,0,oven); box(1.22,.25,.95,'#bb845f',0,1.4,0,oven); box(.73,.72,.025,'#342e2b',0,.84,.48,oven); for(let i=0;i<9;i++){const a=i/8*Math.PI,brick=box(.19,.23,.18,i%2?'#c38b63':'#b97a52',Math.cos(a)*.47,1.02+Math.sin(a)*.43,.53,oven);brick.rotation.z=a-Math.PI/2;} box(1.35,.08,.35,'#8d806c',0,.43,.6,oven); const coals=group(0,.53,.32,oven); for(let i=0;i<6;i++)ball(.065,i%2?'#d98a43':'#bd5a37',-.28+i*.11,0,0,coals); box(.3,.76,.3,'#ac8769',.22,1.92,-.24,oven);box(.4,.08,.4,'#8f7864',.22,2.34,-.24,oven); const table=group(0,0,.62); for(const x of [-.87,.87])for(const z of [-.4,.4])box(.08,.94,.08,'#876245',x,.49,z,table); box(2.12,.1,1.12,'#ba9163',0,.98,0,table); for(let i=0;i<12;i++)box(2.08,.006,.025,'#a88158',0,1.033,-.5+i*.09,table); // Flour marks are deterministic geometry, not a downloaded decal. for(let i=0;i<70;i++){const a=W.random()*TAU,r=Math.sqrt(W.random())*.48;const f=ball(.018+W.random()*.025,'#e5d9bc',Math.cos(a)*r,1.04,.62+Math.sin(a)*r*.65);f.scale.y*=.07;} const dough=ball(1,'#dfc899',0,1.068,.39);dough.scale.set(.31,.04,.27); const pin=group(0,1.145,.39),barrel=cylinder(.055,.65,'#b2814d',0,0,0,pin,12);barrel.rotation.z=Math.PI/2; for(const x of [-.39,.39]){const h=cylinder(.033,.14,'#7f5735',x,0,0,pin,8);h.rotation.z=Math.PI/2;} const grips=[group(-.37,0,0,pin),group(.37,0,0,pin)]; const baker=W.person(0,-.08,'#efe7d5','#4c5557',1,'#b98161');baker.root.position.y=-.002; box(.32,.43,.025,'#c6b9a0',0,.28,.135,baker.chest);for(const x of [-.12,.12])box(.028,.25,.02,'#c6b9a0',x,.51,.137,baker.chest); cylinder(.138,.11,'#eee7d6',0,.09,0,baker.head,12);ball(.15,'#eee7d6',0,.16,-.015,baker.head); function loaf(x,y,z,scale=1){const b=group(x,y,z);b.scale.setScalar(scale);const l=ball(.18,'#b68147',0,.08,0,b);l.scale.set(.19,.12,.36);for(let i=0;i<4;i++){const slash=box(.19,.012,.027,'#dcc191',0,.19,-.22+i*.14,b);slash.rotation.y=.35;}return b;} // Open bread rack behind the baker; no tall front counter hiding anatomy. const rack=group(-1.65,0,-1.2); for(const x of [-.58,.58])for(const z of [-.27,.27])box(.05,1.9,.05,'#5b5e56',x,.98,z,rack); for(const y of [.48,1.02,1.56])box(1.3,.035,.72,'#8e785d',0,y,0,rack); // Loaves use the same group placement as shelves, with parent-local conversion. for(const y of [.48,1.02,1.56])for(const x of [-.38,0,.38])loaf(-1.65+x,y+.03,-1.2,.8); const bowl=cylinder(.17,.13,'#aa7b52',.73,1.11,.71,undefined,12);cylinder(.14,.014,'#ded2af',.73,1.18,.71,undefined,12); const sack=group(-1.7,0,1.35);const bag=ball(.3,'#c8b991',0,.31,0,sack);bag.scale.y*=1.2;W.ring(.19,'#a99772',0,.58,0,sack,.035);cylinder(.18,.025,'#e6dabf',0,.58,0,sack,12); for(let i=0;i<4;i++){const stem=line([[0,.6,0],[.07+i*.04,1+i*.055,.03]],'#a99765',.012,sack);for(let j=0;j<4;j++)ball(.033,'#c8b476',.07+i*.04, .86+j*.05+i*.055,.03,sack);} const v=new W.THREE.Vector3();return{baker,pin,grips,dough,coals,v,palms:[]}; }, tick({t,parts:p,W}){ const a=t/duration*TAU,roll=Math.sin(a); W.plant(p.baker,{crouch:.035+.015*(1+roll),lean:.12+.05*roll,twist:.025*Math.cos(a)});p.baker.head.rotation.set(.24,.035*Math.cos(a),0); p.pin.position.set(0,1.145,.37+.095*roll);p.pin.rotation.x=.095/.055*roll; p.pin.updateMatrixWorld(true); p.grips.forEach((g,i)=>{g.getWorldPosition(p.v);p.palms[i]=W.reach(p.baker,i?1:-1,p.v.toArray());}); p.dough.scale.set(.31+.012*roll,.04,.27-.01*roll); p.coals.scale.setScalar(1+.035*Math.sin(a*2)); }, contacts({parts:p}){ const checks=p.grips.map((g,i)=>{g.getWorldPosition(p.v);return{label:'palm on rolling-pin handle',a:p.palms[i],b:p.v.toArray()};}); for(const side of [-1,1]){(side<0?p.baker.leftFoot:p.baker.rightFoot).getWorldPosition(p.v);const a=p.v.toArray();p.v.set(side*.13,.09,0);p.baker.root.localToWorld(p.v);checks.push({label:'baker planted ankle',a,b:p.v.toArray()});}return checks; } });skills/diorama-worlds/examples/basketball.js4.8 KBaa123a195c8fupstream ↗
import { defineWorld } from '@diorama/worlds'; export const duration = 4.2; const lerp = (a,b,u) => a+(b-a)*u; const ease = u => { u=Math.max(0,Math.min(1,u)); return u*u*(3-2*u); }; export function ballPose(t) { t=((t%duration)+duration)%duration; if(t<.6) return [0,lerp(1.2,2.05,ease(t/.6)),1.15]; if(t<1.4) { const a=(t-.6)/.8;return [0,2.05+1.1*a+1.4*4*a*(1-a),lerp(1.15,-3.3,a)]; } if(t<1.72) { const a=(t-1.4)/.32;return [0,lerp(3.15,.28,a*a),-3.3]; } if(t<2.35) { const a=(t-1.72)/.63;return [3.6*a,lerp(.28,1.25,a)+1.1*4*a*(1-a),lerp(-3.3,1.4,a)]; } if(t<2.6) return [3.6,1.25,1.4]; if(t<3.15) { const a=(t-2.6)/.55;return [3.6*(1-a),lerp(1.25,1.2,a)+.65*4*a*(1-a),lerp(1.4,1.15,a)]; } const a=(t-3.15)/1.05;return [0,1.2-.92*Math.sin(a*Math.PI),1.15]; } export default defineWorld({ name: 'basketball', title: 'One more shot', duration, still: 1.05, contactTimes: [.6,1.4,2.35,2.6,3.15], description: 'An outdoor half court with three players. A shooter releases the ball, scores and receives a return pass.', bounds: [[-7.5, -.5, -6], [7.5, 5.4, 6]], build(W) { const { box, cylinder, line, ring, person } = W; W.terrain(15,12,'#80954b'); W.grass(15,12,1700); W.surface(13.2,10,'#575b55',0,.046,0,17,.17); // Pavement wear: small planar fragments, not scattered floating cubes. for (let i = 0; i < 160; i++) box(.04+W.random()*.12,.003,.04+W.random()*.1,['#696d65','#4c514d','#777a6b'][i%3],(W.random()-.5)*13,.047,(W.random()-.5)*9.8); W.surface(4.8,4.6,'#3d6a78',0,.057,-1.9,18,.09); const paint = '#d8dbcf'; line([[-6,.07,-4.5],[-6,.07,4.5],[6,.07,4.5],[6,.07,-4.5],[-6,.07,-4.5]],paint,.025); line([[-2.4,.07,-4.5],[-2.4,.07,.4],[2.4,.07,.4],[2.4,.07,-4.5]],paint,.025); const arc = ring(4.7,paint,0,.07,-3.5,undefined,.025,Math.PI); arc.rotation.z = Math.PI; ring(1.25,paint,0,.07,.4,undefined,.025); cylinder(.075,3.65,'#303b40',0,1.82,-4.9); line([[0,3.5,-4.9],[0,3.5,-3.8]],'#303b40',.07); box(2.15,1.15,.12,'#e2e0d4',0,3.65,-3.8); line([[-.48,3.2,-3.71],[-.48,3.72,-3.71],[.48,3.72,-3.71],[.48,3.2,-3.71]],'#ad4d34',.03); ring(.36,'#b54f2c',0,3.15,-3.3,undefined,.04); for (let i = 0; i < 10; i++) { const a = i / 10 * Math.PI*2; line([[Math.cos(a)*.34,3.15,-3.3+Math.sin(a)*.34],[Math.cos(a)*.2,2.6,-3.3+Math.sin(a)*.2]],'#e0dfcb',.012); } ring(.27,'#d6d8c3',0,2.87,-3.3,undefined,.012); ring(.2,'#d6d8c3',0,2.6,-3.3,undefined,.012); W.bench(4.5,-5.2); box(.5,.65,.5,'#3b6072',6,.35,-4.9); W.tree(-6.7,-4.5,.9,4); W.tree(6.6,4.5,.7,8); const shooter = person(0,1.4,'#e5e3d3','#38654c',.95,'#b57d54'); shooter.root.rotation.y = Math.PI; const defender = person(-2.4,-.6,'#ad4b31','#2d3838',.95,'#82593f'); defender.root.rotation.y = .6; const passer = person(3.8,1.7,'#d6b35d','#303b3b',.95,'#d5a37c'); passer.root.rotation.y = -Math.PI+.6; const palms = { shooter: {}, passer: {} }; const ball = W.group(0,1.2,1.4); W.ball(.2,'#c67b33',0,0,0,ball); // Seam bands move with the ball. for (const angle of [0,Math.PI/2]) { const seam = ring(.2,'#553d2a',0,0,0,ball,.009); seam.rotation.x = angle; } return { shooter, defender, passer, ball, palms }; }, tick({ t, parts: p, W }) { const u=t/duration, s=W.smooth; const jump = .18*Math.sin(Math.PI*Math.max(0,Math.min(1,(t-.3)/.65))); p.shooter.root.position.y=jump; const ball=ballPose(t);p.ball.position.set(...ball);p.ball.rotation.y=u*Math.PI*4; p.shooter.leftLeg.rotation.x=.12*Math.sin(u*Math.PI*2);p.shooter.rightLeg.rotation.x=-p.shooter.leftLeg.rotation.x; p.defender.root.position.x=-2.4+.45*Math.sin(u*Math.PI*2); p.defender.leftLeg.rotation.x=.25*Math.sin(u*Math.PI*4);p.defender.rightLeg.rotation.x=-p.defender.leftLeg.rotation.x; p.defender.leftArm.rotation.z=-.3-.25*Math.sin(u*Math.PI*2);p.defender.rightArm.rotation.z=.5; p.defender.body.rotation.y=.08*Math.sin(u*Math.PI*2); const shooterOwns=t<=.6||t>=3.15; const follow=1-s((t-.7)/.35); for(const side of [-1,1]){ const shooterTarget=shooterOwns?[ball[0]-side*.14,ball[1]-.1,ball[2]]:[-side*.14,1.1+.85*follow,1.15]; p.palms.shooter[side]=W.reach(p.shooter,side,shooterTarget); const passerOwns=t>=2.35&&t<=2.6; const ready=s((t-1.9)/.45)*(1-s((t-2.6)/.5)); const passerTarget=passerOwns?[ball[0]-side*.13,ball[1]-.1,ball[2]]:[3.6-side*.13,1.0+.15*ready,1.4]; p.palms.passer[side]=W.reach(p.passer,side,passerTarget); } }, contacts({t,parts:p}) { const near=a=>Math.abs(t-a)<1e-5; if(near(1.4))return [{label:'ball passes through hoop centre',a:p.ball.position.toArray(),b:[0,3.15,-3.3]}]; const key=near(.6)||near(3.15)?'shooter':'passer',radius=key==='shooter'?.14:.13; return [-1,1].map(side=>({label:`${key} palm ${side} meets ball`,a:p.palms[key][side],b:[p.ball.position.x-side*radius,p.ball.position.y-.1,p.ball.position.z]})); } });skills/diorama-worlds/examples/cricket.js5.7 KB41f2dd4f6dc2upstream ↗
import { defineWorld } from '@diorama/worlds'; const duration = 7.2; function poseBatter(batter,u) { const swing=Math.sin(Math.PI*Math.max(0,Math.min(1,(u-.34)/.18))); batter.rightArm.rotation.x=-.35-swing*1.8; batter.leftArm.rotation.x=-.25-swing*1.2; batter.body.rotation.y=swing*-.6; } export default defineWorld({ name: 'cricket', title: 'An afternoon at the crease', duration, still: 2.76, contactTimes: [duration*.42], description: 'A miniature cricket ground. A bowler runs in, a batter strikes and a fielder returns the red ball.', bounds: [[-10, -.5, -7], [10, 5.8, 7]], build(W) { const { box, cylinder, line, ring, person } = W; W.terrain(20, 14, '#718941'); W.grass(20, 14, 2000); for (let i = 0; i < 10; i += 2) W.surface(1.95,12.5,'#7c9449',-9+i*2,.026,0,30+i,.18); const rope = ring(8.6, '#ede8d5', 0, .055, 0); rope.scale.y = .69; W.surface(10.3,1.85,'#c7b68b',0,.047,0,51,.16); // A worn centre and painted creases, not a featureless tan rectangle. for (let i = 0; i < 130; i++) { const x = (W.random() - .5) * 10, z = (W.random() - .5) * 1.7; box(.04 + W.random() * .1, .008, .035 + W.random() * .04, ['#b3a27b', '#d3c498', '#bfb18a'][i % 3], x, .052, z); } for (const x of [-4.4, 4.4]) { box(.035, .012, 1.8, '#f2eddc', x, .06, 0); for (const z of [-.65, .65]) box(.8, .012, .035, '#f2eddc', x, .061, z); for (const z of [-.13, 0, .13]) cylinder(.022, .53, '#eee8d4', x, .33, z); box(.045, .025, .35, '#d6c79f', x, .6, 0); } // Fine fencing occupies the back edge only: no screen across the actors. for (let x = -9.5; x <= 9.5; x += 1.9) cylinder(.035, 1.9, '#3d4940', x, .95, -6.5); for (let y = .25; y < 1.9; y += .22) line([[-9.5, y, -6.5], [9.5, y, -6.5]], '#727b68', .008); for (let x = -9.5; x <= 9.5; x += .28) line([[x, .05, -6.5], [x, 1.85, -6.5]], '#727b68', .008); // Timber pavilion: open porch, roof, wall boards and occupied benches. const pavilion = W.group(-6.7, 0, -4.9); box(3.8, .16, 2.05, '#9b7850', 0, .12, 0, pavilion); box(3.8, 1.75, .1, '#81572f', 0, 1.02, -.95, pavilion); for (let x = -1.8; x < 1.9; x += .22) box(.025, 1.7, .03, '#a57d48', x, 1, -.88, pavilion); for (const x of [-1.75, 1.75]) cylinder(.06, 1.85, '#745032', x, 1, .8, pavilion); const roof = box(4.25, .13, 2.6, '#c19454', 0, 2.03, -.05, pavilion); roof.rotation.x = .14; for (let x = -2; x <= 2; x += .19) { const slat = box(.025, .025, 2.6, '#a27840', x, 2.11, -.05, pavilion); slat.rotation.x = .14; } W.bench(-6.7, -5.1); W.bench(-3.8, -5.6); [[-8.8,-4,.9], [-8.8,4.9,1.0], [8.8,-5.2,1.05], [4.9,-5.8,.85]].forEach(([x,z,s], i) => W.tree(x,z,s,12+i)); for (const [x,z] of [[-9.2,-5.8],[9.2,-5.8]]) { cylinder(.045, 5.3, '#555342', x, 2.65, z); box(.75,.16,.23,'#303a36',x,5.3,z); for (let i = 0; i < 3; i++) box(.16,.08,.14,'#e5e2c6',x-.25+i*.25,5.23,z+.08); } // Scoreboard has physical panels; no tiny unreadable text baked into pixels. box(2.5, 1.2, .14, '#293c2c', 3.5, 1.8, -6.1); for (const x of [2.5,4.5]) cylinder(.045,1.5,'#554c33',x,.75,-6.1); for (let i = 0; i < 5; i++) box(.25,.4,.025,'#c7c7a1',2.7+i*.4,1.8,-6); const bowler = person(-5.9, 0, '#e5e4d1', '#e2deca', .76); bowler.root.rotation.y = Math.PI / 2; const batter = person(4.1, -.18, '#eee8d4', '#e4ddc7', .76); batter.root.rotation.y = -Math.PI / 2; W.ball(.14, '#334537', 0, .91, 0, batter.chest); const bat=box(.15, .58, .07, '#bc914e', 0, -.52, 0, batter.rightForearm); poseBatter(batter,.42); batter.root.updateMatrixWorld(true); const hit=new W.THREE.Vector3();bat.getWorldPosition(hit); const keeper = person(5.7, -.05, '#396d90', '#e6e0cc', .73); keeper.root.rotation.y = -Math.PI / 2; const fielder = person(1, 4.2, '#d5b23f', '#314238', .76); const extras = [[-5,3.7,'#b75539'],[-1.8,-3.9,'#3e6c88'],[5.4,3.2,'#376e55'],[5.9,-3.4,'#d2c29b']].map(([x,z,c]) => person(x,z,c,'#384537',.71)); const ball = W.ball(.085, '#a42d25', -5, 1.2, 0); return { bowler, batter, keeper, fielder, extras, ball, bat, hit }; }, tick({ t, parts: p, W }) { const u = t / duration, s = W.smooth; // Every property is assigned from time; seek and frame order are irrelevant. const run = Math.sin(Math.PI * Math.min(u / .24, 1)); p.bowler.root.position.x = -6.7 + 2.3 * s(u / .24) * (1 - s((u - .8) / .2)); p.bowler.leftLeg.rotation.x = run * Math.sin(u * Math.PI * 36) * .55; p.bowler.rightLeg.rotation.x = -p.bowler.leftLeg.rotation.x; p.bowler.rightArm.rotation.x = u < .3 ? -Math.PI * 2 * s(u / .3) : 0; p.bowler.leftArm.rotation.x = run * .7; poseBatter(p.batter,u); p.keeper.leftArm.rotation.x = -.45; p.keeper.rightArm.rotation.x = -.45; p.keeper.body.rotation.x = .13; const catchPose = Math.sin(Math.PI * Math.max(0, Math.min(1, (u - .65) / .22))); p.fielder.leftArm.rotation.x = -catchPose * 1.4; p.fielder.rightArm.rotation.x = -catchPose * 1.4; for (let i = 0; i < p.extras.length; i++) p.extras[i].body.rotation.y = Math.sin(u * Math.PI * 2 + i) * .07; if (u < .25) p.ball.position.set(p.bowler.root.position.x, 1.1, 0); else if (u < .42) { const a = (u - .25) / .17; p.ball.position.set(-4.4+(p.hit.x+4.4)*a,1.1+(p.hit.y-1.1)*a-.55*Math.sin(a*Math.PI),p.hit.z*a); } else if (u < .76) { const a = (u - .42) / .34; p.ball.position.set(p.hit.x+(1-p.hit.x)*a,p.hit.y+(1.1-p.hit.y)*a+2.1*Math.sin(a*Math.PI),p.hit.z+(4.2-p.hit.z)*a); } else { const a = s((u - .76) / .24); p.ball.position.set(1 - 7.7*a, 1.1 + 1.5*Math.sin(a*Math.PI), 4.2*(1-a)); } }, contacts({parts:p,W}) {const actual=new W.THREE.Vector3();p.bat.getWorldPosition(actual);return [{label:'red ball meets bat face',a:p.ball.position.toArray(),b:actual.toArray()}];} });skills/diorama-worlds/examples/florist.js5.8 KB892211fe8248upstream ↗
import { defineWorld } from '@diorama/worlds'; const duration=4.8,TAU=Math.PI*2; export default defineWorld({ name:'florist',title:'A bouquet before closing',duration,still:1.05, description:'A florist gathers and turns a bouquet at a flower stall while a customer waits with a paper bag. Knees, shoulders and hands follow the work.', bounds:[[-3.6,-.45,-2.8],[3.6,3.05,2.8]], contactTimes:[0,.8,1.6,2.4,3.2,4.0], build(W){ const {box,cylinder,ball,line,group}=W; W.terrain(7.2,5.6,'#82935b',17);W.grass(7.2,5.6,720,29); for(let row=0;row<10;row++)for(let col=0;col<12;col++){ const x=-2.8+col*.5+(row%2)*.23,z=-2.1+row*.46; if(x>3||Math.abs(x)>3.1||Math.abs(z)>2.2)continue; box(.46,.035,.42,['#b9b5a3','#c6bfab','#a9aa98'][(row+col)%3],x,.03,z); } // Workbench: its lower height leaves the adult's face and shoulders visible. const table=group(0,0,.72); for(const x of [-1.06,1.06])for(const z of [-.4,.4])box(.07,.92,.07,'#64543b',x,.5,z,table); for(let i=0;i<8;i++)box(2.4,.065,.125,i%2?'#ad8756':'#b99260',0,.98,-.45+i*.13,table); line([[-1.09,.38,-.38],[1.09,.38,-.38]],'#6d5a3f',.035,table); // Small objects anchor the task: twine, shears, wrapping paper and trimmed leaves. cylinder(.12,.12,'#c3af80',.85,1.09,.72); box(.42,.008,.58,'#ded0b1',.55,1.02,.92); line([[.65,1.03,.35],[.9,1.03,.58]],'#4d5850',.022); line([[.92,1.03,.35],[.69,1.03,.58]],'#4d5850',.022); for(let i=0;i<8;i++){const leaf=ball(.065,'#637a47',-.8+W.random()*.5,1.03,.6+W.random()*.4);leaf.scale.y*=.14;} function flowerPot(x,z,color,height=.5){ const g=group(x,.06,z);cylinder(.24,height,'#977452',0,height/2,0,g,12); cylinder(.21,.025,'#4b4e35',0,height+.01,0,g,12); for(let i=0;i<9;i++){ const a=i/9*TAU,fx=Math.cos(a)*.15,fz=Math.sin(a)*.15,h=height+.32+W.random()*.18; line([[fx,height,fz],[fx,h,fz]],'#54744b',.012,g); const leaf=ball(.075,'#718a4d',fx+.04,height+.19,fz,g);leaf.scale.set(.075,.025,.04); for(let j=0;j<5;j++)ball(.045,color,fx+Math.cos(j/5*TAU)*.043,h,fz+Math.sin(j/5*TAU)*.043,g); ball(.025,'#d3af52',fx,h+.02,fz,g); } } flowerPot(-1.9,.5,'#cb7e78');flowerPot(-2.25,-.45,'#e4ce92',.65);flowerPot(2.35,-.8,'#a09cc0');flowerPot(-2.7,1.55,'#eee4cb',.37); // Rear trellis, not a roof hiding the work. for(const x of [-2.2,2.2])cylinder(.045,2.4,'#6d7254',x,1.24,-2.05); for(let y=.7;y<2.3;y+=.25)line([[-2.2,y,-2.05],[2.2,y,-2.05]],'#9eab87',.012); for(let i=0;i<22;i++){const x=-2.05+i*.19,y=.75+.5*Math.sin(i*.45)+i*.045;ball(.11,i%2?'#788e56':'#667f4e',x,y,-2.02);} W.bench(2.0,-1.55,0); const florist=W.person(0,-.12,'#ece3cd','#3a4e4d',1,'#c3906d');florist.root.position.y=.005; // Apron follows the chest and does not replace the human's anatomical silhouette. box(.31,.43,.018,'#9d684d',0,.29,.133,florist.chest); for(const x of [-.12,.12])box(.025,.25,.018,'#9d684d',x,.52,.133,florist.chest); box(.14,.11,.018,'#88563e',0,.21,.151,florist.chest); const bouquet=group(0,1.1,.26),leftGrip=group(-.085,.015,0,bouquet),rightGrip=group(.085,.015,0,bouquet); for(let i=0;i<11;i++){ const a=i/11*TAU,dx=Math.cos(a)*.15,dz=Math.sin(a)*.13,h=.28+(i%3)*.035; line([[0,-.14,0],[dx,h,dz]],'#4e7950',.011,bouquet); for(let j=0;j<5;j++)ball(.048,['#bf6a6d','#e0c99c','#d9967b'][i%3],dx+Math.cos(j/5*TAU)*.04,h,dz+Math.sin(j/5*TAU)*.04,bouquet); ball(.026,'#b79d49',dx,h+.02,dz,bouquet); } W.ring(.073,'#cfb47c',0,.015,0,bouquet,.018); const ribbon=box(.18,.015,.04,'#dbbda1',0,.015,.08,bouquet); const customer=W.person(1.55,1.15,'#889caa','#3f4140',.94,'#8e624d');customer.root.rotation.y=-Math.PI/2;customer.root.position.y=.005; const bag=group(0,-.22,0,customer.leftHand);box(.23,.3,.16,'#c5a772',0,0,0,bag);line([[-.07,.15,0],[-.07,.24,0],[.07,.24,0],[.07,.15,0]],'#957744',.014,bag); // A sleeping cat supplies one quiet secondary silhouette. const cat=group(-2.6,.14,-1.35);const body=ball(.19,'#b79b70',0,0,0,cat);body.scale.z*=1.7; ball(.13,'#b79b70',0,.07,.23,cat);for(const x of [-.08,.08]){const ear=box(.07,.12,.06,'#a4875e',x,.19,.24,cat);ear.rotation.z=x<0?.2:-.2;} const tail=line([[.1,0,-.24],[.26,.03,-.25],[.28,.04,-.05]],'#a4875e',.035,cat); const v=new W.THREE.Vector3();return{florist,customer,bouquet,leftGrip,rightGrip,ribbon,tail,v,palms:{}}; }, tick({t,parts:p,W}){ const q=t/duration,turn=Math.sin(q*TAU),lift=.11*Math.max(0,Math.sin((q-.08)*TAU))**2; W.plant(p.florist,{crouch:.035+.025*(1-turn),lean:.09+.035*turn,twist:.065*turn}); p.florist.head.rotation.set(.15+.05*turn,-.09*turn,0); p.bouquet.position.set(.025*turn,1.1+lift,.26);p.bouquet.rotation.set(.04*turn,.2*turn,.035*Math.sin(q*TAU*2)); p.bouquet.updateMatrixWorld(true); for(const [side,anchor] of [[-1,p.leftGrip],[1,p.rightGrip]]){anchor.getWorldPosition(p.v);p.palms[side]=W.reach(p.florist,side,p.v.toArray());} p.florist.rightHand.rotation.z=.1*Math.sin(q*TAU*2);p.ribbon.rotation.y=.08*Math.sin(q*TAU*2); W.plant(p.customer,{crouch:.015,lean:.025,twist:.025*Math.sin(q*TAU)}); W.reach(p.customer,-1,[1.27,1.1,1.15]);W.reach(p.customer,1,[1.35,1.13,1.3]);p.customer.head.rotation.y=.1*Math.sin(q*TAU); p.tail.rotation.y=.08*Math.sin(q*TAU); }, contacts({parts:p}){ const checks=[[-1,p.leftGrip],[1,p.rightGrip]].map(([side,anchor])=>{anchor.getWorldPosition(p.v);return{label:`florist palm ${side} grips bouquet`,a:p.palms[side],b:p.v.toArray()};}); for(const actor of [p.florist,p.customer])for(const side of [-1,1]){const foot=side<0?actor.leftFoot:actor.rightFoot;foot.getWorldPosition(p.v);const a=p.v.toArray();p.v.set(side*.13,.09,0);actor.root.localToWorld(p.v);checks.push({label:'planted ankle stays over its support',a,b:p.v.toArray()});} return checks; } });skills/diorama-worlds/examples/observatory.js5.1 KBeda3ee36c5cdupstream ↗
import { defineWorld } from '@diorama/worlds'; const duration=5.6,TAU=Math.PI*2; export default defineWorld({ name:'observatory',title:'One more degree',duration,still:1.4, description:'An astronomer makes a small focus adjustment on a brass telescope. A star chart, field case, weather vane and low circular terrace complete the hilltop observatory.', bounds:[[-2.65,-.45,-2.2],[2.65,2.95,2.2]],contactTimes:[0,.9,1.4,2.8,4.2,5.1], build(W){ const {box,ball,cylinder,line,group}=W; W.terrain(5.3,4.4,'#738779',59);W.grass(5.3,4.4,520,51); cylinder(1.83,.12,'#aca999',0,.045,0,undefined,48); for(let i=0;i<24;i++){const a=i/24*TAU,b=box(.36,.045,.28,i%2?'#bdb8a5':'#c9c3b0',Math.cos(a)*1.63,.125,Math.sin(a)*1.63);b.rotation.y=-a;} W.ring(1.82,'#858a7b',0,.12,0,undefined,.035); // Low railing confined to the rear arc keeps the observer visible. for(let i=0;i<11;i++){const a=Math.PI+i/10*Math.PI,x=Math.cos(a)*1.95,z=Math.sin(a)*1.95; cylinder(.022,.73,'#4d6260',x,.44,z);} const rail=[];for(let i=0;i<=40;i++){const a=Math.PI+i/40*Math.PI;rail.push([Math.cos(a)*1.95,.8,Math.sin(a)*1.95]);}line(rail,'#4d6260',.027); const mount=group(.05,.13,.1); for(let i=0;i<3;i++){const a=i/3*TAU;line([[0,.93,0],[Math.cos(a)*.59,.03,Math.sin(a)*.59]],'#6d7974',.045,mount);line([[0,.46,0],[Math.cos(a)*.32,.46,Math.sin(a)*.32]],'#9b9a80',.015,mount);} cylinder(.13,.22,'#5e7470',0,.99,0,mount,12); const telescope=group(0,1.25,.08,mount);telescope.rotation.x=-.48; // The tube's long axis is +z, with eyepiece behind the main body. const tube=cylinder(.15,1.15,'#b9a06a',0,0,.2,telescope,16);tube.rotation.x=Math.PI/2; for(const z of [-.34,.64]){const band=cylinder(.17,.075,'#4e6664',0,0,z,telescope,16);band.rotation.x=Math.PI/2;} const lens=cylinder(.135,.015,'#3d626c',0,0,.785,telescope,16);lens.rotation.x=Math.PI/2; const small=cylinder(.055,.25,'#576864',0,0,-.49,telescope,12);small.rotation.x=Math.PI/2; const finder=cylinder(.038,.43,'#5e7470',.13,.19,.15,telescope,8);finder.rotation.x=Math.PI/2; line([[.13,.02,.02],[.13,.17,.02]],'#637672',.024,telescope); const focus=group(.205,-.08,-.25,telescope),wheel=cylinder(.067,.09,'#6c7c72',0,0,0,focus,12);wheel.rotation.z=Math.PI/2; const grip=group(.25,-.08,-.25,telescope),support=group(-.15,-.08,-.22,telescope); // Observer stands on the near-left side, facing the telescope with a steady stance. const actor=W.person(.06,-.32,'#9f7563','#3b4c50',1,'#bd8b68');actor.root.position.y=.09; const scarf=box(.19,.14,.025,'#d0bc83',0,.52,.15,actor.chest);box(.07,.25,.026,'#d0bc83',.06,.33,.153,actor.chest); const hat=cylinder(.14,.1,'#4b6666',0,.11,0,actor.head,12);W.ring(.14,'#829084',0,.065,0,actor.head,.018); // Field desk with an actual ink star chart, compass and pencil. const desk=group(1.42,0,-.8);for(const x of [-.39,.39])for(const z of [-.24,.24])box(.045,.73,.045,'#6c6958',x,.47,z,desk); box(.94,.065,.7,'#9d8967',0,.86,0,desk);box(.66,.008,.48,'#dfd4b4',0,.9,0,desk); for(const radius of [.09,.17,.23])W.ring(radius,'#78857e',0,.91,0,desk,.006); const stars=[];for(let i=0;i<15;i++){const x=(W.random()-.5)*.5,z=(W.random()-.5)*.34;ball(.012,'#546967',x,.917,z,desk);if(i<6)stars.push([x,.915,z]);}line(stars,'#919b89',.005,desk); const pencil=box(.015,.015,.29,'#8f6e43',.26,.923,.03,desk);pencil.rotation.y=.4; cylinder(.065,.03,'#9d8d5e',-.29,.928,-.12,desk,12); const fieldCase=group(-1.4,.27,.8);box(.62,.35,.37,'#5e736a',0,0,0,fieldCase);for(const x of [-.22,.22])box(.04,.36,.38,'#a79368',x,0,0,fieldCase);line([[-.13,.19,0],[-.13,.26,0],[.13,.26,0],[.13,.19,0]],'#51635c',.018,fieldCase); const vane=group(-1.75,0,-1.27);cylinder(.023,2.55,'#627770',0,1.36,0,vane);line([[-.35,2.56,0],[.35,2.56,0]],'#9f996b',.018,vane);const arrow=box(.2,.1,.018,'#9f996b',.29,2.56,0,vane);arrow.rotation.z=.3; const flag=group(0,2.3,0,vane);box(.36,.15,.015,'#b2956c',.18,0,0,flag); const v=new W.THREE.Vector3();return{actor,telescope,focus,grip,support,flag,v,palms:[]}; }, tick({t,parts:p,W}){ const a=t/duration*TAU,adjust=Math.sin(a); // Tiny tracking correction; hands follow physical knobs, not arbitrary waving. p.telescope.rotation.set(-.48+.025*adjust,.035*Math.sin(a),0);p.focus.rotation.x=.35*adjust; W.plant(p.actor,{crouch:.035+.01*(1+adjust),lean:.08+.025*adjust,twist:.045*Math.sin(a)});p.actor.head.rotation.set(.22,.05*Math.sin(a),0); p.telescope.updateMatrixWorld(true); for(const [i,g] of [p.support,p.grip].entries()){g.getWorldPosition(p.v);p.palms[i]=W.reach(p.actor,i?1:-1,p.v.toArray());} p.actor.rightHand.rotation.x=.35*adjust;p.flag.rotation.y=.08*Math.sin(a*2); }, contacts({parts:p}){ const checks=[p.support,p.grip].map((g,i)=>{g.getWorldPosition(p.v);return{label:i?'palm on focus wheel':'palm steadies telescope',a:p.palms[i],b:p.v.toArray()};}); for(const side of [-1,1]){(side<0?p.actor.leftFoot:p.actor.rightFoot).getWorldPosition(p.v);const a=p.v.toArray();p.v.set(side*.13,.09,0);p.actor.root.localToWorld(p.v);checks.push({label:'observer planted ankle',a,b:p.v.toArray()});}return checks; } });skills/diorama-worlds/examples/pottery-studio.js4.4 KB890ab3ce0a4fupstream ↗
import {defineWorld} from '@diorama/worlds'; const clay='#b66c48', wood='#92714c', dark='#49565a'; function radiusAt(y){const h=y-1.02;return h<.12?.135+(h-.035)/.085*.01:.145-(h-.12)/.10*.005;} function targets(t){const y=1.16+.055*Math.sin(t*Math.PI/3);return [[-radiusAt(y),y,.27],[radiusAt(y+.012),y+.012,.27]];} function worldPoint(W,o){o.updateWorldMatrix(true,false);return o.getWorldPosition(new W.THREE.Vector3()).toArray();} export default defineWorld({name:'pottery-studio',title:'The patient wheel',description:'An adult potter draws both hands slowly up a spinning clay vessel. A compact open-air studio holds drying pots, tools, a draped cloth and a small herb garden.',duration:6,still:1.5,bounds:[[-1.85,-.36,-1.4],[1.85,2.05,1.4]],contactTimes:[0,.75,1.5,3,4.5,5.9], build(W){ W.terrain(3.7,2.8,'#92976d',11);W.surface(2.95,2.5,'#d0c3a8',-.2,.025,-.02,7,.04); for(let x=-1.6;x<1.25;x+=.38)W.box(.008,.01,2.47,'#b3a58c',x,.033,-.02); const actor=W.person(0,-.20,'#e6dfc8','#465857',1,'#b77f5c');actor.root.position.y=-.02; // Work apron follows the standard rig's chest, never substitutes for the human. W.box(.32,.43,.025,'#ab644b',0,.27,.135,actor.chest);W.box(.27,.19,.03,'#ab644b',0,-.025,.13,actor.body); W.box(.19,.11,.014,'#8d4e39',0,.14,.152,actor.chest); // Wheel chassis, hand wheel and moving pottery. W.box(.69,.12,.65,dark,0,.86,.27);for(const x of [-.25,.25])for(const z of [.02,.52])W.box(.05,.78,.05,dark,x,.42,z); W.cylinder(.085,.73,'#646b69',0,.45,.27);W.cylinder(.26,.045,'#777d76',0,.955,.27,undefined,32); const wheel=W.group(0,.995,.27);W.cylinder(.255,.035,'#b9b1a0',0,0,0,wheel,32); for(let i=0;i<4;i++)W.box(.08,.005,.012,'#8a8072',.18*Math.cos(i*Math.PI/2),.02,.18*Math.sin(i*Math.PI/2),wheel); function pot(parent,x,y,z,scale,color){const p=W.group(x,y,z,parent);p.scale.setScalar(scale);const profile=[[0,0],[.1,0],[.135,.035],[.145,.12],[.14,.22],[.125,.27],[.102,.27],[.116,.218],[.12,.12],[.11,.045],[0,.03]];const geo=W.own(new W.THREE.LatheGeometry(profile.map(([r,h])=>new W.THREE.Vector2(r,h)),24));const mat=W.own(new W.THREE.MeshStandardMaterial({color,roughness:1,flatShading:true}));const m=new W.THREE.Mesh(geo,mat);m.castShadow=m.receiveShadow=true;p.add(m);W.ring(.116,color,0,.269,0,p,.014);return p;} pot(wheel,0,.025,0,1,clay); // Two open-backed drying shelves leave the potter's head clear. for(const x of [-1.55,-.62])for(const z of [-1.04,-.65])W.box(.045,1.52,.045,wood,x,.78,z); for(const y of [.17,.72,1.28]){W.box(1.04,.065,.5,wood,-1.085,y,-.84);for(let n=0;n<3;n++)pot(undefined,-1.43+n*.34,y+.035,-.84,.65+.12*(n%2),['#cfaa77','#c48058','#7f968b'][n]);} // Low tool bench, cloth and a bowl of water. W.box(.62,.07,.58,wood,.86,.68,-.72);for(const x of [.61,1.11])for(const z of [-.94,-.5])W.box(.045,.65,.045,wood,x,.34,z); W.box(.27,.024,.48,'#e8e3d3',.82,.735,-.7);W.box(.27,.2,.024,'#e8e3d3',.82,.64,-.417); W.cylinder(.1,.07,'#a87857',1.04,.76,-.8);W.cylinder(.08,.005,'#718f92',1.04,.798,-.8); W.line([[.6,.75,-.84],[.65,.75,-.55]],'#6d543e',.014);W.line([[.68,.75,-.84],[.73,.75,-.55]],'#6d543e',.014);W.ring(.035,'#6b6760',.73,.753,-.52,undefined,.009); // Spare stool sits beside the standing work position. W.cylinder(.23,.075,wood,.79,.51,.49,undefined,16);for(const dx of [-.13,.13])for(const dz of [-.13,.13])W.line([[.79+dx,.48,.49+dz],[.79+dx*1.25,.055,.49+dz*1.25]],wood,.028); // Garden around the cutaway studio, grouped leaves rather than visual noise. for(let n=0;n<5;n++){let z=-1.05+n*.49;W.cylinder(.14,.17,'#a66e4c',1.55,.11,z,undefined,10);W.cylinder(.12,.012,'#514c39',1.55,.2,z);for(let k=0;k<6;k++){let a=k*Math.PI/3;W.line([[1.55,.2,z],[1.55+.08*Math.cos(a),.38+(k%2)*.1,z+.08*Math.sin(a)]],'#526b41',.012);const leaf=W.ball(.06,k%2?'#7a8f57':'#566e45',1.55+.09*Math.cos(a),.39+(k%2)*.1,z+.09*Math.sin(a));leaf.scale.y*=1.6;}} return {actor,wheel}; }, tick({t,parts:{actor,wheel},W}){W.plant(actor,{crouch:.035+.008*Math.sin(t*Math.PI/3),lean:.115+.012*Math.sin(t*Math.PI/3),twist:0});const ts=targets(t);W.reach(actor,-1,ts[0]);W.reach(actor,1,ts[1]);actor.head.rotation.x=.2;wheel.rotation.y=t*2*Math.PI;}, contacts({t,parts:{actor},W}){const ts=targets(t);return [{label:'left wrist to clay wall',a:worldPoint(W,actor.leftHand),b:ts[0]},{label:'right wrist to clay wall',a:worldPoint(W,actor.rightHand),b:ts[1]},...[-1,1].map(side=>({label:`${side<0?'left':'right'} foot planted`,a:worldPoint(W,side<0?actor.leftFoot:actor.rightFoot),b:[side*.13,.07,-.20]}))];} });skills/diorama-worlds/examples/street-juggler.js4.9 KB964093dc0b62upstream ↗
import { defineWorld } from '@diorama/worlds'; // One three-second cascade: three staggered balls, each crosses twice. // Flight (1.15s) -> cushioned catch and inward scoop (.35s) -> opposite flight. const TAU = Math.PI * 2; export function hand(side, t) { const u = ((t - .15 + (side < 0 ? .5 : 0)) % 1 + 1) % 1; if (u < .35) { const a=u/.35; return [side*(.60-.35*a),1.36-.13*Math.sin(Math.PI*a),.38]; } const a=(u-.35)/.65, eased=a*a*(3-2*a); return [side*(.25+.35*eased),1.36+.035*Math.sin(Math.PI*a),.38]; } export function ballPose(t) { const q = ((t % 3) + 3) % 3, side = q < 1.5 ? -1 : 1, u = q % 1.5; if (u >= 1.15) { const a=(u-1.15)/.35; return [-side*(.60-.35*a),1.36-.13*Math.sin(Math.PI*a),.38]; } const a = u / 1.15; return [side * (.25 - .85 * a), 1.36 + 2.05 * 4 * a * (1-a), .38]; } export default defineWorld({ name: 'street-juggler', title: 'Three bright moments', duration: 3, still: .38, description: 'A striped-shirt street juggler keeps three red, gold and teal balls in a continuous cascade above a small cobbled grass island. An open tip hat and a woven wicker basket sit beside the performer.', bounds: [[-2.65,-.45,-2.15],[2.65,3.65,2.15]], build(W) { const {box,ball,cylinder,ring,line,group}=W; W.terrain(5.3,4.3,'#7d9255',81); W.grass(5.3,4.3,460,31); // Worn cobbles leave a generous living grass fringe. for(let row=0;row<8;row++) for(let col=0;col<9;col++) { const x=-1.86+col*.45+(row%2)*.19,z=-1.32+row*.38; if(x*x/5+z*z/2.7>1) continue; const tile=box(.415,.07,.34,['#aaa69a','#c1b9a7','#96998d','#b5b2a3'][(row+col)%4],x,.043,z); tile.rotation.y=(W.random()-.5)*.08; } const skin='#d3a078',navy='#263d45',ivory='#f2e9ce'; // Performer: grounded shoes, hips and knees, striped tunic, expressive face. for(const x of [-.17,.17]) { box(.22,.12,.38,navy,x,.13,.07); cylinder(.085,.44,navy,x,.4,0); ball(.09,navy,x,.62,0); cylinder(.10,.35,navy,x,.8,0); } box(.46,.18,.28,navy,0,.96,0); for(let i=0;i<8;i++) box(.58,.075,.32,i%2?navy:ivory,0,1.08+i*.075,0); cylinder(.08,.15,skin,0,1.69,0); ball(.225,skin,0,1.93,.01); ball(.23,'#493f32',0,2.01,-.045).scale.y*=.5; const beret=ball(.25,navy,.035,2.12,-.03); beret.scale.y*=.38; ball(.06,skin,0,1.93,.225); for(const x of [-.08,.08]) ball(.022,navy,x,1.99,.197); // Two endpoint-driven two-link arms; all resources built once. const arms=[-1,1].map(side=>({side, upper:cylinder(.075,1,ivory), lower:cylinder(.06,1,skin), elbow:ball(.077,skin), palm:ball(.078,skin)})); // Open hat: hollow crown with dark inner floor, brim and a few coins. const hat=group(-1.05,.12,1.02);hat.rotation.y=-.2; cylinder(.48,.045,navy,0,0,0,hat,16); cylinder(.29,.025,'#182b30',0,.027,0,hat,16); for(let i=0;i<16;i++){ const a=i/16*TAU, m=box(.12,.32,.065,navy,Math.cos(a)*.31,.17,Math.sin(a)*.31,hat);m.rotation.y=-a+Math.PI/2; } ring(.31,navy,0,.34,0,hat,.045); for(const [x,z] of [[-.09,.02],[.08,-.04],[.03,.11]]) cylinder(.055,.025,'#d4aa4a',x,.06,z,hat,8); // Wicker basket: vertical stakes, woven horizontal hoops, arched handle. const basket=group(1.35,.1,.35); basket.rotation.y=.15; cylinder(.35,.035,'#987040',0,.035,0,basket,16); for(let i=0;i<16;i++){ const a=i/16*TAU;line([[Math.cos(a)*.33,.04,Math.sin(a)*.33],[Math.cos(a)*.4,.57,Math.sin(a)*.4]],'#a97e46',.023,basket); } for(let i=0;i<10;i++)ring(.335+i*.0068,i%2?'#be9458':'#9b723f',0,.08+i*.051,0,basket,.025); const handle=[];for(let i=0;i<=18;i++){const a=i/18*Math.PI;handle.push([Math.cos(a)*.39,.56+Math.sin(a)*.42,0]);} line(handle,'#a97e46',.038,basket); // Quiet tufts around the island, not competing secondary action. for(const [x,z] of [[-2,1.5],[2,-1.45],[-2,-1.4]]) for(let i=0;i<4;i++)ball(.055,'#d5c887',x+(i%2)*.12,.12,z+Math.floor(i/2)*.10); const balls=['#c94b37','#e6b53e','#378d93'].map(c=>ball(.13,c)); const vA=new W.THREE.Vector3(),vB=new W.THREE.Vector3(),dir=new W.THREE.Vector3(),up=new W.THREE.Vector3(0,1,0); return {arms,balls,vA,vB,dir,up}; }, tick({t,parts:p}) { function segment(mesh,a,b){p.vA.set(...a);p.vB.set(...b);p.dir.subVectors(p.vB,p.vA);mesh.position.copy(p.vA).add(p.vB).multiplyScalar(.5);mesh.scale.y=p.dir.length();mesh.quaternion.setFromUnitVectors(p.up,p.dir.normalize());} for(const arm of p.arms){ const h=hand(arm.side,t),shoulder=[arm.side*.34,1.56,0]; const d=h.map((v,i)=>v-shoulder[i]),dist=Math.hypot(...d),along=(.36**2-.42**2+dist**2)/(2*dist),bend=Math.sqrt(.36**2-along**2); let n=[d[1],-d[0],0],norm=Math.hypot(...n);n=n.map(v=>v/norm*(n[0]*arm.side<0?-1:1)); const elbow=shoulder.map((v,i)=>v+d[i]/dist*along+n[i]*bend); segment(arm.upper,shoulder,elbow);segment(arm.lower,elbow,h);arm.elbow.position.set(...elbow);arm.palm.position.set(...h); } p.balls.forEach((b,i)=>{b.position.set(...ballPose(t+i));}); } });skills/diorama-worlds/kit/world.js26 KBeb91a15dbfb0upstream ↗
import * as THREE from 'three'; export const FINISHES = Object.freeze(['miniature', 'pixel', 'halftone', 'blueprint']); export const phase = (t, period) => ((t % period) + period) % period / period; export const smooth = x => (x = Math.max(0, Math.min(1, x))) * x * (3 - 2 * x); // Absolute elapsed time: skipped frames never turn action into slow motion. export function clockTime(anchor, elapsedMilliseconds, duration, speed = 1) { return phase(anchor + Math.max(0, elapsedMilliseconds) / 1000 * speed, duration) * duration; } export function seeded(seed = 42) { let state = seed >>> 0; return () => { state = (Math.imul(state, 1664525) + 1013904223) >>> 0; return state / 4294967296; }; } export function defineWorld(definition) { if (!/^[a-z][a-z0-9-]*$/.test(definition.name ?? '')) throw new Error('World name must be lowercase and hyphenated.'); if (!(definition.duration > 0) || !Number.isFinite(definition.duration)) throw new Error('World duration must be finite and positive.'); if (!(definition.still >= 0 && definition.still < definition.duration)) throw new Error('Still must be inside the loop.'); if (typeof definition.build !== 'function' || typeof definition.tick !== 'function') throw new Error('World needs build and tick functions.'); if (typeof definition.title !== 'string' || typeof definition.description !== 'string') throw new Error('World needs a title and description.'); if (definition.bounds && (definition.bounds.length !== 2 || definition.bounds.some(v => v.length !== 3 || v.some(n => !Number.isFinite(n))) || definition.bounds[0].some((n, i) => n >= definition.bounds[1][i]))) throw new Error('Bounds must contain finite increasing min/max XYZ corners.'); if (definition.contactTimes && (!Array.isArray(definition.contactTimes) || definition.contactTimes.some(t => !Number.isFinite(t) || t < 0 || t >= definition.duration) || typeof definition.contacts !== 'function')) throw new Error('Contact times need a contact inspector and must lie inside the loop.'); return definition; } // Public scene API. Helpers build once; tick only poses returned Object3Ds. export function toolkit(parent) { const geometries = new Map(), materials = new Map(), textures = new Set(); const random = seeded(42), resources = new Set(); const own = resource => { resources.add(resource); return resource; }; const geometry = (key, make) => { if (!geometries.has(key)) geometries.set(key, make()); return geometries.get(key); }; const material = color => { if (!materials.has(color)) materials.set(color, new THREE.MeshStandardMaterial({ color, roughness: 1, flatShading: true })); return materials.get(color); }; function group(x = 0, y = 0, z = 0, container = parent) { const g = new THREE.Group(); g.position.set(x, y, z); container.add(g); return g; } function mesh(geo, color, x, y, z, container = parent) { const m = new THREE.Mesh(geo, material(color)); m.position.set(x, y, z); m.castShadow = m.receiveShadow = true; container.add(m); return m; } function box(w, h, d, color, x = 0, y = 0, z = 0, container = parent) { const m = mesh(geometry('box', () => new THREE.BoxGeometry(1, 1, 1)), color, x, y, z, container); m.scale.set(w, h, d); return m; } function ball(r, color, x = 0, y = 0, z = 0, container = parent) { const m = mesh(geometry('ball', () => new THREE.IcosahedronGeometry(1, 1)), color, x, y, z, container); m.scale.setScalar(r); return m; } function cylinder(r, h, color, x = 0, y = 0, z = 0, container = parent, segments = 8) { const m = mesh(geometry(`cyl${segments}`, () => new THREE.CylinderGeometry(1, 1, 1, segments)), color, x, y, z, container); m.scale.set(r, h, r); return m; } function line(points, color, width = .035, container = parent) { const g = group(0, 0, 0, container); for (let i = 1; i < points.length; i++) { const a = new THREE.Vector3(...points[i - 1]), b = new THREE.Vector3(...points[i]); const v = b.clone().sub(a), midpoint = a.clone().add(b).multiplyScalar(.5); const m = cylinder(width, v.length(), color, ...midpoint.toArray(), g, 6); m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), v.normalize()); } return g; } function ring(radius, color, x, y, z, container = parent, tube = .028, arc = Math.PI * 2) { const key = `ring:${radius}:${tube}:${arc}`; const m = mesh(geometry(key, () => new THREE.TorusGeometry(radius, tube, 4, 64, arc)), color, x, y, z, container); m.rotation.x = -Math.PI / 2; return m; } function surface(width, depth, color, x = 0, y = .03, z = 0, seed = 12, variation = .13) { const rng = seeded(seed), canvas = document.createElement('canvas'); canvas.width = canvas.height = 256; const ctx = canvas.getContext('2d'), base = new THREE.Color(color), c = new THREE.Color(); ctx.fillStyle = color; ctx.fillRect(0, 0, 256, 256); for (let i = 0; i < 18000; i++) { c.copy(base).multiplyScalar(1 + (rng() - .5) * variation * 2); ctx.fillStyle = '#' + c.getHexString(); ctx.fillRect(rng()*256, rng()*256, 1+rng()*2, 1+rng()*2); } const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; textures.add(texture); const mat = new THREE.MeshStandardMaterial({ map: texture, roughness: 1 }); materials.set(`surface:${materials.size}`, mat); const m = new THREE.Mesh(geometry('surface', () => new THREE.PlaneGeometry(1, 1)), mat); m.rotation.x = -Math.PI/2; m.scale.set(width,depth,1); m.position.set(x,y,z); m.receiveShadow = true; parent.add(m); return m; } function terrain(width, depth, color = '#718c42', seed = 42, { base = 'none' } = {}) { if (!['none', 'slab'].includes(base)) throw new Error('Terrain base must be none or slab.'); const rng = seeded(seed), points = []; for (let side = 0; side < 4; side++) for (let i = 0; i <= 18; i++) { const f = i / 18, notch = rng() * Math.min(width, depth) * .025; points.push(side === 0 ? [-width / 2 + width * f, -depth / 2 + notch] : side === 1 ? [width / 2 - notch, -depth / 2 + depth * f] : side === 2 ? [width / 2 - width * f, depth / 2 - notch] : [-width / 2 + notch, depth / 2 - depth * f]); } const shape = new THREE.Shape(points.map(([x, z]) => new THREE.Vector2(x, -z))); // The default is a ground cutout, not a floating display plinth. // Opt-in extrusion is reserved for explicitly requested raised dioramas. if (base === 'slab') { const soil = new THREE.ExtrudeGeometry(shape, { depth: .36, bevelEnabled: false }); geometries.set(`soil:${geometries.size}`, soil); const slab = mesh(soil, '#a17b4d', 0, -.355, 0); slab.rotation.x = -Math.PI / 2; } const top = new THREE.ShapeGeometry(shape); geometries.set(`top:${geometries.size}`, top); const grass = mesh(top, color, 0, .005, 0); grass.rotation.x = -Math.PI / 2; grass.castShadow = false; // A deterministic mottled texture, generated locally; no remote textures. const canvas = document.createElement('canvas'); canvas.width = canvas.height = 128; const ctx = canvas.getContext('2d'); const baseColor = new THREE.Color(color); for (let y = 0; y < 128; y++) for (let x = 0; x < 128; x++) { const c = baseColor.clone().multiplyScalar(.8 + rng() * .4); ctx.fillStyle = `#${c.getHexString()}`; ctx.fillRect(x, y, 1, 1); } const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.wrapS = texture.wrapT = THREE.RepeatWrapping; texture.repeat.set(3, 3); textures.add(texture); const mat = new THREE.MeshStandardMaterial({ map: texture, roughness: 1 }); materials.set(`turf:${materials.size}`, mat); grass.material = mat; if (base === 'none') { // Small coplanar flecks break the silhouette into white space, without // alpha sorting, vertical walls or a continuous decorative outline. const fringe = seeded(seed + 197), tint = new THREE.Color(color); const geo = geometry('ground-fleck', () => new THREE.PlaneGeometry(1, 1)); for (let i = 0; i < 220; i++) { const side = i % 4, f = fringe(), inset = (fringe() - .25) * .16; const x = side === 0 || side === 2 ? (f - .5) * width : (side === 1 ? 1 : -1) * (width / 2 - inset); const z = side === 1 || side === 3 ? (f - .5) * depth : (side === 0 ? 1 : -1) * (depth / 2 - inset); const tone = .9 + (i % 4) * .06; tint.set(color).multiplyScalar(tone); const fleck = mesh(geo, '#' + tint.getHexString(), x, .006, z); fleck.rotation.x = -Math.PI / 2; const size = .018 + fringe() * .055; fleck.scale.set(size, size * (.6 + fringe() * .8), 1); fleck.castShadow = false; } } return grass; } function grass(width, depth, count = 1400, seed = 7) { const rng = seeded(seed), geo = geometry('blade', () => new THREE.PlaneGeometry(1, 1)); const mat = new THREE.MeshStandardMaterial({ color: '#8b9b4d', side: THREE.DoubleSide, roughness: 1 }); materials.set(`grass:${materials.size}`, mat); const inst = new THREE.InstancedMesh(geo, mat, count), dummy = new THREE.Object3D(), tint = new THREE.Color(); for (let i = 0; i < count; i++) { let x = (rng() - .5) * width, z = (rng() - .5) * depth; // Detail confined to the fringe, never hiding action in the centre. if (Math.abs(x) < width / 2 - .6 && Math.abs(z) < depth / 2 - .6) z = (depth / 2 - rng() * .5) * (z < 0 ? -1 : 1); const h = .05 + rng() * .16; dummy.position.set(x, h / 2, z); dummy.rotation.y = rng() * Math.PI; dummy.scale.set(.035 + rng() * .04, h, 1); dummy.updateMatrix(); inst.setMatrixAt(i, dummy.matrix); tint.setHSL(.2 + rng() * .04, .28 + rng() * .2, .26 + rng() * .16); inst.setColorAt(i, tint); } inst.receiveShadow = true; parent.add(inst); return inst; } function tree(x, z, scale = 1, seed = 3) { const g = group(x, 0, z), rng = seeded(seed); g.scale.setScalar(scale); cylinder(.12, 1.8, '#745139', 0, .9, 0, g); line([[0, 1.1, 0], [.45, 2, .1]], '#745139', .075, g); for (let i = 0; i < 10; i++) ball(.48 + rng() * .24, ['#748b48', '#8d9e5a', '#a3ad65', '#617e41'][i % 4], (rng() - .5) * 1.3, 1.85 + rng() * .8, (rng() - .5) * 1.2, g); return g; } function bench(x, z, rotation = 0) { const g = group(x, 0, z); g.rotation.y = rotation; for (const dx of [-.75, .75]) for (const dz of [-.19, .19]) box(.08, .5, .08, '#303b37', dx, .25, dz, g); for (let i = 0; i < 3; i++) box(1.85, .07, .14, '#ab794d', 0, .53, -.18 + i * .18, g); for (let i = 0; i < 2; i++) box(1.85, .13, .06, '#ab794d', 0, .78 + i * .18, -.27, g); return g; } function person(x, z, shirt = '#e8e2cc', pants = '#283a43', scale = 1, skin = '#c89670') { const root = group(x, 0, z); root.scale.setScalar(scale); const body = group(0, .95, 0, root), chest = group(0, 0, 0, body); // Adult silhouette: about seven head-heights; no oversized toy head. const torsoGeo = geometry('adult-torso', () => { const geo = new THREE.BoxGeometry(1,1,1), pos = geo.attributes.position; for(let i=0;i<pos.count;i++) { const y=pos.getY(i); pos.setX(i,pos.getX(i)*(y>0?.46:.34));pos.setY(i,(y+.5)*.55+.025);pos.setZ(i,pos.getZ(i)*(y>0?.245:.215)); } geo.computeVertexNormals(); return geo; }); mesh(torsoGeo,shirt,0,0,0,chest); box(.35,.12,.235,pants,0,.015,0,body); cylinder(.052,.16,skin,0,.65,0,chest); const head = group(0,.84,0,chest); const face = ball(.13,skin,0,0,0,head);face.scale.z*=.9; const hair = ball(.136,'#42382b',0,.065,-.026,head);hair.scale.y*=.53; box(.045,.05,.045,skin,0,-.005,.113,head); for(const dx of [-.048,.048]) box(.012,.016,.01,'#39352e',dx,.022,.112,head); const limb = (x,y,color,length,radius,container) => { const pivot=group(x,y,0,container); const geo=geometry('limb',()=>new THREE.CylinderGeometry(1,.82,1,8)); const shape=mesh(geo,color,0,-length/2,0,pivot);shape.scale.set(radius,length,radius);return pivot; }; const leftArm=limb(-.265,.53,shirt,.29,.072,chest),rightArm=limb(.265,.53,shirt,.29,.072,chest); const leftForearm=limb(0,-.29,skin,.28,.049,leftArm),rightForearm=limb(0,-.29,skin,.28,.049,rightArm); const leftHand=group(0,-.28,0,leftForearm),rightHand=group(0,-.28,0,rightForearm); box(.075,.095,.055,skin,0,-.012,0,leftHand);box(.075,.095,.055,skin,0,-.012,0,rightHand); const leftLeg=limb(-.13,0,pants,.43,.085,body),rightLeg=limb(.13,0,pants,.43,.085,body); const leftShin=limb(0,-.43,pants,.4,.065,leftLeg),rightShin=limb(0,-.43,pants,.4,.065,rightLeg); const leftFoot=group(0,-.43,0,leftShin),rightFoot=group(0,-.43,0,rightShin); const shoeGeo=geometry('adult-shoe',()=>{const g=new THREE.CapsuleGeometry(.5,1,3,8);g.rotateX(Math.PI/2);return g;}); for(const foot of [leftFoot,rightFoot]){const shoe=mesh(shoeGeo,'#ded9c9',0,0,.05,foot);shoe.scale.set(.155,.09,.1325);} return {root,body,chest,head,face,leftArm,rightArm,leftForearm,rightForearm,leftHand,rightHand,leftLeg,rightLeg,leftShin,rightShin,leftFoot,rightFoot}; } const reachCache=new WeakMap(); function cacheFor(actor){ if(!reachCache.has(actor))reachCache.set(actor,{v:new THREE.Vector3(),d:new THREE.Vector3(),n:new THREE.Vector3(),e:new THREE.Vector3(),lower:new THREE.Vector3(),down:new THREE.Vector3(0,-1,0),inv:new THREE.Quaternion(),endpoint:new THREE.Vector3(),footQ:new THREE.Quaternion()}); return reachCache.get(actor); } function solve(actor,upper,lowerJoint,space,target,a,b,bend){ const c=cacheFor(actor),{v,d,n,e,lower,down,inv,endpoint}=c; actor.root.updateMatrixWorld(true);v.set(...target);space.worldToLocal(v);d.subVectors(v,upper.position); const distance=Math.max(Math.abs(a-b)+.00001,Math.min(a+b-.00001,d.length()));d.normalize();v.copy(upper.position).addScaledVector(d,distance); const along=(a*a-b*b+distance*distance)/(2*distance),height=Math.sqrt(Math.max(0,a*a-along*along)); n.set(...bend);n.addScaledVector(d,-n.dot(d));if(n.lengthSq()<1e-8)n.set(1,0,0);n.normalize(); e.copy(upper.position).addScaledVector(d,along).addScaledVector(n,height); lower.subVectors(e,upper.position).normalize();upper.quaternion.setFromUnitVectors(down,lower); inv.copy(upper.quaternion).invert();lower.subVectors(v,e).applyQuaternion(inv).normalize();lowerJoint.quaternion.setFromUnitVectors(down,lower); actor.root.updateMatrixWorld(true);endpoint.set(0,-b,0);lowerJoint.localToWorld(endpoint);return endpoint.toArray(); } function reach(actor,side,target){return solve(actor,side<0?actor.leftArm:actor.rightArm,side<0?actor.leftForearm:actor.rightForearm,actor.chest??actor.body,target,.29,.28,[side*.6,0,1]);} function plant(actor,{crouch=0,lean=0,twist=0}={}){ actor.body.position.y=.95-crouch;actor.chest.rotation.set(lean,twist,0); const c=cacheFor(actor); for(const side of [-1,1]){ actor.root.updateMatrixWorld(true);c.v.set(side*.13,.09,0);actor.root.localToWorld(c.v); solve(actor,side<0?actor.leftLeg:actor.rightLeg,side<0?actor.leftShin:actor.rightShin,actor.body,c.v.toArray(),.43,.43,[0,0,1]); const shin=side<0?actor.leftShin:actor.rightShin,foot=side<0?actor.leftFoot:actor.rightFoot; shin.getWorldQuaternion(c.inv);c.inv.invert();actor.root.getWorldQuaternion(c.footQ);foot.quaternion.copy(c.inv).multiply(c.footQ); } actor.root.updateMatrixWorld(true); } const batches = []; function batch() { const pools = new Map(); parent.updateMatrixWorld(true); parent.traverse(o => { if (!o.isMesh || o.isInstancedMesh) return; const key = `${o.geometry.uuid}:${o.material.uuid}:${o.castShadow}:${o.receiveShadow}`; if (!pools.has(key)) pools.set(key, []); pools.get(key).push(o); }); for (const originals of pools.values()) { if (originals.length < 2) continue; const first = originals[0], inst = new THREE.InstancedMesh(first.geometry, first.material, originals.length); inst.castShadow = first.castShadow; inst.receiveShadow = first.receiveShadow; inst.instanceMatrix.setUsage(THREE.DynamicDrawUsage); inst.frustumCulled = false; for (const o of originals) o.visible = false; parent.add(inst); batches.push({ inst, originals }); } } const inverse = new THREE.Matrix4(), local = new THREE.Matrix4(); function updateBatches() { parent.updateMatrixWorld(true); inverse.copy(parent.matrixWorld).invert(); for (const { inst, originals } of batches) { originals.forEach((o, i) => { local.multiplyMatrices(inverse, o.matrixWorld); inst.setMatrixAt(i, local); }); inst.instanceMatrix.needsUpdate = true; } } function dispose() { for (const resource of resources) resource.dispose(); for (const g of geometries.values()) g.dispose(); for (const m of materials.values()) m.dispose(); for (const t of textures) t.dispose(); parent.traverse(o => { if (o.isInstancedMesh) o.dispose(); }); } return { THREE, own, reach, plant, surface, group, box, ball, cylinder, line, ring, terrain, grass, tree, bench, person, random, phase, smooth, batch, updateBatches, dispose }; } const vertexShader = `varying vec2 uv0; void main(){uv0=uv;gl_Position=vec4(position.xy,0.,1.);}`; const fragmentShader = ` precision highp float; uniform sampler2D image; uniform sampler2D depth; uniform int mode; uniform vec2 resolution; varying vec2 uv0; float bayer(vec2 p){vec2 q=mod(floor(p),4.);float a=mod(q.x,2.)*2.+mod(q.y,2.)*3.;a=mod(a,4.);vec2 r=floor(q/2.);float b=mod(r.x*2.+r.y*3.,4.);return (4.*a+b+.5)/16.-.5;} vec3 srgb(vec3 x){return mix(12.92*x,1.055*pow(max(x,vec3(0.)),vec3(1./2.4))-.055,step(vec3(.0031308),x));} void main(){ vec4 c=texture2D(image,uv0);vec3 color=srgb(c.rgb); if(mode==1){float d=bayer(gl_FragCoord.xy);color=floor(clamp(color+d/24.,0.,1.)*23.+.5)/23.;} if(mode>=2){ float z=texture2D(depth,uv0).x; // Depth separates even pale objects from untouched white negative space. if(z<.99999){ float l=dot(color,vec3(.299,.587,.114));vec2 px=1./resolution; float edge=0.; for(int i=0;i<4;i++){ vec2 offset=i==0?vec2(px.x,0.):i==1?vec2(-px.x,0.):i==2?vec2(0.,px.y):vec2(0.,-px.y); vec3 neighbor=srgb(texture2D(image,uv0+offset).rgb); edge=max(edge,length(color-neighbor)); edge=max(edge,step(.0015,abs(z-texture2D(depth,uv0+offset).x))); } if(mode==2){ vec2 p=gl_FragCoord.xy;vec2 rotated=vec2(p.x+p.y,p.y-p.x)*.70710678; vec2 cell=fract(rotated/5.)-.5;float tone=clamp((1.-l)*1.25,0.,1.); float dotInk=1.-smoothstep(tone*.65-.06,tone*.65+.06,length(cell)); float lineInk=smoothstep(.14,.5,edge)*.48; vec3 paper=vec3(.975,.949,.882),ink=mix(vec3(.16,.22,.26),vec3(.57,.24,.16),smoothstep(.04,.25,color.r-color.b)); color=mix(paper,ink,max(dotInk,lineInk)); }else{ vec2 grid=abs(fract(gl_FragCoord.xy/24.)-.5); float gridLine=smoothstep(.46,.49,max(grid.x,grid.y))*.13; color=mix(vec3(.055,.19,.38),vec3(.20,.44,.61),l*.65+gridLine); color=mix(color,vec3(.84,.95,.96),smoothstep(.08,.32,edge)); } }else{color=vec3(1.);} } gl_FragColor=vec4(color,c.a); }`; export function mountWorld(def, { el, finish = 'miniature', paused = false } = {}) { if (!el) throw new Error('mountWorld needs a container.'); if (!FINISHES.includes(finish)) throw new Error('Unknown finish.'); let renderer; try { renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false, powerPreference: 'low-power' }); } catch { el.textContent = 'This world needs WebGL 2. Try a browser with hardware acceleration enabled.'; el.dataset.state = 'unavailable'; return null; } el.replaceChildren(renderer.domElement); el.dataset.state = 'ready'; const canvas = renderer.domElement; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', def.description); canvas.style.cssText = 'display:block;width:100%;height:100%'; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFShadowMap; renderer.setPixelRatio(1); renderer.setClearColor('#ffffff'); renderer.outputColorSpace = THREE.SRGBColorSpace; const scene = new THREE.Scene(); scene.background = new THREE.Color('#ffffff'); const content = new THREE.Group(); scene.add(content); scene.add(new THREE.HemisphereLight('#f5f4df', '#8b9476', 2.2)); const sun = new THREE.DirectionalLight('#fff1d5', 3); sun.position.set(-7, 13, 8); sun.castShadow = true; sun.shadow.mapSize.set(1024, 1024); sun.shadow.bias = -.0005; sun.shadow.normalBias = .045; Object.assign(sun.shadow.camera, { left: -15, right: 15, top: 15, bottom: -15, near: .1, far: 45 }); scene.add(sun); const W = toolkit(content), parts = def.build(W); W.batch(); const camera = new THREE.OrthographicCamera(-12, 12, 9, -9, .1, 100); camera.position.set(24, 21, 26); camera.lookAt(0, 0, 0); camera.updateMatrixWorld(); // Fit all declared motion bounds, not merely whatever pose happened to build. const bounds = def.bounds ? new THREE.Box3(new THREE.Vector3(...def.bounds[0]), new THREE.Vector3(...def.bounds[1])) : new THREE.Box3().setFromObject(content); 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 THREE.Vector3(x, y, z).applyMatrix4(camera.matrixWorldInverse)); const minX = Math.min(...corners.map(v => v.x)), maxX = Math.max(...corners.map(v => v.x)); const minY = Math.min(...corners.map(v => v.y)), maxY = Math.max(...corners.map(v => v.y)); const centerX = (minX + maxX) / 2, centerY = (minY + maxY) / 2; const target = new THREE.WebGLRenderTarget(1, 1, { minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter }); target.depthTexture = new THREE.DepthTexture(1, 1, THREE.UnsignedIntType); const post = new THREE.Scene(), postCamera = new THREE.Camera(); const postMaterial = new THREE.ShaderMaterial({ uniforms: { image: { value: target.texture }, depth: { value: target.depthTexture }, mode: { value: FINISHES.indexOf(finish) }, resolution: { value: new THREE.Vector2(1,1) } }, vertexShader, fragmentShader, depthTest: false, depthWrite: false }); const quadGeo = new THREE.PlaneGeometry(2, 2); post.add(new THREE.Mesh(quadGeo, postMaterial)); const media = matchMedia('(prefers-reduced-motion: reduce)'); let disposed = false, visible = true, contextLost = false, looping = false, time = def.still, epoch = performance.now(), anchor = def.still, speed = 1, lastFrame = -Infinity; let frames = 0, draws = 0, triangles = 0, width = 1, height = 1; function render(t) { if (disposed || contextLost) return; time = t; def.tick({ t, parts, W }); W.updateBatches(); renderer.info.reset(); renderer.info.autoReset = false; renderer.setRenderTarget(target); renderer.render(scene, camera); renderer.setRenderTarget(null); renderer.render(post, postCamera); draws = renderer.info.render.calls; triangles = renderer.info.render.triangles; frames++; } function resize() { if (disposed) return; const rect = el.getBoundingClientRect(); width = Math.max(1, Math.round(rect.width)); height = Math.max(1, Math.round(rect.height)); const aspect = width / height, spanY = Math.max(maxY - minY, (maxX - minX) / aspect) * 1.12; camera.left = centerX - spanY * aspect / 2; camera.right = centerX + spanY * aspect / 2; camera.top = centerY + spanY / 2; camera.bottom = centerY - spanY / 2; camera.updateProjectionMatrix(); const scale = finish === 'pixel' ? 1 / (width < 600 ? 2 : 3) : Math.min(devicePixelRatio || 1, 1.5); const w = Math.max(1, Math.round(width * scale)), h = Math.max(1, Math.round(height * scale)); renderer.setSize(w, h, false); target.setSize(w, h); postMaterial.uniforms.resolution.value.set(w,h); canvas.style.imageRendering = finish === 'pixel' ? 'pixelated' : 'auto'; render(time); } function canRun() { return !disposed && !paused && !media.matches && visible && !document.hidden && !contextLost; } function synchronize() { const next = canRun(); if (next === looping) return; looping = next; epoch = performance.now(); anchor = time; lastFrame = -Infinity; renderer.setAnimationLoop(next ? now => { if (now - lastFrame < 1000 / 30 - .5) return; lastFrame = now; render(clockTime(anchor, now - epoch, def.duration, speed)); } : null); } const visibility = () => synchronize(); const reduced = () => { if (media.matches) render(def.still); synchronize(); }; const lose = e => { e.preventDefault(); contextLost = true; el.dataset.state = 'context-lost'; synchronize(); }; const restore = () => { contextLost = false; el.dataset.state = 'ready'; resize(); synchronize(); }; canvas.addEventListener('webglcontextlost', lose); canvas.addEventListener('webglcontextrestored', restore); document.addEventListener('visibilitychange', visibility); media.addEventListener('change', reduced); const io = new IntersectionObserver(entries => { const latest = entries.at(-1); if (latest) { visible = latest.isIntersecting; synchronize(); } }); io.observe(el); const ro = new ResizeObserver(resize); ro.observe(el); resize(); synchronize(); return { setFinish(value) { if (!FINISHES.includes(value)) throw new Error('Unknown finish.'); finish = value; postMaterial.uniforms.mode.value = FINISHES.indexOf(value); resize(); }, setPaused(value) { paused = Boolean(value); synchronize(); }, setSpeed(value) { if (!Number.isFinite(value) || value < .25 || value > 3) throw new Error('Speed must be between 0.25 and 3.'); anchor = time; epoch = performance.now(); speed = value; }, seek(value) { if (!Number.isFinite(value)) throw new Error('Time must be finite.'); render(phase(value, def.duration) * def.duration); anchor = time; epoch = performance.now(); }, contacts() { return def.contacts ? def.contacts({ t: time, parts, W }) : []; }, stats() { return { time, duration: def.duration, finish, speed, paused, reduced: media.matches, running: looping, frames, draws, triangles, width, height, buffer: [canvas.width, canvas.height], geometries: renderer.info.memory.geometries, textures: renderer.info.memory.textures }; }, snapshot() { const a = []; content.updateMatrixWorld(true); content.traverse(o => { if (o.isMesh || o.isGroup) a.push(...o.matrixWorld.elements); }); return a; }, dispose() { if (disposed) return; disposed = true; looping = false; renderer.setAnimationLoop(null); ro.disconnect(); io.disconnect(); document.removeEventListener('visibilitychange', visibility); media.removeEventListener('change', reduced); canvas.removeEventListener('webglcontextlost', lose); canvas.removeEventListener('webglcontextrestored', restore); W.dispose(); sun.shadow.dispose(); target.dispose(); quadGeo.dispose(); postMaterial.dispose(); renderer.dispose(); renderer.forceContextLoss(); canvas.remove(); el.dataset.state = 'disposed'; } }; }skills/diorama-worlds/LICENSE1.1 KB7a64c4a12bfaupstream ↗
MIT License Copyright (c) 2026 Eduardo Calvo Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Generated HTML bundles three.js (MIT); see THIRD_PARTY_NOTICES.txt.skills/diorama-worlds/package-lock.json16 KB4287037b8b4dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/diorama-worlds/package.json401 Bfd343abffee8upstream ↗
{ "name": "diorama-worlds", "version": "1.3.0", "private": true, "type": "module", "scripts": { "build": "node scripts/build-world.mjs", "test": "node --test scripts/runtime.test.mjs", "look": "node scripts/look-world.mjs" }, "dependencies": { "three": "0.186.1" }, "devDependencies": { "esbuild": "0.28.2", "playwright": "1.64.1", "pngjs": "7.0.0" } }skills/diorama-worlds/THIRD_PARTY_NOTICES.txt1.1 KB89e2581b338fupstream ↗
Three.js: MIT license The MIT License Copyright © 2010-2026 three.js authors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.README.mdupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
Plain-text copy, no login: /skill-bundles/educlopez-diorama-skills-diorama-worlds.md