Skip to content

Commit 779cf9c

Browse files
jeremymanningclaude
andcommitted
fix(print): correct 3D rendering of wall features + face-a-wall default
Three fixes for the 3D preview: 1. Near-plane projection bug: projectPoint culled at camZ <= 1 (mm), but values like camZ = 1.5 still produce NDC like (camX*1.73 / 1.5) that map to screen coords thousands of pixels off-canvas. When one endpoint of a line projects normally and the other lands in this degenerate zone, the rendered line stretches across half the viewport — the user reported wall features appearing as huge garbage lines. Bumped the near plane to 50 mm; nothing in a normal room ever sits inside that volume. 2. Holes inside no-paint cutouts: pdf-builder.ts § 4 already drops stars whose centres fall inside a no-paint feature (FR-013). The 3D preview wasn't applying the same exclusion, so windows showed star dots inside them — misleading because those holes won't print. Added a local pointInPolygonUV mirroring the pdf-builder helper and filter holes before binning, so the preview matches the PDF. 3. Default camera now faces a wall instead of looking straight up at the ceiling. Picks the first ENABLED wall (or wall-0 as fallback) and computes yaw from its midpoint relative to the observer; pitch is 0 (horizon). The user can immediately see what their stencil looks like on a wall — including window/door/closet placement — without having to drag through 90° of pitch first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e9dc1cb commit 779cf9c

1 file changed

Lines changed: 82 additions & 7 deletions

File tree

src/ui/print-mode/preview-3d.ts

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,30 @@ import type { RegisterRefresh } from "./print-mode";
5454
const SVG_NS = "http://www.w3.org/2000/svg";
5555
const DEG2RAD = Math.PI / 180;
5656

57+
/**
58+
* Even-odd point-in-polygon test on (uMm, vMm). Mirrors the helper in
59+
* pdf-builder.ts so the 3D preview applies the SAME no-paint exclusion
60+
* to holes that the PDF builder does.
61+
*/
62+
function pointInPolygonUV(
63+
u: number,
64+
v: number,
65+
poly: ReadonlyArray<{ uMm: number; vMm: number }>,
66+
): boolean {
67+
let inside = false;
68+
const n = poly.length;
69+
for (let i = 0, j = n - 1; i < n; j = i++) {
70+
const a = poly[i];
71+
const b = poly[j];
72+
if (!a || !b) continue;
73+
const intersects =
74+
a.vMm > v !== b.vMm > v &&
75+
u < ((b.uMm - a.uMm) * (v - a.vMm)) / (b.vMm - a.vMm + 1e-15) + a.uMm;
76+
if (intersects) inside = !inside;
77+
}
78+
return inside;
79+
}
80+
5781
// ---------------------------------------------------------------------------
5882
// Dataset cache (shared with the compute panel pattern, but module-local).
5983
// ---------------------------------------------------------------------------
@@ -250,13 +274,33 @@ function buildSceneFromJob(
250274
}
251275
}
252276

277+
// FR-013 mirror: drop any hole whose centre falls inside a
278+
// no-paint feature on this surface. The PDF builder does the same
279+
// exclusion (pdf-builder.ts § 4) so the 3D preview must match —
280+
// otherwise stars appear inside windows/doors and the user can't
281+
// tell that the cutout will mask them out.
282+
const noPaintFeatures = job.room.features.filter(
283+
(f) => f.surfaceId === surface.id && f.paint === false,
284+
);
285+
const keptHoles =
286+
noPaintFeatures.length === 0
287+
? holes
288+
: holes.filter((h) => {
289+
for (const f of noPaintFeatures) {
290+
if (pointInPolygonUV(h.surfaceUMm, h.surfaceVMm, f.outline)) {
291+
return false;
292+
}
293+
}
294+
return true;
295+
});
296+
253297
// Bin holes into tiles using the same row/col floor-division as
254298
// assignHolesToTiles. We DON'T need the edge-tolerance multi-tile
255299
// duplication here — the preview only needs to know "how many
256300
// sheets do I print, what's on each one" — so primary tile only.
257301
const { rows, cols, cellWidthMm, cellHeightMm } = grid;
258302
const holesByTile = new Map<string, Hole[]>();
259-
for (const h of holes) {
303+
for (const h of keptHoles) {
260304
if (h.surfaceUMm < 0 || h.surfaceUMm > surface.widthMm) continue;
261305
if (h.surfaceVMm < 0 || h.surfaceVMm > surface.heightMm) continue;
262306
const c = Math.min(Math.max(Math.floor(h.surfaceUMm / cellWidthMm), 0), cols - 1);
@@ -325,12 +369,38 @@ interface Camera {
325369
}
326370

327371
function makeInitialCamera(job: PrintJob): Camera {
372+
// Default to facing the FIRST enabled wall (or wall-0 if none enabled
373+
// yet) at horizon pitch. This is more useful than looking straight up
374+
// at the ceiling: the user can immediately see what their stencil
375+
// will look like on a wall, including any windows/doors they placed.
376+
// Yaw is computed from the wall's midpoint relative to the observer.
377+
const obs = job.room.observerPositionMm;
378+
let yaw = 0;
379+
const surfaces = deriveSurfaces(
380+
job.room,
381+
job.outputOptions.blockHorizonOnWalls,
382+
);
383+
const walls = surfaces.filter((s) => s.kind === "wall");
384+
const targetWall = walls.find((s) => s.enabled) ?? walls[0];
385+
if (targetWall) {
386+
// Midpoint of the wall = origin + uAxis * (widthMm / 2).
387+
const u = targetWall.originPose.uAxisMm;
388+
const o = targetWall.originPose.originMm;
389+
const midX = o.x + u.x * (targetWall.widthMm / 2);
390+
const midY = o.y + u.y * (targetWall.widthMm / 2);
391+
const dx = midX - obs.xMm;
392+
const dy = midY - obs.yMm;
393+
// Yaw is measured from +y (north) clockwise toward +x (east).
394+
// forward = (sin(yaw), cos(yaw)) when pitch=0, so we want
395+
// sin(yaw) = dx/r, cos(yaw) = dy/r.
396+
yaw = Math.atan2(dx, dy);
397+
}
328398
return {
329-
posX: job.room.observerPositionMm.xMm,
330-
posY: job.room.observerPositionMm.yMm,
331-
posZ: job.room.observerPositionMm.eyeHeightMm,
332-
yaw: 0,
333-
pitch: Math.PI / 2 - 0.001, // looking straight up (avoid singularity)
399+
posX: obs.xMm,
400+
posY: obs.yMm,
401+
posZ: obs.eyeHeightMm,
402+
yaw,
403+
pitch: 0,
334404
fovY: 60 * DEG2RAD,
335405
};
336406
}
@@ -378,7 +448,12 @@ function projectPoint(
378448
const camY = dx * ux + dy * uy + dz * uz;
379449
const camZ = dx * fx + dy * fy + dz * fz;
380450

381-
if (camZ <= 1) return null; // behind / too close
451+
// Near-plane: 50 mm in front of camera. Below this, projection
452+
// produces extreme NDC values (camX*f / camZ blows up) which renders
453+
// as wildly off-screen lines that "wrap around" visually. 50 mm is
454+
// generous enough that no in-room geometry should ever land inside
455+
// the eye position.
456+
if (camZ <= 50) return null;
382457

383458
const f = 1 / Math.tan(cam.fovY / 2);
384459
const ndcX = (camX * f) / aspect / camZ;

0 commit comments

Comments
 (0)