feat(export): tighter 3D framing, readable projection fonts, cleaner per-wall filters

- Drop file size: jsPDF compression + 3D page encoded as JPEG (0.95) on a
  white composite canvas (no more black background), PDF pixelRatio=1.
- Tighten 3D export framing: new 'birds-eye-export' preset derives camera
  height from Three.js vertical FOV at the wall-top plane so the room
  fills the page without the interactive preset's extra headroom.
- Thread canvas + preset trigger through Room3DView props so the export
  dialog can always find the live canvas and snap the camera even when
  the view was lazy-mounted by the dialog itself.
- 2D capture on PDF uses an A4-landscape-matched aspect (1820x1200) so
  the image fills the page box without large empty bands.
- Projection exports: per-wall hidden renderer mounts every wall at
  native aspect with a right/bottom overflow budget for label headroom;
  fontScale prop (passed as 2 from the export block) keeps Konva text
  legible at high export resolution without affecting interactive view.
- Filter out annotations attached to electrical items from the 2D view
  and exported images (they belong to the wall-projection context).
- Projection filters: assign furniture and orphan electrical items to a
  single 'home' wall (minimum edge / perpendicular distance) so a corner
  item stops double-projecting onto both neighbouring walls; stale
  wallIds fall back to the proximity path instead of being dropped.
- Annotate tool works in projection view: clicking creates a wall-
  anchored annotation rendered next to the wall at the click position.
- Outlet projection coord labels now show 3 decimal places.
- PDF page titles use a Unicode font (DejaVu Sans) so Cyrillic renders
  correctly; wall projections get one dedicated landscape page each at
  native aspect; pdf aspect-preserving addImage helper centres images
  horizontally and top-aligns vertically.
