isaac1804.com · analysis V2 · four bugs fixed 101,682 B 58,920 triangles

QuickCAM Pro: the STL was there the whole time — the renderer was drawing its insides

A single-file browser clone of Denford's QuickCAM Pro, built against the F1 in Schools design guide. The hard part was never the UI: it was that every user action looked like nothing happened, because the mesh was being back-face culled with the sign flipped. This page is the measured diagnosis — the pixel counts, the frame timings, and the four separate bugs that had to fall together before a real 2.9 MB chassis STL would appear inside the block.

triangles parsed
58,920
model pixels drawn
32,301
visible faces
40,368 / 58,920
frame time
20–28 ms
toolpath points
11,720
G-code lines
11,754
The one constraint that shaped everything

One HTML file, no dependencies, works offline from a USB stick — so no Three.js, no WebGL pipeline, no build step. Every pixel is drawn by hand into a 2D canvas with a JavaScript transform-and-project loop. That is also why four geometry bugs could hide behind each other: there was no renderer to hand the problem to.

"i think when i orientate the model, the model disappears and it would also be better if there is a plane that shows where the cut depth is set, also the simulation isnt really doing the work, i also don't think thats all the cnc tools for the denford to cut these blocks" Isaac — the message that started the rewrite

What "doesn't work" actually was

Four independent faults, each capable of producing a blank or broken viewport on its own. Fixing any three of them still leaves a broken app — which is exactly why the first three rounds of "fixes" looked like nothing had changed.

1

Back-face culling was inverted

The face normal test kept faces pointing away from the camera and discarded the ones facing it. A closed shell therefore drew only its inner cavity: you saw the inside of the back wall, through the front. Visible face count was plausible, pixel count was near zero, and rotating made it worse instead of better.

camDir must point toward the camera and kept faces must satisfy dot(n, camDir) > 0. One comparison, wrong way round.

2

Rotation order rotated the part into the block

Angles were applied X → Y → Z. Since the auto-orientation sets Z = −90° to lay the chassis along the block, any later X tilt was first applied in model space and then swung 90° sideways — the car tipped onto its nose and partly out of frame. Setting rotX = 45° dropped the drawn area from 30,742 px to 14,550 px; it read as "the model disappears when I orientate it".

Now every axis rotates about the machine axes, Z first (modelMat()), so Z aligns the part and X/Y tilt it in the bed's frame: 45° tilt is now 30,742 px — unchanged, as it should be.

3

The toolpath engine produced zero points

The heightmap rasteriser walked the raw mesh coordinates while the sampling grid was in billet space. With scale 62.5% and a −90° rotation, the two never overlapped: the grid stayed at −∞, no scanline produced a cutting move, and state.toolpath.length was 0 after every "Calculate". The simulation had nothing to play and the G-code had no toolpath block — which is what "the simulation isn't really doing the work" felt like from the outside.

