feat(#23): Gate B length axis and 6" widths (TV-4, TV-5)

Pure client axis module: min-area length axis, rotate/confirm session,
scale, and perpendicular width samples every 6".
This commit is contained in:
2026-09-07 06:07:07 +00:00
parent 55d66b9644
commit 08c463b8d8
8 changed files with 601 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
import type { MaskBuffer } from "../../lib/pipeline/types.js";
function blankMask(width: number, height: number): MaskBuffer {
return new Uint8Array(width * height);
}
function fillMaskRect(
mask: MaskBuffer,
width: number,
x0: number,
y0: number,
x1: number,
y1: number,
): void {
for (let y = y0; y < y1; y++) {
for (let x = x0; x < x1; x++) {
mask[y * width + x] = 255;
}
}
}
/**
* TV-4: axis-aligned slab 96×24 px → with lengthIn=96, scale=1 px/in.
* Constant width 24" at every 6" station.
*/
export function tv4RectMask(): {
mask: MaskBuffer;
width: number;
height: number;
lengthIn: number;
expectedWidthIn: number;
expectedStations: number[];
} {
const width = 120;
const height = 48;
const mask = blankMask(width, height);
fillMaskRect(mask, width, 12, 12, 108, 36);
const expectedStations: number[] = [];
for (let s = 6; s < 96; s += 6) expectedStations.push(s);
return {
mask,
width,
height,
lengthIn: 96,
expectedWidthIn: 24,
expectedStations,
};
}
/**
* TV-5: 96×24 slab with square left end and 45° cut on the right end.
* Prevailing length remains horizontal; diagonal of the cut must not win.
*/
export function tv5AngledEndMask(): {
mask: MaskBuffer;
width: number;
height: number;
lengthIn: number;
bodyWidthIn: number;
} {
const width = 120;
const height = 48;
const mask = blankMask(width, height);
const x0 = 12;
const x1 = 108; // 96 px long
const y0 = 12;
const y1 = 36; // 24 px wide
const cutStart = x1 - 24; // 45° cut over last 24" of length
for (let y = y0; y < y1; y++) {
for (let x = x0; x < x1; x++) {
if (x >= cutStart) {
const cutY = y0 + (x - cutStart);
if (y < cutY) continue;
}
mask[y * width + x] = 255;
}
}
return {
mask,
width,
height,
lengthIn: 96,
bodyWidthIn: 24,
};
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
confirmAxis,
createAxisSession,
estimateLengthAxis,
rotateAxis,
sampleWidths,
computeScale,
} from "../lib/pipeline/axis.js";
import { WIDTH_SAMPLE_IN } from "../lib/pipeline/constants.js";
import { tv4RectMask, tv5AngledEndMask } from "./fixtures/axis.js";
describe("TV-4 Width samples at 6\" stations", () => {
it("reports min, max, average perpendicular to confirmed axis on 96\" length", () => {
const fx = tv4RectMask();
const axis = estimateLengthAxis(fx.mask, fx.width, fx.height);
const session = confirmAxis(createAxisSession(axis));
expect(session.state).toBe("confirmed");
const scale = computeScale(session.axis.lengthPx, fx.lengthIn);
const stats = sampleWidths(
fx.mask,
fx.width,
fx.height,
session.axis,
fx.lengthIn,
);
console.log(
JSON.stringify({
case: "TV-4",
lengthPx: session.axis.lengthPx,
widthPx: session.axis.widthPx,
scale,
stations: stats.samples.map((s) => s.stationIn),
widths: stats.samples.map((s) => s.widthIn),
min: stats.min,
max: stats.max,
average: stats.average,
expectedWidthIn: fx.expectedWidthIn,
}),
);
expect(WIDTH_SAMPLE_IN).toBe(6);
expect(scale).toBeCloseTo(1, 5);
expect(stats.samples.map((s) => s.stationIn)).toEqual(fx.expectedStations);
expect(stats.samples.length).toBe(15);
for (const s of stats.samples) {
expect(s.widthIn).toBeCloseTo(fx.expectedWidthIn, 0);
}
expect(stats.min).toBeCloseTo(fx.expectedWidthIn, 0);
expect(stats.max).toBeCloseTo(fx.expectedWidthIn, 0);
expect(stats.average).toBeCloseTo(fx.expectedWidthIn, 0);
});
});
describe("TV-5 Angled-end length axis", () => {
it("follows prevailing length; widths stay perpendicular after confirm", () => {
const fx = tv5AngledEndMask();
const axis = estimateLengthAxis(fx.mask, fx.width, fx.height);
let session = createAxisSession(axis);
expect(session.state).toBe("pending");
expect(Math.abs(session.axis.direction.x)).toBeGreaterThan(
Math.abs(session.axis.direction.y),
);
expect(session.axis.lengthPx).toBeGreaterThan(session.axis.widthPx);
session = confirmAxis(session);
expect(session.state).toBe("confirmed");
const stats = sampleWidths(
fx.mask,
fx.width,
fx.height,
session.axis,
fx.lengthIn,
);
const bodySamples = stats.samples.filter((s) => s.stationIn <= 72);
expect(bodySamples.length).toBeGreaterThan(0);
for (const s of bodySamples) {
expect(s.widthIn).toBeCloseTo(fx.bodyWidthIn, 0);
}
const rotated = confirmAxis(rotateAxis(createAxisSession(axis), Math.PI / 2));
expect(Math.abs(rotated.axis.direction.y)).toBeGreaterThan(
Math.abs(rotated.axis.direction.x) - 1e-9,
);
console.log(
JSON.stringify({
case: "TV-5",
direction: session.axis.direction,
lengthPx: session.axis.lengthPx,
widthPx: session.axis.widthPx,
bodySampleCount: bodySamples.length,
bodyWidths: bodySamples.map((s) => s.widthIn),
min: stats.min,
max: stats.max,
average: stats.average,
rotatedDirection: rotated.axis.direction,
}),
);
});
});
+316
View File
@@ -0,0 +1,316 @@
import { WIDTH_SAMPLE_IN } from "./constants.js";
import type {
AxisSession,
LengthAxis,
MaskBuffer,
Vec2,
WidthSample,
WidthStats,
} from "./types.js";
const MASK_ON = 128;
function isOn(mask: MaskBuffer, width: number, x: number, y: number): boolean {
if (x < 0 || y < 0 || x >= width) return false;
const h = mask.length / width;
if (y >= h) return false;
return mask[y * width + x]! >= MASK_ON;
}
function collectPoints(mask: MaskBuffer, width: number, height: number): Vec2[] {
const pts: Vec2[] = [];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (mask[y * width + x]! >= MASK_ON) {
// Pixel as unit square so axis-aligned span matches pixel count.
pts.push({ x, y }, { x: x + 1, y }, { x, y: y + 1 }, { x: x + 1, y: y + 1 });
}
}
}
return pts;
}
function cross(o: Vec2, a: Vec2, b: Vec2): number {
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
}
/** Andrew's monotone chain. */
function convexHull(points: Vec2[]): Vec2[] {
if (points.length <= 1) return points.slice();
const sorted = points.slice().sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x));
const lower: Vec2[] = [];
for (const p of sorted) {
while (lower.length >= 2 && cross(lower[lower.length - 2]!, lower[lower.length - 1]!, p) <= 0) {
lower.pop();
}
lower.push(p);
}
const upper: Vec2[] = [];
for (let i = sorted.length - 1; i >= 0; i--) {
const p = sorted[i]!;
while (upper.length >= 2 && cross(upper[upper.length - 2]!, upper[upper.length - 1]!, p) <= 0) {
upper.pop();
}
upper.push(p);
}
lower.pop();
upper.pop();
return lower.concat(upper);
}
function norm(v: Vec2): number {
return Math.hypot(v.x, v.y);
}
function normalize(v: Vec2): Vec2 {
const n = norm(v);
if (n < 1e-12) return { x: 1, y: 0 };
return { x: v.x / n, y: v.y / n };
}
function perp(v: Vec2): Vec2 {
return { x: -v.y, y: v.x };
}
function dot(a: Vec2, b: Vec2): number {
return a.x * b.x + a.y * b.y;
}
function add(a: Vec2, b: Vec2): Vec2 {
return { x: a.x + b.x, y: a.y + b.y };
}
function scale(v: Vec2, s: number): Vec2 {
return { x: v.x * s, y: v.y * s };
}
function sub(a: Vec2, b: Vec2): Vec2 {
return { x: a.x - b.x, y: a.y - b.y };
}
/** Min-area bounding rect via edge-aligned projections on the hull. */
function minAreaRect(hull: Vec2[]): LengthAxis {
if (hull.length === 0) {
return {
center: { x: 0, y: 0 },
direction: { x: 1, y: 0 },
lengthPx: 0,
widthPx: 0,
corners: [
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
],
};
}
if (hull.length === 1) {
const c = hull[0]!;
return {
center: c,
direction: { x: 1, y: 0 },
lengthPx: 0,
widthPx: 0,
corners: [c, c, c, c],
};
}
let bestArea = Infinity;
let best: LengthAxis | null = null;
for (let i = 0; i < hull.length; i++) {
const a = hull[i]!;
const b = hull[(i + 1) % hull.length]!;
const edge = normalize(sub(b, a));
const normal = perp(edge);
let minE = Infinity;
let maxE = -Infinity;
let minN = Infinity;
let maxN = -Infinity;
for (const p of hull) {
const e = dot(p, edge);
const n = dot(p, normal);
if (e < minE) minE = e;
if (e > maxE) maxE = e;
if (n < minN) minN = n;
if (n > maxN) maxN = n;
}
const len = maxE - minE;
const wid = maxN - minN;
const area = len * wid;
if (area < bestArea) {
bestArea = area;
const longIsEdge = len >= wid;
const direction = longIsEdge ? edge : normal;
const lengthPx = longIsEdge ? len : wid;
const widthPx = longIsEdge ? wid : len;
const center = {
x: ((minE + maxE) / 2) * edge.x + ((minN + maxN) / 2) * normal.x,
y: ((minE + maxE) / 2) * edge.y + ((minN + maxN) / 2) * normal.y,
};
const halfL = lengthPx / 2;
const halfW = widthPx / 2;
const d = direction;
const n = perp(d);
const corners: [Vec2, Vec2, Vec2, Vec2] = [
add(add(center, scale(d, -halfL)), scale(n, -halfW)),
add(add(center, scale(d, halfL)), scale(n, -halfW)),
add(add(center, scale(d, halfL)), scale(n, halfW)),
add(add(center, scale(d, -halfL)), scale(n, halfW)),
];
// Prefer +x (then +y) so axis orientation is stable for overlays.
let dir = direction;
if (dir.x < -1e-9 || (Math.abs(dir.x) < 1e-9 && dir.y < 0)) {
dir = scale(dir, -1);
}
best = { center, direction: dir, lengthPx, widthPx, corners };
}
}
return best!;
}
/**
* TECH-SPEC §4.1: largest foreground → convex hull → min-area rect;
* long side = length axis (prevailing length).
*/
export function estimateLengthAxis(
mask: MaskBuffer,
width: number,
height: number,
): LengthAxis {
const pts = collectPoints(mask, width, height);
if (pts.length === 0) {
throw new Error("estimateLengthAxis: empty mask");
}
const hull = convexHull(pts);
return minAreaRect(hull);
}
export function createAxisSession(axis: LengthAxis): AxisSession {
return { axis, state: "pending" };
}
export function confirmAxis(session: AxisSession): AxisSession {
return { axis: session.axis, state: "confirmed" };
}
/** Rotate length direction about center; swap length/width extents for 90° flips. */
export function rotateAxis(session: AxisSession, deltaRad: number): AxisSession {
const { axis } = session;
const c = Math.cos(deltaRad);
const s = Math.sin(deltaRad);
const d = axis.direction;
let direction = normalize({ x: c * d.x - s * d.y, y: s * d.x + c * d.y });
if (direction.x < -1e-9 || (Math.abs(direction.x) < 1e-9 && direction.y < 0)) {
direction = scale(direction, -1);
}
const quarterTurns = Math.round(deltaRad / (Math.PI / 2));
const swap = Math.abs(quarterTurns) % 2 === 1;
const lengthPx = swap ? axis.widthPx : axis.lengthPx;
const widthPx = swap ? axis.lengthPx : axis.widthPx;
const halfL = lengthPx / 2;
const halfW = widthPx / 2;
const n = perp(direction);
const corners: [Vec2, Vec2, Vec2, Vec2] = [
add(add(axis.center, scale(direction, -halfL)), scale(n, -halfW)),
add(add(axis.center, scale(direction, halfL)), scale(n, -halfW)),
add(add(axis.center, scale(direction, halfL)), scale(n, halfW)),
add(add(axis.center, scale(direction, -halfL)), scale(n, halfW)),
];
return {
axis: { center: axis.center, direction, lengthPx, widthPx, corners },
state: "pending",
};
}
/** scale = longest_axis_px / L (px per inch). */
export function computeScale(lengthPx: number, lengthIn: number): number {
if (lengthIn === 0) throw new Error("computeScale: lengthIn is 0");
return lengthPx / lengthIn;
}
/** Interior stations every WIDTH_SAMPLE_IN along the length (excludes exact ends). */
export function widthStations(lengthIn: number): number[] {
const out: number[] = [];
for (let s = WIDTH_SAMPLE_IN; s < lengthIn; s += WIDTH_SAMPLE_IN) {
out.push(s);
}
return out;
}
function rayExtent(
mask: MaskBuffer,
width: number,
height: number,
origin: Vec2,
dir: Vec2,
maxT: number,
): number {
let lo = 0;
let hi = maxT;
let t = 1;
while (t < maxT && isOn(mask, width, Math.floor(origin.x + dir.x * t), Math.floor(origin.y + dir.y * t))) {
t *= 2;
}
hi = Math.min(t, maxT);
for (let i = 0; i < 24; i++) {
const mid = (lo + hi) / 2;
const x = Math.floor(origin.x + dir.x * mid);
const y = Math.floor(origin.y + dir.y * mid);
if (isOn(mask, width, x, y)) lo = mid;
else hi = mid;
}
return lo;
}
/**
* TECH-SPEC §4.4: sample every 6" along confirmed length axis, perpendicular widths.
*/
export function sampleWidths(
mask: MaskBuffer,
width: number,
height: number,
axis: LengthAxis,
lengthIn: number,
): WidthStats {
const scalePx = computeScale(axis.lengthPx, lengthIn);
const origin = sub(axis.center, scale(axis.direction, axis.lengthPx / 2));
const n = perp(axis.direction);
const maxT = Math.hypot(width, height) + 2;
const samples: WidthSample[] = [];
for (const stationIn of widthStations(lengthIn)) {
const pos = add(origin, scale(axis.direction, stationIn * scalePx));
let probe = pos;
if (!isOn(mask, width, Math.floor(probe.x), Math.floor(probe.y))) {
const inward = scale(axis.direction, Math.sign(lengthIn / 2 - stationIn) || 1);
for (let k = 1; k < 4; k++) {
const cand = add(pos, scale(inward, k));
if (isOn(mask, width, Math.floor(cand.x), Math.floor(cand.y))) {
probe = cand;
break;
}
}
}
const plus = rayExtent(mask, width, height, probe, n, maxT);
const minus = rayExtent(mask, width, height, probe, scale(n, -1), maxT);
const widthPx = plus + minus;
samples.push({ stationIn, widthIn: widthPx / scalePx });
}
if (samples.length === 0) {
return { min: 0, max: 0, average: 0, samples };
}
let min = samples[0]!.widthIn;
let max = samples[0]!.widthIn;
let sum = 0;
for (const s of samples) {
if (s.widthIn < min) min = s.widthIn;
if (s.widthIn > max) max = s.widthIn;
sum += s.widthIn;
}
return { min, max, average: sum / samples.length, samples };
}
+1
View File
@@ -2,3 +2,4 @@ export * from "./constants.js";
export * from "./types.js";
export * from "./mask.js";
export * from "./units.js";
export * from "./axis.js";
+35
View File
@@ -37,3 +37,38 @@ export interface MaskSession {
result: MaskResult;
state: MaskConfirmState;
}
export interface Vec2 {
x: number;
y: number;
}
/** Min-area long-side axis (TECH-SPEC §4.1). */
export interface LengthAxis {
center: Vec2;
/** Unit vector along prevailing length. */
direction: Vec2;
lengthPx: number;
widthPx: number;
corners: [Vec2, Vec2, Vec2, Vec2];
}
export type AxisConfirmState = "pending" | "confirmed";
export interface AxisSession {
axis: LengthAxis;
state: AxisConfirmState;
}
export interface WidthSample {
stationIn: number;
widthIn: number;
}
export interface WidthStats {
min: number;
max: number;
average: number;
samples: WidthSample[];
}
+24
View File
@@ -0,0 +1,24 @@
RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend
stdout | src/__tests__/tv3-tv10-units.test.ts > TV-3 Scale, sqft, bdft > bdft = sqft × thickness_in with no divide-by-12
{"case":"TV-3","lengthIn":96,"thicknessIn":1.5,"sqft":0.64,"bdft":0.96,"expectedBdft":0.96,"identity_1sqft_1in":1}
stdout | src/__tests__/tv3-tv10-units.test.ts > TV-10 Rounding > half-up / nearest-step matches TECH-SPEC vectors
{"case":"TV-10","length":{"raw":96.04,"rounded":96,"expected":96},"sqft":{"raw":0.641,"rounded":0.64,"expected":0.64},"bdft":{"raw":11.525,"rounded":11.53,"expected":11.53},"price":{"raw":12.345,"rounded":12.35,"expected":12.35}}
✓ src/__tests__/tv3-tv10-units.test.ts (3 tests) 5ms
stdout | src/__tests__/tv4-tv5-axis.test.ts > TV-4 Width samples at 6" stations > reports min, max, average perpendicular to confirmed axis on 96" length
{"case":"TV-4","lengthPx":96,"widthPx":24,"scale":1,"stations":[6,12,18,24,30,36,42,48,54,60,66,72,78,84,90],"widths":[23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684],"min":23.999999046325684,"max":23.999999046325684,"average":23.999999046325684,"expectedWidthIn":24}
stdout | src/__tests__/tv4-tv5-axis.test.ts > TV-5 Angled-end length axis > follows prevailing length; widths stay perpendicular after confirm
{"case":"TV-5","direction":{"x":1,"y":0},"lengthPx":96,"widthPx":24,"bodySampleCount":12,"bodyWidths":[23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684],"min":0,"max":23.999999046325684,"average":21.19999910990397,"rotatedDirection":{"x":6.123233995736766e-17,"y":1}}
✓ src/__tests__/tv4-tv5-axis.test.ts (2 tests) 29ms
✓ src/__tests__/tv1-tv2-mask.test.ts (5 tests) 35ms
Test Files 3 passed (3)
Tests 10 passed (10)
Start at 06:06:43
Duration 460ms (transform 206ms, setup 0ms, collect 319ms, tests 69ms, environment 1ms, prepare 212ms)
+16
View File
@@ -0,0 +1,16 @@
RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend
stdout | src/__tests__/tv4-tv5-axis.test.ts > TV-4 Width samples at 6" stations > reports min, max, average perpendicular to confirmed axis on 96" length
{"case":"TV-4","lengthPx":96,"widthPx":24,"scale":1,"stations":[6,12,18,24,30,36,42,48,54,60,66,72,78,84,90],"widths":[23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684],"min":23.999999046325684,"max":23.999999046325684,"average":23.999999046325684,"expectedWidthIn":24}
stdout | src/__tests__/tv4-tv5-axis.test.ts > TV-5 Angled-end length axis > follows prevailing length; widths stay perpendicular after confirm
{"case":"TV-5","direction":{"x":1,"y":0},"lengthPx":96,"widthPx":24,"bodySampleCount":12,"bodyWidths":[23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684],"min":0,"max":23.999999046325684,"average":21.19999910990397,"rotatedDirection":{"x":6.123233995736766e-17,"y":1}}
✓ src/__tests__/tv4-tv5-axis.test.ts (2 tests) 25ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 06:06:32
Duration 445ms (transform 84ms, setup 0ms, collect 86ms, tests 25ms, environment 0ms, prepare 61ms)
+18
View File
@@ -0,0 +1,18 @@
RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend
❯ src/__tests__/tv4-tv5-axis.test.ts (0 test)
⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL src/__tests__/tv4-tv5-axis.test.ts [ src/__tests__/tv4-tv5-axis.test.ts ]
Error: Failed to load url ../lib/pipeline/axis.js (resolved id: ../lib/pipeline/axis.js) in /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend/src/__tests__/tv4-tv5-axis.test.ts. Does the file exist?
❯ loadAndTransform node_modules/vite/dist/node/chunks/dep-BK3b2jBa.js:51969:17
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests no tests
Start at 06:05:51
Duration 423ms (transform 53ms, setup 0ms, collect 0ms, tests 0ms, environment 0ms, prepare 72ms)