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_beforeboolean 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.PlacePositionswrites 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-positionsreportsbreaks 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) 18Two 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 B67Same 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 forsubsectionandweight); 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
xandy, which is whatRowPathactually 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.