import sources from scattered branches

This commit is contained in:
2026-07-08 23:12:18 -04:00
parent a1cbd1657e
commit 5e9d8f1b34
9409 changed files with 97775 additions and 7 deletions

Binary file not shown.

150
src/blockstate.js Normal file
View File

@@ -0,0 +1,150 @@
export class BlockstateHandler {
async process(cache, id, url) {
if (id === ':missing') {
return new Blockstate({variants: {"": {model: ":missing"}}});
}
try {
const res = await fetch(url + '.json');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
return new Blockstate(json);
} catch (e) {
console.warn(`Blockstate missing/broken (${id}), falling back to :missing`);
return new Blockstate({variants: {"": {model: ":missing"}}});
}
}
getFallback(id) {
return new Blockstate({variants: {"": {model: ":missing"}}});
}
}
function evaluateWhen(properties, condition) {
if (!condition) return true;
if (condition.OR) return condition.OR.some(sub => evaluateWhen(properties, sub));
if (condition.AND) return condition.AND.every(sub => evaluateWhen(properties, sub));
for (const [key, val] of Object.entries(condition)) {
if (key === 'OR' || key === 'AND') continue;
const prop = properties[key];
if (typeof val === 'string' && val.includes('|')) {
if (!val.split('|').includes(prop)) return false;
} else if (prop !== val) {
return false;
}
}
return true;
}
export class Blockstate {
constructor(json) {
this.variants = json.variants;
this.multipart = json.multipart;
this._resolutionCache = new Map();
}
_getVariantsSummary() {
const reqs = {};
for (const key of Object.keys(this.variants)) {
if (key === "" || key === "normal") continue;
const pairs = key.split(',');
for (const pair of pairs) {
const [prop, val] = pair.split('=');
if (prop && val) {
if (!reqs[prop]) reqs[prop] = new Set();
reqs[prop].add(val);
}
}
}
const summary = {};
for (const [k, v] of Object.entries(reqs)) {
summary[k] = Array.from(v);
}
return summary;
}
_getMultipartSummary() {
const reqs = {};
const traverse = (cond) => {
if (!cond) return;
if (cond.OR) cond.OR.forEach(traverse);
if (cond.AND) cond.AND.forEach(traverse);
for (const [key, val] of Object.entries(cond)) {
if (key === 'OR' || key === 'AND') continue;
if (!reqs[key]) reqs[key] = new Set();
reqs[key].add(val);
}
};
if (this.multipart) {
for (const part of this.multipart) {
traverse(part.when);
}
}
const summary = {};
for (const [k, v] of Object.entries(reqs)) {
summary[k] = Array.from(v);
}
return summary;
}
resolveParts(properties) {
const cacheKey = Object.keys(properties).sort().map(k => `${k}=${properties[k]}`).join(',');
if (this._resolutionCache.has(cacheKey)) {
return this._resolutionCache.get(cacheKey);
}
const parts = [];
if (this.variants) {
let variantDef = this.variants[cacheKey] || this.variants[""] || this.variants["normal"];
if (!variantDef) {
console.warn(
`Failed to resolve block variant: "${cacheKey}". Falling back to :missing.\n` +
`Properties evaluated by this blockstate:`,
this._getVariantsSummary()
);
const missingFallback = [{model: ":missing"}];
this._resolutionCache.set(cacheKey, missingFallback);
return missingFallback;
}
if (Array.isArray(variantDef)) variantDef = variantDef[0];
parts.push(variantDef);
} else if (this.multipart) {
for (const part of this.multipart) {
if (evaluateWhen(properties, part.when)) {
let applyDef = part.apply;
if (Array.isArray(applyDef)) applyDef = applyDef[0];
parts.push(applyDef);
}
}
if (parts.length === 0) {
console.warn(
`Multipart block ("${cacheKey}") resolved to no parts. Falling back to :missing.\n` +
`Properties evaluated by this blockstate:`,
this._getMultipartSummary()
);
const missingFallback = [{model: ":missing"}];
this._resolutionCache.set(cacheKey, missingFallback);
return missingFallback;
}
}
this._resolutionCache.set(cacheKey, parts);
return parts;
}
static getVariantHash(part) {
return `${part.model}#y=${part.y || 0},x=${part.x || 0},uvlock=${!!part.uvlock}`;
}
}

67
src/cache.js Normal file
View File

@@ -0,0 +1,67 @@
// cache.js
export class Cache {
constructor(root, onResolve) {
this.root = root;
this.onResolve = onResolve || (() => {
});
this.handlers = new Map();
this.promises = new Map();
this.data = new Map();
}
register(kind, handler) {
this.handlers.set(kind, handler);
}
// Used by internal loaders (like models loading parent models)
getAsync(id, kind) {
this.getSync(id, kind); // Triggers the load if it hasn't started
const [namespace, resource] = id.includes(':') ? id.split(':', 2) : ["minecraft", id];
const key = `${kind}:${namespace}:${resource}`;
if (this.promises.has(key)) {
return this.promises.get(key);
}
return Promise.resolve(this.data.get(key));
}
// Used by the Engine for immediate, non-blocking rendering
getSync(id, kind) {
const [namespace, resource] = id.includes(':') ? id.split(':', 2) : ["minecraft", id];
const normalizedId = `${namespace}:${resource}`;
const key = `${kind}:${normalizedId}`;
const url = `${this.root}/${namespace}/${kind}/${resource}`;
// 1. If we already have the real data, return it instantly
if (this.data.has(key)) return this.data.get(key);
// 2. If we haven't even started loading it yet, kick off the fetch
if (!this.promises.has(key)) {
const handler = this.handlers.get(kind);
if (!handler) throw new Error(`No handler registered for resource kind: ${kind}`);
if (handler.prepare) handler.prepare(this, normalizedId, url);
const promise = handler.process(this, normalizedId, url).then(result => {
this.data.set(key, result);
this.onResolve(); // Ping the engine to re-render!
return result;
}).catch(err => {
console.warn(`Failed to load ${key}:`, err);
const fallback = handler.getFallback(normalizedId);
this.data.set(key, fallback);
this.onResolve();
return fallback;
});
this.promises.set(key, promise);
}
// 3. Always return the synchronous fallback while the promise resolves in the background
return this.handlers.get(kind).getFallback(normalizedId);
}
}

260
src/components.css Normal file
View File

