From 5fbd3821206ada190b836911f43d16e9f64429d4 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 14 Apr 2026 21:00:39 +0300 Subject: [PATCH] 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. --- .../components/editor/RoomEditorLayout.tsx | 33 ++-- .../components/editor/export/ExportDialog.tsx | 148 +++++++++++++----- .../components/editor/export/exportUtils.ts | 31 +++- .../editor/layers/AnnotationLayer.tsx | 15 +- .../projection/ProjectionMeasurements.tsx | 34 +++- .../editor/projection/ProjectionPanel.tsx | 27 +++- .../editor/projection/WallProjectionView.tsx | 73 ++++++--- .../editor/three/CameraControls.tsx | 25 ++- .../components/editor/three/Room3DView.tsx | 12 +- .../editor/utils/projectionMapping.ts | 110 ++++++++++--- 10 files changed, 400 insertions(+), 108 deletions(-) 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} /> { + if (canvasRef) canvasRef.current = el ?? null; + }} // `shadows="percentage"` selects PCFShadowMap — the non-deprecated // replacement for the default PCFSoftShadowMap that Three.js removed // support for in recent versions. Double-click anywhere on the empty diff --git a/apps/client/src/components/editor/utils/projectionMapping.ts b/apps/client/src/components/editor/utils/projectionMapping.ts index 6ea9499..087faba 100644 --- a/apps/client/src/components/editor/utils/projectionMapping.ts +++ b/apps/client/src/components/editor/utils/projectionMapping.ts @@ -5,14 +5,16 @@ import { wallLength, wallStartEnd } from './wallUtils'; // ── 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 - * floor plan's positive X (for horizontal walls) or positive Y (for vertical walls) - * direction. This means a south wall is shown left-to-right matching west→east on - * the floor plan, instead of mirrored. + * NORTH wall → left = west, right = east → +X axis + * SOUTH wall → left = east, right = west → −X axis + * EAST wall → left = north, right = south → +Y axis + * WEST wall → left = south, right = north → −Y axis * - * Diagonal walls keep their natural start→end orientation. + * Diagonal / OTHER walls keep their natural start→end orientation. * * @returns the canonical start, end, length and whether the axis is flipped * relative to the wall's stored start→end. @@ -34,8 +36,17 @@ export function getProjectionAxis(wall: Wall): ProjectionAxis { const ay = Math.abs(dy); const isHorizontal = ax >= ay; - // Want horizontal walls to go +X, vertical walls to go +Y. - const flipped = isHorizontal ? dx < 0 : dy < 0; + // SOUTH and WEST walls need the negative axis direction (the viewer + // 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) { return { start: end, end: start, length, flipped: true }; } @@ -211,6 +222,7 @@ export function projectOpenings( export function projectElectricalItems( wall: Wall, electricalItems: readonly ElectricalItem[], + allWalls?: readonly Wall[], ): readonly ProjectedElectrical[] { const axis = getProjectionAxis(wall); const { start, end, length: wallLen } = axis; @@ -220,23 +232,49 @@ export function projectElectricalItems( const dx = (end.x - start.x) / 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; + /** 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 .filter((item) => { - // Exact wallId match + // Explicit match wins. if (item.wallId === wall.id) return true; - // Proximity fallback for wall-mounted items with mismatched/null wallId - if (!item.wallId || WALL_MOUNTED_ELECTRICAL_TYPES.has(item.type)) { - const vx = item.x - start.x; - const vy = item.y - start.y; - const perpDist = Math.abs(vx * (-dy) + vy * dx); - const alongWall = vx * dx + vy * dy; - return perpDist < PROXIMITY_THRESHOLD && alongWall >= -0.1 && alongWall <= wallLen + 0.1; + // Explicit mismatch to a LIVE wall: trust the author, not us. + if (item.wallId && (!liveWallIds || liveWallIds.has(item.wallId))) return false; + + // Orphan or stale-wallId item: fall back to proximity, but assign + // it to the SINGLE closest wall so a corner item doesn't project + // onto both adjacent walls. + 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) => { // Project item position onto wall axis @@ -342,23 +380,38 @@ function furnitureEdgeDistanceToWall( // Perpendicular distance from centre to wall line 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); - // 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; - if (alongWallCenter < -halfAlong || alongWallCenter > wallLen + halfAlong) { + const alongTol = 0.1; + if (alongWallCenter < -alongTol || alongWallCenter > wallLen + alongTol) { return Infinity; } 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( wall: Wall, furnitureItems: readonly FurnitureItem[], wallThreshold: number = 0.15, + allWalls?: readonly Wall[], ): readonly ProjectedFurniture[] { const axis = getProjectionAxis(wall); const { start, end, length: wallLen } = axis; @@ -370,7 +423,16 @@ export function projectFurnitureItems( return furnitureItems .filter((item) => { 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) => { // Convert anchored (x, y) to rotated bounding-box centre.