feat(export): tighter 3D framing, readable projection fonts, cleaner per-wall filters

- Drop file size: jsPDF compression + 3D page encoded as JPEG (0.95) on a
  white composite canvas (no more black background), PDF pixelRatio=1.
- Tighten 3D export framing: new 'birds-eye-export' preset derives camera
  height from Three.js vertical FOV at the wall-top plane so the room
  fills the page without the interactive preset's extra headroom.
- Thread canvas + preset trigger through Room3DView props so the export
  dialog can always find the live canvas and snap the camera even when
  the view was lazy-mounted by the dialog itself.
- 2D capture on PDF uses an A4-landscape-matched aspect (1820x1200) so
  the image fills the page box without large empty bands.
- Projection exports: per-wall hidden renderer mounts every wall at
  native aspect with a right/bottom overflow budget for label headroom;
  fontScale prop (passed as 2 from the export block) keeps Konva text
  legible at high export resolution without affecting interactive view.
- Filter out annotations attached to electrical items from the 2D view
  and exported images (they belong to the wall-projection context).
- Projection filters: assign furniture and orphan electrical items to a
  single 'home' wall (minimum edge / perpendicular distance) so a corner
  item stops double-projecting onto both neighbouring walls; stale
  wallIds fall back to the proximity path instead of being dropped.
- Annotate tool works in projection view: clicking creates a wall-
  anchored annotation rendered next to the wall at the click position.
- Outlet projection coord labels now show 3 decimal places.
- PDF page titles use a Unicode font (DejaVu Sans) so Cyrillic renders
  correctly; wall projections get one dedicated landscape page each at
  native aspect; pdf aspect-preserving addImage helper centres images
  horizontally and top-aligns vertically.