@@ -0,0 +1,260 @@
/* --- Component Structural Styles --- */
mc-world, mc-timeline, mc-camera, mc-frame { display: none; }
mc-diorama {
display: block;
position: relative;
width: 100%;
height: 20em;
& canvas {
display: block;
width: 100%;
height: 100%;
}
}
.spacetime-overlay {
position: absolute;
inset: 0; /* Replaces top, left, width, height */
padding: 5px;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: space-between;
box-sizing: border-box;
z-index: 10;
font-family: sans-serif;
/* Core icon engine rules */
& .icon {
display: inline-block;
width: 1.2em;
height: 1.2em;
background-color: currentColor; /* Inherits text color (white, or hover states) */
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
/* Map classes to your local SVG paths */
&.ico-play {
mask-image: url('ico-play.svg');
-webkit-mask-image: url('ico-play.svg');
}
&.ico-pause {
mask-image: url('ico-pause.svg');
-webkit-mask-image: url('ico-pause.svg');
}
&.ico-auto-events {
mask-image: url('ico-auto-events.svg');
-webkit-mask-image: url('ico-auto-events.svg');
}
&.ico-prev {
mask-image: url('ico-prev.svg');
-webkit-mask-image: url('ico-prev.svg');
}
&.ico-next {
mask-image: url('ico-next.svg');
-webkit-mask-image: url('ico-next.svg');
}
&.ico-prev-event {
mask-image: url('ico-prev-event.svg');
-webkit-mask-image: url('ico-prev-event.svg');
}
&.ico-next-event {
mask-image: url('ico-next-event.svg');
-webkit-mask-image: url('ico-next-event.svg');
}
&.ico-reset {
mask-image: url('ico-reset.svg');
-webkit-mask-image: url('ico-reset.svg');
}
/* Spatial indicators (mapped for future integration if desired) */
&.ico-rotate {
mask-image: url('ico-rotate.svg');
-webkit-mask-image: url('ico-rotate.svg');
}
&.ico-pan {
mask-image: url('ico-pan.svg');
-webkit-mask-image: url('ico-pan.svg');
}
&.ico-zoom {
mask-image: url('ico-zoom.svg');
-webkit-mask-image: url('ico-zoom.svg');
}
}
/* Define the safe zones for the top cluster */
& .top-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
pointer-events: none;
}
/* Spatial Tool Hints */
& .spacetime-indicator {
display: flex;
gap: 4px;
/* Mimic the .btn box model so baselines perfectly align with the Play button */
padding: 4px 8px;
border: 1px solid transparent;
color: rgba(255, 255, 255, 0.4);
font-family: monospace;
font-size: 14px;
opacity: 1;
transition: opacity 0.2s ease;
pointer-events: auto;
}
/* Unified Button Design Language */
& .btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px; /* Spacing between icon and text for the reset button */
}
& .btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
text-align: center;
font-weight: bold;
font-family: inherit;
backdrop-filter: blur(4px);
pointer-events: auto;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
& .top-row .btn {
background: rgba(0, 0, 0, 0.6);
visibility: hidden;
&:hover {
background: rgba(0, 0, 0, 0.8);
}
}
/* Bottom: Timeline Panel Context */
& .visualizer-ui {
pointer-events: none;
background: transparent;
margin: -5px;
padding: 5px;
display: flex;
flex-direction: column;
gap: 4px;
color: white;
user-select: none;
border: 1px solid transparent;
transition: all 0.2s ease;
& .scrubber-row {
display: flex;
gap: 4px;
align-items: center;
}
& .track-container {
flex-grow: 1;
height: 24px;
padding: 0 8px; /* Safe zone so the rounded handle doesn't bleed out */
cursor: pointer;
display: flex;
align-items: center;
& .track-inner {
position: relative;
width: 100%;
height: 8px; /* Defines the actual track geometry */
background: rgba(255, 255, 255, 0.2);
pointer-events: none;
& .track-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0; /* Snaps tightly to inner geometry */
background: #f00;
}
& .track-handle {
position: absolute;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 3;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
}
& .marker {
position: absolute;
top: 0;
bottom: 0; /* Matches track height */
transform: translateX(-50%);
pointer-events: none;
z-index: 1;
&.marker-event {
width: 2px;
background: rgba(255, 255, 255, 0.5);
}
&.marker-label {
width: 4px;
background: gold;
z-index: 2;
}
}
}
}
}
}
/* --- Hover Visibility Logic --- */
.spacetime-hover-reveal {
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.spacetime-container:hover {
& .spacetime-hover-reveal {
opacity: 1;
pointer-events: auto;
}
& .visualizer-ui {
pointer-events: auto;
}
}

123
src/components.js Normal file
View File

@@ -0,0 +1,123 @@
// components.js
import {Engine} from './engine.js';
import {World, WorldFrame} from './world.js';
import {Diorama} from './diorama.js';
import {SpacetimeController} from './controller.js';
// Global Engine Singleton
window.mcEngine = new Engine();
document.body.appendChild(window.mcEngine.canvas)
export class MCFrameElement extends HTMLElement {
connectedCallback() {
// Use a microtask to ensure children are parsed
setTimeout(() => this.init(), 0);
}
init() {
if (this.frame) return;
let world;
const worldId = this.getAttribute('worldid');
if (worldId) {
world = document.getElementById(worldId)?.world;
} else {
const worldEl = this.querySelector('script[type="text/mc-world"]');
if (!worldEl.world) {
worldEl.world = new World(worldEl.textContent || "");
}
world = worldEl?.world;
}
if (world) {
this.frame = new WorldFrame(world);
}
}
}
export class MCDioramaElement extends HTMLElement {
connectedCallback() {
setTimeout(() => this.init(), 0);
}
init() {
const engine = window.mcEngine;
let frame;
// 1. Resolve the Frame context
const frameId = this.getAttribute('frameid');
const worldId = this.getAttribute('worldid');
if (frameId) {
frame = document.getElementById(frameId)?.frame;
} else if (worldId) {
const world = document.getElementById(worldId)?.world;
if (world) frame = new WorldFrame(world);
} else {
const frameEl = this.querySelector('mc-frame');
if (frameEl) {
frame = frameEl.frame;
} else {
const worldEl = this.querySelector('script[type="text/mc-world"]');
if (!worldEl.world) {
worldEl.world = new World(worldEl.textContent || "");
}
frame = new WorldFrame(worldEl.world);
}
}
if (!frame) {
console.error("mc-diorama requires a resolved frame or world.");
return;
}
// 2. Build the Diorama
const diorama = new Diorama(this, frame, () => engine.requestRender());
// Process spatial attributes
if (this.hasAttribute('radius')) diorama.radius = parseFloat(this.getAttribute('radius'));
if (this.hasAttribute('theta')) diorama.theta = parseFloat(this.getAttribute('theta'));
if (this.hasAttribute('phi')) diorama.phi = parseFloat(this.getAttribute('phi'));
if (this.hasAttribute('target')) {
const [x, y, z] = this.getAttribute('target').trim().split(/\s+/);
diorama.target = [
parseFloat(x),
parseFloat(y),
parseFloat(z),
];
} else {
diorama.centerView();
}
diorama.saveState(); // Lock this in for the Reset button
// Default to fully static
const opts = {
orbit: true,
pan: false,
zoom: false,
timeline: false,
autoplay: false,
loop: false,
subticks: false,
};
if (this.hasAttribute('autoplay')) opts.autoplay = this.getAttribute('autoplay') !== 'false';
if (this.hasAttribute('loop')) opts.loop = this.getAttribute('loop') !== 'false';
if (this.hasAttribute('subticks')) opts.subticks = this.getAttribute('subticks') !== 'false';
if (this.hasAttribute('controls')) opts.timeline = this.getAttribute('controls') !== 'false';
if (this.hasAttribute('speed')) opts.speed = parseFloat(this.getAttribute('speed'));
if (this.hasAttribute('orbit')) opts.orbit = this.getAttribute('orbit') !== 'false';
if (this.hasAttribute('pan')) opts.pan = this.getAttribute('pan') !== 'false';
if (this.hasAttribute('zoom')) opts.zoom = this.getAttribute('zoom') !== 'false';
this.controller = new SpacetimeController(diorama, opts);
engine.addDiorama(diorama);
}
}
// Register Elements
customElements.define('mc-frame', MCFrameElement);
customElements.define('mc-diorama', MCDioramaElement);

562
src/controller.js Normal file
View File

@@ -0,0 +1,562 @@
// controller.js
export class SpacetimeController {
constructor(diorama, options = {}) {
this.diorama = diorama;
this.frame = diorama.frame;
this.container = diorama.element;
this.canvas = diorama.canvas;
this.opts = {
orbit: true,
pan: true,
zoom: true,
timeline: true,
subticks: true,
autoplay: false,
loop: false,
speed: 1.0, ...options
};
this.hasSpatial = this.opts.orbit || this.opts.pan || this.opts.zoom;
// Camera State
this.isDragging = false;
this.hasDragged = false;
this.dragButton = 0;
this.lastMouse = {x: 0, y: 0};
// Timeline State
this.playMode = this.opts.autoplay ? 'realtime' : 'paused';
this.isLooping = this.opts.loop;
this.speed = this.opts.speed;
this.lastFrameTime = performance.now();
this.uiTickTime = 0;
this.playbackAccumulator = 0;
this.container.classList.add('spacetime-container');
this.buildUI();
this.attachEvents();
if (this.opts.timeline || this.opts.autoplay) {
this.frame.subscribe(() => this.syncUI());
this._loop = this.loop.bind(this);
requestAnimationFrame(this._loop);
}
}
buildUI() {
this.overlay = document.createElement('div');
this.overlay.className = 'spacetime-overlay';
// --- Top Row Wrapper ---
const topRow = document.createElement('div');
topRow.className = 'top-row';
// --- Feature Indicator (Top Left) ---
this.indicator = document.createElement('div');
this.indicator.className = 'spacetime-indicator';
// Inject individual SVG icons with structural tooltips
if (this.opts.orbit) {
this.indicator.innerHTML += `<span class="icon ico-rotate" title="Rotate view: left drag"></span>`;
}
if (this.opts.pan) {
this.indicator.innerHTML += `<span class="icon ico-pan" title="Move view: middle drag (or shift left drag)"></span>`;
}
if (this.opts.zoom) {
this.indicator.innerHTML += `<span class="icon ico-zoom" title="Zoom view: right drag (or ctrl left drag)"></span>`;
}
topRow.appendChild(this.indicator);
// --- Reset Button (Top Right) ---
if (this.hasSpatial) {
this.resetBtn = document.createElement('div');
this.resetBtn.className = 'btn spacetime-hover-reveal';
this.resetBtn.innerHTML = `<span class="icon ico-reset" title="Reset camera"></span>`;
this.resetBtn.addEventListener('click', () => {
this.diorama.loadState();
this.resetBtn.style.visibility = 'hidden';
});
topRow.appendChild(this.resetBtn);
}
this.overlay.appendChild(topRow);
// --- Bottom: Timeline Controls ---
if (this.opts.timeline) {
this.uiContainer = document.createElement('div');
this.uiContainer.className = 'visualizer-ui';
if (this.opts.subticks) {
this.subRow = document.createElement('div');
this.subRow.className = 'scrubber-row spacetime-hover-reveal';
this.subRow.innerHTML = `
<div class="btn" id="btn-play-sub" title="Animate block updates"><span class="icon ico-auto-events"></span></div>
<div class="btn" id="btn-prev-sub" title="Previous block update"><span class="icon ico-next-event" style="transform: scaleX(-1);"></span></div>
<div class="track-container" id="track-sub-container" title="Select block update (in current tick)">
<div class="track-inner" id="track-sub-inner">
<div class="track-fill" id="fill-sub"></div>
<div id="markers-sub"></div>
<div class="track-handle" id="handle-sub"></div>
</div>
</div>
<div class="btn" id="btn-next-sub" title="Next block update"><span class="icon ico-next-event"></span></div>
`;
this.uiContainer.appendChild(this.subRow);
}
this.tickRow = document.createElement('div');
this.tickRow.className = 'scrubber-row';
this.tickRow.innerHTML = `
<div class="btn" id="btn-play" title="Animate gameticks" style="pointer-events: auto;"><span class="icon ico-play"></span></div>
<div class="btn spacetime-hover-reveal" id="btn-loop" title="Repeat playback"><span class="icon ico-rotate"></span></div>
<div class="btn spacetime-hover-reveal" id="btn-prev" title="Previous gametick"><span class="icon ico-next" style="transform: scaleX(-1);"></span></div>
<div class="track-container spacetime-hover-reveal" id="track-tick-container" title="Select gametick">
<div class="track-inner" id="track-tick-inner">
<div class="track-fill" id="fill-tick"></div>
<div id="markers-tick"></div>
<div class="track-handle" id="handle-tick"></div>
</div>
</div>
<div class="btn spacetime-hover-reveal" id="btn-next" title="Next gametick"><span class="icon ico-next"></span></div>
`;
this.uiContainer.appendChild(this.tickRow);
this.overlay.appendChild(this.uiContainer);
this.bindTimelineEvents();
this.generateTickMarkers();
this.syncUI();
}
this.container.appendChild(this.overlay);
}
// --- SPATIAL CONTROL ---
orbit(dx, dy) {
if (!this.opts.orbit) return;
this.diorama.theta -= dx * 0.4;
this.diorama.phi += dy * 0.4;
this.diorama.phi = Math.max(-90, Math.min(90, this.diorama.phi));
}
pan(dx, dy) {
if (!this.opts.pan) return;
const t = this.diorama.theta * Math.PI / 180;
const p = this.diorama.phi * Math.PI / 180;
const rightX = Math.cos(t), rightZ = -Math.sin(t);
const upX = -Math.sin(p) * Math.sin(t), upY = Math.cos(p), upZ = -Math.sin(p) * Math.cos(t);
const rect = this.canvas.getBoundingClientRect();
const panSpeed = this.diorama.radius / rect.height;
this.diorama.target[0] += (-rightX * dx + upX * dy) * panSpeed;
this.diorama.target[1] += (upY * dy) * panSpeed;
this.diorama.target[2] += (-rightZ * dx + upZ * dy) * panSpeed;
}
zoomRatio(ratio) {
if (!this.opts.zoom) return;
this.diorama.radius *= ratio;
this.diorama.radius = Math.max(1, Math.min(50, this.diorama.radius));
}
zoomLinear(delta) {
if (!this.opts.zoom) return;
this.diorama.radius += delta * 0.05;
this.diorama.radius = Math.max(1, Math.min(50, this.diorama.radius));
}
attachEvents() {
this.canvas.addEventListener('contextmenu', e => {
if (!this.hasSpatial) return;
e.preventDefault()
});
// Correctly set or remove the grab cursor
this.canvas.style.cursor = this.hasSpatial ? 'grab' : 'default';
const notifyCameraChange = () => {
this.hasDragged = true;
if (this.resetBtn && this.diorama.checkpoint) this.resetBtn.style.visibility = 'visible';
this.diorama.requestRender();
};
this.canvas.addEventListener('mousedown', (e) => {
// Short-circuit drag logic entirely if no spatial controls are enabled
if (!this.hasSpatial) return;
this.isDragging = true;
this.hasDragged = false;
this.lastMouse = {x: e.clientX, y: e.clientY};
this.dragButton = e.button;
if (this.dragButton === 0) {
if (e.ctrlKey || e.metaKey) this.dragButton = 2; else if (e.shiftKey) this.dragButton = 1;
}
if (this.dragButton === 1) this.canvas.style.cursor = 'move'; else if (this.dragButton === 2) this.canvas.style.cursor = 'ns-resize'; else this.canvas.style.cursor = 'grabbing';
});
window.addEventListener('mouseup', () => {
if (this.isDragging) {
this.isDragging = false;
if (this.hasSpatial) this.canvas.style.cursor = 'grab';
}
});
window.addEventListener('mousemove', (e) => {
if (!this.isDragging) return;
const dx = e.clientX - this.lastMouse.x;
const dy = e.clientY - this.lastMouse.y;
if (Math.hypot(dx, dy) > 3) {
this.lastMouse = {x: e.clientX, y: e.clientY};
if (this.dragButton === 0) this.orbit(dx, dy); else if (this.dragButton === 1) this.pan(dx, dy); else if (this.dragButton === 2) this.zoomLinear(dy);
notifyCameraChange();
}
});
this.canvas.addEventListener('click', () => {
// hasDragged will always be false if hasSpatial is false,
// ensuring static scenes still act as tap-to-play toggles.
if (!this.hasDragged && this.opts.timeline) {
this.playMode = this.playMode === 'paused' ? 'realtime' : 'paused';
this.syncUI();
}
});
}
// --- TEMPORAL CONTROL ---
getMaxTime() {
const evs = this.frame.world.events;
// Add +1 so the final tick plays out its full duration!
return evs.length > 0 ? evs[evs.length - 1].time + 1 : 0;
}
generateTickMarkers() {
const markerContainer = this.uiContainer.querySelector('#markers-tick');
const maxTime = this.getMaxTime();
if (maxTime <= 0) return;
const labeledTimes = new Set();
for (const idx of this.frame.world.labels.values()) {
if (idx < this.frame.world.events.length) {
labeledTimes.add(this.frame.world.events[idx].time);
}
}
for (const t of labeledTimes) {
if (t < 0) continue;
const pct = (t / maxTime) * 100;
const marker = document.createElement('div');
marker.className = 'marker marker-label';
marker.style.left = `${pct}%`;
markerContainer.appendChild(marker);
}
}
setupDraggableTrack(containerId, innerId, onDrag) {
const wrapper = this.uiContainer.querySelector('#' + containerId);
const inner = this.uiContainer.querySelector('#' + innerId);
let isTrackDragging = false;
const update = (e) => {
const rect = inner.getBoundingClientRect();
let pct = (e.clientX - rect.left) / rect.width;
pct = Math.max(0, Math.min(1, pct));
onDrag(pct);
};
wrapper.addEventListener('mousedown', (e) => {
isTrackDragging = true;
this.playMode = 'paused';
update(e);
});
window.addEventListener('mousemove', (e) => {
if (isTrackDragging) update(e);
});
window.addEventListener('mouseup', () => {
isTrackDragging = false;
});
}
bindTimelineEvents() {
const getEl = id => this.uiContainer.querySelector('#' + id);
getEl('btn-loop').onclick = () => {
this.isLooping = !this.isLooping;
this.syncUI();
};
getEl('btn-play').onclick = () => {
if (this.playMode === 'realtime') this.playMode = 'paused';
else {
// Check if time is exhausted OR if all events have been processed
const isAtEnd = this.frame.currentTime >= this.getMaxTime() || this.frame.currentIndex >= this.frame.world.events.length;
if (isAtEnd || this.frame.currentTime < 0) {
this.frame.seek(0);
this.uiTickTime = 0; // Explicitly reset UI sync
}
this.playMode = 'realtime';
}
this.syncUI();
};
getEl('btn-prev').onclick = () => {
this.playMode = 'paused';
let prevTime = Math.floor(this.uiTickTime - 1);
if (prevTime < 0) prevTime = this.isLooping ? this.getMaxTime() : 0;
this.frame.seek(prevTime);
this.uiTickTime = prevTime; // Explicit jump
this.syncUI();
};
getEl('btn-next').onclick = () => {
this.playMode = 'paused';
let nextTime = Math.floor(this.uiTickTime + 1);
if (nextTime > this.getMaxTime()) nextTime = this.isLooping ? 0 : this.getMaxTime();
this.frame.seek(nextTime);
this.uiTickTime = nextTime; // Explicit jump
this.syncUI();
};
this.setupDraggableTrack('track-tick-container', 'track-tick-inner', (pct) => {
const targetTime = Math.round(pct * this.getMaxTime());
this.frame.seek(targetTime);
this.uiTickTime = targetTime; // Explicit jump. Do not snap to event index!
this.syncUI();
});
if (this.opts.subticks) {
getEl('btn-play-sub').onclick = () => {
if (this.playMode === 'subtick') this.playMode = 'paused';
else {
// Check if all events are processed
if (this.frame.currentIndex >= this.frame.world.events.length) {
this.frame.seekIndex(this.frame.world.initialIndex || 0);
const events = this.frame.world.events;
this.uiTickTime = events.length > 0 ? events[0].time : 0;
}
this.playMode = 'subtick';
}
this.syncUI();
};
getEl('btn-prev-sub').onclick = () => {
this.playMode = 'paused';
let idx = this.frame.currentIndex - 1;
const events = this.frame.world.events;
const initialIndex = this.frame.world.initialIndex || 0;
if (idx < initialIndex) idx = this.isLooping ? events.length : initialIndex;
this.frame.seekIndex(idx);
// Allow the UI to fall backwards naturally
if (idx < events.length && idx >= 0) this.uiTickTime = events[idx].time;
this.syncUI();
};
getEl('btn-next-sub').onclick = () => {
this.playMode = 'paused';
let idx = this.frame.currentIndex + 1;
const events = this.frame.world.events;
const initialIndex = this.frame.world.initialIndex || 0;
if (idx > events.length) idx = this.isLooping ? initialIndex : events.length;
this.frame.seekIndex(idx);
// Force UI to advance if the button naturally crossed the threshold
if (idx < events.length && idx >= 0) {
this.uiTickTime = events[idx].time;
} else if (events.length > 0) {
this.uiTickTime = events[events.length - 1].time;
}
this.syncUI();
};
this.setupDraggableTrack('track-sub-container', 'track-sub-inner', (pct) => {
if (this.currentTickEventCount === 0) return;
let targetIdx = this.currentTickStartIndex + Math.round(pct * this.currentTickEventCount);
this.frame.seekIndex(targetIdx);
// Intentionally omit updating uiTickTime to let scrubbing "stick" to the current tick.
this.syncUI();
});
}
}
syncUI() {
if (!this.opts.timeline) return;
const getEl = id => this.uiContainer.querySelector('#' + id);
const playButton = getEl('btn-play');
const playIcon = playButton.querySelector('.icon');
if (this.playMode === 'realtime') {
playIcon.className = 'icon ico-pause';
playButton.setAttribute('title', 'Pause gametick animation'); // Dynamic Tooltip
} else {
playIcon.className = 'icon ico-play';
playButton.setAttribute('title', 'Animate gameticks'); // Dynamic Tooltip
}
getEl('btn-loop').style.background = this.isLooping ? 'rgba(102, 204, 102, 0.4)' : 'rgba(255, 255, 255, 0.1)';
getEl('btn-loop').style.borderColor = this.isLooping ? '#6c6' : 'rgba(255, 255, 255, 0.2)';
if (this.opts.subticks) {
const subPlayButton = getEl('btn-play-sub');
const subPlayIcon = subPlayButton.querySelector('.icon');
if (this.playMode === 'subtick') {
subPlayIcon.className = 'icon ico-pause';
subPlayButton.setAttribute('title', 'Pause block update animation'); // Dynamic Tooltip
} else {
subPlayIcon.className = 'icon ico-auto-events';
subPlayButton.setAttribute('title', 'Animate block updates'); // Dynamic Tooltip
}
this.subRow.style.display = this.playMode === 'realtime' ? 'none' : 'flex';
}
// Force uiTickTime to perfectly match realtime flow
if (this.playMode === 'realtime') {
this.uiTickTime = Math.floor(this.frame.currentTime);
}
const maxTime = this.getMaxTime();
// Fix: Decouple the UI tick track from realtime when scrubbed/paused
let displayTime = Math.max(0, this.frame.currentTime);
if (this.playMode !== 'realtime') {
displayTime = Math.max(0, this.uiTickTime);
}
const tickPct = maxTime > 0 ? (displayTime / maxTime) * 100 : 0;
getEl('fill-tick').style.width = `${tickPct}%`;
getEl('handle-tick').style.left = `${tickPct}%`;
if (!this.opts.subticks) return;
const events = this.frame.world.events;
if (events.length === 0) return;
// --- Verify uiTickTime is valid for the current index ---
let startIdx = -1, count = 0;
for (let i = 0; i < events.length; i++) {
if (events[i].time === this.uiTickTime) {
if (startIdx === -1) startIdx = i;
count++;
}
}
// Fix: Only auto-correct if we are NOT in realtime AND the tick isn't empty.
// This stops empty ticks from auto-snapping to filled ticks.
if (this.playMode !== 'realtime' && startIdx !== -1) {
// Because scrubbing to 100% means currentIndex == startIdx + count,
// this > condition strictly permits the inclusive UI overlap!
if (this.frame.currentIndex < startIdx || this.frame.currentIndex > startIdx + count) {
if (this.frame.currentIndex < events.length) {
this.uiTickTime = events[this.frame.currentIndex].time;
} else {
this.uiTickTime = events[events.length - 1].time;
}
// Recalculate bounds for the newly corrected tick
startIdx = -1;
count = 0;
for (let i = 0; i < events.length; i++) {
if (events[i].time === this.uiTickTime) {
if (startIdx === -1) startIdx = i;
count++;
}
}
}
}
this.currentTickStartIndex = startIdx;
this.currentTickEventCount = count;
const markerContainer = getEl('markers-sub');
markerContainer.innerHTML = '';
if (count > 0) {
const labeledIndices = new Set(this.frame.world.labels.values());
for (let i = 0; i <= count; i++) {
const markerPct = (i / count) * 100;
const marker = document.createElement('div');
marker.className = 'marker';
marker.style.left = `${markerPct}%`;
if (labeledIndices.has(startIdx + i)) marker.classList.add('marker-label'); else marker.classList.add('marker-event');
markerContainer.appendChild(marker);
}
let localCursor = Math.max(0, Math.min(count, this.frame.currentIndex - startIdx));
const subPct = (localCursor / count) * 100;
getEl('fill-sub').style.width = `${subPct}%`;
getEl('handle-sub').style.left = `${subPct}%`;
getEl('handle-sub').style.display = 'block';
} else {
getEl('fill-sub').style.width = `0%`;
getEl('handle-sub').style.display = 'none';
}
}
loop(time) {
const dt = (time - this.lastFrameTime) / 1000;
this.lastFrameTime = time;
const maxTime = this.getMaxTime();
if (this.playMode === 'realtime') {
let nextTime = this.frame.currentTime + dt * 20 * this.speed;
if (nextTime >= maxTime) {
if (this.isLooping && maxTime > 0) {
this.frame.seek(nextTime % maxTime);
} else {
this.frame.seek(maxTime);
this.uiTickTime = maxTime; // Snap UI to the very end
this.playMode = 'paused';
}
} else {
this.frame.seek(nextTime);
}
if (this.opts.timeline) this.syncUI();
} else if (this.playMode === 'subtick') {
this.playbackAccumulator += dt * 4 * this.speed;
if (this.playbackAccumulator >= 1.0) {
const steps = Math.floor(this.playbackAccumulator);
this.playbackAccumulator -= steps;
const nextIdx = this.frame.currentIndex + steps;
const events = this.frame.world.events;
const initialIndex = this.frame.world.initialIndex || 0;
if (nextIdx > events.length) {
if (this.isLooping) {
this.frame.seekIndex(initialIndex);
if (events.length > 0) this.uiTickTime = events[0].time;
} else {
this.frame.seekIndex(events.length);
this.playMode = 'paused';
}
} else {
this.frame.seekIndex(nextIdx);
if (nextIdx < events.length) this.uiTickTime = events[nextIdx].time;
}
}
if (this.opts.timeline) this.syncUI();
} else {
this.playbackAccumulator = 0;
}
requestAnimationFrame(this._loop);
}
}

108
src/diorama.js Normal file
View File

@@ -0,0 +1,108 @@
// diorama.js
import {mat4Ortho, mat4LookAt, mat4Multiply} from './math.js';
export class Diorama {
constructor(element, frame, requestRenderCallback) {
this.element = element;
this.canvas = document.createElement('canvas');
this.element.appendChild(this.canvas);
this.ctx2d = this.canvas.getContext('2d');
this.frame = frame;
this.dirty = true; // Start dirty to guarantee the first render
this.requestEngineRender = requestRenderCallback;
this.target = [0.5, 0, 0.5];
this.radius = 4;
this.theta = 135;
this.phi = 30;
this.projMatrix = new Float32Array(16);
this.viewMatrix = new Float32Array(16);
this.viewProjMatrix = new Float32Array(16);
this.checkpoint = null;
}
requestRender() {
this.dirty = true;
if (this.requestEngineRender) this.requestEngineRender();
}
saveState() {
this.checkpoint = {
target: [...this.target],
radius: this.radius,
theta: this.theta,
phi: this.phi,
};
}
loadState() {
if (!this.checkpoint) return;
this.target = [...this.checkpoint.target];
this.radius = this.checkpoint.radius;
this.theta = this.checkpoint.theta;
this.phi = this.checkpoint.phi;
this.requestRender();
}
centerView() {
if (this.frame.blocks.size === 0) {
this.target = [0.5, 0.5, 0.5];
return;
}
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (const block of this.frame.blocks.values()) {
minX = Math.min(minX, block.pos[0]);
minY = Math.min(minY, block.pos[1]);
minZ = Math.min(minZ, block.pos[2]);
maxX = Math.max(maxX, block.pos[0] + 1);
maxY = Math.max(maxY, block.pos[1] + 1);
maxZ = Math.max(maxZ, block.pos[2] + 1);
}
this.target = [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2];
this.requestRender();
}
updateMatrices(aspectRatio) {
const t = this.theta * Math.PI / 180;
const p = this.phi * Math.PI / 180;
this.viewDir = [
Math.cos(p) * Math.sin(t),
Math.sin(p),
Math.cos(p) * Math.cos(t)
];
this.upDir = [
-Math.sin(p) * Math.sin(t),
Math.cos(p),
-Math.sin(p) * Math.cos(t),
];
this.eyePos = [
this.target[0] + this.radius * this.viewDir[0],
this.target[1] + this.radius * this.viewDir[1],
this.target[2] + this.radius * this.viewDir[2],
];
const size = this.radius * 0.5;
mat4Ortho(this.projMatrix, -size * aspectRatio, size * aspectRatio, -size, size, -50, 50);
mat4LookAt(
this.viewMatrix,
this.eyePos[0], this.eyePos[1], this.eyePos[2],
this.target[0], this.target[1], this.target[2],
this.upDir[0], this.upDir[1], this.upDir[2],
);
mat4Multiply(this.viewProjMatrix, this.projMatrix, this.viewMatrix);
return this.viewProjMatrix;
}
}

428
src/engine.js Normal file
View File

@@ -0,0 +1,428 @@
import {mat4Identity, mat4Translate} from './math.js';
import {Cache} from "./cache.js";
import {Blockstate, BlockstateHandler} from "./blockstate.js";
import {ModelHandler} from "./model.js";
import {TextureHandler} from "./texture.js";
const VS_SRC = `#version 300 es
layout(location=0) in vec3 a_position;
layout(location=1) in vec3 a_normal;
layout(location=2) in vec2 a_uv;
layout(location=3) in float a_tint;
layout(location=4) in float a_shade;
layout(location=5) in mat4 i_matrix;
layout(location=9) in vec3 i_color;
uniform mat4 u_viewProj;
uniform vec3 u_lightDir;
uniform vec3 u_viewDir;
uniform vec3 u_upDir;
out vec2 v_uv;
out float v_light;
out vec3 v_color;
void main() {
gl_Position = u_viewProj * i_matrix * vec4(a_position, 1.0);
v_uv = a_uv;
vec3 normal = normalize(mat3(i_matrix) * a_normal);
float sun = max(dot(normal, normalize(u_lightDir)), 0.0);
float head = max(dot(normal, normalize(u_viewDir)), 0.0);
float baseLight = clamp(sun + head * 0.3, 0.0, 1.0) * 0.6 + 0.4;
// Strict Schematic Detection: Both vectors must be perfectly axis-aligned
vec3 absV = abs(u_viewDir);
vec3 absU = abs(u_upDir);
bool isSchematic = (absV.x + absV.y + absV.z == 1.0) && (absU.x + absU.y + absU.z == 1.0);
if (isSchematic && head == 1.0) {
baseLight = 1.0;
}
v_light = mix(1.0, baseLight, a_shade);
v_color = mix(vec3(1.0), i_color, a_tint);
}
`;
const FS_SRC = `#version 300 es
precision highp float;
in vec2 v_uv;
in float v_light;
in vec3 v_color;
uniform sampler2D u_texture;
out vec4 fragColor;
void main() {
vec4 texColor = texture(u_texture, v_uv);
if (texColor.a < 0.5) discard;
fragColor = vec4(texColor.rgb * v_color * v_light, 1.0);
}`;
function compileShader(gl, type, src) {
const shader = gl.createShader(type);
gl.shaderSource(shader, src);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader));
return shader;
}
export class Engine {
constructor() {
this.canvas = document.createElement('canvas');
this.canvas.style.display = 'none';
document.body.appendChild(this.canvas);
this.gl = this.canvas.getContext('webgl2', {antialias: false, alpha: true});
const gl = this.gl;
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.CULL_FACE);
const vs = compileShader(gl, gl.VERTEX_SHADER, VS_SRC);
const fs = compileShader(gl, gl.FRAGMENT_SHADER, FS_SRC);
this.program = gl.createProgram();
gl.attachShader(this.program, vs);
gl.attachShader(this.program, fs);
gl.linkProgram(this.program);
this.uniforms = {
viewProj: gl.getUniformLocation(this.program, "u_viewProj"),
lightDir: gl.getUniformLocation(this.program, "u_lightDir"),
viewDir: gl.getUniformLocation(this.program, "u_viewDir"),
upDir: gl.getUniformLocation(this.program, "u_upDir"),
texture: gl.getUniformLocation(this.program, "u_texture")
};
this.atlasTexture = gl.createTexture();
this.cache = new Cache('assets', () => {
for (const frame of this.observedFrames) this.dirtyFrames.add(frame);
for (const diorama of this.dioramas) diorama.dirty = true;
this.requestUpdate();
});
this.atlas = new TextureHandler(256);
this.cache.register('blockstates', new BlockstateHandler());
this.cache.register('models', new ModelHandler());
this.cache.register('textures', this.atlas);
this.dioramas = [];
this.frameMeshes = new Map();
this.renderRequested = false;
this.updateRequested = false;
this.observedFrames = new Set();
this.dirtyFrames = new Set(); // Tracks specific timelines that need mesh updates
this.debug_redraw = false;
window.addEventListener('resize', () => this.requestRender());
window.addEventListener('scroll', () => this.requestRender());
}
requestUpdate() {
if (!this.updateRequested) {
this.updateRequested = true;
Promise.resolve().then(async () => {
this.updateAll();
this.updateRequested = false;
});
}
}
requestRender() {
if (!this.renderRequested) {
this.renderRequested = true;
requestAnimationFrame(() => {
this.renderRequested = false;
this.render();
});
}
}
addDiorama(diorama) {
this.dioramas.push(diorama);
diorama.dirty = true;
if (!this.observedFrames.has(diorama.frame)) {
this.observedFrames.add(diorama.frame);
this.dirtyFrames.add(diorama.frame);
this.preloadWorld(diorama.frame.world);
diorama.frame.subscribe(() => {
this.dirtyFrames.add(diorama.frame);
for (const d of this.dioramas) {
if (d.frame === diorama.frame) d.dirty = true;
}
this.requestUpdate();
});
} else {
this.requestRender();
}
}
async preloadWorld(world) {
const blocks = world.getUniqueBlockStates();
// 1. Await all Blockstates
const uniqueIds = new Set(blocks.map(b => b.id));
await Promise.all(Array.from(uniqueIds).map(id => this.cache.getAsync(id, 'blockstates')));
// 2. Resolve permutations and await all Models
const uniqueModels = new Set();
for (const block of blocks) {
const stateDef = this.cache.getSync(block.id, 'blockstates');
const parts = stateDef.resolveParts(block.state);
for (const p of parts) uniqueModels.add(p.model);
}
await Promise.all(Array.from(uniqueModels).map(id => this.cache.getAsync(id, 'models')));
// 3. Scan geometry and await all Textures
const textureTasks = [];
for (const modelId of uniqueModels) {
const blockModel = this.cache.getSync(modelId, 'models');
for (const el of blockModel.elements) {
for (const face of Object.values(el.faces || {})) {
const texPath = blockModel.resolveTexture(face.texture);
if (texPath && texPath !== ':missing') {
textureTasks.push(this.cache.getAsync(texPath, 'textures'));
}
}
}
}
await Promise.all(textureTasks);
}
updateAll() {
if (this.dirtyFrames.size === 0) return;
const framesToUpdate = Array.from(this.dirtyFrames);
this.dirtyFrames.clear();
const parsedFrames = new Map();
for (const frame of framesToUpdate) {
const parsedBlocks = [];
for (const block of frame.blocks.values()) {
// Sync grab (returns fallback instantly if loading)
const state = this.cache.getSync(block.id, 'blockstates');
const parts = state.resolveParts(block.state);
parsedBlocks.push({block, parts});
}
parsedFrames.set(frame, parsedBlocks);
}
const framePools = new Map();
for (const frame of framesToUpdate) {
const instancePool = new Map();
for (const {block, parts} of parsedFrames.get(frame)) {
for (const part of parts) {
const hash = Blockstate.getVariantHash(part);
if (!instancePool.has(hash)) {
instancePool.set(hash, {partDef: part, matrices: [], colors: []});
}
const matrix = mat4Identity(new Float32Array(16));
mat4Translate(matrix, matrix, block.pos);
const pool = instancePool.get(hash);
pool.matrices.push(...matrix);
if (block.state.power) {
const p = parseInt(block.state.power, 10);
pool.colors.push((0x4B + (p * 12)) / 255, 0.0, 0.0);
} else {
pool.colors.push(1.0, 1.0, 1.0);
}
}
}
for (const pool of instancePool.values()) {
const blockModel = this.cache.getSync(pool.partDef.model, 'models');
for (const el of blockModel.elements) {
for (const face of Object.values(el.faces || {})) {
const texPath = blockModel.resolveTexture(face.texture);
if (texPath) {
this.cache.getSync(texPath, 'textures');
}
}
}
}
framePools.set(frame, instancePool);
}
this.updateAtlasTexture();
for (const frame of framesToUpdate) {
const oldMeshes = this.frameMeshes.get(frame);
if (oldMeshes) {
for (const mesh of oldMeshes) {
this.gl.deleteVertexArray(mesh.vao);
for (const buf of mesh.buffers) this.gl.deleteBuffer(buf);
}
}
const newMeshes = [];
for (const pool of framePools.get(frame).values()) {
const blockModel = this.cache.getSync(pool.partDef.model, 'models');
const geometry = blockModel.buildGeometry(this.atlas.uvmap, pool.partDef);
const matrixArray = new Float32Array(pool.matrices);
const colorArray = new Float32Array(pool.colors);
const instanceCount = pool.matrices.length / 16;
newMeshes.push(this.createInstancedMesh(geometry, instanceCount, matrixArray, colorArray));
}
this.frameMeshes.set(frame, newMeshes);
}
for (const d of this.dioramas) {
if (framesToUpdate.includes(d.frame)) d.dirty = true;
}
this.requestRender();
}
updateAtlasTexture() {
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.atlas.canvas);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
}
createInstancedMesh(geometry, instanceCount, matrices, colors) {
const gl = this.gl;
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const buffers = []; // Track buffers for GC
const bindGeomAttr = (loc, data, size) => {
const buffer = gl.createBuffer();
buffers.push(buffer);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0);
};
bindGeomAttr(0, geometry.positions, 3);
bindGeomAttr(1, geometry.normals, 3);
bindGeomAttr(2, geometry.uvs, 2);
bindGeomAttr(3, geometry.tints, 1);
bindGeomAttr(4, geometry.shades, 1);
const ebo = gl.createBuffer();
buffers.push(ebo);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ebo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW);
const matrixBuffer = gl.createBuffer();
buffers.push(matrixBuffer);
gl.bindBuffer(gl.ARRAY_BUFFER, matrixBuffer);
gl.bufferData(gl.ARRAY_BUFFER, matrices, gl.STATIC_DRAW);
for (let i = 0; i < 4; i++) {
const loc = 5 + i;
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 4, gl.FLOAT, false, 64, i * 16);
gl.vertexAttribDivisor(loc, 1);
}
const colorBuffer = gl.createBuffer();
buffers.push(colorBuffer);
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);
gl.enableVertexAttribArray(9);
gl.vertexAttribPointer(9, 3, gl.FLOAT, false, 0, 0);
gl.vertexAttribDivisor(9, 1);
gl.bindVertexArray(null);
return {vao, buffers, indexCount: geometry.indices.length, instanceCount};
}
render() {
const gl = this.gl;
let setupProgram = false;
for (const diorama of this.dioramas) {
const rect = diorama.canvas.getBoundingClientRect();
// Visibility Culling
if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth) {
continue;
}
const dpr = window.devicePixelRatio || 1;
const targetW = Math.floor(rect.width * dpr);
const targetH = Math.floor(rect.height * dpr);
if (diorama.canvas.width !== targetW || diorama.canvas.height !== targetH) {
diorama.canvas.width = targetW;
diorama.canvas.height = targetH;
diorama.dirty = true;
}
// Lazy Render: Only draw if the diorama actually changed
if (!diorama.dirty) continue;
const draw = () => {
if (!setupProgram) {
gl.useProgram(this.program);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture);
gl.uniform1i(this.uniforms.texture, 0);
setupProgram = true;
}
if (this.canvas.width < targetW || this.canvas.height < targetH) {
this.canvas.width = Math.max(this.canvas.width, targetW);
this.canvas.height = Math.max(this.canvas.height, targetH);
}
const viewportY = this.canvas.height - targetH;
gl.viewport(0, viewportY, targetW, targetH);
gl.enable(gl.SCISSOR_TEST);
gl.scissor(0, viewportY, targetW, targetH);
gl.clearColor(0.0, 0.0, 0.0, 0.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.disable(gl.SCISSOR_TEST);
const aspect = rect.width / rect.height;
const viewProj = diorama.updateMatrices(aspect);
gl.uniformMatrix4fv(this.uniforms.viewProj, false, viewProj);
gl.uniform3f(this.uniforms.lightDir, 1.0, 3.0, 2.0);
gl.uniform3f(this.uniforms.viewDir, diorama.viewDir[0], diorama.viewDir[1], diorama.viewDir[2]);
gl.uniform3f(this.uniforms.upDir, diorama.upDir[0], diorama.upDir[1], diorama.upDir[2]);
const meshes = this.frameMeshes.get(diorama.frame) || [];
for (const mesh of meshes) {
gl.bindVertexArray(mesh.vao);
gl.drawElementsInstanced(gl.TRIANGLES, mesh.indexCount, gl.UNSIGNED_SHORT, 0, mesh.instanceCount);
}
diorama.ctx2d.clearRect(0, 0, targetW, targetH);
diorama.ctx2d.drawImage(
this.canvas,
0, 0, targetW, targetH,
0, 0, targetW, targetH
);
// Clean state!
diorama.dirty = false;
};
if (this.debug_redraw) {
diorama.ctx2d.fillStyle = '#FF00FF33';
diorama.ctx2d.fillRect(0, 0, targetW, targetH);
requestAnimationFrame(draw);
} else {
draw();
}
}
}
}

