@asunited/floor-planner
v1.1.0
Published
A plug-and-play 2D/3D floor planner React component with wall drawing, furniture, drawing takeoff (PDF/image import, scale calibration, polygon area measurement, AI-assisted room detection), export (PNG/SVG/JSON/CSV), undo/redo and keyboard shortcuts.
Readme
asu-floorplanner
A self-contained 2D/3D floor planner React component. Drop it into any Next.js, Vite, or Create-React-App project and pass an onSave callback — no API setup required inside the package.
Live
- Playground: https://floorplanner.asunited.com.sg
- Backend API: https://floorplanner-api.asunited.com.sg
- API docs: https://floorplanner-api.asunited.com.sg/docs
Pushing to main builds the package, builds the playground and deploys it to
Cloud Run in Singapore. See .github/workflows/deploy.yml.
Install
npm install asu-floorplanner
# or
pnpm add asu-floorplannerQuick start
import { FloorPlanner } from 'asu-floorplanner';
export default function MyPage() {
return (
// The parent must have a defined height
<div style={{ height: '100vh' }}>
<FloorPlanner
onSave={async (data) => {
// data = { shapes, walls, version }
await fetch('/api/floor-plans/my-plan', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}}
/>
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| onSave | (data: FloorPlanData) => Promise<void> \| void | — | Called on Ctrl+S and auto-save. If omitted, saving is disabled. |
| initialData | { id, name, shapes, walls } | — | Pre-load an existing plan on mount. |
| autoSaveDelay | number | 30000 | Auto-save debounce in milliseconds. |
| className | string | '' | Extra CSS classes on the wrapper div. |
| mode | 'design' \| 'takeoff' | 'design' | Which side panel opens first. Both are always reachable from the toggle. |
| onAnalyze | (req: AnalyzeRequest) => Promise<AnalyzeResult> | — | Host-supplied drawing analyser. Omit to disable auto-detect. |
| takeoffRules | Partial<TakeoffRules> | — | Upturn height, wastage, deduction minimum, variance tolerance. |
Load an existing plan
const plan = await fetch('/api/floor-plans/plan-1').then(r => r.json());
<FloorPlanner
initialData={{
id: plan.id,
name: plan.name,
shapes: plan.shapes,
walls: plan.walls,
}}
onSave={async (data) => {
await fetch(`/api/floor-plans/${plan.id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}}
/>Tailwind CSS
The package uses Tailwind utility classes. Add the package path to your tailwind.config:
// tailwind.config.js
module.exports = {
content: [
'./src/**/*.{ts,tsx}',
'./node_modules/asu-floorplanner/dist/**/*.{js,mjs}',
],
};If you are not using Tailwind, import the pre-built stylesheet instead:
import 'asu-floorplanner/dist/styles.css'; // coming in v1.1Drawing takeoff
Added in v1.1. Load a floor plan drawing, set the scale off a known dimension, trace the wet areas, and read off quantities.
<FloorPlanner
mode="takeoff"
onAnalyze={async (req) => {
const res = await fetch('/api/takeoff/analyse', {
method: 'POST',
body: JSON.stringify(req),
});
return res.json();
}}
takeoffRules={{ upturnHeight: 0.3, wastageFactor: 0.1, wetAreasOnly: true }}
onSave={save}
/>The workflow
- Upload a PDF or image. Vector PDFs also give up their embedded text layer, so dimension strings are read exactly — no OCR involved.
- Calibrate (
C): click both ends of a dimension you know, type its real length. Everything downstream derives from this, so it is recorded with the plan and shown on every export. - Trace (
A): click each corner of an area. Click the first point again, right-click, or press Enter to close. Concave outlines are measured properly via the shoelace formula — a notched room is not billed as its bounding box. - Or auto-detect: press Analyse drawing. Proposals appear dashed on the canvas and queued in the panel. Nothing becomes a quantity until someone accepts it, and accepted shapes record that they came from AI.
- Read off floor area, perimeter and billable area per room. Export CSV.
Accuracy, and what "accurate" means here
The traced polygon gives one area. The dimensions printed on the drawing give a
second, independent one. When they disagree by more than varianceTolerance
(2% by default) the room is flagged for review rather than billed. That
cross-check is what makes a number defensible to a customer.
Two things are deliberate:
- A model never measures. Geometry comes from deterministic detection; the model reads labels and dimension text. Vision models return plausible coordinates, which on an invoice is worse than none.
upturnHeightandwastageFactordefault to 0. Waterproofing is normally billed as floor area plus membrane upturn up the wall, plus an overlap allowance — but until your QS confirms the basis, nothing is added on an assumption. Set them explicitly.
An uncalibrated plan says so in the status bar, in the panel, and in the CSV footer.
Analyser contract
onAnalyze receives the rendered drawing, its pixel dimensions, where it sits
in world coordinates, the current scale and any PDF text layer. It returns
DetectedRoom[] with points in image pixel coordinates — the SDK maps them
onto the canvas, simplifies contours, clamps confidences and drops degenerate
rings.
Keep the model call server-side. A key in the browser bundle ships to every
consumer of this package. See examples/ for a working endpoint with
OpenCV geometry, swappable Gemini/Claude adapters and the dimension cross-check.
Saved payload
getExportData() now returns v1.1, which additionally carries scale,
backgroundImage and takeoffRules. Reading stays backward compatible: a v1.0
plan loads unchanged. A billed quantity is not reproducible without knowing how
the drawing was calibrated, so persist the whole payload.
Advanced: use individual pieces
import {
FloorPlannerCanvas,
Toolbar,
PropertiesPanel,
StatusBar,
ViewToggle,
ExportModal,
TakeoffPanel,
TakeoffOverlay,
CalibrationModal,
DetectionReview,
useFloorPlannerStore,
useKeyboardShortcuts,
useAutoSave,
useDrawingImport,
useAnalyze,
summariseTakeoff,
takeoffToCsv,
polygonArea,
} from 'asu-floorplanner';
export function CustomLayout() {
const { viewMode } = useFloorPlannerStore();
useKeyboardShortcuts();
return (
<div className="flex h-screen">
<Toolbar />
<div className="flex-1 relative">
{viewMode === '2d' ? <FloorPlannerCanvas /> : null}
</div>
<PropertiesPanel />
</div>
);
}Keyboard shortcuts
| Key | Action |
|---|---|
| V / Esc | Select tool |
| R | Room (rectangle) |
| W | Wall tool |
| L | Line |
| D | Door |
| O | Window |
| T | Text label |
| F | Furniture |
| A | Area (trace a room outline) |
| C | Calibrate scale |
| G | Toggle grid |
| S | Toggle snap |
| + / - | Zoom in/out |
| 0 | Reset view |
| 2 / 3 | Switch 2D / 3D |
| Enter | Close the area being traced |
| Delete | Delete selected |
| Ctrl+Z | Undo |
| Ctrl+Y / Ctrl+Shift+Z | Redo |
| Ctrl+A | Select all |
| Ctrl+S | Save (calls onSave) |
Export formats
- PNG — raster snapshot of the 2D canvas
- SVG — scalable vector of all shapes and walls
- JSON — full plan data, re-importable via
importData() - CSV — takeoff quantities per room, with calibration provenance in the footer
TypeScript types
import type {
Shape, Wall, FloorPlanData, FloorPlannerProps, ToolType,
Point, Scale, ScaleCalibration,
AnalyzeRequest, AnalyzeResult, AnalyzeFn, DetectedRoom,
TakeoffRules, TakeoffSummary, RoomQuantity, QuantityVariance,
} from 'asu-floorplanner';Peer dependencies
react >= 18
react-dom >= 18
pdfjs-dist >= 4 (optional — only needed to import PDF drawings)pdfjs-dist is lazy-loaded and never bundled. Without it, image upload still
works and PDF upload fails with an actionable message.
Everything else (three.js, zustand, lucide-react, sonner, uuid, immer) is a
regular dependency: npm installs it alongside the package. These are not
bundled into dist, so if you link the package locally with file: or
npm link you must make sure they resolve — see playground/ for a working
setup.
License
UNLICENSED — private package for AS United Pte Ltd.
