diff --git a/apps/client/package.json b/apps/client/package.json index 8f2c098..5e7a6a4 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -14,6 +14,7 @@ "@house-plan-maker/shared": "*", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", + "dejavu-fonts-ttf": "^2.37.3", "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.2.1", "jspdf": "^4.2.1", diff --git a/apps/client/public/locales/en/translation.json b/apps/client/public/locales/en/translation.json index 6d91b31..2dcced8 100644 --- a/apps/client/public/locales/en/translation.json +++ b/apps/client/public/locales/en/translation.json @@ -256,8 +256,14 @@ "properties.wallLightStyle.pendant-globe": "Pendant Globe", "properties.wallLightStyle.sconce-up": "Sconce Up", "properties.wallLightStyle.sconce-down": "Sconce Down", + "properties.wallLightStyle.wood-cube": "Wood Cube", + "properties.legs": "Legs", + "properties.legHeight": "Leg height", "properties.cordLength": "Cord length", "properties.lampSize": "Lamp size", + "properties.acUnitStyleLabel": "Style", + "properties.acUnitStyle.generic": "Generic", + "properties.acUnitStyle.lg": "LG", "properties.surfaceTexture": "Surface", "furnitureTexture.NONE": "None (solid color)", "furnitureTexture.WOOD_LIGHT": "Light Wood", @@ -341,7 +347,12 @@ "export.json": "JSON Data", "export.scope": "Scope", "export.currentView": "Current View", + "export.defaultView": "Default View", "export.allRoomViews": "All Room Views", + "export.pdfIncludesLabel": "PDF includes", + "export.pdfIncludes3DTop": "3D top view (default)", + "export.pdfIncludes2DDefault": "2D floor plan (default view)", + "export.pdfIncludesProjections": "All wall projections (default view)", "export.options": "Options", "export.includeGrid": "Include grid", "export.scaleFactor": "Scale factor:", diff --git a/apps/client/public/locales/ru/translation.json b/apps/client/public/locales/ru/translation.json index a93c7b1..598b656 100644 --- a/apps/client/public/locales/ru/translation.json +++ b/apps/client/public/locales/ru/translation.json @@ -259,8 +259,14 @@ "properties.wallLightStyle.pendant-globe": "Подвесной шар", "properties.wallLightStyle.sconce-up": "Бра вверх", "properties.wallLightStyle.sconce-down": "Бра вниз", + "properties.wallLightStyle.wood-cube": "Деревянный куб", + "properties.legs": "Ножки", + "properties.legHeight": "Высота ножек", "properties.cordLength": "Длина шнура", "properties.lampSize": "Размер светильника", + "properties.acUnitStyleLabel": "Стиль", + "properties.acUnitStyle.generic": "Обычный", + "properties.acUnitStyle.lg": "LG", "properties.surfaceTexture": "Поверхность", "furnitureTexture.NONE": "Нет (сплошной цвет)", "furnitureTexture.WOOD_LIGHT": "Светлое дерево", @@ -344,7 +350,12 @@ "export.json": "JSON данные", "export.scope": "Область", "export.currentView": "Текущий вид", + "export.defaultView": "Вид по умолчанию", "export.allRoomViews": "Все виды комнаты", + "export.pdfIncludesLabel": "PDF содержит", + "export.pdfIncludes3DTop": "3D вид сверху (по умолчанию)", + "export.pdfIncludes2DDefault": "2D план (вид по умолчанию)", + "export.pdfIncludesProjections": "Все проекции стен (вид по умолчанию)", "export.options": "Параметры", "export.includeGrid": "Включить сетку", "export.scaleFactor": "Масштаб:", diff --git a/apps/client/src/components/editor/RoomEditorLayout.tsx b/apps/client/src/components/editor/RoomEditorLayout.tsx index fe6da1a..5f6c301 100644 --- a/apps/client/src/components/editor/RoomEditorLayout.tsx +++ b/apps/client/src/components/editor/RoomEditorLayout.tsx @@ -13,8 +13,17 @@ import { ElectricalPalette } from './panels/ElectricalPalette'; import { FurniturePalette } from './panels/FurniturePalette'; import { CableLengthStatus } from './panels/CableLengthStatus'; import { ProjectionPanel } from './projection/ProjectionPanel'; +import { WallProjectionView } from './projection/WallProjectionView'; +import { wallLength as computeWallLength } from './utils/wallUtils'; import { ExportDialog } from './export/ExportDialog'; import { importRoomFromJson } from './export/roomFormat'; +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). +const EXPORT_PX_PER_M = 400; +const EXPORT_PROJ_PADDING = 80; const Room3DView = lazy(() => import('./three/Room3DView').then((m) => ({ default: m.Room3DView })), @@ -140,6 +149,15 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { const mainStageRef = useRef(null); const projectionStageMapRef = useRef>(new Map()); const threeCanvasRef = useRef(null); + const preset3DRef = useRef<((preset: CameraPreset) => void) | null>(null); + + // Once the 3D view has been requested (either by entering 3D mode or opening + // the export dialog) keep it mounted off-screen so exports can always + // capture a 3D image without a visible mode switch. + const [mount3D, setMount3D] = useState(false); + useEffect(() => { + if (viewMode === '3d' || showExport) setMount3D(true); + }, [viewMode, showExport]); const handleMainStageRef = useCallback((stage: Konva.Stage | null) => { mainStageRef.current = stage; @@ -153,6 +171,23 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { } }, []); + // ── Export-only projection stages ── + // The visible projection panel only mounts a single wall stage in 'tabs' + // mode, which means PDF/"all-room-views" exports would lose the other walls. + // We mount a hidden, per-wall copy of WallProjectionView while the export + // dialog is open so the export code can always reach every wall. + const exportProjectionStageMapRef = useRef>(new Map()); + const handleExportProjectionStageRef = useCallback( + (wallId: string, stage: Konva.Stage | null) => { + if (stage) { + exportProjectionStageMapRef.current.set(wallId, stage); + } else { + exportProjectionStageMapRef.current.delete(wallId); + } + }, + [], + ); + // ── Resize observer for canvas ── useEffect(() => { const container = canvasContainerRef.current; @@ -759,9 +794,22 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { - {viewMode === '3d' && ( + {mount3D && (
{ // Grab the R3F canvas element for 3D export if (el) { @@ -773,7 +821,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { }} > {t('editor.loading3D')}
}> - + )} @@ -805,6 +853,57 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { onStageRef={handleProjectionStageRef} /> + + {/* Hidden per-wall projection renderer used by the export dialog. + Each stage is sized to match its wall's native aspect ratio so the + captured PNG has minimal empty space. Mounted only while the + export dialog is open to avoid paying the render cost otherwise. */} + {showExport && ( +
+ {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); + return ( +
+ {}} + onStageRef={handleExportProjectionStageRef} + width={w} + height={h} + showMeasurements={state.layerVisibility.measurements} + /> +
+ ); + })} +
+ )} @@ -815,7 +914,10 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { onClose={() => setShowExport(false)} mainStageRef={mainStageRef} projectionStageRefs={projectionStageMapRef} + exportProjectionStageRefs={exportProjectionStageMapRef} threeCanvasRef={threeCanvasRef} + preset3DRef={preset3DRef} + canvasSize={canvasSize} is3DView={viewMode === '3d'} viewMode={viewMode} /> diff --git a/apps/client/src/components/editor/export/ExportDialog.tsx b/apps/client/src/components/editor/export/ExportDialog.tsx index cd23a52..00d2fce 100644 --- a/apps/client/src/components/editor/export/ExportDialog.tsx +++ b/apps/client/src/components/editor/export/ExportDialog.tsx @@ -9,21 +9,28 @@ import { downloadBlob, createRoomPdf, sanitizeFilename, + computeFitView, + waitFrames, } from './exportUtils'; import { exportRoomToJson } from './roomFormat'; import { wallDirectionLabel } from '../utils/projectionMapping'; import type Konva from 'konva'; +import type { CameraPreset } from '../three/CameraControls'; import styles from './export-dialog.module.css'; export type ExportFormat = 'png' | 'pdf' | 'json'; -export type ExportScope = 'current-view' | 'room'; +export type ExportScope = 'current-view' | 'default-view' | 'room'; interface ExportDialogProps { readonly open: boolean; readonly onClose: () => void; readonly mainStageRef: React.RefObject; readonly projectionStageRefs: React.RefObject>; + /** Per-wall stages that are rendered off-screen at export resolution. */ + readonly exportProjectionStageRefs: React.RefObject>; readonly threeCanvasRef: React.RefObject; + readonly preset3DRef: React.RefObject<((preset: CameraPreset) => void) | null>; + readonly canvasSize: { readonly width: number; readonly height: number } | null; readonly is3DView: boolean; readonly viewMode?: '2d' | '3d' | 'projections'; } @@ -33,21 +40,135 @@ export function ExportDialog({ onClose, mainStageRef, projectionStageRefs, + exportProjectionStageRefs, threeCanvasRef, + preset3DRef, + canvasSize, is3DView, viewMode = '2d', }: ExportDialogProps) { const { t } = useTranslation(); - const { state } = useEditor(); + const { state, dispatch } = useEditor(); const { room, walls } = state; const [format, setFormat] = useState('png'); - const [scope, setScope] = useState('current-view'); + const [scope, setScope] = useState('default-view'); const [includeGrid, setIncludeGrid] = useState(false); const [pixelRatio, setPixelRatio] = useState(2); const [isExporting, setIsExporting] = useState(false); const [error, setError] = useState(null); + /** + * 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 => { + 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; + + let resized = false; + const container = stage.container(); + const parent = container?.parentElement; + const origParentStyle = parent?.getAttribute('style') ?? ''; + if (stageW === 0 || stageH === 0) { + resized = true; + if (parent) { + parent.setAttribute( + 'style', + `position:absolute;width:${targetW}px;height:${targetH}px;overflow:hidden;pointer-events:none;opacity:0;`, + ); + } + stage.width(targetW); + stage.height(targetH); + } + + const fit = computeFitView(state.room.shape, targetW, targetH); + if (!fit) { + if (resized) { + stage.width(stageW); + stage.height(stageH); + if (parent) parent.setAttribute('style', origParentStyle); + } + return null; + } + + const savedZoom = state.zoom; + const savedOffset = state.panOffset; + dispatch({ type: 'SET_VIEW', zoom: fit.zoom, offset: { x: fit.panX, y: fit.panY } }); + await waitFrames(3); + stage.batchDraw(); + + const dataUrl = exportKonvaStageToDataUrl(stage, { pixelRatio, includeGrid: renderGrid }); + + dispatch({ type: 'SET_VIEW', zoom: savedZoom, offset: savedOffset }); + if (resized) { + stage.width(stageW); + stage.height(stageH); + if (parent) parent.setAttribute('style', origParentStyle); + } + return dataUrl; + }, + [mainStageRef, canvasSize, state.room.shape, state.zoom, state.panOffset, dispatch, pixelRatio], + ); + + /** 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 every wall projection as a high-resolution image. Uses the + * dedicated hidden per-wall renderer (one stage per wall) so every wall is + * always present even when the visible projection panel is in tab mode. + * Waits briefly on the first call so the hidden render pass can settle. + */ + const captureAllProjections = useCallback( + async (): Promise<{ label: string; dataUrl: string }[]> => { + const out: { label: string; dataUrl: string }[] = []; + const projMap = exportProjectionStageRefs.current; + if (!projMap) return out; + + // The hidden export stages mount only while the dialog is open — give + // them a few frames to register their refs and draw their first frame. + for (let attempt = 0; attempt < 20 && projMap.size < walls.length; attempt += 1) { + await waitFrames(2); + } + await waitFrames(2); + + 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 }); + out.push({ label, dataUrl }); + } + } + return out; + }, + [exportProjectionStageRefs, walls, pixelRatio], + ); + const handleExport = useCallback(async () => { setIsExporting(true); setError(null); @@ -57,7 +178,6 @@ export function ExportDialog({ if (format === 'png') { if (scope === 'current-view') { - // Export the currently visible view if (viewMode === '3d') { let canvas = threeCanvasRef.current; if (!canvas) { @@ -93,90 +213,69 @@ export function ExportDialog({ const dataUrl = exportKonvaStageToDataUrl(stage, { pixelRatio, includeGrid }); downloadDataUrl(dataUrl, `${baseName}_2d.png`); } - } else { - // Export all views for the room (2D + projections) - const stage = mainStageRef.current; - if (stage) { - const dataUrl = exportKonvaStageToDataUrl(stage, { pixelRatio, includeGrid }); + } else if (scope === 'default-view') { + // Default view: fit-to-room for 2D, birds-eye for 3D, + // all projections (always fit) for projections mode. + if (viewMode === '3d') { + const dataUrl = await capture3DBirdsEye(); + if (!dataUrl) { + setError(t('export.error.3dNotAvailable')); + return; + } + downloadDataUrl(dataUrl, `${baseName}_3d_top.png`); + } else if (viewMode === 'projections') { + const projs = await captureAllProjections(); + if (projs.length === 0) { + setError(t('export.error.2dNotAvailable')); + return; + } + for (const p of projs) { + const safeName = sanitizeFilename(p.label); + downloadDataUrl(p.dataUrl, `${baseName}_wall_${safeName}.png`); + } + } else { + const dataUrl = await captureMainStageAtFit(includeGrid); + if (!dataUrl) { + setError(t('export.error.2dNotAvailable')); + return; + } downloadDataUrl(dataUrl, `${baseName}_2d.png`); } - - // Export projection views - const projMap = projectionStageRefs.current; - if (projMap) { - for (const wall of walls) { - const projStage = projMap.get(wall.id); - if (projStage) { - const label = wallDirectionLabel(wall); - const safeName = sanitizeFilename(label); - const dataUrl = exportKonvaStageToDataUrl(projStage, { pixelRatio }); - downloadDataUrl(dataUrl, `${baseName}_wall_${safeName}.png`); - } - } + } else { + // All room views: 2D default + all projections + const dataUrl2d = await captureMainStageAtFit(includeGrid); + if (dataUrl2d) { + downloadDataUrl(dataUrl2d, `${baseName}_2d.png`); + } + const projs = await captureAllProjections(); + for (const p of projs) { + const safeName = sanitizeFilename(p.label); + downloadDataUrl(p.dataUrl, `${baseName}_wall_${safeName}.png`); } } } else if (format === 'json') { - // JSON export const jsonStr = exportRoomToJson(state); const blob = new Blob([jsonStr], { type: 'application/json' }); downloadBlob(blob, `${baseName}.json`); } else { - // PDF export - // Capture 2D view — temporarily restore size if hidden - let topDownDataUrl: string | null = null; - const stage = mainStageRef.current; - if (stage) { - const wasHidden = stage.width() === 0; - if (wasHidden) { - // Temporarily make the stage visible for capture - const container = stage.container(); - const parent = container?.parentElement; - const origStyle = parent?.getAttribute('style') ?? ''; - if (parent) { - parent.setAttribute('style', 'position:absolute;width:1200px;height:900px;overflow:hidden;pointer-events:none;opacity:0;'); - } - stage.width(1200); - stage.height(900); - stage.batchDraw(); - } - if (stage.width() > 0 && stage.height() > 0) { - topDownDataUrl = exportKonvaStageToDataUrl(stage, { pixelRatio, includeGrid }); - } - if (wasHidden) { - // Restore hidden state - const container = stage.container(); - const parent = container?.parentElement; - if (parent) { - parent.setAttribute('style', 'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;'); - } - stage.width(0); - stage.height(0); - } - } + // 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'); - // Capture projection views — they may not be mounted if not in projection mode - const projectionDataUrls: { label: string; dataUrl: string }[] = []; - const projMap = projectionStageRefs.current; - if (projMap) { - for (const wall of walls) { - const projStage = projMap.get(wall.id); - if (projStage && projStage.width() > 0 && projStage.height() > 0) { - const label = wallDirectionLabel(wall); - const dataUrl = exportKonvaStageToDataUrl(projStage, { pixelRatio }); - projectionDataUrls.push({ label, dataUrl }); - } - } - } + const topDownDataUrl = await captureMainStageAtFit(includeGrid); + const projectionDataUrls = await captureAllProjections(); - let view3dDataUrl: string | null = null; - // Try the ref first, then fall back to querying the DOM + // 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; } - if (canvas3d && canvas3d.width > 0 && canvas3d.height > 0) { - view3dDataUrl = exportThreeCanvasToDataUrl(canvas3d); - } + const view3dDataUrl = + canvas3d && canvas3d.width > 0 && canvas3d.height > 0 + ? exportThreeCanvasToDataUrl(canvas3d) + : null; const pdf = await createRoomPdf(room.name, topDownDataUrl, projectionDataUrls, view3dDataUrl); const blob = pdf.output('blob'); @@ -190,7 +289,25 @@ export function ExportDialog({ } finally { setIsExporting(false); } - }, [format, scope, includeGrid, pixelRatio, is3DView, state, room, walls, mainStageRef, projectionStageRefs, threeCanvasRef, onClose, t]); + }, [ + format, + scope, + includeGrid, + pixelRatio, + viewMode, + state, + room, + walls, + mainStageRef, + projectionStageRefs, + threeCanvasRef, + preset3DRef, + captureMainStageAtFit, + capture3DBirdsEye, + captureAllProjections, + onClose, + t, + ]); const footer = (
@@ -263,6 +380,16 @@ export function ExportDialog({ /> {t('export.currentView')} +