8
src/ico-auto-events.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.-->
<path
d="M500.7 138.7L512 149.4L512 96C512 78.3 526.3 64 544 64C561.7 64 576 78.3 576 96L576 224C576 241.7 561.7 256 544 256L416 256C398.3 256 384 241.7 384 224C384 206.3 398.3 192 416 192L463.9 192L456.3 184.8C456.1 184.6 455.9 184.4 455.7 184.2C380.7 109.2 259.2 109.2 184.2 184.2C109.2 259.2 109.2 380.7 184.2 455.7C259.2 530.7 380.7 530.7 455.7 455.7C463.9 447.5 471.2 438.8 477.6 429.6C487.7 415.1 507.7 411.6 522.2 421.7C536.7 431.8 540.2 451.8 530.1 466.3C521.6 478.5 511.9 490.1 501 501C401 601 238.9 601 139 501C39.1 401 39 239 139 139C238.9 39.1 400.7 39 500.7 138.7z"/>
<path
transform="scale(0.5) translate(320, 320)"
d="M259.1 73.5C262.1 58.7 275.2 48 290.4 48L350.2 48C365.4 48 378.5 58.7 381.5 73.5L396 143.5C410.1 149.5 423.3 157.2 435.3 166.3L503.1 143.8C517.5 139 533.3 145 540.9 158.2L570.8 210C578.4 223.2 575.7 239.8 564.3 249.9L511 297.3C511.9 304.7 512.3 312.3 512.3 320C512.3 327.7 511.8 335.3 511 342.7L564.4 390.2C575.8 400.3 578.4 417 570.9 430.1L541 481.9C533.4 495 517.6 501.1 503.2 496.3L435.4 473.8C423.3 482.9 410.1 490.5 396.1 496.6L381.7 566.5C378.6 581.4 365.5 592 350.4 592L290.6 592C275.4 592 262.3 581.3 259.3 566.5L244.9 496.6C230.8 490.6 217.7 482.9 205.6 473.8L137.5 496.3C123.1 501.1 107.3 495.1 99.7 481.9L69.8 430.1C62.2 416.9 64.9 400.3 76.3 390.2L129.7 342.7C128.8 335.3 128.4 327.7 128.4 320C128.4 312.3 128.9 304.7 129.7 297.3L76.3 249.8C64.9 239.7 62.3 223 69.8 209.9L99.7 158.1C107.3 144.9 123.1 138.9 137.5 143.7L205.3 166.2C217.4 157.1 230.6 149.5 244.6 143.4L259.1 73.5zM320.3 400C364.5 399.8 400.2 363.9 400 319.7C399.8 275.5 363.9 239.8 319.7 240C275.5 240.2 239.8 276.1 240 320.3C240.2 364.5 276.1 400.2 320.3 400z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

