137 lines
6.2 KiB
TypeScript
137 lines
6.2 KiB
TypeScript
// The model turned about the desk centre, as the montage draws it.
|
|
import { project } from "../../src/iso.ts";
|
|
import { model } from "../../src/model.ts";
|
|
import { type Box, SCREEN, screens } from "./parts.ts";
|
|
|
|
const SVG = "http://www.w3.org/2000/svg";
|
|
export const PIVOT = [8, 5.5];
|
|
const COS = Math.cos(Math.PI / 6);
|
|
const TOP = model[0].blocks[0].h;
|
|
|
|
/** A world point turned by `a`, then projected. */
|
|
export function turned(a: number, x: number, y: number, z: number): [number, number] {
|
|
const [dx, dy] = [x - PIVOT[0], y - PIVOT[1]];
|
|
return project(PIVOT[0] + dx * Math.cos(a) - dy * Math.sin(a), PIVOT[1] + dx * Math.sin(a) + dy * Math.cos(a), z);
|
|
}
|
|
|
|
/** The montage's grown screens, which leave in bar 35. */
|
|
export const slabs: Box[] = ["ElyMark", "ElyTable", "ElyShow", "ElyDoc", "ElyPDF", "ElyTree"].map((app) => {
|
|
if (app === "ElyShow") return { x: 8.6, y: 2, z: TOP + 0.25, w: 4.8, d: 0.08, h: 2.7, tone: SCREEN };
|
|
// The montage drew Tree's screen at its old depth.
|
|
const b = app === "ElyTree" ? { ...screens[app], y: 7.9 } : screens[app];
|
|
return { ...b, x: b.x - b.w / 4, w: b.w * 1.5, h: b.h * 1.5 };
|
|
});
|
|
|
|
/** The montage's fixed framing, in this renderer's camera terms. */
|
|
export const montage = (() => {
|
|
const boxes = [...model.flatMap((p) => p.blocks), ...slabs, ...slabs.map((b) => ({ ...b, z: b.z + 1 }))];
|
|
const xs: number[] = [];
|
|
const ys: number[] = [];
|
|
for (let i = 0; i <= 90; i++) {
|
|
const a = (-i * Math.PI) / 180;
|
|
for (const b of boxes) for (const [x, y, z] of corners(b)) {
|
|
const [dx, dy] = [x - PIVOT[0], y - PIVOT[1]];
|
|
const [rx, ry] = [dx * Math.cos(a) - dy * Math.sin(a), dx * Math.sin(a) + dy * Math.cos(a)];
|
|
xs.push((rx - ry) * COS);
|
|
ys.push((rx + ry) / 2 - z);
|
|
}
|
|
}
|
|
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
|
|
const S = Math.min(1800 / (x1 - x0), 1000 / (y1 - y0));
|
|
const [X, Y] = [960 - (S * (x0 + x1)) / 2, 540 - (S * (y0 + y1)) / 2];
|
|
const [cx, cy] = [(PIVOT[0] - PIVOT[1]) * COS, (PIVOT[0] + PIVOT[1]) / 2];
|
|
const [, unit] = project(0, 0, -1);
|
|
return { s: S / unit, x: X - S * cx, y: Y - S * cy };
|
|
})();
|
|
|
|
function corners(b: Box): [number, number, number][] {
|
|
return [0, 1].flatMap((i) => [0, 1].flatMap((j) => [0, 1].map((k) => [b.x + i * b.w, b.y + j * b.d, b.z + k * b.h] as [number, number, number])));
|
|
}
|
|
|
|
/** Side darkness by turned normal: +y 9%, +x 20%. */
|
|
function darkness(nx: number, ny: number): number {
|
|
const u = Math.atan2(ny, nx) - Math.PI / 2;
|
|
return 0.145 - 0.055 * (Math.cos(u) + Math.sin(u));
|
|
}
|
|
|
|
function polygon(points: [number, number][], fill: string): SVGPolygonElement {
|
|
const el = document.createElementNS(SVG, "polygon");
|
|
el.setAttribute("points", points.map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`).join(" "));
|
|
el.style.fill = fill;
|
|
return el;
|
|
}
|
|
|
|
/** Convex hull of points, for the seam-hiding outline. */
|
|
function hull(points: [number, number][]): [number, number][] {
|
|
const p = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
const cross = (o: number[], a: number[], b: number[]) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
const half = (list: [number, number][]) => {
|
|
const out: [number, number][] = [];
|
|
for (const q of list) {
|
|
while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], q) <= 0) out.pop();
|
|
out.push(q);
|
|
}
|
|
return out.slice(0, -1);
|
|
};
|
|
return [...half(p), ...half([...p].reverse())];
|
|
}
|
|
|
|
/** A block turned by `a`: visible sides, then the top. */
|
|
export function turnedBlock(b: Box, a: number): SVGGElement {
|
|
const P = (x: number, y: number, z: number) => turned(a, x, y, z);
|
|
const [x, y, z, X, Y, Z] = [b.x, b.y, b.z, b.x + b.w, b.y + b.d, b.z + b.h];
|
|
const sides: { n: [number, number]; c: [number, number, number][] }[] = [
|
|
{ n: [1, 0], c: [[X, y, Z], [X, Y, Z], [X, Y, z], [X, y, z]] },
|
|
{ n: [-1, 0], c: [[x, Y, Z], [x, y, Z], [x, y, z], [x, Y, z]] },
|
|
{ n: [0, 1], c: [[X, Y, Z], [x, Y, Z], [x, Y, z], [X, Y, z]] },
|
|
{ n: [0, -1], c: [[x, y, Z], [X, y, Z], [X, y, z], [x, y, z]] },
|
|
];
|
|
const shade = (d: number) => `color-mix(in oklab, ${b.tone}, black ${(d * 100).toFixed(2)}%)`;
|
|
const g = document.createElementNS(SVG, "g");
|
|
g.append(polygon(hull(corners(b).map(([px, py, pz]) => P(px, py, pz))), shade(0.09)));
|
|
for (const { n, c } of sides) {
|
|
const [nx, ny] = [n[0] * Math.cos(a) - n[1] * Math.sin(a), n[0] * Math.sin(a) + n[1] * Math.cos(a)];
|
|
if (nx + ny <= 1e-9) continue;
|
|
g.append(polygon(c.map(([px, py, pz]) => P(px, py, pz)), shade(darkness(nx, ny))));
|
|
}
|
|
g.append(polygon([P(x, y, Z), P(X, y, Z), P(X, Y, Z), P(x, Y, Z)], b.tone));
|
|
return g;
|
|
}
|
|
|
|
/** Back to front for a view turned by `a`. */
|
|
export function turnedOrder(list: Box[], a: number): number[] {
|
|
const view = [Math.cos(a) + Math.sin(a), Math.cos(a) - Math.sin(a)];
|
|
const rect = (b: Box) => {
|
|
const pts = corners(b).map(([x, y, z]) => turned(a, x, y, z));
|
|
const xs = pts.map(([px]) => px);
|
|
const ys = pts.map(([, py]) => py);
|
|
return [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
|
|
};
|
|
const rects = list.map(rect);
|
|
const meet = (i: number, j: number) =>
|
|
rects[i][0] < rects[j][1] - 0.01 && rects[j][0] < rects[i][1] - 0.01 && rects[i][2] < rects[j][3] - 0.01 && rects[j][2] < rects[i][3] - 0.01;
|
|
const lo = (b: Box) => [b.x, b.y, b.z];
|
|
const hi = (b: Box) => [b.x + b.w, b.y + b.d, b.z + b.h];
|
|
// Separating axes agree when the blocks truly overlap.
|
|
const behind = (p: Box, q: Box): boolean | null => {
|
|
const sign = [view[0], view[1], 1];
|
|
const votes = new Set<boolean>();
|
|
for (let k = 0; k < 3; k++) {
|
|
if (Math.abs(sign[k]) < 1e-6) continue;
|
|
if (hi(p)[k] <= lo(q)[k]) votes.add(sign[k] > 0);
|
|
if (hi(q)[k] <= lo(p)[k]) votes.add(sign[k] < 0);
|
|
}
|
|
return votes.size === 1 ? [...votes][0] : null;
|
|
};
|
|
const before = list.map((q, i) => list.flatMap((p, j) => (j !== i && meet(i, j) && behind(p, q) === true ? [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("turned blocks occlude in a cycle");
|
|
done.add(next);
|
|
out.push(next);
|
|
}
|
|
return out;
|
|
}
|