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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user