diff --git a/apps/client/src/components/editor/RoomEditorLayout.tsx b/apps/client/src/components/editor/RoomEditorLayout.tsx
index 5f6c301..d735a80 100644
--- a/apps/client/src/components/editor/RoomEditorLayout.tsx
+++ b/apps/client/src/components/editor/RoomEditorLayout.tsx
@@ -21,9 +21,16 @@ import type { CameraPreset } from './three/CameraControls';
// Pixel density and padding used by the hidden projection renderers that
// 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_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(() =>
import('./three/Room3DView').then((m) => ({ default: m.Room3DView })),
@@ -797,6 +804,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
{mount3D && (
{
- // Grab the R3F canvas element for 3D export
- if (el) {
- const canvas = el.querySelector('canvas');
- threeCanvasRef.current = canvas;
- } else {
- threeCanvasRef.current = null;
- }
- }}
>
{t('editor.loading3D')}
}>
-
+
)}
@@ -873,12 +872,19 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
{state.walls.map((wall) => {
const len = computeWallLength(wall);
if (len <= 0) return null;
- const w = Math.round(len * EXPORT_PX_PER_M + EXPORT_PROJ_PADDING);
- const h = Math.round(state.room.wallHeight * EXPORT_PX_PER_M + EXPORT_PROJ_PADDING);
+ const w = Math.round(
+ 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 (
);
diff --git a/apps/client/src/components/editor/export/ExportDialog.tsx b/apps/client/src/components/editor/export/ExportDialog.tsx
index 00d2fce..6eacc0c 100644
--- a/apps/client/src/components/editor/export/ExportDialog.tsx
+++ b/apps/client/src/components/editor/export/ExportDialog.tsx
@@ -58,28 +58,73 @@ export function ExportDialog({
const [isExporting, setIsExporting] = useState(false);
const [error, setError] = useState(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.
* Saves the user's current zoom/pan, dispatches the computed fit view,
* waits for layers to re-render, captures, then restores.
*/
const captureMainStageAtFit = useCallback(
- async (renderGrid: boolean): Promise => {
+ 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 => {
const stage = mainStageRef.current;
if (!stage) return null;
if (state.room.shape.length === 0) return null;
const stageW = stage.width();
const stageH = stage.height();
- // Use a synthetic size if the stage is currently hidden (0×0) or very small.
- const targetW = stageW > 100 ? stageW : canvasSize?.width && canvasSize.width > 100 ? canvasSize.width : 1600;
- const targetH = stageH > 100 ? stageH : canvasSize?.height && canvasSize.height > 100 ? canvasSize.height : 1200;
+ // PDF page image box ≈ 273 × 180 mm on A4 landscape → 1.517:1.
+ // Match it to avoid leaving large empty bands in the PDF.
+ 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;
const container = stage.container();
const parent = container?.parentElement;
const origParentStyle = parent?.getAttribute('style') ?? '';
- if (stageW === 0 || stageH === 0) {
+ if (matchPdfAspect || stageW === 0 || stageH === 0) {
resized = true;
if (parent) {
parent.setAttribute(
@@ -91,7 +136,7 @@ export function ExportDialog({
stage.height(targetH);
}
- const fit = computeFitView(state.room.shape, targetW, targetH);
+ const fit = computeFitView(buildFitPoints(), targetW, targetH);
if (!fit) {
if (resized) {
stage.width(stageW);
@@ -107,7 +152,10 @@ export function ExportDialog({
await waitFrames(3);
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 });
if (resized) {
@@ -117,24 +165,33 @@ export function ExportDialog({
}
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 => {
- const trigger = preset3DRef.current;
- if (trigger) {
- trigger('birds-eye');
- // Wait long enough for the preset to settle and one full render pass.
- await waitFrames(12);
- }
- 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);
- }, [preset3DRef, threeCanvasRef]);
+ /**
+ * Capture the 3D canvas with a tight top-down framing. `tightFraming`
+ * picks the export-only preset that fills the frame with the room instead
+ * of the loose default birds-eye used by the interactive view.
+ * `jpegForPdf` encodes as JPEG — photographic 3D renders compress an
+ * order of magnitude smaller than PNG with little visible quality loss.
+ */
+ const capture3DBirdsEye = useCallback(
+ async (opts: { tightFraming?: boolean; jpegForPdf?: boolean } = {}): Promise => {
+ const trigger = preset3DRef.current;
+ if (trigger) {
+ trigger(opts.tightFraming ? 'birds-eye-export' : 'birds-eye');
+ // Wait long enough for the preset to settle and one full render pass.
+ await waitFrames(12);
+ }
+ 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
@@ -143,7 +200,7 @@ export function ExportDialog({
* Waits briefly on the first call so the hidden render pass can settle.
*/
const captureAllProjections = useCallback(
- async (): Promise<{ label: string; dataUrl: string }[]> => {
+ async (pixelRatioOverride?: number): Promise<{ label: string; dataUrl: string }[]> => {
const out: { label: string; dataUrl: string }[] = [];
const projMap = exportProjectionStageRefs.current;
if (!projMap) return out;
@@ -155,12 +212,13 @@ export function ExportDialog({
}
await waitFrames(2);
+ const ratio = pixelRatioOverride ?? pixelRatio;
for (const wall of walls) {
const projStage = projMap.get(wall.id);
if (projStage && projStage.width() > 0 && projStage.height() > 0) {
projStage.batchDraw();
const label = wallDirectionLabel(wall);
- const dataUrl = exportKonvaStageToDataUrl(projStage, { pixelRatio });
+ const dataUrl = exportKonvaStageToDataUrl(projStage, { pixelRatio: ratio });
out.push({ label, dataUrl });
}
}
@@ -258,23 +316,39 @@ export function ExportDialog({
const blob = new Blob([jsonStr], { type: 'application/json' });
downloadBlob(blob, `${baseName}.json`);
} else {
- // PDF: always capture 3D birds-eye + 2D default + all projections.
- // Kick 3D render first so it has plenty of frames to settle while we
- // capture the 2D stage.
- preset3DRef.current?.('birds-eye');
+ // PDF: always capture 3D (tight top-down) + 2D default + all projections.
+ // Room3DView lazy-loads when the export dialog opens, so the preset
+ // trigger ref may not exist yet. Poll for it, then snap the camera
+ // 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);
- const projectionDataUrls = await captureAllProjections();
+ // Source stages are already rendered at 400 px/m — capturing at
+ // 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.
- await waitFrames(8);
- let canvas3d = threeCanvasRef.current;
- if (!canvas3d) {
- canvas3d = document.querySelector('canvas[data-engine]') as HTMLCanvasElement | null;
+ // Re-trigger the preset after other captures settle, and poll for a
+ // valid 3D canvas (it may still be mounting on first export).
+ preset3DRef.current?.('birds-eye-export');
+ await waitFrames(20);
+ 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 =
canvas3d && canvas3d.width > 0 && canvas3d.height > 0
- ? exportThreeCanvasToDataUrl(canvas3d)
+ ? exportThreeCanvasToDataUrl(canvas3d, 'image/jpeg', 0.95)
: null;
const pdf = await createRoomPdf(room.name, topDownDataUrl, projectionDataUrls, view3dDataUrl);
diff --git a/apps/client/src/components/editor/export/exportUtils.ts b/apps/client/src/components/editor/export/exportUtils.ts
index 5e55df4..d4018f2 100644
--- a/apps/client/src/components/editor/export/exportUtils.ts
+++ b/apps/client/src/components/editor/export/exportUtils.ts
@@ -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(
canvas: HTMLCanvasElement,
+ mimeType: 'image/png' | 'image/jpeg' = 'image/png',
+ quality = 0.85,
): 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');
}
@@ -156,7 +178,10 @@ export async function createRoomPdf(
view3dDataUrl: string | null,
): Promise {
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);
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
@@ -175,7 +200,7 @@ export async function createRoomPdf(
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) {
addTitledPage(roomName, 18);
const boxH = pageHeight - IMAGE_TOP - MARGIN;
diff --git a/apps/client/src/components/editor/layers/AnnotationLayer.tsx b/apps/client/src/components/editor/layers/AnnotationLayer.tsx
index 55612b6..31b92a8 100644
--- a/apps/client/src/components/editor/layers/AnnotationLayer.tsx
+++ b/apps/client/src/components/editor/layers/AnnotationLayer.tsx
@@ -68,10 +68,21 @@ export const AnnotationLayer = memo(function AnnotationLayer({
return map;
}, [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(() => {
if (!visible) return [];
- return annotations;
- }, [annotations, visible]);
+ return annotations.filter(
+ (a) => !a.attachedToId || !electricalIdSet.has(a.attachedToId),
+ );
+ }, [annotations, visible, electricalIdSet]);
return (
diff --git a/apps/client/src/components/editor/projection/ProjectionMeasurements.tsx b/apps/client/src/components/editor/projection/ProjectionMeasurements.tsx
index c20bccc..8957ced 100644
--- a/apps/client/src/components/editor/projection/ProjectionMeasurements.tsx
+++ b/apps/client/src/components/editor/projection/ProjectionMeasurements.tsx
@@ -23,11 +23,14 @@ interface ProjectionMeasurementsProps {
/** When false, the wall-level dimensions/labels are skipped (useful when the
* measurements layer is toggled off but per-item overlays should still draw). */
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. */
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 y1: number;
@@ -38,6 +41,7 @@ function DimensionLine({
readonly horizontal: boolean;
readonly lineColor: string;
readonly textColor: string;
+ readonly fontScale?: number;
}) {
const arrowSize = 4;
@@ -69,7 +73,7 @@ function DimensionLine({
width={40}
text={label}
align="center"
- fontSize={9}
+ fontSize={9 * fontScale}
fill={textColor}
/>
@@ -98,7 +102,7 @@ function DimensionLine({
x={lineX + 3}
y={midY - 5}
text={label}
- fontSize={9}
+ fontSize={9 * fontScale}
fill={textColor}
/>
@@ -121,6 +125,7 @@ export function ProjectionMeasurements({
outletWidth = DEFAULT_OUTLET_WIDTH,
outletHeight = DEFAULT_OUTLET_HEIGHT,
showWallDimensions = true,
+ fontScale = 1,
}: ProjectionMeasurementsProps) {
const colors = useCanvasColors();
const elements: ReactNode[] = [];
@@ -131,6 +136,7 @@ export function ProjectionMeasurements({
const floorRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
elements.push(
@@ -295,7 +305,7 @@ export function ProjectionMeasurements({
x={openingCenter.x - 16}
y={openingCenter.y - 22}
text={formatM(rect.x + rect.width / 2)}
- fontSize={8}
+ fontSize={8 * fontScale}
fill={colors.openingLabel}
align="center"
width={32}
@@ -327,6 +337,7 @@ export function ProjectionMeasurements({
const wRightFloor = projectionToPixel(rect.x + rect.width, 0, wallHeight, scale, padding);
elements.push(
{
+ 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) {
return null;
}
@@ -196,6 +219,7 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
const clampedIndex = Math.min(activeWallIndex, walls.length - 1);
const sharedProps = {
+ walls,
openings,
electricalItems: layerVisibility.electrical ? electricalItems : [],
furnitureItems: layerVisibility.furniture ? furnitureItems : [],
@@ -214,6 +238,7 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
onUpdateAnnotation: handleUpdateAnnotation,
onEditAnnotation: handleEditAnnotation,
onPlaceElectrical: handlePlaceElectrical,
+ onPlaceAnnotation: handlePlaceAnnotation,
showMeasurements: layerVisibility.measurements,
activeTool,
selectedElectricalType,
diff --git a/apps/client/src/components/editor/projection/WallProjectionView.tsx b/apps/client/src/components/editor/projection/WallProjectionView.tsx
index b56bb6b..2fa3c81 100644
--- a/apps/client/src/components/editor/projection/WallProjectionView.tsx
+++ b/apps/client/src/components/editor/projection/WallProjectionView.tsx
@@ -25,6 +25,9 @@ import { useCanvasColors } from '../utils/canvasThemeColors';
interface WallProjectionViewProps {
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 electricalItems: readonly ElectricalItem[];
readonly furnitureItems: readonly FurnitureItem[];
@@ -48,8 +51,13 @@ interface WallProjectionViewProps {
readonly onUpdateAnnotation?: (annotation: Annotation) => void;
readonly onEditAnnotation?: (annotationId: string) => void;
readonly onPlaceElectrical?: (wallId: string, alongWall: number, fromFloor: number) => void;
+ readonly onPlaceAnnotation?: (wallId: string, alongWall: number, fromFloor: number) => void;
readonly activeTool?: EditorToolType;
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;
@@ -82,6 +90,7 @@ interface DragInfo {
export function WallProjectionView({
wall,
+ walls,
openings,
electricalItems,
furnitureItems,
@@ -104,8 +113,10 @@ export function WallProjectionView({
onUpdateAnnotation,
onEditAnnotation,
onPlaceElectrical,
+ onPlaceAnnotation,
activeTool,
selectedElectricalType,
+ fontScale = 1,
}: WallProjectionViewProps) {
const { t } = useTranslation();
const colors = useCanvasColors();
@@ -130,6 +141,11 @@ export function WallProjectionView({
const baseScale = projectionScale(wallLen, wallHeight, width, height, PADDING);
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) ──
const dragRef = useRef(null);
const [dragElectricalFromFloor, setDragElectricalFromFloor] = useState<{ itemId: string; fromFloor: number } | null>(null);
@@ -168,12 +184,12 @@ export function WallProjectionView({
[wall, openings],
);
const projectedElectrical = useMemo(
- () => projectElectricalItems(wall, electricalItems),
- [wall, electricalItems],
+ () => projectElectricalItems(wall, electricalItems, walls),
+ [wall, electricalItems, walls],
);
const projectedFurniture = useMemo(
- () => projectFurnitureItems(wall, furnitureItems),
- [wall, furnitureItems],
+ () => projectFurnitureItems(wall, furnitureItems, 0.15, walls),
+ [wall, furnitureItems, walls],
);
const plinthSegments = useMemo(
() => computePlinthSegments(wall, openings, plinthHeight),
@@ -495,20 +511,31 @@ export function WallProjectionView({
// ── Handle click on wall background for placement ──
const handleWallBgClick = useCallback((e: Konva.KonvaEventObject) => {
- // Only place when electrical tool is active
- if (activeTool !== 'electrical' || !selectedElectricalType || !onPlaceElectrical) return;
-
const pointer = getStagePointer(e.evt);
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.fromFloor < 0 || proj.fromFloor > wallHeight) return;
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 ──
// 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}
text={`${w}`}
align="center"
- fontSize={8}
+ fontSize={8 * fontScale}
fill={colors.rulerText}
/>,
);
@@ -661,7 +688,7 @@ export function WallProjectionView({
width={20}
text={`${h}`}
align="right"
- fontSize={8}
+ fontSize={8 * fontScale}
fill={colors.rulerText}
/>,
);
@@ -722,7 +749,7 @@ export function WallProjectionView({
x={stretchLeft.x + 4}
y={stretchLeft.y - 12}
text={`${t('properties.stretchCeilingOffset')}: ${(stretchCeilingOffset * 100).toFixed(1)}cm`}
- fontSize={9}
+ fontSize={9 * fontScale}
fill="#2563eb"
listening={false}
/>
@@ -882,7 +909,7 @@ export function WallProjectionView({
x={midX + 8}
y={midY - 14}
text={label}
- fontSize={12}
+ fontSize={12 * fontScale}
fontFamily="sans-serif"
fontStyle="bold"
fill="#e74c3c"
@@ -906,21 +933,31 @@ export function WallProjectionView({
outletWidth={outletWidth}
outletHeight={outletHeight}
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
.filter((ann) => {
if (!ann.attachedToId) return false;
+ if (ann.attachedToId === wall.id) return true;
return projectedElectrical.some((pe) => pe.item.id === ann.attachedToId) ||
projectedFurniture.some((pf) => pf.item.id === ann.attachedToId);
})
.map((ann) => {
const elec = projectedElectrical.find((pe) => pe.item.id === ann.attachedToId);
const furn = projectedFurniture.find((pf) => pf.item.id === ann.attachedToId);
+ const isWallAnchored = ann.attachedToId === wall.id;
let anchorAlongWall = 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;
anchorFromFloor = elec.position.fromFloor;
} else if (furn) {
@@ -936,7 +973,7 @@ export function WallProjectionView({
const textX = anchorPx.x + projOffsetX * effectiveScale;
const textY = anchorPx.y + projOffsetY * effectiveScale;
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 boxHeight = fontSize + 4;
// URL detection mirrors the floor-plan AnnotationLayer so links
@@ -1012,7 +1049,7 @@ export function WallProjectionView({
x={PADDING}
y={8}
text={`${label} (${wallLen.toFixed(2)}m)`}
- fontSize={11}
+ fontSize={11 * fontScale}
fontStyle="bold"
fill="#334155"
/>
diff --git a/apps/client/src/components/editor/three/CameraControls.tsx b/apps/client/src/components/editor/three/CameraControls.tsx
index 35c53d4..69de36c 100644
--- a/apps/client/src/components/editor/three/CameraControls.tsx
+++ b/apps/client/src/components/editor/three/CameraControls.tsx
@@ -67,7 +67,14 @@ interface CameraControlsProps {
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 {
readonly position: [number, number, number];
@@ -91,11 +98,27 @@ function computePresets(
const target: [number, number, number] = [centerX, centerY, 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 {
'birds-eye': {
position: [centerX, dist * 1.5, centerZ + 0.01],
target: floorTarget,
},
+ 'birds-eye-export': {
+ position: [centerX, exportDist, centerZ + 0.01],
+ target: floorTarget,
+ },
'eye-level': {
position: [centerX - dist, 1.6, centerZ],
target: [centerX, 1.6, centerZ],
diff --git a/apps/client/src/components/editor/three/Room3DView.tsx b/apps/client/src/components/editor/three/Room3DView.tsx
index 19e62c6..069db9f 100644
--- a/apps/client/src/components/editor/three/Room3DView.tsx
+++ b/apps/client/src/components/editor/three/Room3DView.tsx
@@ -75,13 +75,20 @@ interface Room3DViewProps {
* (e.g. 'birds-eye') before taking a screenshot.
*/
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;
}
/**
* Room3DView — read-only 3D perspective view of the room.
* 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 { state, dispatch } = useEditor();
const { theme } = useTheme();
@@ -210,6 +217,9 @@ export function Room3DView({ presetTriggerRef }: Room3DViewProps = {}) {
onPreset={handlePreset}
/>