Skip to content

Commit dc3c06b

Browse files
jeremymanningclaude
andcommitted
Merge feature/003-print-mode-ux-fixes
Print Mode UX improvements + 3D preview based on user feedback after the initial 002 release. Issues fixed: - #1 Compute/PDF error surfacing (sticky toast, console.error stack) - #2 Explicit Add Window/Door/Closet buttons in feature panel - #3 Location search via dedicated print-mode map picker - #4 Numeric height/width/sill/horizontal-position inputs for features - #5 Shift-drag snaps to 90° axis - #6 Cmd/Ctrl-click multi-vertex select + drag together Plus 4 follow-on items: - Skip blank tiles (reverses original FR-014 — pages were too many) - 2× hole diameters (12/8/5/2 mm — reads as actual stars at room scale) - Remove star labels from printouts (cluttered the stencil) - 3D preview tab — drag-to-rotate room view from observer's eye showing numbered paper sheets + projected star positions Verification: - 228 vitest tests pass - 14 print-mode chromium e2e tests pass - Build payload OK Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 parents 46e355c + a9fc30c commit dc3c06b

21 files changed

Lines changed: 2833 additions & 51 deletions

src/print/pdf-builder.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,19 @@ export function buildPdf(
293293
const holes = holesByTile.get(key) ?? [];
294294
const featureCutouts = featuresByTile.get(key) ?? [];
295295
const constellationSegments = segmentsByTile.get(key) ?? [];
296+
// Skip blank tiles — those that contain zero stars, zero
297+
// feature cutouts, AND zero constellation-line segments.
298+
// (FR-014 originally required emitting them; user feedback
299+
// says page counts blow up — at 12 ft × 12 ft / Letter the
300+
// ceiling alone is ~220 pages, with most blank near the
301+
// horizon-far corners. Page numbers in the printed PDF stay
302+
// contiguous because we increment pageNumber AFTER the skip
303+
// decision; surface row/col labels still convey position.)
304+
const isBlank =
305+
holes.length === 0 &&
306+
featureCutouts.length === 0 &&
307+
constellationSegments.length === 0;
308+
if (isBlank) continue;
296309
const tile: Tile = {
297310
surfaceId: surface.id,
298311
row: r,

src/print/preflight.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
deriveSurfaces,
2828
projectBodyOntoSurface,
2929
} from "./projection";
30-
import { computeTileGrid } from "./tile-grid";
30+
import { assignHolesToTiles, computeTileGrid } from "./tile-grid";
3131
import {
3232
classifyMagnitude,
3333
type Hole,
@@ -126,25 +126,74 @@ export function computePreflightSummary(
126126
});
127127
}
128128

129-
// Tile-page counter: every enabled surface contributes rows × cols
130-
// pages, even blank ones (FR-014, SC-013).
129+
// Tile-page counter: count only NON-BLANK tiles. A tile is non-blank
130+
// if it contains at least one hole OR overlaps a no-paint feature.
131+
// (Per user feedback, FR-014's "blank tiles still emitted" rule was
132+
// reversed — page counts at room-scale × paper-size were too high.)
131133
let tilePageCount = 0;
132134
// Per-class hole tallies across all enabled surfaces.
133135
const counts: Record<SizeClass, number> = { pencil: 0, largeNail: 0, smallNail: 0, pin: 0 };
134136
let totalHoles = 0;
135137