This commit is contained in:
2026-04-14 21:00:39 +03:00
parent 68d2b5e3b0
commit 5fbd382120
10 changed files with 400 additions and 108 deletions
@@ -21,9 +21,16 @@ import type { CameraPreset } from './three/CameraControls';
// Pixel density and padding used by the hidden projection renderers that
// populate the PDF export. Sized so each wall's stage matches its native
// aspect ratio (no vertical empty space inside the captured PNG).
// aspect ratio (no vertical empty space inside the captured PNG). The
// extra ``OVERFLOW`` budget keeps annotations that end up just outside the
// wall rectangle (typical for labels next to outlets near a corner) inside
// the captured stage — WallProjectionView's projectionToPixel places
// content at (padding, padding), so the overflow budget sits on the right
// and bottom edges and is picked up by stage.toDataURL.
const EXPORT_PX_PER_M = 400;
const EXPORT_PROJ_PADDING = 80;
const EXPORT_PROJ_OVERFLOW_RIGHT_PX = 600; // ~1.5 m of label headroom
const EXPORT_PROJ_OVERFLOW_BOTTOM_PX = 240; // ~0.6 m of label headroom
const Room3DView = lazy(() =>
import('./three/Room3DView').then((m) => ({ default: m.Room3DView })),
@@ -797,6 +804,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
{mount3D && (
<div
className={styles.canvasContainer}
data-export-3d-host
style={
viewMode !== '3d'
? {
@@ -810,18 +818,9 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
}
: undefined
}
ref={(el) => {
// Grab the R3F canvas element for 3D export
if (el) {
const canvas = el.querySelector('canvas');
threeCanvasRef.current = canvas;
} else {
threeCanvasRef.current = null;
}
}}
>
<Suspense fallback={<div className={styles.loading3D}>{t('editor.loading3D')}</div>}>
<Room3DView presetTriggerRef={preset3DRef} />
<Room3DView presetTriggerRef={preset3DRef} canvasRef={threeCanvasRef} />
</Suspense>
</div>
)}
@@ -873,12 +872,19 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
{state.walls.map((wall) => {
const len = computeWallLength(wall);
if (len <= 0) return null;
const w = Math.round(len * EXPORT_PX_PER_M + EXPORT_PROJ_PADDING);
const h = Math.round(state.room.wallHeight * EXPORT_PX_PER_M + EXPORT_PROJ_PADDING);
const w = Math.round(
len * EXPORT_PX_PER_M + EXPORT_PROJ_PADDING + EXPORT_PROJ_OVERFLOW_RIGHT_PX,
);
const h = Math.round(
state.room.wallHeight * EXPORT_PX_PER_M +
EXPORT_PROJ_PADDING +
EXPORT_PROJ_OVERFLOW_BOTTOM_PX,
);
return (
<div key={`export-proj-${wall.id}`} style={{ width: `${w}px`, height: `${h}px` }}>
<WallProjectionView
wall={wall}
walls={state.walls}
openings={state.openings}
electricalItems={state.layerVisibility.electrical ? state.electricalItems : []}
furnitureItems={state.layerVisibility.furniture ? state.furnitureItems : []}
@@ -898,6 +904,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
width={w}
height={h}
showMeasurements={state.layerVisibility.measurements}
fontScale={2}
/>
</div>
);
@@ -58,28 +58,73 @@ export function ExportDialog({
const [isExporting, setIsExporting] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* Build the set of points used to compute the 2D fit view. Includes the
* room polygon plus every annotation's anchor point (with a small buffer
* for its text) so annotations placed outside the room boundary stay
* visible in exported views.
*/
const buildFitPoints = useCallback(() => {
// Include annotation anchor points so annotations placed outside the
// room polygon don't get clipped. Skip annotations attached to
// electrical items — they belong to the wall-projection view and are
// hidden on the 2D view, so we don't need to reserve space for them.
const electricalIds = new Set(state.electricalItems.map((e) => e.id));
const pts: { x: number; y: number }[] = state.room.shape.map((p) => ({ x: p.x, y: p.y }));
for (const ann of state.annotations) {
if (ann.attachedToId && electricalIds.has(ann.attachedToId)) continue;
pts.push({ x: ann.x, y: ann.y });
}
return pts;
}, [state.room.shape, state.annotations, state.electricalItems]);
/**
* Capture the main 2D Konva stage with the fit-to-room view applied.
* Saves the user's current zoom/pan, dispatches the computed fit view,
* waits for layers to re-render, captures, then restores.
*/
const captureMainStageAtFit = useCallback(
async (renderGrid: boolean): Promise<string | null> => {
async (
renderGrid: boolean,
pixelRatioOverride?: number,
/** When true, force the capture stage to an A4-landscape-matched
* aspect (1.517:1) so the exported image fills the PDF image box
* without height-cropping or leaving empty bands above/below. */
matchPdfAspect = false,
): Promise<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;
// PDF page image box ≈ 273 × 180 mm on A4 landscape → 1.517:1.
// Match it to avoid leaving large empty bands in the PDF.
const PDF_W = 1820;
const PDF_H = 1200;
// Use a synthetic size when the stage is hidden (0×0) or very small.
const fallbackW = matchPdfAspect ? PDF_W : 1600;
const fallbackH = matchPdfAspect ? PDF_H : 1200;
const targetW = matchPdfAspect
? PDF_W
: stageW > 100
? stageW
: canvasSize?.width && canvasSize.width > 100
? canvasSize.width
: fallbackW;
const targetH = matchPdfAspect
? PDF_H
: stageH > 100
? stageH
: canvasSize?.height && canvasSize.height > 100
? canvasSize.height
: fallbackH;
let resized = false;
const container = stage.container();
const parent = container?.parentElement;
const origParentStyle = parent?.getAttribute('style') ?? '';
if (stageW === 0 || stageH === 0) {
if (matchPdfAspect || stageW === 0 || stageH === 0) {
resized = true;
if (parent) {
parent.setAttribute(
@@ -91,7 +136,7 @@ export function ExportDialog({
stage.height(targetH);
}
const fit = computeFitView(state.room.shape, targetW, targetH);
const fit = computeFitView(buildFitPoints(), targetW, targetH);
if (!fit) {
if (resized) {
stage.width(stageW);
@@ -107,7 +152,10 @@ export function ExportDialog({
await waitFrames(3);
stage.batchDraw();
const dataUrl = exportKonvaStageToDataUrl(stage, { pixelRatio, includeGrid: renderGrid });
const dataUrl = exportKonvaStageToDataUrl(stage, {
pixelRatio: pixelRatioOverride ?? pixelRatio,
includeGrid: renderGrid,
});
dispatch({ type: 'SET_VIEW', zoom: savedZoom, offset: savedOffset });
if (resized) {
@@ -117,24 +165,33 @@ export function ExportDialog({
}
return dataUrl;
},
[mainStageRef, canvasSize, state.room.shape, state.zoom, state.panOffset, dispatch, pixelRatio],
[mainStageRef, canvasSize, state.room.shape, state.zoom, state.panOffset, dispatch, pixelRatio, buildFitPoints],
);
/** Capture the 3D canvas with the bird's-eye (top-down) preset applied. */
const capture3DBirdsEye = useCallback(async (): Promise<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 the 3D canvas with a tight top-down framing. `tightFraming`
* picks the export-only preset that fills the frame with the room instead
* of the loose default birds-eye used by the interactive view.
* `jpegForPdf` encodes as JPEG — photographic 3D renders compress an
* order of magnitude smaller than PNG with little visible quality loss.
*/
const capture3DBirdsEye = useCallback(
async (opts: { tightFraming?: boolean; jpegForPdf?: boolean } = {}): Promise<string | null> => {
const trigger = preset3DRef.current;
if (trigger) {
trigger(opts.tightFraming ? 'birds-eye-export' : 'birds-eye');
// Wait long enough for the preset to settle and one full render pass.
await waitFrames(12);
}
let canvas = threeCanvasRef.current;
if (!canvas) {
canvas = document.querySelector('canvas[data-engine]') as HTMLCanvasElement | null;
}
if (!canvas || canvas.width === 0 || canvas.height === 0) return null;
return exportThreeCanvasToDataUrl(canvas, opts.jpegForPdf ? 'image/jpeg' : 'image/png', 0.85);
},
[preset3DRef, threeCanvasRef],
);
/**
* Capture every wall projection as a high-resolution image. Uses the
@@ -143,7 +200,7 @@ export function ExportDialog({
* Waits briefly on the first call so the hidden render pass can settle.
*/
const captureAllProjections = useCallback(
async (): Promise<{ label: string; dataUrl: string }[]> => {
async (pixelRatioOverride?: number): Promise<{ label: string; dataUrl: string }[]> => {
const out: { label: string; dataUrl: string }[] = [];
const projMap = exportProjectionStageRefs.current;
if (!projMap) return out;
@@ -155,12 +212,13 @@ export function ExportDialog({
}
await waitFrames(2);
const ratio = pixelRatioOverride ?? pixelRatio;
for (const wall of walls) {
const projStage = projMap.get(wall.id);
if (projStage && projStage.width() > 0 && projStage.height() > 0) {
projStage.batchDraw();
const label = wallDirectionLabel(wall);
const dataUrl = exportKonvaStageToDataUrl(projStage, { pixelRatio });
const dataUrl = exportKonvaStageToDataUrl(projStage, { pixelRatio: ratio });
out.push({ label, dataUrl });
}
}
@@ -258,23 +316,39 @@ export function ExportDialog({
const blob = new Blob([jsonStr], { type: 'application/json' });
downloadBlob(blob, `${baseName}.json`);
} else {
// PDF: always capture 3D birds-eye + 2D default + all projections.
// Kick 3D render first so it has plenty of frames to settle while we
// capture the 2D stage.
preset3DRef.current?.('birds-eye');
// PDF: always capture 3D (tight top-down) + 2D default + all projections.
// Room3DView lazy-loads when the export dialog opens, so the preset
// trigger ref may not exist yet. Poll for it, then snap the camera
// to the export-only tight preset.
for (let i = 0; i < 60 && !preset3DRef.current; i += 1) {
await waitFrames(2);
}
preset3DRef.current?.('birds-eye-export');
const topDownDataUrl = await captureMainStageAtFit(includeGrid);
const projectionDataUrls = await captureAllProjections();
// Source stages are already rendered at 400 px/m — capturing at
// pixelRatio=1 still produces ~300-350 DPI on an A4 page and keeps
// the PDF file size an order of magnitude smaller than pixelRatio=2.
const PDF_PIXEL_RATIO = 1;
const topDownDataUrl = await captureMainStageAtFit(includeGrid, PDF_PIXEL_RATIO, true);
const projectionDataUrls = await captureAllProjections(PDF_PIXEL_RATIO);
// Let the 3D view finish applying birds-eye + rendering.
await waitFrames(8);
let canvas3d = threeCanvasRef.current;
if (!canvas3d) {
canvas3d = document.querySelector('canvas[data-engine]') as HTMLCanvasElement | null;
// Re-trigger the preset after other captures settle, and poll for a
// valid 3D canvas (it may still be mounting on first export).
preset3DRef.current?.('birds-eye-export');
await waitFrames(20);
let canvas3d: HTMLCanvasElement | null = null;
for (let i = 0; i < 60; i += 1) {
canvas3d = threeCanvasRef.current;
if (!canvas3d) {
const host = document.querySelector('[data-export-3d-host]');
canvas3d = host ? (host.querySelector('canvas') as HTMLCanvasElement | null) : null;
}
if (canvas3d && canvas3d.width > 0 && canvas3d.height > 0) break;
await waitFrames(2);
}
const view3dDataUrl =
canvas3d && canvas3d.width > 0 && canvas3d.height > 0
? exportThreeCanvasToDataUrl(canvas3d)
? exportThreeCanvasToDataUrl(canvas3d, 'image/jpeg', 0.95)
: null;
const pdf = await createRoomPdf(room.name, topDownDataUrl, projectionDataUrls, view3dDataUrl);
@@ -57,11 +57,33 @@ export function exportKonvaStageToDataUrl(
}
/**
* Export a Three.js canvas element to a data URL.
* Export a Three.js canvas element to a data URL. Defaults to PNG but
* callers targeting a PDF should pass `'image/jpeg'` with a quality to
* cut file size by 10-20x on photographic 3D renders.
*
* For JPEG (which has no alpha channel), transparent pixels in the source
* canvas would otherwise be filled with the browser's default of black —
* which gives the 3D export a black sky/background. We composite the
* source onto a white offscreen canvas first to avoid that.
*/
export function exportThreeCanvasToDataUrl(
canvas: HTMLCanvasElement,
mimeType: 'image/png' | 'image/jpeg' = 'image/png',
quality = 0.85,
): string {
if (mimeType === 'image/jpeg') {
const composite = document.createElement('canvas');
composite.width = canvas.width;
composite.height = canvas.height;
const ctx = composite.getContext('2d');
if (ctx) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, composite.width, composite.height);
ctx.drawImage(canvas, 0, 0);
return composite.toDataURL('image/jpeg', quality);
}
return canvas.toDataURL('image/jpeg', quality);
}
return canvas.toDataURL('image/png');
}
@@ -156,7 +178,10 @@ export async function createRoomPdf(
view3dDataUrl: string | null,
): Promise<JsPDFType> {
const { jsPDF } = await loadJsPDF();
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
// `compress: true` enables Flate compression for all streams (including
// raster images), which is the single biggest file-size win for PDFs
// dominated by Konva PNG exports.
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4', compress: true });
await applyPdfUnicodeFont(pdf);
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
@@ -175,7 +200,7 @@ export async function createRoomPdf(
pdf.text(title, pageWidth / 2, TITLE_Y, { align: 'center' });
};
// ── Page 1: 3D top view ──
// ── Page 1: 3D top view (JPEG — far smaller than PNG for photographic 3D) ──
if (view3dDataUrl) {
addTitledPage(roomName, 18);
const boxH = pageHeight - IMAGE_TOP - MARGIN;
@@ -68,10 +68,21 @@ export const AnnotationLayer = memo(function AnnotationLayer({
return map;
}, [electricalItems, furnitureItems]);
// Annotations attached to electrical items belong to the wall-projection
// context — they clutter the top-down 2D view (and exported images) with
// labels that were authored for per-wall projection layout. Hide them
// here; they still render in WallProjectionView.
const electricalIdSet = useMemo(
() => new Set(electricalItems.map((e) => e.id)),
[electricalItems],
);
const renderedAnnotations = useMemo(() => {
if (!visible) return [];
return annotations;
}, [annotations, visible]);
return annotations.filter(
(a) => !a.attachedToId || !electricalIdSet.has(a.attachedToId),
);
}, [annotations, visible, electricalIdSet]);
return (
<Group visible={visible}>
@@ -23,11 +23,14 @@ interface ProjectionMeasurementsProps {
/** When false, the wall-level dimensions/labels are skipped (useful when the
* measurements layer is toggled off but per-item overlays should still draw). */
readonly showWallDimensions?: boolean;
/** Multiplier applied to every hardcoded fontSize so labels stay legible
* when the parent renders on a large export stage. Default 1. */
readonly fontScale?: number;
}
/** Dimension line with arrows and text. */
function DimensionLine({
x1, y1, x2, y2, label, offset, horizontal, lineColor, textColor,
x1, y1, x2, y2, label, offset, horizontal, lineColor, textColor, fontScale = 1,
}: {
readonly x1: number;
readonly y1: number;
@@ -38,6 +41,7 @@ function DimensionLine({
readonly horizontal: boolean;
readonly lineColor: string;
readonly textColor: string;
readonly fontScale?: number;
}) {
const arrowSize = 4;
@@ -69,7 +73,7 @@ function DimensionLine({
width={40}
text={label}
align="center"
fontSize={9}
fontSize={9 * fontScale}
fill={textColor}
/>
</Group>
@@ -98,7 +102,7 @@ function DimensionLine({
x={lineX + 3}
y={midY - 5}
text={label}
fontSize={9}
fontSize={9 * fontScale}
fill={textColor}
/>
</Group>
@@ -121,6 +125,7 @@ export function ProjectionMeasurements({
outletWidth = DEFAULT_OUTLET_WIDTH,
outletHeight = DEFAULT_OUTLET_HEIGHT,
showWallDimensions = true,
fontScale = 1,
}: ProjectionMeasurementsProps) {
const colors = useCanvasColors();
const elements: ReactNode[] = [];
@@ -131,6 +136,7 @@ export function ProjectionMeasurements({
const floorRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
fontScale={fontScale}
key="wall-width"
x1={floorLeft.x}
y1={floorLeft.y}
@@ -149,6 +155,7 @@ export function ProjectionMeasurements({
const bottomRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
fontScale={fontScale}
key="wall-height"
x1={topRight.x}
y1={topRight.y}
@@ -172,6 +179,7 @@ export function ProjectionMeasurements({
// Height annotation (vertical, left side of opening)
elements.push(
<DimensionLine
fontScale={fontScale}
key={`opening-h-${opening.id}`}
x1={topLeft.x}
y1={topLeft.y}
@@ -190,6 +198,7 @@ export function ProjectionMeasurements({
const floorBelow = projectionToPixel(rect.x, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
fontScale={fontScale}
key={`sill-${opening.id}`}
x1={bottomLeft.x}
y1={bottomLeft.y}
@@ -208,6 +217,7 @@ export function ProjectionMeasurements({
const topRight2 = projectionToPixel(rect.x + rect.width, rect.y + rect.height, wallHeight, scale, padding);
elements.push(
<DimensionLine
fontScale={fontScale}
key={`opening-w-${opening.id}`}
x1={topLeft.x}
y1={topLeft.y}
@@ -253,16 +263,16 @@ export function ProjectionMeasurements({
const displayY = invertY ? wallHeight - pe.elevation : pe.elevation;
const coordLabel = `(${displayX.toFixed(3)}; ${displayY.toFixed(3)})`;
const labelX = center.x + halfWidthPx + 6;
const labelY = center.y - 6;
const labelY = center.y - 6 * fontScale;
// Rough text-width estimate (monospace-ish): ~5.5px per char at fontSize 9.
const labelWidth = coordLabel.length * 5.5 + 4;
const labelWidth = coordLabel.length * 5.5 * fontScale + 4;
elements.push(
<Group key={`elec-coord-${pe.item.id}`}>
<Rect
x={labelX - 2}
y={labelY - 1}
width={labelWidth}
height={12}
height={12 * fontScale}
fill={colors.coordLabelBg}
cornerRadius={2}
listening={false}
@@ -271,7 +281,7 @@ export function ProjectionMeasurements({
x={labelX}
y={labelY}
text={coordLabel}
fontSize={9}
fontSize={9 * fontScale}
fill={colors.coordLabelText}
listening={false}
/>
@@ -295,7 +305,7 @@ export function ProjectionMeasurements({
x={openingCenter.x - 16}
y={openingCenter.y - 22}
text={formatM(rect.x + rect.width / 2)}
fontSize={8}
fontSize={8 * fontScale}
fill={colors.openingLabel}
align="center"
width={32}
@@ -327,6 +337,7 @@ export function ProjectionMeasurements({
const wRightFloor = projectionToPixel(rect.x + rect.width, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
fontScale={fontScale}
key={`furn-w-${item.id}`}
x1={wLeftFloor.x}
y1={wLeftFloor.y}
@@ -343,6 +354,7 @@ export function ProjectionMeasurements({
const oLeft = projectionToPixel(0, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
fontScale={fontScale}
key={`furn-off-${item.id}`}
x1={oLeft.x}
y1={oLeft.y}
@@ -360,6 +372,7 @@ export function ProjectionMeasurements({
// Inline width dimension drawn just below the bottom edge of the item
elements.push(
<DimensionLine
fontScale={fontScale}
key={`furn-w-${item.id}`}
x1={wLeft.x}
y1={wLeft.y}
@@ -379,6 +392,7 @@ export function ProjectionMeasurements({
const oLeft = projectionToPixel(0, rect.y, wallHeight, scale, padding);
elements.push(
<DimensionLine
fontScale={fontScale}
key={`furn-off-${item.id}`}
x1={oLeft.x}
y1={oLeft.y}
@@ -403,6 +417,7 @@ export function ProjectionMeasurements({
if (isNearLeft) {
elements.push(
<DimensionLine
fontScale={fontScale}
key={`furn-h-${item.id}`}
x1={hTop.x}
y1={hTop.y}
@@ -419,6 +434,7 @@ export function ProjectionMeasurements({
const eFloor = projectionToPixel(rect.x, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
fontScale={fontScale}
key={`furn-elev-${item.id}`}
x1={hBottom.x}
y1={hBottom.y}
@@ -436,6 +452,7 @@ export function ProjectionMeasurements({
// Inline height dimension drawn just to the left of the item
elements.push(
<DimensionLine
fontScale={fontScale}
key={`furn-h-${item.id}`}
x1={hTop.x}
y1={hTop.y}
@@ -455,6 +472,7 @@ export function ProjectionMeasurements({
const eBottom = projectionToPixel(rect.x + rect.width, rect.y, wallHeight, scale, padding);
elements.push(
<DimensionLine
fontScale={fontScale}
key={`furn-elev-${item.id}`}
x1={eBottom.x}
y1={eBottom.y}
@@ -20,7 +20,7 @@ interface ProjectionPanelProps {
export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPanelProps = {}) {
const { t } = useTranslation();
const { state, selectElement, updateElectrical, updateOpening, addElectrical, updateAnnotation } = useEditor();
const { state, selectElement, updateElectrical, updateOpening, addElectrical, updateAnnotation, addAnnotation } = useEditor();
const {
walls,
openings,
@@ -189,6 +189,29 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
[selectedElectricalIndex, walls, room, addElectrical, selectElement],
);
// Place a free-form annotation anchored to a wall at projection-space
// (alongWall, fromFloor). Stored with attachedToId = wall.id and the
// anchor point in (x, y); WallProjectionView reads these directly.
const handlePlaceAnnotation = useCallback(
(wallId: string, alongWall: number, fromFloor: number) => {
const newAnnotation: Annotation = {
id: generateLocalId(),
roomId: room.id,
x: alongWall,
y: fromFloor,
text: 'Text',
fontSize: 12,
color: '#334155',
attachedToId: wallId,
projectionOffsetX: 0,
projectionOffsetY: 0,
};
addAnnotation(newAnnotation);
selectElement(newAnnotation.id);
},
[room.id, addAnnotation, selectElement],
);
if (walls.length === 0) {
return null;
}
@@ -196,6 +219,7 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
const clampedIndex = Math.min(activeWallIndex, walls.length - 1);
const sharedProps = {
walls,
openings,
electricalItems: layerVisibility.electrical ? electricalItems : [],
furnitureItems: layerVisibility.furniture ? furnitureItems : [],
@@ -214,6 +238,7 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
onUpdateAnnotation: handleUpdateAnnotation,
onEditAnnotation: handleEditAnnotation,
onPlaceElectrical: handlePlaceElectrical,
onPlaceAnnotation: handlePlaceAnnotation,
showMeasurements: layerVisibility.measurements,
activeTool,
selectedElectricalType,
@@ -25,6 +25,9 @@ import { useCanvasColors } from '../utils/canvasThemeColors';
interface WallProjectionViewProps {
readonly wall: Wall;
/** All walls in the room — lets furniture projection assign each item
* to its closest wall so neighbour walls don't double-project items. */
readonly walls?: readonly Wall[];
readonly openings: readonly WallOpening[];
readonly electricalItems: readonly ElectricalItem[];
readonly furnitureItems: readonly FurnitureItem[];
@@ -48,8 +51,13 @@ interface WallProjectionViewProps {
readonly onUpdateAnnotation?: (annotation: Annotation) => void;
readonly onEditAnnotation?: (annotationId: string) => void;
readonly onPlaceElectrical?: (wallId: string, alongWall: number, fromFloor: number) => void;
readonly onPlaceAnnotation?: (wallId: string, alongWall: number, fromFloor: number) => void;
readonly activeTool?: EditorToolType;
readonly selectedElectricalType?: ElectricalType | null;
/** Multiplier applied to hardcoded Konva font sizes. Defaults to 1 for
* the interactive view; the hidden export renderer passes a larger
* value so text stays legible in high-resolution PDF exports. */
readonly fontScale?: number;
}
const PADDING = 40;
@@ -82,6 +90,7 @@ interface DragInfo {
export function WallProjectionView({
wall,
walls,
openings,
electricalItems,
furnitureItems,
@@ -104,8 +113,10 @@ export function WallProjectionView({
onUpdateAnnotation,
onEditAnnotation,
onPlaceElectrical,
onPlaceAnnotation,
activeTool,
selectedElectricalType,
fontScale = 1,
}: WallProjectionViewProps) {
const { t } = useTranslation();
const colors = useCanvasColors();
@@ -130,6 +141,11 @@ export function WallProjectionView({
const baseScale = projectionScale(wallLen, wallHeight, width, height, PADDING);
const effectiveScale = baseScale * viewZoom;
// fontScale is supplied by the export renderer (RoomEditorLayout's
// hidden per-wall stages). The interactive projection view leaves it at
// the default of 1 — we don't auto-scale by stage size because the
// interactive stage can be large on wide monitors.
// ── Drag state (refs for transient data, state only for visual feedback) ──
const dragRef = useRef<DragInfo | null>(null);
const [dragElectricalFromFloor, setDragElectricalFromFloor] = useState<{ itemId: string; fromFloor: number } | null>(null);
@@ -168,12 +184,12 @@ export function WallProjectionView({
[wall, openings],
);
const projectedElectrical = useMemo(
() => projectElectricalItems(wall, electricalItems),
[wall, electricalItems],
() => projectElectricalItems(wall, electricalItems, walls),
[wall, electricalItems, walls],
);
const projectedFurniture = useMemo(
() => projectFurnitureItems(wall, furnitureItems),
[wall, furnitureItems],
() => projectFurnitureItems(wall, furnitureItems, 0.15, walls),
[wall, furnitureItems, walls],
);
const plinthSegments = useMemo(
() => computePlinthSegments(wall, openings, plinthHeight),
@@ -495,20 +511,31 @@ export function WallProjectionView({
// ── Handle click on wall background for placement ──
const handleWallBgClick = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
// Only place when electrical tool is active
if (activeTool !== 'electrical' || !selectedElectricalType || !onPlaceElectrical) return;
const pointer = getStagePointer(e.evt);
if (!pointer) return;
const proj = pixelToProjection(
pointer.x - viewPanRef.current.x,
pointer.y - viewPanRef.current.y,
wallHeight,
effectiveScale,
PADDING,
);
const proj = pixelToProjection(pointer.x - viewPanRef.current.x, pointer.y - viewPanRef.current.y, wallHeight, effectiveScale, PADDING);
if (activeTool === 'annotate' && onPlaceAnnotation) {
// Annotations may sit anywhere — including just outside the wall
// rectangle for labels — so don't clip the click to the wall span.
onPlaceAnnotation(wall.id, proj.alongWall, proj.fromFloor);
return;
}
// Only place within wall bounds
if (activeTool !== 'electrical' || !selectedElectricalType || !onPlaceElectrical) return;
// Only place electrical within wall bounds
if (proj.alongWall < 0 || proj.alongWall > wallLen) return;
if (proj.fromFloor < 0 || proj.fromFloor > wallHeight) return;
onPlaceElectrical(wall.id, proj.alongWall, proj.fromFloor);
}, [activeTool, selectedElectricalType, onPlaceElectrical, getStagePointer, wallHeight, effectiveScale, wallLen, wall.id]);
}, [activeTool, selectedElectricalType, onPlaceElectrical, onPlaceAnnotation, getStagePointer, wallHeight, effectiveScale, wallLen, wall.id]);
// ── Reset zoom when the *physical* wall changes ──
// We key on the wall's start/end coords rather than id so a save (which
@@ -630,7 +657,7 @@ export function WallProjectionView({
width={20}
text={`${w}`}
align="center"
fontSize={8}
fontSize={8 * fontScale}
fill={colors.rulerText}
/>,
);
@@ -661,7 +688,7 @@ export function WallProjectionView({
width={20}
text={`${h}`}
align="right"
fontSize={8}
fontSize={8 * fontScale}
fill={colors.rulerText}
/>,
);
@@ -722,7 +749,7 @@ export function WallProjectionView({
x={stretchLeft.x + 4}
y={stretchLeft.y - 12}
text={`${t('properties.stretchCeilingOffset')}: ${(stretchCeilingOffset * 100).toFixed(1)}cm`}
fontSize={9}
fontSize={9 * fontScale}
fill="#2563eb"
listening={false}
/>
@@ -882,7 +909,7 @@ export function WallProjectionView({
x={midX + 8}
y={midY - 14}
text={label}
fontSize={12}
fontSize={12 * fontScale}
fontFamily="sans-serif"
fontStyle="bold"
fill="#e74c3c"
@@ -906,21 +933,31 @@ export function WallProjectionView({
outletWidth={outletWidth}
outletHeight={outletHeight}
showWallDimensions={showMeasurements}
fontScale={fontScale}
/>
{/* Attached annotations for items on this wall — interactive */}
{/* Attached annotations for items on this wall — interactive.
Also includes annotations attached directly to this wall (created
via the annotate tool inside the projection view). */}
{annotations
.filter((ann) => {
if (!ann.attachedToId) return false;
if (ann.attachedToId === wall.id) return true;
return projectedElectrical.some((pe) => pe.item.id === ann.attachedToId) ||
projectedFurniture.some((pf) => pf.item.id === ann.attachedToId);
})
.map((ann) => {
const elec = projectedElectrical.find((pe) => pe.item.id === ann.attachedToId);
const furn = projectedFurniture.find((pf) => pf.item.id === ann.attachedToId);
const isWallAnchored = ann.attachedToId === wall.id;
let anchorAlongWall = 0;
let anchorFromFloor = 0;
if (elec) {
if (isWallAnchored) {
// Wall-anchored annotations store their projection-space
// anchor point in (x, y) — not a delta from another item.
anchorAlongWall = ann.x;
anchorFromFloor = ann.y;
} else if (elec) {
anchorAlongWall = elec.position.alongWall;
anchorFromFloor = elec.position.fromFloor;
} else if (furn) {
@@ -936,7 +973,7 @@ export function WallProjectionView({
const textX = anchorPx.x + projOffsetX * effectiveScale;
const textY = anchorPx.y + projOffsetY * effectiveScale;
const isSelected = selectedIds.has(ann.id);
const fontSize = ann.fontSize ?? 10;
const fontSize = (ann.fontSize ?? 10) * fontScale;
const boxWidth = ann.text.length * (fontSize * 0.6) + 6;
const boxHeight = fontSize + 4;
// URL detection mirrors the floor-plan AnnotationLayer so links
@@ -1012,7 +1049,7 @@ export function WallProjectionView({
x={PADDING}
y={8}
text={`${label} (${wallLen.toFixed(2)}m)`}
fontSize={11}
fontSize={11 * fontScale}
fontStyle="bold"
fill="#334155"
/>
@@ -67,7 +67,14 @@ interface CameraControlsProps {
readonly wallHeight: number;
}
export type CameraPreset = 'birds-eye' | 'eye-level' | 'corner-ne' | 'corner-nw' | 'corner-se' | 'corner-sw';
export type CameraPreset =
| 'birds-eye'
| 'birds-eye-export'
| 'eye-level'
| 'corner-ne'
| 'corner-nw'
| 'corner-se'
| 'corner-sw';
interface PresetConfig {
readonly position: [number, number, number];
@@ -91,11 +98,27 @@ function computePresets(
const target: [number, number, number] = [centerX, centerY, centerZ];
const floorTarget: [number, number, number] = [centerX, 0, centerZ];
// Compute the camera height for the export-only top-down preset. Three.js
// PerspectiveCamera.fov is VERTICAL FOV, so we derive the horizontal half-
// angle from (tanV · aspect). Walls extend up from the floor toward the
// camera, so the frustum must fit the room at the WALL-TOP plane (closer
// to the camera) — fitting at floor level lets wall edges overflow into
// the frame. Canvas is rendered at 4:3 for the off-screen export pass.
const exportAspect = 4 / 3;
const tanV = Math.tan((50 / 2) * (Math.PI / 180));
const tanH = tanV * exportAspect;
const dTop = Math.max(sizeX / (2 * tanH), sizeZ / (2 * tanV));
const exportDist = Math.max(wallHeight + dTop * 1.15, wallHeight + 1);
return {
'birds-eye': {
position: [centerX, dist * 1.5, centerZ + 0.01],
target: floorTarget,
},
'birds-eye-export': {
position: [centerX, exportDist, centerZ + 0.01],
target: floorTarget,
},
'eye-level': {
position: [centerX - dist, 1.6, centerZ],
target: [centerX, 1.6, centerZ],
@@ -75,13 +75,20 @@ interface Room3DViewProps {
* (e.g. 'birds-eye') before taking a screenshot.
*/
readonly presetTriggerRef?: React.MutableRefObject<((preset: CameraPreset) => void) | null>;
/**
* Optional ref for the underlying R3F canvas. Forwarded so the export
* dialog can always reach the live canvas even when Room3DView was lazy-
* mounted mid-export (querying the outer container at mount time would
* miss a canvas that hadn't been created yet).
*/
readonly canvasRef?: React.MutableRefObject<HTMLCanvasElement | null>;
}
/**
* Room3DView — read-only 3D perspective view of the room.
* Renders inside a @react-three/fiber Canvas with orbit controls.
*/
export function Room3DView({ presetTriggerRef }: Room3DViewProps = {}) {
export function Room3DView({ presetTriggerRef, canvasRef }: Room3DViewProps = {}) {
const { t, i18n } = useTranslation();
const { state, dispatch } = useEditor();
const { theme } = useTheme();
@@ -210,6 +217,9 @@ export function Room3DView({ presetTriggerRef }: Room3DViewProps = {}) {
onPreset={handlePreset}
/>
<Canvas
ref={(el) => {
if (canvasRef) canvasRef.current = el ?? null;
}}
// `shadows="percentage"` selects PCFShadowMap — the non-deprecated
// replacement for the default PCFSoftShadowMap that Three.js removed
// support for in recent versions. Double-click anywhere on the empty
@@ -5,14 +5,16 @@ import { wallLength, wallStartEnd } from './wallUtils';
// ── Projection axis (canonical orientation for elevation views) ──
/**
* Pick a canonical orientation for the projection X axis.
* Pick a canonical orientation for the projection X axis so that
* left-to-right in the elevation view matches the viewer's perspective
* when standing **inside** the room looking at the wall:
*
* For axis-aligned walls, the projection axis is oriented so that it matches the
* floor plan's positive X (for horizontal walls) or positive Y (for vertical walls)
* direction. This means a south wall is shown left-to-right matching west→east on
* the floor plan, instead of mirrored.
* NORTH wall → left = west, right = east → +X axis
* SOUTH wall → left = east, right = west → X axis
* EAST wall → left = north, right = south → +Y axis
* WEST wall → left = south, right = north → Y axis
*
* Diagonal walls keep their natural start→end orientation.
* Diagonal / OTHER walls keep their natural start→end orientation.
*
* @returns the canonical start, end, length and whether the axis is flipped
* relative to the wall's stored start→end.
@@ -34,8 +36,17 @@ export function getProjectionAxis(wall: Wall): ProjectionAxis {
const ay = Math.abs(dy);
const isHorizontal = ax >= ay;
// Want horizontal walls to go +X, vertical walls to go +Y.
const flipped = isHorizontal ? dx < 0 : dy < 0;
// SOUTH and WEST walls need the negative axis direction (the viewer
// faces the negative direction and sees the positive side on the left).
// NORTH and EAST walls need the positive axis direction (current default).
// OTHER / diagonal walls keep start→end as-is.
let flipped: boolean;
if (wall.direction === 'SOUTH' || wall.direction === 'WEST') {
flipped = isHorizontal ? dx > 0 : dy > 0;
} else {
flipped = isHorizontal ? dx < 0 : dy < 0;
}
if (flipped) {
return { start: end, end: start, length, flipped: true };
}
@@ -211,6 +222,7 @@ export function projectOpenings(
export function projectElectricalItems(
wall: Wall,
electricalItems: readonly ElectricalItem[],
allWalls?: readonly Wall[],
): readonly ProjectedElectrical[] {
const axis = getProjectionAxis(wall);
const { start, end, length: wallLen } = axis;
@@ -220,23 +232,49 @@ export function projectElectricalItems(
const dx = (end.x - start.x) / wallLen;
const dy = (end.y - start.y) / wallLen;
// Match by wallId first; fall back to proximity for items whose wallId
// became stale after a save (wall IDs change on bulk replace)
const PROXIMITY_THRESHOLD = 0.3;
/** Perpendicular distance from an item to a wall's line, clamped to the
* wall's span so a corner-adjacent item doesn't count as "on" the
* neighbouring wall. */
const perpDistToWall = (item: ElectricalItem, w: Wall): number => {
const a = getProjectionAxis(w);
if (a.length === 0) return Infinity;
const wdx = (a.end.x - a.start.x) / a.length;
const wdy = (a.end.y - a.start.y) / a.length;
const vx = item.x - a.start.x;
const vy = item.y - a.start.y;
const along = vx * wdx + vy * wdy;
if (along < -0.05 || along > a.length + 0.05) return Infinity;
return Math.abs(vx * -wdy + vy * wdx);
};
// Build a set of live wall ids. A wallId that doesn't appear here is
// stale (wall ids are reassigned on bulk-save), so we treat the item as
// orphan and fall back to proximity instead of dropping it.
const liveWallIds = allWalls ? new Set(allWalls.map((w) => w.id)) : null;
return electricalItems
.filter((item) => {
// Exact wallId match
// Explicit match wins.
if (item.wallId === wall.id) return true;
// Proximity fallback for wall-mounted items with mismatched/null wallId
if (!item.wallId || WALL_MOUNTED_ELECTRICAL_TYPES.has(item.type)) {
const vx = item.x - start.x;
const vy = item.y - start.y;
const perpDist = Math.abs(vx * (-dy) + vy * dx);
const alongWall = vx * dx + vy * dy;
return perpDist < PROXIMITY_THRESHOLD && alongWall >= -0.1 && alongWall <= wallLen + 0.1;
// Explicit mismatch to a LIVE wall: trust the author, not us.
if (item.wallId && (!liveWallIds || liveWallIds.has(item.wallId))) return false;
// Orphan or stale-wallId item: fall back to proximity, but assign
// it to the SINGLE closest wall so a corner item doesn't project
// onto both adjacent walls.
if (!WALL_MOUNTED_ELECTRICAL_TYPES.has(item.type)) return false;
const thisDist = perpDistToWall(item, wall);
if (thisDist >= PROXIMITY_THRESHOLD) return false;
if (allWalls && allWalls.length > 1) {
for (const other of allWalls) {
if (other.id === wall.id) continue;
const otherDist = perpDistToWall(item, other);
if (otherDist < thisDist - 1e-6) return false;
}
}
return false;
return true;
})
.map((item) => {
// Project item position onto wall axis
@@ -342,23 +380,38 @@ function furnitureEdgeDistanceToWall(
// Perpendicular distance from centre to wall line
const centerDist = Math.abs(dxC * (-dy) + dyC * dx);
const { halfAlong, halfPerp } = rotatedHalfExtents(item, dx, dy);
const { halfPerp } = rotatedHalfExtents(item, dx, dy);
const edgeDist = Math.max(0, centerDist - halfPerp);
// Along-wall extent: item (rotated) must overlap the wall's length.
// Along-wall extent: the item's CENTRE must lie within this wall's span
// (with a small tolerance). Using the rotated half-width as tolerance
// here picked up items sitting on perpendicular neighbour walls whose
// corner happened to be close to this wall's extended line — they then
// got projected into the elevation view as if they belonged to this wall.
const alongWallCenter = dxC * dx + dyC * dy;
if (alongWallCenter < -halfAlong || alongWallCenter > wallLen + halfAlong) {
const alongTol = 0.1;
if (alongWallCenter < -alongTol || alongWallCenter > wallLen + alongTol) {
return Infinity;
}
return edgeDist;
}
/** Project furniture items that are near a wall into elevation coords. */
/** Project furniture items that are near a wall into elevation coords.
*
* When `allWalls` is provided, each furniture item is assigned to the
* single wall that minimizes its edge-distance (its "home" wall) — items
* that are closer to a different wall are excluded from this wall's
* projection even if they happen to be within `wallThreshold` of it. This
* removes neighbour-wall leakage in corners (e.g. a wardrobe on the east
* wall that sits flush in the NE corner used to show up on the north wall
* projection too).
*/
export function projectFurnitureItems(
wall: Wall,
furnitureItems: readonly FurnitureItem[],
wallThreshold: number = 0.15,
allWalls?: readonly Wall[],
): readonly ProjectedFurniture[] {
const axis = getProjectionAxis(wall);
const { start, end, length: wallLen } = axis;
@@ -370,7 +423,16 @@ export function projectFurnitureItems(
return furnitureItems
.filter((item) => {
const dist = furnitureEdgeDistanceToWall(item, wall);
return dist < wallThreshold;
if (dist >= wallThreshold) return false;
if (allWalls && allWalls.length > 1) {
// Must be the *closest* wall to avoid corner leakage.
for (const other of allWalls) {
if (other.id === wall.id) continue;
const otherDist = furnitureEdgeDistanceToWall(item, other);
if (otherDist < dist - 1e-6) return false;
}
}
return true;
})
.map((item) => {
// Convert anchored (x, y) to rotated bounding-box centre.