Site: order only blocks whose projections overlap; ordering tests
This commit is contained in:
7 files changed
+172
-37
No files matched your search
+2
-1
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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=""$"#,##0"/></numFmts>'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user