136138
for (const surface of surfaces) {
137139
const grid = computeTileGrid(surface, job.outputOptions.paper);
138-
tilePageCount += grid.rows * grid.cols;
140+
const { rows, cols, cellWidthMm, cellHeightMm } = grid;
141+
// Build the hole list for this surface using the same projection
142+
// logic the builder uses. Then run the hole list through
143+
// assignHolesToTiles so the same FR-012 ½″ split-window applies —
144+
// this guarantees SC-008 (preflight count == actual page count).
145+
const holesForSurface: Array<{
146+
surfaceUMm: number;
147+
surfaceVMm: number;
148+
sizeClass: SizeClass;
149+
label: string;
150+
bodyKind: "star" | "planet" | "sun" | "moon";
151+
apparentMag: number;
152+
}> = [];
139153
for (const body of projectable) {
140154
const hits = projectBodyForSurface(body.altDeg, body.azDeg, surface, observerPosMm);
141-
for (const _hit of hits) {
155+
for (const hit of hits) {
142156
const cls = classifyMagnitude(body.mag);
143157
if (cls === null) continue;
144158
counts[cls] += 1;
145159
totalHoles += 1;
160+
holesForSurface.push({
161+
surfaceUMm: hit.uMm,
162+
surfaceVMm: hit.vMm,
163+
sizeClass: cls,
164+
label: body.label,
165+
bodyKind: body.bodyKind,
166+
apparentMag: body.mag,
167+
});
146168
}
147169
}
170+
const holeBins = assignHolesToTiles(holesForSurface, surface, grid);
171+
// No-paint features on this surface also keep their overlapping
172+
// tiles non-blank (the dotted cut line still needs to be drawn).
173+
const featuresOnSurface = job.room.features.filter(
174+
(f) => f.surfaceId === surface.id && !f.paint,
175+
);
176+
const occupied = new Set<string>(holeBins.keys());
177+
for (const feat of featuresOnSurface) {
178+
let uMin = Infinity, uMax = -Infinity, vMin = Infinity, vMax = -Infinity;
179+
for (const p of feat.outline) {
180+
if (p.uMm < uMin) uMin = p.uMm;
181+
if (p.uMm > uMax) uMax = p.uMm;
182+
if (p.vMm < vMin) vMin = p.vMm;
183+
if (p.vMm > vMax) vMax = p.vMm;
184+
}
185+
if (!Number.isFinite(uMin)) continue;
186+
const c0 = Math.max(0, Math.floor(uMin / cellWidthMm));
187+
const c1 = Math.min(cols - 1, Math.floor(uMax / cellWidthMm));
188+
const r0 = Math.max(0, Math.floor(vMin / cellHeightMm));
189+
const r1 = Math.min(rows - 1, Math.floor(vMax / cellHeightMm));
190+
for (let r = r0; r <= r1; r++) {
191+
for (let c = c0; c <= c1; c++) {
192+
occupied.add(`${r},${c}`);
193+
}
194+
}
195+
}
196+
tilePageCount += occupied.size;
148197
}
149198

150199
const surfaceCount = surfaces.length;

src/print/tile-page.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,6 @@ import {
3131
type Tile,
3232
} from "./types";
3333

34-
/** Magnitude cutoff for emitting a small label under a hole on the tile. */
35-
const BRIGHT_LABEL_MAG_CUTOFF = 2.0;
36-
3734
/** Length of each leg of the corner alignment L-marks. */
3835
const ALIGN_MARK_LEG_MM = 6;
3936

@@ -210,12 +207,9 @@ export function emitTilePage(
210207
// Thin outer ring for B&W contrast (R9).
211208
doc.setDrawColor("0");
212209
doc.circle(cx, cy, radius + 0.25, "S");
213-
214-
// Optional small label under bright-magnitude holes.
215-
if (h.apparentMag <= BRIGHT_LABEL_MAG_CUTOFF) {
216-
doc.setFontSize(6);
217-
doc.text(h.label, cx + radius + 0.5, cy + radius);
218-
}
210+
// Star labels intentionally omitted from printouts (per user
211+
// feedback): names clutter the stencil and aren't useful when the
212+
// user is cutting the holes by hand.
219213
}
220214

221215
// ---- 6. Accessibility annotations (FR-021) ------------------------------

src/print/types.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,18 @@ export interface Vec3 {
1818
/** Hole-size encoding per FR-011 / R4 (cover-page legend = calibration). */
1919
export type SizeClass = "pencil" | "largeNail" | "smallNail" | "pin";
2020

21-
/** Printed diameter (mm) per size class. */
21+
/**
22+
* Printed diameter (mm) per size class. Doubled from the original
23+
* R4 spec values per user feedback: at the original 6/4/2.5/1 mm
24+
* sizes the printed holes were too small to read at typical room
25+
* scale (8 ft ceiling, 12 ft room → eye-to-ceiling distance feels
26+
* like a pinhole-projector). 12/8/5/2 mm reads as actual stars.
27+
*/
2228
export const HOLE_DIAMETERS_MM: Record<SizeClass, number> = {
23-
pencil: 6,
24-
largeNail: 4,
25-
smallNail: 2.5,
26-
pin: 1,
29+
pencil: 12,
30+
largeNail: 8,
31+
smallNail: 5,
32+
pin: 2,
2733
};
2834

2935
/**

src/styles.css

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,3 +1293,84 @@ body.red-light-active {
12931293
}
12941294
}
12951295

1296+
/* --- Tab strip (Edit room / 3D preview) ----------------------------- */
1297+
1298+
.print-mode-tab-strip {
1299+
display: flex;
1300+
gap: 4px;
1301+
margin-bottom: 8px;
1302+
border-bottom: 1px solid rgba(220, 228, 240, 0.12);
1303+
}
1304+
1305+
.print-mode-tab {
1306+
background: transparent;
1307+
color: var(--fg-muted);
1308+
border: 1px solid transparent;
1309+
border-bottom: none;
1310+
padding: 6px 12px;
1311+
font-family: var(--font-sans);
1312+
font-size: 13px;
1313+
cursor: pointer;
1314+
border-radius: 4px 4px 0 0;
1315+
}
1316+
1317+
.print-mode-tab:hover {
1318+
color: var(--fg);
1319+
background: rgba(240, 192, 64, 0.06);
1320+
}
1321+
1322+
.print-mode-tab-active {
1323+
color: var(--fg);
1324+
background: var(--accent-active);
1325+
border-color: rgba(240, 192, 64, 0.4);
1326+
}
1327+
1328+
.print-mode-tab-panel {
1329+
display: block;
1330+
}
1331+
1332+
.print-mode-tab-panel[hidden] {
1333+
display: none;
1334+
}
1335+
1336+
/* --- 3D preview ----------------------------------------------------- */
1337+
1338+
.print-mode-preview-3d-panel {
1339+
display: flex;
1340+
flex-direction: column;
1341+
gap: 8px;
1342+
}
1343+
1344+
.print-mode-preview-3d-toolbar {
1345+
display: flex;
1346+
align-items: center;
1347+
gap: 8px;
1348+
flex-wrap: wrap;
1349+
}
1350+
1351+
.print-mode-preview-3d-status {
1352+
font-size: 12px;
1353+
color: var(--fg-muted);
1354+
}
1355+
1356+
.print-mode-preview-3d-svg {
1357+
display: block;
1358+
width: 100%;
1359+
max-width: 100%;
1360+
height: auto;
1361+
background: var(--bg-night);
1362+
border: 1px solid rgba(220, 228, 240, 0.12);
1363+
border-radius: 4px;
1364+
cursor: grab;
1365+
user-select: none;
1366+
-webkit-user-select: none;
1367+
}
1368+
1369+
.print-mode-preview-3d-svg:active {
1370+
cursor: grabbing;
1371+
}
1372+
1373+
.print-mode-preview-3d-hole {
1374+
filter: drop-shadow(0 0 1.5px rgba(255, 255, 255, 0.6));
1375+
}
1376+

src/ui/print-mode/compute-progress.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,17 +154,38 @@ export function mountComputeButton(host: HTMLElement, register: RegisterRefresh)
154154
}
155155
}
156156

