Site: order only blocks whose projections overlap; ordering tests

This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 23:13:19 -04:00
1 parent 623554fa77
commit 4b76473fde
7 files changed
+172 -37

No files matched your search

+2 -1
View File
@@ -6,7 +6,8 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"deploy": "pnpm build && wrangler deploy"
"deploy": "pnpm build && wrangler deploy",
"test": "node --test test/*.test.ts"
},
"devDependencies": {
"typescript": "6.0.3",
+38 -20
View File
@@ -49,10 +49,24 @@ export function block(spec: Block): SVGGElement {
return g;
}
const boxes = new WeakMap<SVGGElement, Block & { z: number }>();
const boxes = new WeakMap<SVGGElement, Required<Block>>();
/** A box's screen footprint on three iso axes. */
function spans(b: Required<Block>): [number, number][] {
return [
[b.x - b.z - b.h, b.x + b.w - b.z],
[b.y - b.z - b.h, b.y + b.d - b.z],
[b.x - b.y - b.d, b.x + b.w - b.y],
];
}
function overlap(a: Required<Block>, b: Required<Block>): boolean {
const [sa, sb] = [spans(a), spans(b)];
return sa.every(([lo, hi], i) => lo < sb[i][1] && sb[i][0] < hi);
}
/** Whether `a` draws before `b`: the first separating axis decides. */
function behind(a: Block & { z: number }, b: Block & { z: number }): boolean {
function behind(a: Required<Block>, b: Required<Block>): 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;
@@ -62,19 +76,18 @@ function behind(a: Block & { z: number }, b: Block & { z: number }): boolean {
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));
/** Drawing order, back to front; only overlapping boxes constrain. */
export function order(list: Required<Block>[]): number[] {
const before = list.map((b, i) =>
list.flatMap((a, j) => (j !== i && overlap(a, b) && behind(a, b) ? [j] : [])),
);
const done = new Set<number>();
const out: number[] = [];
while (out.length < list.length) {
const next = list.findIndex((_, i) => !done.has(i) && before[i].every((j) => done.has(j)));
if (next < 0) throw new Error("blocks occlude each other in a cycle");
done.add(next);
out.push(next);
}
return out;
}
@@ -82,13 +95,18 @@ function ordered(blocks: SVGGElement[]): SVGGElement[] {
/** An SVG for `blocks`, drawn back to front. */
export function stage(blocks: SVGGElement[]): SVGSVGElement {
const svg = document.createElementNS(SVG, "svg");
svg.append(...ordered(blocks));
const box = (g: SVGGElement) => {
const found = boxes.get(g);
if (!found) throw new Error("not a block");
return found;
};
svg.append(...order(blocks.map(box)).map((i) => blocks[i]));
document.body.append(svg);
const box = svg.getBBox();
const bounds = 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("viewBox", `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`);
svg.setAttribute("width", String(Math.ceil(bounds.width)));
svg.setAttribute("height", String(Math.ceil(bounds.height)));
svg.setAttribute("class", "stage");
return svg;
}
+29
View File
@@ -0,0 +1,29 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { order } from "../src/iso.ts";
const box = (x: number, y: number, z: number, w: number, d: number, h: number) => ({ x, y, z, w, d, h, tone: "" });
const desk = box(0, 0, 0, 12, 12, 0.5);
test("blocks apart on screen add no constraint, so no false cycle", () => {
const list = [desk, box(0, 2, 0.5, 1, 1, 1), box(1, 0, 0.5, 2, 1, 1), box(0, 1, 0.5, 2, 1, 1)];
const drawn = order(list);
assert.equal(drawn[0], 0, "the desk is drawn first");
// C (index 3) covers A (index 1) only from behind A.
assert.ok(drawn.indexOf(3) < drawn.indexOf(1));
});
test("the desk scene draws ground, then blocks", () => {
const list = [desk, box(2, 2, 0.5, 3, 3, 4), box(7, 2, 0.5, 3, 3, 2), box(2, 7, 0.5, 3, 3, 1)];
assert.equal(order(list)[0], 0);
});
test("a block in front draws after the one it covers", () => {
const back = box(0, 0, 0, 2, 2, 4);
const front = box(2, 2, 0, 2, 2, 1);
assert.deepEqual(order([front, back]), [1, 0]);
});
test("intersecting blocks fail loudly", () => {
assert.throws(() => order([box(0, 0, 0, 2, 2, 2), box(1, 1, 1, 2, 2, 2)]), /intersect/);
});
+18 -13
View File
@@ -122,10 +122,15 @@ def docx(path):
# XLSX: half-year sales by product line.
MONTHS = ["January", "February", "March", "April", "May", "June"]
LINES = ["Trail Maps", "Guidebooks", "Prints", "Workshops"]
SALES = [[4820, 6150, 2310, 1800], [5140, 5980, 2475, 2100], [6380, 7210, 2890, 2650],
[7120, 8040, 3120, 3400], [7950, 8820, 3460, 3900], [8610, 9470, 3780, 4350]]
MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
SALES = {
"Trail Maps": [4820, 5140, 6380, 7120, 7950, 8610],
"Guidebooks": [6150, 5980, 7210, 8040, 8820, 9470],
"Prints": [2310, 2475, 2890, 3120, 3460, 3780],
"Workshops": [1800, 2100, 2650, 3400, 3900, 4350],
"Postcards": [940, 870, 1120, 1360, 1580, 1710],
"Gift Cards": [1250, 980, 760, 820, 1430, 2050],
}
def col(i):
@@ -139,18 +144,18 @@ def xlsx(path):
def num(ref, value, s):
return f'<c r="{ref}" s="{s}"><v>{value}</v></c>'
rows = ['<row r="1" ht="22" customHeight="1">' + text("A1", "Month", 1)
+ "".join(text(f"{col(i + 1)}1", n, 2) for i, n in enumerate(LINES)) + text("F1", "Total", 2) + "</row>"]
for r, (month, values) in enumerate(zip(MONTHS, SALES), start=2):
cells = text(f"A{r}", month, 3) + "".join(num(f"{col(i + 1)}{r}", v, 4) for i, v in enumerate(values))
total = sum(values)
cells += f'<c r="F{r}" s="5"><f>SUM(B{r}:E{r})</f><v>{total}</v></c>'
rows = ['<row r="1" ht="22" customHeight="1">' + text("A1", "Product", 1)
+ "".join(text(f"{col(i + 1)}1", m, 2) for i, m in enumerate(MONTHS)) + text("H1", "Total", 2) + "</row>"]
for r, (line, values) in enumerate(SALES.items(), start=2):
cells = text(f"A{r}", line, 3) + "".join(num(f"{col(i + 1)}{r}", v, 4) for i, v in enumerate(values))
cells += f'<c r="H{r}" s="5"><f>SUM(B{r}:G{r})</f><v>{sum(values)}</v></c>'
rows.append(f'<row r="{r}" ht="20" customHeight="1">{cells}</row>')
rows.append('<row r="8" ht="22" customHeight="1">' + text("A8", "Half year", 1) + "</row>")
rows.append('<row r="9" ht="22" customHeight="1">' + text("A9", "All lines", 1)
+ "".join(f'<c r="{col(i + 1)}9" s="5"/>' for i in range(7)) + "</row>")
sheet = (f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="{S}">'
'<sheetViews><sheetView workbookViewId="0" showGridLines="1" zoomScale="130" zoomScaleNormal="130"/></sheetViews>'
'<sheetViews><sheetView workbookViewId="0" showGridLines="1"/></sheetViews>'
'<sheetFormatPr defaultRowHeight="20"/>'
'<cols><col min="1" max="1" width="16" customWidth="1"/><col min="2" max="6" width="14" customWidth="1"/></cols>'
'<cols><col min="1" max="1" width="16" customWidth="1"/><col min="2" max="8" width="12" customWidth="1"/></cols>'
f'<sheetData>{"".join(rows)}</sheetData></worksheet>')
styles = (f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="{S}">'
'<numFmts count="1"><numFmt numFmtId="164" formatCode="&quot;$&quot;#,##0"/></numFmts>'
+9 -3
View File
@@ -8,23 +8,29 @@ wait_log "lok: first tile drawn" 60
wait_log "doc: 3 headings"
front
sleep 3
read -r zx zy < <(where "title=zoom-in")
record_start doc
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
pause 0.6
mark "click end of Purpose paragraph"
mouse click $((x + 720)) $((y + 392))
pause 0.5
mark "typing starts"
typed " Every route also gets a printable offline map."
mark "typing ends"
pause 0.8
mark "double-click 'forty'"
mouse double $((x + 243)) $((y + 351))
pause 0.5
keys 'keystroke "b" using command down'
mark "bold applied"
pause 1.0
mouse click "$zx" "$zy"
mark "zoom to 125%"
pause 0.6
mouse move $((x + 760)) $((y + 640)) 0.7
pause 2.0
+10
View File
@@ -146,13 +146,23 @@ record_start() {
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"
cap_name=$1
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=$!
t0=$(now)
: >"$out/rec/$1.txt"
sleep 1.2
}
now() { perl -MTime::HiRes=time -e 'printf "%.2f\n", time'; }
# Notes a key moment, in clip seconds, for the editor.
mark() {
awk -v a="$(now)" -v b="$t0" -v m="$1" 'BEGIN { printf "%5.1f %s\n", a - b - 0.5, m }' >>"$out/rec/$cap_name.txt"
}
# Stops capture; encodes 1920x1080 H.264 60 fps; checks frames.
record_stop() {
local name=$1
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# ElyTable: SUM row, sort by total, insert a chart.
source "$(dirname "$0")/lib.sh"
us_input
launch ely-table "$demo/Sales 2027.xlsx"
wait_log "lok: first tile drawn" 60
front
sleep 4
x=$win_x
y=$win_y
# Cell centers in window points.
cell() { echo $((x + $1)) $((y + $2)); }
menu() {
osascript -e "tell application \"System Events\" to tell (first process whose unix id is $app_pid) to click menu item \"$2\" of menu \"$1\" of menu bar 1" >/dev/null
}
record_start table
pause 0.8
mark "click B9"
mouse click $(cell 242 357)
pause 0.4
mark "typing =SUM(B2:B7)"
typed "=SUM(B2:B7)"
pause 0.4
keys "key code 36"
mark "formula entered"
pause 0.5
mouse click $(cell 242 357)
pause 0.4
mark "fill handle drag B9 to H9"
mouse drag $(cell 295 370) $(cell 880 362) 1.0
pause 0.8
mark "select A1:H7"
mouse drag $(cell 118 145) $(cell 880 305) 0.8
pause 0.4
menu Data AutoFilter
mark "AutoFilter on"
pause 0.7
mouse click $(cell 921 147)
mark "Total filter menu open"
pause 0.8
read -r sx sy < <(where $'title=Sort Z to A\t')
mouse click "$sx" "$sy"
mark "sorted Z to A by Total"
pause 1.2
mouse click $(cell 1100 600)
pause 0.3
mark "select A1:G7"
mouse drag $(cell 118 145) $(cell 774 305) 0.8
pause 0.4
menu Insert "Chart…"
mark "Insert Chart dialog"
pause 0.8
read -r ix iy < <(where $'AXButton\tvalue=\ttitle=Insert\t')
mouse click $((ix - 87)) $((iy - 185))
typed "Sales by product"
pause 0.4
mouse click "$ix" "$iy"
mark "chart inserted"
pause 1.0
mouse drag $(cell 520 300) $(cell 520 610) 0.9
mark "chart moved below the table"
pause 0.6
mouse click $(cell 1100 700)
pause 1.5
record_stop table