);
}
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 toolMeters + 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 ? (
) : (
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.