12
src/ico-next-event.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.-->
<rect x="0" y="0" width="640" height="640" fill="none" stroke="red"></rect>
<path transform="translate(120, 0)"
d="M441.3 299.8C451.5 312.4 450.8 330.9 439.1 342.6L311.1 470.6C301.9 479.8 288.2 482.5 276.2 477.5C264.2 472.5 256.5 460.9 256.5 448L256.5 192C256.5 179.1 264.3 167.4 276.3 162.4C288.3 157.4 302 160.2 311.2 169.3L439.2 297.3L441.4 299.7z"/>
<path
transform="scale(0.5) translate(120, 320)"
d="M259.1 73.5C262.1 58.7 275.2 48 290.4 48L350.2 48C365.4 48 378.5 58.7 381.5 73.5L396 143.5C410.1 149.5 423.3 157.2 435.3 166.3L503.1 143.8C517.5 139 533.3 145 540.9 158.2L570.8 210C578.4 223.2 575.7 239.8 564.3 249.9L511 297.3C511.9 304.7 512.3 312.3 512.3 320C512.3 327.7 511.8 335.3 511 342.7L564.4 390.2C575.8 400.3 578.4 417 570.9 430.1L541 481.9C533.4 495 517.6 501.1 503.2 496.3L435.4 473.8C423.3 482.9 410.1 490.5 396.1 496.6L381.7 566.5C378.6 581.4 365.5 592 350.4 592L290.6 592C275.4 592 262.3 581.3 259.3 566.5L244.9 496.6C230.8 490.6 217.7 482.9 205.6 473.8L137.5 496.3C123.1 501.1 107.3 495.1 99.7 481.9L69.8 430.1C62.2 416.9 64.9 400.3 76.3 390.2L129.7 342.7C128.8 335.3 128.4 327.7 128.4 320C128.4 312.3 128.9 304.7 129.7 297.3L76.3 249.8C64.9 239.7 62.3 223 69.8 209.9L99.7 158.1C107.3 144.9 123.1 138.9 137.5 143.7L205.3 166.2C217.4 157.1 230.6 149.5 244.6 143.4L259.1 73.5zM320.3 400C364.5 399.8 400.2 363.9 400 319.7C399.8 275.5 363.9 239.8 319.7 240C275.5 240.2 239.8 276.1 240 320.3C240.2 364.5 276.1 400.2 320.3 400z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

