255 lines
14 KiB
HTML
255 lines
14 KiB
HTML
<!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', table: true, 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>
|
||
${['table','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(); });
|
||
['table','anchors','buildings','spawns','paths'].forEach(k =>
|
||
document.getElementById('ly-' + k).onchange = e => { opts[k] = e.target.checked; build(); });
|
||
|
||
// ---------- geometry ----------
|
||
function extent() {
|
||
let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
|
||
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);
|
||
};
|
||
const t = scene.table;
|
||
if (t && t.show !== false) { eat(t.offsetX || 0, t.offsetZ || 0, 6); eat((t.offsetX || 0) + t.width, (t.offsetZ || 0) + t.depth, 6); }
|
||
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]);
|
||
// nothing to draw → fall back to a small default page area
|
||
if (!isFinite(minX)) { minX = 0; minZ = 0; maxX = 100; maxZ = 100; }
|
||
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.table && scene.table && scene.table.show !== false) {
|
||
const t = scene.table;
|
||
const x0 = t.offsetX || 0, z0 = t.offsetZ || 0;
|
||
const tx = X(x0), ty = Y(z0 + t.depth), tw = t.width * S, th = t.depth * S;
|
||
g += `<rect x="${px(tx)}" y="${px(ty)}" width="${px(tw)}" height="${px(th)}"
|
||
fill="none" stroke="#e67e00" stroke-width="0.7"/>`;
|
||
// dimension labels with arrows: width along the front (bottom) edge, depth along the left edge
|
||
g += `<text x="${px(tx + tw / 2)}" y="${px(ty + th + 5)}" font-size="4" text-anchor="middle" fill="#e67e00" font-weight="bold">← ${t.width} cm →</text>`;
|
||
g += `<text x="${px(tx - 3)}" y="${px(ty + th / 2)}" font-size="4" text-anchor="middle" fill="#e67e00" font-weight="bold"
|
||
transform="rotate(-90 ${px(tx - 3)} ${px(ty + th / 2)})">← ${t.depth} cm →</text>`;
|
||
g += `<text x="${px(tx + tw / 2)}" y="${px(ty - 2)}" font-size="3" text-anchor="middle" fill="#e67e00">TABLE ${t.width} × ${t.depth} cm — back edge (N)</text>`;
|
||
}
|
||
|
||
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>
|