157+
let errorClearTimer: ReturnType<typeof setTimeout> | null = null;
158+
157159
function showError(msg: string): void {
158160
status.textContent = msg;
159161
status.classList.add("print-mode-status-error");
162+
// Issue #1 — keep the error visible long enough to read & paste into a
163+
// bug report. Previously the status was wiped out by the next refresh
164+
// tick within ~4 s; we now hold it for 12 s before allowing clearStatus.
165+
if (errorClearTimer !== null) clearTimeout(errorClearTimer);
166+
errorClearTimer = setTimeout(() => {
167+
errorClearTimer = null;
168+
}, 12_000);
160169
}
161170

162171
function clearStatus(): void {
172+
// Don't clobber an error toast while it is still in its sticky window.
173+
if (errorClearTimer !== null) return;
163174
status.textContent = "";
164175
status.classList.remove("print-mode-status-error");
165176
}
166177

167178
function attachPdf(pdf: PdfBlob): void {
179+
// Issue #1 — surface generation failures explicitly. If the builder
180+
// returned an empty Blob (jsPDF internal failure) or both objectUrl
181+
// and blob are unusable, the user previously saw a Download button
182+
// that did nothing. Throw so the surrounding try/catch can present
183+
// the error.
184+
if (!pdf || !pdf.blob || pdf.blob.size === 0) {
185+
throw new Error(
186+
"PDF builder returned an empty document (Blob size = 0). This usually means jsPDF failed to render at least one tile.",
187+
);
188+
}
168189
// Revoke previous URL to avoid leaking blob memory across re-computes.
169190
if (lastPdf && lastPdf.objectUrl) {
170191
try {
@@ -186,13 +207,18 @@ export function mountComputeButton(host: HTMLElement, register: RegisterRefresh)
186207
url = "";
187208
}
188209
}
210+
if (!url) {
211+
throw new Error(
212+
"PDF was generated but could not be exposed as a downloadable URL (URL.createObjectURL unavailable).",
213+
);
214+
}
189215
// Set both the property and the attribute so `getAttribute('href')`
190216
// works in every test environment (some Playwright contexts read the
191217
// attribute, not the property).
192218
downloadBtn.href = url;
193219
downloadBtn.setAttribute("href", url);
194220
downloadBtn.hidden = false;
195-
status.textContent = `PDF ready ${formatNumber(pdf.pageCount)} pages.`;
221+
status.textContent = `PDF ready - ${formatNumber(pdf.pageCount)} pages.`;
196222
// Sync `lastJobSnapshot` to the current job. Otherwise the next
197223
// `refresh()` (fired by the persistence-debounce of the very
198224
// setObservation calls that happened during Compute) treats the
@@ -238,9 +264,15 @@ export function mountComputeButton(host: HTMLElement, register: RegisterRefresh)
238264
const pdf = await mod.buildPdf(job, skyDatasets, bodies);
239265
attachPdf(pdf);
240266
} catch (err) {
267+
// Issue #1 — log the stack trace so users can paste it in a bug
268+
// report. The status toast itself stays compact (just `message`).
241269
// eslint-disable-next-line no-console
242-
console.error("print-mode: compute failed", err);
243-
showError(err instanceof Error ? `Compute failed: ${err.message}` : "Compute failed.");
270+
console.error("print-mode: compute failed", err, err instanceof Error ? err.stack : "");
271+
const msg =
272+
err instanceof Error && err.message
273+
? `Compute failed: ${err.message}`
274+
: "Compute failed (see browser console for details).";
275+
showError(msg);
244276
} finally {
245277
setBusy(false);
246278
}

0 commit comments

Comments
 (0)