1
src/ico-next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M441.3 299.8C451.5 312.4 450.8 330.9 439.1 342.6L311.1 470.6C301.9 479.8 288.2 482.5 276.2 477.5C264.2 472.5 256.5 460.9 256.5 448L256.5 192C256.5 179.1 264.3 167.4 276.3 162.4C288.3 157.4 302 160.2 311.2 169.3L439.2 297.3L441.4 299.7z"/></svg>

After

Width:  |  Height:  |  Size: 467 B

1
src/ico-pan.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M342.6 73.4C330.1 60.9 309.8 60.9 297.3 73.4L233.3 137.4C220.8 149.9 220.8 170.2 233.3 182.7C245.8 195.2 266.1 195.2 278.6 182.7L288 173.3L288 288L173.3 288L182.7 278.6C195.2 266.1 195.2 245.8 182.7 233.3C170.2 220.8 149.9 220.8 137.4 233.3L73.4 297.3C60.9 309.8 60.9 330.1 73.4 342.6L137.4 406.6C149.9 419.1 170.2 419.1 182.7 406.6C195.2 394.1 195.2 373.8 182.7 361.3L173.3 351.9L288 351.9L288 466.6L278.6 457.2C266.1 444.7 245.8 444.7 233.3 457.2C220.8 469.7 220.8 490 233.3 502.5L297.3 566.5C309.8 579 330.1 579 342.6 566.5L406.6 502.5C419.1 490 419.1 469.7 406.6 457.2C394.1 444.7 373.8 444.7 361.3 457.2L351.9 466.6L351.9 351.9L466.6 351.9L457.2 361.3C444.7 373.8 444.7 394.1 457.2 406.6C469.7 419.1 490 419.1 502.5 406.6L566.5 342.6C579 330.1 579 309.8 566.5 297.3L502.5 233.3C490 220.8 469.7 220.8 457.2 233.3C444.7 245.8 444.7 266.1 457.2 278.6L466.6 288L351.9 288L351.9 173.3L361.3 182.7C373.8 195.2 394.1 195.2 406.6 182.7C419.1 170.2 419.1 149.9 406.6 137.4L342.6 73.4z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

