Grainwise Cutlist
scan your timber - pick the grain
Desktop
In the Shop
Required Panels - Solid timber
A finished panel size, cut from a board you choose. If the panel is wider than that board, it's split into board-width strips (each cut to the panel's full length) meant to be edge-glued up to size — "Even split" divides the panel width evenly across the strips needed; "Full board width" uses full-width strips with only the last one as leftover. Strips nest normally and may share the board with other cuts/panels of the same stock.
IncludePanelQtyLengthWidthRoughing
Length
Roughing
Width
ThicknessBoardTrimModel
grain
Type
Required Laminated Parts - Solid Timber
A thick part (like a table leg) built by face-gluing several laminations cut from ordinary boards. Enter the FINISHED size — the thickness is the glue-up target. The part is split into ceil(target ÷ board thickness) laminations, each cut to the full length and width; "Even split" planes every lamination to target ÷ N so the glue-up lands exactly on size, "Full board thickness" keeps laminations at board thickness and you plane the overshoot off the glued blank. Laminations nest as ordinary pieces; grain match keeps each part's laminations end-to-end on the board for continuous grain around the glue-up.
IncludeLaminatedQtyLengthWidthRoughing
Length
Roughing
Width
Target
thickness
BoardTrimGlue-upGrain
match
Type
Required Parts - Solid Timber
IncludePartQtyLengthWidthRoughing
Length
Roughing
Width
ThicknessGrain match groupPreferred boardGrain
Direction
Visually
Critical
Type
Cutting Details — Solid Timber
Applies to every solid timber cut (Panels and Parts above). More detail will be added here over time.
Saw kerf
End trim, each end
Assumed waste at the yard %
Roughing allowance: extra stock left on each end/edge of a Final Dimension so the piece can be trued up before cutting to size. Roughing Dim. = Final Dim. + allowance, and it's what actually gets nested/packed.
End waste, each end
Edge waste, each edge
Required Sheet Goods
Plywood, MDF, melamine and other manufactured board — nested as true 2D rectangles on sheets. Separate from solid timber: sheet material is optimized for least wasted area, not fewest sheets.
IncludePartQtyLengthWidthThicknessGrain
Direction
Type
Cutting Details — Sheet Goods
Applies to every sheet good cut above. More detail will be added here over time.
Saw kerf
End trim, each end
Side trim, each side
Stock assigned to this Project
SourceCodeLabel
(optional)
LengthWidthThicknessSpecies
(optional)
CharacterGradeCut typeGrainQuantity on handValue ($/board)
(optional)
Supplier
Your Stock On-Hand (Your Library)
Generic Catalog
The sizes the app assumes a timber yard sells. Yours, across every project. Edit a line and the sizes rebuild; every Generic Board is priced from its own volume.
mm, assumed — a typical sawn length. The yard's real lengths arrive as Real Boards.
$ per m³ — every Generic Board is priced from its own volume.
mm, assumed for any cut, rip or cross. A project's own Saw kerf (Your Stock) overrides it.
Available sheet stock
Label
(optional)
WidthLengthThicknessQuantity on handValue ($/sheet)
(optional)
Boards
Cutting Layout Diagram
Re-Nest
Sheet Goods
Sheet Goods Cutting Diagram
Project Scans
At the yard: put the boards you pull in your cart, scan the ones for the Visually Critical Parts, Run — keep these, put back those — then accept the cart. A scan is the board's grain captured on a phone: photos along the board with a tape measure laid on it, stitched, healed and calibrated into one Final Scan.
Your project
Loose scans — attach each to its board

Scanning

Capture Lab

1 · Your tapeprinted side up, hooked over the left end · 30 mm bladeChange

Lay the tape beside the board, a finger’s width of bench between, the hook roughly at the board’s start, the tape running past both ends. On the board is fine too: then hook it over the start.

Nothing to measure and nothing to calibrate. The lines are sized so that when the board's edges sit on them, you are holding the phone about the chosen height above the board — change the height if it feels wrong. The tape in frame checks your height and scale as you go.

Once per tape — remembered for every board.

2 · Board

3 · Shoot

Three or more shots along the board, each overlapping the last by half. Board edges on the lines.

Set the phone camera's flash to ON (not auto) once. Each shot opens the camera, fires the flash, and comes straight back here.

Hold the phone upright, with the board running up-and-down the screen. Hold it where it's comfortable and press Calibrate at this height — the lines snap to your board at that height (using the tape as the ruler). Then keep the board's edges on the lines as you work along the board, overlapping generously — extra shots are free.

No photos yet.

Kept photos are saved on this device. Send them to the PC over the local network — they land in the app's test-photos folder, where Claude can analyse them directly. (Cloud sync is a later step; this is the workshop-network version.)

Ultimate capture checklist

Fix the light before fixing the pixels. Two of the three things that spoil a scan - colour changing along the board and flash glare - are made at capture, and no software fully undoes them. This list is the design in Developer › App Details › Scanning, section 7, as steps.
  1. Light. Board flat under even, soft light - an open door on an overcast day, or away from any single lamp. No flash unless it is polarised (below).
  2. Lock the camera. Samsung: More › Pro. ISO, shutter and WB from Auto to manual - start ISO 100, 1/100 (1/60 under LEDs), WB 5500 K daylight or about 4000 K under LEDs; aim at the grey card and nudge WB until it looks neutral and shutter until it sits mid-brightness. Then never touch a control for the whole board. Once, in Settings: RAW copies on, Scene optimiser off.
  3. Tape colour calibration not calibrated - one photo of the tape with the grey and white cards, per light (card below).
  4. Flat-field none - one photo of a white sheet filling the frame, at scanning height (card below).
  5. Polarised flash (indoors, evening): film square over the LED, clip-on CPL over the lens, rotated until a glossy reflection goes black. Expect one to two stops less light.
  6. Overlap. Square-on at one height; at least half of each frame shared with the next - three shots per 1500 mm. The lamp's image is then never the only view of a spot.
  7. Figure pass (optional): one extra shot with the torch held low from the side, added with the button under the photos. It rides with the scan for relighting later; it is never stitched.

Tape colour calibration not calibrated

One photo, once per tape and light: the tape flat across the frame, your matte grey card and white card beside it, even light, no flash, camera locked in Pro mode. Then tap the grey card, then the white card.

The yellow tape is in every frame, so once its colour is known relative to a neutral grey, every frame can be white-balanced and exposed from the blade alone - no card in the shot. Two boards scanned a month apart then come out the same colour. A yellow blade reflects little blue, so the blue gain is the least certain; the whole length of blade in a frame is averaged to steady it. Calibrate once in daylight and once under the shop lights; the nearer one is used for each frame.

Flat-field calibration none

