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:
@@ -14,6 +14,7 @@
|
|||||||
"@house-plan-maker/shared": "*",
|
"@house-plan-maker/shared": "*",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.5.0",
|
"@react-three/fiber": "^9.5.0",
|
||||||
|
"dejavu-fonts-ttf": "^2.37.3",
|
||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.3",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
|
|||||||
@@ -256,8 +256,14 @@
|
|||||||
"properties.wallLightStyle.pendant-globe": "Pendant Globe",
|
"properties.wallLightStyle.pendant-globe": "Pendant Globe",
|
||||||
"properties.wallLightStyle.sconce-up": "Sconce Up",
|
"properties.wallLightStyle.sconce-up": "Sconce Up",
|
||||||
"properties.wallLightStyle.sconce-down": "Sconce Down",
|
"properties.wallLightStyle.sconce-down": "Sconce Down",
|
||||||
|
"properties.wallLightStyle.wood-cube": "Wood Cube",
|
||||||
|
"properties.legs": "Legs",
|
||||||
|
"properties.legHeight": "Leg height",
|
||||||
"properties.cordLength": "Cord length",
|
"properties.cordLength": "Cord length",
|
||||||
"properties.lampSize": "Lamp size",
|
"properties.lampSize": "Lamp size",
|
||||||
|
"properties.acUnitStyleLabel": "Style",
|
||||||
|
"properties.acUnitStyle.generic": "Generic",
|
||||||
|
"properties.acUnitStyle.lg": "LG",
|
||||||
"properties.surfaceTexture": "Surface",
|
"properties.surfaceTexture": "Surface",
|
||||||
"furnitureTexture.NONE": "None (solid color)",
|
"furnitureTexture.NONE": "None (solid color)",
|
||||||
"furnitureTexture.WOOD_LIGHT": "Light Wood",
|
"furnitureTexture.WOOD_LIGHT": "Light Wood",
|
||||||
@@ -341,7 +347,12 @@
|
|||||||
"export.json": "JSON Data",
|
"export.json": "JSON Data",
|
||||||
"export.scope": "Scope",
|
"export.scope": "Scope",
|
||||||
"export.currentView": "Current View",
|
"export.currentView": "Current View",
|
||||||
|
"export.defaultView": "Default View",
|
||||||
"export.allRoomViews": "All Room Views",
|
"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.options": "Options",
|
||||||
"export.includeGrid": "Include grid",
|
"export.includeGrid": "Include grid",
|
||||||
"export.scaleFactor": "Scale factor:",
|
"export.scaleFactor": "Scale factor:",
|
||||||
|
|||||||
@@ -259,8 +259,14 @@
|
|||||||
"properties.wallLightStyle.pendant-globe": "Подвесной шар",
|
"properties.wallLightStyle.pendant-globe": "Подвесной шар",
|
||||||
"properties.wallLightStyle.sconce-up": "Бра вверх",
|
"properties.wallLightStyle.sconce-up": "Бра вверх",
|
||||||
"properties.wallLightStyle.sconce-down": "Бра вниз",
|
"properties.wallLightStyle.sconce-down": "Бра вниз",
|
||||||
|
"properties.wallLightStyle.wood-cube": "Деревянный куб",
|
||||||
|
"properties.legs": "Ножки",
|
||||||
|
"properties.legHeight": "Высота ножек",
|
||||||
"properties.cordLength": "Длина шнура",
|
"properties.cordLength": "Длина шнура",
|
||||||
"properties.lampSize": "Размер светильника",
|
"properties.lampSize": "Размер светильника",
|
||||||
|
"properties.acUnitStyleLabel": "Стиль",
|
||||||
|
"properties.acUnitStyle.generic": "Обычный",
|
||||||
|
"properties.acUnitStyle.lg": "LG",
|
||||||
"properties.surfaceTexture": "Поверхность",
|
"properties.surfaceTexture": "Поверхность",
|
||||||
"furnitureTexture.NONE": "Нет (сплошной цвет)",
|
"furnitureTexture.NONE": "Нет (сплошной цвет)",
|
||||||
"furnitureTexture.WOOD_LIGHT": "Светлое дерево",
|
"furnitureTexture.WOOD_LIGHT": "Светлое дерево",
|
||||||
@@ -344,7 +350,12 @@
|
|||||||
"export.json": "JSON данные",
|
"export.json": "JSON данные",
|
||||||
"export.scope": "Область",
|
"export.scope": "Область",
|
||||||
"export.currentView": "Текущий вид",
|
"export.currentView": "Текущий вид",
|
||||||
|
"export.defaultView": "Вид по умолчанию",
|
||||||
"export.allRoomViews": "Все виды комнаты",
|
"export.allRoomViews": "Все виды комнаты",
|
||||||
|
"export.pdfIncludesLabel": "PDF содержит",
|
||||||
|
"export.pdfIncludes3DTop": "3D вид сверху (по умолчанию)",
|
||||||
|
"export.pdfIncludes2DDefault": "2D план (вид по умолчанию)",
|
||||||
|
"export.pdfIncludesProjections": "Все проекции стен (вид по умолчанию)",
|
||||||
"export.options": "Параметры",
|
"export.options": "Параметры",
|
||||||
"export.includeGrid": "Включить сетку",
|
"export.includeGrid": "Включить сетку",
|
||||||
"export.scaleFactor": "Масштаб:",
|
"export.scaleFactor": "Масштаб:",
|
||||||
|
|||||||
@@ -13,8 +13,17 @@ import { ElectricalPalette } from './panels/ElectricalPalette';
|
|||||||
import { FurniturePalette } from './panels/FurniturePalette';
|
import { FurniturePalette } from './panels/FurniturePalette';
|
||||||
import { CableLengthStatus } from './panels/CableLengthStatus';
|
import { CableLengthStatus } from './panels/CableLengthStatus';
|
||||||
import { ProjectionPanel } from './projection/ProjectionPanel';
|
import { ProjectionPanel } from './projection/ProjectionPanel';
|
||||||
|
import { WallProjectionView } from './projection/WallProjectionView';
|
||||||
|
import { wallLength as computeWallLength } from './utils/wallUtils';
|
||||||
import { ExportDialog } from './export/ExportDialog';
|
import { ExportDialog } from './export/ExportDialog';
|
||||||
import { importRoomFromJson } from './export/roomFormat';
|
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(() =>
|
const Room3DView = lazy(() =>
|
||||||
import('./three/Room3DView').then((m) => ({ default: m.Room3DView })),
|
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 mainStageRef = useRef<Konva.Stage | null>(null);
|
||||||
const projectionStageMapRef = useRef<Map<string, Konva.Stage>>(new Map());
|
const projectionStageMapRef = useRef<Map<string, Konva.Stage>>(new Map());
|
||||||
const threeCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
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) => {
|
const handleMainStageRef = useCallback((stage: Konva.Stage | null) => {
|
||||||
mainStageRef.current = stage;
|
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 ──
|
// ── Resize observer for canvas ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = canvasContainerRef.current;
|
const container = canvasContainerRef.current;
|
||||||
@@ -759,9 +794,22 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === '3d' && (
|
{mount3D && (
|
||||||
<div
|
<div
|
||||||
className={styles.canvasContainer}
|
className={styles.canvasContainer}
|
||||||
|
style={
|
||||||
|
viewMode !== '3d'
|
||||||
|
? {
|
||||||
|
position: 'absolute',
|
||||||
|
width: '1200px',
|
||||||
|
height: '900px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
opacity: 0,
|
||||||
|
zIndex: -1,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
// Grab the R3F canvas element for 3D export
|
// Grab the R3F canvas element for 3D export
|
||||||
if (el) {
|
if (el) {
|
||||||
@@ -773,7 +821,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Suspense fallback={<div className={styles.loading3D}>{t('editor.loading3D')}</div>}>
|
<Suspense fallback={<div className={styles.loading3D}>{t('editor.loading3D')}</div>}>
|
||||||
<Room3DView />
|
<Room3DView presetTriggerRef={preset3DRef} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -805,6 +853,57 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
onStageRef={handleProjectionStageRef}
|
onStageRef={handleProjectionStageRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<PropertiesPanel />
|
<PropertiesPanel />
|
||||||
</div>
|
</div>
|
||||||
@@ -815,7 +914,10 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
onClose={() => setShowExport(false)}
|
onClose={() => setShowExport(false)}
|
||||||
mainStageRef={mainStageRef}
|
mainStageRef={mainStageRef}
|
||||||
projectionStageRefs={projectionStageMapRef}
|
projectionStageRefs={projectionStageMapRef}
|
||||||
|
exportProjectionStageRefs={exportProjectionStageMapRef}
|
||||||
threeCanvasRef={threeCanvasRef}
|
threeCanvasRef={threeCanvasRef}
|
||||||
|
preset3DRef={preset3DRef}
|
||||||
|
canvasSize={canvasSize}
|
||||||
is3DView={viewMode === '3d'}
|
is3DView={viewMode === '3d'}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,21 +9,28 @@ import {
|
|||||||
downloadBlob,
|
downloadBlob,
|
||||||
createRoomPdf,
|
createRoomPdf,
|
||||||
sanitizeFilename,
|
sanitizeFilename,
|
||||||
|
computeFitView,
|
||||||
|
waitFrames,
|
||||||
} from './exportUtils';
|
} from './exportUtils';
|
||||||
import { exportRoomToJson } from './roomFormat';
|
import { exportRoomToJson } from './roomFormat';
|
||||||
import { wallDirectionLabel } from '../utils/projectionMapping';
|
import { wallDirectionLabel } from '../utils/projectionMapping';
|
||||||
import type Konva from 'konva';
|
import type Konva from 'konva';
|
||||||
|
import type { CameraPreset } from '../three/CameraControls';
|
||||||
import styles from './export-dialog.module.css';
|
import styles from './export-dialog.module.css';
|
||||||
|
|
||||||
export type ExportFormat = 'png' | 'pdf' | 'json';
|
export type ExportFormat = 'png' | 'pdf' | 'json';
|
||||||
export type ExportScope = 'current-view' | 'room';
|
export type ExportScope = 'current-view' | 'default-view' | 'room';
|
||||||
|
|
||||||
interface ExportDialogProps {
|
interface ExportDialogProps {
|
||||||
readonly open: boolean;
|
readonly open: boolean;
|
||||||
readonly onClose: () => void;
|
readonly onClose: () => void;
|
||||||
readonly mainStageRef: React.RefObject<Konva.Stage | null>;
|
readonly mainStageRef: React.RefObject<Konva.Stage | null>;
|
||||||
readonly projectionStageRefs: React.RefObject<Map<string, Konva.Stage>>;
|
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 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 is3DView: boolean;
|
||||||
readonly viewMode?: '2d' | '3d' | 'projections';
|
readonly viewMode?: '2d' | '3d' | 'projections';
|
||||||
}
|
}
|
||||||
@@ -33,21 +40,135 @@ export function ExportDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
mainStageRef,
|
mainStageRef,
|
||||||
projectionStageRefs,
|
projectionStageRefs,
|
||||||
|
exportProjectionStageRefs,
|
||||||
threeCanvasRef,
|
threeCanvasRef,
|
||||||
|
preset3DRef,
|
||||||
|
canvasSize,
|
||||||
is3DView,
|
is3DView,
|
||||||
viewMode = '2d',
|
viewMode = '2d',
|
||||||
}: ExportDialogProps) {
|
}: ExportDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state } = useEditor();
|
const { state, dispatch } = useEditor();
|
||||||
const { room, walls } = state;
|
const { room, walls } = state;
|
||||||
|
|
||||||
const [format, setFormat] = useState<ExportFormat>('png');
|
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 [includeGrid, setIncludeGrid] = useState(false);
|
||||||
const [pixelRatio, setPixelRatio] = useState(2);
|
const [pixelRatio, setPixelRatio] = useState(2);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 () => {
|
const handleExport = useCallback(async () => {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -57,7 +178,6 @@ export function ExportDialog({
|
|||||||
|
|
||||||
if (format === 'png') {
|
if (format === 'png') {
|
||||||
if (scope === 'current-view') {
|
if (scope === 'current-view') {
|
||||||
// Export the currently visible view
|
|
||||||
if (viewMode === '3d') {
|
if (viewMode === '3d') {
|
||||||
let canvas = threeCanvasRef.current;
|
let canvas = threeCanvasRef.current;
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
@@ -93,90 +213,69 @@ export function ExportDialog({
|
|||||||
const dataUrl = exportKonvaStageToDataUrl(stage, { pixelRatio, includeGrid });
|
const dataUrl = exportKonvaStageToDataUrl(stage, { pixelRatio, includeGrid });
|
||||||
downloadDataUrl(dataUrl, `${baseName}_2d.png`);
|
downloadDataUrl(dataUrl, `${baseName}_2d.png`);
|
||||||
}
|
}
|
||||||
|
} 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 {
|
} else {
|
||||||
// Export all views for the room (2D + projections)
|
const dataUrl = await captureMainStageAtFit(includeGrid);
|
||||||
const stage = mainStageRef.current;
|
if (!dataUrl) {
|
||||||
if (stage) {
|
setError(t('export.error.2dNotAvailable'));
|
||||||
const dataUrl = exportKonvaStageToDataUrl(stage, { pixelRatio, includeGrid });
|
return;
|
||||||
|
}
|
||||||
downloadDataUrl(dataUrl, `${baseName}_2d.png`);
|
downloadDataUrl(dataUrl, `${baseName}_2d.png`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
// Export projection views
|
// All room views: 2D default + all projections
|
||||||
const projMap = projectionStageRefs.current;
|
const dataUrl2d = await captureMainStageAtFit(includeGrid);
|
||||||
if (projMap) {
|
if (dataUrl2d) {
|
||||||
for (const wall of walls) {
|
downloadDataUrl(dataUrl2d, `${baseName}_2d.png`);
|
||||||
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`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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') {
|
} else if (format === 'json') {
|
||||||
// JSON export
|
|
||||||
const jsonStr = exportRoomToJson(state);
|
const jsonStr = exportRoomToJson(state);
|
||||||
const blob = new Blob([jsonStr], { type: 'application/json' });
|
const blob = new Blob([jsonStr], { type: 'application/json' });
|
||||||
downloadBlob(blob, `${baseName}.json`);
|
downloadBlob(blob, `${baseName}.json`);
|
||||||
} else {
|
} else {
|
||||||
// PDF export
|
// PDF: always capture 3D birds-eye + 2D default + all projections.
|
||||||
// Capture 2D view — temporarily restore size if hidden
|
// Kick 3D render first so it has plenty of frames to settle while we
|
||||||
let topDownDataUrl: string | null = null;
|
// capture the 2D stage.
|
||||||
const stage = mainStageRef.current;
|
preset3DRef.current?.('birds-eye');
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture projection views — they may not be mounted if not in projection mode
|
const topDownDataUrl = await captureMainStageAtFit(includeGrid);
|
||||||
const projectionDataUrls: { label: string; dataUrl: string }[] = [];
|
const projectionDataUrls = await captureAllProjections();
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let view3dDataUrl: string | null = null;
|
// Let the 3D view finish applying birds-eye + rendering.
|
||||||
// Try the ref first, then fall back to querying the DOM
|
await waitFrames(8);
|
||||||
let canvas3d = threeCanvasRef.current;
|
let canvas3d = threeCanvasRef.current;
|
||||||
if (!canvas3d) {
|
if (!canvas3d) {
|
||||||
canvas3d = document.querySelector('canvas[data-engine]') as HTMLCanvasElement | null;
|
canvas3d = document.querySelector('canvas[data-engine]') as HTMLCanvasElement | null;
|
||||||
}
|
}
|
||||||
if (canvas3d && canvas3d.width > 0 && canvas3d.height > 0) {
|
const view3dDataUrl =
|
||||||
view3dDataUrl = exportThreeCanvasToDataUrl(canvas3d);
|
canvas3d && canvas3d.width > 0 && canvas3d.height > 0
|
||||||
}
|
? exportThreeCanvasToDataUrl(canvas3d)
|
||||||
|
: null;
|
||||||
|
|
||||||
const pdf = await createRoomPdf(room.name, topDownDataUrl, projectionDataUrls, view3dDataUrl);
|
const pdf = await createRoomPdf(room.name, topDownDataUrl, projectionDataUrls, view3dDataUrl);
|
||||||
const blob = pdf.output('blob');
|
const blob = pdf.output('blob');
|
||||||
@@ -190,7 +289,25 @@ export function ExportDialog({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsExporting(false);
|
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 = (
|
const footer = (
|
||||||
<div className={styles.footerButtons}>
|
<div className={styles.footerButtons}>
|
||||||
@@ -263,6 +380,16 @@ export function ExportDialog({
|
|||||||
/>
|
/>
|
||||||
{t('export.currentView')}
|
{t('export.currentView')}
|
||||||
</label>
|
</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}>
|
<label className={styles.radioLabel}>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -277,6 +404,17 @@ export function ExportDialog({
|
|||||||
</div>
|
</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 */}
|
{/* Options */}
|
||||||
<div className={styles.fieldGroup}>
|
<div className={styles.fieldGroup}>
|
||||||
<span className={styles.fieldLabel}>{t('export.options')}</span>
|
<span className={styles.fieldLabel}>{t('export.options')}</span>
|
||||||
|
|||||||
@@ -119,3 +119,13 @@
|
|||||||
color: var(--color-danger-600);
|
color: var(--color-danger-600);
|
||||||
padding: var(--space-1) 0;
|
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 Konva from 'konva';
|
||||||
import type { jsPDF as JsPDFType } from 'jspdf';
|
import type { jsPDF as JsPDFType } from 'jspdf';
|
||||||
|
import { applyPdfUnicodeFont, PDF_FONT_FAMILY } from './pdfFonts';
|
||||||
|
|
||||||
// ── PNG Export Options ──
|
// ── PNG Export Options ──
|
||||||
|
|
||||||
@@ -102,11 +103,51 @@ async function loadJsPDF(): Promise<typeof import('jspdf')> {
|
|||||||
return 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:
|
* Create a single-room PDF with:
|
||||||
* - Room name header
|
* - Page 1: 3D top-down view (default camera) filling the page
|
||||||
* - 2D top-down view
|
* - Page 2: 2D floor plan (default view)
|
||||||
* - Up to 4 wall projection views
|
* - One page per wall projection (default view)
|
||||||
|
*
|
||||||
|
* Images are embedded with their native aspect ratio preserved.
|
||||||
*/
|
*/
|
||||||
export async function createRoomPdf(
|
export async function createRoomPdf(
|
||||||
roomName: string,
|
roomName: string,
|
||||||
@@ -116,52 +157,53 @@ export async function createRoomPdf(
|
|||||||
): Promise<JsPDFType> {
|
): Promise<JsPDFType> {
|
||||||
const { jsPDF } = await loadJsPDF();
|
const { jsPDF } = await loadJsPDF();
|
||||||
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
|
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
|
||||||
|
await applyPdfUnicodeFont(pdf);
|
||||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||||
|
|
||||||
// ── Page 1: Room name + 2D view ──
|
const MARGIN = 12;
|
||||||
pdf.setFontSize(18);
|
const TITLE_Y = 12;
|
||||||
pdf.text(roomName, pageWidth / 2, 15, { align: 'center' });
|
const IMAGE_TOP = 18;
|
||||||
|
|
||||||
if (topDownDataUrl) {
|
let pageIndex = 0;
|
||||||
const imgWidth = pageWidth - 30;
|
|
||||||
const imgHeight = (pageHeight - 40) * 0.55;
|
|
||||||
pdf.addImage(topDownDataUrl, 'PNG', 15, 25, imgWidth, imgHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Page 2: Wall projections (if any) ──
|
const addTitledPage = (title: string, fontSize: number): void => {
|
||||||
if (projectionDataUrls.length > 0) {
|
if (pageIndex > 0) pdf.addPage('a4', 'landscape');
|
||||||
pdf.addPage('a4', 'landscape');
|
pageIndex += 1;
|
||||||
pdf.setFontSize(14);
|
pdf.setFont(PDF_FONT_FAMILY, 'normal');
|
||||||
pdf.text(`${roomName} - Wall Projections`, pageWidth / 2, 15, { align: 'center' });
|
pdf.setFontSize(fontSize);
|
||||||
|
pdf.text(title, pageWidth / 2, TITLE_Y, { align: 'center' });
|
||||||
|
};
|
||||||
|
|
||||||
const cols = 2;
|
// ── Page 1: 3D top view ──
|
||||||
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) ──
|
|
||||||
if (view3dDataUrl) {
|
if (view3dDataUrl) {
|
||||||
pdf.addPage('a4', 'landscape');
|
addTitledPage(roomName, 18);
|
||||||
pdf.setFontSize(14);
|
const boxH = pageHeight - IMAGE_TOP - MARGIN;
|
||||||
pdf.text(`${roomName} - 3D View`, pageWidth / 2, 15, { align: 'center' });
|
const boxW = pageWidth - MARGIN * 2;
|
||||||
|
addImageFit(pdf, view3dDataUrl, MARGIN, IMAGE_TOP, boxW, boxH);
|
||||||
|
}
|
||||||
|
|
||||||
const imgWidth = pageWidth - 30;
|
// ── Page 2: 2D floor plan ──
|
||||||
const imgHeight = pageHeight - 30;
|
if (topDownDataUrl) {
|
||||||
pdf.addImage(view3dDataUrl, 'PNG', 15, 20, imgWidth, imgHeight);
|
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;
|
return pdf;
|
||||||
@@ -180,10 +222,18 @@ export async function createApartmentPdf(
|
|||||||
): Promise<JsPDFType> {
|
): Promise<JsPDFType> {
|
||||||
const { jsPDF } = await loadJsPDF();
|
const { jsPDF } = await loadJsPDF();
|
||||||
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
|
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
|
||||||
|
await applyPdfUnicodeFont(pdf);
|
||||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||||
|
|
||||||
|
const MARGIN = 12;
|
||||||
|
const TITLE_Y = 12;
|
||||||
|
const IMAGE_TOP = 18;
|
||||||
|
const boxW = pageWidth - MARGIN * 2;
|
||||||
|
const boxH = pageHeight - IMAGE_TOP - MARGIN;
|
||||||
|
|
||||||
// ── Cover page ──
|
// ── Cover page ──
|
||||||
|
pdf.setFont(PDF_FONT_FAMILY, 'normal');
|
||||||
pdf.setFontSize(28);
|
pdf.setFontSize(28);
|
||||||
pdf.text(apartmentName, pageWidth / 2, pageHeight / 3, { align: 'center' });
|
pdf.text(apartmentName, pageWidth / 2, pageHeight / 3, { align: 'center' });
|
||||||
pdf.setFontSize(14);
|
pdf.setFontSize(14);
|
||||||
@@ -198,40 +248,23 @@ export async function createApartmentPdf(
|
|||||||
{ align: 'center' },
|
{ 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 ──
|
// ── Room pages ──
|
||||||
for (const room of rooms) {
|
for (const room of rooms) {
|
||||||
pdf.addPage('a4', 'landscape');
|
|
||||||
pdf.setFontSize(18);
|
|
||||||
pdf.text(room.roomName, pageWidth / 2, 15, { align: 'center' });
|
|
||||||
|
|
||||||
if (room.topDownDataUrl) {
|
if (room.topDownDataUrl) {
|
||||||
const imgWidth = pageWidth - 30;
|
addTitledPage(`${room.roomName} — 2D`, 16);
|
||||||
const imgHeight = (pageHeight - 40) * 0.55;
|
addImageFit(pdf, room.topDownDataUrl, MARGIN, IMAGE_TOP, boxW, boxH);
|
||||||
pdf.addImage(room.topDownDataUrl, 'PNG', 15, 25, imgWidth, imgHeight);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Projections page
|
for (const proj of room.projectionDataUrls) {
|
||||||
if (room.projectionDataUrls.length > 0) {
|
addTitledPage(`${room.roomName} — ${proj.label}`, 16);
|
||||||
pdf.addPage('a4', 'landscape');
|
addImageFit(pdf, proj.dataUrl, MARGIN, IMAGE_TOP, boxW, boxH);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,3 +277,63 @@ export async function createApartmentPdf(
|
|||||||
export function sanitizeFilename(name: string): string {
|
export function sanitizeFilename(name: string): string {
|
||||||
return name.replace(/[^a-zA-Z0-9_\-\s]/g, '').replace(/\s+/g, '_');
|
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 invertY = pe.item.type === 'OUTLET' && getOutletInvertCoordY(pe.item.metadata);
|
||||||
const displayX = invertX ? wallLen - pe.position.alongWall : pe.position.alongWall;
|
const displayX = invertX ? wallLen - pe.position.alongWall : pe.position.alongWall;
|
||||||
const displayY = invertY ? wallHeight - pe.elevation : pe.elevation;
|
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 labelX = center.x + halfWidthPx + 6;
|
||||||
const labelY = center.y - 6;
|
const labelY = center.y - 6;
|
||||||
// Rough text-width estimate (monospace-ish): ~5.5px per char at fontSize 9.
|
// Rough text-width estimate (monospace-ish): ~5.5px per char at fontSize 9.
|
||||||
|
|||||||
@@ -68,11 +68,20 @@ function NearestWallTracker({ walls, onUpdate }: { readonly walls: readonly Wall
|
|||||||
return null;
|
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.
|
* Room3DView — read-only 3D perspective view of the room.
|
||||||
* Renders inside a @react-three/fiber Canvas with orbit controls.
|
* Renders inside a @react-three/fiber Canvas with orbit controls.
|
||||||
*/
|
*/
|
||||||
export function Room3DView() {
|
export function Room3DView({ presetTriggerRef }: Room3DViewProps = {}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { state, dispatch } = useEditor();
|
const { state, dispatch } = useEditor();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -128,6 +137,16 @@ export function Room3DView() {
|
|||||||
setTimeout(() => setActivePreset(null), 100);
|
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
|
// Reset the camera to the default (Bird's Eye) view. Bound to the Canvas
|
||||||
// element's onDoubleClick so double-tapping empty space snaps back.
|
// element's onDoubleClick so double-tapping empty space snaps back.
|
||||||
const handleResetView = useCallback(() => {
|
const handleResetView = useCallback(() => {
|
||||||
|
|||||||
Generated
+12
@@ -27,6 +27,7 @@
|
|||||||
"@house-plan-maker/shared": "*",
|
"@house-plan-maker/shared": "*",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.5.0",
|
"@react-three/fiber": "^9.5.0",
|
||||||
|
"dejavu-fonts-ttf": "^2.37.3",
|
||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.3",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
@@ -3161,6 +3162,11 @@
|
|||||||
"integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==",
|
"integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==",
|
||||||
"devOptional": true
|
"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": {
|
"node_modules/dequal": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
@@ -7191,6 +7197,7 @@
|
|||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@types/three": "^0.183.1",
|
"@types/three": "^0.183.1",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"dejavu-fonts-ttf": "*",
|
||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.3",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
@@ -8513,6 +8520,11 @@
|
|||||||
"integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==",
|
"integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==",
|
||||||
"devOptional": true
|
"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": {
|
"dequal": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user