When no library exists: building a browser-based drawing engine from scratch
How we built a scale-calibrated construction drawing tool on raw HTML5 Canvas because nothing on the market could do what we needed.
There's a decision point on some projects where you look at what's available, realise it won't work, and accept that you're building it yourself. It's not always the right call, more often than not, a library exists that does 90% of what you need and the 10% gap is negotiable. But sometimes the 10% is load-bearing.
On a construction management platform we've been building for APM Software, that moment came early in the estimating module.
What we needed to build
Residential builders estimate the cost of a build by measuring construction plans, counting windows, calculating floor areas, measuring wall runs. Traditionally this is done on printed plans with a scale rule, or in a desktop application that's been the industry standard for years. We needed this to happen in the browser, on uploaded PDFs, with measurements flowing automatically into a cost catalogue.
The requirements were specific:
- Estimators upload a PDF construction plan
- They draw lines, polygons, and place stamps (representing catalogue items) directly on the plan
- Every measurement is in real-world units (metres, square metres), not pixels
- Measurements update the bill of materials live as they're drawn
- Multiple pages feed into one takeoff
The libraries we looked at, Fabric.js, Konva.js, Paper.js, are capable tools for general-purpose drawing in the browser. None of them handled scale-calibrated real-world units. None had any concept of a cost catalogue. And none were built to sit on top of a PDF rendered by PDF.js in a way that would survive redraws without corrupting the drawing layer.
So we built it on the raw HTML5 Canvas API.
Two canvases, not one
The first problem was the PDF itself. PDF.js renders a plan onto a canvas element. If you draw on top of that same canvas, every time PDF.js re-renders, on page navigation, on zoom, on window resize, your drawings disappear.
The solution is two overlapping canvas elements. PDF.js owns one; the application draws on the other. They're stacked with CSS, share the same dimensions, and the drawing canvas sits on top to capture pointer events. When the PDF re-renders, the drawing layer is unaffected.
Getting pointer events, z-index, cursor behaviour, and resize handling to work correctly across two canvases that appear to be one surface took significant iteration, but it's a clean architecture once it's working.
Scale calibration
A PDF has no inherent concept of real-world scale. A pixel on screen is just a pixel. To convert pixel distances into metres, the user draws a reference line over a dimension they know, say, a 6-metre wall, and enters the real-world length. The system calculates a pixels-per-metre ratio from that.
The calibration has to account for:
- PDF resolution: different PDFs render at different DPI
- Zoom level: the same line is more pixels at 150% zoom than at 100%
- HiDPI screens: a Retina display has more physical pixels per CSS pixel
The formula is straightforward once you know what to account for (px_to_mm = 25.4 / 72 for standard PDF DPI), but getting it right across all combinations required testing against physical plans with known dimensions and checking the output matched.
Hit-testing without a scene graph
Drawing libraries manage a scene graph, a list of objects that know their own bounds. When you click, the library checks which object was hit. Without a library, you implement that yourself.
The drawing engine uses three geometric algorithms depending on what's being tested:
- Polygons: ray-casting. A ray from the click point; if it crosses the polygon boundary an odd number of times, the click is inside.
- Lines: point-to-line-segment projection. The closest point on the line to the click is calculated; if it's within 3px, it's a hit.
- Stamps: radial distance. If the click is within 14px of the stamp centre, it's selected.
None of this is complicated mathematics, but it has to be correct and fast, the hit-test runs on every pointer event.
Connecting to the cost catalogue
Stamps are SVG icons representing items from the cost catalogue. Placing a stamp on the plan should atomically increment the item's quantity in the takeoff and persist that change to the backend.
This sounds simple but creates a concurrency problem: the canvas is running event handlers (pointer events, renders), React is managing state, and the backend is receiving API calls. If a user places stamps quickly, the naive approach produces race conditions, the count gets out of sync.
The solution is careful separation of concerns: canvas events update local state synchronously, and persistence to the backend is debounced and reconciled. The takeoff always reflects what's on the canvas; the backend catches up.
The result
The drawing engine runs entirely in the browser. No desktop application, no plugin, no file format to export and import. An estimator uploads a plan, calibrates scale once, and starts measuring. The BOM updates live. When they're done, the cost model is already populated, nothing to transfer, nothing to retype.
It took longer to build than dropping in a library would have. But a library that did 80% of this wouldn't have been usable, the 20% gap was the whole product.
Code Workshop builds custom web and mobile applications for Australian businesses. If you're facing a requirement that off-the-shelf tools can't handle, we're happy to talk.