Read a row's physical breaks from the drawing into a gap_before flag

Phase 1 of [#3615673]: derive the breaks, store them, draw them. Nothing reads the flag yet, so this changes no seating behaviour and cannot regress the hot path.

The point of splitting it: the detection is a threshold, and a threshold that decides bookings before anyone has looked at it is the risk. This makes it checkable first.

What it adds

  • gap_before boolean base field on the place: is there a physical break between this place and the previous position in its row.
  • RowPath::breaksAlong() — a step is a break at 1.8x the row's own median step. Relative to the row, not the hall: a balcony's seats sit closer than a parterre's, and a row curving away from the plan's centre has a shorter chord between neighbors.
  • PlacePositions writes it beside the position, from the same walk. A row already numbered right but never told where its aisles are now counts as work to do.
  • drush yoyaku:venue-positions reports breaks before: A36 A46 A74.
  • The Numbering tab breaks the thread at each one and marks it, so a break the hall does not have is as visible as one it does.

Why 1.8, with the numbers

The threshold is a cliff on this venue, not a judgement. Step sizes as a multiple of each row's own pitch, over 1300 consecutive pairs in the Auditorium:

under 1.2  (a neighbor)         1255
1.2 - 1.5                          5
1.5 - 1.8  <- ambiguous            2
1.8 - 3.0                         20
over 3.0   (plainly a break)      18

Two pairs in the whole venue are near the line, so moving it inside that empty band changes nothing. Deliberately conservative: calling a break where there is none would refuse seatings that are genuinely together, which is worse than missing one.

Verified on the real hall

38 breaks found, and they come out mirror-symmetric, which is the strongest check available since the jardin and cour sides are physical mirrors:

S16 row A:  A36 A46 A74          S17 row A:  A33 A45 A73
S16 row B:  B40 B52 B64 B76      S17 row B:  B39 B51 B63 B75
S11:1 A40                        S12:1 A37
S11   B68                        S12   B67

Same count at corresponding seats on both sides, including S16 jumping A32 to A36 where seat 34 does not exist, mirrored by S17 jumping A37 to A33 where 35 does not. A threshold picking up noise would not come out symmetric. It also catches missing seats, not only aisles, which is correct: if there is no seat 34 then 32 and 36 are not adjacent.

On the tab: 38 break markers and 157 path segments against 119 rows, so each break splits a row into exactly one more segment.

Cost

Nothing on the booking path: no strategy, no PlaceGeometry, no PlaceCandidate, no candidate read. The flag is written when a venue is imported or renumbered.

The booker's drawing is byte-identical: it is packed to about 100 bytes a seat because every booker on a placed production fetches it, so the flagged ids are passed to the Numbering tab in its own settings instead of adding a tenth column there.

Tests

RowPathTest: an aisle is a break, an even row has none, a step merely 1.5x the pitch is still a neighbor, a row too short to have a typical spacing has none. PlacePositionsTest: the flag is written onto the seat after the aisle, a hall already correct is untouched, an undrawn row gets none. Five classes green including SeatTogetherTest and VenueIoTest.

phpcs, phpstan, prettier, eslint, stylelint and cspell all clean, each run the way CI runs it.

Deploying

Pre-1.0, so no update hook: the field storage definition needs installing by hand, then drush yoyaku:venue-positions <venue> --fix to populate. Done on the site this was measured against.

Phase 2, not here

Making the flag decide anything is separate, and it should be options on the seating strategy rather than baked in, so the default stays today's behaviour and the cost lands only where a house opts in:

  • respect_gaps — a run stops at a break, so "together" stops meaning "across the aisle".
  • orphan_prevention — disabled / allow_aisle / no_singles, the tiers commercial ticketing systems ship. This belongs on the strategy and not on a constraint: at high fill an orphan is unavoidable and the last booker should get it, so it has to be a preference with a fallback, which a constraint cannot be.

Reworked after review: no automatic processing on import

The first version derived position and gap_before from the coordinates on import, silently overriding the manifest. That is now gone, and the import is literal again.

Why it was wrong: a package stopped stating what the hall is. It round-tripped only because the same algorithm ran twice, so changing the threshold would change how an old package imports, and an operator who disagreed with a detected aisle had no way to say so.

  • The package carries both facts. The exporter writes gap_before (only when true, as it does for subsection and weight); the importer reads it. Manifest version 3; older packages still import, since every key is optional.
  • Import derives nothing. A package whose numbering contradicts its own coordinates loads exactly as written. The import-time derivation added in [#3615672] is removed too.
  • Derivation is drush yoyaku:venue-positions <venue> --fix, and only that. The report still exits non-zero on drift and the Numbering tab still draws it, so a wrong package is loud rather than quietly repaired.
  • The bundled Auditorium states its own 38 aisles, matched from the hall this was measured on.

New tests: the breaks are carried by the file and survive export and re-import; and an import takes the manifest as written even when the coordinates disagree, with the disagreement still reported. The old test asserting that import renumbers is removed, since that is no longer the contract.

Vocabulary

"The drawing" meant three different things in this feature and was genuinely confusing. Swept, by hand, per sentence:

  • the coordinates - a place's x and y, which is what RowPath actually reads.
  • the plan - the hall as laid out.
  • the map background - the SVG, by its own field name. Nothing here parses it.

And the thing that was nowhere in the documentation: x and y are the centre of the seat, as the map's own rendering says by pulling each glyph back half its size. So a step between two seats is centre-to-centre spacing, a seat pitch, which is what makes comparing a gap against a row's median step meaningful. Written up in venue-map.md under "What x and y mean exactly", with the two consequences for anyone generating a hall.

Edited by Frank Mably

Merge request reports

Loading
Loading