One photo, once per phone (or once per session under the session's light): an even matte white sheet filling the whole frame, at the height you scan from, no flash.

A lens is darker toward its corners and a lamp falls off across a board. Photographing a plain white surface measures both at once; every scan frame is then divided by that map, so a real dark end stays dark and a knot stays a knot instead of being flattened along with the light. The map is a coarse grid - anything sharper than it is grain, and grain is left alone.

4 · Process

The numbers were read as you shot. One tap runs everything on this phone: it asks about a reading only if one was doubted, then builds the finished board image — no PC needed.

1 · The board

2 · Photos

Add the photos from the phone, in order along the board.

No photos yet.

Notes & known limits

  • Affine, not projective. Rotation, scale and shear are corrected; keystoning is not. A four-corner homography is the planned upgrade.
  • Spread the two dots as far apart as the glare allows. A 2 px error over 1000 mm of separation is 0.07% of scale; over 100 mm it is 0.7%. On the end-of-board photo, use the board's end as one of the anchors — it costs nothing and gives a long baseline.
  • Nothing is auto-detected. Every number comes from something you pointed at.
  • Photos, taps and the tape datum are saved in this browser.

Check the reading

Pinch to zoom, drag to pan, double-tap to reset. Tap a green ✓ beside any number that reads correctly to accept in one tap — or tap a marked line to answer about that mark instead.

3 · Tag this photo

Step

Result

Each photo contributes only its middle, cross-faded and brightness-matched at the seams. The ruler comes from the board's own dimensions.
Save scan to

Project Summary

🔒 Starter and Pro — on Free the summary is on screen only

Everything this project comes to, in two parts: the desk’s design and the shop’s boards. Tick the sections you want; each opens and closes on screen, and every ticked one prints open.

Shopping List

the desk’s nest · your boards first, then what is still to buy 🔒 Starter and Pro — on Free the list is on screen only

What is still to buy after the boards you have assigned. The nest uses your Library boards and the boards in the shop first (From your Library, In the shop); every row under To buy is a Generic Board — timber you do not yet have — and the total counts those only. Lineal metres lead; where the count rests on an assumed length it is marked *. Needs: a grain run must come off one board long enough; a Visually Critical Part needs a board that fits it.

Enter stock and cuts — the list follows the desk’s nest.

Cutlist

the shop’s nest · your Real Boards 🔒 Starter and Pro — on Free the list is on screen only

One block per Real Board, in Layout Diagram order. Hook the tape on the right-hand end and mark each figure; cut with the kerf on the left of the line. The Part nearest the datum is exact; each next mark = last mark + Part + kerf. A Part turned to follow the grain comes off as a blank; its card gives A, B, W and L for the sliding table.

No cuts yet — the Cutlist follows the shop’s nest on your Real Boards.
How to use this tool

Available stock (Your Stock tab) — list the boards you have on hand (width, length, thickness, quantity). Add a label if you want to track a specific sheet by name. Set your saw kerf and end trim once here; they apply to every cut.

Required Parts (Required Parts tab) — add parts by hand, or click the file field to import a .xlsx or .csv with columns Part, Qty, Length, Width, Thickness. Uncheck "Include" to exclude a part without deleting it. "Grain match group" (e.g. "A,B,C") keeps pieces sharing a label nested end-to-end on the same board for continuous grain. "Preferred board" pins a part to one specific stock row instead of letting the optimizer choose.

Required Face Laminated Parts (Required Parts tab) — for parts too thick for any board, like a table leg 700 × 80 × 80 from 18mm stock. Enter the finished size; the tool splits it into ceil(target ÷ board thickness) laminations to face-glue, cuts each at full length and width, and nests them as ordinary pieces. "Even split" planes every lamination to target ÷ N; "Full board thickness" planes the overshoot off the glued blank instead. Grain match (on by default) keeps each part's laminations end-to-end on the board for continuous grain around the glue-up — see the stack assembled with its real wood in the glue-ups under the 3D Model tab (Grain Decisions or Grain Photos view).

3D model import & part shapes (the 3D Model tab) — import an STL, OBJ or STEP file and every solid becomes a reviewable row. The importer reads the part's REAL 2D shape from the file's own curve geometry (ovals, tapers, arcs — including SolidWorks' spline-encoded ellipses), so an oval table top imports with correct dimensions, shows its cross-section as a to-scale line drawing in the review, and its shaped profile is recorded on the row and travels with the project. Auto-classification: thicker than 45mm suggests FLP, wider than 300mm (or wider than your stock) suggests Panel, otherwise Part — the review dropdown and each table's Type column can always override, and re-typing a row keeps the 3D views in sync. The Imported Model card has five views — Solid Bodies (closest readable form of the file: ovals, tapers), Bounding Boxes (each part's box drawn as a thin pink outline, with the model's real 2D profile projected onto it as a dashed black line), Component Choices (just the cut parts: panels as glue-up boards with black join lines, FLPs as lamination stacks, plain timber), Grain Decisions (the same parts wearing their grain-match group colours — click parts on the model, or their chips in the toolbar above it, to select and group; the glue-ups appear below the model), and Grain Photos (the same parts wearing the real scanned wood they'll be cut from) — with an eye list to show/hide Panels, FLPs and Parts. Lines say what they are: a SOLID outline is the box a part is cut from (pink in the Bounding Box View, blue in the cut-parts views), a DASHED BLACK line is the model's real projected shape inside that box, and solid black lines are glue joins or the imported model's own edges. Use the Labels eye toggle beside the family list to show or hide the text labels on the model — handy once a job has a lot of parts.

Nested cutting diagram (Layout Diagram tab) — updates automatically as you edit. "Re-Nest" recomputes the layout from scratch with a new random seed — useful after loading a saved layout from a project file, or if you just want another attempt. "Top off boards" tries to consolidate the current layout onto fewer boards without re-optimizing from scratch.

Right-click what a view shows you (the 3D Model tab) — each 3D view puts one useful fact on the model, and you can change it where you see it. The Solid Bodies and Bounding Boxes views label every body with its name from the CAD file: right-click a name to rename it, and the matching row in your tables is renamed too (renaming the row instead works the same way, in reverse). The Component Choices view labels each piece with the board it is cut from: right-click to choose a different board, or Any. A board shown as “Any → 3600×140” means the tool picked that board for you — for a panel it picks the width that wastes least, and it never locks the piece to that board; choose a board by hand if you want it locked. A panel's board menu also offers Rotate board direction 90°: normally a panel is cut as boards running its full length, and rotating cuts it as boards running its full width instead — a 350 × 2500 top goes from 2 boards of 2500 × 175 to 14 of 350 × 178.6. That is a real change to the cut list, not a view setting, and it is not the same as the grain flip in the panels table (which only changes which way the grain reads). Because the strips change, any grain-match groups on that panel are cleared and you are told so. The Labels eye toggle beside the family list hides every label at once, for when a job has too many parts to read. Section cuts the model with a plane so you can see inside it: pick the axis (X, Y or Z), drag the slider to sweep the cut through the model, and use ⇄ to cut from the other side. Cut faces are filled in, in the part's own colour darkened, so a sectioned door reads as solid timber rather than a hollow shell. On a cabinet, cutting the shallowest axis is usually the one that opens it up — the doors fall away and the braces, dividers and trims inside become visible, and they get their labels as they appear.

Board scans (Project Scans tab) — a scan is a photograph of one real board, calibrated so the app knows how many pixels equal a millimetre. That is what puts the board’s real grain behind the layout diagram at the right size and lets parts be positioned on the actual timber. The tab is the yard flow: your cart — put in the boards you pull (species and thickness once, each board’s width and length), Add scan or Scan this board on the ones for the Visually Critical Parts (the capture lab opens with the board’s sizes: photos along the board with a tape measure on it, stitched, healed and cropped into one calibrated Final Scan; on a phone, since it needs the camera), then Run — evaluate your cart: the nest Real Boards first, the Visually Critical Parts on the scanned boards by their grain, and every board reads Keep or Put back; Accept cart makes the kept boards the project’s and puts the others back. The first pill counts what the job still needs by the metre as you add. Every scan travels by your account (the cloud), so there is no file to load. To put a scan on a board, right-click that board in the Layout Diagram and choose Add scan… (then Change scan… or Remove scan). The link runs both ways — the board remembers the scan and the scan remembers the board’s stock row — which is what makes the attachment survive a Re-Run. The camera tick on a stock row does the other job: it starts a capture for that specific board, splitting one off into its own row first if the row’s quantity is more than one, since a scan describes one physical plank rather than a quantity — and it hands the row’s dimensions to the capture lab so nothing is typed twice, then attaches the finished scan back to that board.

Grain-match colours (Layout Diagram tab) — every piece is labelled with its Part’s name (“Door 1 1/2” for the first of a Panel’s two strips) with its length × width under it, and the hover gives the full name, the dimensions and the group letter; a piece that belongs to a grain-match group is painted in that group’s own colour — the SAME colour the group wears on its chip and in the Grain Decisions view, so you can look from the 3D model to the boards and back and follow one group by eye. Pieces with no group keep the ordinary per-part colours. Laminations bundled automatically for a Face Laminated Part stay the plain grain-match green, since those bundles are not coloured in the model either.

Saving your work — the page autosaves stock/cuts/settings to this browser's local database as you type, so a refresh won't lose your edits. That autosave is tied to this browser profile, not to a specific project. To keep a project you can reopen later (or share with someone else), give it a name in File ▸ Open a saved project and press Save in the header. Open… in the same menu lists everything you have saved. Loading restores the exact calculated board layout as it was saved (no recompute) — use Re-Run if you want a fresh calculation instead. Import Parts (in Required Parts) / Import Stock (in Available Stock) load an Excel or CSV file into the parts or stock table respectively (replacing what's currently there). Export HTML (Developer ▸ App Details ▸ Code Structure) saves a copy of this tool itself (the whole page) to a file.

Works fully offline, including opening the file directly from a local or network drive — no internet connection or install required.

Trade mark, domain and renewal records for Grainwise Cutlist. The dates below are real deadlines — the February 2027 one expires and cannot be recovered.

Product name

Grainwise Cutlist — chosen 2026-08-25 over the bare word “Grainwise”, because Grainwise Pty Ltd is an established Australian grain-marketing brokerage (trading since 2015) holding grainwise.com.au. The compound removes the collision, is more distinctive, and left every domain free. Shorten to “Grainwise” in conversation; use the full name on anything legal.

Do not register grainwise.au even though it is available — taking the bare word while another Australian company trades under it reads as encroachment and undoes the point of choosing the compound.

Australian trade mark — FILED

IP right number2689781 — this is the base application number, use it for everything
Batch / payment refAMCZ-2616160956 (receipt only, not the trade mark number)
Filed25 August 2026, 7:46 AM
MarkGrainwise Cutlist — word or phrase (protected in any font, any colour)
Classes9 (software) and 42 (software as a service)
OwnerLloyd Green, sole trader, ABN 44526802202
Fee paidAUD $500.00
Recordsearch.ipaustralia.gov.au › 2689781
Portalportal.ipaustralia.gov.au

Goods — class 9: software; computer-aided design [CAD] software; computer software for computer-aided design [CAD]; downloadable software.
Class 42: software as a service; cloud computing; cloud computing services; electronic data storage; electronic data storage for others; providing online non-downloadable software.

Domain — OWNED

Domaingrainwisecutlist.com
RegistrarPorkbun — order 11462066
PaidUSD $55.40 for 5 years (US$11.08/yr, at-cost pricing)
Expires~25 August 2031 — confirm auto-renew is ON
Points toNothing yet (parked). The app lives at cornerstonefurniture.com.au/grainwise/

Deliberately NOT bought: .com.au (Australian presence already covered by cornerstonefurniture.com.au, and customers are expected to be mostly American), .net (registrar upsell), grainwise.au. A registered trade mark — not defensive domain-buying — is what lets you recover a squatted domain.

Follow-ups — dates that matter

~25 Feb 2027US / international priority window CLOSES. File a Madrid Protocol application through IP Australia (the Office of Origin) by then and the US treats it as lodged 25 Aug 2026. Miss it and you can still file — you only lose the backdating. Est. CHF 1,573 (~AUD $3,000) for a US designation across 2 classes.
Madrid System — IP Australia · WIPO fee calculator
5 Jan 2027Reminder already scheduled in Claude Code to make that decision, 7 weeks ahead of the deadline.
A few months from Aug 2026IP Australia examination report on 2689781. Watch for a citation of GRAINFOX (2146612, classes 9/35/42) — its class 9 is broad: “Computer software including…”. Also GRAINTEC (9, 42) and GRAIN CLOUD (9).
~25 Aug 2031Domain renewal falls due (earlier if auto-renew lapses).
25 Aug 2036Trade mark renewal — registration runs 10 years from the filing date, then renewable.

If the examiner cites GRAINFOX

A citation is a letter to answer, not a rejection. The prepared response is to accept a voluntary limitation narrowing class 9 to software for woodworking and timber cutting optimisation — that distinguishes it from agricultural software and should clear the citation while keeping everything that matters. If only ONE class is objected to, a divisional application lets the clean class register immediately while the other is argued.

Do NOT narrow the Australian application pre-emptively. Madrid lets you limit the specification per designated country, so Australia stays broad and only the US designation is tightened. Goods can never be re-broadened once deleted.

US designation — draft wording

The USPTO rejects bare software; it wants function + field. The following sits inside the Australian base, so it is a valid limitation rather than an addition:

Class 9: Downloadable computer software for optimising material cutting layouts, grain matching and panel layout in woodworking and furniture making.

Class 42: Software as a service (SAAS) featuring software for optimising material cutting layouts, grain matching and panel layout in woodworking; electronic storage of design files and images.

The mark must be identical to the base (“Grainwise Cutlist”, not “Grainwise”) — a mismatch is a defect. Form MM18 (Declaration of Intention to Use) is mandatory for any US designation. Do not tick “collective/certification mark” or “in colour” — colour costs more and narrows protection. A US-licensed attorney is legally required for foreign-domiciled applicants if the USPTO raises an objection. Have this wording checked before filing.

DEV CONSOLE — the second platform
Process Audit, Gates, Opus–Fable Work, User Experience, Cloud and the Specs now live there. It reads the same files this tab does, so the two cannot disagree. Temporary link, while the Developer tab is moved out of the app before go-live.
Open the Dev Console ↗
App-domain governance — what the app does and why, as agreed with the project owner. Read live from this page's own leading comment block (search "PRINCIPLES" near the top of the file), so this can never drift from the actual rules Claude is following.
Loading…
How the 3 nesting priority modes are configured — summary first, then the detail. Hand-written 2026-08-17 (branch maximizing-tails) directly from the live code: isBetter/scalarCost (the scoring tiers), packLanesFirst (Ripping Preference's own algorithm), and the NESTING PRIORITY PRINCIPLES governance comment. If the scoring or mode logic changes, this tab must be updated in the same commit — same rule as the governance comment itself.

Summary

The app has 3 nesting priority modes, chosen from the dropdown in the Layout Diagram header (Balanced / Pure Math / Ripping Preference). All three share the same three non-negotiables, in order: never silently drop a piece, then fewest boards, then cheapest board mix. They differ only in what happens after that:

  • Balanced (default) — since 2026-08-18 (branch grouping-optimisation-1) a two-stage pipeline: stage 1 nests for the longest usable full-width tail, that tail is locked (its board re-enters the next stage as a genuinely shorter board, so nothing can spend it), then stage 2 re-nests everything for grouping — identical parts and part families (a split panel's strips count as one family) onto as few boards as possible, ranked above least-waste. A stage-2 result that is worse on warnings, board count, or cost is discarded in favour of stage 1's; a saved board always beats a locked tail.
  • Pure Math — the same machinery with waste promoted: least waste and lane-levelness rank above clustering, which becomes the final tie-break. "Just give me the mathematically tightest pack."
  • Ripping Preference — not a reordering of the scoring at all: a different construction algorithm (packLanesFirst) that builds every complete same-width lane up front and keeps same-width lanes together, so a rip-fence setting is reused end-to-end. It runs alone — nothing else competes with its result on score.

Detail — how a result is produced (all modes)

For each thickness group, the engine runs a basin-hopping search over thousands of randomized piece orderings. Each ordering is packed by greedy heuristics (packBestFit, plus a packBySubsetSum lane-filling alternative) and scored. isBetter() is the strict, lexicographic arbiter of which result wins; scalarCost() is only a smoothed copy of the same priorities used to steer the search's explore/accept decisions. The best 5 distinct raw candidates (plus the raw winner, always) each get the full compactBins() consolidation pass at the end, and whichever compacts best is kept — a candidate that looks marginally worse raw can compact into something better. In Ripping Preference mode this whole competition is bypassed: packLanesFirst produces the one result.

Detail — Balanced (default)

Two-stage pipeline (owner-approved 2026-08-18, the owner's own staging model): the full search below runs twice per thickness group. Stage 1 — optimise for the tail: tail-first tier order (tiers 1–3, then waste, then tails, then clustering). Lock: the longest full-width tail at or above the tail-waste threshold (exactly one — LOCK_TAIL_COUNT) is locked by capacity reduction — its board enters stage 2 as a genuinely shorter board (virtual stock row, same id/cost), so no stage-2 pass can eat the tail; there is no keep-out rule to police, the tail structurally isn't there. Stage 2 — regroup: a fresh full search over the reduced stock with the tier order below. Stage 2 wins unless it is worse on warnings, board count, or cost; a stage 2 that leaves the locked board unused has fewer boards and wins outright ("minimising board count always wins"). Jobs with no qualifying tail skip the lock but still get both stages.

Tie-break tiers in isBetter (stage 2, and the single-stage modes), highest first. A lower tier is only consulted when every tier above it is tied:

  1. No dropped pieces (warningCount) — a result that fails to place something is never acceptable, whatever its other numbers.
  2. Fewest boards (boardCount).
  3. Cheapest board mix (totalCost via boardUnitCost — a board's Value if set, else its width, so wider/pricier stock costs more).
  4. Same-part clustering (fragmentation — the count, summed over part families, of how many distinct boards each appears on; since 2026-08-18 a split panel's "(1/n)"…"(n/n)" width-strips share their parent panel's family, so scattering them costs fragmentation like any other part — previously their per-strip names made that scatter invisible). Deliberately ranked above waste: one large offcut is worth more than several scraps of the same combined area, and fewer boards per part means fewer saw setups. Enforced by the scoring tier plus TWO active passes during compaction: tryClusterSameName (moves a straggler into free space on a board already holding more of its family) and tryRegroupFamilies (pools the boards a family is scattered across and refills those exact board instances via packBestFit in family-grouped order, kept only if the mode's own arbitration accepts the whole result — this is what makes the grouping deterministic instead of search luck).
  5. Least wasted area (wasted) — but only when the difference is meaningful: more than 1% of the larger candidate's total board area. Below that floor it's treated as a tie so kerf-rounding noise can't override a real levelness difference.
  6. Longest, cleanest full-width tails (owner-approved 2026-08-17, replacing the old level-lanes tier) — less nonTailWaste wins (each board's waste that is not shaped as one clean full-width tail at its end), then the longest single full-width tail (maxTailLen) wins. A zero-waste board contributes nothing to either — no tail, exempt, by arithmetic rather than special case. This strictly generalizes the old unlevelness tier (unlevel lane ends are non-tail waste) while adding what it couldn't see: where the lanes end, not just whether they end together. Tail waste length (tails-round-3): a tail shorter than the user-set threshold (Layout Diagram header, default 400mm) is plain waste — no credit from this tier, and the tie falls through to a restored unlevelness residual so lower priorities decide. Set 0 to make every tail count.

Detail — Pure Math

Same tiers 1–3 (dropped pieces / board count / cost), then reordered per the owner's 2026-08-14 approval (task #122):

  1. Least wasted area (same 1% materiality floor).
  2. Longest, cleanest full-width tails (nonTailWaste, then maxTailLen — same metric pair as Balanced's tier 6, owner-approved 2026-08-17).
  3. Same-part clustering (fragmentation) — demoted to the final tie-break.

Everything else — the search, the compaction pool, the finalize passes — is identical to Balanced; only the tier order inside isBetter/scalarCost changes.

Detail — Ripping Preference

Task #124. Keeping same-width pieces together for rip-fence reuse is structural, not a score to be outvoted, so this mode replaces the construction entirely (packLanesFirst) and skips the packBestFit competition:

  1. Lanes first — for each width (widest first), every lane is built in full before anything is placed, using the same best-fit-decreasing subset-sum fill as packBySubsetSum. Pinned pieces get their own lanes sized only against boards their pin allows.
  2. Every lane gets a score = lane length used ÷ best board capacity for that width. At or above 70% (HIGH_SCORE_THRESHOLD, the owner's number) a lane is "high-score"; below it, "low-score".
  3. High-score lanes place first, side by side: a board already carrying a head lane of the same width is preferred, then best width-leftover on any board, then a closed-lane zone, then a new board (cheapest first).
  4. Low-score lanes place end-to-end: they first try to land as a zone on a board already hosting other low-score content (same-width host preferred), so the short/sparse stuff shares boards instead of nibbling the ends of good ones.
  5. "Perfect boards" are protected — a board with a head lane ≥70% of its length and ≥75% of its width used is excluded from every low-score candidate search, so an already-clean board is never degraded to give a sparse lane a home.
  6. In the shared finalize pass, Ripping Preference also skips maximizeLaneParallelism (which deliberately splits long single lanes — the opposite of this mode's goal) and reorderSequentialZonesByWidth.

Shared finalize (compactBins) — all modes

The winning result then gets one consolidation pass: relocating units into spare room on other boards (findReceiver/applyMove), same-name clustering, sparse-lane filling, orphan-lane dissolution, uniform-board flattening, lane swaps for levelness, then per-board cleanup — merging compatible strips, absorbing split same-width zones, aligning sibling zones (gap-bug fix), and sorting each lane's pieces longest-left. These passes never add boards or drop pieces; they only tidy the chosen result.

Note — tails vs. levelness (branch maximizing-tails)

History: until 2026-08-17 the only tail-adjacent scoring signal was unlevelness, which measured how level a board's lanes were relative to its own longest lane — never where they ended. Lanes ending level at 1500/2000mm scored identically to lanes ending level at 1990/2000mm, so scoring could not see that only the first leaves a usable full-width tail — exactly what Test 7 / 7v2 exercise (content fills ~1500 of 2000mm; the leftover ~500mm should be one clean full-width tail, not scattered per-lane stubs). Replaced the same day by the nonTailWaste / maxTailLen tier described above, owner-approved: "The longer the full width tail the better."

Note — arbitration vs. search guidance (task #150, 2026-08-18)

Hard rule, learned from a real regression: the tail waste length may only ever influence isBetter (which candidate WINS), never scalarCost (which candidates the annealing search GENERATES). The first version of the threshold fed both — reshaping the search landscape so it deterministically stopped finding Regression v2's known 30/30 packing and left Real Job 1 with a stranded-zone gap. Fixed by having computeCostAndLevel return two metric pairs: the thresholded nonTailWaste/maxTailLen for isBetter, and raw un-thresholded rawNonTailWaste/rawMaxTailLen for scalarCost (byte-identical to the pre-threshold heuristic). The same investigation also added promoteEligibleHeadZones + reflowFurtherZonesAfterShrink to both finalize loops, closing a gap where a zone stranded behind a completely EMPTY head region could not be moved by any finalize pass. Any future result-construction site must thread BOTH metric pairs.

Balanced is a TWO-STAGE tail-lock pipeline (branch grouping-optimisation-1, 2026-08-18)

The owner's design, implemented verbatim: stage 1 runs the whole search tail-first (a dedicated tailFirstArbitration tier order inside isBetter: warnings → boards → cost → waste → tails → grouping → levelness), then the single longest usable tail (LOCK_TAIL_COUNT = 1, the owner's adjustment from two) is locked by swapping that board for a virtual shortened stock row, and stage 2 re-runs the whole search grouping-first against the locked stock. Stage 2's answer is accepted unless it is worse on warnings, board count, or cost — a saved board always beats a locked tail. Family grouping threads familyName (the parent panel) through computeFragmentation and tryRegroupFamilies so a split panel's strips count as one family.

packByLevels v2 — multi-stock perfect puzzles (branch 6b-fix, 2026-08-18)

The level construction (above) originally bailed on any dataset with more than one stock row. v2 lifts that for exactly one narrow case: when the pieces' per-width-class areas admit an exact area partition across the stock rows — the zero-slack perfect-puzzle signature. Each width class is assigned whole to one row (backtracking, width-feasibility pruned), each row is solved by the untouched single-row construction, and any imperfection abandons the whole candidate. This is what finally solved Test 6b at 28/28 with 0% waste (and perfected Test 6); every other multi-stock dataset still gets no candidate, exactly as v1.

Status at SUPER FREEZE (2026-08-18)

Since 2026-08-31 (task #121, the piece-drop fix): Full Regression is 33/33 with an EMPTY fail set; the figures below are the 2026-08-18 moment, kept as history.

Suite 11/11 — zero fails, the first fully clean suite in the project's history. Full Regression 26/33: Balanced 11/11, Pure Math 11/11, Ripping Preference fails exactly {t6, t6b, t7, t7v2, regv2, regv3, regv4} — all one known bug (task #121, packLanesFirst piece-drop on tight multi-width boards), the single open engine item. kerfInfeasible is 0 everywhere and is a hard gate. These baselines are the permanent reference under PROCESS PRINCIPLES ▸ SUPER FREEZE — the engine is frozen and the pre-commit hook now blocks unapproved changes outright.

Scanning deep dive — lifelike boards (2026-09-07, branch scanning-deep-dive, research + proposal, nothing built)

The brief. Grain work is the hero of this app, and grain work is only easy if the scanned board on screen looks like the board on the bench. The owner's verdict on the current scans: “not lifelike, colour changes between the start and end of stitched photos and flash glare is not acceptable.” The user captures with a phone, in a workshop, without a studio. This section says what “lifelike” is made of, why the present pipeline falls short on each count, how the people who do this for a living do it, and what the app should do about it — in the order the wins come cheapest.

1. What “lifelike” is, physically

A board's appearance has four separable parts, and a phone photo bakes all four into one set of pixels:

ComponentWhat it isWhat ruins it in a phone photo
Colour (albedo)The timber's own diffuse colour, independent of the light on it. This is the thing a stitched scan must hold constant from one end of the board to the other.Auto exposure and auto white balance re-deciding on every shot; light that is a different colour and strength at each end of a 2.5 m board; flash mixed with shop light in a different ratio at each distance.
Figure and chatoyanceCurl, fiddleback and ray fleck are wave-shaped fibre structure: crests reflect light back at the viewer, troughs scatter it, and the bright/dark bands swap as the light or eye moves. This is the “depth” the eye reads as real wood.Perfectly flat, shadowless light kills it (a flatbed-scanner look). Only directional light shows it, and directional light is also what makes glare and gradients. The two goals fight.
Sheen (specular)Mirror-angle reflection of the light source off the surface. On planed timber it is real and part of the look, but only as a soft, even sheen.A point flash next to the lens puts a hard hotspot exactly where the lens looks. Field run 1 measured it: a ~130 mm specular core inside a ~800 mm bright zone, 10–30 mm blobs the flat-field cannot touch because they are not brightness, they are the light source's image.
Even illuminationA flat scan lights every millimetre the same. The eye forgives a lot, but not a brightness ramp along a board that is one colour of timber.Lens vignetting, inverse-square flash fall-off, one lamp overhead, a window at one end. A per-slice column mean cannot separate a light gradient from a real sapwood-to-heartwood change; it flattens both.

The gold standards each solve this differently. A flatbed scanner gives perfectly even, glare-free colour at a fixed distance, which is why veneer libraries scan on them — but the look is flat and the board does not fit. The Cruse synchron-table scanner, the decor industry's standard for wood floors and veneers, is a line-scan camera over a moving table with lighting that can be set one-sided or raking (its “texture effect” kit) precisely so the figure reads three-dimensionally while colour stays managed. Material-capture pipelines (texture libraries, game and film) separate the four parts on purpose: cross-polarised light for a pure albedo, then a normal map and a gloss map captured or estimated separately, and the material is relit in software — chatoyance comes back because the viewer moves the light. Lifelike on a phone means borrowing from all three: even colour like the flatbed, a controlled directional pass like the Cruse, and separation like the material scanners.

2. Why the present scans fall short — symptom by symptom

Owner's symptomRoot causes, in order of weightWhat the pipeline does today, and why it is not enough
Colour changes from the start to the end of the stitch(1) The phone camera app re-meters exposure and white balance on every shot, so each frame is a different guess. (2) The workshop light itself changes along the board (a window or a lamp at one end). (3) With flash, the flash-to-ambient ratio changes with distance and angle, and the two lights are different colours (flash ~5500 K, LED shop light 3000–4000 K), so each frame's mix is different. (4) The camera app's JPEG processing (tone curve, HDR, local contrast, sharpening) is non-linear, so a gain applied afterwards does not undo a difference made before it.Each slice is flattened by its own column means and then matched per channel to the median photo. Two slices with different content (one has the tape hook, one is clean board; one has a knot) get different gains for the same light. The README already names the fix — match in the overlap, the same timber seen by both photos — and it has not been built. Nothing anchors the whole board to an absolute reference, so the result is only ever as good as the median frame's guess.
Flash glareA point source beside the lens; specular reflection at the mirror angle lands in the middle of every frame. Planed and especially oiled timber is a partial mirror.N4 flattens a flash frame toward the board-wide mean with a ±40 mm window, which fixes the brightness ramp. The sheen core survives because it is the lamp's image, not a brightness error — no per-pixel gain removes it without also crushing real light grain. The only fixes are optical (polarise or diffuse the light, or move the light off the lens axis) or statistical (see the same point from several angles and keep the darkest observation, which is exactly what Google PhotoScan does with five shots and a soft-minimum).
Not lifelike(1) Flat-field clamps and per-slice normalisation squeeze tonality; a walnut board that really is darker at one end is pulled to uniform. (2) Phone JPEGs are already tone-mapped and over-sharpened; grain reads as “video”. (3) The composite is a pure diffuse projection at 2 px/mm; no directional cue, so figure that shimmers on the bench lies dead on screen. (4) Gains are applied in gamma-encoded sRGB, which shifts hue and saturation as it corrects brightness.The pipeline was designed for geometry first (scale, straightness, placement — and it is strong there: every error mode in the table further down is handled). Appearance was one flat-field and one colour match. That was the right order; this is the next layer.

3. How other people do it — what transfers to a phone

  • Lock the camera, then shoot the set. Every serious capture workflow fixes exposure and white balance once per session. On a phone that is the camera app's AE/AF lock (press-and-hold) plus a fixed white balance in Pro mode, or an app that exposes manual controls; RAW/DNG where available. The web Image Capture API defines exposureMode, whiteBalanceMode, exposureCompensation, iso and colorTemperature, but they are device-gated (check getCapabilities(), and set the mode before the value in two calls) — and the flash spike already found Chrome cannot fire the real flash at all. So the capture stays in the phone's own camera app, and the lock is a user instruction the app must make impossible to skip.
  • A reference in every frame. Product and archive photography puts a grey card or a colour checker in the shot and corrects to it; the correction is then absolute, not relative to a neighbouring frame. Our tape already lies along every board; a small printed grey/white patch on the same tape carrier gives every frame its own white balance and exposure anchor for free.
  • Cross-polarisation kills glare at the source. A linear polariser film over the light and a polariser over the lens, rotated 90° to each other, removes the specular reflection entirely; this is the standard in texture scanning and museum reproduction, and it works with a phone plus polarising film for a few dollars. The cost is one accessory and a stop or two of light. Diffusing or bouncing the flash halves the problem instead of removing it.
  • Even light beats corrected light. Slab and lumber sellers shoot in flat diffuse light (an open door on an overcast day, or a softbox) and lay the board down under it; industrial veneer lines use controlled, calibrated illumination and alarm when a lamp drifts. Flat-field correction from a white-card calibration shot is how scientific and machine-vision cameras remove vignetting and lamp fall-off: photograph a matte white surface once per phone and divide it out.
  • Gain compensation in the overlaps, then multi-band blending. The panorama literature (Brown & Lowe's gain compensation, Burt & Adelson's multi-band blending, graph-cut seam placement — all in OpenCV's stitching pipeline) solves one set of per-frame gains jointly from every overlapping pair, so a chain of six frames cannot accumulate a drift, and blends low frequencies over a wide band and high frequencies over a narrow one so seams vanish without ghosting.
  • Several views, darkest wins. Google PhotoScan takes five frames while the phone moves, aligns them with a homography refined by optical flow, and composes a soft minimum over the aligned frames so the glare, which moves between frames, is never chosen. Photogrammetry texturing offers the same “minimum intensity” option to drop highlights. Our three-shots-per-1500 mm proposal is this, applied along a board.
  • Separate the light from the material. Aittala et al.'s two-shot flash/no-flash capture, the single-image SVBRDF networks that followed, Substance 3D Sampler's “delight”, and phone photometric stereo from a few flash positions all recover albedo plus a normal map from ordinary phone photos. The payoff for us is not the maths, it is the presentation: a board with a normal map is relit by the 3D viewer, and chatoyance returns when the user orbits. This is the far end of the road, not the first step.

4. The method proposed for this app — cheapest wins first

Design rule. Fix the light before fixing the pixels. Every software correction below is smaller and safer if the capture is disciplined, and two of the three symptoms (glare, colour drift) are mostly capture problems. Nothing in this list is built; each item is a separate ask-first branch.
#ChangeWhyCost to the user
C1Capture protocol, enforced by the app's capture screen. No flash as the main light. Board laid flat under even, soft light (open door on an overcast day, or away from any single lamp). Phone square-on at a fixed height for every shot. AE/AF lock and fixed white balance set once on the first frame and held for the set. Overlap of at least 50% (three shots per 1500 mm at the current field of view). The app shows a one-screen checklist with pictures before the first shoot and a per-frame “same as last?” tick afterwards. The Samsung Pro-mode procedure is in section 7.Removes per-shot AE/AWB drift (the largest colour-change cause) and the flash hotspot at zero software cost. The owner's own single-flash photo had no colour steps because it had no seams; a locked set has no steps for the same reason.One long-press and one setting; a minute of reading once.
C2Reference patch on the tape carrier. A printed neutral grey (and white) patch that sits beside the tape in every frame; the analyser already finds the tape, so it finds the patch. Each frame is white-balanced and exposure-normalised to the patch first, then the overlap solver (P1) handles the residual. Owner decision 2026-09-07: the first form of this is the calibrated yellow tape, not a patch — section 7.Turns colour matching from relative (to the median frame) into absolute (to a known neutral). Two boards scanned a month apart come out the same colour, which the Timber Library needs anyway.Print one strip once.
C3Cross-polarised flash kit as the indoor option. When ambient light will not do (evening shop), a clip-on linear polariser over the phone's LED and a circular polariser over the lens, crossed. Offered as an accessory, not required. Recommended kit and set-up in section 7.The one method that removes specular glare rather than hiding it. Also removes the sheen the eye expects, so the presentation layer (P5) puts a constant soft sheen back.A cheap accessory and ~1 stop of light.
P1Overlap gain solver (replaces per-slice mean vs median). For every overlapping pair, measure the per-channel means of the same board region in both frames; solve all frame gains at once by least squares (Brown & Lowe), in linear light (undo sRGB gamma, apply, redo).Compares timber to the same timber, so content differences (knot, hook, sapwood) cannot masquerade as light differences; a joint solve cannot drift along a chain. Linear light stops the hue shift that gamma-space gains cause.None.
P2Flat-field from a one-time white-card shot, plus a 2-D low-order field fitted only in overlaps. Photograph a matte white sheet once per phone (or once per session under the session's light); divide every frame by it. Any remaining ramp is fitted as a smooth 2-D surface constrained by the overlaps, not by each slice's own content.Removes vignetting and lamp fall-off without ever looking at grain, so a real dark end stays dark and a knot stays a knot. The present column-mean flatten with a 0.6–1.7 clamp is retired for ambient frames.One extra photo, once.
P3Multi-band blend with soft-minimum where frames overlap. Seams placed by graph cut through low-detail timber (never through a knot), low frequencies feathered over the whole overlap, high frequencies over a few millimetres. Where two or more frames see a pixel, take a soft minimum weighted away from each frame's hotspot centre (the three-shot blend already specified ask-first on native-flash-capture).Ends the visible 60 mm feather band and the residual step; with flash frames, the darkest observation is the one without the lamp in it.None (a third shot per 1500 mm when using flash).
P4Tonality preserved. Corrections operate only on the low-frequency field; no per-slice contrast clamps; the composite carries the camera's colour, not a normalised one. Prefer the phone's least-processed capture (Pro/RAW where offered) and warn when a frame arrives HDR-tone-mapped (EXIF).“Lifelike” is mostly tonality; every normalisation step so far has traded some of it for evenness.None.
P5Presentation: light the board in the viewer, don't bake it. The composite is the albedo. The WebGL viewer already lights the model; give scanned boards a constant soft specular term and, later, a per-board normal map from a figure pass (one extra raking-light shot with the phone torch held low from the side, registered to the composite by the same tape geometry).This is where chatoyance comes back: the user orbits and the curl moves, as on the bench. It is the Cruse “texture light” idea and the material scanners' relighting, done with the viewer we have.One optional extra shot per board.

Deliberately not proposed: native phone panorama (a cylindrical projection with no recoverable scale — rejected earlier and still wrong); slit-scan (archived); AI delighting networks on the phone (heavy, and they hallucinate on figure — keep as a later experiment on the PC side); a flatbed or a rig (the user has a phone and a bench).

5. How we will know — the numbers a lifelike scan must meet

  • Colour drift end to end: on a board the owner judges one colour, the mean colour of the first and last 200 mm differ by less than ΔE2000 3 (the “just noticeable at a glance” threshold); today's scans are to be measured first so the starting number is on record.
  • Seams: no luminance step above 2% across any seam line, measured on a 20 mm band either side; no visible feather band in the Scanning tab's before/after strip.
  • Glare: no region larger than 10 mm where any channel is saturated or luminance exceeds the board mean by 25%.
  • Tonality: the composite's luminance histogram spans at least 90% of the median frame's span (nothing flattened away).
  • The owner's eye, last and decisive: a side-by-side of the composite and one straight phone photo of the same stretch, in the Scanning tab, with the ruler and the detected centre-line as the standing rule requires. If he cannot say which is the photo, it is lifelike.

6. Experiments, in order — each one cheap, each with a pass/fail

#ExperimentPass meansNeeds
E1Locked-camera ambient set: one board, AE/AF lock + fixed WB, no flash, even light, 50% overlap, through the current pipeline unchanged.End-to-end ΔE drops below 3 with no code change — proves the drift is capture, not code.Owner, phone, one board, ten minutes.
E2Overlap gain solver offline (Python) on the existing 7-photo walnut set and the 2-shot flash set.Seam steps below 2% on both sets; no drift along the chain.Nothing new; photos on disk.
E3Grey patch beside the tape; frames normalised to it before E2's solver.Two sets of the same board on different days match within ΔE 3.A printed strip.
E4Cross-polarised flash on the phone LED vs plain flash, same board.Glare metric passes with the polariser; sheen visibly gone.Polariser film + lens filter.
E5Three-shot soft-minimum blend on a flash set (the ask-first item from native-flash-capture), offline first.Glare metric passes without a polariser.A 3-shot set from the owner.
E6Figure pass: one raking-torch shot registered to the composite; shown in the 3D viewer as a normal-map overlay.The owner sees the curl move when he orbits a curly board.A figured board and one extra shot.

The recommended first build, after E1 and E2 report, is P1 + P3 (overlap solver and multi-band blend) behind the existing capture, with C1's checklist in the capture screen. That pair attacks both named symptoms with no new hardware and no change to the geometry pipeline. C2, C3 and P5 follow once the owner has seen the difference.

7. Decisions taken with the owner, 2026-09-07

The reference is the yellow tape, calibrated once against a grey card. Asked whether the tape could do the grey card's job: for frame-to-frame consistency it can, because it is the one object in every frame; for absolute colour it can stand in for the card once it has been calibrated against one. Asked whether a white tape would be better: no. White blade coatings carry optical brighteners that fluoresce blue under daylight and flash, a white blade blows out under flash against a dark board, and the tick and numeral reader is built and field-proven on red ink over yellow (its literals live in the Phone/Tape Calibration record). Yellow stays. The owner's long-term wish is several tapes, each calibrated; the colour profile below becomes a third part of a tape's profile alongside its two geometry profiles, so more tapes are more profiles, not more code. Dirt and fading are not a concern at this level; a tape that reads oddly is one photo to recalibrate.

  • The calibration shot, once per tape. Tape flat across most of the frame, the owner's matte grey card and matte white card beside it, even light, no flash, exposure and white balance locked. One shot in daylight and one under the shop LEDs, because a yellow pigment's ratio to grey shifts a little with the light source; the app keeps both and picks the nearer by the frame's own white-balance estimate. The app finds the blade as it already does, samples clean blade between numerals and ticks, samples the card, converts to linear light and stores the blade's per-channel ratios to the grey plus its brightness relative to the card. The white card in the same shot gives a second known level, which checks the phone's tone curve (JPEGs are not linear; a wrong curve bends every gain).
  • Per frame, afterwards. Measure the blade, divide by the profile, and the result is what a grey card would have read: three white-balance gains and one exposure gain per frame with no card in the shot. The overlap solver (P1) then tidies only residuals.
  • Known weakness. Yellow reflects little blue, so the blue gain is the noisiest of the three, and blue-versus-yellow is exactly the axis workshop light drifts along. The mitigation is averaging along the whole blade in a frame rather than a small patch; expect a couple of ΔE either way, inside the section 5 target.
  • The owner's cards. A matte 50% grey card and a matte white card. The grey does the tape calibration (mid-range, never clips). The white is the linearity check in the same shot and, only if it fills the frame, the flat-field shot for P2; a small card cannot measure vignetting, a sheet of matte white board can. The printed reflectance does not matter: every tape and board is compared to the same card, and consistency is the goal.
  • Locking the Samsung camera (the C1 procedure). Photo mode's press-and-hold AF/AE lock freezes focus and exposure but leaves white balance automatic, so it is not enough. Use More › Pro: set ISO, shutter and WB from Auto to manual (start ISO 100, 1/100 in good light or 1/60 under LEDs, WB 5500 K daylight or ~4000 K under LEDs), aim at the grey card and nudge WB until it looks neutral and shutter until it sits mid-brightness, manual focus if the phone hunts, then never touch a control for the whole board. Flash is available in Pro mode on most Galaxy models. Once, in Settings: RAW copies on (Format and advanced options) and Scene optimiser off, which would otherwise re-tune colour per shot. The capture checklist will ask for these same values.
  • Cross-polarised kit (C3), recommended for the Samsung. Lens: K&F Concept 52 mm clip-on CPL phone filter kit (kentfaith.com.au, KF01.2676) — the 52 mm glass covers the camera cluster; generic 37 mm clips often shade the LED. Flash: K&F Concept linear polarising film sheet 20×30 cm, 0.21 mm (kentfaith.com.au/polarized-film); premium alternative Rosco 7300 sheet (Image Melbourne). Set-up: a ~15 mm square of film taped flat over the LED only; CPL clipped over the main camera; under the torch, rotate the CPL until a glossy object's reflection goes black, mark the ring. Expect one to two stops less light. Check the clip does not sit over the LED, and that the film square survives the LED's heat — film on the LED, glass on the lens, never the reverse.
  • Next step agreed. The owner supplies the calibration photo(s) (tape + grey + white, locked Pro mode, daylight and LED) to the test-files share. The tape calibration and the overlap solver are then run offline on the existing walnut and flash photo sets first, and the end-to-end colour numbers reported before anything goes into the app. Ask-first still applies to the in-app build.

8. Built, ready for the testing day (branch scanning-lifelike-build, 2026-09-07)

Owner: “build everything you can. it will take me some time to do physical tests. develop all the code in the app in readiness for a testing day. Also, I want to give the user a simple and complex image capture option.” Everything below is in the capture lab (Scanned Images › + New Scan) and ran end to end on the 7-photo walnut set on disk. Nothing has met a real board since; the numbers in the last row are the honest starting point, not a result.

BuiltWhereWhat the testing day checks
Simple / Ultimate capture (L7). Two buttons at the top of Set up. Simple: point and shoot, the app does its best. Ultimate: the checklist (light, Samsung Pro-mode values, tape colour calibration, flat-field, polarised flash, overlap, figure pass) with live calibration status, and the two calibration cards. Same pipeline for both.Set up card; localStorage captureQualityOne board shot both ways; the Ultimate set should pass more of the report's rows.
Tape as the frame reference (L1) in linear light: each frame's blade colour (inboard third, tape pixels only, no ink, no sheen) sets its white balance in full and its brightness by the square root (0.8–1.25), toward the set's median blade.composite, before the joint field; results row Tape matchA set shot with the camera on auto: does the drift row fall without a calibration?
Tape colour calibration (L2): photo of the tape with the grey and white cards, tap grey, tap white; per-light profile (blade / grey ratios, white / grey check); the nearest profile is used per frame and the frame is rendered so a grey card would read 0.20 linear.Ultimate card; lab meta tapeColourTwo sets of one board on different days within ΔE 3 of each other (E3).
Overlap gain solver (L3): the same board columns in both slices at every seam, solved jointly (log-ratios, pull toward 1), geometric mean 1, capped 0.8–1.25, applied after the joint field as a residual polish. Measured on walnut: before the field the ratio is the frame's own sideways light gradient (0.50 at one end, 1.71 at the other), which is why it must come after.composite; results row Overlap gainsSeam steps under 2% on a locked-camera set (E1, E2).
Two-band seam blend + soft minimum (L4): low frequencies cross the whole 120 mm band, high frequencies switch at the band's quietest column; a flash pair leans to the darker observation.composite; results row Seam blendA three-shot flash set (E5): the sheen core should not survive a seam.
White-card flat-field (L5): one photo of a white sheet filling the frame; 24-wide per-channel grid; every frame of the same shape divided by it before rectification.Ultimate card; lab meta flatField; results row Flat-fieldA plain pale board: the within-frame ramp should vanish with the joint field's own factors near 1.
Lifelike report (L6): colour drift (ΔE2000 first vs last 200 mm), seam steps, glare (a 5 mm cell far brighter and far less saturated than its 55 mm neighbourhood, or saturated), tonality (P2–P98 span kept against the raw slices, tape band skipped). Four pass/fail rows; window.__lastLifelike; lab meta lastReport; __lab.lifelike(); per-stage slice means in window.__lifelikeStages.results table, four Lifelike rowsThe thresholds themselves: are 3 ΔE, 2%, 10 mm and 90% the right lines for the owner's eye? Adjust from the day's numbers.
Sheen in the viewer (L8, P5 first half): a soft Blinn-Phong highlight on photo-textured faces that moves with the orbit. Figure pass (L7): one raking-light shot kept with the photos, never stitched, for the normal-map work later.3D viewer; Photos card buttonDoes a planed board read as timber when orbited? Does the figure pass show the curl?
What the walnut set proved about frame colour (owner: “some spots are yellowish, e.g. at 900/1300/1700”; measured as a 15–20% blue/green dip after each seam). Three software levers were tried and each made colour worse, so each is now measured and reported but not applied: the seam-residual solver after the field (a whole-frame gain answering a local edge artefact), the blade's blue (a mild sheen reads as blue), and a chroma solve from the shared timber before the field (without a flat-field the seam chroma carries the phone's own colour shading, one sign all along the board). Uncalibrated, the tape now sets brightness only. The chroma solve switches on by itself once a white-card flat-field exists. Yellowness spread went 26.7% → 22.5% with the levers off. Then, on the owner's next look (“500 has introduced yellow... reduce yellow more at 700 & 1300”), a seam-chroma solve with the phone's shading divided out was tried and made it worse (33%: the shading moves with each shot's framing), and the answer that worked was the joint field's own target: its ±300 mm luminance profile is kept, its colour is the board-wide mean colour, so per-shot white balance is flattened with the light. Walnut: spread 26.7% → 10.6%, ΔE end to end 11.4 → 2.2, pass. A board with a real colour change along its length (sapwood at one end) will be flattened too; window.__lifelikeKeepFrameColour keeps the old target for that comparison on the testing day.results rows Tape match, Overlap colour, Overlap residualThis is the case for C1 and C2: a locked-WB set, then a calibrated tape, should remove the dips; the flat-field then unlocks the seam-colour solve (E1, E3).
The tape as the yardstick (owner: “the tape is also clearly showing those spots as not being a consistent yellow”, “the brightness changes a fair bit”). The blade is one colour in reality, so its colour and brightness along the finished composite is a content-free reading of the error left behind — the report's tape uniformity row. Sixteen pipeline variants were run against it on the walnut set. Decided: each photo is straightened against its own tape with the blade's blue used (red/green along the tape 2.5% against 10%); the board-based field corrects brightness only, because the glossy blade cannot arbitrate brightness across photos (a tape-flat build left the board's seams at 11%, the board-based field holds them at 4%); the overlap residual builds a ramp whenever applied and stays report-only. Known residue: the tape's own brightness still varies about 25% along the composite, which is the board field disagreeing with the glossy blade; the healed board does not show it, and a white-card flat-field is the reference that would settle it.results rows Tape along each photo, Lifelike: tape uniformity; testing switches __lifelikeNoTapeStraighten, __lifelikeNoField, __lifelikeNoTapeBlue, __lifelikeFullTapeMatch, __lifelikeFieldPerChannel, __lifelikeFieldClamp, __lifelikeApplyOverlapJudge the tape row against your eye on a locked-camera set; then the flat-field (E1, then P2).
The bold seam bands (owner: “numbers 400–440, 800–840, 1200–1240, 2360–2440 are bold and sharp compared to the rest” — exactly the seam bands). Measured on the tape: ink at 7 inside a band against 90 in both source photos, gradient 28 against 21 and 15. The two-band blend was adding each photo's detail as a difference onto a mixed low band, so wherever the two photos differ in brightness a dark pixel of the brighter one went below black. Light is multiplicative, so the detail now rides as a ratio to its own low band; the blur also ignores empty padding pixels. After: band gradient 20 between its sources' 21 and 15, ink 85–94 against 87–100. The owner also judged the tape-colour defaults of v13 worse than v9; v9's per-channel field with the colour-flat target is the default again and the tape-colour variants stay as testing switches.seam blend (blendSeamBand); switches __lifelikeTapeBlue, __lifelikeFieldLumOnlyRead the tape across every seam on a real set: same weight of ink and the same sharpness as either side.
The tape scorecard per 20 mm, the sharpness equaliser, the tape polish (owner: “if you can see and read the tape then you know if you have been successful. your goal is to make the tape perfect”; L28, L32–L35). Per 20 mm of blade: Lab colour, worst ΔE2000 to the median window, L* spread, sharpness as the 1 px step over the 4 px step of the same edge. Equaliser: unsharp the soft columns, lightly blur the crisp ones toward the board’s own look, two passes, on the composite and the kept band alike (it must run after the auto-trim’s edge measurement — a sharpened bottom edge tipped the K4 pair check and moved the whole board 7.5 mm). Polish: the finished tape read per 20 mm, every window’s factor to the board-wide blade colour divided out of every row in linear light — as read on the tape rows, running-median and blue within 5% on the timber, hook and case skipped. Straightening split: one flash lights one frame with one colour, so along a photo the timber takes the tape’s correction as luminance only, the tape in full colour (photo B’s blade ran sRGB blue 122→80 over its first 100 mm — sheen at the frame edge, which had made every seam band pale). Blue between frames now in full (0.7–1.4). Walnut: tape L* spread 4.4→1.6, worst ΔE 2.44→1.13, sharpness spread 133%→71%; board ΔE end to end 3.2→2.4.tapeUniformity, equaliseSharpness, tapePolish, straightenSliceByTape; rows Lifelike: tape uniformity, Sharpness equaliser, Tape polishSharpness still 0.7–1.2 (bar 25%); the hook end’s glare; photo 7’s blade wants >20% more blue than the clamp allows — whether full blue between frames is right for the timber, only the grey card can say.
The same timber, read in both photos (L36–L42, the definitive test). At every seam the two slices hold the same 60 mm of timber, so their disagreement there is light and nothing else. It showed: photo B’s start brighter and bluer than photo A’s end on the board while B’s blade read darker and less blue — the blade’s blue sets the tape rows only now, the timber’s frame gain has blue following green (L38); along a photo the timber’s red takes the blade’s own r/g swing, the rest is luminance (L37); a bug — the slice profiles were read over every row, tape band included, while the field acts on the board rows only, so a third of every seam difference survived (L39, board-row profiles); colour flattened at 80 mm, which the diluted profiles had wrongly ruled out (L40); the timber takes only the slow part of the tape polish (L41); and the across-board field (L42): per row, the overlap’s light ratio, a chained least-squares over the photos, each photo a row-profile gain ramping between its overlaps — the column field could not see that each photo lights the board’s top and bottom differently. The sharpness score is read on the tick rows with the red numerals excluded, and the equaliser runs three passes with a 2 px kernel (L36).overlapVerticalField, computeSliceProfile(skipRows), applyGainsLinear(gBoard), tickStep, isInteriorTape; row Across-board fieldPhoto 7’s start (blade in shade, board redder); the first 100 mm bright; the soft stretch after each seam is real blur (the 50% overlap of the Ultimate capture is the fix).
Walnut set, as it stands (7 photos, camera on auto, no calibration, v49): glare 0–10 mm pass; ΔE 1.1 end to end pass; worst seam 3.6%; tonality 70% kept (the bar compares with the raw slices’ span, which includes their light gradients — flattening the light lowers it; a bar to revisit); tape L* spread 1.9, worst ΔE 0.96, sharpness spread 55%; the same timber either side of every seam within 1% in colour; top rows 148–160 and bottom rows 138–151 the whole length, the last photo’s end included (owner on v44: “2270 onwards looks funny” — its bottom rows were 25% darker than its top; the across-board field now pulls every photo’s ends toward the board-wide row profile, per channel, L43–L46). The end-to-end drift on this set is partly the board’s own light gradient on the day it was shot; only a locked-camera set says how much.this run, 2026-09-07E1 first: the same board, Pro mode locked, no flash, even light.

Not built, still ask-first: the 2-D field fitted in overlaps (P2's second half; the white-card map covers the lens, the joint field the room), the figure-pass normal map and relighting (P5's second half), and any change to the geometry pipeline. Guarded: the Scanning Partial suite stayed at baseline (9/10, {p2:board-152-tape60-parallel}) after every step; a scan branch still owes the Scanning FULL regression on real timber before it merges, which is the testing day.

9. Sources read for this section

  • Google Research, PhotoScan: Taking Glare-Free Pictures of Pictures (five frames, homography + optical flow, soft-minimum composite) — research.google/blog/photoscan-taking-glare-free-pictures-of-pictures
  • Cruse Synchron Table Scanner, decor and design industries (line scan, moving table, one-sided and raking “texture” lighting, colour management) — crusescanner.com/en/decor-and-design-industries
  • Cross-polarisation for texture photography and material scanning — lotpixel.com/blog/how-to-use-cross-polarization-for-texture-photography-and-material-scanning; docs.sharktacos.com/photography/xpol.html; Springer, A Cross-Polarised Photogrammetry Workflow for Complex Materials (wood among the test objects)
  • Aittala, Weyrich, Lehtinen, Two-shot SVBRDF capture for stationary materials (phone flash/no-flash); Deschaintre et al., Single-image SVBRDF capture with a rendering-aware deep network; Li et al., Materials for Masses: SVBRDF acquisition with a single mobile phone image
  • Adobe, Substance 3D Sampler Image to Material and Delight (AI powered) documentation
  • OpenCV stitching_detailed.cpp (exposure compensation, seam finding, multi-band blending); Brown & Lowe, Automatic Panoramic Image Stitching using Invariant Features (gain compensation); Burt & Adelson, A Multiresolution Spline with Application to Image Mosaics
  • Flat-field correction: RawTherapee RawPedia Flat-Field; Imatest Using Flat Field; Flat-field and colour correction for the Raspberry Pi camera module (arXiv 1911.13295); Standardized spectral and radiometric calibration of consumer cameras (arXiv 1906.04155)
  • Colour references: X-Rite ColorChecker White Balance; PetaPixel, How to use a color checker; Envato, Perfect color in product photos with a reference card
  • W3C MediaStream Image Capture (exposureMode, whiteBalanceMode, exposureCompensation, iso, colorTemperature; capabilities are device-gated); webrtcHacks, Fix bad lighting with JavaScript webcam exposure controls (set the mode before the value, in two calls)
  • Chatoyance and figure under directional light — Fine Woodworking forum, Chatoyancy: what types of figured maple work best; commercialforestproducts.com, Chatoyancy in figured wood
  • Industrial veneer and lumber imaging: US patents 10825164 (Imaging system for analysis of wood products) and 8253793 (Lumber inspection, reflected and transmitted illumination); Vision Systems Design, Machine vision analyzes lumber; realworldtextures.com, PBR scanning of laminates and wood veneers
  • Photometric stereo and relighting with hand-held phone flash: Northwestern Comp Photo Lab, Near-light correction for image relighting and 3D shape recovery; FlashNormal (arXiv 2608.25360)
  • In-house: capture-lab/README.md (pipeline, known limits: “matching in the overlap is the proper fix”); branch-memory/native-flash-capture.md (flash spike, N3/N4, the three-shot blend held ask-first); Field run 1, 2026-09-05.
  • Section 7 kit: K&F Concept clip-on CPL (kentfaith.com / kentfaith.com.au, KF01.2676); K&F Concept polarised film (kentfaith.com.au/polarized-film); Rosco 7300 (imagemelbourne.com.au); photographyattic.com, A practical guide to cross-polarisation photography; DPReview forum, Cross polarization (film on the LED, CPL on the phone).

10. The lifelike pipeline as built, stage by stage (L1–L46, 2026-09-07) — the record

Owner, after v44: “that is great as far as colour matching the stitched photos together goes. well done!” and “document everything in great detail so we don’t have to relearn what you have learnt.” This section is that record. Every stage below runs inside buildComposite() in the capture lab — nothing was done by hand; the test harness only typed the board size and the tape distance. The order is the order of execution. Every testing switch is a window.__lifelike… flag read at build time; the defaults are what the app does with no flag set.

The two yardsticks that made this work (owner: “if you can see and read the tape then you know if you have been successful. your goal is to make the tape perfect. if the tape is perfect then the wood might be as well”). (1) The tape scorecard per 20 mm — the blade is one yellow along its whole length, so any change in it is the pipeline’s doing. (2) The same timber read in both photos — at every seam the two slices hold the same 60–85 mm of wood, so their disagreement there is light and nothing else; measured in the slice buffers (__lifelikeItems) and on the composite either side of each seam, top rows and bottom rows separately. Judging by the owner’s eye alone had led round in circles for twenty versions; these two measures found a real bug (stage 5) and settled the blue question (stage 4) in an afternoon.

#StageFunctionsWhat it does, on which rows, with what clamp — and why it is that wayTesting switches
1Rectify, pass 1buildComposite (pass 1)Each photo rectified onto the board plane with 60 mm feather + 100 mm seam room of padding either side (SEAM_ROOM_MM), so a seam can move inside the real overlap. The slice buffer is padded 250 mm past the board: anything ramping along a slice must run between its COVERED columns, not its buffer edge (L46).—
2Sharpness-balanced seamssliceSharpColsPer 5 mm column of each rectified slice, mean |d lum / dx| over the board rows; within the overlap (20 mm inside both photos) the seam goes where the two are equally sharp, the midpoint breaking ties. Band half-width = min(60, (overlap − 10)/2), so the band never runs past either photo. Walnut overlap is ~85 mm; the Ultimate capture’s 50% overlap is the real fix for the soft stretch after each seam.__lifelikeMidpointSeams
3The tape along each photosliceTapeProfile, straightenSliceByTapeThe blade read every 30 mm (inboard third, tape pixels, no ink, no sheen), running median of three, interpolated per column, flat past the ends. TAPE rows: full colour, blue may go 0.3–3 (photo B’s blade started at sRGB blue 122 against 79 further in — sheen at the frame edge, and the old 0.6 clamp left the seam band pale). TIMBER rows: one luminance gain per column (one flash lights one frame with one colour), with red on the blade’s own r/g swing within 0.85–1.18 (the phone’s colour shading); green and blue on the luminance gain. Clamp 0.6–1.7.__lifelikeNoTapeStraighten, __lifelikeStraightenColourOnBoard, __lifelikeStraightenLumOnlyBoard, __lifelikeStraightenClamp, __lifelikeUseGamma / __lifelikeGamma (opt-in, pinned at the bounds)
4Frame match from the bladesampleBladeColour, tapeFrameGains, applyGainsLinear(gBoard)Every frame’s blade colour (median over its 30 mm windows) pulled to the median frame, in linear light. Level in full (0.5–2). Blue: in full (0.7–1.4) on the TAPE rows; on the TIMBER rows blue follows green — the same timber read in both photos at a seam showed photo B brighter and bluer while its blade read darker and less blue: tape and timber disagree in opposite directions on blue. With a tape calibration (meta tapeColour, the grey card) the match is absolute.__lifelikeDampedTapeMatch, __lifelikeDampedTapeBlue, __lifelikeNoTapeBlue, __lifelikeBoardTapeBlue
5Slice profilescomputeSliceProfile(skipRows)Per-column sRGB means over the BOARD ROWS ONLY (tape band and ruler skipped) and board columns only, mirror-padded at the ends. Two windows: 40 mm for brightness, 80 mm for colour. THE BUG (L39): profiles used to read every row while the field acts on the board rows only — the band, identical in both slices, diluted every correction and a third of each seam difference survived.__lifelikeProfileAllRows, __lifelikeWideProfile, __lifelikeColourWindowMm, __lifelikeColourWide, __lifelikeColourAt40
6The joint column fieldapplyFieldEvery slice mapped onto one flat target: the board-wide mean colour (Tflat), luminance from the 40 mm profile, colour ratio from the 80 mm profile within ±15% of the brightness factor; clamp 0.6–1.7 (0.4–2.5 for a flash frame); BOARD rows only — the tape rows keep stage 3. A real change of tone along a board is flattened too; the white-card flat-field (meta flatField) is the principled reference.__lifelikeNoField, __lifelikeKeepBroadTone, __lifelikeKeepFrameColour, __lifelikeFieldLumOnly, __lifelikeFieldPerChannel, __lifelikeFieldClamp, __lifelikeFieldChromaClamp, __lifelikeFieldOnTape
7Overlap gain solver (report only)seamPairMeans, solveLogGains, overlapGainsThe same board columns in both slices at every seam, log-ratios solved jointly. Measured and reported, not applied: applied it always builds a ramp (a whole-frame gain answering a local edge). The chroma solve turns on with a flat-field.__lifelikeApplyOverlap, __lifelikeChromaSolve
8The across-board fieldoverlapVerticalFieldThe 2-D field. Per ROW and per CHANNEL: in each overlap the two slices hold the same timber, so log(B/A) per row is light; unknowns are each slice’s log-gain at its left and right anchor (the overlap centres; for an end photo a point 50 mm inside the board’s end); one equation per seam; each unknown pulled (λ 0.3) toward log(board-wide median row profile / that end’s own 60 mm profile) and toward a flat slice (μ 0.1); Gaussian elimination per row; each slice takes exp(lerp) between its anchors, flat beyond, board rows only, ±10 mm smoothed down the rows, clamp 0.6–1.7. Why: each photo lights the board’s top and bottom differently and a column field cannot see it (walnut bottom rows stepped 142→150 at a seam while the whole-height means agreed; photo 7’s bottom was 25% darker than its top).__lifelikeNoVerticalField, __lifelikeVerticalPullZero, __lifelikeVerticalLumOnly, __lifelikeVerticalLambda, __lifelikeVerticalMu, __lifelikeVerticalClamp; debug window.__lifelikeVfDbg
9Composite + seam bandsblendSeamBandSlices laid hard-edged up to their bands; each band rebuilt from both: gain-ramp-and-cut — a 25 mm alpha-aware low-pass of each side (per-pixel tape/timber class), gain = lowB/lowA, A × gain^w and B × gain^(w−1) crossfaded per row over exactly the columns both photos cover, detail switched at the band’s quietest column where both cover ≥ H/4 rows; a flash pair leans to the darker (soft minimum). Earlier additive Laplacian blends crushed the blacks (the owner’s “bold” bands).__lifelikeBlendDebug
10The colour bridgebridgeSeamBandsPer band (+40 mm zones): brightness and saturation brought to the line between the timber either side, target half local 60 mm refs / half board-wide median; chroma scaled around each pixel’s own luminance so hue is untouched; tape pixels and ruler skipped. The owner’s own option: “go back to where the bands were white, then add the timber colour into the white section”.__lifelikeNoBridge, __lifelikeBridgeExtendMm, __lifelikeBridgeBoardMix
11Ruler, heal, edge measurementheal v5.1, measureRenderedEdgesThe ruler row; the tape band healed (reflection above/below, lay-following, adaptive shadow, healState.orig kept for Show the tape); then the auto-trim’s edge measurement. ANYTHING THAT SHARPENS OR RE-COLOURS THE COMPOSITE MUST RUN AFTER THIS MEASUREMENT: a sharpened bottom edge tipped the K4 pair check and a 7.5 mm trim came and went with a switch (L32).—
12The sharpness equaliserequaliseSharpness, tickStepPer 20 mm window on the scorecard’s grid, the edge ratio (1 px step over the 4 px step of the same edge) on the blade’s OUTER thirds (the tick rows), red ink excluded at either end — the red hundred numerals are printed larger and softer and had made every window holding one read soft; a tick-only variant read the tick’s geometry (0.4–0.6) and was blind to blur. Target = the 60th percentile; unsharp the soft (radius 1 px to +0.8, a 2 px kernel for the rest, up to +2.0), lightly blur the crisp (to −0.7); amounts feathered ±10 mm; three passes; applied to the composite AND the kept band. True blur is not restored.__lifelikeNoSharpen, __lifelikeSharpenPasses, __lifelikeSharpenMax, __lifelikeSharpenR2Off; debug window.__lifelikeSharpenDbg
13The tape polishtapePolish, isInteriorTapeThe last word on colour: the kept band read per 20 mm (INTERIOR tape pixels — tape 2 px on all four sides, so the equaliser’s halos are not counted; no ink, no sheen; the hook and the case, first/last 60 mm, not read); factor per window = board-wide median blade colour / window, in linear light. TAPE rows: every window as read, within 30%. TIMBER rows: only the slow trend — running median of three, then a ±60 mm mean, within 5%, blue within 5% — because a 20 mm factor at a seam (0.94 then 1.04) had printed a 10% step on the wood, and because the blue of a yellow blade is a toe (0.03–0.2 linear), sheen- and JPEG-sensitive, not a light probe.__lifelikeNoPolish, __lifelikePolishClamp, __lifelikePolishTapeClamp, __lifelikePolishBoardClamp, __lifelikePolishBoardSmoothMm, __lifelikePolishBoardBlue, __lifelikePolishEndMm, __lifelikePolishTapeOnly; debug window.__lifelikePolishPts
14The reportlifelikeReport, tapeUniformityBoard: ΔE2000 first vs last 200 mm (< 3), seams as the steepest 5 mm change within ±70 mm of each seam (< 2%; sharpening raises it), glare as a local outlier cell (< 10 mm), tonality as the P2–P98 span against the raw slices’ spans (≥ 0.9 — a bar that now penalises flattened light; to revisit). Tape per 20 mm: Lab of interior tape pixels, worst ΔE2000 to the median window (< 3), L* spread (< 5), sharpness spread on the tick rows (< 25%). All in window.__lastLifelike.—

The rules learned (the physics, each one measured on the walnut set):

  1. One flash lights one frame with ONE colour. Along a photo the light changes in strength, not in hue; a colour change along a photo’s tape is sheen (the glossy blade at the frame edge nearest the flash). So along a photo the timber takes luminance only (plus the blade’s r/g for the phone’s colour shading), and the tape takes full colour so it comes out one yellow.
  2. The blue of a yellow blade is a toe: 0.03–0.2 linear, sheen- and JPEG-sensitive. Between frames the blade’s blue and the timber’s blue moved in OPPOSITE directions on this set. The blade’s blue sets the tape rows only; the timber’s blue comes from the board field (colour at 80 mm) and stays within 5% of the polish.
  3. Colour must be corrected at a coarse scale (80 mm, board-only profiles); brightness at 40 mm. A fine colour window chases grain colour and lands on one channel’s clamp (the “yellow bands”).
  4. A profile must be read on exactly the rows the correction is applied to. Reading the tape band into the board profile diluted every correction by the band’s share and left a third of every seam difference in place.
  5. Anything that alters the composite must run after the auto-trim’s edge measurement; and every per-position comparison must be made at the same trim state (a 7.5 mm shift looked like a colour change).
  6. A 20 mm correction factor applied to timber prints a visible step. The timber takes only slow trends (±60 mm means, within 5%); the tape may take every window.
  7. The sharpness measure must be blind to content: outer tick rows only, red numerals excluded, on a fixed grid. A step-ratio on thin ticks measures tick geometry, not blur. A 1 px kernel cannot change the shape of an edge blurred wider than itself.
  8. Colour must be read from interior tape pixels; sharpening halos around the ink shift a window’s mean by a visible amount.
  9. A column field cannot fix a row difference: each photo lights the top and bottom of the board differently. The overlap’s same-timber row ratio is the only honest evidence for that, and an end photo needs a prior (the board-wide median row profile at its own end) because it has one neighbour.
  10. A ramp along a slice must run between covered columns, and an end anchor must sit inside the board (the last 50 mm holds the tape case and pencil marks).
  11. Regularisation weights do nothing when the target they pull toward is wrong or diluted — measure the target before tuning the weight (λ 0.05→5 changed nothing until the end profile was read at the anchor).
  12. Additive detail blends crush the blacks at a seam; a gain-ramp-and-cut keeps the range. A crossfade must run over the columns BOTH photos cover on that row (a tilted frame’s last column differs row by row).

How it was tested, and how to test it again. Open the app on the dev server, Project Scans (then Scanned Images) → New Scan, add the seven walnut photos (capture-lab/test-photos), type the board 2500 × 215 and the tape distance 109 mm (typed, not guided: the scan gate leaves its 76 mm fixture datum in the lab’s IndexedDB, and the auto-trim moved 109 to 101.5 in one branch of the K4 check — retype after every reload). Set any window.__lifelike… flags, press Build. Read window.__lastLifelike (the report incl. .tape.windows per 20 mm), window.__lifelikeItems (the slice buffers, with .from/.to/.x0/.w/.prof/.tapeProf/.fg/.gains), window.__lifelikeStages (per-stage means and 50 mm profiles with __lifelikeStageProfiles), window.__lifelikeVfDbg, __lifelikeSharpenDbg, __lifelikePolishPts. Export the full canvas with fetch('/save-png', {method:'POST', body: JSON.stringify({name, dataUrl})}) (quick-server writes to capture-lab/test-photos); crop at 3–5× and LOOK — the owner’s eye is the judge and the numbers are how to find what the eye saw. Show the tape flips the healed band to the kept one; both views were always sent, full canvas with the ruler and the off-board tape.

Walnut set (7 photos, auto camera, no calibration)v1 (L1–L8)v39 (L32–L35)v44 (L36–L42)v49 (L43–L46)
Tape L* spread / worst ΔE2000 (per 20 mm)not measured1.6 / 1.131.9 / 0.961.9 / 0.96
Tape sharpness spread (tick rows)—133% (old measure)55%55%
Same timber either side of a seam, r/g—3.5% apartwithin 1%within 1%
End of board, top / bottom rows (luminance)——163 / 121157 / 141
Board ΔE2000 end to end11.42.41.81.1
Worst seam step (5 mm)2.6%3.4%3.5%3.6%
Glarepasspasspasspass

Still open (the honest list): the soft stretch after each seam is genuine blur in photo B’s first 100 mm — only the Ultimate capture’s 50% overlap removes it; the tonality bar (≥ 90% of the raw slices’ P2–P98 span) now punishes a correctly flattened light and needs a reference that excludes the light gradient; the seam-step bar (2%) is sharpening-sensitive; photo 7’s blade wants a fifth more blue than the frame match’s 1.4 — whether full blue between frames is right for the timber, only the grey card on the testing day can say; the first 20 mm reads bright (the hook end’s glare); a real change of tone along a board is flattened by the column field (the white-card flat-field is the way out); and every number here is from ONE board under ONE light — the Scanning FULL regression on real timber (E1–E6) is owed.

Grain classification — the sandpit record (2026-09-11/12, branch grain-sandpit; v17 87.2 % cell agreement with the owner's marks; owner: “sufficient for this version of the app”)

What this is. The complete account of the grain sandpit: the question, the marks it is scored against, the image pipeline, every rule that was tried and how it scored, the rule that won (v17: the owner's heart model with the self-set agreement rule underneath), every knob and its value, the readings that failed and why, and what integration into the app needs. Read this before touching any of it. The chronological lab notebook, with every number, is branch-memory/grain-sandpit.md.

1. The question and the scoring

Given a scanned board (the stitched composite, 2 px/mm), say for every 20 × 15 mm cell whether the grain there is straight or figure (cathedral), and find the cathedral hearts and centrelines so parts can be placed on the figure and oriented to the grain. The owner marks the truth by hand on the sandpit page: right-click a cell or drag a box and paint straight, figure, fat blob (figure; kept apart as its own class so a blob reader can be assessed) or washed out (a poor photo: not scored). Unmarked cells count as straight. The score is cells right: the share of scored cells where the rule's call matches the mark, pooled over the five Walnut TV boards (XX-4.3-020 board 1, XX-4.3-021 board 2, XX-3.2-022 board 3, XX-3.1-023 board 4, XX-3-024 board 5; 7725 scored cells on the marks of 2026-09-12 evening). A cell is right or wrong; a one-cell miss at a zone edge costs the same as calling a whole cathedral straight, so the owner reads the disagreement map as well as the number (board 3's disagreement is mostly edge and washed-out wood, “better than the number suggests”).

The final table (2026-09-12, retuned marks, the pinned map):

cells right, %board 1board 2board 3board 4board 5pooled
v4 (the wobble rule on the app tracer's lines)86.780.269.090.991.282.9
v16 (the combined reading, self-set level)94.086.269.487.788.685.3
heart zones alone95.582.266.689.888.584.5
v17 = heart zones or v1694.088.278.087.786.187.2

The heart zones alone paint no false figure at all on boards 1, 2 and 4 (three cells on board 3, 41 on board 5). Board 5's photo is washed out and the owner calls it inconclusive; board 4's v17 false figure (168 cells) is all from the v16 half. Held-out honesty for v16 (settings chosen on four boards, scored on the fifth): 80.8 against v4's 81.5 on the marks of that morning — the self-set level is the robust part, the split and span wobble between boards. v17 has not been held out yet: its heart knobs came from the owner's eye on three boards, not from a sweep.

2. The image pipeline (the “fingerprint” approach)

Every reading starts from the same stages, all in sandpit/grain-lab.js, each cached on the scan object by its options:

  1. Normalised grey grainNormalisedGray(sc, {ppm, win}): the composite at 2 px/mm, each pixel expressed as (grey − local mean) / local spread over a win mm square (20 mm for lines, 60–70 mm for the fat blobs so a 30 mm band is not flattened). Negative = darker than its surroundings.
  2. Orientation field grainOrientField(sc, {sub:4, blur:2}): the structure tensor per sub-cell (the grid cell divided by sub in both directions: 5 × 3.75 mm), giving the grain angle and a coherence. Trap: the field has exactly sub × sub sub-cells per grid cell; indexing it by pixels-per-sub-cell reads the wrong rows (a bug found 2026-09-12 that silently zeroed the lower rows). Its angle is noisy at sub-cell scale: mean |angle| is 12–23° in straight rows as much as in figure, so it cannot bound a zone.
  3. Line spacing grainLineSpacing(sc): per 20 mm block, the autocorrelation of the grey profile across the grain, strongest peak between 1.5 and 16 mm; blocks with no period take the board median (raw keeps the zeros). It finds no period in 60–80 % of blocks on the straight boards, so it is not a “plain” detector.
  4. Enhanced grey grainEnhancedGray(sc): the fingerprint enhancement — a Gabor filter separated in the rotated frame (pass 1 averages along the local grain, pass 2 a cosine across it at the local spacing), DC restored only where the raw grey is very dark. The owner: “the fingerprint image looks great and accurate to the grain”; “using the enhanced map is the key”.
  5. Ridge map grainRidgeMap(sc, {threshold, darkCores}) (mode ‘core’): the enhanced cores (local minima across the field normal, 0.05 tolerance, dilated, thinned by Zhang-Suen, spurs and specks removed, nodes by crossing number, branches traced) plus, when darkCores is on, a second reader (Steger on the raw grey normalised over 60 mm) merged as LINES: enhanced lines first, a dark line kept unless a long enhanced line lies within 0.5 mm. The darkness floor threshold (the header's “darker than”: 0.3 = everything the enhancement shows, 1.0 = only the dark dashes) and the “+ raw dark” switch are the two dials. The approved map for the rules is enhanced alone, darker than 0.61 (2026-09-11: “short straight logical lines”).
  6. Ridge lines grainRidgeLines(sc, {gap:0, threshold, darkCores}): the branches joined through the forks (gap 0; a gap > 0 bridges along the field, which the owner found “makes it worse”), in mm, y from the top. Since 2026-09-12 the lines take their own map (threshold, darkCores) and key their cache by it.
  7. The pinned rule map GRAIN_RULE_MAP = { threshold: -0.61, darkCores: false }: v16, the self-set level, the hearts and v17 read the ridge lines at this map whatever the header dial shows. Before this the owner's tab at “darker than 0.01” showed v17 at 68 on board 2 where the scored rule reads 83. The view layers (ridge map, ridge peaks, flow, radius, trend) still follow the dial.

3. The rules, in the order they were found

v4 — the wobble rule (grainCellsV4). On the app's own traced lines (Line art): each line cut into 80 mm chunks stepping 40, a straight fit per chunk gives its tilt; per cell the mean tilt of the line in it (12 mm of line needed); the wobble is the standard deviation of the row's tilt over 600 mm either way, the rows above and below included; figure when the wobble is 3° or more, or a chunk bends hard from its fit, or a cross-grain line runs through; then fill-and-settle (a plain cell takes its neighbours' side, every cell the majority of ±10 columns × ±1 row, twice — worth five points, the owner marks bands not speckle). Switched off after scoring lower: the noise floor against the calmest rows, detrending the tilt's drift, the across-the-width band. 81.5–82.9 pooled; the mark every later rule had to beat.

v16 — the owner's combined reading (grainCellsV16; 2026-09-11 evening: “thick long lines should hold a lot of weight … the shorter lines should be considered as a zone … cathedral zones have turnback lines … a very slight smooth then do the radius check”). On the pinned ridge lines, votes in mm of line per cell: lines ≥ split (150 mm) vote by their radius after a smooth of sm points (16) over ±span (50 mm) against R (1500 mm: tighter = figure), weighted ×wLong (1); lines under the split vote as a zone by the fragment agreement (grainFragAgreement: each piece's chord direction, doubled, length-weighted, summed over ±100 mm × 3 rows; the circular spread in degrees is the disagreement); scattered beyond the agree level = figure; turn-backs and meets add figure at tb mm each (0: they never helped); then fill-and-settle. The self-set level (grainSelfLevel) was the turn of the key: agree = 3.0 × the board's tidiest tenth (the 10th percentile of the cells' short-piece spread, read from the pieces under 100 mm whatever the split). The straight boards' tidiest tenth is 2.4°, the figured boards' 1.4–1.8°; no fixed level served both (the fixed-level grid of 2916 settings never beat v4; the self-set grid of 1458 had 92 settings over it). Sensitive to a tenth of a degree of level.

The hearts — the owner's model of a cathedral zone (grainHearts, 2026-09-12): “the Heart is the centre and the cathedral zone grows from it. you can have multiple hearts within a cathedral zone, these are normally connected to each other. in between the hearts there is a small area that is typically a bland featureless patch. a Cathedral Zone is normally long compared to its width … some non straight grain areas are cut so you cant see the heart.” Built as:

  1. Hearts = the fat blobs (grainFatBlobs: the dark-patch finder grainDarkPatches extended and shaped — the raw grey normalised over win mm, pixels darker than dark local spreads grown into patches, kept when their 75th-percentile width along the patch ≥ minWidth, their length ≥ minLen and widest/length ≥ aspect) at the owner's heart setting: win 10, darker than 0.25, width ≥ 19, length ≥ 20, fat ≥ 0.02 (“AMAZING at catching the very Heart of a cathedral zone and nothing else”). Each patch's outline is traced round its pixels (Moore neighbourhood, grainTraceOutline); the older column envelope closed each end with a vertical line.
  2. The false-heart filter: a heart is false when, within 200 mm either side (150–200 both work; its rows ±2), half or more of the cells read straight by v16 (“a Heart will have a minimum of 400 mm to the left and right before it turns into full board width straight grain”). Swept 50–400 mm: 150–200 at 50 % caught all three the owner called false and four more he confirmed; 50–100 mm saw only one; 300–400 pulled in real hearts. Kept 27/29, 34/36, 17/17, 14/15, 10/12 on boards 1–5.
  3. Chains: kept hearts link when their gap along the board is under 1500 mm and their rows within 80 mm. 500 mm split board 3's cathedral at its plain patch (hearts at 1000 and 2300, “1300 apart, this is common”). The plain patch between hearts reads exactly like straight grain to every reader (line per cell, enhanced contrast, agreement all normal) — only its position between two hearts marks it, and the chain rule is what carries that.
  4. The zone: the band from a chain's first heart to its last, the chain's rows ±2, then fill-and-settle. Growing the zone past the end hearts by the orientation field's tilt (rule 5 of the model: “the zone runs on while the lines are still arch limbs”) was built, debugged and switched off: the field's tilt is too noisy to bound a zone (off scores best; the knob “tilt” = 99 means off). Rule 5 as the owner means it — follow the lines outward and stop where they stop bending — is still to be built.

v17 (grainCellsV17) = figure where the heart zones or v16 say so. The zones give the precision (no false figure on three boards), v16 the coverage (the cut cathedrals with no heart, and the figure between and beyond the zones).

4. Every knob and its value

whereknobvaluenote
rule mapridge threshold / raw dark reader0.61 / offpinned for the rules; the header dial is a view
v16split / R / span / smooth / long weight / turn-backs150 / 1500 / 50 / 16 / 1 / 0the self-set grid's best; split 150 means nearly everything is read as a zone
v16self-set level k / percentile / pieces under3.0 / 10th / 100 mmk chosen every time in the held-out test
heartswin / dark / width / length / fat10 / 0.25 / 19 / 20 / 0.02the owner's heart setting, by eye
heartsreach / straight share200 mm / 50 %the false-heart filter
chainsgap along / across1500 / 80 mmthe plain patch between hearts
zonerows either side / tilt2 / off (99)the band between the hearts, settled
settlecolumns / rows / passes10 / 1 / 2shared by every rule
gridcell20 × 15 mmGRAIN_COL_MM × GRAIN_ROW_MM

5. What was tried and did not work (so it is not tried again unknowingly)

  • Tracer-line methods before the fingerprint: dark streaks horizontal and vertical, at 30/40°, the angle bank (every 5° from −40 to 40 with a universal sensitivity), the second-pass follower on the bank's dashes, the chain cleaner. The owner: “forget the trace line method, there has to be other ways”.
  • Zone rules v6–v15 on the ridge lines: cathedral by merging lines, wispy erratic lines, parallel-and-straightish, straight over 200 mm (v11/straight 200), the trend over 200 mm with smoothing (v13, trend spread against the board's quiet level), the enhanced field's waviness (v14), the radius (v15: a second, looser setting in fragmented cells). Every one pooled below v4, with the same split: the figured boards want strict settings, the straight boards loose.
  • The fitted model: a 30-feature leave-one-board-out fit reached a held-out ceiling of 73.2, under v4.
  • Joined lines (bridging the ridge lines' gaps along the field): “make it worse”. Smoothed lines for the pieces: “you have to use the unsmoothed lines”.
  • The raw-photo dark-core reader added to the enhanced map: adds erroneous lines; off for the rules.
  • Fat-blob readings that do not separate the owner's blob cells from straight grain across boards: 2D chamfer thickness of the dark mask (dense straight grain merges into one region); band width across the grain as a run below zero (broad shading reads as a band) and at half depth (the blob's interior ripples read as thin bands; smoothed enough to lose them, everything reads broad); coarse-over-fine contrast (board 4's coarse straight grain is “fatter” than the blobs); the field's coherence, the enhanced amplitude, the spacing finder's no-period blocks; the relative width and gap along each ridge line. What distinguishes a blob is not width or contrast measured this way.
  • The extended fat blobs as a zone: at the owner's board-3 setting (win 70, darker than 0.25) the “blob” is the whole cathedral zone as one sheet 2939 × 125 mm, and the straight boards make sheets too (2600 × 30–45 mm); gating by tidy pieces, by a dark core inside, by a length cap, by distance from a heart (“within 60 mm” is the wrong metric, the owner), by a heart within reach: none beat v16. What separates the sheets is their width against the board (125 of 180 mm against 30–45) — a zone reading, untested.
  • The zone grown by the orientation field's tilt: see 3.4.

6. The sandpit tooling

sandpit/grain-sandpit.html on the app's server (the owner's tab on 8092; gates and the model's checks on the gate port 8093, never in the owner's tab): the board picker, the ruler, Line art, Zones, Grid; header layers with knobs (enhanced, ridge map ≥ length / darker than / + raw dark, joined lines, ridge peaks, straight 200, trend 200, radius, flow, hearts (reach, tilt), fat blobs (win, dark, width, len, fat), dark streaks, vertical streaks, angled, angle bank); the lab panel with the show selector (v3, v4, v6–v17, straight, trend), the v16 row (split, R, span, smooth, long ×, turn-backs, self-set level k), “Score this board” and “Score all boards” (every rule, per board and pooled, figure found / false figure / missed), the disagreement layer (red = it says figure and you said straight, blue = the other way), “show my marks”, “Fit on 4 boards, test on the 5th”. Right-click marks: straight / figure / fat blob / washed out, per cell, per column or by box; marks save to sandpit/marks/<code>.json on every change (PUT /sandpit/marks/<code>). Every knob persists in localStorage sandpit.bank; the camera in sandpit.cam. sandpit/grain-votes.html is the earlier Hough-vote method, kept. The images are the library composites (/sandpit/image/lib-<code>.jpg, 2 px/mm).

7. What integration into the app needs

  • The pipeline stages 1–6 and the rules v16, the hearts and v17 move from sandpit/grain-lab.js into the app's grain map build (they read the composite and the grid only; nothing in them needs the sandpit page). The rule map stays pinned.
  • The cathedral centreline is the chain through a zone's hearts (the axis the owner draws by hand today, F31); the straight grain angle for orienting parts is the pieces' mean direction in the straight cells (the fragment agreement's mean, or v4's row tilt). Both are to be shown on the sandpit before integration (owner, 2026-09-12: “confirming you can draw cathedral zone centerlines and reading straight grain angle so you orient the parts correctly. show me that and we are ready to integrate”).
  • Cost: the enhanced grey and ridge map take a few seconds per 4 m board at 2 px/mm on first read (cached after); the hearts' patch finder about half a second; the rules milliseconds.
  • Still open for a later optimisation: rule 5 (the zone's true ends by following the lines); the zone's height at the board edges (board 5's rows 0–2); the cut cathedrals with no heart (v16's job today); more marked boards (the only honest test of the k = 3 level and the heart knobs); the sheet's width against the board as a zone reading.

Applying scans to the model — the user interface (draft 7, owner 2026-09-12; the master text for Apply Scans)

Vocabulary

Visually Critical supersedes Show. A Part is Visually Critical when it is a nominated Solid Part, or when it is in a Grain Matched Group. Only Visually Critical Parts are assigned to scanned boards, automatically or by hand. Every Part is shown by its Part name; a Part placed automatically and a Part placed by hand carry no distinguishing name or label.

Project Scans tab

The Scan Timber tab is renamed Project Scans. It is where new scans are added and where the Library of scans is accessed. It is the one place a board with a scan enters a project. Every prompt below that asks for boards sends the user here. J27.9 (2026-09-17): superseded in part - picking a board from the Library is Desktop work and lives under Nesting + › Your Stock (the same Use in this project); Project Scans is where a board becomes real. J14 (2026-09-19): the tab is the yard flow - the boards still needed, your cart, Run, Keep / Put back, Accept - with the Library at its foot filtered to the project and ranked for the grain the design wants; the W8 prompts named below were retired by W10 into the readiness lines.

Apply Scans tab

  1. The Apply Scans tab is where scans are applied to the model.
  2. If no boards are assigned to the project, the first thing the user sees is a prompt: assign boards to the project, or scan new boards. Pressing it takes the user to the Project Scans tab.
  3. If boards are assigned to the project, they are used automatically, with no prompt. Any Visually Critical Part that has not found an appropriate grain zone on a board raises a prompt to add more boards to the project. Pressing it takes the user to the Project Scans tab. It keeps asking until every Visually Critical Part has a correct zone.
  4. A board assigned from My Stock On-Hand without a scan is ignored by Apply Scans.

Auto apply

  1. Auto apply places the Visually Critical Parts. Every other Part is ignored by Apply Scans and stays where Nesting + put it.
  2. Auto apply uses the straight and cathedral zone classifier (the grain record above). It goes through the boards assigned to this project and positions each Part by the grain it was given on the Apply Grain tab. A Cathedral part is rotated and centred on a cathedral grain area. A Straight or Rift sawn part is rotated to align with the direction of the grain on a straight grain area, while minimising waste.
  3. Relationships from Apply Grain are honoured: End, in sequence off one board, and Edge, adjacent rips in order.
  4. A board's auto placement can be adjusted by hand. Clicking the board's label on the 3D model offers manual adjustment, and choosing it opens the Layout Bench loaded with the current auto placement for that board.

Manually Place All Parts

  1. A Manually place all button sits on the main screen of the Apply Scans tab. This button throws away all auto apply logic.
  2. It opens the Layout Bench blank: the boards are there, no Parts placed, nothing of the auto placement remembered.
  3. From that press on, the project is placed by hand. Auto apply does not run again on the tab until the user asks for it.

The two manual paths

  • Manually place, all: the button above, the Bench blank.
  • Manually place, individual: the board's label on the 3D model, the Bench loaded with that board's auto placement.

Utilising the remaining board

  1. After the Visually Critical Parts have been placed on a board, automatically or by hand, the user has a button to utilise the rest of that board with Standard Parts, those that are not Visually Critical.
  2. Once placed, a Visually Critical Part is pinned. The app treats the rest of the board as a normal board and fills it with Standard Parts around the pinned ones.
Build notes, not decisions: the Bench already has a lock on a board's layout that the engine respects; pinning a placed Part is the same idea one level down, at the Part, and is built on that mechanism, not beside it - the user sees one word, pinned. The fill is ordinary nesting on the free area of the board, so the Standard Parts placed there stay Standard and can be re-nested; the pinned Parts do not move.
Why a scanned board measures short the physics, the tests, the residual

A board measured with a rule at 151.0–151.9 mm scans at about 149 mm. This is the record of what that gap is made of. Every number below was measured on the three real-photo fixtures and checked against the owner's own readings.

1 · The physics

The model maps each photo onto the board with a similarity transform: one rotation, one scale, applied identically in both directions. Four parameters. That is the assumption "the camera was held perfectly, square-on, over a flat board with sharp edges" written into the geometry. Reality misses each of those, and each miss is worth about a millimetre.

What is assumedWhat was measured
The camera is square to the board Roll up to 1.5°. The square-on row sits 28–109 px off the frame centre, consistently below it, which is a slight habitual pitch. A small lateral offset shows as a 0.8–3.0° asymmetry between the tape's two rims, the same sign in every photo.
The tape lies in the board's plane It does not. It is a curved blade sitting on the board, crowned a couple of millimetres proud. At a ~320 mm working distance that is worth roughly 0.9% of scale on its own: a ruler that floats above the thing it measures reads it short.
The tape is flat It is a trough. Tick marks printed across it swing from +15.6° at the top of frame to −13.5° at the bottom, crossing zero directly under the camera. The angle also changes 4–6° along a single tick, which no flat surface can do. The blade therefore projects narrow: it measures 28.2–29.0 mm against a true 29.00.
The board is flat and straight It is not. Its edge sweeps 11.3 px (about 2 mm) over 1.5 m in one smooth curve. The tape's steel edge in the same photo, through the same lens, wanders 3.0 px at random. Ordinary timber, not a defect.
Both board edges are sharp One is rounded over. A round-over presents no hard boundary, so the widest part of the board is never visible to the camera at all. Owner-confirmed: rule 151.45 mm, visible width 150.14 mm.
One scale serves both directions The numerals measure along the tape; board width is measured across it. The two differ by 1–6% in the same photograph, and the along scale itself varies about 5% down a single frame.

2 · The testing

Ten explanations were put up and tested. Nine were eliminated. They are listed because each one looked right before it was measured, and the list is what stops them being tried again.

ExplanationTestOutcome
The edges are found badly Strongest tonal step instead of a darkness threshold, against the owner's magnified reading Partly true. Agrees with his reading to 0.12 mm and cuts a 14.4 mm error to 1.4 mm. Board widths tighten from a 16–22 mm range per board to 1.1–6.7. Kept, behind a flag.
The scale is an average where a local value is needed Fit ppm(y) from the anchor gaps, convert at the measurement row No. The model measures at the span midpoint, and the average of a linear gradient is its midpoint value. Worth 0.15 mm. Reverted.
The scale gradient is linear Local tick pitch, windowed down the frame No. It rises, dips, then rises again. A straight line is the wrong shape.
Perspective — the tick fan gives the geometry Build a homography from the tick vanishing point No. Made the width worse (4.08% spread against 3.01%). The fan is the tape's curl, not the board's perspective, proved by the tick angle changing along its own length.
Lens distortion Fit k₁ to the bow in the board edges, then check the tape's straight steel edge in the same photos No. k₁ looked consistent (8.8–18.4×10⁻⁹) until the tape edge was checked: it shows no consistent bow, and the sign flips between photos. The bow is in the board. Correcting it would have injected a fake distortion to make curved boards look straight.
The blade is an across-axis ruler Calibrate the across scale from the 29 mm blade, hold out a whole fixture No. On the development set it landed at 151.65, apparently conclusive. On the held-out fixture the bias improved but the scatter tripled, because 152 ÷ 29 = 5.2× amplification: 0.1 mm of blade error becomes 0.5 mm of board error.
Cross-row consensus finds the hard edge Pool every candidate dark run, take the offset that recurs No. 6 of 8 wrong, errors to 59.9 mm. Grain runs along the board, parallel to the edge, so a grain stripe is exactly as consistent across rows as a real edge.
The tape is laid at an angle to the board Fit both edge tracks, take the angle between them Real but negligible for length. 0.03–0.43°, worth 0.04 mm over 1515 mm, because the error goes as θ². It is first-order for the tape's offset across the board (11 mm end to end), which the lay measurement already handles.
The tape floats proud of the board Compare scanned board length against the rule Untested. The model has no independent length measurement: the composite is built to the typed length. Settling it needs two pencil marks a known distance apart on a real board.
Use each 100 mm section's own tick spacing as the scale Local scale per hundred-mark pair, applied to that section's width Yes, partly. Recovers 0.62 mm of the deficit and is closer on 24 of 40 sections. Small, real, and the best of the scale ideas.

3 · The result

With the best measurement available, gradient edges with a section-local scale and outliers rejected, the deficit decomposes with nothing left over:

TermmmWhose problem
Rule width, measured over the full length151.45—
Round-over, never presents an edge−1.31 Physical. Not an error, and no measurement can recover it.
Owner-confirmed visible width150.14 What the camera can actually see, and the correct target.
Edge finder reads inside the true edge−1.04 Ours. The one term still worth attacking.
What the model measures149.10—

Judged against the rule the model is 2.35 mm short. Judged against what the photograph can show, it is 1.04 mm short, on a measurement whose own scatter is ±2 mm.

4 · Conclusion

There was never one bug. The deficit is a stack of small real effects, and two of the largest belong to the timber rather than the software: the round-over that cannot be photographed, and the board's own curve. Every attempt to close the whole gap with a single fix failed because no single fix was ever available.

The scale question is worth about 0.6 mm, and the section-local tick spacing captures it. It is not where the remaining millimetres are.

The binding constraint is now measurement precision, not physics. The board width scatters ±2 mm while the effects being chased are 0.6–1.0 mm. Nothing further can be learned about the geometry from these photographs until the edge finder is better than the thing it is measuring. That is the wall every one of the ten hypotheses hit, from a different direction each time.

What would move it, in order: a sub-pixel edge fit on the rounded side, which is the only remaining term that is ours; a mat that overhangs both board edges, since a darkness-based edge test cannot separate a board edge from the timber table beneath it; and a check on distance along the board, which nothing in the suite currently asserts even though it is the number a cut list depends on most.

Method note. Three separate conclusions were overturned during this work by looking at a picture rather than a number: the surround was the workbench, not a second board; the tick fan was the tape's curl, not perspective; and a bow fitted on the board edges vanished when the same test was run on the tape's steel edge. Where a measurement and a photograph disagree, photograph first.

How board scanning works today, and the capture-method decision. Hand-written 2026-08-20 (branch scanning-tab) directly from the live code — toggleScan/renderScanLoopStep (capture), locateTape/confirmTapePosition (tape), straightenUsingTape (straightening), confirmTrim (calibration), saveScanImage (the record) — not from memory. Same standing rule as Nesting Logic: if the scan code changes, this tab is updated in the same commit.
CURRENT STATE — 2026-08-27 (D20 complete). Capture is the in-app capture lab: Project Scans (then Scanned Images) ▸ + New Scan (or a stock row’s camera tick, which pre-fills the board’s dims and attaches the finished scan to that row). Still photos along the board with a tape on it → stitched, auto-trimmed onto measured edges, tape healed (v5.1 copy-paste-blend + LAY-FOLLOWING band + per-segment adaptive shadow extension), an adjustable crop box, ONE q35 encode → the Final Scan (~50 KB), named with its dimensions so it imports calibrated with no trim step. Slit-scan is retired and archived (archive/slit-scan-capture-2026-08-27.js, tag) — the sections below are the design record that got here, kept as written on their dates.

Summary

A scan is a photograph of a real board, calibrated so the app knows how many pixels equal one millimetre. That is what lets the board's real grain sit behind the layout diagram at the right size, and lets parts be positioned on the actual timber. Every scan ends up as one record — image, board dimensions, pixelsPerMm, pixelsPerMmY, and the stock row it belongs to — and everything downstream reads only that record.

Capture at the time of writing (2026-08-20) was slit-scan — retired 2026-08-27, see the banner above: the phone camera stays pointed at the board while it travels along it, and a narrow vertical slice of each video frame is appended to a growing strip. It works like a flatbed scanner, and it produces excellent geometry when the camera moves the way it assumes — straight along the board at a constant distance. A jig delivers that. A person walking freehand does not, which is why freehand results have been unsatisfactory.

Two independent things can go wrong, and they need separate fixes:

  • Cross-board wobble (Y). The board drifts up and down in frame during the pass. Corrected by straightenUsingTape, which tracks a tape measure laid along the board and shears each column back into line.
  • Along-board scale (X). Travel speed is measured and compensated every frame — see below. What survives that compensation is quantisation at very small per-frame motions, and any change in how far the camera is from the board, which is indistinguishable from speed. Straightening cannot help with either.

Every error mode, and what handles it

The single most useful thing to hold onto: moving faster or slower along the board is harmless; moving closer or further is not. Travel speed changes how many frames contribute but not how many pixels a millimetre is worth, so it cancels out. Camera distance changes the millimetre-to-pixel rate itself, so it doesn't.

Error mode Method Handled? Our solution How good
Along the board (X)
Scale along boardSlitYesPer-frame motion estimate (built) + sub-pixel accumulator (proposed)Strong
StillYes (immune)No motion; scale comes from rectificationStrong
Camera distance (Z)SlitYes (proposed)Calibrate per scan → guide lines → live tape-width correctionGood, untested
StillYes (immune)Each photo rectified independently, so distance normalises outStrong
Overall lengthSlitYesTrim to both board ends + known length (built)Strong — endpoints pinned exactly
StillYesSame, plus every photo independently anchoredStrong
Position along boardSlitPartlyAccumulates; ends pinned, interior rides on the Z fix. Cannot use tape readings — a slit smears printed digitsGood once Z lands
StillYes (proposed)User places two dots on legible tape readings and types them — on the PC, in post-processingStrong — cannot fail silently
Plain / low-texture timberSlitNoNone — holds the last speed, a guess. Tape ticks are the obvious alternative and they are exactly what aliased—
StillYes (immune)No motion tracking needed at allStrong
Board longer than bufferSlitNo12000 px cap, capture force-stops with no warning. 5 m boards are at real risk today— cheap to fix
StillYes (immune)No buffer involvedStrong
Speed outside 2–50 px/frameSlitPartly (proposed)Widen the range and warn liveWeak — detection, not correction
StillYes (immune)No clamps involvedStrong
Coverage gapsSlitYesContinuous capture (built)Strong
StillYes (proposed)Overlapping frames, deliberately more than needed, plus a visible checkGood
Across the board (Y) and framing
Wobble / straightnessSlitPartlyLive drift correction + two-pass straightening (built)Fair — owner reports not good enough
StillYes (immune)A photo is a single instant — nothing to wobbleStrong
Cross-board scaleSlitYes (proposed)Live tape width, instead of measuring once at locate timeGood, untested
StillYesFrom per-photo rectificationStrong
Perspective / tiltSlitYes (jig)A jig holds the camera square-on — freehand tilt stays uncorrectedGood — conditional on the jig
StillYes (proposed)Rectify from the board's own edges, tapped by the user on the PCStrong — exact for a flat subject
Board offset in frameSlitYes (proposed)Guide lines sized to the board's real width; user keeps it between them by eyeGood — prevention beats correction
StillYes (proposed)Same guide lines; less critical since each photo is rectified anywayGood
Glare hiding the tapeSlitn/aDoes not read the tape's digits anyway—
StillYes (proposed)Extra frames as insurance, a two-second glance at each shot on the phone, and an automatic glare flagGood — no feedback loop otherwise

The three shaded rows are the open ones, and two of the three are the same fix. A per-frame reading of the tape's apparent width gives magnification directly, which corrects the along-board rate and re-bases the cross-board scale that is currently measured once and assumed constant.

The solution in full

Worked out with the owner across sixteen numbered constraints on 2026-08-20. This section is the complete description — enough to build from without rediscovering any of it. A condensed copy lives in the governance comment block at the top of this file, under BOARD SCANNING — CAPTURE METHOD DESIGN, so a future session picks it up without reading the tab.

The problem, in one sentence

Scanning a board means producing a photograph in which a millimetre is the same number of pixels everywhere. The current slit-scan does that well on a jig and badly freehand, and the reason is narrow and specific: moving faster or slower along the board is harmless, but moving closer or further is not. Travel speed changes how many frames contribute without changing what a pixel is worth, so it cancels out. Camera distance changes the millimetre-to-pixel rate itself, so it does not. Every accuracy question here reduces to keeping those two apart.

What we decided

Two capture paths, converging on one record. Still photos become the default because most of slit-scan's failure modes exist only because its capture is continuous — remove the motion and they stop existing rather than needing to be solved. Slit-scan is kept exactly as built for anyone with a camera jig, because a jig enforces precisely the assumptions it makes.

Native panorama was rejected outright. A web page cannot invoke a phone's panorama mode at all, so the best available would be asking the user to shoot one in their camera app and import it — which returns a cylindrical projection from an unknown per-device stitcher. Straight lines curve and scale is unrecoverable, which fails the one criterion the owner ranks highest.

One photo is not enough for a 5 m board. Fitting 5 m across a frame means standing roughly 3.6 m back for about 0.8 px/mm — worse than the current slit captures, and few workshops have that clearance beside a 5 m bench. Three to five frames from about a metre give 2.4–3.4 px/mm. Multi-shot wins on resolution and floor space simultaneously.

Why this is cheap and safe to add

Both paths must produce the same scan record: image, board dimensions, pixelsPerMm, pixelsPerMmY, and the stock row it belongs to. Everything downstream — the grain behind the layout diagram, the attached-scan state, the detail view, positioning parts on real timber, the automatic board lock — reads only that record. So a new capture method is a new producer, never a change to any consumer. No frozen function needs editing. The rule that keeps it that way: divergence stays at capture only. Two representations leaking downstream would double the surface area of everything, permanently.

Division of labour: the phone captures, the PC decides

Nothing on the phone requires precision, and nothing on the phone is a judgement worth revisiting later. Corner-picking on a small screen while holding a camera at a bench is miserable; the same task on a large screen with a still image is trivial. So the split is absolute.

On the phone: open the project (same cloud project code as the PC); one tap to pick which board is being scanned; lay the tape along the board, always first; Find Tape, which also calibrates; capture; glance at each shot; save. That is three taps and a walk.

On the PC or iPad: trim to the board's ends; straighten (slit-scan only); rectify each photo; position each photo along the board; composite; apply the magnification correction from the capture log; review against a ruler overlay and nudge anything wrong; confirm.

Find Tape absorbs calibration

The user already drags two handles onto the tape blade's edges. That single act yields pixels-per-millimetre at whatever distance they happen to be holding the camera — which is all a calibration needs. So rather than adding a Calibrate button, Find Tape gains an effect: the guide lines immediately redraw to the board's real width, replacing today's fixed 20%/80% frame positions. One action, four jobs, no new step for the user.

Calibration happens once per scan, not once per device — it cannot then go stale. It is resolution-specific, so the frame dimensions must be stored alongside it and the calibration invalidated if they change; otherwise a browser or camera change produces a silent scale error.

The guide lines become a live instrument

Once sized to the board, the lines tell the user where the board's edges should sit. Keeping them roughly aligned while walking is holding the standoff steady — a manual Z control requiring no precision, just a glance. The tape-width measurement then supplies the actual number. Coarse prevention plus fine measurement: because prevention keeps deviation small, the correction is small, and small corrections are the reliable kind. Neither mechanism has to be perfect because they cover each other.

Note the lines control magnification, not distance. Those differ if the camera tilts — but magnification is exactly the quantity that affects scale, so tilt that doesn't change apparent size is harmless. The lines happen to control the right variable.

Positioning a photo: two dots

Still-photo path only. On the PC, the user places two dots on the photo wherever the tape is legible and types each reading. Two dots give position and scale for that photo, self-contained: pixels between them divided by millimetres between them is that photo's pixels-per-millimetre, measured in that photo, at whatever distance it was taken. Add the board's two edges (width known from the stock row) and that is a complete rectification built entirely from things the user pointed at — no character recognition, no edge detection, no feature matching. Nothing in the critical path can fail silently, because nothing in it is a guess.

Spread the dots as far apart as the glare allows. Click error is fixed at a couple of pixels wherever you put them, but its effect on scale depends entirely on separation. At roughly 3 px/mm a 2 px error is about 0.7 mm: over 1000 mm of separation that is 0.07% and invisible; over 100 mm it is 0.7%, which compounds to tens of millimetres on a 5 m board. The interface should push toward the two furthest legible spots and warn when they are close together.

A photo with only one legible reading can still be positioned, using the phone's calibrated pixels-per-millimetre for scale instead of the dot separation. That is a degraded path and must be visibly marked as lower confidence rather than blending in. Positioning a photo from its overlap with neighbours was considered and rejected — it is feature matching by the back door, on exactly the low-texture surfaces where it is least reliable, and it fails quietly.

Slit-scan cannot use any of this. A slit reconstructs anything printed along the direction of travel from many frames, so advance-rate error smears the digits and tape readings are not trustworthy in its output. Its longitudinal anchoring stays endpoints plus the magnification log, with no interior anchors available.

Glare, and the missing feedback loop

The user gets no signal at capture time and cannot easily retake once back at the PC — the board may be back on the rack. Three mitigations, in order of value. Take more frames than needed: photos are free, so a glared one is dropped rather than causing a return trip. Show each shot on the phone right after capture for a two-second human glance — not precision work, just "can I read the tape in that", and the user is better at that judgement than software. An automatic glare flag counting saturated pixels in the tape band: simple thresholding, no character recognition, held in reserve as an assist rather than built first.

Tape orientation now differs by method. Blank side up is cleaner for slit-scan straightening, because the printed digits were only ever noise to that algorithm. Printed side up is required for the photo path, because the digits are the positioning reference. That belongs in the user instructions rather than being discovered the hard way.

What has no answer yet

Both are slit-scan only, and both are honest gaps rather than oversights.

Plain, low-texture timber defeats motion estimation, and the code falls back to holding the last speed — a guess dressed as a measurement. The obvious alternative, matching against the tape's tick marks, is exactly what aliased badly and was removed. This is the strongest single argument that slit-scan cannot be the universal default.

The buffer cap is a hard-coded 12000 px, and capture force-stops there with no warning. At the ~2.4 px/mm real logs imply, 5 m of board consumes the entire budget before the overscan needed at each end for trimming — so 5 m boards are at genuine risk of silent truncation today. Raising it to around 20000 px is roughly 15 megapixels of canvas, well inside browser limits. Cheap, and worth doing whichever method wins.

Built and proven: the capture-lab sandbox

The still-photo path above is no longer just a design — it exists and has been run against a real board. It lives in capture-lab/, deliberately separate from this app: nothing there imports from here, nothing there can break this, and no frozen function is touched. When a path is proven it returns as a new producer of the existing scan record, never as a change to any consumer. Full detail is in capture-lab/README.md.

What it does. Phone mode captures and flags glare with zero taps — every blown-out pixel painted red so the user can see whether the glare sits on the tape. PC mode does everything else: two clicks on legible tape readings per photo (carrying position, scale and the board's angle), plus one click on the board's edge for the whole set. Each photo is rectified into board coordinates, contributes only its middle with seams at the midpoints between neighbours, is flat-field corrected along its length, colour-matched per channel against the median, cross-faded over 60 mm, and composited under a ruler drawn from the board's own dimensions.

Proven on synthetic geometry: three photos at −6°/+4°/−2° and scales 1.00/0.85/1.15 — datum recovered exactly, every scale recovered exactly, board-to-image round trips 0.000 px, and the composited tape flat at 89.75 mm across 13 probes spanning 2400 mm including both seams against a 90 mm truth.

Proven on a real board: seven phone photos of a 2500 × 215 mm walnut board. All seven placed, spans overlapping with no gaps, 11.5% scale spread, no outliers, tape datum 109 mm. The tape runs straight and unbroken through all six seams and its printed numbers land on the ruler ticks the whole way. Build time 216 ms. One photo carried a 30% colour cast (gains 1.33/1.48/1.73) which the per-channel match identified and corrected.

Two traps worth not repeating. Browsers apply EXIF orientation on decode, so phone photos arrive rotated — the board runs top-to-bottom and the tape is a vertical band. The geometry is orientation-agnostic because the two dots define the axis, but any coordinate taken from an upright view must be transformed first. And loading images in parallel pushes them into the array in completion order, not request order.

Still open: the warp is affine, not projective, so keystoning is uncorrected; and some tonal stepping remains because gains are computed from each slice's own mean over differing content — matching in the overlap, the same physical timber seen by two photos, is the proper fix.

Owner assumptions this rests on

Boards up to 5 m. The user walks freehand alongside a board on a bench with no jig, so the camera rotates and translates on every axis. Beautiful scans are not critical, because parts are only being placed roughly — but reasonably accurate scale is the highest priority. Processing must be quick. Scans are captured on a phone while 99% of the work happens on a PC or iPad. The project already syncs through the cloud, so the phone opens the same project. The user will happily use a tape measure, and lays it on the board first. And the user can distinguish the board's edge by eye even where software would struggle.

One consequence of the sync assumption is worth stating: scans are not currently in buildProjectPayload, and images should not simply be added to it. The whole project payload re-uploads on every save, so images belong in object storage with only references in the JSON — and should be downscaled on save to what rough placement actually needs, rather than stored at capture resolution.

Detail — the capture pipeline

  1. Camera (initCamera) — rear camera stream, torch enabled where supported.
  2. Locate the tape (locateTape → confirmTapePosition) — the user marks the top and bottom of the tape measure in the preview. The tape then does three jobs: it sets the cross-board scale from the blade's known width (pixelsPerMmY_fromTape = tapeHeightPx / tapeWidthMm, default blade 22.5 mm); it is the reference straightening tracks afterwards; and during the capture itself it corrects vertical drift, so hand wobble doesn't slowly slide the sampled strip onto a different band of the board.
  3. Capture (toggleScan → renderScanLoop → renderScanLoopStep) — each frame, the visible motion between this frame and the last is estimated (estimateShiftPx on a column profile) and that many pixels of the current frame's slit are appended to the buffer. Advancing on measured motion rather than a clock is what keeps a slow pass from stretching the image.
  4. Straighten (straightenUsingTape) — detail below.
  5. Trim & calibrate (showTrimHandles → confirmTrim) — the user drags handles to the two ends of the board; the app then sets the scale.
  6. Save (saveScanImage) — writes the scan record into the project gallery, named after its stock row when the scan was started from one.

Detail — how scale is actually established (important)

Cross-board (Y) comes from a real measurement: the tape blade's known physical width against its measured pixel height.

Along-board (X) is established in two stages, and the first one is easy to overlook.

During capture, speed is measured and compensated per frame. Each frame, the horizontal image motion since the previous frame is estimated (estimateShiftPx against a 240 px-wide column profile taken from the middle of the frame), and that many pixels of the current frame are appended: slitWidth = clamp(round(|shift|), 2, 50), then scanX += slitWidth. Move faster and a wider slice is taken; move slower and a narrower one is. So the output strip is in uniform spatial scale, not uniform time scale — the design already solves the basic "I slowed down and that bit stretched" problem, and solves it correctly. Motion is deliberately read from wood grain rather than the tape, because tick marks are periodic and block-matching against a repeating pattern can confidently lock onto the wrong tick.

Afterwards, confirmTrim converts that strip to millimetres by dividing: pixelsPerMm = finalWidthPx / physicalLengthMm, with the length taken from the Length field (prefilled from the stock row). Because the strip is already uniformly scaled, this is a legitimate conversion — and it has the useful side effect of normalising away any constant bias in the motion estimate.

What survives all that — the real residual error sources, in rough order of importance for freehand use:

  • Camera distance (the big one — but measurable). Motion is measured in pixels, but pixels-per-millimetre depends on how far the camera is from the board. Drifting closer makes the same physical travel produce more pixel motion, so more pixels get appended and that stretch of board is silently rendered larger. The estimator cannot distinguish "moved further along" from "moved closer in", which is the best explanation for why jig captures are good and freehand ones are not — a jig holds the distance constant.
    The tape solves this, and the measurement is nearly free. The tape blade is a known width (22.5 mm), so if it appears wider in frame the camera has moved closer, by exactly that ratio. The capture loop already crops a window around the tape band every frame and computes a row profile of it for drift correction — the blade's two edges are the two strongest transitions in that same profile. Today only its displacement is extracted; extracting its separation as well gives a per-frame magnification signal against the reference frame locked at the start. At typical framing the blade spans roughly 115 px (about 5 px/mm), so one pixel of edge error is under 1% of scale — comfortably enough to track real standoff drift. That signal corrects both axes: the along-board advance, and the cross-board scale, which currently is measured once at locate time and then silently drifts with distance for the rest of the pass.
  • Whole-pixel quantisation at very small motions. The slice width is rounded to an integer, and real captures run at only about 2 px per frame (a logged 3647 px capture over roughly 26 seconds averages ~2.3 px/frame). At that magnitude, rounding is a large relative error, and everything with true motion between about 1 and 2.4 px records identically as 2 px — a dead zone in which speed variation is flattened rather than compensated. The compensation works, but it is operating at the very bottom of its dynamic range. A sub-pixel accumulator (carry the fraction to the next frame) would recover most of this.
  • The floor and ceiling. MIN_SLICE_PX 2 and MAX_SLICE_PX 50 clamp the advance, so a pass too slow or too fast has its scale error frozen in rather than tracked.
  • Untrusted frames. When the patch is too low in texture for a confident match, the previous speed is reused (lastGoodSlicePx) — a held guess, not a measurement. Plain or figureless timber makes this more likely.

What the manual trim already anchors. Dragging the trim handles to the two ends of the board supplies two absolute positions, and the board's real length between them is known. That is a genuine constraint, not a formality: it pins the ends exactly and forces the total to come out right, so any cumulative error — a pass that ran uniformly fast or slow, or a constant bias in the motion estimate — is fully absorbed. Net drift over 5 m cannot survive it.

What two endpoints cannot do is constrain how error is distributed between them. Two anchors fix one linear correction exactly and say nothing about the shape in the middle. If magnification varied through the pass — walking closer to the board around the midpoint and back out again, say — the ends still land perfectly while the middle sits in the wrong place, and forcing the total to be right is what pushes that error inward. This is where the per-frame magnification signal above earns its keep: the trim fixes the linear term, the magnification log fixes the shape between the ends. The two are complementary, and together they close the loop.

Note finally what the tape is and isn't doing. It is genuinely load-bearing — cross-board scale, straightening reference, live vertical-drift correction — but it is never used as a ruler along the board's length: its printed graduations are not read. That omission is deliberate (matching against periodic tick marks aliases badly, as above). If the graduations are ever brought in, the lesson from that history is to read them as absolute position on a still frame, where there is no frame-to-frame matching to alias, rather than as a motion signal.

Detail — straightening (straightenUsingTape)

Every constant here was arrived at by measurement against real captures, and several intuitive "improvements" were tried and measured worse. The function's own comments carry the evidence; the headlines:

  • Detect on colour, not brightness. Wood figure contains streaks as bright as the tape, so brightness thresholding confidently tracked the wrong thing. Tape is bright and desaturated; wood is saturated at every brightness. The score is brightness minus saturation.
  • Narrow search window (35 px). A wide window let surrounding grain drag the centroid off the tape — narrowing it roughly halved residual wobble.
  • Do not smooth the detection. Box-blur and median filtering of the per-column detection both made results worse; they blur away real signal once detection is colour-scored.
  • Do smooth the resulting curve, lightly. A radius-10 median on the curve barely changes straightness but cuts column-to-column jitter by about 90%. That jitter was being applied as per-column shift and corrugating the grain. The window must stay symmetric near the edges — a one-sided window is systematically biased on a ramp.
  • Two passes. Pass 1 detects against a fixed band; pass 2 repeats with the same narrow window but re-centred on pass 1's smoothed curve, so a board that waves further than the fixed window can still be followed. (An earlier attempt re-centred on the raw curve and was measurably worse — that is what the comment near the window constant refers to. The two are not in conflict.)
  • Ignore the tape's end hook. The metal hook is far darker than wood and skews the relative threshold. It is identified as a long run of severe darkness, not by a per-column darkness limit — ordinary grain dips just as dark, but only for a column or two.
  • Shift by fractional pixels. Rounding each column's shift to a whole pixel chopped near-horizontal grain lines into steps.

A permanent numeric baseline exists for this function from the standalone prototype (tape-centreline standard deviation, all columns detected). Any change scoring worse on those captures is a regression, not a trade-off.

Detail — the scan record, and why it matters

Everything downstream — the grain background on a board, the "scan attached" state, the detail view, positioning parts on real timber, and the automatic board lock — reads one shape: { id, name, imageDataUrl, lengthMm, widthMm, pixelsPerMm, pixelsPerMmY, createdAt, stockId }. Attachment is a two-way link: the board carries scanId and the scan carries stockId.

Because that record is the only contract, a different capture method is a new producer of the same record, not a change to anything that consumes it. That is the cheapest and safest way to add one.

Capture-method decision (2026-08-20)

Owner's constraints: the user walks freehand alongside a board on a bench, up to 5 m long, with no jig; scans are captured on a phone but 99% of the work happens on a PC or iPad; rough part placement is fine but scale accuracy is the priority; processing must be quick; a tape measure in shot is acceptable.

Native panorama was rejected. A web page cannot invoke a phone's panorama mode at all — the best available is asking the user to shoot one in their camera app and import it, which returns a cylindrical projection from an unknown per-device stitcher: curved straight lines and no recoverable scale.

Two capture paths were planned, converging on the same record (2026-08-27: the still-photo path won outright — slit-scan is archived, D6/A5):

  • Still photos (default). Three to five ordinary photos along the board with a tape in frame, each rectified independently on the PC/iPad. A still has no motion to go wrong, so freehand rotation and wobble stop mattering. Each frame is anchored by the tape, so errors cannot accumulate down a 5 m board. One photo is not enough at that length: fitting 5 m in frame means standing about 3.6 m back for roughly 0.8 px/mm, where 3–5 frames from about 1 m give 2.4–3.4 px/mm.
  • Slit-scan (for a jig). Kept as built and offered as the higher-quality path for anyone whose camera is held on a jig or sled — continuous coverage, no seams, no per-frame input. Its assumptions are exactly what a jig enforces.

For speed, the intended split is that the app finds the tape and derives angle and scale from the periodic graduations (measuring their pitch, not recognising digits), while the user supplies each photo's position along the board. Character recognition and frame-to-frame feature matching are both slow, library-heavy, and — worse — capable of failing silently; a typed number cannot be quietly wrong.

Field-proven — evening session, 2026-08-20

The still-photo path was tested live at the bench on a 152×1515 mm board and is now proven on real hardware: five casual phone photos → one confirmation tap → a dimensionally true composite. Length agreed with the tape measure within ~0.3%, width within 1 mm, scale spread between photos 1.9% (the morning freehand baseline was 12.7%), colour cast 6% (was 30%).

The capture flow: phone upright, board running up-and-down the screen; press Calibrate at this height wherever the arm is comfortable (scale from the tape's tick pitch; blade width only as fallback); keep the board's edges on the held guide lines; shoot with generous overlap; Send photos to the PC over the workshop network.

Corrections applied per photo, each measured before it was built: rotation from the printed numerals; the tape's curved lay (its centre-to-edge distance measured at every photo, each corrected with its local slope — only measurements whose implied board width matches the known width are believed); keystone from phone tilt, per 25 mm strip, measured free from the anchor spacing (consecutive red numerals are 100 mm apart by definition) and never extrapolated beyond the span it was measured over; flat-field and per-channel colour matching.

The red-box test view (the owner's design): the composite renders 10% margin per side with a red rectangle at exactly the claimed board — a wrong length, width, or tape position is visible at a glance. It caught a wrong length input on its first build.

Still open: overlap-based colour matching (mild tonal steps remain); the board-start photo's confirm UX; running the whole pipeline on the phone; app integration as a new producer of the existing scan record. Full detail: capture-lab/HANDOFF.md.

Export a copy of this app
Writes the whole page to Grainwise Cutlist (standalone).html, governance comment block and all, so an exported copy carries the rules with it. Not a project export — projects go to the cloud under their name (File ▸ Open a saved project).
Export HTML
Architecture overview plus a live outline of every named code section (// ==== markers), read straight from this page's own source — never a separate doc to fall out of sync.
Loading…
Task/feature completion list, exported from Claude Code's task tracker (dev-progress.html, hand-copied — the tracker itself only exists in Claude's session state, same pattern as Branch History).
Only visible in dev mode (add ?dev=1 to the URL once to turn it on, ?dev=0 to turn it off — the flag persists in this browser). Each button in Test Summary replaces your current stock/parts with a stress-test scenario, runs a recompute, and runs every diagnostic check automatically (overlap, zone sizing, legibility, geometry fidelity, board-width bounds, label/geometry match, leading-gap justification, audit cross-check) — nothing is checked by hand or from memory. This overwrites your current project in memory — nothing is saved until you click Save, so reload the page (or click Restore below) to get back to what you were working on.
Your verdict is how you tell Claude what to do next: run a test (or Run full suite), look at the report below, then click Pass or Fail here. Pass → Claude keeps going. Fail → Claude stops and asks what's wrong before touching anything. Re-running a test clears its old verdict, since a fresh run is a fresh judgment. "Computed" is Claude's own mechanical read of the same diagnostics — always derived live from the actual last run, never hand-written, so it can't go stale.
Full Regression Test Checklist — every task Claude is meant to complete when running a full regression test, numbered so a chat sticky-note can confirm each one by reference number (see FULL REGRESSION TEST CHECKLIST in AI Management ▸ Principles for the sticky-note format). Source shows which memory layer documents that task; "Last read" is the last time Claude actually re-applied it, not just when it was written.
Scanning Regression Test — requirements (owner, 2026-08-27)
The nesting engine has had a regression test since detailing-testing-workflow — 11 scenarios, 33 runs, permanent baselines. The scanning model has never had one. After the 2026-08-27 field session the capture chain measures to within a millimetre of a rule, and nothing anywhere checks that it still does. Six real defects were found that day by photographing real boards; every one of them is replayable, and every one of them can come back silently. Status: requirements agreed, design under discussion, no code written.
The two tests differ in kind, not in size. Partial never touches the phone, so it can run on demand, every time, unattended. Full needs real timber, a real tape and the phone's camera, so it is a scheduled event with a human in it. Neither substitutes for the other — Partial cannot prove a camera works, and Full cannot be run often enough to catch a regression the day it lands.
#RequirementWhat it meansWhy it is stated this way
1 Scanning Partial Regression Test A series of tests that does not involve using the phone. Runs from stored inputs, in a browser, with no camera and no timber. This is the one that can gate a commit. Anything requiring the phone cannot run often enough to be a gate.
2 Scanning Full Regression Test Full end-to-end test including taking real scans of timber — the phone, the camera, a real tape on a real board, through to the finished scan. The only test that exercises the camera, the lighting, the grain and the owner's own hands. The 2026-08-27 field session was one of these, run informally.
3 Claude recommends, the owner decides Claude recommends Full or Partial for any given moment. The owner makes the final decision. Same shape as the existing Your-verdict rule in Test Summary: Claude's read is mechanical, the judgment is the owner's.
Partial — the three parts, from the owner's own notes. The “What it tests for” column is Claude's answer to the owner's “This tests for ???”, and is open for correction.
#InputWhat it tests forWhat it CANNOT test
P1 Synthetic test inputs — generated images with a drawn board and drawn tape, plus a table tracking test results The arithmetic and the geometry. Because the image is generated, ground truth is known to the pixel — there is no measurement error anywhere in the comparison. This is what proves the datum derivation, the frame and strip geometry, the composite assembly, the scale in px/mm, the auto-trim, and where the heal band lands. It is the direct analogue of Pure Math in the nesting suite: a deterministic answer that is either right or wrong. It would have caught the width ratchet, the 2× datum, the off-canvas strips and the black composite. Anything about real images. A generated board has no grain, no glare, no blur, no perspective and a perfectly legible tape. Analysis that passes here can still fail on wood.
P2 Stored real photos with known results — previously captured, prepared with lines or markers to simulate the phone scan The image analysis against reality. Real grain, real glare, real perspective, a real tape. This is what proves analysePhoto, readNumerals, the edge hunts, tape detection, the tape-based board straightening and heal quality. Ground truth is the owner's rule measurement. It is the analogue of Real Job 1: the only scenario taken from real life. It is the ONLY partial test that can catch K1 (guides under-read by ~5mm), K2 (the far-edge hunt locking onto the tape) and K3 (an absurd 52mm width on a 152mm board reported rather than refused). The camera itself, the capture loop, torch and exposure, and whether the phone can hold the guides steady. Those are Full-only.
P3 Results in a new Test Bed tab — including screenshots, plus a summary table tracking historical results That a regression is visible rather than merely detected. Screenshots because a scan is a picture and a number cannot describe a bad one — the standing rule already requires a real screenshot when discussing diagram quality, and the same reasoning applies here with more force. History because a slow drift over five runs is invisible in any single run; the packer's Regression Trend exists for exactly this reason. —
One open point on P2, raised by Claude for the owner's decision. Drawing the guide lines onto the photo makes it self-contained, which is the appealing part. But the live model never reads guides out of an image — at capture it records them as numbers (p.guides = {a, b, w, bladeMm}) and datumFromGuides() reads those numbers back. A test that detects painted lines would be testing a code path the app does not have. Proposed resolution: do both. Burn the markers in so a human can see what the guides were, and carry the same numbers in a small sidecar file so the machine replays exactly what the phone did. One artifact, two readers.
Why a sidecar is needed at all — the finding that shapes this work. btnSendPc in the capture lab sends p.blob and nothing else, so guides, an and read never leave the phone. Replay a bare photo and datum is null, frameOf() and stripFrameOf() both return null at their first line, nothing places, and the composite is black with no message anywhere. The IndexedDB store already persists all three fields — there is simply no export path. That is why a fixture format comes before the test itself.
TestDescriptionResultComputedYour verdict
Regression Trend (all 3 nesting modes)
Runs every scenario above against all three nesting modes (Balanced, Pure Math, Ripping Preference) — 33 runs total — and appends the results as one dated batch to a persisted history (localStorage), so mode-over-mode and run-over-run quality can be tracked over time instead of only ever seeing the latest single run. Each scenario run reuses the exact same pipeline as "Run full suite" above (same diagnostics, same pass/fail rule), just repeated per mode.
Live — every card below is generated straight from the actual last run of that test, including a real capture of the app's own #diagramContainer output. There is no separate hand-copied document to fall out of sync: this is the report, regenerated the instant a test finishes. Each nesting mode keeps its own set of cards — running a test in one mode never overwrites another mode's results, so all 3 sets stay available to compare.
The Scanning Partial Regression Test — a series of tests that does not involve using the phone. Two halves. P1 generates its own photographs, so ground truth is known to the pixel: that is the arithmetic and geometry, the analogue of Pure Math. P2 replays real photographs of real boards through the real analysePhoto and readNumerals: that is the image analysis against reality, the analogue of Real Job 1, and the only half that can catch K1, K2 or K3.
The benchmark is the owner's own eye. Where a fixture carries confirmed edges, they were read off a 6× magnified strip of that fixture's own photograph with a 1 mm scale printed on it — not a measurement the software made. Those fixtures compare the model to the board; the rest can only compare it to its own recorded past, and say so.
The Scanning FULL Regression Test is a different thing and is not run from here: it is end to end, including taking real scans of real timber, and it needs the owner and a board. Per GATE BY WHAT CHANGED, a scan branch merge owes BOTH.
The baseline is hardcoded and compared member by member (owner, 2026-08-27: “can you put the test into hardcode”) — BASELINE at the top of capture-lab/scan-tests.js. Same standing as the packer's 11/11 and 33/33 with its EMPTY fail set, and for the same reason: a count alone is worthless, because 9 of 10 tells you nothing if a different one is failing. Never re-baseline downward. An extra failure is a regression to root-cause; a failure that disappears is news that has to be explained before it is accepted.
Baseline — 12 of 13, failing exactly: p2:board-152-tape60-parallel — the K1 residual, tape offset 72.2 against a rule-measured 74.5. A real defect held still so a second cannot hide behind it. History: 7 of 10 on 2026-08-31 with s3 (K4, fixed the same day) and p2:board-152-tape60-rotated (the datum measured from the opposite edge, fixed 2026-09-01); 9 of 10 on 2026-09-01; 10 of 11 on 2026-09-26 when p2:board-1515x152-tape-beside-20 joined (the tape beside the board); 11 of 12 on 2026-10-08 when p2:board-1515x152-fatmax-beside joined (the owner's Stanley FatMax, imperial); 12 of 13 the same day when p2:board-1515x152-fatmax-1353 joined (his 13:53 photos driven through the phone's real path) — each step owner-approved and recorded above __GATE_BASELINE.
The only panel in this section that ASKS THE SERVER. The other five are hand-written notes with a date on them; this one reads your actual rows and your actual bucket every time you open it, as you, through the same owner-locked policies the app uses. Stage 1 is read-only — it can tell you what is there and what does not add up, and it cannot change anything. Deleting needs SQL that does not exist yet; see the bottom of the panel.
Open to read your cloud.
Rendered from cloud/tiers.json, which is the single definition of the three tiers. The numbers used to live in four places — this panel, the rollout record, a constant in the code and the landing page — and none of them was authoritative. Change a price or a cap in that file and everything follows, here and in the Dev Console.
Open to read the tiers.
Live, like Manage beside it. Everything here is read as you, through the same owner-locked policies the app uses. Stage 1 is read-only. A tool that could see or change ANOTHER person’s account cannot be built in this app at all, and the reason is at the bottom of the panel — it is a design decision, not a missing feature.
Open to read your account.
Read from the owner’s Supabase dashboard on 2026-08-25 and cross-checked against the live API. Usage figures are a snapshot of that billing cycle, not a live feed — re-read the dashboard before relying on them. Accounts landed 28 Aug 2026 (phase 3): the Auth, Table and Auth-email rows below reflect the new world; see the Users sub-tab for the full account system.
ItemValue
ProviderSupabase (Postgres + REST), served through Cloudflare
OrganisationCornerstone Furniture — wvwiyblrpvakgsxqpivq
PlanFREE, $0.00/month
Project refzqbujmfvotmyhtierczu
Endpointhttps://zqbujmfvotmyhtierczu.supabase.co
Region / computeAWS ap-southeast-1 (Singapore) · NANO
Tablecutlist_sync — user_id (the owning account), code (the project name you type) and data (the whole project as JSON). Unique per (user_id, code), so two customers can both have a project called “Hall Table”.
AuthEmail + password accounts with verified email (phase 3, 28 Aug 2026). The publishable sb_publishable_… key still ships in this file but is now only the doorbell: every cloud read and write also needs a signed-in user’s token, and the database owner-locks each row to its account. The key alone can do nothing — anonymous access has zero policies.
Auth emailsSent as Grainwise Cutlist <grainwise-noreply@cornerstonefurniture.com.au> through the owner’s hosting: host secure251.inmotionhosting.com, port 465 (custom SMTP, 28 Aug 2026). The mailbox lives in cPanel. Never point SMTP at mail.cornerstonefurniture.com.au — that name resolves to the web front, not the mail server, and hangs.
Free plan — limits and usage at 2026-08-25
AllowanceUsedLimit
Database size26 MB500 MB
Egress0 MB5 GB (+5 GB cached)
File storage (Supabase Storage — separate from the database)0 GB1 GB
Monthly active users050,000
The 26 MB is not our data. Every row this app has ever saved totals 33 KB (measured across all five projects). The 26 MB is Postgres’ own baseline — system catalogs and empty overhead — so the usable headroom is roughly 474 MB, and we are using 0.007% of it.
Two free-plan rules that bite this app specifically: free projects are PAUSED after 1 week of inactivity, and there is a limit of 2 active projects. A paused project means cloud Save and Load fail until it is resumed from the dashboard — which is a real risk for a sync used occasionally, e.g. after a fortnight in the workshop.
If the free plan is outgrown
PRO is from $25/month: 8 GB disk per project (then $0.125/GB), 250 GB egress (then $0.09/GB), 100 GB file storage (then $0.0213/GB), 100,000 MAU, daily backups kept 7 days, email support — and no inactivity pausing. TEAM is from $599/month and buys SOC2/ISO 27001, SSO and 14-day backups, none of which this app needs today.
The account system as BUILT and PROVEN on 28 Aug 2026 — the full signup→email→verify→sign-in chain has completed end to end on the production database (rollout record K6, closed). Decisions behind it: D13 (verified email + captcha before any storage), D25 (cloud has zero anonymous surface; Demonstration Mode for browsers), D28 (a third-party failure in this chain is OUR defect).
How an account comes to exist — the signup chain
StepWhat happens
1. Sign upGear menu ▸ Account ▸ Sign up (email + password). The captcha slot is built in; it activates the day the Turnstile keys are pasted into the dashboard. No account data is stored in this file — the request goes straight to Supabase auth.
2. Verification emailArrives within seconds from Grainwise Cutlist <grainwise-noreply@cornerstonefurniture.com.au>. Until the link is clicked the account exists but cannot sign in — that refusal is deliberate and was watched happen live (D13).
3. Click the linkSupabase stamps email_confirmed_at on the account and redirects to the app (the Site URL in Auth ▸ URL Configuration — must be updated at go-live).
4. Sign inThe app stores a session token in this browser (localStorage; refreshed automatically). Cloud Save and Open both require it — there is exactly one gate in the code (requireCloudSession), so nothing can drift.
5. Behind the scenesThe database trigger handle_new_user auto-creates the account’s profiles row. The user cannot edit or forge it — only the trigger writes there.
The user database — three tables
TableHoldsWho writes it
auth.usersEmail, hashed password, email_confirmed_at, last_sign_in_at. Supabase’s own table — the app never touches it; the dashboard and SQL editor can.Supabase auth only
public.profilesOne row per account: id, email, created_at. Deliberately minimal — phase 4’s usage accounting (tier caps) adds its columns HERE, not to auth.users.The signup trigger only
public.cutlist_syncThe projects: user_id, code, data (whole project as JSON), updated_at. Unique per (user_id, code).The owning account only (RLS)
The protections, in one breath: owner-only row-level security (an account reads and writes only auth.uid() = user_id rows); anonymous access has zero policies; 20 writes per user per minute (a save-spamming script is stopped, and one busy user can no longer lock out the rest); 1 MB per row; and no DELETE policy exists for anyone — the app never deletes a project, so no key or session can either. A row with user_id NULL (unclaimed) is invisible to everybody — the safe failure mode.
Deleting a USER is the one cascade: removing an account from the dashboard permanently deletes its profile and every project it owns (on delete cascade). That is the only path by which project rows die — treat it accordingly.
Managing users — the admin playbook
There is deliberately no admin role inside the app — every account is equal and owner-locked, so the app has no privileged door to pick. All administration happens in the Supabase dashboard (the owner’s dashboard login IS the admin login), project zqbujmfvotmyhtierczu.
TaskWhere / how
See or search all usersAuthentication ▸ Users — email, confirmed or not, last sign-in. The row’s “⋯” menu holds the per-user actions.
“I never got the email”First check Logs ▸ Auth: a 504 request_timeout on /signup means the SMTP host is unreachable (that exact failure happened on commissioning day — wrong host). If sending is healthy, have them Sign up again with the same email: for an existing unconfirmed account that re-sends the verification email. Then their junk folder.
Password resetSelf-serve since 28 Aug 2026: the sign-in form’s “Forgot password?” link emails a reset link; clicking it lands back in the app, which opens a set-new-password screen and signs the user in when they set one. The dashboard user row’s “Send password recovery” triggers the identical email by hand. An expired or reused link reports itself in the Account panel instead of failing silently.
Remove a userDashboard user row ▸ Delete user. Permanent, and cascades to their profile and ALL their projects — see the cascade warning above.
Email sending healthAuthentication ▸ Emails ▸ SMTP Settings. Sender mailbox lives in cPanel (Email Accounts). Host must stay secure251.inmotionhosting.com:465.
Inspect the dataSQL Editor — the two snippets below cover the routine questions.
-- every user, with how many projects each owns
select u.email, u.email_confirmed_at, u.last_sign_in_at, count(s.code) as projects
  from auth.users u
  left join public.cutlist_sync s on s.user_id = u.id
 group by u.id, u.email, u.email_confirmed_at, u.last_sign_in_at
 order by min(u.created_at);
-- one user's projects, newest first, with row sizes
select code, updated_at, pg_column_size(data) as bytes
  from public.cutlist_sync
 where user_id = (select id from auth.users where email = 'their@email')
 order by updated_at desc;
Capacity, and what is NOT built yet
Capacity: the free plan allows 50,000 monthly active users — accounts cost nothing until real scale. The binding limits are the ones on the Account & Limits sub-tab (database size, the 1-week inactivity pause).
Not built yet, owed before/around launch: an in-app “resend verification email” button (the passwordless endpoint exists and is proven — it just has no button); Turnstile CAPTCHA keys (D13 — slot built, keys pending); sender authentication so hotmail drops its “can’t verify the sender” banner (SPF/DKIM — rollout record A9); phase 4 usage accounting on profiles for the paid tiers.
What one cloud row actually contains, and what each candidate addition would cost. Every size here was measured on the owner’s real TV cabinet project, not estimated.
ContentsPer projectProjects before 500 MB
Stock, parts, panels, FLPs, grain groups, the computed layout~10 KB~50,000
+ the lite 3D model (added 2026-08-25)~28 KB~18,000
+ 4 scans as PNG (today’s format)~20 MB~25
+ 4 scans as JPEG q85, same resolution~2.6 MB~190
The lite 3D model is genuinely lite. persistModelImport() has always stored a compact loft rebuilt from the detected outlines, never the original tessellation. Measured on the 25-body TV cabinet: 18 KB, 200 vertices, 300 triangles for the entire cabinet. The same liteModelImportPayload() builds both the local save and the cloud copy, so they cannot drift apart.
Scans are the only heavy thing — but a Final Scan is 100× lighter than a legacy one. A capture-lab Final Scan arrives as ONE q35 JPEG (~50 KB, measured 2026-08-27) and is stored AS RECEIVED — never re-encoded (saveImportedScan). The legacy path below still applies to trim-screen saves: saveScanImage() stores toDataURL('image/png') — full resolution, lossless — measured 4.5–5 MB per board on seven real boards. The format, not the stitching, is what makes those big.
1. RESOLVED 28 Aug 2026 — the table is now owner-locked (phase 3 accounts)
The risk as it stood (written 25 Aug 2026 — now history): the publishable key is embedded in this HTML file, and cutlist_sync has no row-level security — so anyone holding a copy of the file can read and overwrite every project in the table, not just their own. The Export HTML button hands out exactly such a copy. Today the exposure is 33 KB of board dimensions, which is close to harmless. Scans would change its character: they are photographs of a real workshop, and at 5 MB each the table could also be filled cheaply. If scans ever go to the cloud, the auth question goes with them.
How it was resolved: phase 3 (28 Aug 2026) replaced the permissive table with accounts. Signing up requires a verified email (an unverified address cannot sign in — proven live, the refusal was watched happen); every row is owned by a user_id; row-level security lets an account see and write only its own rows; anonymous access has no policies at all; each user is limited to 20 writes/minute; rows are capped at 1 MB; and nobody — no key, no session — holds a DELETE right. Someone holding this file today gets exactly nothing without an account, and with one gets only their own data. Still open on this front: Turnstile CAPTCHA keys (slot is built, keys pending), and sender authentication for the auth emails (SPF/DKIM — rollout record A9).
2. Images belong in Storage, not in the database
The free plan includes 1 GB of file storage, currently completely unused, which is a separate allowance from the 500 MB database. Base64 in a JSON row is the wrong home for a photograph on three counts: it inflates the bytes by a third, it is pulled in full on every project load even when you only wanted the cut list, and it competes with project data for the smaller of the two allowances. A scan uploaded to a Storage bucket with its URL in the row would cost the database a few dozen bytes. 1 GB holds roughly 200 scans as PNG, or well over a thousand as JPEG.
3. Untested: whether a large row survives the round trip
A ~20 MB JSON POST through PostgREST may be rejected or time out. This has never been tested, and it should be proven before anything relies on it rather than assumed either way.
4. Inactivity pausing
Free projects pause after 1 week of inactivity, and cloud Save and Open fail while paused. For a tool used in bursts around a job, that is the most likely way the sync will appear “broken” — worth recognising as a paused project rather than debugging the app.
Process governance — HOW changes get made, not what the app does (frozen code, git discipline, branch memory, read-attestations, diagnose-before-patching). Read live from this page's own leading comment block, same as App Details ▸ Principles.
Loading…
Commits and checkpoint tags on this branch. Static snapshot (dev-branch-history.html) copied in by hand — see the live published version if this lags.
Every PROCESS PRINCIPLES clause (AI Management ▸ Principles), where it's sourced from, whether it has a backup memory layer, and when it was last added/re-applied — read live from the same in-code source as the Principles tab, so this can't drift from the actual rules.
Branch memory matrices (Component/Region → Current State → Constraints, one per branch) and the cross-session memory index, color-coded — moved out of Git Branch Graph 2026-08-15 so that tab stays pure git history. Static snapshot (dev-memory-map.html), same cache-busted-iframe pattern as Branch History.
Raw memory files — branch memory (in-repo, one file per branch) and a manual mirror of Claude's cross-session memory relevant to this project (lives outside the repo normally, so this is a periodic hand-copy). Click a file to view its full text.