Site: isometric blocks in SVG with an outline under the faces; separating-axis draw order

This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 23:07:10 -04:00
1 parent 01397eb1e5
commit 623554fa77
5 files changed
+150 -83

No files matched your search

+6 -49
View File
@@ -1,56 +1,13 @@
/* True isometric: tilt 54.7356°, turn 45°, no perspective. */
:root {
--u: 28px;
--hu: 28px;
}
.stage {
position: relative;
width: calc(var(--w) * var(--u));
height: calc(var(--d) * var(--u));
transform: rotateX(54.7356deg) rotateZ(45deg);
transform-style: preserve-3d;
}
.block {
position: absolute;
left: calc(var(--x) * var(--u));
top: calc(var(--y) * var(--u));
width: calc(var(--w) * var(--u));
height: calc(var(--d) * var(--u));
transform: translateZ(calc(var(--z) * var(--hu)));
transform-style: preserve-3d;
}
.face {
position: absolute;
box-sizing: border-box;
overflow: hidden;
}
/* One colour, three tones; sides overlap seams by 1px. */
/* One colour, three tones; no shadows. */
.face.top {
inset: 0;
background: var(--tone);
transform: translateZ(calc(var(--h) * var(--hu)));
fill: var(--tone);
}
.face.left {
left: 0;
top: calc(var(--d) * var(--u));
width: calc(var(--w) * var(--u) + 1px);
height: calc(var(--h) * var(--hu) + 1px);
background: color-mix(in oklab, var(--tone), black 9%);
transform-origin: top;
transform: rotateX(90deg);
.face.left,
.face.outline {
fill: color-mix(in oklab, var(--tone), black 9%);
}
.face.right {
left: calc(var(--w) * var(--u));
top: 0;
width: calc(var(--h) * var(--hu) + 1px);
height: calc(var(--d) * var(--u) + 1px);
background: color-mix(in oklab, var(--tone), black 20%);
transform-origin: left;
transform: rotateY(-90deg);
fill: color-mix(in oklab, var(--tone), black 20%);
}
+78 -25
View File
@@ -1,4 +1,11 @@
// Isometric blocks: one colour, three shaded faces, no perspective.
// True isometric in SVG: edges at 30°, no perspective.
const SVG = "http://www.w3.org/2000/svg";
const COS = Math.cos(Math.PI / 6);
const SIN = Math.sin(Math.PI / 6);
/** Pixels per grid cell; heights use the same unit. */
export const UNIT = 28;
/** A block on the grid, in grid units. */
export interface Block {
@@ -11,31 +18,77 @@ export interface Block {
tone: string;
}
/** Builds a block: top, left and right faces of one colour. */
export function block(spec: Block, top?: Node): HTMLElement {
const el = document.createElement("div");
el.className = "block";
el.style.setProperty("--x", String(spec.x));
el.style.setProperty("--y", String(spec.y));
el.style.setProperty("--z", String(spec.z ?? 0));
el.style.setProperty("--w", String(spec.w));
el.style.setProperty("--d", String(spec.d));
el.style.setProperty("--h", String(spec.h));
el.style.setProperty("--tone", spec.tone);
for (const face of ["left", "right", "top"]) {
const part = document.createElement("div");
part.className = `face ${face}`;
if (face === "top" && top) part.append(top);
el.append(part);
}
/** Grid point to screen pixels. */
export function project(x: number, y: number, z: number): [number, number] {
return [(x - y) * COS * UNIT, ((x + y) * SIN - z) * UNIT];
}
function polygon(points: [number, number][], className: string): SVGPolygonElement {
const el = document.createElementNS(SVG, "polygon");
el.setAttribute("points", points.map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`).join(" "));
el.setAttribute("class", className);
return el;
}
/** The ground: a stage `w` by `d` cells that holds blocks. */
export function stage(w: number, d: number): HTMLElement {
const el = document.createElement("div");
el.className = "stage";
el.style.setProperty("--w", String(w));
el.style.setProperty("--d", String(d));
return el;
/** A block: outline, then left, right and top faces. */
export function block(spec: Block): SVGGElement {
const { x, y, w, d, h } = spec;
const z = spec.z ?? 0;
const p = (dx: number, dy: number, dz: number) => project(x + dx, y + dy, z + dz);
const g = document.createElementNS(SVG, "g");
g.setAttribute("class", "block");
g.style.setProperty("--tone", spec.tone);
// The outline hides seams between faces.
g.append(
polygon([p(0, d, 0), p(w, d, 0), p(w, 0, 0), p(w, 0, h), p(0, 0, h), p(0, d, h)], "face outline"),
polygon([p(0, d, 0), p(w, d, 0), p(w, d, h), p(0, d, h)], "face left"),
polygon([p(w, d, 0), p(w, 0, 0), p(w, 0, h), p(w, d, h)], "face right"),
polygon([p(0, 0, h), p(w, 0, h), p(w, d, h), p(0, d, h)], "face top"),
);
boxes.set(g, { ...spec, z });
return g;
}
const boxes = new WeakMap<SVGGElement, Block & { z: number }>();
/** Whether `a` draws before `b`: the first separating axis decides. */
function behind(a: Block & { z: number }, b: Block & { z: number }): boolean {
if (a.x + a.w <= b.x) return true;
if (b.x + b.w <= a.x) return false;
if (a.y + a.d <= b.y) return true;
if (b.y + b.d <= a.y) return false;
if (a.z + a.h <= b.z) return true;
if (b.z + b.h <= a.z) return false;
throw new Error("blocks intersect");
}
/** Blocks in drawing order, back to front. */
function ordered(blocks: SVGGElement[]): SVGGElement[] {
const box = (g: SVGGElement) => {
const found = boxes.get(g);
if (!found) throw new Error("not a block");
return found;
};
const left = [...blocks];
const out: SVGGElement[] = [];
while (left.length) {
const next = left.findIndex((g) => left.every((o) => o === g || !behind(box(o), box(g))));
if (next < 0) throw new Error("blocks overlap; no drawing order");
out.push(...left.splice(next, 1));
}
return out;
}
/** An SVG for `blocks`, drawn back to front. */
export function stage(blocks: SVGGElement[]): SVGSVGElement {
const svg = document.createElementNS(SVG, "svg");
svg.append(...ordered(blocks));
document.body.append(svg);
const box = svg.getBBox();
svg.remove();
svg.setAttribute("viewBox", `${box.x} ${box.y} ${box.width} ${box.height}`);
svg.setAttribute("width", String(Math.ceil(box.width)));
svg.setAttribute("height", String(Math.ceil(box.height)));
svg.setAttribute("class", "stage");
return svg;
}
+7 -7
View File
@@ -5,11 +5,11 @@ import { block, stage } from "./iso.ts";
const app = document.querySelector<HTMLElement>("#app");
if (!app) throw new Error("index.html has no #app");
const desk = stage(12, 12);
desk.append(
block({ x: 0, y: 0, w: 12, d: 12, h: 0.5, tone: "#e9e9e6" }),
block({ x: 2, y: 2, z: 0.5, w: 3, d: 3, h: 4, tone: "#f4f4f2" }),
block({ x: 7, y: 2, z: 0.5, w: 3, d: 3, h: 2, tone: "#f4f4f2" }),
block({ x: 2, y: 7, z: 0.5, w: 3, d: 3, h: 1, tone: "#3d7fe5" }),
app.append(
stage([
block({ x: 0, y: 0, w: 12, d: 12, h: 0.5, tone: "#e9e9e6" }),
block({ x: 2, y: 2, z: 0.5, w: 3, d: 3, h: 4, tone: "#f4f4f2" }),
block({ x: 7, y: 2, z: 0.5, w: 3, d: 3, h: 2, tone: "#f4f4f2" }),
block({ x: 2, y: 7, z: 0.5, w: 3, d: 3, h: 1, tone: "#3d7fe5" }),
]),
);
app.append(desk);