Last active
March 2, 2026 15:44
-
-
Save andrew-kramer-inno/3f7697e92026ac98897ba609d4cfaea6 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ========================================================= | |
| // Isometric Engine Factory | |
| // | |
| // Creates a scoped engine bound to a specific SVG root and scale. | |
| // Each consumer gets its own set of closured functions — no globals, | |
| // no conflicts, no eval. | |
| // | |
| // Usage: | |
| // const E = createIsoEngine(svgElement, { scale: 42 }); | |
| // const { iso, el, poly, group, gBox, ... } = E; | |
| // ========================================================= | |
| (function () { | |
| const root = window.StudioTycoon || (window.StudioTycoon = {}); | |
| root.randomSeed = 12345; | |
| root.random = function () { | |
| let t = root.randomSeed += 0x6D2B79F5; | |
| t = Math.imul(t ^ (t >>> 15), t | 1); | |
| t ^= t + Math.imul(t ^ (t >>> 7), t | 61); | |
| return ((t ^ (t >>> 14)) >>> 0) / 4294967296; | |
| }; | |
| })(); | |
| function createIsoEngine(svgRoot, options = {}) { | |
| const SVG_NS = 'http://www.w3.org/2000/svg'; | |
| const SCALE = options.scale || 42; | |
| const CENTER_X = options.centerX || 400; | |
| const CENTER_Y = options.centerY || 580; | |
| const cam = (window.StudioTycoon && window.StudioTycoon.Camera) || {}; | |
| const pitch = options.pitch ?? cam.pitch ?? (Math.PI / 6); | |
| const yaw = options.yaw ?? cam.yaw ?? 0; | |
| const yawCenterX = options.yawCenterX ?? cam.yawCenterX ?? 5; | |
| const yawCenterY = options.yawCenterY ?? cam.yawCenterY ?? 5; | |
| const ISO_COS30 = Math.cos(pitch); | |
| const ISO_SIN30 = Math.sin(pitch); | |
| const VIEW_DIR = [-1, -1, 1]; | |
| // --- Core Primitives --- | |
| function iso(worldX, worldY, z = 0) { | |
| let x = worldX - yawCenterX; | |
| let y = worldY - yawCenterY; | |
| if (yaw !== 0) { | |
| const rotX = x * Math.cos(yaw) - y * Math.sin(yaw); | |
| const rotY = x * Math.sin(yaw) + y * Math.cos(yaw); | |
| x = rotX; | |
| y = rotY; | |
| } | |
| x += yawCenterX; | |
| y += yawCenterY; | |
| return { | |
| x: (y - x) * SCALE * ISO_COS30 + CENTER_X, | |
| y: -(x + y) * SCALE * ISO_SIN30 - z * SCALE + CENTER_Y | |
| }; | |
| } | |
| function el(tag, attrs = {}, parent = svgRoot) { | |
| const e = document.createElementNS(SVG_NS, tag); | |
| for (const [k, v] of Object.entries(attrs)) { | |
| if (k === 'textContent') { e.textContent = v; continue; } | |
| if (v !== undefined && v !== null) e.setAttribute(k, v); | |
| } | |
| if (typeof attrs.class === 'string') { | |
| const timeSeconds = performance.now() / 1000; | |
| const cls = attrs.class; | |
| if (cls.match(/(?:^|\s)(bubble|typing-cursor|agent-character|holo-mag|floating-bubble|stamp-hologram)(?:\s|$)/)) { | |
| e.style.setProperty('animation-delay', `-${timeSeconds.toFixed(3)}s`, 'important'); | |
| } else if (cls.match(/(?:^|\s)(holo-cyan)(?:\s|$)/)) { | |
| e.style.setProperty('animation-delay', `-${(timeSeconds - 0.5).toFixed(3)}s`, 'important'); | |
| } | |
| } | |
| if (parent) parent.appendChild(e); | |
| return e; | |
| } | |
| function poly(points, attrs = {}, parent = svgRoot) { | |
| const screenPts = points.map(([x, y, z]) => { | |
| const s = iso(x, y, z || 0); | |
| return `${s.x.toFixed(2)},${s.y.toFixed(2)}`; | |
| }).join(' '); | |
| return el('polygon', { points: screenPts, ...attrs }, parent); | |
| } | |
| function ellipseFace(face, parent = svgRoot) { | |
| const attrs = { cx: face.cx, cy: face.cy, rx: face.rx, ry: face.ry, ...face.attrs }; | |
| if (Math.abs(face.rotationDeg) > 1e-6) attrs.transform = `rotate(${face.rotationDeg} ${face.cx} ${face.cy})`; | |
| return el('ellipse', attrs, parent); | |
| } | |
| function group(attrs = {}, parent = svgRoot) { | |
| return el('g', attrs, parent); | |
| } | |
| function screenLineXPlane(x, y, z, lenPx, color, opacity = 0.85, parent = svgRoot, extraAttrs = {}) { | |
| const worldLen = lenPx / (SCALE * ISO_COS30); | |
| const p1 = iso(x, y, z); | |
| const p2 = iso(x, y + worldLen, z); | |
| const line = el('line', { | |
| x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y, | |
| stroke: color, 'stroke-width': 1.8, 'stroke-linecap': 'round', opacity | |
| }, parent); | |
| for (const [k, v] of Object.entries(extraAttrs)) line.setAttribute(k, v); | |
| return line; | |
| } | |
| // --- Immediate Box Renderers --- | |
| // Room-shell box (renders far faces: +Y, +X, top) | |
| function isoBox(x, y, z, sx, sy, sz, colors, parent = svgRoot) { | |
| const [top, left, right] = colors; | |
| const g = group({}, parent); | |
| poly([[x, y, z + sz], [x + sx, y, z + sz], [x + sx, y + sy, z + sz], [x, y + sy, z + sz]], { fill: top }, g); | |
| poly([[x, y + sy, z], [x + sx, y + sy, z], [x + sx, y + sy, z + sz], [x, y + sy, z + sz]], { fill: left }, g); | |
| poly([[x + sx, y, z], [x + sx, y + sy, z], [x + sx, y + sy, z + sz], [x + sx, y, z + sz]], { fill: right }, g); | |
| return g; | |
| } | |
| // Solid object box (renders near faces: -Y, -X, top) | |
| function isoSolidBox(x, y, z, sx, sy, sz, colors, parent = svgRoot) { | |
| const [top, yFace, xFace] = colors; | |
| const g = group({}, parent); | |
| poly([[x, y, z], [x + sx, y, z], [x + sx, y, z + sz], [x, y, z + sz]], { fill: yFace }, g); | |
| poly([[x, y, z], [x, y + sy, z], [x, y + sy, z + sz], [x, y, z + sz]], { fill: xFace }, g); | |
| poly([[x, y, z + sz], [x + sx, y, z + sz], [x + sx, y + sy, z + sz], [x, y + sy, z + sz]], { fill: top }, g); | |
| return g; | |
| } | |
| // --- 3D Transform Helpers --- | |
| function makePitch(pivotX, pivotZ, angleDeg) { | |
| const rad = angleDeg * Math.PI / 180; | |
| const cos = Math.cos(rad), sin = Math.sin(rad); | |
| return ([x, y, z]) => { | |
| const dx = x - pivotX, dz = z - pivotZ; | |
| return [pivotX + dx * cos - dz * sin, y, pivotZ + dx * sin + dz * cos]; | |
| }; | |
| } | |
| function makeYaw(cx, cy, angleDeg) { | |
| const rad = angleDeg * Math.PI / 180; | |
| const cos = Math.cos(rad), sin = Math.sin(rad); | |
| return ([x, y, z]) => { | |
| const lx = x - cx, ly = y - cy; | |
| return [cx + (lx * cos - ly * sin), cy + (lx * sin + ly * cos), z]; | |
| }; | |
| } | |
| // --- 3D Face Generators (return face arrays for depth sorting) --- | |
| function gBox(x, y, z, sx, sy, sz, colorMap, localPivotX = x + sx / 2, localPivotY = y + sy / 2, localAngle = 0, extraAttrs = {}, customTransform = null) { | |
| const [cTop, cSideDark, cSideLight] = colorMap; | |
| const rad = localAngle * Math.PI / 180; | |
| const rot = (px, py, pz) => { | |
| const lx = px - localPivotX, ly = py - localPivotY; | |
| let p = [ | |
| localPivotX + lx * Math.cos(rad) - ly * Math.sin(rad), | |
| localPivotY + lx * Math.sin(rad) + ly * Math.cos(rad), | |
| pz | |
| ]; | |
| if (customTransform) p = customTransform(p); | |
| return p; | |
| }; | |
| const p = [ | |
| rot(x, y, z), rot(x + sx, y, z), rot(x + sx, y + sy, z), rot(x, y + sy, z), | |
| rot(x, y, z + sz), rot(x + sx, y, z + sz), rot(x + sx, y + sy, z + sz), rot(x, y + sy, z + sz) | |
| ]; | |
| const center = rot(x + sx / 2, y + sy / 2, z + sz / 2); | |
| return [ | |
| { points: [p[0], p[1], p[5], p[4]], attrs: { fill: cSideDark, ...extraAttrs }, cullBackface: true, objectCenter: center }, | |
| { points: [p[3], p[2], p[6], p[7]], attrs: { fill: cSideLight, ...extraAttrs }, cullBackface: true, objectCenter: center }, | |
| { points: [p[0], p[4], p[7], p[3]], attrs: { fill: cSideLight, ...extraAttrs }, cullBackface: true, objectCenter: center }, | |
| { points: [p[1], p[2], p[6], p[5]], attrs: { fill: cSideDark, ...extraAttrs }, cullBackface: true, objectCenter: center }, | |
| { points: [p[4], p[5], p[6], p[7]], attrs: { fill: cTop, ...extraAttrs }, cullBackface: true, objectCenter: center }, | |
| { points: [p[0], p[3], p[2], p[1]], attrs: { fill: '#020617', ...extraAttrs }, cullBackface: true, objectCenter: center } | |
| ]; | |
| } | |
| function gCyl(cx, cy, z, r, h, colorMap, segments = 8, angleOffset = 0, extraAttrs = {}, customTransform = null) { | |
| const faces = []; | |
| const [cTop, cSideDark, cSideLight] = colorMap; | |
| const ptsTop = [], ptsBot = []; | |
| for (let i = 0; i < segments; i++) { | |
| const a = angleOffset + (i * Math.PI * 2) / segments; | |
| let ptT = [cx + r * Math.cos(a), cy + r * Math.sin(a), z + h]; | |
| let ptB = [cx + r * Math.cos(a), cy + r * Math.sin(a), z]; | |
| if (customTransform) { ptT = customTransform(ptT); ptB = customTransform(ptB); } | |
| ptsTop.push(ptT); ptsBot.push(ptB); | |
| } | |
| const center = customTransform ? customTransform([cx, cy, z + h / 2]) : [cx, cy, z + h / 2]; | |
| faces.push({ points: ptsTop, attrs: { fill: cTop, ...extraAttrs }, cullBackface: true, objectCenter: center }); | |
| for (let i = 0; i < segments; i++) { | |
| const nx = (i + 1) % segments; | |
| const a = angleOffset + (i * Math.PI * 2) / segments; | |
| const isLight = (Math.cos(a) - Math.sin(a)) > 0; | |
| faces.push({ | |
| points: [ptsTop[i], ptsTop[nx], ptsBot[nx], ptsBot[i]], | |
| attrs: { fill: isLight ? cSideLight : cSideDark, ...extraAttrs }, | |
| cullBackface: true, | |
| objectCenter: center | |
| }); | |
| } | |
| return faces; | |
| } | |
| function gWedge(x, y, z, sx, sy, szFront, szBack, colorMap, localPivotX = x + sx / 2, localPivotY = y + sy / 2, localAngle = 0, extraAttrs = {}, customTransform = null) { | |
| const [cTop, cSideDark, cSideLight] = colorMap; | |
| const rad = localAngle * Math.PI / 180; | |
| const rot = (px, py, pz) => { | |
| const lx = px - localPivotX, ly = py - localPivotY; | |
| let p = [ | |
| localPivotX + lx * Math.cos(rad) - ly * Math.sin(rad), | |
| localPivotY + lx * Math.sin(rad) + ly * Math.cos(rad), | |
| pz | |
| ]; | |
| if (customTransform) p = customTransform(p); | |
| return p; | |
| }; | |
| const p = [ | |
| rot(x, y, z), rot(x + sx, y, z), rot(x + sx, y + sy, z), rot(x, y + sy, z), | |
| rot(x, y, z + szFront), rot(x + sx, y, z + szBack), rot(x + sx, y + sy, z + szBack), rot(x, y + sy, z + szFront) | |
| ]; | |
| return [ | |
| { points: [p[0], p[1], p[5], p[4]], attrs: { fill: cSideDark, ...extraAttrs }, cullBackface: true }, | |
| { points: [p[3], p[2], p[6], p[7]], attrs: { fill: cSideLight, ...extraAttrs }, cullBackface: true }, | |
| { points: [p[0], p[4], p[7], p[3]], attrs: { fill: cSideLight, ...extraAttrs }, cullBackface: true }, | |
| { points: [p[1], p[2], p[6], p[5]], attrs: { fill: cSideDark, ...extraAttrs }, cullBackface: true }, | |
| { points: [p[4], p[5], p[6], p[7]], attrs: { fill: cTop, ...extraAttrs }, cullBackface: true }, | |
| { points: [p[0], p[3], p[2], p[1]], attrs: { fill: '#020617', ...extraAttrs }, cullBackface: true } | |
| ]; | |
| } | |
| // --- Plane Generators --- | |
| function makePlane(x, y, z, sx, sy, color, localPivotX = x + sx / 2, localPivotY = y + sy / 2, localAngle = 0, extraAttrs = {}, customTransform = null) { | |
| const rad = localAngle * Math.PI / 180; | |
| const rot = (px, py, pz) => { | |
| const lx = px - localPivotX, ly = py - localPivotY; | |
| let p = [ | |
| localPivotX + lx * Math.cos(rad) - ly * Math.sin(rad), | |
| localPivotY + lx * Math.sin(rad) + ly * Math.cos(rad), | |
| pz | |
| ]; | |
| if (customTransform) p = customTransform(p); | |
| return p; | |
| }; | |
| return [{ points: [rot(x, y, z), rot(x + sx, y, z), rot(x + sx, y + sy, z), rot(x, y + sy, z)], attrs: { fill: color, ...extraAttrs }, cullBackface: false }]; | |
| } | |
| function makeVerticalPlane(x, y, z, sy, sz, color, localAngle = 0, extraAttrs = {}, customTransform = null) { | |
| const rad = localAngle * Math.PI / 180; | |
| const cos = Math.cos(rad), sin = Math.sin(rad); | |
| const cy = sy / 2, cz = sz / 2; | |
| const rot = (dy, dz) => { | |
| const ly = dy - cy, lz = dz - cz; | |
| let p = [x, y + cy + (ly * cos - lz * sin), z + cz + (ly * sin + lz * cos)]; | |
| if (customTransform) p = customTransform(p); | |
| return p; | |
| }; | |
| return [{ points: [rot(0, 0), rot(sy, 0), rot(sy, sz), rot(0, sz)], attrs: { fill: color, ...extraAttrs }, cullBackface: false }]; | |
| } | |
| function clipPolygonToYZBounds(points, minY, maxY, minZ, maxZ) { | |
| const clipWithEdge = (polygon, isInside, intersect) => { | |
| if (polygon.length === 0) return polygon; | |
| const out = []; | |
| let prev = polygon[polygon.length - 1]; | |
| let prevInside = isInside(prev); | |
| for (const curr of polygon) { | |
| const currInside = isInside(curr); | |
| if (currInside) { | |
| if (!prevInside) out.push(intersect(prev, curr)); | |
| out.push(curr); | |
| } else if (prevInside) { | |
| out.push(intersect(prev, curr)); | |
| } | |
| prev = curr; | |
| prevInside = currInside; | |
| } | |
| return out; | |
| }; | |
| const intersectAtY = (a, b, yVal) => { | |
| const denom = b[1] - a[1]; | |
| const t = Math.abs(denom) < 1e-9 ? 0 : (yVal - a[1]) / denom; | |
| return [a[0] + (b[0] - a[0]) * t, yVal, a[2] + (b[2] - a[2]) * t]; | |
| }; | |
| const intersectAtZ = (a, b, zVal) => { | |
| const denom = b[2] - a[2]; | |
| const t = Math.abs(denom) < 1e-9 ? 0 : (zVal - a[2]) / denom; | |
| return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, zVal]; | |
| }; | |
| let clipped = points.slice(); | |
| clipped = clipWithEdge(clipped, p => p[1] >= minY, (a, b) => intersectAtY(a, b, minY)); | |
| clipped = clipWithEdge(clipped, p => p[1] <= maxY, (a, b) => intersectAtY(a, b, maxY)); | |
| clipped = clipWithEdge(clipped, p => p[2] >= minZ, (a, b) => intersectAtZ(a, b, minZ)); | |
| clipped = clipWithEdge(clipped, p => p[2] <= maxZ, (a, b) => intersectAtZ(a, b, maxZ)); | |
| return clipped; | |
| } | |
| function makeVerticalPlaneClipped(x, y, z, sy, sz, color, localAngle = 0, extraAttrs = {}, customTransform = null, clipBounds = null) { | |
| const rad = localAngle * Math.PI / 180; | |
| const cos = Math.cos(rad), sin = Math.sin(rad); | |
| const cy = sy / 2, cz = sz / 2; | |
| const rotLocal = (dy, dz) => { | |
| const ly = dy - cy, lz = dz - cz; | |
| return [x, y + cy + (ly * cos - lz * sin), z + cz + (ly * sin + lz * cos)]; | |
| }; | |
| const localPoints = [rotLocal(0, 0), rotLocal(sy, 0), rotLocal(sy, sz), rotLocal(0, sz)]; | |
| const clippedLocal = clipBounds | |
| ? clipPolygonToYZBounds(localPoints, clipBounds.minY, clipBounds.maxY, clipBounds.minZ, clipBounds.maxZ) | |
| : localPoints; | |
| if (clippedLocal.length < 3) return []; | |
| const points = customTransform ? clippedLocal.map(customTransform) : clippedLocal; | |
| return [{ points, attrs: { fill: color, ...extraAttrs }, cullBackface: false }]; | |
| } | |
| // --- Depth Sorting & Rendering --- | |
| function faceNormal(points) { | |
| const [a, b, c] = points; | |
| const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2]; | |
| const vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2]; | |
| return [uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx]; | |
| } | |
| function faceCentroid(points) { | |
| let cx = 0, cy = 0, cz = 0; | |
| for (const [x, y, z] of points) { cx += x; cy += y; cz += z; } | |
| const n = points.length || 1; | |
| return [cx / n, cy / n, cz / n]; | |
| } | |
| function isFrontFacing(points, objectCenter) { | |
| let [nx, ny, nz] = faceNormal(points); | |
| if (objectCenter) { | |
| const [fx, fy, fz] = faceCentroid(points); | |
| if (nx * (fx - objectCenter[0]) + ny * (fy - objectCenter[1]) + nz * (fz - objectCenter[2]) < 0) { | |
| nx = -nx; ny = -ny; nz = -nz; | |
| } | |
| } | |
| return nx * VIEW_DIR[0] + ny * VIEW_DIR[1] + nz * VIEW_DIR[2] > 1e-6; | |
| } | |
| function drawFacesByDepth(faces, parent = svgRoot) { | |
| const visibleFaces = faces.filter(f => !f.cullBackface || isFrontFacing(f.points, f.objectCenter)); | |
| const keyed = visibleFaces.map((f, i) => { | |
| let depth = 0, avgZ = 0, avgY = 0; | |
| for (const [x, y, z] of f.points) { depth += x + y - z; avgZ += z; avgY += y; } | |
| return { ...f, i, depth: depth / f.points.length, avgZ: avgZ / f.points.length, avgY: avgY / f.points.length }; | |
| }); | |
| keyed.sort((a, b) => (b.depth - a.depth) || (a.avgZ - b.avgZ) || (b.avgY - a.avgY) || (a.i - b.i)); | |
| keyed.forEach(f => { | |
| if (f.kind === 'ellipse') ellipseFace(f, parent); | |
| else poly(f.points, f.attrs, parent); | |
| }); | |
| } | |
| // --- Utilities --- | |
| function worldOffsetToScreenTransform(dx, dy, dz = 0) { | |
| let x = dx; | |
| let y = dy; | |
| if (yaw !== 0) { | |
| const rotX = x * Math.cos(yaw) - y * Math.sin(yaw); | |
| const rotY = x * Math.sin(yaw) + y * Math.cos(yaw); | |
| x = rotX; | |
| y = rotY; | |
| } | |
| return { | |
| x: (y - x) * SCALE * ISO_COS30, | |
| y: -(x + y) * SCALE * ISO_SIN30 - dz * SCALE | |
| }; | |
| } | |
| return { | |
| SVG_NS, SCALE, CENTER_X, CENTER_Y, ISO_COS30, ISO_SIN30, | |
| iso, el, poly, ellipseFace, group, screenLineXPlane, | |
| isoBox, isoSolidBox, | |
| makePitch, makeYaw, | |
| gBox, gCyl, gWedge, | |
| makePlane, makeVerticalPlane, clipPolygonToYZBounds, makeVerticalPlaneClipped, | |
| faceNormal, faceCentroid, isFrontFacing, | |
| drawFacesByDepth, renderLayer: drawFacesByDepth, | |
| worldOffsetToScreenTransform | |
| }; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment