Kulman is a 2D CAD editor that runs entirely in the browser. It's an Angular app drawing onto a single <canvas>, with no backend at all: drawings live in IndexedDB, and files come in and go out as DXF, the format most CAD tools can exchange.
Over the last two months it got about 490 commits. This is a tour of what changed and what I learned. Some of it is new features, but a good part is the unglamorous work of making a canvas app survive real drawings and real browsers.
Splines you can actually edit
Splines used to be something you could draw and look at. Now you can trim them, trim other things against them, extend lines to them, and hatch inside them, even when a spline crosses itself.
The interesting decision was how to cut one. The obvious approach is to sample the curve and fit a new spline to each remaining piece, but then the trimmed piece is only close to where the original was. Instead, a cut is knot insertion: the piece is the same B-spline restated over less of its parameter range, exact to floating-point rounding, and it only costs a few array splices however long the spline is.
Finding where things meet works the way it does elsewhere in the app: intersections are found on the curve's chords, then pulled onto the true curve with Newton's method. Chords alone miss one case: a curve that dips through a shape and comes back out between two chord ends. Those get followed on the true curve too.
Performance was the other half. One spline with 5,000 control points used to cost about 150 ms per mouse move to pick and 180 ms per frame to draw, partly because evaluation scanned for the knot span instead of bisecting. The chords now sit in a tree of boxes in the order they run, so a pick or an intersection costs what's near it rather than the whole curve.
Hatching that finds its own boundary
Hatch asks for a point and fills whatever region encloses it. Four separate lines that meet at their ends enclose a region just as a closed polyline does, and anything closed inside becomes an island.
Instead of computing every face of the drawing, the tracer shoots a ray to the first wall and then hugs it, always taking the first turn clockwise, until it's back where it started. Only geometry the walk actually touches is looked at, so a simple region traces in under a millisecond whether the drawing has 1,000 entities or 100,000. This round it learned to find the wall past whatever stands in the way, to get past fillets that touch a line without ending on it, and to work inside self-crossing splines. Hatches now also export to DXF.
Drawing 125,000 entities without melting
Zoomed all the way out on a large drawing, almost every entity is a few pixels across, yet each one was still drawn in full: text laid out letter by letter, dimensions given arrowheads and labels, splines traced point by point.
Three changes fixed most of it:
-
Sketches. Every renderer gained a
renderSketch: what an entity looks like from afar, without the detail that costs. Text becomes a bar over each row of lettering, a dimension its extension lines and a block where the label goes, a hatch its area filled flat, a whole ellipse an octagon. Lines, arcs, circles, polylines and splines are already about as cheap as any sketch of them would be, so they're drawn as they are. -
Rectangles below 5 px. Anything whose bounds are under 5 px on screen is drawn with a single
fillRectin its colour. On a 125,000-entity drawing seen whole, the full path took about 670 ms a frame. On their own, 125,000 tinyfillRects take about 37 ms. - Cached bounds. The render loop computed every entity's bounds every frame just to cull what's off screen, and text had to be measured on a canvas to do it. Bounds are now cached per entity and dropped whenever the drawing changes. On 100,000 entities that took bounds from about 70 ms a frame to 2–4 ms, and a medium-zoom frame from 84 ms to 20 ms.
Selecting everything also stopped being expensive: past 100 selected entities, grips aren't drawn or hit-tested at all, just the selection colour.
More to draw and annotate with
- Arcs can be drawn the usual ways: start/centre/end, by angle, by chord length, by radius, by tangent direction, or centre first. When the numbers can't make an arc, the terminal says why.
- Regular polygons, from the centre or from an edge, inscribed or circumscribed.
- Polar arrays with a fill angle, next to the grid array.
- Dimension styles, alongside text styles and leader styles, each with a live preview and per-dimension unit formats. They read from and write back to DXF.
- A Measure tool that reads what surrounds the cursor as you move: the distance to the nearest geometry in each direction, the length or radius of what it hits, and the angle where two lines meet. Click inside a closed area to get its area and perimeter.
-
Points. DXF
POINTentities used to be dropped on import. They're now a real entity you can place, move, snap to and export, with a Divide command on the way that drops points at equal distances along a line, arc, circle, ellipse or polyline. - Copy and paste between tabs, and the app is now installable as a PWA.
33 languages, and prompts as data
The whole UI is now translated into 33 languages with Transloco, including the command line, which turned out to be the hard part.
Commands used to return their prompts as English strings with markers spliced in for the terminal to regex back out. Now a prompt is data: a translation key plus named values.
// before: the terminal had to parse this back apart
getPrompt(): string {
return `Pick center or enter X,Y: {cursor}`;
}
// after: a key and the values the terminal needs to show what's being typed
getPrompt(): PromptMessage {
return { key: 'command.circle.pickCenter.orXY', values: { inputPhase: 'x', inputX: '12' } };
}
Command names are translated too, so you can type Kreis in German or Коло in Ukrainian. Matching ignores diacritics. For scripts that need an IME (Hindi, Bengali, Punjabi, Thai, Chinese, Japanese, Korean), typing straight into a raw keydown command line isn't reliable, so those languages show "Circle 円" and accept the English word as input.
Every command also now writes what it did to an expandable terminal history, with coordinates highlighted.
What error reporting dug up
I added Sentry with tight defaults: errors only, no session replay, no user info, cookies, headers or console output, each distinct error at most once per session, and source maps uploaded from the production build. Within days it found bugs I would never have hit myself.
A blank page for people who block site data. When a browser is set to block storage for a site, indexedDB.open fails with "The user denied permission to access the database." Two APP_INITIALIZERs loaded stored fonts and hatch patterns from IndexedDB, and a rejected initializer aborts Angular's bootstrap. Those users got nothing at all. Now they get the app, working in memory, with every storage failure caught and reported instead of fatal.
A RangeError with no recursion in sight. "Maximum call stack size exceeded" came from this line in the snap index:
if (pts) result.push(...pts);
Spreading an array into a call passes every element as a separate argument, and arguments live on the call stack. In Node, the largest array I could spread into one push() was 110,153 elements; doubling the stack size raised the limit to 255,561. A drawing where hundreds of lines cross at one point puts that many intersections under a single coordinate, and moving the mouse near it crashed. A plain loop fixes it, and it turned out to be faster anyway.
What people actually open. The DXF parser now reports which entity types it couldn't read. By far the most common is block references (INSERT), followed by 3DFACE, SOLID and POINT. That last one is why points exist now, and it set the order of what's next.
What's next
Block references are first: every block exploded into its entities on import, with its own move, scale, rotation and mirror applied. Then SOLID and 3DFACE, which map neatly onto hatches and lines.
If you want to see where it's at, try it at kulmanlab.com. There's no account to create, since there's no server to keep one on; it works best with a mouse and keyboard. If you open a DXF that doesn't come through right, I'd love to hear about it.
Top comments (0)