Skip to content

feat(annotator): the bbox tool — three numbers, three questions, and a cursor that cannot lie (#43) - #119

Merged
JArmandoAnaya merged 1 commit into
mainfrom
issue-43-bbox-tool
Jul 30, 2026
Merged

feat(annotator): the bbox tool — three numbers, three questions, and a cursor that cannot lie (#43)#119
JArmandoAnaya merged 1 commit into
mainfrom
issue-43-bbox-tool

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #43. M4 position 7 of 14 — "the first tool, and the one that exercises the machine's drag and resize variants."

Ledger

No migration. FORMAT_VERSION stays 11, VERSION stays 0.0.1.dev0, openapi.json and frontend/ui-core/src/generated/api.ts are byte-identical, and not one file under src/ or tests/ is touched (git diff origin/main --stat over all six is empty). No new dependency of any kind. M4's finding H says a change to any of those is the signal the annotator reached for the backend early.

454 annotator tests, up from 384 — three new files and two extended. Plus the 15 node:test boundary/script tests, unchanged. No new interaction state and no new event: the seven and the eight stand.

The honest starting position

#42 already drew, moved and resized boxes. What it left is what this is — and it left it by name, in four committed docstrings:

left where said
geometry/bbox.ts "No creation threshold. … the drawing tool (#43) owns that one and should choose it knowingly rather than inherit it."
interaction/machine.ts the same hole, from the row that would apply it
interaction/draft.ts "Filling defaults from LabelClass.attributes is #43's."
interaction/target.ts the hover query the affordance layer was shaped to sit on

All four close here. gestures.test.ts is not duplicated: its there-and-back / walked-versus-jumped / dropped-move trio are properties of an absolute transform and stay where they are. bboxTool.test.ts is the matrix.

Three numbers, three questions

v1 had two spellings of 3width > 3 strictly when a drawn box was accepted, < 3 when a resized one was pushed back out. The fault was not that there were two numbers. It was that both answered the same question and disagreed about its boundary.

constant unit question
CLICK_SLOP_PX screen did a press on empty canvas travel, or was it a click?
MIN_DRAW_SIZE_PX screen did the human mean to draw a box, or twitch?
MIN_BBOX_SIZE asset may a stored box be this degenerate?

Screen pixels, for tolerance.ts's own thesis. v1 compared its threshold against a coordinate it had already divided by the zoom, so a box drawn at 30% needed about one screen pixel of drag and one at 200% needed six — the same inversion #41 exists to have fixed, on the one gate that decides whether an annotation exists at all. It is Tolerances' seventh field, so no signature moved; tolerance.test.ts's total Record<keyof Tolerances, number> refused to compile until it was converted, which is that gate working.

Reusing CLICK_SLOP_PX was considered and declined. The two are 3 today and their questions rhyme, but they are independently tunable: raising the size a box must reach before it enters a dataset should not make a selection click harder to land.

isDrawnBox(bbox, minimum, bounds) lives in bbox.ts beside MIN_BBOX_SIZE, takes its number as an argument, and:

  • gates both axes — v1 refused a 200×2 sliver too, and a sliver is the shape a click plus a flick of drift actually makes. The cost is stated rather than hidden: a genuinely thin object has to be drawn to the minimum on its short axis and resized after.
  • accepts exactly the minimum (>=), pinned from both sides, so the boundary is written down once.
  • lets bounds win where the frame is smaller than the minimum — the same collision resizeBbox already resolves that way. A 2-px asset must not be a surface on which nothing can be drawn.
  • reads its extents through Math.abs, like bboxContains reads edges through min/max. It is exported from the package root, so it can be asked about a negative-width box off the wire; its only in-repo caller hands it normalizeBbox output and would never have noticed.

A consequence, stated because it reads like an inconsistency and is not: above 100% the gate admits a box smaller than MIN_BBOX_SIZE, so that constant is an invariant of resizeBbox and not of the document. That is the right way round — somebody who zoomed to 400% and drew two pixels demonstrably meant to; the same two pixels out of a resize at 100% is a grip that got away from them.

A rejected drag returns idle() with no effects: nothing added, nothing selected, and the selection the press cleared stays cleared. The press did land on empty canvas.

The affordance layer — and the bug the design caught

resolveTarget answers what is under this point. A renderer needs two more things — the cursor, and which grip to paint hot — and neither is a fact about the document. interaction/affordance.ts is that layer, one pure function, so the cursor and the press cannot disagree.

That thesis earned its keep during review. The first draft answered default over an edge, reasoning that a 15-px band only a double-click uses should not advertise itself. IDLE_ROW says otherwise:

if (target.kind === "body" || target.kind === "edge") return pressOnShape(turn, target.id);

A press in that band picks the polygon and enters moving. default there was the drawing-tool lie inverted — under-promising rather than over — and still a disagreement with the table. It is move now, and the test asserts the cursor and the resulting transition in the same it, so the two cannot drift.

The rules:

  • A drawing tool outranks every grip, so the cursor must too. IDLE_ROW checks tool !== "select" before it resolves any target, so a grip on a selected box is unreachable however exactly the pointer sits on one. Draw mode answers crosshair + NO_TARGET unconditionally — pinned with the pointer parked on the picked box's own nw grip, in the same test that asserts the press starts a drawing.
  • pressing-empty shows nothing. The button is already down on empty canvas and that row has no pointer-down, so for the whole gesture nothing is reachable. Answering what idle would answer lights a grip that cannot be taken.
  • A drag answers from what it holds, not from the point. A resize keeps its grip lit when the pointer outruns the box, which is exactly when a user needs to see what they are holding.
  • The scene an adapter passes is built from store.rendered; the machine's is store.document. Hover has to hit-test the pixels actually on screen, and mid-drag those are the preview's; a transition must never resolve against a preview. Small, load-bearing, and annotator: React adapter — SVG render, zoom/pan, asset image, annotation layer #47 is the caller that has to get it right.

A CSS cursor keyword is vocabulary, not a DOM API. Cursor is seven strings; nothing touches a node and it compiles under tsconfig.core.json with lib: ["ES2022"] and types: [] like everything else in core/. The alternative — an abstract union an adapter maps one-for-one onto these very names — is a second spelling of one fact. LabelClass.color is already a plain string for the same reason. A test asserts every member of the union is actually produced by a real call, because vocabulary nobody speaks reads as capability.

Attribute defaults, and the one deliberately not filled

draftAnnotation seeds every attribute the class declares a default for, read through the document's own classNamed. An attribute with a null default contributes no key rather than a null value — AttributeValue has no null on the wire.

A required attribute with no default is drawn anyway. document.ts hands "the tools refuse at draw time, where a user can be told" to the tools — but there is nobody to tell yet. Refusing would make such a class simply undrawable, with no panel in existence to satisfy it and no channel to explain why the pointer did nothing. The kernel's MissingRequiredAttribute is the backstop on write; M5's attributes panel is what turns that sentence into something a user can act on.

Active class stays the host's — recorded, not changed

Nothing in core/ stores it; InteractionContext.labelClass arrives per turn and finishDrawing stamps it, which is the whole of "class assignment from the active class". Moving it into AnnotatorStore was declined: the store is the document and its history, and a palette click is neither — putting it in the history would make Ctrl+Z step through palette clicks. What the host owes in exchange is one line, now in tool.ts so #47 inherits it: when the class changes such that toolFor returns a different tool, send tool-changed — not on every class change, since drawing-bbox captured its class at the press and a switch between two bbox classes retargets nothing.

The audit, tightened rather than weakened

tolerance.ts documents grep -rn zoom src/core | grep -v ' \* ' as the proof that one module names a viewport. #43's new tests set assetTolerances(4) and assetTolerances(0.25) deliberately — nothing else in the suite varies the frame, so swapping tolerances.minDraw back for the raw constant would have gone unnoticed. A test naming a zoom is the discipline being exercised, not broken. The audit is now scoped to the shipped engine, which is what it always meant and what tsconfig.build.json already defines:

grep -rn zoom src/core --include='*.ts' | grep -v '\.test\.ts' | grep -v ' \* '

It answers with tolerance.ts alone — a stronger statement than the one it replaces.

Acceptance criteria

"Scripted event-sequence tests: draw/move/resize produce exact expected geometry"bboxTool.test.ts, 32 tests. Four drag directions to one expectation; a draw clamped at the frame; the threshold from both sides plus the sliver, the click, and both zoom extremes; move and its frame pin; one it per grip, all eight, against a total Record<BboxHandle, {to, out}> whose expectations are written out, not recomputed from resizeBbox — which bbox.test.ts pins independently. Plus one assertion at the effect level in the form effects.ts specified for this task, with no store in the test.

"Undo after each operation restores the prior document" — asserted with toBe, by reference, because undo is a pointer swap over kept snapshots and the object that comes back must be the very one. Draw, move and resize each undone; then a three-operation session unwound one entry at a time back to the document it began with; then a gesture that changed nothing, recorded as nothing.

Next: #44 — the polygon tool.

…a cursor that cannot lie (#43)

#42 already drew, moved and resized boxes. What it left, by name in four
docstrings, is what this is: the creation threshold (`bbox.ts`: "the drawing tool
(#43) owns that one and should choose it knowingly rather than inherit it"),
attribute defaults (`draft.ts`), the affordance layer `resolveTarget` was shaped
for (`target.ts`), and the acceptance matrix stated as tests.

v1 had two spellings of `3` — `width > 3` strictly when a drawn box was accepted,
`< 3` when a resized one was pushed back out. The fault was not that there were
two numbers; it was that both answered the same question and disagreed about its
boundary. There are now three, each named and each in its own frame:
CLICK_SLOP_PX (did a press travel?), MIN_DRAW_SIZE_PX (did a human mean to draw?)
and MIN_BBOX_SIZE (may a stored box be this degenerate?). The first two are facts
about hands and are measured on the screen; the third is a fact about the
document and stays in asset pixels. A consequence stated rather than hidden: at
400% the gate admits a box below MIN_BBOX_SIZE, because somebody who zoomed in to
draw two pixels demonstrably meant to.

`affordanceAt` mirrors IDLE_ROW's precedence, and that is the whole of its
correctness. That row checks the tool before it resolves any target, so in a
drawing tool a grip is unreachable and the cursor is a crosshair on top of one; it
groups `edge` with `body`, so an edge offers `move`. Both are asserted in the same
`it` as the press they claim to predict, so the two cannot drift. A drag answers
from what it is holding rather than from the point, and the scene an adapter
passes is built from `rendered` where the machine's is `document` — hover
hit-tests the pixels on screen, a transition never resolves against a preview.
@JArmandoAnaya
JArmandoAnaya merged commit 60e6d87 into main Jul 30, 2026
6 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the issue-43-bbox-tool branch July 30, 2026 19:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

annotator: bbox tool — create, move, resize, native-pixel coordinates

1 participant