Site: isometric blocks in SVG with an outline under the faces; separating-axis draw order
This commit is contained in:
5 files changed
+150
-83
No files matched your search
+6
-49
@@ -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
@@ -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
@@ -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);
|
||||
Reference in new issue
Block a user