1
src/ico-pause.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M176 96C149.5 96 128 117.5 128 144L128 496C128 522.5 149.5 544 176 544L240 544C266.5 544 288 522.5 288 496L288 144C288 117.5 266.5 96 240 96L176 96zM400 96C373.5 96 352 117.5 352 144L352 496C352 522.5 373.5 544 400 544L464 544C490.5 544 512 522.5 512 496L512 144C512 117.5 490.5 96 464 96L400 96z"/></svg>

After

Width:  |  Height:  |  Size: 528 B

5
src/ico-play.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.-->
<path
d="M187.2 100.9C174.8 94.1 159.8 94.4 147.6 101.6C135.4 108.8 128 121.9 128 136L128 504C128 518.1 135.5 531.2 147.6 538.4C159.7 545.6 174.8 545.9 187.2 539.1L523.2 355.1C536 348.1 544 334.6 544 320C544 305.4 536 291.9 523.2 284.9L187.2 100.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 486 B

17
src/ico-prev-event.svg Normal file
View File

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.-->
<rect x="0" y="0" width="640" height="640" fill="none" stroke="red"></rect>
<path
transform="translate(-120, 0)"
d="M199.7 299.8C189.4 312.4 190.2 330.9 201.9 342.6L329.9 470.6C339.1 479.8 352.8 482.5 364.8 477.5C376.8 472.5 384.6 460.9 384.6 447.9L384.6 191.9C384.6 179 376.8 167.3 364.8 162.3C352.8 157.3 339.1 160.1 329.9 169.2L201.9 297.2L199.7 299.6z"/>
<!-- <path transform="translate(120, 0)"-->
<!-- d="M441.3 299.8C451.5 312.4 450.8 330.9 439.1 342.6L311.1 470.6C301.9 479.8 288.2 482.5 276.2 477.5C264.2 472.5 256.5 460.9 256.5 448L256.5 192C256.5 179.1 264.3 167.4 276.3 162.4C288.3 157.4 302 160.2 311.2 169.3L439.2 297.3L441.4 299.7z"/>-->
<path
transform="scale(0.5) translate(520, 320)"
d="M259.1 73.5C262.1 58.7 275.2 48 290.4 48L350.2 48C365.4 48 378.5 58.7 381.5 73.5L396 143.5C410.1 149.5 423.3 157.2 435.3 166.3L503.1 143.8C517.5 139 533.3 145 540.9 158.2L570.8 210C578.4 223.2 575.7 239.8 564.3 249.9L511 297.3C511.9 304.7 512.3 312.3 512.3 320C512.3 327.7 511.8 335.3 511 342.7L564.4 390.2C575.8 400.3 578.4 417 570.9 430.1L541 481.9C533.4 495 517.6 501.1 503.2 496.3L435.4 473.8C423.3 482.9 410.1 490.5 396.1 496.6L381.7 566.5C378.6 581.4 365.5 592 350.4 592L290.6 592C275.4 592 262.3 581.3 259.3 566.5L244.9 496.6C230.8 490.6 217.7 482.9 205.6 473.8L137.5 496.3C123.1 501.1 107.3 495.1 99.7 481.9L69.8 430.1C62.2 416.9 64.9 400.3 76.3 390.2L129.7 342.7C128.8 335.3 128.4 327.7 128.4 320C128.4 312.3 128.9 304.7 129.7 297.3L76.3 249.8C64.9 239.7 62.3 223 69.8 209.9L99.7 158.1C107.3 144.9 123.1 138.9 137.5 143.7L205.3 166.2C217.4 157.1 230.6 149.5 244.6 143.4L259.1 73.5zM320.3 400C364.5 399.8 400.2 363.9 400 319.7C399.8 275.5 363.9 239.8 319.7 240C275.5 240.2 239.8 276.1 240 320.3C240.2 364.5 276.1 400.2 320.3 400z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

1
src/ico-prev.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M199.7 299.8C189.4 312.4 190.2 330.9 201.9 342.6L329.9 470.6C339.1 479.8 352.8 482.5 364.8 477.5C376.8 472.5 384.6 460.9 384.6 447.9L384.6 191.9C384.6 179 376.8 167.3 364.8 162.3C352.8 157.3 339.1 160.1 329.9 169.2L201.9 297.2L199.7 299.6z"/></svg>

After

Width:  |  Height:  |  Size: 471 B

1
src/ico-reset.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M341.8 72.6C329.5 61.2 310.5 61.2 298.3 72.6L74.3 280.6C64.7 289.6 61.5 303.5 66.3 315.7C71.1 327.9 82.8 336 96 336L112 336L112 512C112 547.3 140.7 576 176 576L464 576C499.3 576 528 547.3 528 512L528 336L544 336C557.2 336 569 327.9 573.8 315.7C578.6 303.5 575.4 289.5 565.8 280.6L341.8 72.6zM304 384L336 384C362.5 384 384 405.5 384 432L384 528L256 528L256 432C256 405.5 277.5 384 304 384z"/></svg>

After

Width:  |  Height:  |  Size: 620 B

1
src/ico-rotate.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M129.9 292.5C143.2 199.5 223.3 128 320 128C373 128 421 149.5 455.8 184.2C456 184.4 456.2 184.6 456.4 184.8L464 192L416.1 192C398.4 192 384.1 206.3 384.1 224C384.1 241.7 398.4 256 416.1 256L544.1 256C561.8 256 576.1 241.7 576.1 224L576.1 96C576.1 78.3 561.8 64 544.1 64C526.4 64 512.1 78.3 512.1 96L512.1 149.4L500.8 138.7C454.5 92.6 390.5 64 320 64C191 64 84.3 159.4 66.6 283.5C64.1 301 76.2 317.2 93.7 319.7C111.2 322.2 127.4 310 129.9 292.6zM573.4 356.5C575.9 339 563.7 322.8 546.3 320.3C528.9 317.8 512.6 330 510.1 347.4C496.8 440.4 416.7 511.9 320 511.9C267 511.9 219 490.4 184.2 455.7C184 455.5 183.8 455.3 183.6 455.1L176 447.9L223.9 447.9C241.6 447.9 255.9 433.6 255.9 415.9C255.9 398.2 241.6 383.9 223.9 383.9L96 384C87.5 384 79.3 387.4 73.3 393.5C67.3 399.6 63.9 407.7 64 416.3L65 543.3C65.1 561 79.6 575.2 97.3 575C115 574.8 129.2 560.4 129 542.7L128.6 491.2L139.3 501.3C185.6 547.4 249.5 576 320 576C449 576 555.7 480.6 573.4 356.5z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

1
src/ico-zoom.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M480 272C480 317.9 465.1 360.3 440 394.7L566.6 521.4C579.1 533.9 579.1 554.2 566.6 566.7C554.1 579.2 533.8 579.2 521.3 566.7L394.7 440C360.3 465.1 317.9 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272zM272 416C351.5 416 416 351.5 416 272C416 192.5 351.5 128 272 128C192.5 128 128 192.5 128 272C128 351.5 192.5 416 272 416z"/></svg>

After

Width:  |  Height:  |  Size: 596 B

161
src/math.js Normal file
View File

