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);
|
||||
@@ -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
|
||||
@@ -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=$!
|
||||
|
||||
Reference in new issue
Block a user