The rasteriser now transforms each triangle through the same modelMat() the viewport uses, so what you see is what gets cut: 11,720 points, Z from 35.67 mm (the guide's derived cut depth) to 75.00 mm safe height.

4

Painter's-algorithm cost, not painter's-algorithm correctness

Frames took 237 ms with a drag stuttering at 4 fps: 530,000 trig calls per frame because every vertex recomputed its own camera rotation. Hoisting the matrix and camera constants out of the loop took it to 20–28 ms. An attempt to batch faces into 16 Path2D shade buckets made it dramatically worse — building 177k subpaths took 6,525 ms, so that approach was measured and thrown away rather than shipped.

The fix path

STL file (binary, 2,946,084 B) │ ├─ parseSTL() chunked 1,000 tri/slice, yields to the event loop │ → 58,920 triangles, progress toasts every 5,000 │ ├─ autoFitMesh() bbox 78.3 × 205 × 46.7 mm → block 223 × 50 × 65 mm │ axis-swap so the long axis lands on X (Z = −90°) │ uniform scale, capped at 1:1 → 62.5%, centred in the block │ ├─ modelMat() rotate about MACHINE axes: Z → Y → X, then scale, then block offset │ (one matrix, shared by the viewport AND the toolpath — this is the fix │ for bug 3: one transform, two consumers) │ ├─ drawMesh() per triangle: normal → cull if dot(n, camDir) ≤ 0 → project → fill │ colours quantised to 16 shade bands, flat fill, no stroke │ ├─ calculateToolpath() rasterise the SAME transformed triangles into a 110×110 heightmap │ then: roughing layers → plan stepover → surface pass │ └─ generateGCodeText() Denford VR5: N-numbers, M06/T01, S8000 M03, G04 P2000, G00 rapid / G01 feed / slow plunge, M30
Imported chassis inside the billet with the cut-depth plane
Real import, no demo mode: the 58,920-triangle chassis at 62.5% inside the 223 × 50 × 65 mm balsa block, with the cut-depth plane at Z = 32.5 mm. Everything in this shot is drawn by the hand-written canvas pipeline.

Bugs by the numbers

symptommeasured beforeafterwhy it mattered
model invisiblefaces drawn = inner shell32,301 px paintedthe headline complaint
disappears on orientate14,550 px @ rotX 45°30,742 pxevery orientation looked like a crash
simulation does nothing0 toolpath points11,720 pointsno toolpath = no motion = no G-code
drag is unusable237 ms / frame20–28 ms4 fps made the app feel broken
Path2D batching "optimisation"6,525 ms to buildremovedmeasured, rejected, documented
G-code ignores move types11,000+ × G01549 G00 / 230 plunge / 10,941 G01a machine would crash on that

Verification, not vibes

Every number above came from driving the real app in headless Chrome over the DevTools protocol and importing the file through the actual input[type=file] element — not by calling the loader function directly, which is how the earlier "fixed!" claims went wrong.

How the render was proven

  • canvas pixels read back with getImageData
  • warm-pixel test isolates wood-shaded geometry from the grid
  • 72 × 30 ASCII density map — a solid blob, not speckle
  • silhouette fill 74% of bbox (speckle would be ~8%)

How the geometry was proven

  • signed volume of the mesh = +59,328 mm³ ⇒ outward winding
  • so the cull sign is right, not just "less wrong"
  • bbox re-measured after transform: model inside 0–223 × 0–50 × 0–65
  • Z range of the toolpath vs the guide's 35.675 mm derivation
Generated toolpath over the chassis
11,720-point toolpath from two selected plans (Roughing X-Axis + Raster Pass). The selection is real: each plan contributes its own stepover, angle and stepdown, so changing the cards changes the path.

What the app actually does now

24-step F1 Car Wizard

Covers the guide end to end — opening model → orientate → cut depth → billet → scale → boundary → tools → plans → parameters → toolpath → simulation → CNC file → side B → axle holes → top machining → lithophane image import → datum → final review → export. Step 25 renders blank; the wizard stops where it should.

12 selectable machining plans

Roughing X/Y/Z-Axis, X & Y Parallel, Raster, Radial, Spiral, Contour Follow, and Fine X/Y Parallel + Fine Raster. Cards are toggles, not decoration: the chosen plans drive the generated toolpath and get their own section header in the G-code.

16-tool Denford library

Ball noses from 1/4" down to 2 mm, end mills, V-bits, drills, engravers, face mill — with the Manufacturing Centre tool (1/4" ball nose) and the axle-hole tool flagged from the guide rather than invented.

Denford VR5 G-code

N-line numbering, G21/G17/G90/G40/G49/G80/G54 header, M06 T01, S8000 M03, G04 P2000 dwell, real rapids, M30. Section comments name the plan that produced them.

Simulation mid-run
Simulation at 39.5% — the tool follows the generated points and the cut surface drops behind it. This is the part that used to do nothing at all, because the toolpath array was empty.
Honest limitations. This is a 2D-canvas renderer with no z-buffer: faces are culled, not depth sorted, so a deeply concave part can show a rare occlusion error. The heightmap rasteriser is a 110×110 grid (≈2 mm cells on the long axis), so the simulated surface is a good approximation of the cut, not a true swept-volume boolean. Toolpaths are 3-axis only — no undercuts, no 4th axis. It reproduces QuickCAM's workflow and output format, not its kernel.

Status

artifactstateevidence
appworking101,682 B single file, no external requests
sha-256verifiedbe332767905adc607001424d4be3d379ebaac48fbc10227868a92a3a8b319b5b
mirrorbyte-identicalsame hash on source, demo and -mirror/
importverified58,920 tri in 412 ms via the real file input
toolpathverified11,720 points, 2 plan sections
G-codeverified11,754 lines, VR5 header, M30 terminator
frame timeverified20–28 ms with 40,368 visible faces
offlineby constructionzero src=/href= to any origin
▶ Open the app Full mirror Build spec