diff --git a/frontend/src/capture/capture.types.ts b/frontend/src/capture/capture.types.ts index ed3e134..0d218a7 100644 --- a/frontend/src/capture/capture.types.ts +++ b/frontend/src/capture/capture.types.ts @@ -33,7 +33,7 @@ export interface ABox { // Minimum-area bounding rectangle of the mask, source px. export interface BBox { corners: [number, number][]; // 4 corners; corners[3] → corners[0] is the long side - longSidePx: number; // geometric extent only; scale uses mask span along the axis + longSidePx: number; // geometric extent only; scale prefers tape tick span (#99) angleDeg: number; // long-side angle, degrees, [0, 180) } @@ -43,13 +43,26 @@ export interface Mask { slabPx: number; // 0 ⇒ approve blocked, user retakes bitset: Uint8Array; // source-grid mask, 1 byte/px bbox: BBox; - slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop + slabBounds: ABox; // axis-aligned extent of slab px, for the crop_fill PNG crop (default 90%) +} + +// Tape-line calibration (#99): photo tilt/pan/zoom under a fixed middle line; +// ticks declare L. bbox is derived for overlay/width sampling direction. +export interface TapeLine { + tiltDeg: number; + zoom: number; + panX: number; + panY: number; + /** Tick Y in logical stage units (STAGE_H = 640). */ + tickA: number; + tickB: number; + /** Image-space distance between ticks (px); drives scale = spanPx / L. */ + spanPx: number; } -// Direction only: an oriented rectangle over the slab. The arrow reads -// corners[3] → corners[0] (the long side). export interface Axis { bbox: BBox; + tape: TapeLine; } export interface Widths { @@ -138,6 +151,7 @@ export interface Draft { maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch') mask: Omit; // slabPx, bbox, slabBounds axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts + tape?: TapeLine | null; // #99 tape-line transform; absent on legacy drafts lengthEighths: Eighths; thicknessEighths: Eighths; results: Omit | null; // present when results computed diff --git a/frontend/src/capture/tapeGeometry.test.ts b/frontend/src/capture/tapeGeometry.test.ts new file mode 100644 index 0000000..2fff53c --- /dev/null +++ b/frontend/src/capture/tapeGeometry.test.ts @@ -0,0 +1,53 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + STAGE_H, STAGE_W, clampTickPair, containFit, defaultTape, defaultTicks, + lineX, stageToImage, tapeEndpoints, +} from './tapeGeometry.ts'; +import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop.ts'; + +test('defaultTicks sit at crop insets for 90% fill', () => { + const t = defaultTicks(0.9); + const inset = STAGE_H * cropMarginFraction(0.9); + assert.ok(Math.abs(t.tickA - inset) < 1e-9); + assert.ok(Math.abs(t.tickB - (STAGE_H - inset)) < 1e-9); +}); + +test('lineX is the middle of the 3:4 stage', () => { + assert.equal(lineX(), STAGE_W / 2); +}); + +test('identity tape: stage centre maps near image centre', () => { + const imgW = 480, imgH = 640; + const tape = defaultTape(DEFAULT_CROP_FILL, 0); + const [ix, iy] = stageToImage(STAGE_W / 2, STAGE_H / 2, tape, imgW, imgH); + assert.ok(Math.abs(ix - imgW / 2) < 1e-6, `ix=${ix}`); + assert.ok(Math.abs(iy - imgH / 2) < 1e-6, `iy=${iy}`); +}); + +test('tapeEndpoints span equals tick distance in image px at identity (matching aspect)', () => { + const imgW = 480, imgH = 640; + const tape = defaultTape(0.9, 0); + const ep = tapeEndpoints(tape, imgW, imgH); + const { scale0 } = containFit(imgW, imgH); + const expected = Math.abs(tape.tickB - tape.tickA) / scale0; + assert.ok(Math.abs(ep.spanPx - expected) < 1e-6, `span ${ep.spanPx} vs ${expected}`); + // Direction should be +y (down the stage → down the image) + assert.ok(Math.abs(ep.ux) < 1e-6, `ux=${ep.ux}`); + assert.ok(ep.uy > 0.99, `uy=${ep.uy}`); +}); + +test('zoom scales inverse: 2× zoom halves image-space tick span', () => { + const imgW = 480, imgH = 640; + const base = defaultTape(0.9, 0); + const zoomed = { ...base, zoom: 2 }; + const a = tapeEndpoints(base, imgW, imgH).spanPx; + const b = tapeEndpoints(zoomed, imgW, imgH).spanPx; + assert.ok(Math.abs(b * 2 - a) < 1e-6, `base=${a} zoomed=${b}`); +}); + +test('clampTickPair enforces gap and order', () => { + const p = clampTickPair(400, 100, 40); + assert.ok(p.tickB - p.tickA >= 40); + assert.ok(p.tickA <= p.tickB); +}); diff --git a/frontend/src/capture/tapeGeometry.ts b/frontend/src/capture/tapeGeometry.ts new file mode 100644 index 0000000..7260f78 --- /dev/null +++ b/frontend/src/capture/tapeGeometry.ts @@ -0,0 +1,117 @@ +/** + * Pure tape-line geometry (#99). + * Fixed vertical line in the middle of a 3:4 stage; photo pans/zooms/tilts; + * ticks slide on the line; L↔px from tick distance in image space. + */ +import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop'; + +/** Logical stage matching the wireframe / inventory aspect. */ +export const STAGE_W = 480; +export const STAGE_H = 640; + +export interface TapeTransform { + /** Photo tilt in degrees (CSS-style; positive = clockwise). */ + tiltDeg: number; + zoom: number; + /** Pan in stage units. */ + panX: number; + panY: number; + /** Tick Y positions in stage units. */ + tickA: number; + tickB: number; +} + +export function defaultTicks(fill: number = DEFAULT_CROP_FILL): { tickA: number; tickB: number } { + const inset = STAGE_H * cropMarginFraction(fill); + return { tickA: inset, tickB: STAGE_H - inset }; +} + +export function defaultTape(fill: number = DEFAULT_CROP_FILL, seedTiltDeg = 0): TapeTransform { + const t = defaultTicks(fill); + return { + tiltDeg: seedTiltDeg, + zoom: 1, + panX: 0, + panY: 0, + tickA: t.tickA, + tickB: t.tickB, + }; +} + +const rad = (deg: number) => (deg * Math.PI) / 180; + +/** Contain-fit of the photo into the stage at zoom=1, pan=0. */ +export function containFit(imgW: number, imgH: number): { scale0: number; offX: number; offY: number } { + const scale0 = Math.min(STAGE_W / Math.max(imgW, 1), STAGE_H / Math.max(imgH, 1)); + return { + scale0, + offX: (STAGE_W - imgW * scale0) / 2, + offY: (STAGE_H - imgH * scale0) / 2, + }; +} + +/** + * Map a stage point through the inverse photo transform into source image pixels. + * Transform order (forward): contain-fit → zoom+tilt about stage centre → pan. + */ +export function stageToImage( + sx: number, + sy: number, + tape: TapeTransform, + imgW: number, + imgH: number, +): [number, number] { + const cx = STAGE_W / 2; + const cy = STAGE_H / 2; + const { scale0, offX, offY } = containFit(imgW, imgH); + // undo pan + let x = sx - tape.panX - cx; + let y = sy - tape.panY - cy; + // undo rotate(tilt) scale(zoom) about centre + const z = tape.zoom || 1; + const a = -rad(tape.tiltDeg); + const cos = Math.cos(a); + const sin = Math.sin(a); + const xr = (x * cos - y * sin) / z; + const yr = (x * sin + y * cos) / z; + x = xr + cx; + y = yr + cy; + // undo contain fit + const ix = (x - offX) / scale0; + const iy = (y - offY) / scale0; + return [ix, iy]; +} + +export function lineX(): number { + return STAGE_W / 2; +} + +/** Image-space endpoints of the two ticks + span in px. */ +export function tapeEndpoints( + tape: TapeTransform, + imgW: number, + imgH: number, +): { start: [number, number]; end: [number, number]; spanPx: number; ux: number; uy: number } { + const start = stageToImage(lineX(), tape.tickA, tape, imgW, imgH); + const end = stageToImage(lineX(), tape.tickB, tape, imgW, imgH); + const dx = end[0] - start[0]; + const dy = end[1] - start[1]; + const spanPx = Math.hypot(dx, dy); + const len = spanPx || 1; + return { start, end, spanPx, ux: dx / len, uy: dy / len }; +} + +export function clampTickPair(a: number, b: number, gap = 40): { tickA: number; tickB: number } { + let tickA = a; + let tickB = b; + if (tickB < tickA) { + const t = tickA; + tickA = tickB; + tickB = t; + } + const minY = 0; + const maxY = STAGE_H; + tickA = Math.max(minY, Math.min(tickA, maxY - gap)); + tickB = Math.min(maxY, Math.max(tickB, tickA + gap)); + return { tickA, tickB }; +} diff --git a/frontend/src/geometry/rect.ts b/frontend/src/geometry/rect.ts index 8e6cbf3..ad57a48 100644 --- a/frontend/src/geometry/rect.ts +++ b/frontend/src/geometry/rect.ts @@ -115,6 +115,8 @@ export function orientedRect(prev: BBox, angleDeg: number): BBox { // Project every slab pixel onto the unit vector (ux, uy); return max − min // extent in px. 0 when the mask is empty. +// #99: demoted to an optional sanity check — declared L comes from tape tick +// distance in image space, not this projected guess. export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number { let min = Infinity, max = -Infinity; for (let y = 0; y < h; y++) { diff --git a/frontend/src/measure/measure.tape.test.ts b/frontend/src/measure/measure.tape.test.ts new file mode 100644 index 0000000..4ba6a94 --- /dev/null +++ b/frontend/src/measure/measure.tape.test.ts @@ -0,0 +1,18 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { minAreaRect, orientedRect } from '../geometry/rect.ts'; +import { measure } from './measure.ts'; + +test('#99: tape span drives scale, not maskSpanAlong', () => { + const W = 480, H = 100; + const bitset = new Uint8Array(W * H).fill(1); + const box = orientedRect(minAreaRect(bitset, W, H)!, 0); + // Declared L = 48"; tape span = 240 px → 5 px/in (not the full 480 mask span). + const m = measure(bitset, W, H, box, 384, 8, W * H, { + start: [100, 50], + ux: 1, + uy: 0, + spanPx: 240, + }); + assert.ok(Math.abs(m.scale - 5) < 1e-9, `scale ${m.scale}`); +}); diff --git a/frontend/src/measure/measure.ts b/frontend/src/measure/measure.ts index 074bc88..4f8284b 100644 --- a/frontend/src/measure/measure.ts +++ b/frontend/src/measure/measure.ts @@ -6,8 +6,19 @@ export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [numb return { start: bbox.corners[3], end: bbox.corners[0] }; } +export interface TapeAxis { + /** Image-space tape start (tick A). */ + start: [number, number]; + /** Image-space unit direction tick A → tick B. */ + ux: number; + uy: number; + /** Image-space distance between ticks (owner-declared tape length in px). */ + spanPx: number; +} + // Wood end→end along the approved direction: project the mask onto the axis // unit vector and return the segment on the bbox center line covering that span. +// Used only as a fallback / sanity path when no tape ticks are present (#99). function maskAxisSegment( bitset: Uint8Array, w: number, h: number, bbox: BBox, ): { start: [number, number]; end: [number, number]; span: number; ux: number; uy: number } { @@ -71,16 +82,38 @@ function sampleWidths( return out; } +/** + * @param tape When provided (#99), scale = tape.spanPx / L and width stations + * walk from the tick-A endpoint. maskSpanAlong is not used for scale. + */ export function measure( bitset: Uint8Array, w: number, h: number, bbox: BBox, lengthEighths: Eighths, thicknessEighths: Eighths, slabPx: number, + tape?: TapeAxis | null, ): Omit & { sourceBelowFloor: false } { const lengthIn = lengthEighths / 8; - const seg = maskAxisSegment(bitset, w, h, bbox); - const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn; - const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn); + let start: [number, number]; + let ux: number; + let uy: number; + let scale: number; + if (tape && tape.spanPx > 0 && lengthIn > 0) { + start = tape.start; + ux = tape.ux; + uy = tape.uy; + scale = tape.spanPx / lengthIn; + // Optional sanity: maskSpanAlong remains available for diagnostics only. + void maskSpanAlong; + } else { + const seg = maskAxisSegment(bitset, w, h, bbox); + // Legacy fallback when no tape ticks — keep prior behaviour for old drafts. + scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn; + start = seg.start; + ux = seg.ux; + uy = seg.uy; + } + const every6In = sampleWidths(bitset, w, h, start, ux, uy, scale, lengthIn); let widths: Widths; if (every6In.length > 0) { const min = Math.min(...every6In); diff --git a/frontend/src/png/crop.test.ts b/frontend/src/png/crop.test.ts new file mode 100644 index 0000000..ff277c5 --- /dev/null +++ b/frontend/src/png/crop.test.ts @@ -0,0 +1,56 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { clampFill, cropMarginFraction, DEFAULT_CROP_FILL } from './crop.ts'; +import { _resetCropFillForTests, getCropFill, setCropFill } from '../settings/cropFill.ts'; +import { composeInventoryFrame } from './inventory.ts'; +import type { Mask, Source } from '../capture/capture.types'; + +test('cropMarginFraction(0.9) === 0.05', () => { + assert.ok(Math.abs(cropMarginFraction(0.9) - 0.05) < 1e-12); + assert.equal(DEFAULT_CROP_FILL, 0.9); +}); + +test('clampFill bounds', () => { + assert.equal(clampFill(2), 1); + assert.equal(clampFill(0), 0.01); + assert.equal(clampFill(Number.NaN), DEFAULT_CROP_FILL); +}); + +test('getCropFill / setCropFill round-trip', () => { + _resetCropFillForTests(); + assert.equal(getCropFill(), 0.9); + assert.equal(setCropFill(0.8), 0.8); + assert.equal(getCropFill(), 0.8); + _resetCropFillForTests(); +}); + +test('composeInventoryFrame honors cropFill override (#99)', () => { + const source: Pick = { widthPx: 2200, heightPx: 2000 }; + const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1); + const mask: Pick = { + bitset, + slabBounds: { x: 100, y: 100, w: 2000, h: 1800 }, + }; + const rgba = new Uint8Array(source.widthPx * source.heightPx * 4).fill(255); + const out90 = composeInventoryFrame({ width: 2200, height: 2000, rgba }, mask, source, 0.9); + const out80 = composeInventoryFrame({ width: 2200, height: 2000, rgba }, mask, source, 0.8); + + const extent = (frame: typeof out90) => { + let minx = frame.width, maxx = -1, miny = frame.height, maxy = -1; + for (let y = 0; y < frame.height; y++) { + for (let x = 0; x < frame.width; x++) { + if (frame.rgba[(y * frame.width + x) * 4 + 3] !== 0) { + if (x < minx) minx = x; + if (x > maxx) maxx = x; + if (y < miny) miny = y; + if (y > maxy) maxy = y; + } + } + } + return { w: maxx - minx + 1, h: maxy - miny + 1 }; + }; + const e90 = extent(out90); + const e80 = extent(out80); + assert.ok(Math.abs(e90.w - 720) <= 2, `90% w ${e90.w}`); + assert.ok(e80.w < e90.w - 10, `80% fill must be tighter than 90%: ${e80.w} vs ${e90.w}`); +}); diff --git a/frontend/src/png/crop.ts b/frontend/src/png/crop.ts new file mode 100644 index 0000000..8a7a890 --- /dev/null +++ b/frontend/src/png/crop.ts @@ -0,0 +1,21 @@ +/** + * Shared crop constants for inventory PNG + tape-line overlay (#99). + * Aspect is fixed 3:4; crop_fill only moves the long-axis end insets. + * Non-retro: changing fill affects future encodes only, not stored PNGs. + */ + +export const DEFAULT_CROP_FILL = 0.9; +export const ASPECT_W = 3; +export const ASPECT_H = 4; +export const FLOOR_PX = 800; + +/** Fraction of the long axis inset from each end: (1 - fill) / 2. */ +export function cropMarginFraction(fill: number = DEFAULT_CROP_FILL): number { + const f = clampFill(fill); + return (1 - f) / 2; +} + +export function clampFill(fill: number): number { + if (!Number.isFinite(fill)) return DEFAULT_CROP_FILL; + return Math.min(1, Math.max(0.01, fill)); +} diff --git a/frontend/src/png/inventory.ts b/frontend/src/png/inventory.ts index 2818198..37fe911 100644 --- a/frontend/src/png/inventory.ts +++ b/frontend/src/png/inventory.ts @@ -1,9 +1,6 @@ import type { Mask, Source } from '../capture/capture.types'; - -const FLOOR = 800; // shorter side, px -const FILL = 0.9; // 90% fill at the widest and tallest points of the slab -const ASPECT_W = 3; -const ASPECT_H = 4; +import { ASPECT_H, ASPECT_W, FLOOR_PX, DEFAULT_CROP_FILL } from './crop'; +import { getCropFill } from '../settings/cropFill'; type MaskPick = Pick; type SourcePick = Pick; @@ -12,6 +9,8 @@ export interface InventoryInput { bitmap: ImageBitmap; mask: MaskPick; source: SourcePick; + /** Optional override; default reads live settings.crop_fill (#99). */ + cropFill?: number; } export interface Frame { @@ -25,7 +24,9 @@ export function composeInventoryFrame( src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray }, mask: MaskPick, source: SourcePick, + cropFill: number = DEFAULT_CROP_FILL, ): Frame { + const FILL = cropFill > 0 && cropFill <= 1 ? cropFill : DEFAULT_CROP_FILL; const b = mask.slabBounds; const x0 = Math.max(0, Math.round(b.x)); const y0 = Math.max(0, Math.round(b.y)); @@ -33,8 +34,8 @@ export function composeInventoryFrame( const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h))); const sw = x1 - x0; const sh = y1 - y0; - const belowFloor = Math.min(sw, sh) < FLOOR; - const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR; + const belowFloor = Math.min(sw, sh) < FLOOR_PX; + const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR_PX)) : FLOOR_PX; const outH = Math.round((outW * ASPECT_H) / ASPECT_W); const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1)); const dw = sw * scale; @@ -66,6 +67,7 @@ export async function encodeInventoryPng( input: InventoryInput, ): Promise<{ blob: Blob; sourceBelowFloor: boolean }> { const { bitmap, mask, source } = input; + const fill = input.cropFill ?? getCropFill(); const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx); const srcCtx = srcCanvas.getContext('2d')!; srcCtx.drawImage(bitmap, 0, 0); @@ -74,6 +76,7 @@ export async function encodeInventoryPng( { width: source.widthPx, height: source.heightPx, rgba: img.data }, mask, source, + fill, ); const out = new OffscreenCanvas(frame.width, frame.height); const ctx = out.getContext('2d')!; diff --git a/frontend/src/png/overlay.ts b/frontend/src/png/overlay.ts index f487961..a328861 100644 --- a/frontend/src/png/overlay.ts +++ b/frontend/src/png/overlay.ts @@ -1,5 +1,7 @@ import type { BBox } from '../capture/capture.types'; import { axisEndpoints } from '../measure/measure'; +import { DEFAULT_CROP_FILL } from './crop'; +import { getCropFill } from '../settings/cropFill'; /** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */ export function overlayWidthStations(lengthIn: number): number[] { @@ -485,8 +487,9 @@ export function inventorySourceMap( source: { widthPx: number; heightPx: number }, outW: number, outH: number, + cropFill: number = DEFAULT_CROP_FILL, ): InventoryMap { - const FILL = 0.9; + const FILL = cropFill > 0 && cropFill <= 1 ? cropFill : DEFAULT_CROP_FILL; const x0 = Math.max(0, Math.round(slabBounds.x)); const y0 = Math.max(0, Math.round(slabBounds.y)); const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w))); @@ -517,6 +520,8 @@ export interface EncodeOverlayInput { lengthLabel: string; slabBounds: { x: number; y: number; w: number; h: number }; source: { widthPx: number; heightPx: number }; + /** Optional override; default reads live settings.crop_fill (#99). */ + cropFill?: number; } /** @@ -531,7 +536,8 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise const canvas = new OffscreenCanvas(outW, outH); const ctx = canvas.getContext('2d')!; ctx.drawImage(bmp, 0, 0); - const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH); + const fill = input.cropFill ?? getCropFill(); + const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH, fill); const plan = buildOverlayPlan({ bitset: input.bitset, widthPx: input.widthPx, diff --git a/frontend/src/png/rotateCal.ts b/frontend/src/png/rotateCal.ts new file mode 100644 index 0000000..2c75311 --- /dev/null +++ b/frontend/src/png/rotateCal.ts @@ -0,0 +1,92 @@ +/** + * Bake calibration photo tilt into a rotated bitmap + mask (#99 transform scope). + * Used only for the calibration inventory PNG + dimension-overlay clone. + * Batch siblings are never passed through here. + */ +import type { Mask, Source } from '../capture/capture.types'; +import { minAreaRect, slabBounds } from '../geometry/rect'; + +export interface RotatedCal { + bitmap: ImageBitmap; + mask: Mask; + source: Pick; +} + +/** + * Rotate source clockwise by tiltDeg (same sign as CSS/canvas rotate). + * Returns a new bitmap the caller must close, plus recomputed mask geometry. + * Identity (≈0°) returns clones of inputs without resampling. + */ +export async function rotateCalibration( + bitmap: ImageBitmap, + mask: Mask, + source: Pick, + tiltDeg: number, +): Promise { + const a = ((tiltDeg % 360) + 360) % 360; + if (a < 0.05 || a > 359.95) { + // No meaningful rotation — hand back the same bitmap reference; caller closes once. + return { bitmap, mask, source }; + } + const srcW = source.widthPx; + const srcH = source.heightPx; + const rad = (a * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + // Axis-aligned bounds of rotated rectangle corners. + const corners = [ + [0, 0], + [srcW, 0], + [srcW, srcH], + [0, srcH], + ].map(([x, y]) => { + const cx = x - srcW / 2; + const cy = y - srcH / 2; + return [cx * cos - cy * sin, cx * sin + cy * cos] as [number, number]; + }); + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const [x, y] of corners) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + const outW = Math.max(1, Math.ceil(maxX - minX)); + const outH = Math.max(1, Math.ceil(maxY - minY)); + const canvas = new OffscreenCanvas(outW, outH); + const ctx = canvas.getContext('2d')!; + ctx.translate(outW / 2, outH / 2); + ctx.rotate(rad); + ctx.drawImage(bitmap, -srcW / 2, -srcH / 2); + + // Nearest-neighbour resample of the bitset into the rotated frame. + const bitset = new Uint8Array(outW * outH); + const invCos = Math.cos(-rad); + const invSin = Math.sin(-rad); + for (let y = 0; y < outH; y++) { + for (let x = 0; x < outW; x++) { + const dx = x - outW / 2; + const dy = y - outH / 2; + const sx = Math.round(dx * invCos - dy * invSin + srcW / 2); + const sy = Math.round(dx * invSin + dy * invCos + srcH / 2); + if (sx < 0 || sy < 0 || sx >= srcW || sy >= srcH) continue; + bitset[y * outW + x] = mask.bitset[sy * srcW + sx]; + } + } + let slabPx = 0; + for (let i = 0; i < bitset.length; i++) if (bitset[i]) slabPx++; + const bounds = slabBounds(bitset, outW, outH); + const bbox = minAreaRect(bitset, outW, outH) ?? mask.bbox; + const outBmp = canvas.transferToImageBitmap(); + return { + bitmap: outBmp, + mask: { + params: mask.params, + slabPx, + bitset, + bbox, + slabBounds: bounds, + }, + source: { widthPx: outW, heightPx: outH }, + }; +} diff --git a/frontend/src/settings/cropFill.ts b/frontend/src/settings/cropFill.ts new file mode 100644 index 0000000..4aaa6b8 --- /dev/null +++ b/frontend/src/settings/cropFill.ts @@ -0,0 +1,55 @@ +/** + * Thin frontend-only crop_fill setting (#99). + * Default 0.9 — replaces duplicated FILL = 0.9 in png/inventory + png/overlay. + * Not a full M3 Settings surface; localStorage so it survives reloads. + */ +import { DEFAULT_CROP_FILL, clampFill } from '../png/crop'; + +const KEY = 'slabuploader.crop_fill'; + +let cached: number | null = null; + +export function getCropFill(): number { + if (cached != null) return cached; + if (typeof localStorage === 'undefined') { + cached = DEFAULT_CROP_FILL; + return cached; + } + try { + const raw = localStorage.getItem(KEY); + if (raw == null) { + cached = DEFAULT_CROP_FILL; + return cached; + } + cached = clampFill(Number.parseFloat(raw)); + return cached; + } catch { + cached = DEFAULT_CROP_FILL; + return cached; + } +} + +export function setCropFill(fill: number): number { + const next = clampFill(fill); + cached = next; + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem(KEY, String(next)); + } catch { + /* private mode / quota — in-memory still updated */ + } + } + return next; +} + +/** Test helper: clear cached value (and optional storage). */ +export function _resetCropFillForTests(): void { + cached = null; + if (typeof localStorage !== 'undefined') { + try { + localStorage.removeItem(KEY); + } catch { + /* ignore */ + } + } +}