feat(export): default-view option, Cyrillic PDF, per-wall projection pages

- Add 'Default View' scope to PNG export (fit-to-room for 2D, birds-eye for 3D)
- PDF now includes 3D top view, 2D default view, and every wall projection
- Embed DejaVu Sans TTF so Cyrillic room names render correctly in PDFs
- Preserve image aspect ratios and top-align on PDF pages
- Render each wall projection on its own PDF page at native aspect
- Mount a hidden per-wall projection renderer during export so all walls
  are captured even when the visible panel is in tab mode
- Keep 3D view mounted off-screen once requested so PDF can always snap
  to birds-eye without a visible view switch
- Outlet projection coordinate labels now show 3 decimal places
This commit is contained in:
2026-04-14 18:49:26 +03:00
parent ea4fb5c6c9
commit 68d2b5e3b0
11 changed files with 595 additions and 148 deletions
+1
View File
@@ -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",
@@ -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:",
@@ -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": "Масштаб:",
@@ -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<Konva.Stage | null>(null);
const projectionStageMapRef = useRef<Map<string, Konva.Stage>>(new Map());
const threeCanvasRef = useRef<HTMLCanvasElement | null>(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<Map<string, Konva.Stage>>(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) {
</button>
</div>
{viewMode === '3d' && (
{mount3D && (
<div
className={styles.canvasContainer}
style={
viewMode !== '3d'
? {
position: 'absolute',
width: '1200px',
height: '900px',
overflow: 'hidden',
pointerEvents: 'none',
opacity: 0,
zIndex: -1,
}
: undefined
}
ref={(el) => {
// Grab the R3F canvas element for 3D export
if (el) {
@@ -773,7 +821,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
}}
>
<Suspense fallback={<div className={styles.loading3D}>{t('editor.loading3D')}</div>}>
<Room3DView />
<Room3DView presetTriggerRef={preset3DRef} />
</Suspense>
</div>
)}
@@ -805,6 +853,57 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
onStageRef={handleProjectionStageRef}
/>
</div>
{/* 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 && (
<div
aria-hidden
style={{
position: 'absolute',
left: '-99999px',
top: 0,
pointerEvents: 'none',
opacity: 0,
zIndex: -1,
}}
>
{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 (
<div key={`export-proj-${wall.id}`} style={{ width: `${w}px`, height: `${h}px` }}>
<WallProjectionView
wall={wall}
openings={state.openings}
electricalItems={state.layerVisibility.electrical ? state.electricalItems : []}
furnitureItems={state.layerVisibility.furniture ? state.furnitureItems : []}
annotations={state.layerVisibility.annotations ? state.annotations : []}
globalFurnitureOpacity={state.globalFurnitureOpacity}
wallHeight={state.room.wallHeight}
plinthHeight={state.room.plinthHeight}
stretchCeilingOffset={
state.layerVisibility.stretchCeiling ? state.room.stretchCeilingOffset : 0
}
outletWidth={state.room.outletWidth}
outletHeight={state.room.outletHeight}
selectedIds={state.selectedIds}
isHighlighted={false}
onSelectElement={() => {}}
onStageRef={handleExportProjectionStageRef}
width={w}
height={h}
showMeasurements={state.layerVisibility.measurements}
/>
</div>
);
})}
</div>
)}
</div>
<PropertiesPanel />
</div>
@@ -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}
/>
@@ -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<Konva.Stage | null>;
readonly projectionStageRefs: React.RefObject<Map<string, Konva.Stage>>;
/** Per-wall stages that are rendered off-screen at export resolution. */
readonly exportProjectionStageRefs: React.RefObject<Map<string, Konva.Stage>>;
readonly threeCanvasRef: React.RefObject<HTMLCanvasElement | null>;
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<ExportFormat>('png');
const [scope, setScope] = useState<ExportScope>('current-view');
const [scope, setScope] = useState<ExportScope>('default-view');
const [includeGrid, setIncludeGrid] = useState(false);
const [pixelRatio, setPixelRatio] = useState(2);
const [isExporting, setIsExporting] = useState(false);
const [error, setError] = useState<string | null>(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<string | null> => {
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<string | null> => {
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 = (
<div className={styles.footerButtons}>
@@ -263,6 +380,16 @@ export function ExportDialog({
/>
{t('export.currentView')}
</label>
<label className={styles.radioLabel}>
<input
type="radio"
name="scope"
value="default-view"
checked={scope === 'default-view'}
onChange={() => setScope('default-view')}
/>
{t('export.defaultView')}
</label>
<label className={styles.radioLabel}>
<input
type="radio"
@@ -277,6 +404,17 @@ export function ExportDialog({
</div>
)}
{format === 'pdf' && (
<div className={styles.fieldGroup}>
<span className={styles.fieldLabel}>{t('export.pdfIncludesLabel')}</span>
<ul className={styles.pdfIncludesList}>
<li>{t('export.pdfIncludes3DTop')}</li>
<li>{t('export.pdfIncludes2DDefault')}</li>
<li>{t('export.pdfIncludesProjections')}</li>
</ul>
</div>
)}
{/* Options */}
<div className={styles.fieldGroup}>
<span className={styles.fieldLabel}>{t('export.options')}</span>
@@ -119,3 +119,13 @@
color: var(--color-danger-600);
padding: var(--space-1) 0;
}
.pdfIncludesList {
margin: 0;
padding-left: var(--space-4);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
@@ -1,5 +1,6 @@
import type Konva from 'konva';
import type { jsPDF as JsPDFType } from 'jspdf';
import { applyPdfUnicodeFont, PDF_FONT_FAMILY } from './pdfFonts';
// ── PNG Export Options ──
@@ -102,11 +103,51 @@ async function loadJsPDF(): Promise<typeof import('jspdf')> {
return import('jspdf');
}
/**
* Add an image inside a bounding box, preserving its native aspect ratio and
* centering it inside the box. Uses jsPDF's getImageProperties to read the
* source dimensions from the PNG data URL.
*/
function addImageFit(
pdf: JsPDFType,
dataUrl: string,
boxX: number,
boxY: number,
boxW: number,
boxH: number,
): void {
if (boxW <= 0 || boxH <= 0) return;
let drawW = boxW;
let drawH = boxH;
try {
const props = pdf.getImageProperties(dataUrl);
if (props.width > 0 && props.height > 0) {
const aspect = props.width / props.height;
drawW = boxW;
drawH = boxW / aspect;
if (drawH > boxH) {
drawH = boxH;
drawW = boxH * aspect;
}
}
} catch {
// Fall back to stretched-fit if properties can't be read.
}
const offX = boxX + (boxW - drawW) / 2;
// Top-align so the image sits directly under the page title instead of
// getting pushed to the vertical centre of the page (which looks like a
// big empty strip above the image for wide walls on A4 landscape).
const offY = boxY;
pdf.addImage(dataUrl, 'PNG', offX, offY, drawW, drawH);
}
/**
* Create a single-room PDF with:
* - Room name header
* - 2D top-down view
* - Up to 4 wall projection views
* - Page 1: 3D top-down view (default camera) filling the page
* - Page 2: 2D floor plan (default view)
* - One page per wall projection (default view)
*
* Images are embedded with their native aspect ratio preserved.
*/
export async function createRoomPdf(
roomName: string,
@@ -116,52 +157,53 @@ export async function createRoomPdf(
): Promise<JsPDFType> {
const { jsPDF } = await loadJsPDF();
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
await applyPdfUnicodeFont(pdf);
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
// ── Page 1: Room name + 2D view ──
pdf.setFontSize(18);
pdf.text(roomName, pageWidth / 2, 15, { align: 'center' });
const MARGIN = 12;
const TITLE_Y = 12;
const IMAGE_TOP = 18;
if (topDownDataUrl) {
const imgWidth = pageWidth - 30;
const imgHeight = (pageHeight - 40) * 0.55;
pdf.addImage(topDownDataUrl, 'PNG', 15, 25, imgWidth, imgHeight);
}
let pageIndex = 0;
// ── Page 2: Wall projections (if any) ──
if (projectionDataUrls.length > 0) {
pdf.addPage('a4', 'landscape');
pdf.setFontSize(14);
pdf.text(`${roomName} - Wall Projections`, pageWidth / 2, 15, { align: 'center' });
const addTitledPage = (title: string, fontSize: number): void => {
if (pageIndex > 0) pdf.addPage('a4', 'landscape');
pageIndex += 1;
pdf.setFont(PDF_FONT_FAMILY, 'normal');
pdf.setFontSize(fontSize);
pdf.text(title, pageWidth / 2, TITLE_Y, { align: 'center' });
};
const cols = 2;
const rows = 2;
const cellW = (pageWidth - 30) / cols;
const cellH = (pageHeight - 35) / rows;
for (let i = 0; i < Math.min(projectionDataUrls.length, 4); i++) {
const col = i % cols;
const row = Math.floor(i / cols);
const x = 15 + col * cellW;
const y = 22 + row * cellH;
const proj = projectionDataUrls[i];
pdf.setFontSize(9);
pdf.text(proj.label, x + cellW / 2, y + 2, { align: 'center' });
pdf.addImage(proj.dataUrl, 'PNG', x + 2, y + 5, cellW - 4, cellH - 10);
}
}
// ── Page 3: 3D view (if available) ──
// ── Page 1: 3D top view ──
if (view3dDataUrl) {
pdf.addPage('a4', 'landscape');
pdf.setFontSize(14);
pdf.text(`${roomName} - 3D View`, pageWidth / 2, 15, { align: 'center' });
addTitledPage(roomName, 18);
const boxH = pageHeight - IMAGE_TOP - MARGIN;
const boxW = pageWidth - MARGIN * 2;
addImageFit(pdf, view3dDataUrl, MARGIN, IMAGE_TOP, boxW, boxH);
}
const imgWidth = pageWidth - 30;
const imgHeight = pageHeight - 30;
pdf.addImage(view3dDataUrl, 'PNG', 15, 20, imgWidth, imgHeight);
// ── Page 2: 2D floor plan ──
if (topDownDataUrl) {
addTitledPage(`${roomName} — 2D`, 16);
const boxH = pageHeight - IMAGE_TOP - MARGIN;
const boxW = pageWidth - MARGIN * 2;
addImageFit(pdf, topDownDataUrl, MARGIN, IMAGE_TOP, boxW, boxH);
}
// ── One page per wall projection ──
for (const proj of projectionDataUrls) {
addTitledPage(`${roomName}${proj.label}`, 16);
const boxH = pageHeight - IMAGE_TOP - MARGIN;
const boxW = pageWidth - MARGIN * 2;
addImageFit(pdf, proj.dataUrl, MARGIN, IMAGE_TOP, boxW, boxH);
}
// Fallback: if nothing at all was captured, emit a placeholder first page.
if (pageIndex === 0) {
pdf.setFont(PDF_FONT_FAMILY, 'normal');
pdf.setFontSize(18);
pdf.text(roomName, pageWidth / 2, TITLE_Y, { align: 'center' });
}
return pdf;
@@ -180,10 +222,18 @@ export async function createApartmentPdf(
): Promise<JsPDFType> {
const { jsPDF } = await loadJsPDF();
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
await applyPdfUnicodeFont(pdf);
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const MARGIN = 12;
const TITLE_Y = 12;
const IMAGE_TOP = 18;
const boxW = pageWidth - MARGIN * 2;
const boxH = pageHeight - IMAGE_TOP - MARGIN;
// ── Cover page ──
pdf.setFont(PDF_FONT_FAMILY, 'normal');
pdf.setFontSize(28);
pdf.text(apartmentName, pageWidth / 2, pageHeight / 3, { align: 'center' });
pdf.setFontSize(14);
@@ -198,40 +248,23 @@ export async function createApartmentPdf(
{ align: 'center' },
);
const addTitledPage = (title: string, fontSize: number): void => {
pdf.addPage('a4', 'landscape');
pdf.setFont(PDF_FONT_FAMILY, 'normal');
pdf.setFontSize(fontSize);
pdf.text(title, pageWidth / 2, TITLE_Y, { align: 'center' });
};
// ── Room pages ──
for (const room of rooms) {
pdf.addPage('a4', 'landscape');
pdf.setFontSize(18);
pdf.text(room.roomName, pageWidth / 2, 15, { align: 'center' });
if (room.topDownDataUrl) {
const imgWidth = pageWidth - 30;
const imgHeight = (pageHeight - 40) * 0.55;
pdf.addImage(room.topDownDataUrl, 'PNG', 15, 25, imgWidth, imgHeight);
addTitledPage(`${room.roomName} — 2D`, 16);
addImageFit(pdf, room.topDownDataUrl, MARGIN, IMAGE_TOP, boxW, boxH);
}
// Projections page
if (room.projectionDataUrls.length > 0) {
pdf.addPage('a4', 'landscape');
pdf.setFontSize(14);
pdf.text(`${room.roomName} - Wall Projections`, pageWidth / 2, 15, { align: 'center' });
const cols = 2;
const rows = 2;
const cellW = (pageWidth - 30) / cols;
const cellH = (pageHeight - 35) / rows;
for (let i = 0; i < Math.min(room.projectionDataUrls.length, 4); i++) {
const col = i % cols;
const row = Math.floor(i / cols);
const x = 15 + col * cellW;
const y = 22 + row * cellH;
const proj = room.projectionDataUrls[i];
pdf.setFontSize(9);
pdf.text(proj.label, x + cellW / 2, y + 2, { align: 'center' });
pdf.addImage(proj.dataUrl, 'PNG', x + 2, y + 5, cellW - 4, cellH - 10);
}
for (const proj of room.projectionDataUrls) {
addTitledPage(`${room.roomName}${proj.label}`, 16);
addImageFit(pdf, proj.dataUrl, MARGIN, IMAGE_TOP, boxW, boxH);
}
}
@@ -244,3 +277,63 @@ export async function createApartmentPdf(
export function sanitizeFilename(name: string): string {
return name.replace(/[^a-zA-Z0-9_\-\s]/g, '').replace(/\s+/g, '_');
}
// ── Default-view helpers ──
export interface Point2D {
readonly x: number;
readonly y: number;
}
/**
* Compute fit-to-room zoom and pan for a given canvas size and room shape,
* mirroring the auto-fit logic used by the 2D editor (RoomEditorLayout).
*/
export function computeFitView(
shape: readonly Point2D[],
canvasWidth: number,
canvasHeight: number,
padding = 80,
): { readonly zoom: number; readonly panX: number; readonly panY: number } | null {
if (shape.length === 0) return null;
if (canvasWidth <= 0 || canvasHeight <= 0) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const p of shape) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
const roomW = maxX - minX;
const roomH = maxY - minY;
if (roomW <= 0 || roomH <= 0) return null;
const scaleX = (canvasWidth - padding * 2) / roomW;
const scaleY = (canvasHeight - padding * 2) / roomH;
const zoom = Math.min(scaleX, scaleY, 300);
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
return {
zoom,
panX: canvasWidth / 2 - cx * zoom,
panY: canvasHeight / 2 - cy * zoom,
};
}
/** Await N animation frames. */
export function waitFrames(n = 2): Promise<void> {
return new Promise((resolve) => {
let count = 0;
const tick = (): void => {
count += 1;
if (count >= n) {
resolve();
} else {
requestAnimationFrame(tick);
}
};
requestAnimationFrame(tick);
});
}
@@ -0,0 +1,50 @@
import type { jsPDF as JsPDFType } from 'jspdf';
// Vite serves the TTF as an asset URL; the font is fetched on first use and
// the base64 result cached for subsequent exports. DejaVu Sans supports
// Latin, Latin-Extended, Cyrillic, Greek and more — enough for room names in
// any European language.
import dejaVuSansUrl from 'dejavu-fonts-ttf/ttf/DejaVuSans.ttf?url';
export const PDF_FONT_FAMILY = 'DejaVuSans';
let cachedBase64: string | null = null;
let inflight: Promise<string> | null = null;
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const CHUNK = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + CHUNK)));
}
return btoa(binary);
}
async function loadFontBase64(): Promise<string> {
if (cachedBase64) return cachedBase64;
if (inflight) return inflight;
inflight = (async () => {
const resp = await fetch(dejaVuSansUrl);
if (!resp.ok) throw new Error(`Failed to load PDF font: ${resp.status}`);
const buf = await resp.arrayBuffer();
cachedBase64 = arrayBufferToBase64(buf);
return cachedBase64;
})();
try {
return await inflight;
} finally {
inflight = null;
}
}
/**
* Register a Unicode TTF font (DejaVu Sans) with a jsPDF instance and make it
* the active font. Required for Cyrillic / non-Latin text — jsPDF's built-in
* Helvetica is WinAnsi-only.
*/
export async function applyPdfUnicodeFont(pdf: JsPDFType): Promise<void> {
const base64 = await loadFontBase64();
pdf.addFileToVFS('DejaVuSans.ttf', base64);
pdf.addFont('DejaVuSans.ttf', PDF_FONT_FAMILY, 'normal');
pdf.setFont(PDF_FONT_FAMILY, 'normal');
}
@@ -251,7 +251,7 @@ export function ProjectionMeasurements({
const invertY = pe.item.type === 'OUTLET' && getOutletInvertCoordY(pe.item.metadata);
const displayX = invertX ? wallLen - pe.position.alongWall : pe.position.alongWall;
const displayY = invertY ? wallHeight - pe.elevation : pe.elevation;
const coordLabel = `(${displayX.toFixed(2)}; ${displayY.toFixed(2)})`;
const coordLabel = `(${displayX.toFixed(3)}; ${displayY.toFixed(3)})`;
const labelX = center.x + halfWidthPx + 6;
const labelY = center.y - 6;
// Rough text-width estimate (monospace-ish): ~5.5px per char at fontSize 9.
@@ -68,11 +68,20 @@ function NearestWallTracker({ walls, onUpdate }: { readonly walls: readonly Wall
return null;
}
interface Room3DViewProps {
/**
* Optional mutable ref — set by the room editor so external components
* (notably the export dialog) can imperatively snap the camera to a preset
* (e.g. 'birds-eye') before taking a screenshot.
*/
readonly presetTriggerRef?: React.MutableRefObject<((preset: CameraPreset) => void) | null>;
}
/**
* Room3DView — read-only 3D perspective view of the room.
* Renders inside a @react-three/fiber Canvas with orbit controls.
*/
export function Room3DView() {
export function Room3DView({ presetTriggerRef }: Room3DViewProps = {}) {
const { t, i18n } = useTranslation();
const { state, dispatch } = useEditor();
const { theme } = useTheme();
@@ -128,6 +137,16 @@ export function Room3DView() {
setTimeout(() => setActivePreset(null), 100);
}, []);
// Expose handlePreset so the export dialog can snap the camera to 'birds-eye'
// before capturing the 3D canvas for PDF export.
useEffect(() => {
if (!presetTriggerRef) return;
presetTriggerRef.current = handlePreset;
return () => {
presetTriggerRef.current = null;
};
}, [presetTriggerRef, handlePreset]);
// Reset the camera to the default (Bird's Eye) view. Bound to the Canvas
// element's onDoubleClick so double-tapping empty space snaps back.
const handleResetView = useCallback(() => {
+12
View File
@@ -27,6 +27,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",
@@ -3161,6 +3162,11 @@
"integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==",
"devOptional": true
},
"node_modules/dejavu-fonts-ttf": {
"version": "2.37.3",
"resolved": "https://registry.npmjs.org/dejavu-fonts-ttf/-/dejavu-fonts-ttf-2.37.3.tgz",
"integrity": "sha512-f1hd7jJbeQa1VWcw+K2KrTXS50zTMaHpVC4XIKJpNcDeYR5ajMtj/iLlQDYNvLOKamUB3ARVVCf79lNwNVztSQ=="
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -7191,6 +7197,7 @@
"@types/react-dom": "^19.0.0",
"@types/three": "^0.183.1",
"@vitejs/plugin-react": "^4.3.0",
"dejavu-fonts-ttf": "*",
"i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.2.1",
"jsdom": "^26.0.0",
@@ -8513,6 +8520,11 @@
"integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==",
"devOptional": true
},
"dejavu-fonts-ttf": {
"version": "2.37.3",
"resolved": "https://registry.npmjs.org/dejavu-fonts-ttf/-/dejavu-fonts-ttf-2.37.3.tgz",
"integrity": "sha512-f1hd7jJbeQa1VWcw+K2KrTXS50zTMaHpVC4XIKJpNcDeYR5ajMtj/iLlQDYNvLOKamUB3ARVVCf79lNwNVztSQ=="
},
"dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",