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);
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# ElyDoc: type into the brief, bold a word, zoom in.
source "$(dirname "$0")/lib.sh"
us_input
launch ely-doc "$demo/Field Guide Brief.docx"
wait_log "lok: first tile drawn" 60
wait_log "doc: 3 headings"
front
sleep 3
snap doc-before
# The page's left edge, past the outline column.
page=$(first_bright doc-before 600 252 225)
x=$((win_x + page))
y=$win_y
read -r zx zy < <(where "title=zoom-in")
record_start doc
pause 1.0
mouse click $((x + 720)) $((y + 392))
pause 0.5
typed " Every route also gets a printable offline map."
pause 0.8
mouse double $((x + 243)) $((y + 351))
pause 0.5
keys 'keystroke "b" using command down'
pause 1.0
mouse click "$zx" "$zy"
pause 0.6
mouse move $((x + 760)) $((y + 640)) 0.7
pause 2.0
record_stop doc
+28 -2
View File
@@ -74,7 +74,14 @@ wait_ax() {
# Center of the first AX element whose line has $1, as "x y".
where() {
"$bin/ax-values" "$app_pid" | grep -F -- "$1" | grep -oE 'at=[0-9]+,[0-9]+' | head -n 1 | sed 's/at=//; s/,/ /'
local i at
for i in $(seq 20); do
at=$("$bin/ax-values" "$app_pid" | grep -F -- "$1" | grep -oE 'at=[0-9]+,[0-9]+' | head -n 1 | sed 's/at=//; s/,/ /' || true)
[ -n "$at" ] && { echo "$at"; return 0; }
sleep 0.25
done
echo "no AX element: $1" >&2
return 1
}
front() {
@@ -114,13 +121,32 @@ function run(argv) {
JS
}
# Saves the window as $1.png in the check folder.
snap() {
local id
id=$("$root/tests/scripts/shot.sh" --id "$app_pid")
screencapture -x -o -l"$id" "$check/$1.png"
}
# First x (points, window-relative) on row $2 brighter than $3.
first_bright() {
python3 - "$check/$1.png" "$2" "$3" "${4:-0}" <<'PY'
import sys
from PIL import Image
im = Image.open(sys.argv[1]).convert("L")
scale = im.size[0] / 1600
y, level, start = int(float(sys.argv[2]) * scale), int(sys.argv[3]), int(float(sys.argv[4]) * scale)
print(next(round(x / scale) for x in range(start, im.size[0]) if im.getpixel((x, y)) >= level))
PY
}
# Starts capturing the window region of the main screen.
record_start() {
local x y w h
read -r x y w h < <(frame)
[ "$w" = "$win_w" ] && [ "$h" = "$win_h" ] || echo "note: window is ${w}x${h}, not ${win_w}x${win_h}" >&2
rm -f "$out/rec/$1.mov"
ffmpeg -hide_banner -loglevel error -f avfoundation -capture_cursor 1 -framerate 60 -i "0:none" \
ffmpeg -hide_banner -loglevel error -f avfoundation -capture_cursor 1 -framerate 60 -pixel_format nv12 -i "0:none" \
-vf "crop=$((w * 2)):$((h * 2)):$((x * 2)):$((y * 2))" -c:v h264_videotoolbox -b:v 80M -r 60 \
"$out/rec/$1.mov" </dev/null &
cap_pid=$!