@reekon-tools/boldr-cad
v1.1.0
Published
BOLDR's 2D layout editor (React Native, iOS/Android/web) and how its drawings are kept in BOLDR.
Downloads
374
Readme
@reekon-tools/boldr-cad
BOLDR's 2D layout editor (BOLDR CAD) as a React Native component, plus the model, solver and exporters behind it. Walls, doors and windows, spaces, dimensions and constraints, wall elevations and markup, drawn with Skia on iOS and Android and with react-native-web in a browser. Drawings are kept in BOLDR through a small provider interface, so the package has no Firebase dependency of its own.
Install
npm install @reekon-tools/boldr-cadPeer dependencies:
| Package | Range | Needed for |
| ----------------------------- | --------- | ------------------------------------------- |
| react | >=19 | always |
| react-native | >=0.74 | always (react-native-web in a browser) |
| @shopify/react-native-skia | >=2.5 | the editor on iOS and Android |
| react-native-gesture-handler| >=2.31 | the editor on iOS and Android |
| react-native-reanimated | >=4 | the editor on iOS and Android (pan and zoom run on the UI thread; its Babel plugin must be in the app's config) |
| react-native-svg | >=15 | the editor's icons on iOS and Android |
Only ./core (see below) runs without the four native packages.
How it resolves
The package has two entry points:
@reekon-tools/boldr-cadis the editor:CadEditor, the data layer and the device hooks.@reekon-tools/boldr-cad/coreis the drawing model without React: types,migrateProject, project files, unit formatting and the PDF/DXF exporters. For servers and tools.
React Native hosts read the TypeScript source. The package exports a
react-native condition that points at src/, so Metro compiles it like app
code and picks the .native platform files itself. Nothing is prebuilt for
native, which also means a local checkout linked into an app is live with no
build step.
For TypeScript in a React Native app, two compiler options are needed. Expo's base tsconfig already sets the first:
{
"compilerOptions": {
"customConditions": ["react-native"],
"moduleSuffixes": [".ios", ".android", ".native", ""]
}
}Web and Node take the built dist/ through the default and types
conditions. In a Vite app, alias react-native to react-native-web and let
.web.js win, because the platform files in dist/ have no bare X.js:
// vite.config.ts
resolve: {
alias: [{ find: /^react-native$/, replacement: 'react-native-web' }],
extensions: ['.web.js', '.web.tsx', '.web.ts', '.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'],
},dist/ is emitted with extensionless relative imports, so plain Node cannot
import('@reekon-tools/boldr-cad/core') directly. Run it through a bundler
(esbuild, tsx, vite-node) as BOLDR's own services do.
Using the editor
import { CadEditor, openCadDrawing, NotACadDrawing, type CadSync } from '@reekon-tools/boldr-cad';
function DrawingScreen({ provider }: { provider: CadDataProvider }) {
const [sync, setSync] = useState<CadSync | null>(null);
useEffect(() => {
let cancelled = false;
openCadDrawing(provider, { onError: e => console.error(e) }).then(
s => (cancelled ? s.stop() : setSync(s)),
e => { if (e instanceof NotACadDrawing) console.log(e.reason); }, // 'missing' | 'legacy'
);
return () => { cancelled = true; };
}, [provider]);
if (!sync) return null;
return <CadEditor sync={sync} onBack={goBack} theme="dark" devTools={false} />;
}Mount one CadEditor per screen. Mounting starts an editing session,
unmounting saves and stops the sync it was given. Without sync the drawing
lives in the browser's own storage, which is what the dev harness uses.
CadEditor props:
| Prop | Type | Purpose |
| ------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| sync | CadSync | The drawing, from openCadDrawing. Saved there as it changes. |
| onBack | () => void | The top bar's back button. Without it, Back opens the project's pages. |
| headerRight | ReactNode \| (buttonSize: number) => ReactNode | The host's own buttons in the top bar, such as a Bluetooth status icon. As a function it gets the bar's round-button size (42 pt on a phone, 46 otherwise). |
| theme | 'light' \| 'dark' | Canvas colors. The chrome is dark either way. Without it, the person's Theme setting decides. |
| units | { format: (inches: number) => string; metric: boolean; unit?: Unit; fractions?: boolean; denom?: number } | Lengths the host's way: how to write one, the unit a bare number is typed in (unit: ftin, in, ft, mm, cm or m; metric alone means mm), whether they're typed with fractions, and the fraction precision. Settings' Units then offers Account beside the editor's own. |
| fonts | SkiaFonts | Typefaces for text on the plan by CSS weight, with a fallback. Device only. |
| pickImage | () => Promise<{ dataUrl: string; w: number; h: number } \| null> | A photo or plan to trace. In a browser a file picker is the default. |
| saveFile | (name: string, mime: string, data: string) => void | How exports (PDF, DXF, project file) reach the person, e.g. a share sheet. In a browser they download. |
| readOnly | boolean | Pan and zoom only, nothing saved. A tap on a link's tile asks, then opens it. |
| devTools | boolean | Reset to the demo, open and save project files, in Settings. Default true; turn off in production. |
| links | CadLinks | The job's files to link on the drawing, and its lengths for lines (see below). Without it there's nothing to link. |
Fonts on a device, with react-native-skia:
const regular = useTypeface(require('./assets/Inter-Regular.ttf'));
const bold = useTypeface(require('./assets/Inter-Bold.ttf'));
const fonts: SkiaFonts | null = regular && bold ? { weights: { 400: regular, 700: bold }, fallback: regular } : null;Wait for the typefaces before mounting the editor.
Keeping a drawing in BOLDR
openCadDrawing(provider, options) loads a drawing through a
CadDataProvider and keeps it in step while the editor is open: only the
pages that changed are written, each stamped with the session's id; traced
images are uploaded and stored by reference; pages another device changed are
taken in when they aren't being edited locally.
interface CadDataProvider {
subscribeFile(cb: (file: { name: string; meta: CadFileMeta | null } | null, meta?: CadSnapshotMeta) => void): () => void;
subscribePages(cb: (pages: Record<string, CadPageDoc>, meta?: CadSnapshotMeta) => void): () => void;
writeMeta(meta: CadFileMeta): Promise<void>;
writePage(id: string, page: CadPageDoc): Promise<void>;
deletePage(id: string): Promise<void>;
uploadImage(pageId: string, dataUrl: string): Promise<string>; // returns a URL or a `stored:<path>` reference
imageUrl?(ref: string): Promise<string>; // turns a stored reference into a URL
uploadThumbnail?(dataUrl: string): Promise<void>;
measurements?: CadMeasurements;
}A drawing is one file document with fileData: CadFileMeta
(format: 'boldr-cad', version: 1, pageOrder, shared settings) and one
page document per page (name, the sketch, the underlay, and the rev of
the session that wrote it last). Storage paths are relative to the drawing's
own folder, so copying or moving a drawing with its folder keeps it working.
CadSnapshotMeta says whether a snapshot is the local cache's word
(fromCache: true) or the server's. Firestore answers from the cache first
when it's offline, and for a drawing never opened on that device the cache has
no pages: the editor then waits for the server rather than starting a blank
first page over the real one. Pass snapshot.metadata.fromCache from both
listeners and subscribe with includeMetadataChanges: true, so the server's
confirmation of an empty collection still arrives.
Options: initialize: true says the file was just created here, so its first
page is made without waiting for the server (and starts a new drawing when the
file has no drawing record yet); onError hears about failed writes and
uploads; signal (an AbortSignal) closes the drawing while it's still
opening, rejecting with Error('closed'). The promise rejects with
NotACadDrawing whose reason is 'missing' (no such file) or 'legacy' (a
file from the retired layout tool).
The returned CadSync has stop(), idle() (resolves once every write so
far has finished), saveThumbnail(pngDataUrl) and imageUrl(ref).
Measurements. A dimension drawn in the editor is the drawing's alone: no
measurement is made for it. The person saves one to the drawing's group from
its bar (Save to group, through links.saveMeasurement), and it's linked to
that measurement from then on. Drawings made by earlier versions kept every
driving dimension as a measurement of the drawing's file; when the provider
supplies measurements, those still follow their dimensions both ways
(create is no longer called). setPlaced(ids, linked) hears every
measurement the drawing shows: its own, and the job's it's linked to, each with
its group (and a form's column). measurementOf(constraint) gives a
constraint's value as BOLDR keeps it — lengths in micrometres, angles in
degrees — or null for reference and print dimensions and linked ones.
Linking the job's files and measurements
With links, Annotate gets a yellow + (under the rail; a phone's in the
trash's corner while nothing's selected) that puts the job's files on the plan
as tiles, with leaders, opened from the bar. A measurement comes into a drawing
only as the dimension it drives: in Draft, a line's length keypad (select the
line, tap +) offers the job's lengths (a page it swipes to on a touch screen,
Select from list on a desktop), and the one picked sets the line, linked to
it. A dimension is saved to the group from its bar (Save to group). A new length for a linked dimension asks first: Change both, Only this
drawing (unlinks), or Cancel.
interface CadLinks {
pick(req: { want: 'files'; linked: LinkTarget[] }): Promise<LinkTarget[]>; // [] for nothing
measurements?(cb: (list: LinkTarget[]) => void): () => void; // the job's lengths and angles, in the order to show them
pickMeasurement?(req: { kind: 'length' | 'angle' }): Promise<LinkTarget | null>; // the host's own list (a side drawer)
subscribe(targets: LinkTarget[], cb: (info: Record<string, LinkInfo>) => void): () => void;
open(target: LinkTarget): void;
update?(target: LinkTarget, value: number, capture?: unknown): void; // µm or degrees, the person having said so
saveMeasurement?(m: { kind: 'length' | 'angle'; value: number; capture?: unknown }): Promise<LinkTarget | null>;
canSave?: boolean; // false where a measurement can't be saved (a form's group)
}- A
LinkTargetis{ kind: 'file' | 'measurement', id, groupId, columnId? }plus what it was when it was linked (name,groupName,fileType,mkind,value,text). Resolve it bygroupIdandidin the job open now, never by a stored path, so copied and moved jobs keep working. pickoffers the job's files to pick. BOLDR's pickers show the measurements too, greyed out, and a tap on one says they're added in Draft; anything but a file handed back is dropped.- A line's length keypad and a dimension's angle keypad take the job's
measurements. On a touch screen the keypad swipes to a page of them; on a
desktop it has Select from list, which calls
pickMeasurement(the host's own list, those ofkindpickable and the rest greyed out), or shows that page when the host has none.measurementscalls back with the job's lengths and angles as measurement targets (withmkind,valuein micrometres or degrees, andtext), again as they change, and not until the whole job's been heard from; the keypad greys out those that aren't its kind. A host keypad (keypad) getsmeasurementKindandpickMeasurementon such a request: show the job's measurements a swipe away, greying out the other kind, and hand back the one tapped. - To draw that page itself, so the list looks as it does everywhere else in
the host, pass
measurementListtoCadEditor: a function of{ kind, pick, width, height }that returns the list's element (for examplepage => <JobList {...page} />). It's rendered as a component of its own from when the page is first shown, and again when what it's given changes. List the job's measurements at that size, grey out the other kind, and callpickwith the one tapped. BOLDR does on a tablet, with its phone keypad's list. Without it the page is the editor's own grid, made frommeasurements. Either way the page is there only whenlinkshasmeasurementsorpickMeasurement, and a desktop shows it only withoutpickMeasurement. subscribeanswers bylinkKey(target), and again whenever that changes. Sayexists: falseonly once it's known to be gone, never while loading.textis the value written the host's way;thumbUrla picture to show.- Pass one
linksobject for as long as the drawing is open (memoize it).
Laser readings. laserReading(inches, capture) takes the host's own record
of a reading (anything JSON: its device, its number). It's kept on the
dimension the reading sets and handed back with saveMeasurement, or with
update for a linked one, so a measurement saved from a reading records its
device. A value typed over it isn't the reading's.
For tests and prototypes, memoryProvider() is an in-memory provider with
remote() and remoteMeasurement() to change things as another device would.
Measuring devices
import { setLaserConnected, laserReading } from '@reekon-tools/boldr-cad';
setLaserConnected(true); // shows the reading tray, enables the keypad's Laser key
laserReading(inches, capture); // into an open keypad, onto the selected wall, or into the traylaserReading returns false when no drawing is open to take the value, so
the host can handle it instead.
./core
import { sketchToPdf, pdfFileName, sketchToDxf, dxfFileName, fmtLen, parseLen, projectFromFile, migrateProject } from '@reekon-tools/boldr-cad/core';The model types (Project, Page, Sketch, Line, Constraint, ...),
migrateProject for older saves, projectToFile / projectFromFile for
project files, the unit helpers (fmtLen, parseLen, fmtArea) and the
PDF and DXF exporters, which return the file's contents as a string.
Geometry is stored in inches. Units are display only, and switching them never changes a drawing.
Development
npm ci
npm run dev # the editor in a browser at http://localhost:5173 (react-native-web, saves locally)
npm test # vitest: units, solver, rooms, trim, sync
npm run e2e # playwright flows against the dev server (npx playwright install first)
npm run typecheck # the browser build
npm run typecheck:native # the device build (.native files)
npm run build:lib # dist/ for web and Node consumers
npm run build:single # one self-contained HTML file for testers, in dist-single/npm run build is the browser harness and writes to dist/ too, replacing
the library output. npm run build:lib always starts from an empty dist/.
To work on the package inside an app without publishing, depend on the
checkout with link: (yarn) or file: (npm). Metro then needs the checkout
as a watch folder, the package name mapped to it, and the checkout's own
node_modules blocked so React and React Native aren't bundled twice. The
BOLDR Pro app derives all three from its link: dependencies in
metro.config.js.
Publishing
npm publish --access publicprepublishOnly runs the native typecheck, the tests and build:lib first,
so the tarball always carries a fresh dist/ next to src/.
License
MIT
