| Include | Panel | Qty | Length | Width | Roughing Length | Roughing Width | Thickness | Board | Trim | Model grain | Type |
|---|
| Include | Laminated | Qty | Length | Width | Roughing Length | Roughing Width | Target thickness | Board | Trim | Glue-up | Grain match | Type |
|---|
| Include | Part | Qty | Length | Width | Roughing Length | Roughing Width | Thickness | Grain match group | Preferred board | Grain Direction | Visually Critical | Type |
|---|
| Include | Part | Qty | Length | Width | Thickness | Grain Direction | Type |
|---|
| Source | Code | Label (optional) | Length | Width | Thickness | Species (optional) | Character | Grade | Cut type | Grain | Quantity on hand | Value ($/board) (optional) | Supplier |
|---|
| Label (optional) | Width | Length | Thickness | Quantity on hand | Value ($/sheet) (optional) |
|---|
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.
Once per tape — remembered for every board.
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.
No photos yet.
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.
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.
Add the photos from the phone, in order along the board.
No photos yet.
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.
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.
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.
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.
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.
| IP right number | 2689781 — this is the base application number, use it for everything |
| Batch / payment ref | AMCZ-2616160956 (receipt only, not the trade mark number) |
| Filed | 25 August 2026, 7:46 AM |
| Mark | Grainwise Cutlist — word or phrase (protected in any font, any colour) |
| Classes | 9 (software) and 42 (software as a service) |
| Owner | Lloyd Green, sole trader, ABN 44526802202 |
| Fee paid | AUD $500.00 |
| Record | search.ipaustralia.gov.au › 2689781 |
| Portal | portal.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 | grainwisecutlist.com |
| Registrar | Porkbun — order 11462066 |
| Paid | USD $55.40 for 5 years (US$11.08/yr, at-cost pricing) |
| Expires | ~25 August 2031 — confirm auto-renew is ON |
| Points to | Nothing 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.
| ~25 Feb 2027 | US / 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 2027 | Reminder already scheduled in Claude Code to make that decision, 7 weeks ahead of the deadline. |
| A few months from Aug 2026 | IP 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 2031 | Domain renewal falls due (earlier if auto-renew lapses). |
| 25 Aug 2036 | Trade mark renewal — registration runs 10 years from the filing date, then renewable. |
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.
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.
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.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:
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.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.
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:
warningCount) — a result that fails to place something is never acceptable, whatever its other numbers.boardCount).totalCost via boardUnitCost — a board's Value if set, else its width, so wider/pricier stock costs more).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).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.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.Same tiers 1–3 (dropped pieces / board count / cost), then reordered per the owner's 2026-08-14 approval (task #122):
nonTailWaste, then maxTailLen — same metric pair as Balanced's tier 6, owner-approved 2026-08-17).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.
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:
packBySubsetSum. Pinned pieces get their own lanes sized only against boards their pin allows.HIGH_SCORE_THRESHOLD, the owner's number) a lane is "high-score"; below it, "low-score".maximizeLaneParallelism (which deliberately splits long single lanes — the opposite of this mode's goal) and reorderSequentialZonesByWidth.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.
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."
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.
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.
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.
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.
A board's appearance has four separable parts, and a phone photo bakes all four into one set of pixels:
| Component | What it is | What 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 chatoyance | Curl, 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 illumination | A 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.
| Owner's symptom | Root causes, in order of weight | What 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 glare | A 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. |
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.| # | Change | Why | Cost to the user |
|---|---|---|---|
| C1 | Capture 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. |
| C2 | Reference 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. |
| C3 | Cross-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. |
| P1 | Overlap 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. |
| P2 | Flat-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. |
| P3 | Multi-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). |
| P4 | Tonality 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. |
| P5 | Presentation: 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).
| # | Experiment | Pass means | Needs |
|---|---|---|---|
| E1 | Locked-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. |
| E2 | Overlap 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. |
| E3 | Grey 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. |
| E4 | Cross-polarised flash on the phone LED vs plain flash, same board. | Glare metric passes with the polariser; sheen visibly gone. | Polariser film + lens filter. |
| E5 | Three-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. |
| E6 | Figure 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.
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.
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.
| Built | Where | What 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 captureQuality | One 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 match | A 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 tapeColour | Two 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 gains | Seam 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 blend | A 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-field | A 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 rows | The 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 button | Does 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 residual | This 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, __lifelikeApplyOverlap | Judge 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, __lifelikeFieldLumOnly | Read 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 polish | Sharpness 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 field | Photo 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-07 | E1 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.
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 MosaicsOwner, 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.
| # | Stage | Functions | What it does, on which rows, with what clamp — and why it is that way | Testing switches |
|---|---|---|---|---|
| 1 | Rectify, pass 1 | buildComposite (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). | — |
| 2 | Sharpness-balanced seams | sliceSharpCols | Per 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 |
| 3 | The tape along each photo | sliceTapeProfile, straightenSliceByTape | The 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) |
| 4 | Frame match from the blade | sampleBladeColour, 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 |
| 5 | Slice profiles | computeSliceProfile(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 |
| 6 | The joint column field | applyField | Every 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 |
| 7 | Overlap gain solver (report only) | seamPairMeans, solveLogGains, overlapGains | The 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 |
| 8 | The across-board field | overlapVerticalField | The 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 |
| 9 | Composite + seam bands | blendSeamBand | Slices 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 |
| 10 | The colour bridge | bridgeSeamBands | Per 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 |
| 11 | Ruler, heal, edge measurement | heal v5.1, measureRenderedEdges | The 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). | — |
| 12 | The sharpness equaliser | equaliseSharpness, tickStep | Per 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 |
| 13 | The tape polish | tapePolish, isInteriorTape | The 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 |
| 14 | The report | lifelikeReport, tapeUniformity | Board: Δ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):
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 measured | 1.6 / 1.13 | 1.9 / 0.96 | 1.9 / 0.96 |
| Tape sharpness spread (tick rows) | — | 133% (old measure) | 55% | 55% |
| Same timber either side of a seam, r/g | — | 3.5% apart | within 1% | within 1% |
| End of board, top / bottom rows (luminance) | — | — | 163 / 121 | 157 / 141 |
| Board ΔE2000 end to end | 11.4 | 2.4 | 1.8 | 1.1 |
| Worst seam step (5 mm) | 2.6% | 3.4% | 3.5% | 3.6% |
| Glare | pass | pass | pass | pass |
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.
branch-memory/grain-sandpit.md.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 1 | board 2 | board 3 | board 4 | board 5 | pooled |
|---|---|---|---|---|---|---|
| v4 (the wobble rule on the app tracer's lines) | 86.7 | 80.2 | 69.0 | 90.9 | 91.2 | 82.9 |
| v16 (the combined reading, self-set level) | 94.0 | 86.2 | 69.4 | 87.7 | 88.6 | 85.3 |
| heart zones alone | 95.5 | 82.2 | 66.6 | 89.8 | 88.5 | 84.5 |
| v17 = heart zones or v16 | 94.0 | 88.2 | 78.0 | 87.7 | 86.1 | 87.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.
Every reading starts from the same stages, all in sandpit/grain-lab.js, each cached on the scan object by its options:
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.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.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.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”.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”).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.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.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:
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.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).
| where | knob | value | note |
|---|---|---|---|
| rule map | ridge threshold / raw dark reader | 0.61 / off | pinned for the rules; the header dial is a view |
| v16 | split / R / span / smooth / long weight / turn-backs | 150 / 1500 / 50 / 16 / 1 / 0 | the self-set grid's best; split 150 means nearly everything is read as a zone |
| v16 | self-set level k / percentile / pieces under | 3.0 / 10th / 100 mm | k chosen every time in the held-out test |
| hearts | win / dark / width / length / fat | 10 / 0.25 / 19 / 20 / 0.02 | the owner's heart setting, by eye |
| hearts | reach / straight share | 200 mm / 50 % | the false-heart filter |
| chains | gap along / across | 1500 / 80 mm | the plain patch between hearts |
| zone | rows either side / tilt | 2 / off (99) | the band between the hearts, settled |
| settle | columns / rows / passes | 10 / 1 / 2 | shared by every rule |
| grid | cell | 20 × 15 mm | GRAIN_COL_MM × GRAIN_ROW_MM |
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).
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.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.
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.
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.
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 assumed | What 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. |
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.
| Explanation | Test | Outcome |
|---|---|---|
| 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. |
With the best measurement available, gradient edges with a section-local scale and outliers rejected, the deficit decomposes with nothing left over:
| Term | mm | Whose problem |
|---|---|---|
| Rule width, measured over the full length | 151.45 | — |
| Round-over, never presents an edge | −1.31 | Physical. Not an error, and no measurement can recover it. |
| Owner-confirmed visible width | 150.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 measures | 149.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.
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.
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.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:
straightenUsingTape, which tracks a tape measure laid along the board and shears each column back into line.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 board | Slit | Yes | Per-frame motion estimate (built) + sub-pixel accumulator (proposed) | Strong |
| Still | Yes (immune) | No motion; scale comes from rectification | Strong | |
| Camera distance (Z) | Slit | Yes (proposed) | Calibrate per scan → guide lines → live tape-width correction | Good, untested |
| Still | Yes (immune) | Each photo rectified independently, so distance normalises out | Strong | |
| Overall length | Slit | Yes | Trim to both board ends + known length (built) | Strong — endpoints pinned exactly |
| Still | Yes | Same, plus every photo independently anchored | Strong | |
| Position along board | Slit | Partly | Accumulates; ends pinned, interior rides on the Z fix. Cannot use tape readings — a slit smears printed digits | Good once Z lands |
| Still | Yes (proposed) | User places two dots on legible tape readings and types them — on the PC, in post-processing | Strong — cannot fail silently | |
| Plain / low-texture timber | Slit | No | None — holds the last speed, a guess. Tape ticks are the obvious alternative and they are exactly what aliased | — |
| Still | Yes (immune) | No motion tracking needed at all | Strong | |
| Board longer than buffer | Slit | No | 12000 px cap, capture force-stops with no warning. 5 m boards are at real risk today | — cheap to fix |
| Still | Yes (immune) | No buffer involved | Strong | |
| Speed outside 2–50 px/frame | Slit | Partly (proposed) | Widen the range and warn live | Weak — detection, not correction |
| Still | Yes (immune) | No clamps involved | Strong | |
| Coverage gaps | Slit | Yes | Continuous capture (built) | Strong |
| Still | Yes (proposed) | Overlapping frames, deliberately more than needed, plus a visible check | Good | |
| Across the board (Y) and framing | ||||
| Wobble / straightness | Slit | Partly | Live drift correction + two-pass straightening (built) | Fair — owner reports not good enough |
| Still | Yes (immune) | A photo is a single instant — nothing to wobble | Strong | |
| Cross-board scale | Slit | Yes (proposed) | Live tape width, instead of measuring once at locate time | Good, untested |
| Still | Yes | From per-photo rectification | Strong | |
| Perspective / tilt | Slit | Yes (jig) | A jig holds the camera square-on — freehand tilt stays uncorrected | Good — conditional on the jig |
| Still | Yes (proposed) | Rectify from the board's own edges, tapped by the user on the PC | Strong — exact for a flat subject | |
| Board offset in frame | Slit | Yes (proposed) | Guide lines sized to the board's real width; user keeps it between them by eye | Good — prevention beats correction |
| Still | Yes (proposed) | Same guide lines; less critical since each photo is rectified anyway | Good | |
| Glare hiding the tape | Slit | n/a | Does not read the tape's digits anyway | — |
| Still | Yes (proposed) | Extra frames as insurance, a two-second glance at each shot on the phone, and an automatic glare flag | Good — 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
initCamera) — rear camera stream, torch enabled where supported.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.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.straightenUsingTape) — detail below.showTrimHandles → confirmTrim) — the user drags handles to the two ends of the board; the app then sets the scale.saveScanImage) — writes the scan record into the project gallery, named after its stock row when the scan was started from one.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:
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.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.
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:
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.
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.
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):
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.
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.
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).// ==== markers), read straight from this page's own source — never a separate doc to fall out of sync.dev-progress.html, hand-copied — the tracker itself only exists in Claude's session state, same pattern as Branch History).?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.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.| # | Requirement | What it means | Why 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. |
| # | Input | What it tests for | What 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. | — |
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.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.| Test | Description | Result | Computed | Your verdict |
|---|
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.#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.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.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.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.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.| Item | Value |
|---|---|
| Provider | Supabase (Postgres + REST), served through Cloudflare |
| Organisation | Cornerstone Furniture — wvwiyblrpvakgsxqpivq |
| Plan | FREE, $0.00/month |
| Project ref | zqbujmfvotmyhtierczu |
| Endpoint | https://zqbujmfvotmyhtierczu.supabase.co |
| Region / compute | AWS ap-southeast-1 (Singapore) · NANO |
| Table | cutlist_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”. |
| Auth | Email + 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 emails | Sent 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. |
| Allowance | Used | Limit |
|---|---|---|
| Database size | 26 MB | 500 MB |
| Egress | 0 MB | 5 GB (+5 GB cached) |
| File storage (Supabase Storage — separate from the database) | 0 GB | 1 GB |
| Monthly active users | 0 | 50,000 |
| Step | What happens |
|---|---|
| 1. Sign up | Gear 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 email | Arrives 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 link | Supabase 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 in | The 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 scenes | The database trigger handle_new_user auto-creates the account’s profiles row. The user cannot edit or forge it — only the trigger writes there. |
| Table | Holds | Who writes it |
|---|---|---|
auth.users | Email, 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.profiles | One 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_sync | The projects: user_id, code, data (whole project as JSON), updated_at. Unique per (user_id, code). | The owning account only (RLS) |
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.on delete cascade). That is the only path by which project rows die — treat it accordingly.zqbujmfvotmyhtierczu.| Task | Where / how |
|---|---|
| See or search all users | Authentication ▸ 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 reset | Self-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 user | Dashboard user row ▸ Delete user. Permanent, and cascades to their profile and ALL their projects — see the cascade warning above. |
| Email sending health | Authentication ▸ Emails ▸ SMTP Settings. Sender mailbox lives in cPanel (Email Accounts). Host must stay secure251.inmotionhosting.com:465. |
| Inspect the data | SQL 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;
profiles for the paid tiers.| Contents | Per project | Projects 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 |
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.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.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.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).dev-branch-history.html) copied in by hand — see the live published version if this lags.dev-memory-map.html), same cache-busted-iframe pattern as Branch History.