const { useEffect, useMemo, useRef, useState } = React; const DEFAULT_WALL_HEIGHT_M = 2.5; const DEFAULT_WALL_THICKNESS_M = 0.16; const DEFAULT_CALIBRATION_DISTANCE_M = 5; const DEFAULT_METERS_PER_PIXEL = 0; const DEFAULT_GRID_STEP_M = 1; const DEFAULT_PLAN_OPACITY = 0.84; const TOOL_NAME = "Floor Plan OBJ Builder"; function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function safeNumber(value, fallback = 0) { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : fallback; } function formatNumber(value, digits = 2) { const numeric = Number(value); return Number.isFinite(numeric) ? numeric.toFixed(digits) : "0"; } function uid() { try { return crypto.randomUUID(); } catch (error) { return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`; } } function stripExtension(filename) { return String(filename || "").replace(/\.[^.]+$/, "") || "floor-plan"; } function sanitizeFilename(value) { return String(value || "floor-plan") .trim() .replace(/[<>:"/\\|?*\u0000-\u001f]+/g, "-") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, "") .toLowerCase(); } function distanceBetween(a, b) { if (!a || !b) { return 0; } return Math.hypot(b.x - a.x, b.y - a.y); } function polygonArea(points) { if (!Array.isArray(points) || points.length < 3) { return 0; } let area = 0; for (let index = 0; index < points.length; index += 1) { const current = points[index]; const next = points[(index + 1) % points.length]; area += current.x * next.y - next.x * current.y; } return area * 0.5; } function ensureCounterClockwise(points) { const clone = Array.isArray(points) ? points.map((point) => ({ ...point })) : []; return polygonArea(clone) < 0 ? clone.reverse() : clone; } function createImageRect(imageInfo) { if (!imageInfo) { return []; } return [ { x: 0, y: 0 }, { x: imageInfo.width, y: 0 }, { x: imageInfo.width, y: imageInfo.height }, { x: 0, y: imageInfo.height } ]; } function snapToAxis(anchor, point) { if (!anchor || !point) { return point; } const dx = point.x - anchor.x; const dy = point.y - anchor.y; if (Math.abs(dx) >= Math.abs(dy)) { return { x: point.x, y: anchor.y }; } return { x: anchor.x, y: point.y }; } function normalizePoint(rawPoint) { if (!rawPoint || rawPoint.x === undefined || rawPoint.y === undefined) { return null; } return { x: safeNumber(rawPoint.x), y: safeNumber(rawPoint.y) }; } function normalizeWall(rawWall) { const start = normalizePoint(rawWall && rawWall.start); const end = normalizePoint(rawWall && rawWall.end); if (!start || !end || distanceBetween(start, end) < 1e-6) { return null; } return { id: String((rawWall && rawWall.id) || uid()), start, end }; } function pointToWorld(point, imageInfo, metersPerPixel) { return { x: (point.x - imageInfo.width * 0.5) * metersPerPixel, y: (imageInfo.height * 0.5 - point.y) * metersPerPixel }; } function pointsToWorld(points, imageInfo, metersPerPixel) { return points.map((point) => pointToWorld(point, imageInfo, metersPerPixel)); } function buildGridLines(imageInfo, metersPerPixel, requestedStepM, showGrid) { if (!showGrid || !imageInfo || !Number.isFinite(metersPerPixel) || metersPerPixel <= 0) { return { vertical: [], horizontal: [], stepM: 0 }; } let stepM = Math.max(0.1, safeNumber(requestedStepM, DEFAULT_GRID_STEP_M)); let stepPx = stepM / metersPerPixel; while (stepPx < 18) { stepM *= 2; stepPx = stepM / metersPerPixel; } const vertical = []; const horizontal = []; for (let x = 0; x <= imageInfo.width + 0.1; x += stepPx) { vertical.push(Number(x.toFixed(3))); } for (let y = 0; y <= imageInfo.height + 0.1; y += stepPx) { horizontal.push(Number(y.toFixed(3))); } return { vertical, horizontal, stepM }; } function editorPointFromEvent(svg, event, imageInfo) { if (!svg || !imageInfo) { return null; } const rect = svg.getBoundingClientRect(); if (!rect.width || !rect.height) { return null; } const x = clamp(((event.clientX - rect.left) / rect.width) * imageInfo.width, 0, imageInfo.width); const y = clamp(((event.clientY - rect.top) / rect.height) * imageInfo.height, 0, imageInfo.height); return { x, y }; } function readImageFile(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const image = new Image(); image.onload = () => { resolve({ name: file.name, dataUrl: String(reader.result || ""), width: image.naturalWidth, height: image.naturalHeight }); }; image.onerror = () => reject(new Error("Could not decode the uploaded image.")); image.src = String(reader.result || ""); }; reader.onerror = () => reject(reader.error || new Error("Could not read the uploaded image.")); reader.readAsDataURL(file); }); } async function readImageFromUrl(url, name) { const response = await fetch(url); if (!response.ok) { throw new Error(`Could not load sample image (${response.status}).`); } const blob = await response.blob(); return readImageFile(new File([blob], name, { type: blob.type || "image/jpeg" })); } function readJsonFile(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { try { resolve(JSON.parse(String(reader.result || ""))); } catch (error) { reject(error); } }; reader.onerror = () => reject(reader.error || new Error("Could not read the project file.")); reader.readAsText(file); }); } function downloadText(filename, contents, mimeType = "text/plain;charset=utf-8") { const blob = new Blob([contents], { type: mimeType }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); } function triangulateFootprint(worldPoints) { if (!window.THREE) { throw new Error("Three.js is not ready yet."); } const contour = ensureCounterClockwise(worldPoints); if (contour.length < 3) { throw new Error("At least three footprint points are required."); } const vectors = contour.map((point) => new THREE.Vector2(point.x, point.y)); const faces = THREE.ShapeUtils.triangulateShape(vectors, []); return { contour, faces }; } function createWallPrism(start, end, wallHeightM, wallThicknessM) { const dx = end.x - start.x; const dy = end.y - start.y; const length = Math.hypot(dx, dy); if (length < 1e-6) { return null; } const halfThickness = Math.max(0.005, wallThicknessM * 0.5); const nx = (-dy / length) * halfThickness; const ny = (dx / length) * halfThickness; const a = { x: start.x + nx, y: start.y + ny, z: 0 }; const b = { x: end.x + nx, y: end.y + ny, z: 0 }; const c = { x: end.x - nx, y: end.y - ny, z: 0 }; const d = { x: start.x - nx, y: start.y - ny, z: 0 }; const topOffset = { z: wallHeightM }; const vertices = [ a, b, c, d, { x: a.x, y: a.y, z: topOffset.z }, { x: b.x, y: b.y, z: topOffset.z }, { x: c.x, y: c.y, z: topOffset.z }, { x: d.x, y: d.y, z: topOffset.z } ]; const faces = [ [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7] ]; return { vertices, faces }; } function createWallPreviewGeometry(start, end, wallHeightM, wallThicknessM) { const prism = createWallPrism(start, end, wallHeightM, wallThicknessM); if (!prism || !window.THREE) { return null; } const geometry = new THREE.BufferGeometry(); const positions = []; prism.faces.forEach((face) => { face.forEach((index) => { const vertex = prism.vertices[index]; positions.push(vertex.x, vertex.y, vertex.z); }); }); geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); geometry.computeVertexNormals(); return geometry; } function createProjectSnapshot(state) { return { version: 1, exportedAt: new Date().toISOString(), projectName: state.projectName, imageInfo: state.imageInfo, metersPerPixel: state.metersPerPixel, calibrationDistanceM: state.calibrationDistanceM, calibrationPoints: state.calibrationPoints, wallHeightM: state.wallHeightM, wallThicknessM: state.wallThicknessM, planOpacity: state.planOpacity, gridStepM: state.gridStepM, showGrid: state.showGrid, chainWalls: state.chainWalls, useCustomFootprint: state.useCustomFootprint, footprintPoints: state.footprintPoints, footprintClosed: state.footprintClosed, walls: state.walls }; } function buildObjExport({ projectName, imageInfo, metersPerPixel, wallHeightM, wallThicknessM, footprintImagePoints, walls }) { const footprintWorld = pointsToWorld(footprintImagePoints, imageInfo, metersPerPixel); const triangulated = triangulateFootprint(footprintWorld); const wallWorld = walls.map((wall) => ({ id: wall.id, start: pointToWorld(wall.start, imageInfo, metersPerPixel), end: pointToWorld(wall.end, imageInfo, metersPerPixel) })); const lines = [ `# ${TOOL_NAME}`, `# Project: ${projectName}`, "# Units: meters", "# Coordinate system: X/Y floor plane, Z up", `# Roof elevation: ${formatNumber(wallHeightM, 3)} m`, "" ]; let vertexOffset = 1; const addVertices = (vertices) => { vertices.forEach((vertex) => { lines.push(`v ${vertex.x.toFixed(6)} ${vertex.y.toFixed(6)} ${vertex.z.toFixed(6)}`); }); }; const addFaces = (faces) => { faces.forEach((face) => { lines.push(`f ${face.map((index) => index + vertexOffset).join(" ")}`); }); }; lines.push("o floor"); addVertices(triangulated.contour.map((point) => ({ x: point.x, y: point.y, z: 0 }))); addFaces(triangulated.faces); vertexOffset += triangulated.contour.length; lines.push(""); lines.push("o roof"); addVertices(triangulated.contour.map((point) => ({ x: point.x, y: point.y, z: wallHeightM }))); addFaces(triangulated.faces); vertexOffset += triangulated.contour.length; lines.push(""); lines.push("o walls"); wallWorld.forEach((wall, index) => { const prism = createWallPrism(wall.start, wall.end, wallHeightM, wallThicknessM); if (!prism) { return; } lines.push(`g wall_${index + 1}`); addVertices(prism.vertices); addFaces(prism.faces); vertexOffset += prism.vertices.length; }); return { objText: lines.join("\n"), footprintWorld, wallWorld, stats: { floorTriangles: triangulated.faces.length, wallCount: wallWorld.length } }; } function disposeMaterial(material) { if (!material) { return; } if (Array.isArray(material)) { material.forEach(disposeMaterial); return; } if (material.map) { material.map.dispose(); } material.dispose(); } function clearThreeGroup(group) { if (!group) { return; } while (group.children.length) { const child = group.children[0]; group.remove(child); if (child.geometry) { child.geometry.dispose(); } if (child.material) { disposeMaterial(child.material); } } } function framePreview(api) { if (!api || !api.contentGroup || !window.THREE) { return; } const box = new THREE.Box3().setFromObject(api.contentGroup); if (box.isEmpty()) { return; } const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); const radius = Math.max(size.x, size.y, size.z, 1); api.controls.target.copy(center); api.camera.position.set(center.x + radius * 1.25, center.y - radius * 1.7, center.z + radius * 1.2); api.camera.near = Math.max(0.1, radius / 200); api.camera.far = Math.max(500, radius * 25); api.camera.updateProjectionMatrix(); api.controls.update(); } function NumberField({ label, value, onChange, step = "any", min, max, help }) { return ( ); } function StatTile({ label, value, note, tone = "default" }) { return (
{label}
{value}
{note ?
{note}
: null}
); } function App() { const imageInputRef = useRef(null); const projectInputRef = useRef(null); const svgRef = useRef(null); const previewRef = useRef(null); const previewApiRef = useRef(null); const autoFramePreviewRef = useRef(true); const [projectName, setProjectName] = useState("RF floor plan"); const [imageInfo, setImageInfo] = useState(null); const [status, setStatus] = useState( "Load a floor-plan image, calibrate the scale, trace the footprint if needed, then draw walls for OBJ export." ); const [editorMode, setEditorMode] = useState("walls"); const [metersPerPixel, setMetersPerPixel] = useState(DEFAULT_METERS_PER_PIXEL); const [calibrationDistanceM, setCalibrationDistanceM] = useState(DEFAULT_CALIBRATION_DISTANCE_M); const [calibrationPoints, setCalibrationPoints] = useState([]); const [wallHeightM, setWallHeightM] = useState(DEFAULT_WALL_HEIGHT_M); const [wallThicknessM, setWallThicknessM] = useState(DEFAULT_WALL_THICKNESS_M); const [planOpacity, setPlanOpacity] = useState(DEFAULT_PLAN_OPACITY); const [gridStepM, setGridStepM] = useState(DEFAULT_GRID_STEP_M); const [showGrid, setShowGrid] = useState(true); const [chainWalls, setChainWalls] = useState(true); const [useCustomFootprint, setUseCustomFootprint] = useState(false); const [footprintPoints, setFootprintPoints] = useState([]); const [footprintClosed, setFootprintClosed] = useState(false); const [walls, setWalls] = useState([]); const [selectedWallId, setSelectedWallId] = useState(null); const [wallDraftStart, setWallDraftStart] = useState(null); const [hoverPoint, setHoverPoint] = useState(null); const scaleReady = !!imageInfo && Number.isFinite(metersPerPixel) && metersPerPixel > 0; const customFootprintReady = useCustomFootprint && footprintClosed && footprintPoints.length >= 3; const activeFootprintImagePoints = useMemo(() => { if (!imageInfo) { return []; } if (useCustomFootprint) { return customFootprintReady ? footprintPoints : []; } return createImageRect(imageInfo); }, [customFootprintReady, footprintPoints, imageInfo, useCustomFootprint]); const footprintWorldPoints = useMemo(() => { if (!scaleReady || !activeFootprintImagePoints.length) { return []; } return pointsToWorld(activeFootprintImagePoints, imageInfo, metersPerPixel); }, [activeFootprintImagePoints, imageInfo, metersPerPixel, scaleReady]); const planWidthM = imageInfo ? imageInfo.width * metersPerPixel : 0; const planHeightM = imageInfo ? imageInfo.height * metersPerPixel : 0; const footprintAreaM2 = footprintWorldPoints.length >= 3 ? Math.abs(polygonArea(footprintWorldPoints)) : 0; const totalWallLengthM = scaleReady ? walls.reduce((sum, wall) => sum + distanceBetween(wall.start, wall.end) * metersPerPixel, 0) : 0; const calibrationPixelLength = calibrationPoints.length === 2 ? distanceBetween(calibrationPoints[0], calibrationPoints[1]) : 0; const gridLines = useMemo( () => buildGridLines(imageInfo, metersPerPixel, gridStepM, showGrid), [gridStepM, imageInfo, metersPerPixel, showGrid] ); useEffect(() => { if (!previewRef.current || !window.THREE) { return undefined; } const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.domElement.style.width = "100%"; renderer.domElement.style.height = "100%"; renderer.domElement.style.display = "block"; previewRef.current.appendChild(renderer.domElement); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x08111d); const camera = new THREE.PerspectiveCamera(56, 1, 0.1, 2000); camera.position.set(10, -16, 9); const controls = new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.target.set(0, 0, 1.25); const ambient = new THREE.HemisphereLight(0xf8fafc, 0x0f172a, 1.15); scene.add(ambient); const keyLight = new THREE.DirectionalLight(0xffffff, 0.95); keyLight.position.set(18, -20, 26); scene.add(keyLight); const rimLight = new THREE.DirectionalLight(0x7dd3fc, 0.5); rimLight.position.set(-20, 12, 16); scene.add(rimLight); const grid = new THREE.GridHelper(160, 40, 0x2f6879, 0x1d3342); grid.rotation.x = Math.PI / 2; scene.add(grid); const axes = new THREE.AxesHelper(2.5); scene.add(axes); const contentGroup = new THREE.Group(); scene.add(contentGroup); const api = { renderer, scene, camera, controls, contentGroup, animationFrame: 0 }; previewApiRef.current = api; const resize = () => { const width = previewRef.current ? previewRef.current.clientWidth || 1 : 1; const height = previewRef.current ? previewRef.current.clientHeight || 1 : 1; renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); }; const animate = () => { api.animationFrame = requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); }; resize(); animate(); window.addEventListener("resize", resize); return () => { cancelAnimationFrame(api.animationFrame); window.removeEventListener("resize", resize); clearThreeGroup(contentGroup); controls.dispose(); renderer.dispose(); renderer.domElement.remove(); previewApiRef.current = null; }; }, []); useEffect(() => { const api = previewApiRef.current; if (!api || !window.THREE) { return; } clearThreeGroup(api.contentGroup); if (!scaleReady) { return; } if (footprintWorldPoints.length >= 3) { const contour = ensureCounterClockwise(footprintWorldPoints); const shape = new THREE.Shape(contour.map((point) => new THREE.Vector2(point.x, point.y))); const floor = new THREE.Mesh( new THREE.ShapeGeometry(shape), new THREE.MeshStandardMaterial({ color: 0xdab277, roughness: 0.9, metalness: 0.04, side: THREE.DoubleSide, transparent: true, opacity: 0.96 }) ); api.contentGroup.add(floor); const roof = new THREE.Mesh( new THREE.ShapeGeometry(shape), new THREE.MeshStandardMaterial({ color: 0x8faec2, roughness: 0.68, metalness: 0.08, side: THREE.DoubleSide, transparent: true, opacity: 0.5 }) ); roof.position.z = wallHeightM; api.contentGroup.add(roof); const floorOutline = new THREE.LineLoop( new THREE.BufferGeometry().setFromPoints( contour.map((point) => new THREE.Vector3(point.x, point.y, 0.015)) ), new THREE.LineBasicMaterial({ color: 0xfff6d5 }) ); api.contentGroup.add(floorOutline); const roofOutline = new THREE.LineLoop( new THREE.BufferGeometry().setFromPoints( contour.map((point) => new THREE.Vector3(point.x, point.y, wallHeightM + 0.015)) ), new THREE.LineBasicMaterial({ color: 0xbfe7ff }) ); api.contentGroup.add(roofOutline); } walls.forEach((wall) => { const startWorld = pointToWorld(wall.start, imageInfo, metersPerPixel); const endWorld = pointToWorld(wall.end, imageInfo, metersPerPixel); const geometry = createWallPreviewGeometry(startWorld, endWorld, wallHeightM, wallThicknessM); if (!geometry) { return; } const selected = wall.id === selectedWallId; const mesh = new THREE.Mesh( geometry, new THREE.MeshStandardMaterial({ color: selected ? 0xf97316 : 0x0f766e, roughness: 0.5, metalness: 0.08, transparent: true, opacity: selected ? 0.95 : 0.88 }) ); api.contentGroup.add(mesh); const centerline = new THREE.Line( new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(startWorld.x, startWorld.y, wallHeightM * 0.5), new THREE.Vector3(endWorld.x, endWorld.y, wallHeightM * 0.5) ]), new THREE.LineBasicMaterial({ color: selected ? 0xfff1c4 : 0xdaf5ef }) ); api.contentGroup.add(centerline); }); if (autoFramePreviewRef.current) { framePreview(api); autoFramePreviewRef.current = false; } }, [footprintWorldPoints, imageInfo, metersPerPixel, scaleReady, selectedWallId, wallHeightM, wallThicknessM, walls]); function resetTracingState() { setCalibrationPoints([]); setFootprintPoints([]); setFootprintClosed(false); setWalls([]); setSelectedWallId(null); setWallDraftStart(null); setHoverPoint(null); } async function handleImageChange(event) { const file = event.target.files && event.target.files[0]; if (!file) { return; } try { const loaded = await readImageFile(file); setImageInfo(loaded); setProjectName(stripExtension(file.name)); resetTracingState(); setUseCustomFootprint(false); setEditorMode("calibrate"); autoFramePreviewRef.current = true; setStatus(`Loaded ${file.name}. Pick two calibration points, enter the real distance, then trace walls.`); } catch (error) { console.error(error); setStatus(`Could not load ${file.name}: ${error.message}`); } finally { event.target.value = ""; } } async function handleProjectLoad(event) { const file = event.target.files && event.target.files[0]; if (!file) { return; } try { const payload = await readJsonFile(file); if (!payload || payload.version !== 1) { throw new Error("Unsupported project format."); } const nextImage = payload.imageInfo && payload.imageInfo.dataUrl ? payload.imageInfo : null; const nextWalls = Array.isArray(payload.walls) ? payload.walls.map(normalizeWall).filter(Boolean) : []; const nextCalibration = Array.isArray(payload.calibrationPoints) ? payload.calibrationPoints.map(normalizePoint).filter(Boolean).slice(0, 2) : []; const nextFootprint = Array.isArray(payload.footprintPoints) ? payload.footprintPoints.map(normalizePoint).filter(Boolean) : []; setProjectName(String(payload.projectName || "RF floor plan")); setImageInfo(nextImage); setMetersPerPixel(Math.max(0, safeNumber(payload.metersPerPixel, DEFAULT_METERS_PER_PIXEL))); setCalibrationDistanceM(Math.max(0.1, safeNumber(payload.calibrationDistanceM, DEFAULT_CALIBRATION_DISTANCE_M))); setCalibrationPoints(nextCalibration); setWallHeightM(Math.max(0.1, safeNumber(payload.wallHeightM, DEFAULT_WALL_HEIGHT_M))); setWallThicknessM(Math.max(0.01, safeNumber(payload.wallThicknessM, DEFAULT_WALL_THICKNESS_M))); setPlanOpacity(clamp(safeNumber(payload.planOpacity, DEFAULT_PLAN_OPACITY), 0.15, 1)); setGridStepM(Math.max(0.1, safeNumber(payload.gridStepM, DEFAULT_GRID_STEP_M))); setShowGrid(payload.showGrid !== false); setChainWalls(payload.chainWalls !== false); setUseCustomFootprint(!!payload.useCustomFootprint); setFootprintPoints(nextFootprint); setFootprintClosed(!!payload.footprintClosed); setWalls(nextWalls); setSelectedWallId(nextWalls[0] ? nextWalls[0].id : null); setWallDraftStart(null); setHoverPoint(null); setEditorMode("walls"); autoFramePreviewRef.current = true; setStatus(`Loaded ${file.name}.`); } catch (error) { console.error(error); setStatus(`Could not load ${file.name}: ${error.message}`); } finally { event.target.value = ""; } } function saveProjectJson() { const payload = createProjectSnapshot({ projectName, imageInfo, metersPerPixel, calibrationDistanceM, calibrationPoints, wallHeightM, wallThicknessM, planOpacity, gridStepM, showGrid, chainWalls, useCustomFootprint, footprintPoints, footprintClosed, walls }); downloadText(`${sanitizeFilename(projectName)}-project.json`, JSON.stringify(payload, null, 2), "application/json;charset=utf-8"); setStatus("Saved the current project JSON snapshot."); } function applyCalibration() { if (calibrationPoints.length !== 2) { setStatus("Pick exactly two calibration points on the plan first."); return; } const pixels = distanceBetween(calibrationPoints[0], calibrationPoints[1]); if (pixels < 1) { setStatus("The calibration line is too short. Pick points farther apart."); return; } const nextDistance = Math.max(0.01, safeNumber(calibrationDistanceM, DEFAULT_CALIBRATION_DISTANCE_M)); setMetersPerPixel(nextDistance / pixels); autoFramePreviewRef.current = true; setStatus( `Scale locked: ${formatNumber(nextDistance / pixels, 4)} m/px from a ${formatNumber(nextDistance)} m reference line.` ); } function updateMode(nextMode) { setEditorMode(nextMode); setHoverPoint(null); if (nextMode !== "walls") { setWallDraftStart(null); } if (nextMode !== "footprint" && footprintClosed) { setHoverPoint(null); } } function handlePlanPointerMove(event) { if (!imageInfo) { return; } const rawPoint = editorPointFromEvent(svgRef.current, event, imageInfo); if (!rawPoint) { return; } if (editorMode === "walls" && wallDraftStart) { setHoverPoint(event.shiftKey ? snapToAxis(wallDraftStart, rawPoint) : rawPoint); return; } if (editorMode === "footprint" && useCustomFootprint && !footprintClosed && footprintPoints.length) { const anchor = footprintPoints[footprintPoints.length - 1]; setHoverPoint(event.shiftKey ? snapToAxis(anchor, rawPoint) : rawPoint); return; } setHoverPoint(rawPoint); } function handlePlanPointerLeave() { setHoverPoint(null); } function handleCalibrationClick(point) { if (calibrationPoints.length === 0) { setCalibrationPoints([point]); setStatus("Calibration start pinned. Click the second point on the known reference distance."); return; } if (calibrationPoints.length === 1) { setCalibrationPoints([calibrationPoints[0], point]); setStatus("Calibration line captured. Enter the real-world distance and press Apply scale."); return; } setCalibrationPoints([point]); setStatus("Calibration restarted from a new first point."); } function handleWallClick(point, shiftKey) { if (!wallDraftStart) { setWallDraftStart(point); setSelectedWallId(null); setStatus("Wall run started. Click the next point to place a wall segment."); return; } const snappedPoint = shiftKey ? snapToAxis(wallDraftStart, point) : point; if (distanceBetween(wallDraftStart, snappedPoint) < 3) { setStatus("That wall segment is too short to keep. Click farther away."); return; } const nextWall = { id: uid(), start: wallDraftStart, end: snappedPoint }; setWalls((current) => [...current, nextWall]); setSelectedWallId(nextWall.id); setWallDraftStart(chainWalls ? snappedPoint : null); autoFramePreviewRef.current = true; const segmentLengthText = scaleReady ? `${formatNumber(distanceBetween(wallDraftStart, snappedPoint) * metersPerPixel)} m` : `${formatNumber(distanceBetween(wallDraftStart, snappedPoint), 1)} px`; setStatus(`Added wall segment ${segmentLengthText}.${chainWalls ? " Continue clicking to chain the next segment." : ""}`); } function handleFootprintClick(point, shiftKey) { if (!useCustomFootprint) { setStatus("Enable custom footprint mode first."); return; } if (footprintClosed) { setStatus("The custom footprint is already closed. Clear it to retrace."); return; } const anchor = footprintPoints.length ? footprintPoints[footprintPoints.length - 1] : null; const snappedPoint = anchor && shiftKey ? snapToAxis(anchor, point) : point; setFootprintPoints((current) => [...current, snappedPoint]); setStatus( footprintPoints.length === 0 ? "Footprint started. Add at least three points, then close the outline." : "Footprint point added." ); } function handlePlanPointerDown(event) { if (!imageInfo) { return; } const point = editorPointFromEvent(svgRef.current, event, imageInfo); if (!point) { return; } if (editorMode === "calibrate") { handleCalibrationClick(point); return; } if (editorMode === "walls") { handleWallClick(point, event.shiftKey); return; } if (editorMode === "footprint") { handleFootprintClick(point, event.shiftKey); } } function finishWallRun() { setWallDraftStart(null); setHoverPoint(null); setStatus("Finished the current wall run."); } function removeWall(id) { setWalls((current) => current.filter((wall) => wall.id !== id)); if (selectedWallId === id) { setSelectedWallId(null); } setStatus("Removed the selected wall."); } function removeSelectedWall() { if (!selectedWallId) { setStatus("Select a wall first."); return; } removeWall(selectedWallId); } function undoLastWall() { setWalls((current) => { if (!current.length) { return current; } const next = current.slice(0, -1); setSelectedWallId(next[next.length - 1] ? next[next.length - 1].id : null); return next; }); setStatus("Removed the most recent wall segment."); } function clearWalls() { if (!walls.length) { return; } if (!window.confirm("Clear all wall segments from this plan?")) { return; } setWalls([]); setSelectedWallId(null); setWallDraftStart(null); setStatus("Cleared all wall segments."); } function closeFootprint() { if (footprintPoints.length < 3) { setStatus("A custom footprint needs at least three points."); return; } setFootprintClosed(true); autoFramePreviewRef.current = true; setStatus("Closed the custom floor/roof footprint."); } function clearFootprint() { setFootprintPoints([]); setFootprintClosed(false); setHoverPoint(null); setStatus("Cleared the custom footprint."); } function exportObj() { if (!imageInfo) { setStatus("Load a floor plan first."); return; } if (!scaleReady) { setStatus("Apply the image scale before exporting so the OBJ is in meters."); return; } if (useCustomFootprint && !customFootprintReady) { setStatus("Close the custom footprint before exporting."); return; } try { const exportData = buildObjExport({ projectName, imageInfo, metersPerPixel, wallHeightM, wallThicknessM, footprintImagePoints: activeFootprintImagePoints, walls }); downloadText(`${sanitizeFilename(projectName)}.obj`, exportData.objText); setStatus( `Exported OBJ with ${exportData.stats.wallCount} walls, ${exportData.stats.floorTriangles} floor triangles, and a roof at ${formatNumber(wallHeightM)} m.` ); } catch (error) { console.error(error); setStatus(`OBJ export failed: ${error.message}`); } } function frame3dView() { framePreview(previewApiRef.current); } async function loadBundledSample() { try { const loaded = await readImageFromUrl("./assets/sample-floorplan.jpg", "sample-floorplan.jpg"); setImageInfo(loaded); setProjectName("sample-floorplan"); resetTracingState(); setUseCustomFootprint(false); setEditorMode("calibrate"); autoFramePreviewRef.current = true; setStatus("Loaded the bundled sample plan. Pick two known points to calibrate and start tracing."); } catch (error) { console.error(error); setStatus(`Could not load the bundled sample: ${error.message}`); } } const wallList = walls.map((wall, index) => { const length = distanceBetween(wall.start, wall.end); const lengthLabel = scaleReady ? `${formatNumber(length * metersPerPixel)} m` : `${formatNumber(length, 1)} px`; return { ...wall, label: `Wall ${index + 1}`, lengthLabel }; }); const exportReady = !!imageInfo && scaleReady && (!useCustomFootprint || customFootprintReady); return (
Hostinger-ready browser tool Meters + Z-up OBJ for RF

{TOOL_NAME}

Trace a floor plan image, calibrate its scale, draw 2.5 m walls, and export a clean OBJ containing the floor, roof, and vertical wall geometry for the RF RSRP workflow.

Export contract
The OBJ stays in meters so `Scale to meters = 1` works when you import it into the RF tool.
The image midpoint becomes `(0, 0)` in plan view, with `Z` up, floor at `0`, and roof at `{formatNumber(wallHeightM)} m`.
Walls export as vertical prisms with side faces only, so the floor and roof remain the main horizontal RF surfaces.
{status}

Plan editor

Trace directly on the uploaded plan. Hold Shift while clicking to lock horizontal or vertical segments.

{imageInfo ? ( {gridLines.vertical.map((x) => ( ))} {gridLines.horizontal.map((y) => ( ))} {!useCustomFootprint && imageInfo ? ( `${point.x},${point.y}`).join(" ")} className="footprint-outline footprint-outline-default" /> ) : null} {footprintPoints.length ? ( <> `${point.x},${point.y}`).join(" ")} className="footprint-outline" /> {footprintClosed ? ( `${point.x},${point.y}`).join(" ")} className="footprint-fill" /> ) : null} ) : null} {editorMode === "footprint" && useCustomFootprint && !footprintClosed && footprintPoints.length && hoverPoint ? ( ) : null} {footprintPoints.map((point, index) => ( ))} {walls.map((wall, index) => { const selected = wall.id === selectedWallId; return ( { event.stopPropagation(); setSelectedWallId(wall.id); setStatus(`Selected Wall ${index + 1}.`); }} /> ); })} {editorMode === "walls" && wallDraftStart && hoverPoint ? ( ) : null} {wallDraftStart ? ( ) : null} {calibrationPoints.map((point, index) => ( ))} {calibrationPoints.length >= 1 && hoverPoint && editorMode === "calibrate" ? ( ) : null} {calibrationPoints.length === 2 ? ( ) : null} ) : (
No floor plan loaded yet
Start with an image or scanned plan, then use calibration mode to turn pixel distances into meters before drawing your walls.
)}
{imageInfo ? `${imageInfo.width} x ${imageInfo.height} px` : "Image pending"} {scaleReady ? `${formatNumber(metersPerPixel, 4)} m/px` : "Scale not applied yet"} {gridLines.stepM ? `Grid ${formatNumber(gridLines.stepM)} m` : "Grid hidden"}

3D verification

Floor sits at z=0 and the roof follows the wall height.

); } ReactDOM.createRoot(document.getElementById("root")).render();