Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 818874e65c |
@@ -28,13 +28,18 @@ PLAIN_KEYS: set[str] = {
|
||||
"woo_create_status",
|
||||
"taxonomy_last_sync_at",
|
||||
"taxonomy_anchor",
|
||||
"crop_fill",
|
||||
}
|
||||
ALL_KEYS = SECRET_KEYS | PLAIN_KEYS
|
||||
# Plain keys surfaced as numbers in the API (stored as TEXT in `settings`).
|
||||
NUMERIC_KEYS: set[str] = {"crop_fill"}
|
||||
|
||||
DEFAULTS: dict[str, Any] = {
|
||||
"inference_enabled": False,
|
||||
"content_llm_enabled": False,
|
||||
"woo_create_status": "draft",
|
||||
# #99: global crop fill %, one source of truth for the ref line + PNG crop.
|
||||
"crop_fill": 90,
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +96,12 @@ def get_settings() -> dict[str, Any]:
|
||||
result[k] = val if val is not None else DEFAULTS.get(k)
|
||||
else:
|
||||
result[k] = DEFAULTS.get(k)
|
||||
if k in NUMERIC_KEYS:
|
||||
try:
|
||||
f = float(result[k])
|
||||
result[k] = int(f) if f.is_integer() else f
|
||||
except (TypeError, ValueError):
|
||||
result[k] = DEFAULTS.get(k)
|
||||
|
||||
return result
|
||||
|
||||
@@ -108,6 +119,25 @@ def update_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
},
|
||||
)
|
||||
|
||||
# #99: rules live here in the 4xx layer, not in the DB (structural only).
|
||||
if "crop_fill" in payload:
|
||||
raw = payload["crop_fill"]
|
||||
try:
|
||||
pct = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
pct = None
|
||||
if pct is None or not (1 <= pct <= 100):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "validation",
|
||||
"message": "crop_fill must be a number between 1 and 100 (percent)",
|
||||
"detail": {"field": "crop_fill"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
for k, v in payload.items():
|
||||
if k not in ALL_KEYS:
|
||||
|
||||
@@ -138,3 +138,51 @@ def test_settings_decrypt_failure_returns_reenter_state_not_500(monkeypatch):
|
||||
body = resp.json()
|
||||
assert body["inference_api_key"] == {"configured": False}
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_crop_fill_defaults_to_90(monkeypatch):
|
||||
"""Given: no crop_fill row; When: GET; Then: crop_fill == 90 (#99 req.4 default 90%)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
resp = client.get("/api/v1/settings")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["crop_fill"] == 90
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_crop_fill_round_trips_as_plain_number(monkeypatch):
|
||||
"""Given: crop_fill in PUT; When: PUT then GET; Then: plain TEXT row (no value_enc) and a number back (#99 req.4)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
resp = client.put("/api/v1/settings", json={"crop_fill": 75})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["crop_fill"] == 75
|
||||
conn = db_mod.get_connection()
|
||||
row = conn.execute("SELECT value, value_enc FROM settings WHERE key='crop_fill'").fetchone()
|
||||
assert row["value"] == "75" and row["value_enc"] is None
|
||||
assert client.get("/api/v1/settings").json()["crop_fill"] == 75
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_crop_fill_out_of_range_is_4xx_and_not_written(monkeypatch):
|
||||
"""Given: bad crop_fill; When: PUT; Then: 400 validation, nothing written (#99; rules in the 4xx layer)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
for bad in (0, 101, -5, "abc", None):
|
||||
resp = client.put("/api/v1/settings", json={"crop_fill": bad})
|
||||
assert resp.status_code == 400, f"{bad!r} should be rejected"
|
||||
assert resp.json()["error"]["code"] == "validation"
|
||||
assert client.get("/api/v1/settings").json()["crop_fill"] == 90 # untouched default
|
||||
# boundaries are valid
|
||||
assert client.put("/api/v1/settings", json={"crop_fill": 100}).status_code == 200
|
||||
assert client.put("/api/v1/settings", json={"crop_fill": 1}).status_code == 200
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
@@ -1,713 +0,0 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Design artifact for SlabUploader issue #99 — "Tape-line overlay replaces axis
|
||||
rectangle (calibration length + crop)".
|
||||
Owner-approved design proposal. This is a WIREFRAME, not shippable code.
|
||||
|
||||
What it settles: the black ~2pt line is a FIXED entity in the MIDDLE of the
|
||||
3:4 rectangle; the SLAB alone pans / zooms / tilts so its long axis lands on
|
||||
the line; the ticks belong to the line, slide along it, and L = tick to tick;
|
||||
the crop % moves only the overlay's two ends — (1 - fill)/2 of the long axis
|
||||
in from the top and the bottom — and never changes the aspect ratio.
|
||||
|
||||
SCRUB BEFORE SHIPPING — wireframe scaffolding, none of this belongs in the
|
||||
production workflow:
|
||||
- the viewport switcher (412 / 390 / 720 / 1440) and the flowbar step nav
|
||||
- the requirements-coverage board below the stage
|
||||
- the "was / now" pair of mini diagrams
|
||||
- the PROPOSAL / OPEN markers and the implementation-notes card
|
||||
- in-stage tutorial labels ("crop 90% — overlay 5.0% in from the top", …)
|
||||
- the alignment read-out box (a proposal, not in the issue; nothing gated on it)
|
||||
- the synthetic slab drawing + grain, and the sample values (88.0" tape, 2.125 thickness)
|
||||
- the window.__wf debug hook
|
||||
What stays: the fixed middle line + its two end ticks, the slab transform set
|
||||
(pan / zoom / tilt) with the visible zoom row and Fit, the tick drag + nudge
|
||||
semantics, and the single shared crop_fill.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SlabUploader — tape-line overlay wireframe (issue #99)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#121212; --card:#1c1c1c; --card2:#1d1d1d; --line:#333;
|
||||
--fg:#eee; --mut:#aaa; --acc:#00e5ff; --acc2:#002;
|
||||
--ok:#7fd37f; --warn:#ffb4a2; --r:3px;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,"Segoe UI",sans-serif;font-size:16px}
|
||||
.top{max-width:900px;margin:0 auto;padding:14px 12px 4px}
|
||||
.top h1{font-size:1rem;margin:0 0 8px;color:var(--acc);letter-spacing:.04em;text-transform:uppercase}
|
||||
.flowbar{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.fstep{font-size:.72rem;padding:5px 8px;border-radius:var(--r);border:1px solid var(--line);background:var(--card2);color:var(--mut);cursor:pointer}
|
||||
.fstep.on{background:var(--acc);color:var(--acc2);font-weight:700;border-color:var(--acc)}
|
||||
.vp{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:.72rem;color:var(--mut)}
|
||||
.vp button{border:1px solid var(--line);background:var(--card2);color:var(--fg);font-size:.72rem;padding:4px 8px;border-radius:var(--r);cursor:pointer}
|
||||
.vp button.on{background:var(--acc);color:var(--acc2);font-weight:700}
|
||||
.stage{display:flex;justify-content:center;padding:18px 10px 40px}
|
||||
#phone{width:412px;max-width:100%;background:#0c0c0c;border:1px solid #2a2a2a;border-radius:18px;padding:14px;min-height:640px}
|
||||
.panel{padding:10px 4px;color:var(--fg)}
|
||||
h1{font-size:1.3rem;margin:0 0 10px}
|
||||
h3{font-size:.95rem;margin:0 0 8px}
|
||||
.hint,.sub{color:var(--mut);font-size:.82rem;line-height:1.5;margin:0 0 12px}
|
||||
.row{display:flex;gap:10px;margin-top:16px}
|
||||
.primary{flex:1;padding:9px;font-size:.95rem;border-radius:var(--r);border:0;background:var(--acc);color:var(--acc2);font-weight:700;cursor:pointer;align-self:flex-start}
|
||||
.ghost{padding:9px 18px;border-radius:var(--r);border:1px solid #444;background:transparent;color:#ccc;cursor:pointer}
|
||||
.nudge{color:#e8a;font-size:.74rem;margin:6px 0 0}
|
||||
/* ---- the stage: fixed line + movable slab ---- */
|
||||
.tw{position:relative;width:100%;aspect-ratio:3/4;background:#111;border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
|
||||
.tw svg{display:block;width:100%;height:100%;touch-action:none;cursor:grab;user-select:none}
|
||||
.tw.dragging svg{cursor:grabbing}
|
||||
.tickg{cursor:ns-resize}
|
||||
.rotg{cursor:ew-resize}
|
||||
.controls{margin-top:16px;display:flex;flex-direction:column;gap:14px}
|
||||
.angle,.croprow{display:flex;align-items:center;gap:10px}
|
||||
.thumbbtn{width:44px;height:44px;border-radius:50%;font-size:1.4rem;border:1px solid #444;background:var(--card2);color:var(--fg);flex:none;cursor:pointer}
|
||||
input[type=range]{flex:1}
|
||||
.val{min-width:4ch;text-align:right;font-variant-numeric:tabular-nums}
|
||||
.ctl-label{font-size:.78rem;color:var(--mut);margin:0 0 6px}
|
||||
.nums{display:flex;flex-direction:column;gap:8px;margin-top:8px}
|
||||
.nums div{display:flex;justify-content:space-between;border-bottom:1px solid #2a2a2a;padding-bottom:6px;font-size:.84rem;gap:10px}
|
||||
.nums span:first-child{color:var(--mut)}
|
||||
.nums .tab{font-variant-numeric:tabular-nums;text-align:right}
|
||||
.nums .dim{color:#777}
|
||||
.status{margin-top:12px;padding:10px 12px;border-radius:var(--r);font-size:.88rem;font-weight:600;background:#2a1512;color:var(--warn)}
|
||||
.status.ok{background:#12210f;color:var(--ok)}
|
||||
.plan{margin-top:18px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r);background:var(--card)}
|
||||
.plan h3{font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 8px}
|
||||
.plan ul{margin:0;padding-left:16px;color:var(--mut);font-size:.76rem;line-height:1.65}
|
||||
.plan code{color:#ddd;font-size:.72rem}
|
||||
.proposal{display:inline-block;font-size:.6rem;font-weight:800;letter-spacing:.03em;background:#3a2a00;color:#ffc96b;border-radius:2px;padding:0 4px;margin-left:5px;vertical-align:1px}
|
||||
/* replacement two-up */
|
||||
.swap{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:14px}
|
||||
.swap figure{margin:0;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px;text-align:center}
|
||||
.swap svg{width:100%;display:block;background:#0f0f0f;border-radius:2px}
|
||||
.swap figcaption{font-size:.68rem;color:var(--mut);margin-top:6px;line-height:1.4}
|
||||
.swap .gone{color:#ff9d8a}
|
||||
/* scope lineup */
|
||||
.lineup{list-style:none;padding:0;margin:12px 0 0;display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
||||
.lineup-item{text-align:center;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px}
|
||||
.lineup-item svg{width:100%;display:block;border-radius:2px;background:#0f0f0f}
|
||||
.role{display:block;margin-top:6px;font-size:.72rem;color:var(--fg);font-weight:600}
|
||||
.rot-badge{display:inline-block;margin-top:3px;font-size:.6rem;padding:0 4px;border-radius:2px;font-weight:700}
|
||||
.rot-badge.yes{background:var(--acc);color:var(--acc2)}
|
||||
.rot-badge.no{background:#3a3a3a;color:#bbb}
|
||||
/* fields + settings */
|
||||
.fields{display:flex;gap:16px;margin-top:16px}
|
||||
.field{flex:1}
|
||||
.label{display:block;font-size:.85rem;color:var(--mut);margin-bottom:8px}
|
||||
.inp{box-sizing:border-box;width:100%;min-height:44px;padding:10px;font-size:.95rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:var(--fg);font-variant-numeric:tabular-nums}
|
||||
.settings label{display:block;margin:10px 0 4px;font-size:.85rem;color:var(--mut)}
|
||||
.settings .section{margin-top:20px;padding-top:14px;border-top:1px solid var(--line)}
|
||||
.settings button{padding:8px 12px;font-size:.85rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:#ddd;cursor:pointer;margin-top:10px}
|
||||
.settings .msg{font-size:.8rem;margin:8px 0 0;color:var(--mut)}
|
||||
.settings .msg.ok{color:var(--ok)}
|
||||
/* requirement coverage board (outside the phone) */
|
||||
.cov{max-width:900px;margin:0 auto 40px;padding:0 12px}
|
||||
.cov-inner{border:1px solid var(--line);border-radius:var(--r);background:var(--card);padding:12px 14px}
|
||||
.cov h2{font-size:.86rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 10px}
|
||||
.cov table{width:100%;border-collapse:collapse;font-size:.76rem}
|
||||
.cov th,.cov td{text-align:left;padding:6px 8px;border-bottom:1px solid #2a2a2a;vertical-align:top;color:var(--mut)}
|
||||
.cov th{color:var(--fg);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
|
||||
.cov td:first-child{color:var(--fg);white-space:nowrap}
|
||||
.cov .where{color:#ddd}
|
||||
/* narrow screens: stack the table instead of forcing its 400px min width */
|
||||
@media (max-width:760px){
|
||||
.cov thead{display:none}
|
||||
.cov table,.cov tbody,.cov tr,.cov td{display:block;width:auto}
|
||||
.cov tr{border-bottom:1px solid #2a2a2a;padding:8px 0}
|
||||
.cov tr:last-child{border-bottom:0}
|
||||
.cov td{border:0;padding:1px 0}
|
||||
.cov td:first-child{white-space:normal;color:var(--acc);font-weight:700}
|
||||
.cov td:nth-child(3)::before{content:"→ ";color:#666}
|
||||
}
|
||||
.hidden{display:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<h1>SlabUploader · tape-line overlay — issue #99</h1>
|
||||
<div class="flowbar" id="flowbar"></div>
|
||||
<div class="vp">
|
||||
<span>Viewport</span>
|
||||
<span id="vpval">412</span>
|
||||
<button data-w="390">390</button>
|
||||
<button data-w="412" class="on">412</button>
|
||||
<button data-w="720">720</button>
|
||||
<button data-w="1440">1440</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stage"><div id="phone"></div></div>
|
||||
<div class="cov"><div class="cov-inner">
|
||||
<h2>Issue #99 — requirement coverage in this wireframe</h2>
|
||||
<table>
|
||||
<thead><tr><th>Req</th><th>What it says</th><th>Where it shows up</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>1 Overlay</td><td>Black ~2pt line + two rifle-scope end ticks; ticks draggable; replaces the cyan bbox + red arrow </td><td class="where">Tape line step: the black line, its two end ticks, the "was / now" pair below the stage, and the hint copy</td></tr>
|
||||
<tr><td>2 Controls</td><td>Pan / zoom / tilt the photo; ticks ≥44×44px, draggable, arrow + Shift+arrow fine nudge; one pointer path</td><td class="where">Zoom now has a visible row (− / slider / + / Fit) beside the tilt row, plus wheel, pinch, <b>+</b>/<b>−</b> keys and double-click to fit; pan = drag, tilt = handle + arrows</td></tr>
|
||||
<tr><td>3 Fixed line</td><td>The reference line is fixed in the MIDDLE of the rectangle, and it IS the crop line: its ends are the crop insets</td><td class="where">The line's x is computed from the frame, never from the slab's transform; the crop % moves only its two ends, (1 − fill)/2 of the long axis in from the top and the bottom</td></tr>
|
||||
<tr><td>4 Crop %</td><td>Global <code>settings.crop_fill</code> (default 90) replaces both <code>FILL = 0.9</code> copies, read live, non-retro. <b>It does not change the aspect ratio</b></td><td class="where">Crop fill control on the tape step and the Settings step: 90% puts the overlay 5% in from the top and the bottom; the 3:4 rectangle itself never changes</td></tr>
|
||||
<tr><td>5 Crop content</td><td>Post-BG-removal, slab pixels only, transparent background, bounds = slab extent</td><td class="where">Mask step note + the crop frame outline around the stage</td></tr>
|
||||
<tr><td>6 Transform scope</td><td>Tilt persists only to the calibration pic and its dimension-overlay clone; siblings keep their own orientation</td><td class="where">Photos-ready step lineup: calibration + dimensioned copy tilted, backside sibling untouched</td></tr>
|
||||
<tr><td>7 Doc fix</td><td><code>capture/types.ts:46</code> says "80%-fill"; live value is 90%</td><td class="where">Noted in the Implementation notes card on the Settings step</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
<script>
|
||||
// ===========================================================================
|
||||
// issue #99 — the tape line is a FIXED ENTITY.
|
||||
//
|
||||
// * The black ~2pt line and its two end ticks never move: not on tilt, not
|
||||
// on pan, not on zoom. It is fixed in the MIDDLE of the rectangle.
|
||||
// * The SLAB is the only thing the operator moves (pan / zoom / tilt) until
|
||||
// its long axis lies on the line, tick to tick.
|
||||
// * The ticks belong to the line - they slide along it, never with the photo.
|
||||
// * L = the distance between the two ticks (the owner's tape reading).
|
||||
// ===========================================================================
|
||||
const S = {
|
||||
screen: "tape",
|
||||
// slab transform (the only movable entity)
|
||||
tilt: 14, // degrees
|
||||
zoom: 1,
|
||||
panX: 60, panY: 0,
|
||||
// the line's ticks: y positions along the fixed line. They rest at the crop
|
||||
// insets - (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
||||
// bottom - and the operator drags them onto their tape marks from there.
|
||||
tickA: 32,
|
||||
tickB: 608,
|
||||
focus: "A",
|
||||
fill: 90, // settings.crop_fill - global, default 90
|
||||
// declared values (entered on Measure, shown on both steps)
|
||||
L: "88",
|
||||
thickness: "2.125",
|
||||
savedMsg: "",
|
||||
};
|
||||
|
||||
// The stage IS the crop frame: 3:4, the same aspect composeInventoryFrame emits.
|
||||
const VB = { w: 480, h: 640 };
|
||||
// Slab drawn in stage coordinates. What has to be aligned is the slab's LONG
|
||||
// AXIS - the centreline running down the middle of the board, which is where
|
||||
// the tape is laid. (Not a physical side edge.)
|
||||
const SLAB = {
|
||||
path: "M 166 40 L 161 150 L 168 300 L 162 455 L 166 600 L 330 608 L 396 596 L 400 320 L 396 96 L 330 34 Z",
|
||||
axis: [{ x: 282, y: 40 }, { x: 282, y: 600 }],
|
||||
};
|
||||
const TICK_LEN = 26; // rifle-scope end tick, total
|
||||
const TICK_GAP = 40; // min separation between ticks
|
||||
// Grab radius in stage units. The stage renders ~352-354px wide for a 480-unit
|
||||
// viewBox, so 68 units is ~50px on screen - comfortably over the required 44x44.
|
||||
const HIT_R = 34;
|
||||
const ALIGN_TOL = 3; // stage units
|
||||
|
||||
const rad = (a) => a * Math.PI / 180;
|
||||
// The fixed line sits in the MIDDLE of the rectangle: the centre of the 3:4
|
||||
// crop frame, which is where the crop centres the slab's long axis.
|
||||
const lineX = () => VB.w / 2;
|
||||
// Crop % = (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
||||
// bottom (cropMarginFraction() in png/crop.ts). It never touches the aspect
|
||||
// ratio: the rectangle stays 3:4 and only the overlay's two ends move.
|
||||
const cropInset = () => VB.h * (1 - S.fill / 100) / 2;
|
||||
const cropTicks = () => ({ a: cropInset(), b: VB.h - cropInset() });
|
||||
const spanPx = () => Math.abs(S.tickB - S.tickA);
|
||||
const pxPerIn = () => spanPx() / (parseFloat(S.L) || 1);
|
||||
|
||||
// slab transform: zoom + tilt about the stage centre, then pan
|
||||
function mapPoint(p) {
|
||||
const cx = VB.w / 2, cy = VB.h / 2, a = rad(S.tilt), z = S.zoom;
|
||||
const x = (p.x - cx) * z, y = (p.y - cy) * z;
|
||||
return { x: cx + x * Math.cos(a) - y * Math.sin(a) + S.panX,
|
||||
y: cy + x * Math.sin(a) + y * Math.cos(a) + S.panY };
|
||||
}
|
||||
function slabTransform() {
|
||||
return `translate(${S.panX} ${S.panY}) translate(${VB.w / 2} ${VB.h / 2}) rotate(${S.tilt}) scale(${S.zoom}) translate(${-VB.w / 2} ${-VB.h / 2})`;
|
||||
}
|
||||
// where the slab's long axis crosses a given stage y
|
||||
function axisXAt(y) {
|
||||
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
||||
if (Math.abs(b.y - a.y) < 1e-6) return a.x;
|
||||
return a.x + ((y - a.y) / (b.y - a.y)) * (b.x - a.x);
|
||||
}
|
||||
const axisAngle = () => {
|
||||
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
||||
return Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI;
|
||||
};
|
||||
// the axis is vertical when its angle points straight down (or up) the screen
|
||||
const axisVertical = () => { const d = ((axisAngle() % 180) + 180) % 180; return Math.abs(d - 90) < 1.5; };
|
||||
const axisOffset = () => axisXAt((S.tickA + S.tickB) / 2) - lineX();
|
||||
const aligned = () => axisVertical() && Math.abs(axisOffset()) <= ALIGN_TOL;
|
||||
|
||||
// ---------------------------------------------------------------- the stage
|
||||
function slabSvg() {
|
||||
return `
|
||||
<g id="slab" transform="${slabTransform()}">
|
||||
<clipPath id="slabClip"><path d="${SLAB.path}"/></clipPath>
|
||||
<path d="${SLAB.path}" fill="hsl(28,36%,31%)" stroke="#000" stroke-opacity=".25"/>
|
||||
<g clip-path="url(#slabClip)" opacity=".8">
|
||||
<path d="M 176 90 Q 290 74 400 96" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 174 200 Q 290 190 400 210" stroke="hsl(28,34%,35%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 172 320 Q 290 306 400 326" stroke="hsl(28,34%,37%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 172 440 Q 290 432 400 452" stroke="hsl(28,34%,34%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 174 550 Q 290 540 400 560" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
||||
</g>
|
||||
</g>`;
|
||||
}
|
||||
// the FIXED entity: black 2pt line + two rifle-scope end ticks. No transform.
|
||||
function fixtureSvg() {
|
||||
const x = lineX();
|
||||
const tick = (y, id) => {
|
||||
const on = S.focus === id;
|
||||
return `
|
||||
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
||||
<circle cx="${x}" cy="${y}" r="5" fill="${on ? "#ff3d71" : "#000"}" stroke="#fff" stroke-width="1.5"/>
|
||||
<circle class="tickg" data-tick="${id}" cx="${x}" cy="${y}" r="${HIT_R}" fill="transparent"/>`;
|
||||
};
|
||||
return `
|
||||
<line x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round"/>
|
||||
<line id="tapeLine" x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
||||
${tick(S.tickA, "A")}${tick(S.tickB, "B")}`;
|
||||
}
|
||||
// The crop frame: the rectangle's ASPECT NEVER CHANGES (3:4, from crop.ts).
|
||||
// The crop % only moves the overlay's ends: (1 - fill)/2 of the long axis in
|
||||
// from the top and the bottom, shaded here as the two bands it leaves outside.
|
||||
function cropFrameSvg() {
|
||||
const m = cropInset();
|
||||
return `
|
||||
<rect x="0" y="0" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
||||
<rect x="0" y="${VB.h - m}" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
||||
<rect id="cropframe" x=".5" y=".5" width="${VB.w - 1}" height="${VB.h - 1}" fill="none" stroke="#555" stroke-width="1" stroke-dasharray="6 5"/>
|
||||
<text x="6" y="${m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">crop ${S.fill}% — overlay ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top</text>
|
||||
<text x="6" y="${VB.h - m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the bottom</text>`;
|
||||
}
|
||||
function rotateHandleSvg() {
|
||||
return `
|
||||
<g class="rotg" data-rot="1">
|
||||
<circle cx="${VB.w / 2}" cy="${VB.h - 26}" r="17" fill="rgba(0,0,0,.6)" stroke="var(--acc)" stroke-width="1.5"/>
|
||||
<text x="${VB.w / 2}" y="${VB.h - 21}" fill="var(--acc)" font-size="15" font-family="system-ui" text-anchor="middle">↻</text>
|
||||
<text x="${VB.w / 2 + 24}" y="${VB.h - 22}" fill="#aaa" font-size="10.5" font-family="system-ui">drag to tilt the slab</text>
|
||||
</g>`;
|
||||
}
|
||||
function stageSvg() {
|
||||
return `<svg viewBox="0 0 ${VB.w} ${VB.h}" id="tsvg" aria-label="fixed tape line over a movable slab">
|
||||
<rect width="${VB.w}" height="${VB.h}" fill="#16281c"/>
|
||||
${cropFrameSvg()}
|
||||
${slabSvg()}
|
||||
${fixtureSvg()}
|
||||
${rotateHandleSvg()}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ screens
|
||||
const screens = {
|
||||
batch() {
|
||||
return `<section class="panel">
|
||||
<h1>Batch photos</h1>
|
||||
<p class="hint">Unchanged by #99 — shown for context. Sheet is green or black and fills the frame around the slab.</p>
|
||||
<div class="row"><button class="primary" data-act="to-mask">Pick calibration photo</button></div>
|
||||
</section>`;
|
||||
},
|
||||
mask() {
|
||||
return `<section class="panel">
|
||||
<h1>Mask</h1>
|
||||
<p class="hint">Unchanged by #99. The approved mask is still what the crop bounds come from.</p>
|
||||
<div class="nums">
|
||||
<div><span>Crop bounds</span><span class="tab">slab extent (kept pixels only)</span></div>
|
||||
<div><span>Crop contents</span><span class="tab">slab pixels only, transparent background</span></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-batch">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-tape">Approve mask</button>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
tape() {
|
||||
const off = axisOffset(), ok = aligned();
|
||||
return `<section class="panel">
|
||||
<h1>Tape line</h1>
|
||||
<p class="hint">Drag each tick to where your tape started and ended.</p>
|
||||
<div class="tw" id="tw">${stageSvg()}</div>
|
||||
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
||||
<p class="nudge">Move the <b>slab</b> instead: drag it to pan, wheel or pinch to zoom, tilt it with the ↻ handle or the arrow keys — until the board's long axis (the line down the middle of the slab) lies on the fixed line, tick to tick.</p>
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<p class="ctl-label">Tilt the slab</p>
|
||||
<div class="angle">
|
||||
<button class="thumbbtn" data-act="tilt" data-d="-1" aria-label="tilt left">−</button>
|
||||
<input type="range" min="0" max="359" value="${S.tilt}" data-act="tilt-range" aria-label="slab tilt"/>
|
||||
<button class="thumbbtn" data-act="tilt" data-d="1" aria-label="tilt right">+</button>
|
||||
<span class="val" id="tiltVal">${S.tilt}°</span>
|
||||
</div>
|
||||
<p class="nudge">← → tilt 1° · Shift + ← → tilt 0.1° · or drag the ↻ handle</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Zoom the slab <span class="sub" style="font-size:.7rem">wheel or pinch over the photo</span></p>
|
||||
<div class="angle">
|
||||
<button class="thumbbtn" data-act="zoom" data-d="-1" aria-label="zoom out">−</button>
|
||||
<input type="range" min="50" max="300" value="${Math.round(S.zoom * 100)}" data-act="zoom-range" aria-label="slab zoom"/>
|
||||
<button class="thumbbtn" data-act="zoom" data-d="1" aria-label="zoom in">+</button>
|
||||
<span class="val" id="zoomVal">${Math.round(S.zoom * 100)}%</span>
|
||||
</div>
|
||||
<p class="nudge">Wheel or pinch over the photo zooms to the cursor · <b>+</b> / <b>−</b> keys step 10% · double-click the photo to fit</p>
|
||||
<div class="row" style="margin-top:8px"><button class="ghost" data-act="fit">Fit — 100%, centred</button></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Tape end ticks <span class="sub" style="font-size:.7rem">on the line; drag along it</span></p>
|
||||
<p class="nudge">Grab a tick anywhere in its 44×44px target and drag it along the line. The tick you last grabbed takes the arrow keys: <b>↑ ↓</b> nudge 1px, Shift = 0.1px — its centre dot turns red to show which one.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Crop fill % <span class="sub" style="font-size:.7rem">global setting</span></p>
|
||||
<div class="croprow">
|
||||
<input type="range" min="1" max="100" value="${S.fill}" data-act="fill-range" aria-label="crop fill percent"/>
|
||||
<span class="val" id="fillVal">${S.fill}%</span>
|
||||
</div>
|
||||
<p class="nudge">settings.crop_fill (default 90). It sets how much of the rectangle the overlay covers: at ${S.fill}% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom. <b>The rectangle keeps its 3:4 aspect</b> — only the ends move, never the middle. Existing stored PNGs are never re-cropped.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status ${ok ? "ok" : ""}" id="alignstatus">${ok
|
||||
? "The board's long axis is on the line ✓"
|
||||
: `The board's long axis is ${off > 0 ? "right" : "left"} of the line by ${Math.abs(off).toFixed(0)} px${axisVertical() ? "" : `, and tilted ${S.tilt}°`}.`}</div>
|
||||
|
||||
<div class="nums">
|
||||
<div><span>Fixed line x <span class="dim">(middle of the 3:4 frame)</span></span><span class="tab" id="lineReadout">${lineX().toFixed(1)} of ${VB.w}</span></div>
|
||||
<div><span>Overlay insets <span class="dim">(from crop fill)</span></span><span class="tab" id="insetReadout">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom</span></div>
|
||||
<div><span>Tape span between ticks</span><span class="tab" id="spanReadout">${spanPx().toFixed(0)} px</span></div>
|
||||
<div><span>Your tape length <span class="dim">(entered on Measure)</span></span><span class="tab">${(parseFloat(S.L) || 0).toFixed(1)}″</span></div>
|
||||
<div><span>Scale from your ticks</span><span class="tab" id="scaleReadout">${pxPerIn().toFixed(2)} px/in</span></div>
|
||||
<div><span>Mask span projection</span><span class="tab dim">not used — sanity check only</span></div>
|
||||
</div>
|
||||
|
||||
<p class="nudge" style="margin-top:10px">Slab tilt ${S.tilt}° · zoom ${S.zoom.toFixed(2)}× <span class="dim">(moving these never changes the line)</span></p>
|
||||
|
||||
<div class="swap">
|
||||
<figure>${oldOverlayMini()}<figcaption class="gone">✗ removed — cyan axis rectangle + red arrow <span class="dim">(PhotoStage.svelte:56-82)</span></figcaption></figure>
|
||||
<figure>${newOverlayMini()}<figcaption>this step — fixed black 2pt line + two end ticks</figcaption></figure>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-mask">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-measure">Approve tape line</button>
|
||||
</div>
|
||||
|
||||
<div class="plan">
|
||||
<h3>Two things on this screen are my proposal, not the issue</h3>
|
||||
<ul>
|
||||
<li>The alignment read-out above <span class="proposal">PROPOSAL</span> — the issue says "rotate until the taped line sits on the fixed reference line" and doesn't specify how the operator knows. Tell me to drop it and it's gone; nothing is gated on it.</li>
|
||||
<li>The shaded crop bands <span class="proposal">PROPOSAL</span> — drawn only to make the crop fill visible: the two strips the overlay leaves outside itself at the top and the bottom.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
measure() {
|
||||
const mini = (tilt, marks) => `
|
||||
<svg viewBox="0 0 480 640">
|
||||
<rect width="480" height="640" fill="#16281c"/>
|
||||
<g transform="translate(240 320) rotate(${tilt}) translate(-240 -320)">
|
||||
<path d="M 168 60 L 300 52 L 392 80 L 400 300 L 388 560 L 300 596 L 168 588 Z" fill="hsl(28,36%,31%)"/>
|
||||
${marks ? `<line x1="168" y1="100" x2="168" y2="548" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="155" y1="100" x2="181" y2="100" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="155" y1="548" x2="181" y2="548" stroke="#000" stroke-width="2.4"/>` : ""}
|
||||
</g>
|
||||
</svg>`;
|
||||
return `<section class="panel">
|
||||
<h1>Photos ready</h1>
|
||||
<div class="fields">
|
||||
<div class="field"><span class="label">Length L (tape reading)</span><input class="inp" type="number" step="0.125" data-act="length" value="${S.L}"/></div>
|
||||
<div class="field"><span class="label">Thickness</span><input class="inp" type="number" step="0.125" data-act="thickness" value="${S.thickness}"/></div>
|
||||
</div>
|
||||
<div class="nums">
|
||||
<div><span>L maps to</span><span class="tab">tick-to-tick span (${spanPx().toFixed(0)} px)</span></div>
|
||||
<div><span>Scale used</span><span class="tab">${pxPerIn().toFixed(2)} px/in</span></div>
|
||||
<div><span>Mask span projection</span><span class="tab dim">not used</span></div>
|
||||
</div>
|
||||
<div class="plan">
|
||||
<h3>Rotation scope — req. 6</h3>
|
||||
<ul><li>The aligning tilt reaches the calibration pic and its dimension-overlay clone only.</li>
|
||||
<li>A backside sibling keeps its own orientation; rotation is never propagated through the shared mask/crop settings.</li></ul>
|
||||
</div>
|
||||
<ol class="lineup">
|
||||
<li class="lineup-item">${mini(S.tilt, true)}<span class="role">Clean calibration</span><span class="rot-badge yes">tilted ${S.tilt}°</span></li>
|
||||
<li class="lineup-item">${mini(S.tilt, false)}<span class="role">Dimension overlay</span><span class="rot-badge yes">same ${S.tilt}°</span></li>
|
||||
<li class="lineup-item">${mini(0, false)}<span class="role">Backside sibling</span><span class="rot-badge no">not re-tilted</span></li>
|
||||
</ol>
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-tape">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-settings">Continue</button>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
settings() {
|
||||
return `<section class="panel settings">
|
||||
<h1>Settings</h1>
|
||||
<div class="section">
|
||||
<h3>Crop — global</h3>
|
||||
<label for="crop-fill">Crop Fill % (default 90) — how much of the rectangle the overlay covers</label>
|
||||
<div class="croprow">
|
||||
<input type="range" id="crop-fill" min="1" max="100" data-act="fill-range" value="${S.fill}"/>
|
||||
<span class="val" id="fillVal">${S.fill}%</span>
|
||||
</div>
|
||||
<p class="sub" style="margin-top:8px">One shared value replaces the two duplicated <code>FILL = 0.9</code> constants. Both the overlay's ends and the PNG crop read it live, so they can never disagree. At 90% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom; <b>the 3:4 aspect ratio never changes</b>. Applies to slabs processed from now on; stored PNGs are not re-cropped.</p>
|
||||
<button data-act="save-fill">Save Changes</button>
|
||||
<p class="msg ${S.savedMsg ? "ok" : ""}">${S.savedMsg}</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>Implementation notes</h3>
|
||||
<ul class="plan" style="border:0;padding:0;background:none">
|
||||
<li><code>capture/types.ts:46</code> says "80%-fill PNG crop" — stale; the live value is 90%. Comment gets fixed (req. 7).</li>
|
||||
<li>Replaces the cyan bbox + red arrow in <code>PhotoStage.svelte:56-82</code>.</li>
|
||||
<li><code>maskSpanAlong</code> is demoted to a sanity check.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="row"><button class="ghost" data-act="to-tape">Back to tape line</button></div>
|
||||
</section>`;
|
||||
},
|
||||
};
|
||||
|
||||
// the two mini overlays for the was/now pair
|
||||
function oldOverlayMini() {
|
||||
return `<svg viewBox="0 0 120 160">
|
||||
<rect width="120" height="160" fill="#141414"/>
|
||||
<rect x="26" y="18" width="68" height="124" fill="hsl(28,30%,30%)"/>
|
||||
<rect x="20" y="12" width="80" height="136" fill="none" stroke="#00e5ff" stroke-width="3" stroke-dasharray="8 5"/>
|
||||
<line x1="60" y1="150" x2="60" y2="26" stroke="#ff3d71" stroke-width="4"/>
|
||||
<path d="M 60 20 L 52 34 L 68 34 Z" fill="#ff3d71"/>
|
||||
</svg>`;
|
||||
}
|
||||
function newOverlayMini() {
|
||||
return `<svg viewBox="0 0 120 160">
|
||||
<rect width="120" height="160" fill="#141414"/>
|
||||
<rect x="34" y="18" width="52" height="124" fill="hsl(28,30%,30%)"/>
|
||||
<line x1="60" y1="30" x2="60" y2="132" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="48" y1="30" x2="72" y2="30" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="48" y1="132" x2="72" y2="132" stroke="#000" stroke-width="2.4"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- plumbing
|
||||
const phone = document.getElementById('phone');
|
||||
const flowbar = document.getElementById('flowbar');
|
||||
const STEPS = [
|
||||
["batch", "Batch photos"], ["mask", "Mask"], ["tape", "Tape line (#99)"],
|
||||
["measure", "Photos ready"], ["settings", "⚙ Settings"],
|
||||
];
|
||||
function render() {
|
||||
phone.innerHTML = screens[S.screen]();
|
||||
flowbar.innerHTML = STEPS.map(([k, label]) =>
|
||||
`<button class="fstep ${S.screen === k ? "on" : ""}" data-goto="${k}">${label}</button>`).join('');
|
||||
if (S.screen === "tape") { bindStage(); refreshStage(); }
|
||||
}
|
||||
flowbar.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-goto]');
|
||||
if (!b) return;
|
||||
S.screen = b.dataset.goto;
|
||||
render();
|
||||
});
|
||||
document.querySelector('.vp').addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-w]');
|
||||
if (!b) return;
|
||||
document.querySelectorAll('.vp [data-w]').forEach((x) => x.classList.remove('on'));
|
||||
b.classList.add('on');
|
||||
phone.style.width = (b.dataset.w > 720 ? '412px' : b.dataset.w + 'px');
|
||||
document.getElementById('vpval').textContent = b.dataset.w;
|
||||
});
|
||||
|
||||
// Replace only the stage + the live readouts, so an in-progress drag survives
|
||||
// (a full panel re-render would destroy the element under the pointer).
|
||||
function refreshStage() {
|
||||
const host = document.getElementById('tw');
|
||||
if (!host) return;
|
||||
host.innerHTML = stageSvg();
|
||||
const set = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
|
||||
set('tiltVal', S.tilt + '\u00B0');
|
||||
set('zoomVal', Math.round(S.zoom * 100) + '%');
|
||||
set('fillVal', S.fill + '%');
|
||||
set('insetReadout', `${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom`);
|
||||
set('lineReadout', `${lineX().toFixed(1)} of ${VB.w}`);
|
||||
set('spanReadout', `${spanPx().toFixed(0)} px`);
|
||||
set('scaleReadout', `${pxPerIn().toFixed(2)} px/in`);
|
||||
const st = document.getElementById('alignstatus');
|
||||
if (st) {
|
||||
const ok = aligned(), off = axisOffset();
|
||||
st.className = 'status' + (ok ? ' ok' : '');
|
||||
st.innerHTML = ok
|
||||
? "The board's long axis is on the line \u2713"
|
||||
: `The board's long axis is ${off > 0 ? 'right' : 'left'} of the line by ${Math.abs(off).toFixed(0)} px`
|
||||
+ `${axisVertical() ? '' : `, and tilted ${S.tilt}\u00B0`}.`;
|
||||
}
|
||||
window.__wf = { tilt: S.tilt, zoom: S.zoom, panX: S.panX, panY: S.panY,
|
||||
tickA: S.tickA, tickB: S.tickB, focus: S.focus, fill: S.fill,
|
||||
lineX: lineX(), spanPx: spanPx(), axisOffset: axisOffset(),
|
||||
axisXAt: axisXAt((S.tickA + S.tickB) / 2), axisVertical: axisVertical(),
|
||||
axisAngle: axisAngle(), aligned: aligned() };
|
||||
}
|
||||
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const norm360 = (a) => ((a % 360) + 360) % 360;
|
||||
function tiltBy(d) { S.tilt = norm360(S.tilt + d); render(); }
|
||||
// Visible zoom path. #99 names wheel / trackpad pinch; this is the discoverable
|
||||
// twin, stepping about the stage centre so the slab stays where it is.
|
||||
function stepZoom(dir) {
|
||||
S.zoom = clamp(S.zoom * (dir > 0 ? 1.1 : 1 / 1.1), 0.5, 3);
|
||||
refreshStage();
|
||||
}
|
||||
function fitSlab() { S.zoom = 1; S.panX = 0; S.panY = 0; refreshStage(); }
|
||||
// no tick picker: the tick you last grabbed is the focused one (set on pointerdown)
|
||||
function nudgeTick(dir, mult) {
|
||||
const step = (mult === undefined ? 1 : mult);
|
||||
if (S.focus === "A") S.tickA = clamp(S.tickA + dir * step, 20, S.tickB - TICK_GAP);
|
||||
else S.tickB = clamp(S.tickB + dir * step, S.tickA + TICK_GAP, VB.h - 20);
|
||||
render();
|
||||
}
|
||||
|
||||
function bindStage() {
|
||||
const host = document.getElementById('tw');
|
||||
if (!host) return;
|
||||
const toVB = (e) => {
|
||||
const r = (host.querySelector('svg') || host).getBoundingClientRect();
|
||||
return { x: (e.clientX - r.left) * (VB.w / r.width), y: (e.clientY - r.top) * (VB.h / r.height) };
|
||||
};
|
||||
// zoom-to-cursor — moves the SLAB only; the line ignores it entirely
|
||||
function zoomAt(p, factor) {
|
||||
const cx = VB.w / 2, cy = VB.h / 2;
|
||||
const z0 = S.zoom, z1 = clamp(z0 * factor, 0.5, 3);
|
||||
if (z1 === z0) return;
|
||||
const qx = cx + (p.x - cx - S.panX) / z0;
|
||||
const qy = cy + (p.y - cy - S.panY) / z0;
|
||||
S.zoom = z1;
|
||||
S.panX = p.x - cx - z1 * (qx - cx);
|
||||
S.panY = p.y - cy - z1 * (qy - cy);
|
||||
refreshStage();
|
||||
}
|
||||
host.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
// No single event may slam the clamp: a PC ctrl+wheel notch arrives at
|
||||
// deltaY ~300 and a trackpad pinch arrives as ctrlKey too, so cap both the
|
||||
// delta and the per-event factor. Plain wheel stays ~16% per notch.
|
||||
const dy = clamp(e.deltaY, -200, 200);
|
||||
const k = e.ctrlKey ? 0.01 : 0.0015;
|
||||
zoomAt(toVB(e), clamp(Math.exp(-dy * k), 0.8, 1.25));
|
||||
}, { passive: false });
|
||||
// belt-and-braces fit: Chromium suppresses this after our pointerdown
|
||||
// preventDefault (see the tap tracking in end()), some engines still emit it
|
||||
host.addEventListener('dblclick', (e) => { e.preventDefault(); fitSlab(); });
|
||||
|
||||
let drag = null, tb = null, moved = false, downP = null;
|
||||
const off = { x: 0, y: 0 };
|
||||
host.addEventListener('pointerdown', (e) => {
|
||||
const tick = e.target.closest('[data-tick]');
|
||||
const rot = e.target.closest('[data-rot]');
|
||||
const p = toVB(e);
|
||||
moved = false; downP = p;
|
||||
if (tick) {
|
||||
S.focus = tick.dataset.tick;
|
||||
drag = { kind: "tick", id: S.focus };
|
||||
} else if (rot) {
|
||||
drag = { kind: "rot", start: p.x, tilt0: S.tilt };
|
||||
} else {
|
||||
drag = { kind: "slab" };
|
||||
off.x = p.x - S.panX; off.y = p.y - S.panY;
|
||||
}
|
||||
host.setPointerCapture(e.pointerId);
|
||||
host.classList.add('dragging');
|
||||
refreshStage();
|
||||
e.preventDefault();
|
||||
});
|
||||
host.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
const p = toVB(e);
|
||||
if (downP && (Math.abs(p.x - downP.x) > 3 || Math.abs(p.y - downP.y) > 3)) moved = true;
|
||||
if (drag.kind === "tick") {
|
||||
// ticks slide ALONG the fixed line — x is ignored on purpose
|
||||
const y = clamp(p.y, 20, VB.h - 20);
|
||||
if (drag.id === "A") S.tickA = clamp(y, 20, S.tickB - TICK_GAP);
|
||||
else S.tickB = clamp(y, S.tickA + TICK_GAP, VB.h - 20);
|
||||
} else if (drag.kind === "rot") {
|
||||
S.tilt = norm360(drag.tilt0 + (p.x - drag.start) * 0.6);
|
||||
} else {
|
||||
S.panX = clamp(p.x - off.x, -400, 400);
|
||||
S.panY = clamp(p.y - off.y, -400, 400);
|
||||
}
|
||||
refreshStage();
|
||||
});
|
||||
const end = (e) => {
|
||||
if (!drag) return;
|
||||
const wasSlab = drag.kind === "slab";
|
||||
const startP = toVB(e);
|
||||
drag = null;
|
||||
host.classList.remove('dragging');
|
||||
try { host.releasePointerCapture(e.pointerId); } catch (_) {}
|
||||
// double-tap to fit. We cannot use a 'dblclick' listener: preventDefault on
|
||||
// pointerdown suppresses the whole compatibility mouse sequence (click and
|
||||
// dblclick both stop arriving), so track the taps ourselves.
|
||||
if (wasSlab && !moved) {
|
||||
const now = performance.now();
|
||||
const near = tb && Math.abs(startP.x - tb.x) < 12 && Math.abs(startP.y - tb.y) < 12;
|
||||
if (near && now - tb.t < 350) { tb = null; fitSlab(); return; }
|
||||
tb = { t: now, x: startP.x, y: startP.y };
|
||||
}
|
||||
refreshStage();
|
||||
};
|
||||
host.addEventListener('pointerup', end);
|
||||
host.addEventListener('pointercancel', end);
|
||||
}
|
||||
|
||||
// keyboard: arrows = Adobe nudge model (1 / 0.1 with Shift), +/- = zoom steps
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (S.screen !== "tape") return;
|
||||
if (e.key === "+" || e.key === "=" || e.key === "-" || e.key === "_") {
|
||||
stepZoom(e.key === "+" || e.key === "=" ? 1 : -1);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!e.key.startsWith("Arrow")) return;
|
||||
const fine = e.shiftKey ? 0.1 : 1;
|
||||
if (e.key === "ArrowLeft") { S.tilt = norm360(S.tilt - fine); render(); }
|
||||
else if (e.key === "ArrowRight") { S.tilt = norm360(S.tilt + fine); render(); }
|
||||
else if (e.key === "ArrowUp") { nudgeTick(-1, fine); }
|
||||
else if (e.key === "ArrowDown") { nudgeTick(1, fine); }
|
||||
else return;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
phone.addEventListener('click', (e) => {
|
||||
const t = e.target.closest('[data-act]');
|
||||
if (!t) return;
|
||||
const a = t.dataset.act;
|
||||
if (a === "tilt") tiltBy(+t.dataset.d);
|
||||
else if (a === "zoom") stepZoom(+t.dataset.d);
|
||||
else if (a === "fit") fitSlab();
|
||||
else if (a === "to-mask") { S.screen = "mask"; render(); }
|
||||
else if (a === "to-batch") { S.screen = "batch"; render(); }
|
||||
else if (a === "to-tape") { S.screen = "tape"; render(); }
|
||||
else if (a === "to-measure") { S.screen = "measure"; render(); }
|
||||
else if (a === "to-settings") { S.screen = "settings"; render(); }
|
||||
else if (a === "save-fill") { S.savedMsg = `Saved crop_fill = ${S.fill}. The overlay's ends and every future PNG crop follow it; the rectangle keeps its 3:4 aspect and stored PNGs are untouched.`; render(); }
|
||||
});
|
||||
phone.addEventListener('input', (e) => {
|
||||
const t = e.target;
|
||||
if (t.dataset.act === "tilt-range") { S.tilt = +t.value; refreshStage(); }
|
||||
else if (t.dataset.act === "zoom-range") { S.zoom = clamp(+t.value / 100, 0.5, 3); refreshStage(); }
|
||||
else if (t.dataset.act === "fill-range") {
|
||||
// crop % moves the overlay's ENDS in/out from the top and bottom; the
|
||||
// rectangle's aspect never changes and the line's x never moves.
|
||||
S.fill = +t.value;
|
||||
const ct = cropTicks();
|
||||
S.tickA = ct.a; S.tickB = ct.b;
|
||||
refreshStage();
|
||||
}
|
||||
});
|
||||
phone.addEventListener('change', (e) => {
|
||||
const t = e.target;
|
||||
if (t.dataset.act === "length") { S.L = t.value; render(); }
|
||||
else if (t.dataset.act === "thickness") { S.thickness = t.value; render(); }
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+10
@@ -8,6 +8,7 @@
|
||||
"name": "slabuploader-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@neodrag/svelte": "2.3.3",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"svelte": "^4.2.20"
|
||||
},
|
||||
@@ -495,6 +496,15 @@
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@neodrag/svelte": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@neodrag/svelte/-/svelte-2.3.3.tgz",
|
||||
"integrity": "sha512-avXzhrilsBsnMFljhVAQ7h+6hbSIrvRCJ61GCiGbGISkC1QOhjDCNvPZo2+7KVwiYrnUBx4NRH0kTIqrcxv9Lg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@neodrag/svelte": "2.3.3",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"svelte": "^4.2.20"
|
||||
},
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
import AxisPanel from './capture/AxisPanel.svelte';
|
||||
import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||
import Settings from './create/settings/Settings.svelte';
|
||||
// #99 PROTOTYPE: tape-line overlay + per-slab length input + crop setting.
|
||||
// Throwaway — driven by the `tape-prototype` query param. Delete this branch
|
||||
// and the component after the design is settled.
|
||||
import TapePrototype from './capture/TapePrototype.svelte';
|
||||
import SlabList from './create/slabs/SlabList.svelte';
|
||||
import Review from './create/review/Review.svelte';
|
||||
import Publish from './create/publish/Publish.svelte';
|
||||
@@ -22,6 +26,10 @@
|
||||
import { clearDraft } from './draft/idb';
|
||||
|
||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||
// #99 PROTOTYPE entry: `?tape-prototype` boots the tape-line design sandbox.
|
||||
const isTapeProto =
|
||||
typeof window !== 'undefined' &&
|
||||
new URLSearchParams(window.location.search).has('tape-prototype');
|
||||
const session: CaptureSession = createCaptureSession();
|
||||
|
||||
let cap: Capture = { k: 'batch', photos: [] };
|
||||
@@ -47,6 +55,11 @@
|
||||
// ?noload to trigger the Resume prompt deterministically.
|
||||
const boot = new URLSearchParams(window.location.search);
|
||||
if (!boot.get('noload')) void session.resumeProbe();
|
||||
// #99: the global crop fill drives both the reference line and the PNG crop.
|
||||
// Falls back to the 90% default when settings are unreachable.
|
||||
void getSettings()
|
||||
.then((s) => session.setCropFill(s.crop_fill ?? 90))
|
||||
.catch(() => {});
|
||||
});
|
||||
onDestroy(() => {
|
||||
unsubCap();
|
||||
@@ -144,6 +157,15 @@
|
||||
</p>
|
||||
<p class="detail">Missing: {gate}</p>
|
||||
</main>
|
||||
{:else if isTapeProto}
|
||||
<!-- #99 PROTOTYPE: tape-line overlay design sandbox (throwaway). -->
|
||||
<main class="app proto">
|
||||
<div class="proto-bar">
|
||||
<strong>#99 tape-line prototype</strong>
|
||||
<a href="?noload">exit →</a>
|
||||
</div>
|
||||
<TapePrototype />
|
||||
</main>
|
||||
{:else}
|
||||
<main class="app">
|
||||
{#if curFlow.phase === 'review'}
|
||||
@@ -260,6 +282,12 @@
|
||||
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
||||
}
|
||||
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
|
||||
.proto-bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 16px; background: #1d1d1d; color: #00e5ff; font-size: 0.85rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.proto-bar a { color: #aaa; text-decoration: none; }
|
||||
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
||||
.hardstop h1 { font-size: 1.6rem; }
|
||||
.detail { color: #888; font-size: 0.85rem; }
|
||||
|
||||
@@ -454,6 +454,8 @@ export interface SettingsView {
|
||||
woo_create_status: string;
|
||||
taxonomy_last_sync_at: string | null;
|
||||
taxonomy_anchor: string | null;
|
||||
/** #99: global crop fill %, default 90. Drives the ref line + the PNG crop. */
|
||||
crop_fill: number;
|
||||
}
|
||||
|
||||
export function getSettings(): Promise<SettingsView> {
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
<script lang="ts">
|
||||
// #99 PROTOTYPE — throwaway design sandbox for the tape-line overlay.
|
||||
// NOT production. Delete this file (and the App.svelte branch) once the design
|
||||
// is settled.
|
||||
//
|
||||
// Model per issue #99:
|
||||
// - The MEASURE LINE IS FIXED VERTICAL on screen: it sits at the position the
|
||||
// crop places the slab's long edge (derived from crop fill + the 3:4 aspect
|
||||
// constants — the crop line IS the reference line).
|
||||
// - The SLAB (the photo) is the only thing that pans / zooms / rotates.
|
||||
// - Two rifle-scope ticks slide ALONG the fixed line (1-D drag). The owner
|
||||
// rotates the photo until its taped edge sits on the line, then places the
|
||||
// ticks at the tape start/end.
|
||||
// - Declared L = source-space distance between the ticks ÷ px-per-inch —
|
||||
// NOT a projected mask span. Zooming in makes tick placement finer.
|
||||
// - crop % is a GLOBAL setting (default 90%) and moves the fixed line.
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { maskSpanAlong } from '../geometry/rect';
|
||||
|
||||
// ---- synthetic slab, portrait (long edge ~vertical) --------------------
|
||||
const SW = 300; // source px width (slab width)
|
||||
const SH = 1040; // source px height (slab runs long along Y)
|
||||
let bitset = new Uint8Array(SW * SH);
|
||||
const rnd = (n: number): number => {
|
||||
const x = Math.sin(n * 127.1) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
function vnoise(x: number, y: number): number {
|
||||
const xi = Math.floor(x), yi = Math.floor(y);
|
||||
const xf = x - xi, yf = y - yi;
|
||||
const s = (a: number, b: number) => rnd(a * 374.13 + b * 217.91);
|
||||
const n00 = s(xi, yi), n10 = s(xi + 1, yi), n01 = s(xi, yi + 1), n11 = s(xi + 1, yi + 1);
|
||||
const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
|
||||
const a = n00 * (1 - u) + n10 * u;
|
||||
const b = n01 * (1 - u) + n11 * u;
|
||||
return a * (1 - v) + b * v;
|
||||
}
|
||||
function buildSlab(): void {
|
||||
bitset = new Uint8Array(SW * SH);
|
||||
for (let y = 0; y < SH; y++) {
|
||||
for (let x = 0; x < SW; x++) {
|
||||
const edge = Math.min(y, SH - y);
|
||||
const taper = 1 - 0.22 * Math.max(0, 1 - edge / 170);
|
||||
const wobble =
|
||||
1 + 0.05 * Math.sin(y / 53 + 0.4)
|
||||
+ 0.035 * Math.sin(y / 19 + 1.1)
|
||||
+ 0.02 * Math.sin(y / 8.1 + 2.2);
|
||||
const halfW = (SW / 2) * taper * wobble;
|
||||
const cx = SW / 2 + 4 * Math.sin(y / 71);
|
||||
if (Math.abs(x - cx) <= halfW) bitset[y * SW + x] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function woodRGB(x: number, y: number): [number, number, number] {
|
||||
const cx = SW / 2;
|
||||
const t = (x - cx) / (SW / 2);
|
||||
let r = 172 - Math.abs(t) * 48;
|
||||
let g = 122 - Math.abs(t) * 42;
|
||||
let b = 78 - Math.abs(t) * 32;
|
||||
const n = vnoise(x * 0.05, y * 0.02);
|
||||
r += (n - 0.5) * 28; g += (n - 0.5) * 24; b += (n - 0.5) * 17;
|
||||
const grain =
|
||||
Math.sin(y * 0.02 + x * 0.5 + Math.sin(x * 0.09) * 2.2) * 0.5 +
|
||||
vnoise(x * 0.4, y * 0.02) * 0.6 - 0.3;
|
||||
const streak = grain * 30;
|
||||
r += streak; g += streak * 0.85; b += streak * 0.6;
|
||||
for (const ky of [240, 560, 820]) {
|
||||
const kx = cx + (rnd(ky) - 0.5) * 34;
|
||||
const d = Math.hypot((x - kx) * 2.0, y - ky);
|
||||
if (d < 22) {
|
||||
const ring = Math.cos(d * 0.7) * 0.5 + 0.5;
|
||||
const f = Math.max(0, 1 - d / 22);
|
||||
r -= f * 60 * (0.4 + 0.6 * (1 - ring));
|
||||
g -= f * 58 * (0.4 + 0.6 * (1 - ring));
|
||||
b -= f * 44 * (0.4 + 0.6 * (1 - ring));
|
||||
}
|
||||
}
|
||||
return [
|
||||
Math.max(38, Math.min(214, Math.round(r))),
|
||||
Math.max(26, Math.min(162, Math.round(g))),
|
||||
Math.max(18, Math.min(112, Math.round(b))),
|
||||
];
|
||||
}
|
||||
// Silhouette path: left edge top→bottom, right edge bottom→top.
|
||||
function slabPath(ctx: CanvasRenderingContext2D, scale: number): void {
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
for (let y = 0; y < SH; y++) {
|
||||
for (let x = 0; x < SW; x++) {
|
||||
if (bitset[y * SW + x]) {
|
||||
if (!started) { ctx.moveTo(x * scale, y * scale); started = true; }
|
||||
else ctx.lineTo(x * scale, y * scale);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let y = SH - 1; y >= 0; y--) {
|
||||
for (let x = SW - 1; x >= 0; x--) {
|
||||
if (bitset[y * SW + x]) { ctx.lineTo(x * scale, y * scale); break; }
|
||||
}
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
let slabCanvas: HTMLCanvasElement | null = null;
|
||||
function buildSlabCanvas(): void {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = SW; c.height = SH;
|
||||
const ctx = c.getContext('2d')!;
|
||||
const img = ctx.createImageData(SW, SH);
|
||||
for (let y = 0; y < SH; y++) {
|
||||
for (let x = 0; x < SW; x++) {
|
||||
const i = (y * SW + x) * 4;
|
||||
if (bitset[y * SW + x]) {
|
||||
const [r, g, b] = woodRGB(x, y);
|
||||
img.data[i] = r; img.data[i+1] = g; img.data[i+2] = b; img.data[i+3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
ctx.save();
|
||||
slabPath(ctx, 1);
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = 'rgba(38,22,11,0.85)';
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
slabCanvas = c;
|
||||
}
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
let parentW = 0, parentH = 0;
|
||||
let stage: HTMLDivElement | undefined;
|
||||
|
||||
// ---- the photo transform (pan/zoom/rotate moves ONLY the slab) ---------
|
||||
let panX = 0, panY = 0; // screen-space offset of the slab
|
||||
let zoom = 1;
|
||||
let photoAngle = 0; // deg — rotate the photo until its taped edge hits the line
|
||||
// ---- global crop fill (settings.crop_fill, default 90) ------------------
|
||||
let cropFill = 90;
|
||||
// ---- ticks: pinned to SOURCE pixels on the photo. Their screen position
|
||||
// is wherever the photo transform puts that pixel — which is ON the fixed
|
||||
// line only when the photo is aligned, which is exactly the task. ----
|
||||
let tickAs = 40, tickBs = 1000; // source-space Y on the slab (tape start/end)
|
||||
let selectedTick: 'A' | 'B' = 'A'; // the tick arrow/Shift+arrow nudges
|
||||
let dragging: 'pan' | 'A' | 'B' | null = null;
|
||||
let lastPointer = { x: 0, y: 0 };
|
||||
// Active pointers, for mouse drag vs two-finger pinch (#99 control model).
|
||||
const pointers = new Map<number, { x: number; y: number }>();
|
||||
let pinchDist = 0;
|
||||
|
||||
// ---- layout: a fixed 3:4 crop frame; the line sits where the crop puts
|
||||
// the slab's long (left) edge at the current fill.
|
||||
interface Frame { x: number; y: number; w: number; h: number }
|
||||
function cropFrame(): Frame {
|
||||
const pad = 16;
|
||||
const availW = parentW - pad * 2, availH = parentH - pad * 2;
|
||||
const w = Math.min(availW, availH * (3 / 4));
|
||||
const h = w * (4 / 3);
|
||||
return { x: (parentW - w) / 2, y: (parentH - h) / 2, w, h };
|
||||
}
|
||||
// Base slab scale so the slab fits the frame at fill% (like composeInventoryFrame).
|
||||
function baseScale(fr: Frame): number {
|
||||
return ((fr.h * (cropFill / 100)) / SH);
|
||||
}
|
||||
// The FIXED measure line: screen X where the crop puts the slab's long edge.
|
||||
function refLineX(fr: Frame): number {
|
||||
const s = baseScale(fr);
|
||||
return fr.x + (fr.w - SW * s) / 2;
|
||||
}
|
||||
|
||||
// Source-space → screen (slab only; the line never uses this).
|
||||
function toScreen(sx: number, sy: number): [number, number] {
|
||||
const fr = cropFrame();
|
||||
const s = baseScale(fr) * zoom;
|
||||
const cx = fr.x + fr.w / 2 + panX, cy = fr.y + fr.h / 2 + panY;
|
||||
const rad = (photoAngle * Math.PI) / 180;
|
||||
const cos = Math.cos(rad), sin = Math.sin(rad);
|
||||
const dx = (sx - SW / 2) * s, dy = (sy - SH / 2) * s;
|
||||
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
|
||||
}
|
||||
// Screen → source-space (inverse of toScreen).
|
||||
function toSource(px: number, py: number): [number, number] {
|
||||
const fr = cropFrame();
|
||||
const s = baseScale(fr) * zoom;
|
||||
const cx = fr.x + fr.w / 2 + panX, cy = fr.y + fr.h / 2 + panY;
|
||||
const rad = (-photoAngle * Math.PI) / 180;
|
||||
const cos = Math.cos(rad), sin = Math.sin(rad);
|
||||
const dx = px - cx, dy = py - cy;
|
||||
return [SW / 2 + (dx * cos - dy * sin) / s, SH / 2 + (dx * sin + dy * cos) / s];
|
||||
}
|
||||
|
||||
// Synthetic tape scale: the slab spans SH px ≈ 88 inches.
|
||||
function pxPerIn(): number {
|
||||
const spanPx = maskSpanAlong(bitset, SW, SH, 0, 1);
|
||||
return spanPx / 88;
|
||||
}
|
||||
// L = distance between the two photo-pinned ticks in source px ÷ scale.
|
||||
// Exact for the photo's natural pixels — independent of on-screen zoom.
|
||||
let declaredLenIn = 0;
|
||||
function recompute(): void {
|
||||
declaredLenIn = Math.abs(tickBs - tickAs) / (pxPerIn() || 1);
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
if (!canvas) return;
|
||||
if (!parentW || !parentH) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(parentW * dpr);
|
||||
canvas.height = Math.round(parentH * dpr);
|
||||
canvas.style.width = parentW + 'px';
|
||||
canvas.style.height = parentH + 'px';
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const fr = cropFrame();
|
||||
const lineX = refLineX(fr);
|
||||
|
||||
// 1) workshop backdrop
|
||||
const bg = ctx.createRadialGradient(parentW/2, parentH*0.4, parentW*0.1, parentW/2, parentH/2, parentW*0.75);
|
||||
bg.addColorStop(0, '#242424');
|
||||
bg.addColorStop(1, '#141414');
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, parentW, parentH);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let gx = 40; gx < parentW; gx += 40) { ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, parentH); ctx.stroke(); }
|
||||
for (let gy = 40; gy < parentH; gy += 40) { ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(parentW, gy); ctx.stroke(); }
|
||||
|
||||
// 2) THE 3:4 CROP FRAME — a fixed screen fixture (where output lands).
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(0,229,255,0.35)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([8, 6]);
|
||||
ctx.strokeRect(fr.x, fr.y, fr.w, fr.h);
|
||||
ctx.setLineDash([]);
|
||||
ctx.font = '600 12px system-ui';
|
||||
ctx.fillStyle = 'rgba(0,229,255,0.6)';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText('3:4 crop frame', fr.x + fr.w - 6, Math.max(fr.y - 4, 14));
|
||||
ctx.restore();
|
||||
|
||||
// 3) THE SLAB + the tape + the ticks — all pinned to the photo, so they
|
||||
// pan / zoom / rotate together. The tape only lands on the fixed line
|
||||
// once the owner has rotated the photo into alignment.
|
||||
ctx.save();
|
||||
if (slabCanvas) {
|
||||
const s = baseScale(fr) * zoom;
|
||||
ctx.save();
|
||||
ctx.translate(fr.x + fr.w/2 + panX, fr.y + fr.h/2 + panY);
|
||||
ctx.rotate((photoAngle * Math.PI) / 180);
|
||||
ctx.scale(s, s);
|
||||
ctx.translate(-SW/2, -SH/2);
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.6)';
|
||||
ctx.shadowBlur = 30 / s;
|
||||
ctx.shadowOffsetY = 12 / s;
|
||||
ctx.drawImage(slabCanvas, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
// tape + ticks in source space → screen
|
||||
const [ax, ay] = toScreen(SW / 2, tickAs);
|
||||
const [bx, by] = toScreen(SW / 2, tickBs);
|
||||
const invZoom = 1 / (baseScale(fr) * zoom);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
|
||||
ctx.lineWidth = 5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2.5; // "2 weight"
|
||||
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
|
||||
const tdx = bx - ax, tdy = by - ay, tl = Math.hypot(tdx, tdy) || 1;
|
||||
const px = -tdy / tl, py = tdx / tl, half = 18;
|
||||
for (const [ex, ey, id] of [[ax, ay, 'A'], [bx, by, 'B']] as const) {
|
||||
// The selected tick gets a highlight ring: it is what arrow/Shift+arrow
|
||||
// nudges for pixel-level tape-mark alignment.
|
||||
if (id === selectedTick) {
|
||||
ctx.strokeStyle = 'rgba(255,214,0,0.95)';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.arc(ex, ey, half + 8, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath(); ctx.moveTo(ex - px*half, ey - py*half); ctx.lineTo(ex + px*half, ey + py*half); ctx.stroke();
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.beginPath(); ctx.moveTo(ex - px*half, ey - py*half); ctx.lineTo(ex + px*half, ey + py*half); ctx.stroke();
|
||||
ctx.fillStyle = id === selectedTick ? '#ffd600' : '#ff3d71';
|
||||
ctx.beginPath(); ctx.arc(ex, ey, 6, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
|
||||
}
|
||||
void invZoom;
|
||||
ctx.restore();
|
||||
|
||||
// 4) THE FIXED MEASURE LINE — a screen fixture, drawn ON TOP of the photo,
|
||||
// never pans/zooms/rotates. crop fill moves it (it is where the crop
|
||||
// puts the slab's long edge). The goal: rotate the photo until the black
|
||||
// tape lines up with this line.
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.lineWidth = 5;
|
||||
ctx.setLineDash([2, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lineX, fr.y - 18);
|
||||
ctx.lineTo(lineX, fr.y + fr.h + 18);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = '#00e5ff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lineX, fr.y - 18);
|
||||
ctx.lineTo(lineX, fr.y + fr.h + 18);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.font = '600 12px system-ui';
|
||||
ctx.fillStyle = '#00e5ff';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
// Clamp above the frame top so the label can never be clipped off-canvas.
|
||||
const lineLabelY = Math.max(fr.y - 22, 15);
|
||||
ctx.fillText(`fixed reference line · crop ${cropFill}%`, lineX, lineLabelY);
|
||||
ctx.restore();
|
||||
|
||||
// 5) length chip beside the fixed line, mid-height (a screen fixture)
|
||||
const my = (ay + by) / 2;
|
||||
const fs = 16;
|
||||
ctx.font = `700 ${fs}px system-ui, -apple-system, sans-serif`;
|
||||
const label = declaredLenIn.toFixed(1) + '\u2033';
|
||||
const tw = ctx.measureText(label).width;
|
||||
const padX = 10, padY = 7, chipX = lineX + 44;
|
||||
ctx.fillStyle = 'rgba(15,15,15,0.92)';
|
||||
ctx.strokeStyle = 'rgba(255,61,113,0.9)';
|
||||
ctx.lineWidth = 1.5;
|
||||
roundRect(ctx, chipX, my - fs/2 - padY, tw + padX*2, fs + padY*2, 8);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = '#ff6b8f';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, chipX + (tw + padX*2)/2, my);
|
||||
|
||||
// Prototype-only debug hook so the behavior tests read geometry directly.
|
||||
(window as any).__tp = {
|
||||
lineX, declaredLenIn, photoAngle, zoom, panX, panY,
|
||||
tickA: { ax, ay }, tickB: { bx, by }, cropFill, frame: fr,
|
||||
tickAs, tickBs, selectedTick, dragging, pointersSize: pointers.size,
|
||||
};
|
||||
}
|
||||
|
||||
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function frame(): void { draw(); } // helper: re-render + re-measure
|
||||
|
||||
onMount(() => {
|
||||
buildSlab();
|
||||
buildSlabCanvas();
|
||||
if (stage) { parentW = stage.clientWidth; parentH = stage.clientHeight; }
|
||||
recompute();
|
||||
const ro = new ResizeObserver((es) => {
|
||||
const e = es[0];
|
||||
if (!e) return;
|
||||
parentW = e.contentRect.width;
|
||||
parentH = e.contentRect.height;
|
||||
recompute();
|
||||
draw();
|
||||
});
|
||||
if (stage) ro.observe(stage);
|
||||
draw();
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
onDestroy(() => {});
|
||||
|
||||
// ---- Pointer Events: pan/zoom the PHOTO; drag ticks ALONG THE LINE -----
|
||||
function pointerPos(e: PointerEvent): { x: number; y: number } {
|
||||
const rect = canvas!.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
}
|
||||
const TICK_HIT = 24; // ≥44×44 effective hit area
|
||||
function tickScreen(): { ax: number; ay: number; bx: number; by: number } {
|
||||
const [ax, ay] = toScreen(SW / 2, tickAs);
|
||||
const [bx, by] = toScreen(SW / 2, tickBs);
|
||||
return { ax, ay, bx, by };
|
||||
}
|
||||
function hitTick(px: number, py: number): 'A' | 'B' | null {
|
||||
const t = tickScreen();
|
||||
if (Math.hypot(px - t.ax, py - t.ay) <= TICK_HIT) return 'A';
|
||||
if (Math.hypot(px - t.bx, py - t.by) <= TICK_HIT) return 'B';
|
||||
return null;
|
||||
}
|
||||
// Zoom about a screen point: the source pixel under the cursor stays put
|
||||
// (#99 control model — "zoom-to-cursor").
|
||||
function zoomAt(px: number, py: number, factor: number): void {
|
||||
const before = toSource(px, py);
|
||||
zoom = Math.max(0.4, Math.min(6, zoom * factor));
|
||||
const [nx, ny] = toScreen(before[0], before[1]);
|
||||
panX += px - nx;
|
||||
panY += py - ny;
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
const pinch = () => {
|
||||
const pts = [...pointers.values()];
|
||||
if (pts.length < 2) return { d: 0, mx: 0, my: 0 };
|
||||
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||
return { d, mx: (pts[0].x + pts[1].x) / 2, my: (pts[0].y + pts[1].y) / 2 };
|
||||
};
|
||||
function onPointerDown(e: PointerEvent): void {
|
||||
const p = pointerPos(e);
|
||||
pointers.set(e.pointerId, p);
|
||||
if (pointers.size === 2) {
|
||||
const { d } = pinch();
|
||||
pinchDist = d;
|
||||
dragging = null; // two fingers = pinch, never a pan/tick drag
|
||||
return;
|
||||
}
|
||||
const t = hitTick(p.x, p.y);
|
||||
if (t) selectedTick = t; // arrow/Shift+arrow then nudges THIS tick
|
||||
dragging = t ?? 'pan';
|
||||
lastPointer = p;
|
||||
canvas?.setPointerCapture(e.pointerId);
|
||||
}
|
||||
function onPointerMove(e: PointerEvent): void {
|
||||
const p = pointerPos(e);
|
||||
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, p);
|
||||
if (pointers.size >= 2) {
|
||||
const { d, mx, my } = pinch();
|
||||
if (pinchDist > 0 && d > 0) zoomAt(mx, my, d / pinchDist);
|
||||
pinchDist = d;
|
||||
return;
|
||||
}
|
||||
if (!dragging) {
|
||||
canvas?.style.setProperty('cursor', hitTick(p.x, p.y) ? 'ns-resize' : 'grab');
|
||||
return;
|
||||
}
|
||||
if (dragging === 'pan') {
|
||||
panX += p.x - lastPointer.x;
|
||||
panY += p.y - lastPointer.y;
|
||||
} else {
|
||||
// The tick slides ALONG the taped edge — source-space Y on the photo.
|
||||
const [, sy] = toSource(p.x, p.y);
|
||||
const y = Math.max(0, Math.min(SH, sy));
|
||||
if (dragging === 'A') tickAs = Math.min(y, tickBs - 4);
|
||||
else tickBs = Math.max(y, tickAs + 4);
|
||||
}
|
||||
lastPointer = p;
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
function onPointerUp(e: PointerEvent): void {
|
||||
pointers.delete(e.pointerId);
|
||||
if (pointers.size < 2) pinchDist = 0;
|
||||
if (pointers.size === 0) dragging = null;
|
||||
canvas?.releasePointerCapture(e.pointerId);
|
||||
canvas?.style.setProperty('cursor', 'grab');
|
||||
}
|
||||
function onWheel(e: WheelEvent): void {
|
||||
e.preventDefault();
|
||||
const rect = canvas!.getBoundingClientRect();
|
||||
// Trackpad pinch arrives as a wheel with ctrlKey set; make it zoom harder
|
||||
// per event than a plain mouse wheel.
|
||||
const f = Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015));
|
||||
zoomAt(e.clientX - rect.left, e.clientY - rect.top, f);
|
||||
}
|
||||
// #99: arrow keys rotate the photo 1° (coarse) / 0.1° (Shift, fine);
|
||||
// with a tick selected they nudge that tick instead — pixel-level alignment.
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
const fine = e.shiftKey;
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
const d = (e.key === 'ArrowUp' ? -1 : 1) * (fine ? 0.1 : 1);
|
||||
if (selectedTick === 'A') tickAs = Math.max(0, Math.min(tickAs + d, tickBs - 4));
|
||||
else tickBs = Math.min(SH, Math.max(tickBs + d, tickAs + 4));
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
const d = (e.key === 'ArrowLeft' ? -1 : 1) * (fine ? 0.1 : 1);
|
||||
photoAngle = ((photoAngle + d) % 360 + 360) % 360;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
function pickTick(t: 'A' | 'B'): void {
|
||||
selectedTick = t;
|
||||
draw();
|
||||
}
|
||||
function nudgePhoto(d: number): void {
|
||||
photoAngle = ((photoAngle + d) % 360 + 360) % 360;
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
function setPhotoAngle(d: number): void {
|
||||
photoAngle = ((d % 360) + 360) % 360;
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
function setCrop(d: number): void {
|
||||
cropFill = d;
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="tp">
|
||||
<h1>Length axis — tape line</h1>
|
||||
<p class="hint">
|
||||
The measure line is <strong>fixed</strong>. Rotate / pan / zoom the <strong>photo</strong>
|
||||
until its taped edge sits on the line, then drag the two ticks along it to where your
|
||||
tape started and ended. L = the distance between the ticks.
|
||||
</p>
|
||||
|
||||
<div class="stage" bind:this={stage} role="application" tabindex="0" on:keydown={onKey}
|
||||
aria-label="Tape-line calibration. Arrow keys rotate the photo; with a tick selected they nudge that tick.">
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
style="cursor:grab"
|
||||
on:pointerdown={onPointerDown}
|
||||
on:pointermove={onPointerMove}
|
||||
on:pointerup={onPointerUp}
|
||||
on:pointercancel={onPointerUp}
|
||||
on:wheel={onWheel}
|
||||
></canvas>
|
||||
</div>
|
||||
<p class="stage-hint">Drag each tick to where your tape started and ended.</p>
|
||||
<p class="stage-sub">Arrow keys rotate 1° (Shift 0.1°) · with a tick selected the arrows nudge it · wheel or pinch zooms at the cursor</p>
|
||||
|
||||
<!-- global crop fill (settings.crop_fill) — moves the fixed line -->
|
||||
<div class="crop">
|
||||
<label for="crop">Global crop fill</label>
|
||||
<input id="crop" type="range" min="50" max="100" step="1" value={cropFill}
|
||||
on:input={(e) => setCrop(Number(e.currentTarget.value))} />
|
||||
<span class="val">{cropFill}%</span>
|
||||
</div>
|
||||
<p class="sub">Default 90% · single setting replaces both duplicated FILL=0.9 · moves the fixed line + re-crops future slabs (non-retro).</p>
|
||||
|
||||
<!-- rotate/pan/zoom the PHOTO (coarse → fine, Adobe nudge model) -->
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Rotate photo counter-clockwise"
|
||||
on:click={() => nudgePhoto(-1)}>−</button>
|
||||
<input type="range" min="0" max="359" step="1" value={Math.round(photoAngle % 360)}
|
||||
on:input={(e) => setPhotoAngle(Number(e.currentTarget.value))} />
|
||||
<button type="button" class="thumb" aria-label="Rotate photo clockwise"
|
||||
on:click={() => nudgePhoto(1)}>+</button>
|
||||
<span class="val">{Math.round(photoAngle % 360)}°</span>
|
||||
<button type="button" class="ghost" on:click={() => { panX = 0; panY = 0; zoom = 1; photoAngle = 0; recompute(); frame(); }}>reset</button>
|
||||
</div>
|
||||
|
||||
<!-- declared length = distance between the two ticks -->
|
||||
<div class="len">
|
||||
<div class="len-head">
|
||||
<span>Declared length (between ticks)</span>
|
||||
<span class="eff">{declaredLenIn.toFixed(1)}″</span>
|
||||
</div>
|
||||
<div class="ticks">
|
||||
<button type="button" class="tickbtn" class:sel={selectedTick === 'A'} on:click={() => pickTick('A')}>
|
||||
tick A {selectedTick === 'A' ? '· arrows nudge' : ''}
|
||||
</button>
|
||||
<button type="button" class="tickbtn" class:sel={selectedTick === 'B'} on:click={() => pickTick('B')}>
|
||||
tick B {selectedTick === 'B' ? '· arrows nudge' : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.tp { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; line-height: 1.5; }
|
||||
.stage {
|
||||
position: relative; width: 100%; height: 56vh; min-height: 360px;
|
||||
background: #181818; border-radius: 10px; overflow: hidden;
|
||||
border: 1px solid #2c2c2c;
|
||||
}
|
||||
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
|
||||
.stage-hint {
|
||||
font-size: 0.78rem; color: #bbb; margin: 8px 0 0;
|
||||
}
|
||||
.stage-sub { font-size: 0.72rem; color: #777; margin: 4px 0 0; }
|
||||
.stage:focus-visible { outline: 2px solid #00e5ff; outline-offset: 2px; }
|
||||
.ticks { display: flex; gap: 10px; margin-top: 8px; }
|
||||
.tickbtn {
|
||||
flex: 1; padding: 10px; font-size: 0.85rem; border-radius: 4px;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #ddd;
|
||||
}
|
||||
.tickbtn.sel { border-color: #ffd600; color: #ffd600; }
|
||||
.crop, .angle { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
|
||||
.crop label { font-size: 0.9rem; color: #ccc; }
|
||||
input[type='range'] { flex: 1; accent-color: #00e5ff; }
|
||||
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; color: #00e5ff; }
|
||||
.ghost {
|
||||
border: 1px solid #444; background: #1d1d1d; color: #ccc; font-size: 0.85rem;
|
||||
padding: 8px 12px; border-radius: 4px;
|
||||
}
|
||||
.sub { color: #888; font-size: 0.82rem; margin-top: 8px; }
|
||||
.thumb {
|
||||
width: 44px; height: 44px; border-radius: 50%; font-size: 1.5rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
.len { margin-top: 18px; }
|
||||
.len-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }
|
||||
.len-head span:first-child { font-size: 0.9rem; color: #aaa; }
|
||||
.eff { color: #ff6b8f; font-weight: 700; font-size: 1.3rem; font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
@@ -43,7 +43,7 @@ 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 PNG crop (settings.crop_fill, default 90%)
|
||||
}
|
||||
|
||||
// Direction only: an oriented rectangle over the slab. The arrow reads
|
||||
|
||||
@@ -10,6 +10,7 @@ import { measure } from '../measure/measure';
|
||||
import { orientedRect } from '../geometry/rect';
|
||||
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
||||
import { FILL_DEFAULT, fillFromSetting } from '../png/crop';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
@@ -41,6 +42,8 @@ export interface CaptureSession {
|
||||
setSheet(s: Sheet): void;
|
||||
approveMask(): void;
|
||||
setAxisAngle(deg: number): void;
|
||||
/** #99: global crop fill from settings (percent or fraction); drives the PNG crop. */
|
||||
setCropFill(pctOrFraction: number): void;
|
||||
approveAxis(): void;
|
||||
setLength(e: Eighths): void;
|
||||
setThickness(e: Eighths): void;
|
||||
@@ -83,6 +86,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
let busyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pngUrl: string | null = null;
|
||||
let resumed = false;
|
||||
// #99: global crop fill (fraction). Set from settings; FILL_DEFAULT (90%) until told otherwise.
|
||||
let cropFill = FILL_DEFAULT;
|
||||
|
||||
const set = (next: Capture) => {
|
||||
state = next;
|
||||
@@ -487,6 +492,11 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
void persist();
|
||||
},
|
||||
|
||||
setCropFill(pctOrFraction: number) {
|
||||
// #99: non-retro — only future encodes use this; stored PNGs are untouched.
|
||||
cropFill = fillFromSetting(pctOrFraction);
|
||||
},
|
||||
|
||||
approveAxis() {
|
||||
if (state.k !== 'axis') return;
|
||||
set({
|
||||
@@ -523,7 +533,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
let pngBlob: Blob;
|
||||
let belowFloor = false;
|
||||
try {
|
||||
const r = await encodeInventory({ bitmap, mask: approvedMask, source });
|
||||
const r = await encodeInventory({ bitmap, mask: approvedMask, source, fill: cropFill });
|
||||
pngBlob = r.blob;
|
||||
belowFloor = r.sourceBelowFloor;
|
||||
} catch {
|
||||
@@ -593,7 +603,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
||||
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
||||
try {
|
||||
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource });
|
||||
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, fill: cropFill });
|
||||
} finally {
|
||||
siblingBitmap.close();
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
brand_voice: string | null;
|
||||
geo_context: string | null;
|
||||
woo_create_status: string | null;
|
||||
crop_fill: number;
|
||||
}
|
||||
|
||||
interface PriceRule {
|
||||
@@ -205,6 +206,7 @@
|
||||
brand_voice: settings.brand_voice || '',
|
||||
geo_context: settings.geo_context || '',
|
||||
woo_create_status: settings.woo_create_status || 'draft',
|
||||
crop_fill: settings.crop_fill ?? 90,
|
||||
// secrets: prefill when unmasked (UAT), else blank (masked, write-only)
|
||||
woo_app_password: secretValue(settings.woo_app_password),
|
||||
inference_base_url: secretValue(settings.inference_base_url),
|
||||
@@ -230,6 +232,7 @@
|
||||
if (form.brand_voice !== (settings?.brand_voice || '')) payload.brand_voice = form.brand_voice || null;
|
||||
if (form.geo_context !== (settings?.geo_context || '')) payload.geo_context = form.geo_context || null;
|
||||
if (form.woo_create_status !== (settings?.woo_create_status || 'draft')) payload.woo_create_status = form.woo_create_status;
|
||||
if (Number(form.crop_fill) !== (settings?.crop_fill ?? 90)) payload.crop_fill = Number(form.crop_fill);
|
||||
// secrets only if provided (write-only)
|
||||
if (form.woo_app_password) payload.woo_app_password = form.woo_app_password;
|
||||
if (form.inference_base_url) payload.inference_base_url = form.inference_base_url;
|
||||
@@ -351,6 +354,12 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="crop-fill">Crop Fill % (default 90) — inventory PNG + reference line</label>
|
||||
<input id="crop-fill" type="range" min="1" max="100" step="1" bind:value={form.crop_fill} />
|
||||
<span>{form.crop_fill}%</span>
|
||||
</div>
|
||||
|
||||
<button on:click={save} disabled={saving}>Save Changes</button>
|
||||
{#if message}<p class="msg">{message}</p>{/if}
|
||||
|
||||
@@ -415,7 +424,10 @@
|
||||
<style>
|
||||
.settings { max-width: 600px; color: #eee; }
|
||||
label { display: block; margin: 8px 0 4px; }
|
||||
input, select, option { color: #eee; background: #1c1c1c; width: 100%; padding: 6px; }
|
||||
input, select, option { color: #eee; background: #1c1c1c; width: 100%; padding: 6px; box-sizing: border-box; }
|
||||
/* width:100% + the UA default input margins overflowed narrow viewports
|
||||
(mobile Settings scrolled 16px sideways). Zero the horizontal margins. */
|
||||
.settings input, .settings select { margin-left: 0; margin-right: 0; }
|
||||
option { color: #eee; background: #1c1c1c; }
|
||||
button { margin-top: 16px; }
|
||||
.section { margin-top: 20px; padding-top: 12px; border-top: 1px solid #333; }
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Single source of truth for the inventory crop geometry (issue #99, req. 4).
|
||||
*
|
||||
* The 90% fill and the 3:4 aspect used to be duplicated as `const FILL = 0.9`
|
||||
* in `png/inventory.ts` and `png/overlay.ts`. Both now read from here, and the
|
||||
* live fill comes from the global `settings.crop_fill` (default 90%) so the
|
||||
* reference line and the PNG crop can never disagree.
|
||||
*/
|
||||
|
||||
/** Default fill (90%) — mirrors the `settings.crop_fill` default. */
|
||||
export const FILL_DEFAULT = 0.9;
|
||||
export const ASPECT_W = 3;
|
||||
export const ASPECT_H = 4;
|
||||
/** Shorter side of the output frame, px. */
|
||||
export const FLOOR = 800;
|
||||
|
||||
/**
|
||||
* Coerce a `settings.crop_fill` value into a 0 < fill <= 1 scale.
|
||||
* Accepts a percentage (90) or a fraction (0.9); anything unusable falls back
|
||||
* to {@link FILL_DEFAULT} rather than silently cropping to zero.
|
||||
*/
|
||||
export function fillFromSetting(v: unknown): number {
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
if (!Number.isFinite(n) || n <= 0) return FILL_DEFAULT;
|
||||
return Math.min(1, n > 1 ? n / 100 : n);
|
||||
}
|
||||
|
||||
/** Output frame size: shorter side clamped to {@link FLOOR}. */
|
||||
export function cropFrameSize(sw: number, sh: number): { outW: number; outH: number; belowFloor: boolean } {
|
||||
const belowFloor = Math.min(sw, sh) < FLOOR;
|
||||
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
|
||||
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
|
||||
return { outW, outH, belowFloor };
|
||||
}
|
||||
|
||||
export interface CropGeometry {
|
||||
scale: number;
|
||||
dw: number;
|
||||
dh: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
}
|
||||
|
||||
/** Scale + centring offsets of a `sw × sh` slab inside an `outW × outH` frame. */
|
||||
export function cropOffsets(
|
||||
sw: number,
|
||||
sh: number,
|
||||
outW: number,
|
||||
outH: number,
|
||||
fill: number = FILL_DEFAULT,
|
||||
): CropGeometry {
|
||||
const scale = Math.min((outW * fill) / Math.max(sw, 1), (outH * fill) / Math.max(sh, 1));
|
||||
const dw = sw * scale;
|
||||
const dh = sh * scale;
|
||||
return { scale, dw, dh, ox: (outW - dw) / 2, oy: (outH - dh) / 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fraction of the frame's LONG axis taken by the crop margin (issue #99, req. 3):
|
||||
* the slab's long edge sits at `oy` (or `ox`) = `outH * (1 - fill) / 2`, so the
|
||||
* fixed reference line is that same fraction in from the frame edge.
|
||||
*/
|
||||
export function cropMarginFraction(fill: number = FILL_DEFAULT): number {
|
||||
return (1 - fill) / 2;
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
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 { FILL_DEFAULT, cropFrameSize, cropOffsets } from './crop';
|
||||
|
||||
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
|
||||
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
||||
@@ -12,6 +8,8 @@ export interface InventoryInput {
|
||||
bitmap: ImageBitmap;
|
||||
mask: MaskPick;
|
||||
source: SourcePick;
|
||||
/** Crop fill, 0<fill<=1. Defaults to FILL_DEFAULT (the 90% settings default). */
|
||||
fill?: number;
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
@@ -25,6 +23,7 @@ export function composeInventoryFrame(
|
||||
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
|
||||
mask: MaskPick,
|
||||
source: SourcePick,
|
||||
fill: number = FILL_DEFAULT,
|
||||
): Frame {
|
||||
const b = mask.slabBounds;
|
||||
const x0 = Math.max(0, Math.round(b.x));
|
||||
@@ -33,14 +32,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 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;
|
||||
const dh = sh * scale;
|
||||
const ox = (outW - dw) / 2;
|
||||
const oy = (outH - dh) / 2;
|
||||
const { outW, outH, belowFloor } = cropFrameSize(sw, sh);
|
||||
const { scale, dw, dh, ox, oy } = cropOffsets(sw, sh, outW, outH, fill);
|
||||
const rgba = new Uint8Array(outW * outH * 4);
|
||||
const xEnd = ox + dw;
|
||||
const yEnd = oy + dh;
|
||||
@@ -74,6 +67,7 @@ export async function encodeInventoryPng(
|
||||
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
|
||||
mask,
|
||||
source,
|
||||
input.fill,
|
||||
);
|
||||
const out = new OffscreenCanvas(frame.width, frame.height);
|
||||
const ctx = out.getContext('2d')!;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BBox } from '../capture/capture.types';
|
||||
import { axisEndpoints } from '../measure/measure';
|
||||
import { FILL_DEFAULT, cropOffsets } from './crop';
|
||||
|
||||
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
|
||||
export function overlayWidthStations(lengthIn: number): number[] {
|
||||
@@ -485,19 +486,15 @@ export function inventorySourceMap(
|
||||
source: { widthPx: number; heightPx: number },
|
||||
outW: number,
|
||||
outH: number,
|
||||
fill: number = FILL_DEFAULT,
|
||||
): InventoryMap {
|
||||
const FILL = 0.9;
|
||||
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)));
|
||||
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(slabBounds.h)));
|
||||
const sw = x1 - x0;
|
||||
const sh = y1 - y0;
|
||||
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
|
||||
const dw = sw * scale;
|
||||
const dh = sh * scale;
|
||||
const ox = (outW - dw) / 2;
|
||||
const oy = (outH - dh) / 2;
|
||||
const { scale, ox, oy } = cropOffsets(sw, sh, outW, outH, fill);
|
||||
return {
|
||||
outW,
|
||||
outH,
|
||||
|
||||
Reference in New Issue
Block a user