Files
newbury-exhibit-v2/public/admin/plan.html
T

238 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Placement Plan — Newbury Exhibit</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; background: #666; }
#controls { padding: 10px 16px; display: flex; gap: 14px; flex-wrap: wrap; align-items: center; background: #0d1226; color: #e8ecff; }
.page { background: #fff; color: #000; margin: 10px auto; box-shadow: 0 2px 10px rgba(0,0,0,.5); position: relative; overflow: hidden; }
.page svg { display: block; }
.pgHead { position: absolute; top: 2mm; left: 5mm; right: 5mm; font-size: 8pt; display: flex; justify-content: space-between; }
@media print {
body { background: #fff; }
#hdr, #controls { display: none !important; }
.page { margin: 0; box-shadow: none; page-break-after: always; }
}
</style></head>
<body>
<div id="hdr"></div>
<div id="controls"></div>
<div id="pages"></div>
<script src="/vendor/cv.js"></script>
<script src="/vendor/svd.js"></script>
<script src="/vendor/posit1.js"></script>
<script src="/vendor/aruco.js"></script>
<script src="/vendor/dictionaries/aruco_4x4_1000.js"></script>
<script type="module">
import { requireAuth, styles, nav } from './admin.js';
document.getElementById('hdr').innerHTML = nav('plan');
document.head.insertAdjacentHTML('beforeend', `<style>@media screen {${styles}}</style>`);
await requireAuth();
const scene = await (await fetch('/api/scene')).json();
const dic = new AR.Dictionary('ARUCO_4X4_1000');
const PAPER = { A4: [210, 297], A3: [297, 420] };
const MARGIN = 10; // mm printable margin
const OVERLAP = 10; // mm tile overlap for joining
const opts = { scale: '1', paper: 'A4', anchors: true, buildings: true, spawns: true, paths: true };
document.getElementById('controls').innerHTML = `
<label>Scale <select id="scale">
<option value="1">1:1 (true size — tape it down)</option>
<option value="2">1:2</option>
<option value="5">1:5</option>
<option value="fit">Fit one page</option>
</select></label>
<label>Paper <select id="paper"><option>A4</option><option>A3</option></select></label>
${['anchors','buildings','spawns','paths'].map(k =>
`<label><input type="checkbox" id="ly-${k}" checked> ${k}</label>`).join('')}
<span style="flex:1"></span>
<button class="primary" onclick="print()">Print…</button>
<span id="pgCount" style="opacity:.7;font-size:.85rem"></span>`;
['scale','paper'].forEach(id => document.getElementById(id).onchange = e => { opts[id] = e.target.value; build(); });
['anchors','buildings','spawns','paths'].forEach(k =>
document.getElementById('ly-' + k).onchange = e => { opts[k] = e.target.checked; build(); });
// ---------- geometry ----------
function extent() {
let minX = 0, minZ = 0, maxX = 100, maxZ = 100;
const eat = (x, z, pad = 10) => {
minX = Math.min(minX, x - pad); maxX = Math.max(maxX, x + pad);
minZ = Math.min(minZ, z - pad); maxZ = Math.max(maxZ, z + pad);
};
for (const a of scene.anchors || []) eat(a.position[0], a.position[2]);
for (const b of scene.buildings || []) eat(b.position[0], b.position[2], Math.max(b.size[0], b.size[2]) / 2 + 5);
for (const s of scene.spawns || []) eat(s.position[0], s.position[2]);
for (const p of scene.paths || []) for (const q of p.points || []) eat(q[0], q[2]);
return { minX, minZ, maxX, maxZ, w: maxX - minX, h: maxZ - minZ };
}
const brgOf = a => a.mount === 'wall'
? ((a.yawDeg % 360) + 360) % 360
: (((180 + (a.yawDeg || 0)) % 360) + 360) % 360;
/* Build the plan as SVG inner markup in mm units at scale k (world cm * 10 / k).
* Page mapping: +X east = right, N (+Z) = UP the page. */
function planSVG(ex, k) {
const S = 10 / k; // mm per world-cm
const X = x => (x - ex.minX) * S;
const Y = z => (ex.maxZ - z) * S; // N up
const px = v => +v.toFixed(2);
let g = '';
// grid: 10 cm light, 50 cm dark, labels every 50 cm
for (let x = Math.ceil(ex.minX / 10) * 10; x <= ex.maxX; x += 10) {
const major = x % 50 === 0;
g += `<line x1="${px(X(x))}" y1="0" x2="${px(X(x))}" y2="${px(Y(ex.minZ))}" stroke="${major ? '#999' : '#ddd'}" stroke-width="${major ? 0.3 : 0.15}"/>`;
if (major) g += `<text x="${px(X(x))}" y="${px(Y(ex.minZ)) - 1}" font-size="3" text-anchor="middle" fill="#666">${x}</text>`;
}
for (let z = Math.ceil(ex.minZ / 10) * 10; z <= ex.maxZ; z += 10) {
const major = z % 50 === 0;
g += `<line x1="0" y1="${px(Y(z))}" x2="${px(X(ex.maxX))}" y2="${px(Y(z))}" stroke="${major ? '#999' : '#ddd'}" stroke-width="${major ? 0.3 : 0.15}"/>`;
if (major) g += `<text x="1" y="${px(Y(z)) - 0.8}" font-size="3" fill="#666">${z}</text>`;
}
// compass rose (top-left)
g += `<g transform="translate(12,14)">
<circle r="8" fill="none" stroke="#000" stroke-width="0.4"/>
<path d="M0,2 L0,-6 M-1.6,-3.6 L0,-6 L1.6,-3.6" stroke="#000" stroke-width="0.7" fill="none"/>
<text y="-9.5" font-size="4" text-anchor="middle" font-weight="bold">N (+Z, back)</text>
<text y="13" font-size="3" text-anchor="middle" fill="#444">grid 10 cm</text></g>`;
if (opts.paths) for (const p of scene.paths || []) {
const pts = (p.points || []);
if (pts.length < 2) continue;
const lp = p.mode === 'loop' ? [...pts, pts[0]] : pts;
g += `<polyline points="${lp.map(q => `${px(X(q[0]))},${px(Y(q[2]))}`).join(' ')}" fill="none" stroke="#8a2be2" stroke-width="0.5" stroke-dasharray="2 1.2"/>`;
for (let i = 0; i < lp.length - 1; i++) { // direction arrow per segment midpoint
const mx = (X(lp[i][0]) + X(lp[i + 1][0])) / 2, my = (Y(lp[i][2]) + Y(lp[i + 1][2])) / 2;
const ang = Math.atan2(Y(lp[i + 1][2]) - Y(lp[i][2]), X(lp[i + 1][0]) - X(lp[i][0])) * 180 / Math.PI;
g += `<path d="M-1.8,-1.2 L1.8,0 L-1.8,1.2 Z" transform="translate(${px(mx)},${px(my)}) rotate(${px(ang)})" fill="#8a2be2"/>`;
}
pts.forEach((q, i) => {
g += `<circle cx="${px(X(q[0]))}" cy="${px(Y(q[2]))}" r="1.4" fill="#fff" stroke="#8a2be2" stroke-width="0.5"/>
<text x="${px(X(q[0]) + 2)}" y="${px(Y(q[2]) - 1.5)}" font-size="2.6" fill="#8a2be2">${p.id}·${i + 1} (y=${q[1]})</text>`;
});
}
if (opts.buildings) for (const b of scene.buildings || []) {
const w = b.size[0] * S, d = b.size[2] * S;
g += `<g transform="translate(${px(X(b.position[0]))},${px(Y(b.position[2]))}) rotate(${px(-(b.yawDeg || 0))})">
<rect x="${px(-w / 2)}" y="${px(-d / 2)}" width="${px(w)}" height="${px(d)}" fill="#dbe8ff" fill-opacity="0.6" stroke="#4a7fd0" stroke-width="0.4"/>
<text font-size="3.2" text-anchor="middle" fill="#2a4f90">${b.name || 'building'}</text>
<text y="4" font-size="2.6" text-anchor="middle" fill="#2a4f90">h=${b.size[1]} cm</text></g>`;
}
if (opts.spawns) for (const s of scene.spawns || []) {
g += `<circle cx="${px(X(s.position[0]))}" cy="${px(Y(s.position[2]))}" r="2.2" fill="#ffd166" fill-opacity="0.7" stroke="#b57f0b" stroke-width="0.4"/>
<text x="${px(X(s.position[0]) + 3)}" y="${px(Y(s.position[2]) + 1)}" font-size="2.8" fill="#7a5500">${s.id} (y=${s.position[1]})</text>`;
}
if (opts.anchors) for (const a of scene.anchors || []) {
const cx = X(a.position[0]), cy = Y(a.position[2]);
const brg = brgOf(a);
if (a.mount === 'wall') {
// vertical marker: footprint line perpendicular to facing, arrow shows facing
const half = (a.sizeMM / 10) * S / 2;
g += `<g transform="translate(${px(cx)},${px(cy)}) rotate(${px(brg)})">
<line x1="${px(-half)}" y1="0" x2="${px(half)}" y2="0" stroke="#c0392b" stroke-width="1"/>
<path d="M0,0 L0,${px(-half)} M-1.5,${px(-half + 2)} L0,${px(-half)} L1.5,${px(-half + 2)}" stroke="#c0392b" stroke-width="0.5" fill="none"/></g>
<text x="${px(cx + half + 1)}" y="${px(cy)}" font-size="3" fill="#c0392b">#${a.markerId} WALL faces ${brg}° · y=${a.position[1]}</text>`;
continue;
}
// flat marker: true-size crest pattern rotated so its top edge faces brg (clockwise from N=up)
const sz = (a.sizeMM / 10) * S; // mm on page
const cell = sz / 6;
const code = dic.codeList[a.markerId] || '';
let cells = `<rect x="${px(-sz / 2)}" y="${px(-sz / 2)}" width="${px(sz)}" height="${px(sz)}" fill="#000"/>`;
for (let yy = 0; yy < 4; yy++) for (let xx = 0; xx < 4; xx++)
if (code[yy * 4 + xx] === '1')
cells += `<rect x="${px(-sz / 2 + (xx + 1) * cell)}" y="${px(-sz / 2 + (yy + 1) * cell)}" width="${px(cell)}" height="${px(cell)}" fill="#fff"/>`;
g += `<g transform="translate(${px(cx)},${px(cy)}) rotate(${px(brg)})">${cells}
<path d="M0,${px(-sz / 2 - 1)} L0,${px(-sz / 2 - 5)} M-1.5,${px(-sz / 2 - 3.2)} L0,${px(-sz / 2 - 5)} L1.5,${px(-sz / 2 - 3.2)}" stroke="#0a8f6b" stroke-width="0.6" fill="none"/></g>
<text x="${px(cx + sz / 2 + 1.5)}" y="${px(cy + 1)}" font-size="3" fill="#0a8f6b" font-weight="bold">#${a.markerId}</text>
<text x="${px(cx + sz / 2 + 1.5)}" y="${px(cy + 4.5)}" font-size="2.4" fill="#0a8f6b">top→${brg}°</text>`;
}
return g;
}
// ---------- pages ----------
function build() {
const ex = extent();
const [pw, ph] = PAPER[opts.paper];
const availW = pw - 2 * MARGIN, availH = ph - 2 * MARGIN;
let k;
if (opts.scale === 'fit') k = Math.max((ex.w * 10) / availW, (ex.h * 10) / availH, 1);
else k = parseFloat(opts.scale);
const S = 10 / k;
const totW = ex.w * S, totH = ex.h * S; // mm
const inner = planSVG(ex, k);
const stepW = availW - OVERLAP, stepH = availH - OVERLAP;
const cols = Math.max(1, Math.ceil((totW - OVERLAP) / stepW));
const rows = Math.max(1, Math.ceil((totH - OVERLAP) / stepH));
const pages = document.getElementById('pages');
pages.innerHTML = '';
const scaleLabel = opts.scale === 'fit' ? `1:${k.toFixed(1)}` : `1:${k}` + (k === 1 ? ' — TRUE SIZE: crest patterns are usable, tape sheet down' : '');
// overview page (always first): whole plan fit on one page with tile grid
const fitK = Math.max((ex.w * 10) / availW, (ex.h * 10) / availH, 1);
const ovW = ex.w * 10 / fitK, ovH = ex.h * 10 / fitK;
let tileGrid = '';
if (cols * rows > 1) {
const rr = (10 / fitK) / S; // convert tile mm to overview mm
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
const tx = c * stepW * rr, ty = r * stepH * rr;
tileGrid += `<rect x="${tx.toFixed(1)}" y="${ty.toFixed(1)}" width="${(availW * rr).toFixed(1)}" height="${(availH * rr).toFixed(1)}"
fill="none" stroke="#e74c3c" stroke-width="0.4" stroke-dasharray="2 1"/>
<text x="${(tx + 2).toFixed(1)}" y="${(ty + 5).toFixed(1)}" font-size="5" fill="#e74c3c" font-weight="bold">${String.fromCharCode(65 + r)}${c + 1}</text>`;
}
}
pages.insertAdjacentHTML('beforeend', `
<div class="page" style="width:${pw}mm;height:${ph}mm">
<div class="pgHead"><b>Newbury Exhibit — placement plan (overview, 1:${fitK.toFixed(1)})</b>
<span>${cols * rows > 1 ? `${cols * rows} tile pages follow at ${scaleLabel} — red boxes show tiles` : `scale ${scaleLabel}`}</span></div>
<svg width="${ovW}mm" height="${ovH}mm" viewBox="0 0 ${ex.w * 10 / fitK} ${ex.h * 10 / fitK}"
style="margin:${MARGIN + 4}mm ${MARGIN}mm">
<g transform="scale(${(10 / fitK) / S})">${inner}</g>${tileGrid}
</svg>
</div>`);
// tile pages
if (!(opts.scale === 'fit')) {
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
const vx = c * stepW, vy = r * stepH;
const label = `${String.fromCharCode(65 + r)}${c + 1}`;
const joins = [
c + 1 < cols ? `right joins ${String.fromCharCode(65 + r)}${c + 2}` : '',
r + 1 < rows ? `bottom joins ${String.fromCharCode(66 + r)}${c + 1}` : '',
].filter(Boolean).join(' · ');
// crop marks at the overlap boundary
const cm = `<g stroke="#e74c3c" stroke-width="0.3">
${[[OVERLAP / 2, 0], [availW - OVERLAP / 2, 0]].map(([x]) =>
`<line x1="${vx + x}" y1="${vy}" x2="${vx + x}" y2="${vy + 4}"/><line x1="${vx + x}" y1="${vy + availH - 4}" x2="${vx + x}" y2="${vy + availH}"/>`).join('')}
${[[0, OVERLAP / 2], [0, availH - OVERLAP / 2]].map(([, y]) =>
`<line x1="${vx}" y1="${vy + y}" x2="${vx + 4}" y2="${vy + y}"/><line x1="${vx + availW - 4}" y1="${vy + y}" x2="${vx + availW}" y2="${vy + y}"/>`).join('')}
</g>`;
pages.insertAdjacentHTML('beforeend', `
<div class="page" style="width:${pw}mm;height:${ph}mm">
<div class="pgHead"><b>Tile ${label}</b><span>${scaleLabel}${joins ? ' · ' + joins : ''} · overlap ${OVERLAP} mm</span></div>
<svg width="${availW}mm" height="${availH}mm" viewBox="${vx} ${vy} ${availW} ${availH}"
style="margin:${MARGIN + 4}mm ${MARGIN}mm ${MARGIN - 4}mm">
${inner}${cm}
</svg>
</div>`);
}
}
document.getElementById('pgCount').textContent =
`${pages.children.length} page${pages.children.length === 1 ? '' : 's'} · plan ${ex.w}×${ex.h} cm`;
}
build();
</script>
</body></html>