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": "*",
|
||||
"@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(() => {
|
||||
|
||||
Generated
+12
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user