@@ -0,0 +1,161 @@
export function mat4Identity() {
return new Float32Array([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]);
}
export function mat4Multiply(out, a, b) {
let a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3];
let a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7];
let a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11];
let a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15];
let b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3];
out[0] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
out[1] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
out[2] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
out[3] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
b0 = b[4];
b1 = b[5];
b2 = b[6];
b3 = b[7];
out[4] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
out[5] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
out[6] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
out[7] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
b0 = b[8];
b1 = b[9];
b2 = b[10];
b3 = b[11];
out[8] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
out[9] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
out[10] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
out[11] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
b0 = b[12];
b1 = b[13];
b2 = b[14];
b3 = b[15];
out[12] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
out[13] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
out[14] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
out[15] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
// TODO We only use this in-place for this prototype. Update the signature to enforce this
return out;
}
export function mat4Ortho(out, left, right, bottom, top, near, far) {
let lr = 1 / (left - right);
let bt = 1 / (bottom - top);
let nf = 1 / (near - far);
out.fill(0);
out[0] = -2 * lr;
out[5] = -2 * bt;
out[10] = 2 * nf;
out[12] = (left + right) * lr;
out[13] = (top + bottom) * bt;
out[14] = (far + near) * nf;
out[15] = 1;
return out;
}
export function mat4LookAt(out, eyeX, eyeY, eyeZ, centerX, centerY, centerZ, upX, upY, upZ) {
let z0 = eyeX - centerX, z1 = eyeY - centerY, z2 = eyeZ - centerZ;
let len = 1 / Math.hypot(z0, z1, z2);
z0 *= len;
z1 *= len;
z2 *= len;
let x0 = upY * z2 - upZ * z1, x1 = upZ * z0 - upX * z2, x2 = upX * z1 - upY * z0;
len = 1 / Math.hypot(x0, x1, x2);
x0 *= len;
x1 *= len;
x2 *= len;
let y0 = z1 * x2 - z2 * x1, y1 = z2 * x0 - z0 * x2, y2 = z0 * x1 - z1 * x0;
out[0] = x0;
out[1] = y0;
out[2] = z0;
out[3] = 0;
out[4] = x1;
out[5] = y1;
out[6] = z1;
out[7] = 0;
out[8] = x2;
out[9] = y2;
out[10] = z2;
out[11] = 0;
out[12] = -(x0 * eyeX + x1 * eyeY + x2 * eyeZ);
out[13] = -(y0 * eyeX + y1 * eyeY + y2 * eyeZ);
out[14] = -(z0 * eyeX + z1 * eyeY + z2 * eyeZ);
out[15] = 1;
return out;
}
export function mat4Translate(out, a, v) {
let x = v[0], y = v[1], z = v[2];
if (a === out) {
out[12] = a[0] * x + a[4] * y + a[8] * z + a[12];
out[13] = a[1] * x + a[5] * y + a[9] * z + a[13];
out[14] = a[2] * x + a[6] * y + a[10] * z + a[14];
out[15] = a[3] * x + a[7] * y + a[11] * z + a[15];
} else {
// TODO We only use this in-place for this prototype. Update the signature to enforce this
}
return out;
}
export function mat4RotateX(m, rad) {
let s = Math.sin(rad), c = Math.cos(rad);
let m10 = m[4], m11 = m[5], m12 = m[6], m13 = m[7];
let m20 = m[8], m21 = m[9], m22 = m[10], m23 = m[11];
m[4] = m10 * c + m20 * s;
m[5] = m11 * c + m21 * s;
m[6] = m12 * c + m22 * s;
m[7] = m13 * c + m23 * s;
m[8] = m20 * c - m10 * s;
m[9] = m21 * c - m11 * s;
m[10] = m22 * c - m12 * s;
m[11] = m23 * c - m13 * s;
return m;
}
export function mat4RotateY(m, rad) {
let s = Math.sin(rad), c = Math.cos(rad);
let m00 = m[0], m01 = m[1], m02 = m[2], m03 = m[3];
let m20 = m[8], m21 = m[9], m22 = m[10], m23 = m[11];
m[0] = m00 * c - m20 * s;
m[1] = m01 * c - m21 * s;
m[2] = m02 * c - m22 * s;
m[3] = m03 * c - m23 * s;
m[8] = m00 * s + m20 * c;
m[9] = m01 * s + m21 * c;
m[10] = m02 * s + m22 * c;
m[11] = m03 * s + m23 * c;
return m;
}
export function mat4RotateZ(m, rad) {
let s = Math.sin(rad), c = Math.cos(rad);
let m00 = m[0], m01 = m[1], m02 = m[2], m03 = m[3];
let m10 = m[4], m11 = m[5], m12 = m[6], m13 = m[7];
m[0] = m00 * c + m10 * s;
m[1] = m01 * c + m11 * s;
m[2] = m02 * c + m12 * s;
m[3] = m03 * c + m13 * s;
m[4] = m10 * c - m00 * s;
m[5] = m11 * c - m01 * s;
m[6] = m12 * c - m02 * s;
m[7] = m13 * c - m03 * s;
return m;
}

224
src/model.js Normal file
View File

@@ -0,0 +1,224 @@
import {mat4Identity, mat4RotateX, mat4RotateY, mat4RotateZ, mat4Translate} from "./math.js";
const MISSING_MODEL_JSON = {
"textures": {"missing": ":missing"},
"elements": [
{
"from": [0, 0, 0],
"to": [16, 16, 16],
"faces": {
"down": {"texture": "#missing"},
"up": {"texture": "#missing"},
"north": {"texture": "#missing"},
"south": {"texture": "#missing"},
"west": {"texture": "#missing"},
"east": {"texture": "#missing"}
}
}
]
};
export class ModelHandler {
async process(cache, id, url) {
if (id === ':missing') return new BlockModel(MISSING_MODEL_JSON);
try {
const res = await fetch(url + '.json');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (json.parent) {
const parent = await cache.getAsync(json.parent, 'models');
json.textures = {...parent.textures, ...json.textures};
if (!json.elements && parent.elements) {
json.elements = parent.elements;
}
}
return new BlockModel(json);
} catch (e) {
console.warn(`Model missing/broken (${id}), falling back to :missing`);
return new BlockModel(MISSING_MODEL_JSON);
}
}
getFallback(id) {
return new BlockModel(MISSING_MODEL_JSON);
}
}
const CUBOID_FACES = {
down: {n: [0, -1, 0], c: [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]]},
up: {n: [0, 1, 0], c: [[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]]},
north: {n: [0, 0, -1], c: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]]},
south: {n: [0, 0, 1], c: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]]},
west: {n: [-1, 0, 0], c: [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]]},
east: {n: [1, 0, 0], c: [[1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]]}
};
export class BlockModel {
constructor(json) {
this.elements = json.elements || [];
this.textures = json.textures || {};
this._geometryCache = new Map();
}
resolveTexture(ref) {
if (!ref) return ':missing';
let val = ref;
if (ref[0] === '#') {
let key = ref.slice(1);
val = this.textures[key];
while (typeof val === 'string' && val[0] === '#') {
key = val.slice(1);
val = this.textures[key];
}
if (val && typeof val === 'object' && val.sprite) val = val.sprite;
}
if (typeof val === 'string') {
return val.includes(':') ? val : `minecraft:${val}`;
}
return ':missing';
}
calculateDefaultUV(faceName, from, to) {
switch (faceName) {
case 'up':
return [from[0], from[2], to[0], to[2]];
case 'down':
return [from[0], 16 - to[2], to[0], 16 - from[2]];
case 'north':
return [16 - to[0], 16 - to[1], 16 - from[0], 16 - from[1]];
case 'south':
return [from[0], 16 - to[1], to[0], 16 - from[1]];
case 'west':
return [from[2], 16 - to[1], to[2], 16 - from[1]];
case 'east':
return [16 - to[2], 16 - to[1], 16 - from[2], 16 - from[1]];
default:
return [0, 0, 16, 16];
}
}
buildGeometry(uvmap, variant = {}) {
const cacheKey = `${variant.x || 0},${variant.y || 0},${!!variant.uvlock}`;
if (this._geometryCache.has(cacheKey)) {
return this._geometryCache.get(cacheKey);
}
const pos = [], norm = [], uv = [], tint = [], shade = [], idx = [];
let vOffset = 0;
// --- GLOBAL BLOCKSTATE MATRIX ---
const blockMatrix = mat4Identity(new Float32Array(16));
mat4Translate(blockMatrix, blockMatrix, [0.5, 0.5, 0.5]);
if (variant.y) mat4RotateY(blockMatrix, -variant.y * Math.PI / 180);
if (variant.x) mat4RotateX(blockMatrix, -variant.x * Math.PI / 180);
mat4Translate(blockMatrix, blockMatrix, [-0.5, -0.5, -0.5]);
for (const el of this.elements) {
const [fx, fy, fz] = el.from.map(x => x / 16);
const [tx, ty, tz] = el.to.map(x => x / 16);
const size = [tx - fx, ty - fy, tz - fz];
const elementShade = el.shade === false ? 0.0 : 1.0;
// --- LOCAL ELEMENT MATRIX ---
const elMatrix = mat4Identity(new Float32Array(16));
if (el.rotation) {
const [ox, oy, oz] = el.rotation.origin.map(x => x / 16);
const angle = el.rotation.angle * Math.PI / 180;
mat4Translate(elMatrix, elMatrix, [ox, oy, oz]);
if (el.rotation.axis === 'x') mat4RotateX(elMatrix, angle);
else if (el.rotation.axis === 'y') mat4RotateY(elMatrix, angle);
else if (el.rotation.axis === 'z') mat4RotateZ(elMatrix, angle);
mat4Translate(elMatrix, elMatrix, [-ox, -oy, -oz]);
// Note: Minecraft specifies a "rescale" boolean on some rotations to fit faces to block bounds.
// It is rarely used (the torch wall JSON doesn't use it), so it is omitted here to save math overhead.
}
for (const [name, face] of Object.entries(el.faces || {})) {
const tmpl = CUBOID_FACES[name];
const texPath = this.resolveTexture(face.texture);
const a = texPath ? uvmap.get(texPath) : null;
if (!a) continue;
const rawUV = face.uv || this.calculateDefaultUV(name, el.from, el.to);
let u1 = rawUV[0] / 16, v1 = rawUV[1] / 16;
let u2 = rawUV[2] / 16, v2 = rawUV[3] / 16;
let texRot = face.rotation || 0;
if (variant.uvlock && (name === 'up' || name === 'down') && variant.y) {
texRot = (texRot - variant.y + 360) % 360;
}
const au1 = a.u + u1 * a.du, au2 = a.u + u2 * a.du;
const av1 = a.v + v1 * a.dv, av2 = a.v + v2 * a.dv;
let faceUVs;
if (texRot === 90) faceUVs = [au2, av2, au2, av1, au1, av1, au1, av2];
else if (texRot === 180) faceUVs = [au2, av1, au1, av1, au1, av2, au2, av2];
else if (texRot === 270) faceUVs = [au1, av1, au1, av2, au2, av2, au2, av1];
else faceUVs = [au1, av2, au2, av2, au2, av1, au1, av1];
const tintable = face.tintindex !== undefined ? 1.0 : 0.0;
for (let i = 0; i < 4; i++) {
const [cx, cy, cz] = tmpl.c[i];
// 1. Raw local position
let vx = fx + cx * size[0], vy = fy + cy * size[1], vz = fz + cz * size[2];
// 2. Apply element rotation
let evx = elMatrix[0] * vx + elMatrix[4] * vy + elMatrix[8] * vz + elMatrix[12];
let evy = elMatrix[1] * vx + elMatrix[5] * vy + elMatrix[9] * vz + elMatrix[13];
let evz = elMatrix[2] * vx + elMatrix[6] * vy + elMatrix[10] * vz + elMatrix[14];
// 3. Apply global blockstate rotation
let wx = blockMatrix[0] * evx + blockMatrix[4] * evy + blockMatrix[8] * evz + blockMatrix[12];
let wy = blockMatrix[1] * evx + blockMatrix[5] * evy + blockMatrix[9] * evz + blockMatrix[13];
let wz = blockMatrix[2] * evx + blockMatrix[6] * evy + blockMatrix[10] * evz + blockMatrix[14];
pos.push(wx, wy, wz);
// --- Normals (Apply Rotations Only, No Translation) ---
// 1. Apply element rotation to normal
let enx = elMatrix[0] * tmpl.n[0] + elMatrix[4] * tmpl.n[1] + elMatrix[8] * tmpl.n[2];
let eny = elMatrix[1] * tmpl.n[0] + elMatrix[5] * tmpl.n[1] + elMatrix[9] * tmpl.n[2];
let enz = elMatrix[2] * tmpl.n[0] + elMatrix[6] * tmpl.n[1] + elMatrix[10] * tmpl.n[2];
// 2. Apply global blockstate rotation to normal
let nx = blockMatrix[0] * enx + blockMatrix[4] * eny + blockMatrix[8] * enz;
let ny = blockMatrix[1] * enx + blockMatrix[5] * eny + blockMatrix[9] * enz;
let nz = blockMatrix[2] * enx + blockMatrix[6] * eny + blockMatrix[10] * enz;
norm.push(nx, ny, nz);
tint.push(tintable);
shade.push(elementShade);
}
uv.push(...faceUVs);
idx.push(vOffset, vOffset + 1, vOffset + 2, vOffset, vOffset + 2, vOffset + 3);
vOffset += 4;
}
}
const bakedGeometry = {
positions: new Float32Array(pos),
normals: new Float32Array(norm),
uvs: new Float32Array(uv),
tints: new Float32Array(tint),
shades: new Float32Array(shade),
indices: new Uint16Array(idx)
};
this._geometryCache.set(cacheKey, bakedGeometry);
return bakedGeometry;
}
}