This commit is contained in:
2026-04-14 21:00:39 +03:00
parent 68d2b5e3b0
commit 5fbd382120
10 changed files with 400 additions and 108 deletions
@@ -21,9 +21,16 @@ import type { CameraPreset } from './three/CameraControls';
// Pixel density and padding used by the hidden projection renderers that // Pixel density and padding used by the hidden projection renderers that
// populate the PDF export. Sized so each wall's stage matches its native // populate the PDF export. Sized so each wall's stage matches its native
// aspect ratio (no vertical empty space inside the captured PNG). // aspect ratio (no vertical empty space inside the captured PNG). The
// extra ``OVERFLOW`` budget keeps annotations that end up just outside the
// wall rectangle (typical for labels next to outlets near a corner) inside
// the captured stage — WallProjectionView's projectionToPixel places
// content at (padding, padding), so the overflow budget sits on the right
// and bottom edges and is picked up by stage.toDataURL.
const EXPORT_PX_PER_M = 400; const EXPORT_PX_PER_M = 400;
const EXPORT_PROJ_PADDING = 80; const EXPORT_PROJ_PADDING = 80;
const EXPORT_PROJ_OVERFLOW_RIGHT_PX = 600; // ~1.5 m of label headroom
const EXPORT_PROJ_OVERFLOW_BOTTOM_PX = 240; // ~0.6 m of label headroom
const Room3DView = lazy(() => const Room3DView = lazy(() =>
import('./three/Room3DView').then((m) => ({ default: m.Room3DView })), import('./three/Room3DView').then((m) => ({ default: m.Room3DView })),
@@ -797,6 +804,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
{mount3D && ( {mount3D && (
<div <div
className={styles.canvasContainer} className={styles.canvasContainer}
data-export-3d-host
style={ style={
viewMode !== '3d' viewMode !== '3d'
? { ? {
@@ -810,18 +818,9 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
} }
: undefined : undefined
} }
ref={(el) => {
// Grab the R3F canvas element for 3D export
if (el) {
const canvas = el.querySelector('canvas');
threeCanvasRef.current = canvas;
} else {
threeCanvasRef.current = null;
}
}}
> >
<Suspense fallback={<div className={styles.loading3D}>{t('editor.loading3D')}</div>}> <Suspense fallback={<div className={styles.loading3D}>{t('editor.loading3D')}</div>}>
<Room3DView presetTriggerRef={preset3DRef} /> <Room3DView presetTriggerRef={preset3DRef} canvasRef={threeCanvasRef} />
</Suspense> </Suspense>
</div> </div>
)} )}
@@ -873,12 +872,19 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
{state.walls.map((wall) => { {state.walls.map((wall) => {
const len = computeWallLength(wall); const len = computeWallLength(wall);
if (len <= 0) return null; if (len <= 0) return null;
const w = Math.round(len * EXPORT_PX_PER_M + EXPORT_PROJ_PADDING); const w = Math.round(
const h = Math.round(state.room.wallHeight * EXPORT_PX_PER_M + EXPORT_PROJ_PADDING); len * EXPORT_PX_PER_M + EXPORT_PROJ_PADDING + EXPORT_PROJ_OVERFLOW_RIGHT_PX,
);
const h = Math.round(
state.room.wallHeight * EXPORT_PX_PER_M +
EXPORT_PROJ_PADDING +
EXPORT_PROJ_OVERFLOW_BOTTOM_PX,
);
return ( return (
<div key={`export-proj-${wall.id}`} style={{ width: `${w}px`, height: `${h}px` }}> <div key={`export-proj-${wall.id}`} style={{ width: `${w}px`, height: `${h}px` }}>
<WallProjectionView <WallProjectionView
wall={wall} wall={wall}
walls={state.walls}
openings={state.openings} openings={state.openings}
electricalItems={state.layerVisibility.electrical ? state.electricalItems : []} electricalItems={state.layerVisibility.electrical ? state.electricalItems : []}
furnitureItems={state.layerVisibility.furniture ? state.furnitureItems : []} furnitureItems={state.layerVisibility.furniture ? state.furnitureItems : []}
@@ -898,6 +904,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
width={w} width={w}
height={h} height={h}
showMeasurements={state.layerVisibility.measurements} showMeasurements={state.layerVisibility.measurements}
fontScale={2}
/> />
</div> </div>
); );
@@ -58,28 +58,73 @@ export function ExportDialog({
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
/**
* Build the set of points used to compute the 2D fit view. Includes the
* room polygon plus every annotation's anchor point (with a small buffer
* for its text) so annotations placed outside the room boundary stay
* visible in exported views.
*/
const buildFitPoints = useCallback(() => {
// Include annotation anchor points so annotations placed outside the
// room polygon don't get clipped. Skip annotations attached to
// electrical items — they belong to the wall-projection view and are
// hidden on the 2D view, so we don't need to reserve space for them.
const electricalIds = new Set(state.electricalItems.map((e) => e.id));
const pts: { x: number; y: number }[] = state.room.shape.map((p) => ({ x: p.x, y: p.y }));
for (const ann of state.annotations) {
if (ann.attachedToId && electricalIds.has(ann.attachedToId)) continue;
pts.push({ x: ann.x, y: ann.y });
}
return pts;
}, [state.room.shape, state.annotations, state.electricalItems]);
/** /**
* Capture the main 2D Konva stage with the fit-to-room view applied. * Capture the main 2D Konva stage with the fit-to-room view applied.
* Saves the user's current zoom/pan, dispatches the computed fit view, * Saves the user's current zoom/pan, dispatches the computed fit view,
* waits for layers to re-render, captures, then restores. * waits for layers to re-render, captures, then restores.
*/ */
const captureMainStageAtFit = useCallback( const captureMainStageAtFit = useCallback(
async (renderGrid: boolean): Promise<string | null> => { async (
renderGrid: boolean,
pixelRatioOverride?: number,
/** When true, force the capture stage to an A4-landscape-matched
* aspect (1.517:1) so the exported image fills the PDF image box
* without height-cropping or leaving empty bands above/below. */
matchPdfAspect = false,
): Promise<string | null> => {
const stage = mainStageRef.current; const stage = mainStageRef.current;
if (!stage) return null; if (!stage) return null;
if (state.room.shape.length === 0) return null; if (state.room.shape.length === 0) return null;
const stageW = stage.width(); const stageW = stage.width();
const stageH = stage.height(); const stageH = stage.height();
// Use a synthetic size if the stage is currently hidden (0×0) or very small. // PDF page image box ≈ 273 × 180 mm on A4 landscape → 1.517:1.
const targetW = stageW > 100 ? stageW : canvasSize?.width && canvasSize.width > 100 ? canvasSize.width : 1600; // Match it to avoid leaving large empty bands in the PDF.
const targetH = stageH > 100 ? stageH : canvasSize?.height && canvasSize.height > 100 ? canvasSize.height : 1200; const PDF_W = 1820;
const PDF_H = 1200;
// Use a synthetic size when the stage is hidden (0×0) or very small.
const fallbackW = matchPdfAspect ? PDF_W : 1600;
const fallbackH = matchPdfAspect ? PDF_H : 1200;
const targetW = matchPdfAspect
? PDF_W
: stageW > 100
? stageW
: canvasSize?.width && canvasSize.width > 100
? canvasSize.width
: fallbackW;
const targetH = matchPdfAspect
? PDF_H
: stageH > 100
? stageH
: canvasSize?.height && canvasSize.height > 100
? canvasSize.height
: fallbackH;
let resized = false; let resized = false;
const container = stage.container(); const container = stage.container();
const parent = container?.parentElement; const parent = container?.parentElement;
const origParentStyle = parent?.getAttribute('style') ?? ''; const origParentStyle = parent?.getAttribute('style') ?? '';
if (stageW === 0 || stageH === 0) { if (matchPdfAspect || stageW === 0 || stageH === 0) {
resized = true; resized = true;
if (parent) { if (parent) {
parent.setAttribute( parent.setAttribute(
@@ -91,7 +136,7 @@ export function ExportDialog({
stage.height(targetH); stage.height(targetH);
} }
const fit = computeFitView(state.room.shape, targetW, targetH); const fit = computeFitView(buildFitPoints(), targetW, targetH);
if (!fit) { if (!fit) {
if (resized) { if (resized) {
stage.width(stageW); stage.width(stageW);
@@ -107,7 +152,10 @@ export function ExportDialog({
await waitFrames(3); await waitFrames(3);
stage.batchDraw(); stage.batchDraw();
const dataUrl = exportKonvaStageToDataUrl(stage, { pixelRatio, includeGrid: renderGrid }); const dataUrl = exportKonvaStageToDataUrl(stage, {
pixelRatio: pixelRatioOverride ?? pixelRatio,
includeGrid: renderGrid,
});
dispatch({ type: 'SET_VIEW', zoom: savedZoom, offset: savedOffset }); dispatch({ type: 'SET_VIEW', zoom: savedZoom, offset: savedOffset });
if (resized) { if (resized) {
@@ -117,24 +165,33 @@ export function ExportDialog({
} }
return dataUrl; return dataUrl;
}, },
[mainStageRef, canvasSize, state.room.shape, state.zoom, state.panOffset, dispatch, pixelRatio], [mainStageRef, canvasSize, state.room.shape, state.zoom, state.panOffset, dispatch, pixelRatio, buildFitPoints],
); );
/** Capture the 3D canvas with the bird's-eye (top-down) preset applied. */ /**
const capture3DBirdsEye = useCallback(async (): Promise<string | null> => { * Capture the 3D canvas with a tight top-down framing. `tightFraming`
const trigger = preset3DRef.current; * picks the export-only preset that fills the frame with the room instead
if (trigger) { * of the loose default birds-eye used by the interactive view.
trigger('birds-eye'); * `jpegForPdf` encodes as JPEG — photographic 3D renders compress an
// Wait long enough for the preset to settle and one full render pass. * order of magnitude smaller than PNG with little visible quality loss.
await waitFrames(12); */
} const capture3DBirdsEye = useCallback(
let canvas = threeCanvasRef.current; async (opts: { tightFraming?: boolean; jpegForPdf?: boolean } = {}): Promise<string | null> => {
if (!canvas) { const trigger = preset3DRef.current;
canvas = document.querySelector('canvas[data-engine]') as HTMLCanvasElement | null; if (trigger) {
} trigger(opts.tightFraming ? 'birds-eye-export' : 'birds-eye');
if (!canvas || canvas.width === 0 || canvas.height === 0) return null; // Wait long enough for the preset to settle and one full render pass.
return exportThreeCanvasToDataUrl(canvas); await waitFrames(12);
}, [preset3DRef, threeCanvasRef]); }
let canvas = threeCanvasRef.current;
if (!canvas) {
canvas = document.querySelector('canvas[data-engine]') as HTMLCanvasElement | null;
}
if (!canvas || canvas.width === 0 || canvas.height === 0) return null;
return exportThreeCanvasToDataUrl(canvas, opts.jpegForPdf ? 'image/jpeg' : 'image/png', 0.85);
},
[preset3DRef, threeCanvasRef],
);
/** /**
* Capture every wall projection as a high-resolution image. Uses the * Capture every wall projection as a high-resolution image. Uses the
@@ -143,7 +200,7 @@ export function ExportDialog({
* Waits briefly on the first call so the hidden render pass can settle. * Waits briefly on the first call so the hidden render pass can settle.
*/ */
const captureAllProjections = useCallback( const captureAllProjections = useCallback(
async (): Promise<{ label: string; dataUrl: string }[]> => { async (pixelRatioOverride?: number): Promise<{ label: string; dataUrl: string }[]> => {
const out: { label: string; dataUrl: string }[] = []; const out: { label: string; dataUrl: string }[] = [];
const projMap = exportProjectionStageRefs.current; const projMap = exportProjectionStageRefs.current;
if (!projMap) return out; if (!projMap) return out;
@@ -155,12 +212,13 @@ export function ExportDialog({
} }
await waitFrames(2); await waitFrames(2);
const ratio = pixelRatioOverride ?? pixelRatio;
for (const wall of walls) { for (const wall of walls) {
const projStage = projMap.get(wall.id); const projStage = projMap.get(wall.id);
if (projStage && projStage.width() > 0 && projStage.height() > 0) { if (projStage && projStage.width() > 0 && projStage.height() > 0) {
projStage.batchDraw(); projStage.batchDraw();
const label = wallDirectionLabel(wall); const label = wallDirectionLabel(wall);
const dataUrl = exportKonvaStageToDataUrl(projStage, { pixelRatio }); const dataUrl = exportKonvaStageToDataUrl(projStage, { pixelRatio: ratio });
out.push({ label, dataUrl }); out.push({ label, dataUrl });
} }
} }
@@ -258,23 +316,39 @@ export function ExportDialog({
const blob = new Blob([jsonStr], { type: 'application/json' }); const blob = new Blob([jsonStr], { type: 'application/json' });
downloadBlob(blob, `${baseName}.json`); downloadBlob(blob, `${baseName}.json`);
} else { } else {
// PDF: always capture 3D birds-eye + 2D default + all projections. // PDF: always capture 3D (tight top-down) + 2D default + all projections.
// Kick 3D render first so it has plenty of frames to settle while we // Room3DView lazy-loads when the export dialog opens, so the preset
// capture the 2D stage. // trigger ref may not exist yet. Poll for it, then snap the camera
preset3DRef.current?.('birds-eye'); // to the export-only tight preset.
for (let i = 0; i < 60 && !preset3DRef.current; i += 1) {
await waitFrames(2);
}
preset3DRef.current?.('birds-eye-export');
const topDownDataUrl = await captureMainStageAtFit(includeGrid); // Source stages are already rendered at 400 px/m — capturing at
const projectionDataUrls = await captureAllProjections(); // pixelRatio=1 still produces ~300-350 DPI on an A4 page and keeps
// the PDF file size an order of magnitude smaller than pixelRatio=2.
const PDF_PIXEL_RATIO = 1;
const topDownDataUrl = await captureMainStageAtFit(includeGrid, PDF_PIXEL_RATIO, true);
const projectionDataUrls = await captureAllProjections(PDF_PIXEL_RATIO);
// Let the 3D view finish applying birds-eye + rendering. // Re-trigger the preset after other captures settle, and poll for a
await waitFrames(8); // valid 3D canvas (it may still be mounting on first export).
let canvas3d = threeCanvasRef.current; preset3DRef.current?.('birds-eye-export');
if (!canvas3d) { await waitFrames(20);
canvas3d = document.querySelector('canvas[data-engine]') as HTMLCanvasElement | null; let canvas3d: HTMLCanvasElement | null = null;
for (let i = 0; i < 60; i += 1) {
canvas3d = threeCanvasRef.current;
if (!canvas3d) {
const host = document.querySelector('[data-export-3d-host]');
canvas3d = host ? (host.querySelector('canvas') as HTMLCanvasElement | null) : null;
}
if (canvas3d && canvas3d.width > 0 && canvas3d.height > 0) break;
await waitFrames(2);
} }
const view3dDataUrl = const view3dDataUrl =
canvas3d && canvas3d.width > 0 && canvas3d.height > 0 canvas3d && canvas3d.width > 0 && canvas3d.height > 0
? exportThreeCanvasToDataUrl(canvas3d) ? exportThreeCanvasToDataUrl(canvas3d, 'image/jpeg', 0.95)
: null; : null;
const pdf = await createRoomPdf(room.name, topDownDataUrl, projectionDataUrls, view3dDataUrl); const pdf = await createRoomPdf(room.name, topDownDataUrl, projectionDataUrls, view3dDataUrl);
@@ -57,11 +57,33 @@ export function exportKonvaStageToDataUrl(
} }
/** /**
* Export a Three.js canvas element to a data URL. * Export a Three.js canvas element to a data URL. Defaults to PNG but
* callers targeting a PDF should pass `'image/jpeg'` with a quality to
* cut file size by 10-20x on photographic 3D renders.
*
* For JPEG (which has no alpha channel), transparent pixels in the source
* canvas would otherwise be filled with the browser's default of black —
* which gives the 3D export a black sky/background. We composite the
* source onto a white offscreen canvas first to avoid that.
*/ */
export function exportThreeCanvasToDataUrl( export function exportThreeCanvasToDataUrl(
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
mimeType: 'image/png' | 'image/jpeg' = 'image/png',
quality = 0.85,
): string { ): string {
if (mimeType === 'image/jpeg') {
const composite = document.createElement('canvas');
composite.width = canvas.width;
composite.height = canvas.height;
const ctx = composite.getContext('2d');
if (ctx) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, composite.width, composite.height);
ctx.drawImage(canvas, 0, 0);
return composite.toDataURL('image/jpeg', quality);
}
return canvas.toDataURL('image/jpeg', quality);
}
return canvas.toDataURL('image/png'); return canvas.toDataURL('image/png');
} }
@@ -156,7 +178,10 @@ export async function createRoomPdf(
view3dDataUrl: string | null, view3dDataUrl: string | null,
): Promise<JsPDFType> { ): Promise<JsPDFType> {
const { jsPDF } = await loadJsPDF(); const { jsPDF } = await loadJsPDF();
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' }); // `compress: true` enables Flate compression for all streams (including
// raster images), which is the single biggest file-size win for PDFs
// dominated by Konva PNG exports.
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4', compress: true });
await applyPdfUnicodeFont(pdf); await applyPdfUnicodeFont(pdf);
const pageWidth = pdf.internal.pageSize.getWidth(); const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight(); const pageHeight = pdf.internal.pageSize.getHeight();
@@ -175,7 +200,7 @@ export async function createRoomPdf(
pdf.text(title, pageWidth / 2, TITLE_Y, { align: 'center' }); pdf.text(title, pageWidth / 2, TITLE_Y, { align: 'center' });
}; };
// ── Page 1: 3D top view ── // ── Page 1: 3D top view (JPEG — far smaller than PNG for photographic 3D) ──
if (view3dDataUrl) { if (view3dDataUrl) {
addTitledPage(roomName, 18); addTitledPage(roomName, 18);
const boxH = pageHeight - IMAGE_TOP - MARGIN; const boxH = pageHeight - IMAGE_TOP - MARGIN;
@@ -68,10 +68,21 @@ export const AnnotationLayer = memo(function AnnotationLayer({
return map; return map;
}, [electricalItems, furnitureItems]); }, [electricalItems, furnitureItems]);
// Annotations attached to electrical items belong to the wall-projection
// context — they clutter the top-down 2D view (and exported images) with
// labels that were authored for per-wall projection layout. Hide them
// here; they still render in WallProjectionView.
const electricalIdSet = useMemo(
() => new Set(electricalItems.map((e) => e.id)),
[electricalItems],
);
const renderedAnnotations = useMemo(() => { const renderedAnnotations = useMemo(() => {
if (!visible) return []; if (!visible) return [];
return annotations; return annotations.filter(
}, [annotations, visible]); (a) => !a.attachedToId || !electricalIdSet.has(a.attachedToId),
);
}, [annotations, visible, electricalIdSet]);
return ( return (
<Group visible={visible}> <Group visible={visible}>
@@ -23,11 +23,14 @@ interface ProjectionMeasurementsProps {
/** When false, the wall-level dimensions/labels are skipped (useful when the /** When false, the wall-level dimensions/labels are skipped (useful when the
* measurements layer is toggled off but per-item overlays should still draw). */ * measurements layer is toggled off but per-item overlays should still draw). */
readonly showWallDimensions?: boolean; readonly showWallDimensions?: boolean;
/** Multiplier applied to every hardcoded fontSize so labels stay legible
* when the parent renders on a large export stage. Default 1. */
readonly fontScale?: number;
} }
/** Dimension line with arrows and text. */ /** Dimension line with arrows and text. */
function DimensionLine({ function DimensionLine({
x1, y1, x2, y2, label, offset, horizontal, lineColor, textColor, x1, y1, x2, y2, label, offset, horizontal, lineColor, textColor, fontScale = 1,
}: { }: {
readonly x1: number; readonly x1: number;
readonly y1: number; readonly y1: number;
@@ -38,6 +41,7 @@ function DimensionLine({
readonly horizontal: boolean; readonly horizontal: boolean;
readonly lineColor: string; readonly lineColor: string;
readonly textColor: string; readonly textColor: string;
readonly fontScale?: number;
}) { }) {
const arrowSize = 4; const arrowSize = 4;
@@ -69,7 +73,7 @@ function DimensionLine({
width={40} width={40}
text={label} text={label}
align="center" align="center"
fontSize={9} fontSize={9 * fontScale}
fill={textColor} fill={textColor}
/> />
</Group> </Group>
@@ -98,7 +102,7 @@ function DimensionLine({
x={lineX + 3} x={lineX + 3}
y={midY - 5} y={midY - 5}
text={label} text={label}
fontSize={9} fontSize={9 * fontScale}
fill={textColor} fill={textColor}
/> />
</Group> </Group>
@@ -121,6 +125,7 @@ export function ProjectionMeasurements({
outletWidth = DEFAULT_OUTLET_WIDTH, outletWidth = DEFAULT_OUTLET_WIDTH,
outletHeight = DEFAULT_OUTLET_HEIGHT, outletHeight = DEFAULT_OUTLET_HEIGHT,
showWallDimensions = true, showWallDimensions = true,
fontScale = 1,
}: ProjectionMeasurementsProps) { }: ProjectionMeasurementsProps) {
const colors = useCanvasColors(); const colors = useCanvasColors();
const elements: ReactNode[] = []; const elements: ReactNode[] = [];
@@ -131,6 +136,7 @@ export function ProjectionMeasurements({
const floorRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding); const floorRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
elements.push( elements.push(
<DimensionLine <DimensionLine
fontScale={fontScale}
key="wall-width" key="wall-width"
x1={floorLeft.x} x1={floorLeft.x}
y1={floorLeft.y} y1={floorLeft.y}
@@ -149,6 +155,7 @@ export function ProjectionMeasurements({
const bottomRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding); const bottomRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
elements.push( elements.push(
<DimensionLine <DimensionLine
fontScale={fontScale}
key="wall-height" key="wall-height"
x1={topRight.x} x1={topRight.x}
y1={topRight.y} y1={topRight.y}
@@ -172,6 +179,7 @@ export function ProjectionMeasurements({
// Height annotation (vertical, left side of opening) // Height annotation (vertical, left side of opening)
elements.push( elements.push(
<DimensionLine <DimensionLine
fontScale={fontScale}
key={`opening-h-${opening.id}`} key={`opening-h-${opening.id}`}
x1={topLeft.x} x1={topLeft.x}
y1={topLeft.y} y1={topLeft.y}
@@ -190,6 +198,7 @@ export function ProjectionMeasurements({
const floorBelow = projectionToPixel(rect.x, 0, wallHeight, scale, padding); const floorBelow = projectionToPixel(rect.x, 0, wallHeight, scale, padding);
elements.push( elements.push(
<DimensionLine <DimensionLine
fontScale={fontScale}
key={`sill-${opening.id}`} key={`sill-${opening.id}`}
x1={bottomLeft.x} x1={bottomLeft.x}
y1={bottomLeft.y} y1={bottomLeft.y}
@@ -208,6 +217,7 @@ export function ProjectionMeasurements({
const topRight2 = projectionToPixel(rect.x + rect.width, rect.y + rect.height, wallHeight, scale, padding); const topRight2 = projectionToPixel(rect.x + rect.width, rect.y + rect.height, wallHeight, scale, padding);
elements.push( elements.push(
<DimensionLine <DimensionLine
fontScale={fontScale}
key={`opening-w-${opening.id}`} key={`opening-w-${opening.id}`}
x1={topLeft.x} x1={topLeft.x}
y1={topLeft.y} y1={topLeft.y}
@@ -253,16 +263,16 @@ export function ProjectionMeasurements({
const displayY = invertY ? wallHeight - pe.elevation : pe.elevation; const displayY = invertY ? wallHeight - pe.elevation : pe.elevation;
const coordLabel = `(${displayX.toFixed(3)}; ${displayY.toFixed(3)})`; const coordLabel = `(${displayX.toFixed(3)}; ${displayY.toFixed(3)})`;
const labelX = center.x + halfWidthPx + 6; const labelX = center.x + halfWidthPx + 6;
const labelY = center.y - 6; const labelY = center.y - 6 * fontScale;
// Rough text-width estimate (monospace-ish): ~5.5px per char at fontSize 9. // Rough text-width estimate (monospace-ish): ~5.5px per char at fontSize 9.
const labelWidth = coordLabel.length * 5.5 + 4; const labelWidth = coordLabel.length * 5.5 * fontScale + 4;
elements.push( elements.push(
<Group key={`elec-coord-${pe.item.id}`}> <Group key={`elec-coord-${pe.item.id}`}>
<Rect <Rect
x={labelX - 2} x={labelX - 2}
y={labelY - 1} y={labelY - 1}
width={labelWidth} width={labelWidth}
height={12} height={12 * fontScale}
fill={colors.coordLabelBg} fill={colors.coordLabelBg}
cornerRadius={2} cornerRadius={2}
listening={false} listening={false}
@@ -271,7 +281,7 @@ export function ProjectionMeasurements({
x={labelX} x={labelX}
y={labelY} y={labelY}
text={coordLabel} text={coordLabel}
fontSize={9} fontSize={9 * fontScale}
fill={colors.coordLabelText} fill={colors.coordLabelText}
listening={false} listening={false}
/> />
@@ -295,7 +305,7 @@ export function ProjectionMeasurements({
x={openingCenter.x - 16} x={openingCenter.x - 16}
y={openingCenter.y - 22} y={openingCenter.y - 22}
text={formatM(rect.x + rect.width / 2)} text={formatM(rect.x + rect.width / 2)}
fontSize={8} fontSize={8 * fontScale}
fill={colors.openingLabel} fill={colors.openingLabel}
align="center" align="center"
width={32} width={32}
@@ -327,6 +337,7 @@ export function ProjectionMeasurements({
const wRightFloor = projectionToPixel(rect.x + rect.width, 0, wallHeight, scale, padding); const wRightFloor = projectionToPixel(rect.x + rect.width, 0, wallHeight, scale, padding);
elements.push( elements.push(
<DimensionLine <DimensionLine
fontScale={fontScale}
key={`furn-w-${item.id}`} key={`furn-w-${item.id}`}
x1={wLeftFloor.x} x1={wLeftFloor.x}
y1={wLeftFloor.y} y1={wLeftFloor.y}
@@ -343,6 +354,7 @@ export function ProjectionMeasurements({
const oLeft = projectionToPixel(0, 0, wallHeight, scale, padding); const oLeft = projectionToPixel(0, 0, wallHeight, scale, padding);
elements.push( elements.push(
<DimensionLine <DimensionLine
fontScale={fontScale}
key={`furn-off-${item.id}`} key={`furn-off-${item.id}`}
x1={oLeft.x} x1={oLeft.x}
y1={oLeft.y} y1={oLeft.y}
@@ -360,6 +372,7 @@ export function ProjectionMeasurements({
// Inline width dimension drawn just below the bottom edge of the item // Inline width dimension drawn just below the bottom edge of the item
elements.push( elements.push(
<DimensionLine <DimensionLine
fontScale={fontScale}
key={`furn-w-${item.id}`} key={`furn-w-${item.id}`}
x1={wLeft.x} x1={wLeft.x}
y1={wLeft.y} y1={wLeft.y}
@@ -379,6 +392,7 @@ export function ProjectionMeasurements({
const oLeft = projectionToPixel(0, rect.y, wallHeight, scale, padding); const oLeft = projectionToPixel(0, rect.y, wallHeight, scale, padding);
elements.push( elements.push(
<DimensionLine <DimensionLine
fontScale={fontScale}
key={`furn-off-${item.id}`} key={`furn-off-${item.id}`}
x1={oLeft.x} x1={oLeft.x}
y1={oLeft.y} y1={oLeft.y}
@@ -403,6 +417,7 @@ export function ProjectionMeasurements({
if (isNearLeft) { if (isNearLeft) {
elements.push( elements.push(
<DimensionLine <DimensionLine
fontScale={fontScale}
key={`furn-h-${item.id}`} key={`furn-h-${item.id}`}
x1={hTop.x} x1={hTop.x}
y1={hTop.y} y1={hTop.y}
@@ -419,6 +434,7 @@ export function ProjectionMeasurements({
const eFloor = projectionToPixel(rect.x, 0, wallHeight, scale, padding); const eFloor = projectionToPixel(rect.x, 0, wallHeight, scale, padding);
elements.push( elements.push(
<DimensionLine <DimensionLine
fontScale={fontScale}
key={`furn-elev-${item.id}`} key={`furn-elev-${item.id}`}
x1={hBottom.x} x1={hBottom.x}
y1={hBottom.y} y1={hBottom.y}
@@ -436,6 +452,7 @@ export function ProjectionMeasurements({
// Inline height dimension drawn just to the left of the item // Inline height dimension drawn just to the left of the item
elements.push( elements.push(
<DimensionLine <DimensionLine
fontScale={fontScale}
key={`furn-h-${item.id}`} key={`furn-h-${item.id}`}
x1={hTop.x} x1={hTop.x}
y1={hTop.y} y1={hTop.y}
@@ -455,6 +472,7 @@ export function ProjectionMeasurements({
const eBottom = projectionToPixel(rect.x + rect.width, rect.y, wallHeight, scale, padding); const eBottom = projectionToPixel(rect.x + rect.width, rect.y, wallHeight, scale, padding);
elements.push( elements.push(
<DimensionLine <DimensionLine
fontScale={fontScale}
key={`furn-elev-${item.id}`} key={`furn-elev-${item.id}`}
x1={eBottom.x} x1={eBottom.x}
y1={eBottom.y} y1={eBottom.y}
@@ -20,7 +20,7 @@ interface ProjectionPanelProps {
export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPanelProps = {}) { export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPanelProps = {}) {
const { t } = useTranslation(); const { t } = useTranslation();
const { state, selectElement, updateElectrical, updateOpening, addElectrical, updateAnnotation } = useEditor(); const { state, selectElement, updateElectrical, updateOpening, addElectrical, updateAnnotation, addAnnotation } = useEditor();
const { const {
walls, walls,
openings, openings,
@@ -189,6 +189,29 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
[selectedElectricalIndex, walls, room, addElectrical, selectElement], [selectedElectricalIndex, walls, room, addElectrical, selectElement],
); );
// Place a free-form annotation anchored to a wall at projection-space
// (alongWall, fromFloor). Stored with attachedToId = wall.id and the
// anchor point in (x, y); WallProjectionView reads these directly.
const handlePlaceAnnotation = useCallback(
(wallId: string, alongWall: number, fromFloor: number) => {
const newAnnotation: Annotation = {
id: generateLocalId(),
roomId: room.id,
x: alongWall,
y: fromFloor,
text: 'Text',
fontSize: 12,
color: '#334155',
attachedToId: wallId,
projectionOffsetX: 0,
projectionOffsetY: 0,
};
addAnnotation(newAnnotation);
selectElement(newAnnotation.id);
},
[room.id, addAnnotation, selectElement],
);
if (walls.length === 0) { if (walls.length === 0) {
return null; return null;
} }
@@ -196,6 +219,7 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
const clampedIndex = Math.min(activeWallIndex, walls.length - 1); const clampedIndex = Math.min(activeWallIndex, walls.length - 1);
const sharedProps = { const sharedProps = {
walls,
openings, openings,
electricalItems: layerVisibility.electrical ? electricalItems : [], electricalItems: layerVisibility.electrical ? electricalItems : [],
furnitureItems: layerVisibility.furniture ? furnitureItems : [], furnitureItems: layerVisibility.furniture ? furnitureItems : [],
@@ -214,6 +238,7 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
onUpdateAnnotation: handleUpdateAnnotation, onUpdateAnnotation: handleUpdateAnnotation,
onEditAnnotation: handleEditAnnotation, onEditAnnotation: handleEditAnnotation,
onPlaceElectrical: handlePlaceElectrical, onPlaceElectrical: handlePlaceElectrical,
onPlaceAnnotation: handlePlaceAnnotation,
showMeasurements: layerVisibility.measurements, showMeasurements: layerVisibility.measurements,
activeTool, activeTool,
selectedElectricalType, selectedElectricalType,
@@ -25,6 +25,9 @@ import { useCanvasColors } from '../utils/canvasThemeColors';
interface WallProjectionViewProps { interface WallProjectionViewProps {
readonly wall: Wall; readonly wall: Wall;
/** All walls in the room lets furniture projection assign each item
* to its closest wall so neighbour walls don't double-project items. */
readonly walls?: readonly Wall[];
readonly openings: readonly WallOpening[]; readonly openings: readonly WallOpening[];
readonly electricalItems: readonly ElectricalItem[]; readonly electricalItems: readonly ElectricalItem[];
readonly furnitureItems: readonly FurnitureItem[]; readonly furnitureItems: readonly FurnitureItem[];
@@ -48,8 +51,13 @@ interface WallProjectionViewProps {
readonly onUpdateAnnotation?: (annotation: Annotation) => void; readonly onUpdateAnnotation?: (annotation: Annotation) => void;
readonly onEditAnnotation?: (annotationId: string) => void; readonly onEditAnnotation?: (annotationId: string) => void;
readonly onPlaceElectrical?: (wallId: string, alongWall: number, fromFloor: number) => void; readonly onPlaceElectrical?: (wallId: string, alongWall: number, fromFloor: number) => void;
readonly onPlaceAnnotation?: (wallId: string, alongWall: number, fromFloor: number) => void;
readonly activeTool?: EditorToolType; readonly activeTool?: EditorToolType;
readonly selectedElectricalType?: ElectricalType | null; readonly selectedElectricalType?: ElectricalType | null;
/** Multiplier applied to hardcoded Konva font sizes. Defaults to 1 for
* the interactive view; the hidden export renderer passes a larger
* value so text stays legible in high-resolution PDF exports. */
readonly fontScale?: number;
} }
const PADDING = 40; const PADDING = 40;
@@ -82,6 +90,7 @@ interface DragInfo {
export function WallProjectionView({ export function WallProjectionView({
wall, wall,
walls,
openings, openings,
electricalItems, electricalItems,
furnitureItems, furnitureItems,
@@ -104,8 +113,10 @@ export function WallProjectionView({
onUpdateAnnotation, onUpdateAnnotation,
onEditAnnotation, onEditAnnotation,
onPlaceElectrical, onPlaceElectrical,
onPlaceAnnotation,
activeTool, activeTool,
selectedElectricalType, selectedElectricalType,
fontScale = 1,
}: WallProjectionViewProps) { }: WallProjectionViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const colors = useCanvasColors(); const colors = useCanvasColors();
@@ -130,6 +141,11 @@ export function WallProjectionView({
const baseScale = projectionScale(wallLen, wallHeight, width, height, PADDING); const baseScale = projectionScale(wallLen, wallHeight, width, height, PADDING);
const effectiveScale = baseScale * viewZoom; const effectiveScale = baseScale * viewZoom;
// fontScale is supplied by the export renderer (RoomEditorLayout's
// hidden per-wall stages). The interactive projection view leaves it at
// the default of 1 — we don't auto-scale by stage size because the
// interactive stage can be large on wide monitors.
// ── Drag state (refs for transient data, state only for visual feedback) ── // ── Drag state (refs for transient data, state only for visual feedback) ──
const dragRef = useRef<DragInfo | null>(null); const dragRef = useRef<DragInfo | null>(null);
const [dragElectricalFromFloor, setDragElectricalFromFloor] = useState<{ itemId: string; fromFloor: number } | null>(null); const [dragElectricalFromFloor, setDragElectricalFromFloor] = useState<{ itemId: string; fromFloor: number } | null>(null);
@@ -168,12 +184,12 @@ export function WallProjectionView({
[wall, openings], [wall, openings],
); );
const projectedElectrical = useMemo( const projectedElectrical = useMemo(
() => projectElectricalItems(wall, electricalItems), () => projectElectricalItems(wall, electricalItems, walls),
[wall, electricalItems], [wall, electricalItems, walls],
); );
const projectedFurniture = useMemo( const projectedFurniture = useMemo(
() => projectFurnitureItems(wall, furnitureItems), () => projectFurnitureItems(wall, furnitureItems, 0.15, walls),
[wall, furnitureItems], [wall, furnitureItems, walls],
); );
const plinthSegments = useMemo( const plinthSegments = useMemo(
() => computePlinthSegments(wall, openings, plinthHeight), () => computePlinthSegments(wall, openings, plinthHeight),
@@ -495,20 +511,31 @@ export function WallProjectionView({
// ── Handle click on wall background for placement ── // ── Handle click on wall background for placement ──
const handleWallBgClick = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => { const handleWallBgClick = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
// Only place when electrical tool is active
if (activeTool !== 'electrical' || !selectedElectricalType || !onPlaceElectrical) return;
const pointer = getStagePointer(e.evt); const pointer = getStagePointer(e.evt);
if (!pointer) return; if (!pointer) return;
const proj = pixelToProjection(
pointer.x - viewPanRef.current.x,
pointer.y - viewPanRef.current.y,
wallHeight,
effectiveScale,
PADDING,
);
const proj = pixelToProjection(pointer.x - viewPanRef.current.x, pointer.y - viewPanRef.current.y, wallHeight, effectiveScale, PADDING); if (activeTool === 'annotate' && onPlaceAnnotation) {
// Annotations may sit anywhere — including just outside the wall
// rectangle for labels — so don't clip the click to the wall span.
onPlaceAnnotation(wall.id, proj.alongWall, proj.fromFloor);
return;
}
// Only place within wall bounds if (activeTool !== 'electrical' || !selectedElectricalType || !onPlaceElectrical) return;
// Only place electrical within wall bounds
if (proj.alongWall < 0 || proj.alongWall > wallLen) return; if (proj.alongWall < 0 || proj.alongWall > wallLen) return;
if (proj.fromFloor < 0 || proj.fromFloor > wallHeight) return; if (proj.fromFloor < 0 || proj.fromFloor > wallHeight) return;
onPlaceElectrical(wall.id, proj.alongWall, proj.fromFloor); onPlaceElectrical(wall.id, proj.alongWall, proj.fromFloor);
}, [activeTool, selectedElectricalType, onPlaceElectrical, getStagePointer, wallHeight, effectiveScale, wallLen, wall.id]); }, [activeTool, selectedElectricalType, onPlaceElectrical, onPlaceAnnotation, getStagePointer, wallHeight, effectiveScale, wallLen, wall.id]);
// ── Reset zoom when the *physical* wall changes ── // ── Reset zoom when the *physical* wall changes ──
// We key on the wall's start/end coords rather than id so a save (which // We key on the wall's start/end coords rather than id so a save (which
@@ -630,7 +657,7 @@ export function WallProjectionView({
width={20} width={20}
text={`${w}`} text={`${w}`}
align="center" align="center"
fontSize={8} fontSize={8 * fontScale}
fill={colors.rulerText} fill={colors.rulerText}
/>, />,
); );
@@ -661,7 +688,7 @@ export function WallProjectionView({
width={20} width={20}
text={`${h}`} text={`${h}`}
align="right" align="right"
fontSize={8} fontSize={8 * fontScale}
fill={colors.rulerText} fill={colors.rulerText}
/>, />,
); );
@@ -722,7 +749,7 @@ export function WallProjectionView({
x={stretchLeft.x + 4} x={stretchLeft.x + 4}
y={stretchLeft.y - 12} y={stretchLeft.y - 12}
text={`${t('properties.stretchCeilingOffset')}: ${(stretchCeilingOffset * 100).toFixed(1)}cm`} text={`${t('properties.stretchCeilingOffset')}: ${(stretchCeilingOffset * 100).toFixed(1)}cm`}
fontSize={9} fontSize={9 * fontScale}
fill="#2563eb" fill="#2563eb"
listening={false} listening={false}
/> />
@@ -882,7 +909,7 @@ export function WallProjectionView({
x={midX + 8} x={midX + 8}
y={midY - 14} y={midY - 14}
text={label} text={label}
fontSize={12} fontSize={12 * fontScale}
fontFamily="sans-serif" fontFamily="sans-serif"
fontStyle="bold" fontStyle="bold"
fill="#e74c3c" fill="#e74c3c"
@@ -906,21 +933,31 @@ export function WallProjectionView({
outletWidth={outletWidth} outletWidth={outletWidth}
outletHeight={outletHeight} outletHeight={outletHeight}
showWallDimensions={showMeasurements} showWallDimensions={showMeasurements}
fontScale={fontScale}
/> />
{/* Attached annotations for items on this wall — interactive */} {/* Attached annotations for items on this wall interactive.
Also includes annotations attached directly to this wall (created
via the annotate tool inside the projection view). */}
{annotations {annotations
.filter((ann) => { .filter((ann) => {
if (!ann.attachedToId) return false; if (!ann.attachedToId) return false;
if (ann.attachedToId === wall.id) return true;
return projectedElectrical.some((pe) => pe.item.id === ann.attachedToId) || return projectedElectrical.some((pe) => pe.item.id === ann.attachedToId) ||
projectedFurniture.some((pf) => pf.item.id === ann.attachedToId); projectedFurniture.some((pf) => pf.item.id === ann.attachedToId);
}) })
.map((ann) => { .map((ann) => {
const elec = projectedElectrical.find((pe) => pe.item.id === ann.attachedToId); const elec = projectedElectrical.find((pe) => pe.item.id === ann.attachedToId);
const furn = projectedFurniture.find((pf) => pf.item.id === ann.attachedToId); const furn = projectedFurniture.find((pf) => pf.item.id === ann.attachedToId);
const isWallAnchored = ann.attachedToId === wall.id;
let anchorAlongWall = 0; let anchorAlongWall = 0;
let anchorFromFloor = 0; let anchorFromFloor = 0;
if (elec) { if (isWallAnchored) {
// Wall-anchored annotations store their projection-space
// anchor point in (x, y) — not a delta from another item.
anchorAlongWall = ann.x;
anchorFromFloor = ann.y;
} else if (elec) {
anchorAlongWall = elec.position.alongWall; anchorAlongWall = elec.position.alongWall;
anchorFromFloor = elec.position.fromFloor; anchorFromFloor = elec.position.fromFloor;
} else if (furn) { } else if (furn) {
@@ -936,7 +973,7 @@ export function WallProjectionView({
const textX = anchorPx.x + projOffsetX * effectiveScale; const textX = anchorPx.x + projOffsetX * effectiveScale;
const textY = anchorPx.y + projOffsetY * effectiveScale; const textY = anchorPx.y + projOffsetY * effectiveScale;
const isSelected = selectedIds.has(ann.id); const isSelected = selectedIds.has(ann.id);
const fontSize = ann.fontSize ?? 10; const fontSize = (ann.fontSize ?? 10) * fontScale;
const boxWidth = ann.text.length * (fontSize * 0.6) + 6; const boxWidth = ann.text.length * (fontSize * 0.6) + 6;
const boxHeight = fontSize + 4; const boxHeight = fontSize + 4;
// URL detection mirrors the floor-plan AnnotationLayer so links // URL detection mirrors the floor-plan AnnotationLayer so links
@@ -1012,7 +1049,7 @@ export function WallProjectionView({
x={PADDING} x={PADDING}
y={8} y={8}
text={`${label} (${wallLen.toFixed(2)}m)`} text={`${label} (${wallLen.toFixed(2)}m)`}
fontSize={11} fontSize={11 * fontScale}
fontStyle="bold" fontStyle="bold"
fill="#334155" fill="#334155"
/> />
@@ -67,7 +67,14 @@ interface CameraControlsProps {
readonly wallHeight: number; readonly wallHeight: number;
} }
export type CameraPreset = 'birds-eye' | 'eye-level' | 'corner-ne' | 'corner-nw' | 'corner-se' | 'corner-sw'; export type CameraPreset =
| 'birds-eye'
| 'birds-eye-export'
| 'eye-level'
| 'corner-ne'
| 'corner-nw'
| 'corner-se'
| 'corner-sw';
interface PresetConfig { interface PresetConfig {
readonly position: [number, number, number]; readonly position: [number, number, number];
@@ -91,11 +98,27 @@ function computePresets(
const target: [number, number, number] = [centerX, centerY, centerZ]; const target: [number, number, number] = [centerX, centerY, centerZ];
const floorTarget: [number, number, number] = [centerX, 0, centerZ]; const floorTarget: [number, number, number] = [centerX, 0, centerZ];
// Compute the camera height for the export-only top-down preset. Three.js
// PerspectiveCamera.fov is VERTICAL FOV, so we derive the horizontal half-
// angle from (tanV · aspect). Walls extend up from the floor toward the
// camera, so the frustum must fit the room at the WALL-TOP plane (closer
// to the camera) — fitting at floor level lets wall edges overflow into
// the frame. Canvas is rendered at 4:3 for the off-screen export pass.
const exportAspect = 4 / 3;
const tanV = Math.tan((50 / 2) * (Math.PI / 180));
const tanH = tanV * exportAspect;
const dTop = Math.max(sizeX / (2 * tanH), sizeZ / (2 * tanV));
const exportDist = Math.max(wallHeight + dTop * 1.15, wallHeight + 1);
return { return {
'birds-eye': { 'birds-eye': {
position: [centerX, dist * 1.5, centerZ + 0.01], position: [centerX, dist * 1.5, centerZ + 0.01],
target: floorTarget, target: floorTarget,
}, },
'birds-eye-export': {
position: [centerX, exportDist, centerZ + 0.01],
target: floorTarget,
},
'eye-level': { 'eye-level': {
position: [centerX - dist, 1.6, centerZ], position: [centerX - dist, 1.6, centerZ],
target: [centerX, 1.6, centerZ], target: [centerX, 1.6, centerZ],
@@ -75,13 +75,20 @@ interface Room3DViewProps {
* (e.g. 'birds-eye') before taking a screenshot. * (e.g. 'birds-eye') before taking a screenshot.
*/ */
readonly presetTriggerRef?: React.MutableRefObject<((preset: CameraPreset) => void) | null>; readonly presetTriggerRef?: React.MutableRefObject<((preset: CameraPreset) => void) | null>;
/**
* Optional ref for the underlying R3F canvas. Forwarded so the export
* dialog can always reach the live canvas even when Room3DView was lazy-
* mounted mid-export (querying the outer container at mount time would
* miss a canvas that hadn't been created yet).
*/
readonly canvasRef?: React.MutableRefObject<HTMLCanvasElement | null>;
} }
/** /**
* Room3DView read-only 3D perspective view of the room. * Room3DView read-only 3D perspective view of the room.
* Renders inside a @react-three/fiber Canvas with orbit controls. * Renders inside a @react-three/fiber Canvas with orbit controls.
*/ */
export function Room3DView({ presetTriggerRef }: Room3DViewProps = {}) { export function Room3DView({ presetTriggerRef, canvasRef }: Room3DViewProps = {}) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { state, dispatch } = useEditor(); const { state, dispatch } = useEditor();
const { theme } = useTheme(); const { theme } = useTheme();
@@ -210,6 +217,9 @@ export function Room3DView({ presetTriggerRef }: Room3DViewProps = {}) {
onPreset={handlePreset} onPreset={handlePreset}
/> />
<Canvas <Canvas
ref={(el) => {
if (canvasRef) canvasRef.current = el ?? null;
}}
// `shadows="percentage"` selects PCFShadowMap — the non-deprecated // `shadows="percentage"` selects PCFShadowMap — the non-deprecated
// replacement for the default PCFSoftShadowMap that Three.js removed // replacement for the default PCFSoftShadowMap that Three.js removed
// support for in recent versions. Double-click anywhere on the empty // support for in recent versions. Double-click anywhere on the empty
@@ -5,14 +5,16 @@ import { wallLength, wallStartEnd } from './wallUtils';
// ── Projection axis (canonical orientation for elevation views) ── // ── Projection axis (canonical orientation for elevation views) ──
/** /**
* Pick a canonical orientation for the projection X axis. * Pick a canonical orientation for the projection X axis so that
* left-to-right in the elevation view matches the viewer's perspective
* when standing **inside** the room looking at the wall:
* *
* For axis-aligned walls, the projection axis is oriented so that it matches the * NORTH wall left = west, right = east +X axis
* floor plan's positive X (for horizontal walls) or positive Y (for vertical walls) * SOUTH wall left = east, right = west X axis
* direction. This means a south wall is shown left-to-right matching westeast on * EAST wall left = north, right = south +Y axis
* the floor plan, instead of mirrored. * WEST wall left = south, right = north Y axis
* *
* Diagonal walls keep their natural startend orientation. * Diagonal / OTHER walls keep their natural startend orientation.
* *
* @returns the canonical start, end, length and whether the axis is flipped * @returns the canonical start, end, length and whether the axis is flipped
* relative to the wall's stored startend. * relative to the wall's stored startend.
@@ -34,8 +36,17 @@ export function getProjectionAxis(wall: Wall): ProjectionAxis {
const ay = Math.abs(dy); const ay = Math.abs(dy);
const isHorizontal = ax >= ay; const isHorizontal = ax >= ay;
// Want horizontal walls to go +X, vertical walls to go +Y. // SOUTH and WEST walls need the negative axis direction (the viewer
const flipped = isHorizontal ? dx < 0 : dy < 0; // faces the negative direction and sees the positive side on the left).
// NORTH and EAST walls need the positive axis direction (current default).
// OTHER / diagonal walls keep start→end as-is.
let flipped: boolean;
if (wall.direction === 'SOUTH' || wall.direction === 'WEST') {
flipped = isHorizontal ? dx > 0 : dy > 0;
} else {
flipped = isHorizontal ? dx < 0 : dy < 0;
}
if (flipped) { if (flipped) {
return { start: end, end: start, length, flipped: true }; return { start: end, end: start, length, flipped: true };
} }
@@ -211,6 +222,7 @@ export function projectOpenings(
export function projectElectricalItems( export function projectElectricalItems(
wall: Wall, wall: Wall,
electricalItems: readonly ElectricalItem[], electricalItems: readonly ElectricalItem[],
allWalls?: readonly Wall[],
): readonly ProjectedElectrical[] { ): readonly ProjectedElectrical[] {
const axis = getProjectionAxis(wall); const axis = getProjectionAxis(wall);
const { start, end, length: wallLen } = axis; const { start, end, length: wallLen } = axis;
@@ -220,23 +232,49 @@ export function projectElectricalItems(
const dx = (end.x - start.x) / wallLen; const dx = (end.x - start.x) / wallLen;
const dy = (end.y - start.y) / wallLen; const dy = (end.y - start.y) / wallLen;
// Match by wallId first; fall back to proximity for items whose wallId
// became stale after a save (wall IDs change on bulk replace)
const PROXIMITY_THRESHOLD = 0.3; const PROXIMITY_THRESHOLD = 0.3;
/** Perpendicular distance from an item to a wall's line, clamped to the
* wall's span so a corner-adjacent item doesn't count as "on" the
* neighbouring wall. */
const perpDistToWall = (item: ElectricalItem, w: Wall): number => {
const a = getProjectionAxis(w);
if (a.length === 0) return Infinity;
const wdx = (a.end.x - a.start.x) / a.length;
const wdy = (a.end.y - a.start.y) / a.length;
const vx = item.x - a.start.x;
const vy = item.y - a.start.y;
const along = vx * wdx + vy * wdy;
if (along < -0.05 || along > a.length + 0.05) return Infinity;
return Math.abs(vx * -wdy + vy * wdx);
};
// Build a set of live wall ids. A wallId that doesn't appear here is
// stale (wall ids are reassigned on bulk-save), so we treat the item as
// orphan and fall back to proximity instead of dropping it.
const liveWallIds = allWalls ? new Set(allWalls.map((w) => w.id)) : null;
return electricalItems return electricalItems
.filter((item) => { .filter((item) => {
// Exact wallId match // Explicit match wins.
if (item.wallId === wall.id) return true; if (item.wallId === wall.id) return true;
// Proximity fallback for wall-mounted items with mismatched/null wallId // Explicit mismatch to a LIVE wall: trust the author, not us.
if (!item.wallId || WALL_MOUNTED_ELECTRICAL_TYPES.has(item.type)) { if (item.wallId && (!liveWallIds || liveWallIds.has(item.wallId))) return false;
const vx = item.x - start.x;
const vy = item.y - start.y; // Orphan or stale-wallId item: fall back to proximity, but assign
const perpDist = Math.abs(vx * (-dy) + vy * dx); // it to the SINGLE closest wall so a corner item doesn't project
const alongWall = vx * dx + vy * dy; // onto both adjacent walls.
return perpDist < PROXIMITY_THRESHOLD && alongWall >= -0.1 && alongWall <= wallLen + 0.1; if (!WALL_MOUNTED_ELECTRICAL_TYPES.has(item.type)) return false;
const thisDist = perpDistToWall(item, wall);
if (thisDist >= PROXIMITY_THRESHOLD) return false;
if (allWalls && allWalls.length > 1) {
for (const other of allWalls) {
if (other.id === wall.id) continue;
const otherDist = perpDistToWall(item, other);
if (otherDist < thisDist - 1e-6) return false;
}
} }
return false; return true;
}) })
.map((item) => { .map((item) => {
// Project item position onto wall axis // Project item position onto wall axis
@@ -342,23 +380,38 @@ function furnitureEdgeDistanceToWall(
// Perpendicular distance from centre to wall line // Perpendicular distance from centre to wall line
const centerDist = Math.abs(dxC * (-dy) + dyC * dx); const centerDist = Math.abs(dxC * (-dy) + dyC * dx);
const { halfAlong, halfPerp } = rotatedHalfExtents(item, dx, dy); const { halfPerp } = rotatedHalfExtents(item, dx, dy);
const edgeDist = Math.max(0, centerDist - halfPerp); const edgeDist = Math.max(0, centerDist - halfPerp);
// Along-wall extent: item (rotated) must overlap the wall's length. // Along-wall extent: the item's CENTRE must lie within this wall's span
// (with a small tolerance). Using the rotated half-width as tolerance
// here picked up items sitting on perpendicular neighbour walls whose
// corner happened to be close to this wall's extended line — they then
// got projected into the elevation view as if they belonged to this wall.
const alongWallCenter = dxC * dx + dyC * dy; const alongWallCenter = dxC * dx + dyC * dy;
if (alongWallCenter < -halfAlong || alongWallCenter > wallLen + halfAlong) { const alongTol = 0.1;
if (alongWallCenter < -alongTol || alongWallCenter > wallLen + alongTol) {
return Infinity; return Infinity;
} }
return edgeDist; return edgeDist;
} }
/** Project furniture items that are near a wall into elevation coords. */ /** Project furniture items that are near a wall into elevation coords.
*
* When `allWalls` is provided, each furniture item is assigned to the
* single wall that minimizes its edge-distance (its "home" wall) items
* that are closer to a different wall are excluded from this wall's
* projection even if they happen to be within `wallThreshold` of it. This
* removes neighbour-wall leakage in corners (e.g. a wardrobe on the east
* wall that sits flush in the NE corner used to show up on the north wall
* projection too).
*/
export function projectFurnitureItems( export function projectFurnitureItems(
wall: Wall, wall: Wall,
furnitureItems: readonly FurnitureItem[], furnitureItems: readonly FurnitureItem[],
wallThreshold: number = 0.15, wallThreshold: number = 0.15,
allWalls?: readonly Wall[],
): readonly ProjectedFurniture[] { ): readonly ProjectedFurniture[] {
const axis = getProjectionAxis(wall); const axis = getProjectionAxis(wall);
const { start, end, length: wallLen } = axis; const { start, end, length: wallLen } = axis;
@@ -370,7 +423,16 @@ export function projectFurnitureItems(
return furnitureItems return furnitureItems
.filter((item) => { .filter((item) => {
const dist = furnitureEdgeDistanceToWall(item, wall); const dist = furnitureEdgeDistanceToWall(item, wall);
return dist < wallThreshold; if (dist >= wallThreshold) return false;
if (allWalls && allWalls.length > 1) {
// Must be the *closest* wall to avoid corner leakage.
for (const other of allWalls) {
if (other.id === wall.id) continue;
const otherDist = furnitureEdgeDistanceToWall(item, other);
if (otherDist < dist - 1e-6) return false;
}
}
return true;
}) })
.map((item) => { .map((item) => {
// Convert anchored (x, y) to rotated bounding-box centre. // Convert anchored (x, y) to rotated bounding-box centre.