From 623554fa773b709ccbf23958ea2e888004454216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 23:07:10 -0400 Subject: [PATCH] Site: isometric blocks in SVG with an outline under the faces; separating-axis draw order --- site/src/iso.css | 55 +++------------------ site/src/iso.ts | 103 +++++++++++++++++++++++++++++---------- site/src/main.ts | 14 +++--- site/video/record/doc.sh | 31 ++++++++++++ site/video/record/lib.sh | 30 +++++++++++- 5 files changed, 150 insertions(+), 83 deletions(-) create mode 100644 site/video/record/doc.sh diff --git a/site/src/iso.css b/site/src/iso.css index 9ecb2b0..9f363b3 100644 --- a/site/src/iso.css +++ b/site/src/iso.css @@ -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%); } diff --git a/site/src/iso.ts b/site/src/iso.ts index a7014e7..003a6ad 100644 --- a/site/src/iso.ts +++ b/site/src/iso.ts @@ -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(); + +/** 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; } diff --git a/site/src/main.ts b/site/src/main.ts index fb2b117..d7ec522 100644 --- a/site/src/main.ts +++ b/site/src/main.ts @@ -5,11 +5,11 @@ import { block, stage } from "./iso.ts"; const app = document.querySelector("#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); diff --git a/site/video/record/doc.sh b/site/video/record/doc.sh new file mode 100644 index 0000000..a8bbb09 --- /dev/null +++ b/site/video/record/doc.sh @@ -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 diff --git a/site/video/record/lib.sh b/site/video/record/lib.sh index 0382b8b..967705e 100644 --- a/site/video/record/lib.sh +++ b/site/video/record/lib.sh @@ -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"