feat(capture): shared crop_fill + tape geometry for #99

- One crop_fill (default 0.9) replaces duplicated FILL in inventory/overlay
- Thin localStorage settings store; crop % only moves long-axis end insets
- Pure tapeGeometry: fixed stage line, stage↔image map, tick span → px
- measure() prefers tape tick span for scale; maskSpanAlong demoted
- rotateCalibration bakes cal tilt into inventory + overlay only
- Fix stale 80%-fill comment on slabBounds
This commit is contained in:
grokbot_ty
2026-09-25 17:41:29 -07:00
parent b44e231038
commit 323f29b649
12 changed files with 486 additions and 16 deletions
+18 -4
View File
@@ -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<Mask, 'bitset' | 'params'>; // 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<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
+53
View File
@@ -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);
});
+117
View File
@@ -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 };
}
+2
View File
@@ -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++) {
+18
View File
@@ -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}`);
});
+36 -3
View File
@@ -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<Measures, 'inventoryPng'> & { 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);
+56
View File
@@ -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<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
const mask: Pick<Mask, 'bitset' | 'slabBounds'> = {
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}`);
});
+21
View File
@@ -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));
}
+10 -7
View File
@@ -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<Mask, 'bitset' | 'slabBounds'>;
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
@@ -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')!;
+8 -2
View File
@@ -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<Blob>
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,
+92
View File
@@ -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<Source, 'widthPx' | 'heightPx'>;
}
/**
* 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<Source, 'widthPx' | 'heightPx'>,
tiltDeg: number,
): Promise<RotatedCal> {
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 },
};
}
+55
View File
@@ -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 */
}
}
}