Add placement plan printer: to-scale tiled plan with true-size crest patterns, crop marks, compass

This commit is contained in:
2026-07-22 17:01:16 +10:00
parent 1814125243
commit 4c55f5a567
+237
View File
@@ -0,0 +1,237 @@
<!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>