BIN
src/mojangles-ascii.woff2 Normal file

Binary file not shown.

88
src/style.css Normal file
View File

@@ -0,0 +1,88 @@
@font-face {
font-family: "Mojangles";
font-style: normal;
font-weight: 400;
src: url("/src/mojangles-ascii.woff2");
unicode-range: U+0000-007F;
}
h1, h2, h3, h4, h5, h6, nav li {
font-family: "Mojangles", sans-serif;
}
body {
max-width: 70ch;
margin-inline: auto;
font: 1rem / 1.5 sans-serif;
}
.tip {
color: forestgreen;
}
.warn {
color: brown;
}
.note {
color: mediumblue;
}
.todo {
color: red;
}
@media (prefers-color-scheme: dark) {
.tip {
color: springgreen;
}
.warn {
color: coral;
}
.note {
color: cyan;
}
}
pre code {
text-wrap: wrap;
}
.callout, details {
background: blue;
margin: 1em -1em;
padding: 0.5em 1em;
background: rgb(from currentColor r g b / 5%);
border: solid currentColor;
border-width: 1px 0;
.callout {
margin-inline: 0;
}
}
.callout {
h1, h2, h3, h4, h5, h6 {
font-size: 1em;
vertical-align: baseline;
margin-inline-end: 1ch;
&, & + p {
display: inline;
}
}
*:last-child {
margin-block-end: 0;
padding-block-end: 0;
}
}
summary::marker {
content: "Click to show ";
padding-left: 0;
}

86
src/texture.js Normal file
View File

@@ -0,0 +1,86 @@
export class TextureHandler {
constructor(size) {
this.canvas = document.createElement('canvas');
this.canvas.width = size;
this.canvas.height = size;
this.ctx = this.canvas.getContext('2d', {willReadFrequently: true});
this.ctx.imageSmoothingEnabled = false;
this.uvmap = new Map();
this.pendingDraws = new Map();
this.cellSize = 16;
this.padding = 1;
this.x = 0;
this.y = 0;
this.rowHeight = 0;
}
prepare(cache, id, url) {
if (this.x + this.cellSize > this.canvas.width) {
this.x = 0;
this.y += this.rowHeight + this.padding;
this.rowHeight = 0;
}
const currentX = this.x;
const currentY = this.y;
this.x += this.cellSize + this.padding;
this.rowHeight = Math.max(this.rowHeight, this.cellSize);
const uvData = {
u: currentX / this.canvas.width,
v: currentY / this.canvas.height,
du: this.cellSize / this.canvas.width,
dv: this.cellSize / this.canvas.height,
};
this.uvmap.set(id, uvData);
this.pendingDraws.set(id, {x: currentX, y: currentY});
this.ctx.fillStyle = '#00000066';
this.ctx.fillRect(currentX, currentY, this.cellSize, this.cellSize);
}
getFallback(id) {
return this.uvmap.get(id); // UVs map to the black silhouette!
}
async process(cache, id, url) {
const pos = this.pendingDraws.get(id);
if (!pos) throw new Error(`Not prepared for ${id}.`);
this.pendingDraws.delete(id);
const drawMissing = () => {
const half = this.cellSize / 2;
this.ctx.fillStyle = '#33333366'; // Gray
this.ctx.fillRect(pos.x, pos.y, half, half);
this.ctx.fillRect(pos.x + half, pos.y + half, half, half);
this.ctx.fillStyle = '#00000066'; // Black
this.ctx.fillRect(pos.x + half, pos.y, half, half);
this.ctx.fillRect(pos.x, pos.y + half, half, half);
};
drawMissing();
if (id !== ':missing') {
try {
const img = await new Promise((resolve, reject) => {
const i = new Image();
i.crossOrigin = 'anonymous';
i.onload = () => resolve(i);
i.onerror = () => reject(new Error(`Image load failed`));
i.src = url + '.png';
});
// await new Promise(r => setTimeout(r, 2000))
this.ctx.clearRect(pos.x, pos.y, this.cellSize, this.cellSize);
this.ctx.drawImage(img, pos.x, pos.y, this.cellSize, this.cellSize);
} catch (e) {
console.warn(`Missing texture: ${id}`);
}
}
return this.uvmap.get(id);
}
}

278
src/world.js Normal file
View File

@@ -0,0 +1,278 @@
// world.js
export class Block {
constructor(id, pos, state) {
this.id = id;
this.pos = pos;
this.state = state;
}
}
export class World {
constructor(script = '') {
// Pure Data State
this.events = [];
this.labels = new Map();
this.initialState = new Map();
this.initialIndex = 0;
if (script) {
this.#compile(script);
}
}
#compile(script) {
let currentTime = -1;
let capturedInitialState = false;
const shadowWorld = new Map();
const captureInitial = () => {
if (capturedInitialState) return;
for (const [k, v] of shadowWorld.entries()) {
this.initialState.set(k, {id: v.id, pos: [...v.pos], state: {...v.state}});
}
this.initialIndex = this.events.length;
capturedInitialState = true;
};
const lines = script.split(/\n|<br>/).map(l => l.trim()).filter(l => l && !l.startsWith('#'));
console.log(lines)
let currentBatch = [];
for (let line of lines) {
let mergeNext = false;
if (line.endsWith('&')) {
mergeNext = true;
line = line.slice(0, -1).trim();
}
const tokens = line.split(/\s+/);
const cmd = tokens[0];
if (cmd === 't') {
const newTime = parseFloat(tokens[1]);
if (newTime >= 0 && !capturedInitialState) captureInitial();
if (currentBatch.length > 0) {
this.events.push({time: currentTime, actions: currentBatch});
currentBatch = [];
}
currentTime = newTime;
continue;
}
if (cmd === 'l') {
this.labels.set(tokens[1], this.events.length);
continue;
}
if (cmd === 'p') {
const x = parseFloat(tokens[1]);
const y = parseFloat(tokens[2]);
const z = parseFloat(tokens[3]);
const key = `${x},${y},${z}`;
let id = null;
const newState = {};
for (let i = 4; i < tokens.length; i++) {
const token = tokens[i];
if (token.includes('=')) {
const [k, v] = token.split('=');
newState[k] = v;
} else {
id = token;
}
}
const prevBlock = shadowWorld.get(key);
const prev = prevBlock ? {id: prevBlock.id, state: {...prevBlock.state}} : null;
const isAir = id === 'air' || id === 'minecraft:air';
if (isAir) {
shadowWorld.delete(key);
} else if (id) {
shadowWorld.set(key, {id, pos: [x, y, z], state: newState});
} else if (prevBlock) {
Object.assign(prevBlock.state, newState);
} else {
console.warn(`Timeline warning: Attempted to update state of non-existent block at ${key}`);
continue;
}
const nextBlock = shadowWorld.get(key);
const next = nextBlock ? {id: nextBlock.id, state: {...nextBlock.state}} : null;
currentBatch.push({x, y, z, prev, next});
if (!mergeNext) {
this.events.push({time: currentTime, actions: currentBatch});
currentBatch = [];
}
}
}
if (currentBatch.length > 0) {
this.events.push({time: currentTime, actions: currentBatch});
}
if (!capturedInitialState) captureInitial();
}
getUniqueBlockStates() {
const unique = new Map();
const add = (block) => {
if (!block || !block.id || block.id === 'air' || block.id === 'minecraft:air') return;
// Create a stable hash for the state object so we don't duplicate requests
const stateHash = Object.entries(block.state)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(e => `${e[0]}=${e[1]}`)
.join(',');
const hash = `${block.id}[${stateHash}]`;
if (!unique.has(hash)) {
unique.set(hash, {id: block.id, state: {...block.state}});
}
};
for (const block of this.initialState.values()) add(block);
for (const ev of this.events) {
for (const action of ev.actions) {
add(action.prev);
add(action.next);
}
}
return Array.from(unique.values());
}
}
export class WorldFrame {
constructor(world) {
this.world = world;
// Playback State
this.blocks = new Map();
this.listeners = new Set();
this.currentIndex = 0;
this.currentTime = -Infinity;
// Immediately sync frame to the bedrock state
this.reset();
}
subscribe(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
notify() {
for (const listener of this.listeners) listener();
}
get(x, y, z) {
return this.blocks.get(`${x},${y},${z}`);
}
#apply(data, x, y, z) {
const key = `${x},${y},${z}`;
if (data) {
this.blocks.set(key, new Block(data.id, [x, y, z], {...data.state}));
} else {
this.blocks.delete(key);
}
}
seek(target) {
let targetTime = target;
if (typeof target === 'string') {
const absoluteIdx = this.world.labels.get(target);
if (absoluteIdx === undefined) throw new Error(`Label not found: ${target}`);
this.seekIndex(absoluteIdx);
return;
}
let changed = false;
while (this.currentIndex < this.world.events.length && this.world.events[this.currentIndex].time < targetTime) {
const batch = this.world.events[this.currentIndex];
for (const action of batch.actions) {
this.#apply(action.next, action.x, action.y, action.z);
}
this.currentIndex++;
changed = true;
}
while (this.currentIndex > this.world.initialIndex && this.world.events[this.currentIndex - 1].time >= targetTime) {
this.currentIndex--;
const batch = this.world.events[this.currentIndex];
for (let i = batch.actions.length - 1; i >= 0; i--) {
const action = batch.actions[i];
this.#apply(action.prev, action.x, action.y, action.z);
}
changed = true;
}
this.currentTime = targetTime;
if (changed) this.notify();
}
seekIndex(absoluteIndex) {
let targetIndex = Math.max(this.world.initialIndex, Math.min(this.world.events.length, absoluteIndex));
let changed = false;
while (this.currentIndex < this.world.events.length && this.currentIndex < targetIndex) {
const batch = this.world.events[this.currentIndex];
for (const action of batch.actions) {
this.#apply(action.next, action.x, action.y, action.z);
}
this.currentIndex++;
changed = true;
}
while (this.currentIndex > 0 && this.currentIndex > targetIndex) {
this.currentIndex--;
const batch = this.world.events[this.currentIndex];
for (let i = batch.actions.length - 1; i >= 0; i--) {
const action = batch.actions[i];
this.#apply(action.prev, action.x, action.y, action.z);
}
changed = true;
}
if (this.currentIndex < this.world.events.length) {
this.currentTime = this.world.events[this.currentIndex].time;
} else if (this.world.events.length > 0) {
this.currentTime = this.world.events[this.world.events.length - 1].time;
} else {
this.currentTime = -Infinity;
}
if (changed) this.notify();
}
reset() {
this.blocks.clear();
for (const [key, block] of this.world.initialState.entries()) {
this.blocks.set(key, new Block(block.id, [...block.pos], {...block.state}));
}
this.currentIndex = this.world.initialIndex;
if (this.world.initialIndex > 0 && this.world.events.length > 0) {
this.currentTime = this.world.events[this.world.initialIndex - 1].time;
} else {
this.currentTime = -Infinity;
}
this.notify();
}
}