The map was one picture of one floor. A second floor was added, the blueprint changed size, and machines moved, so a position now records WHICH DRAWING its coordinates belong to. Buildings and levels (ADR-017). Each level owns its blueprint per theme and its own native pixel size; assets.mapx/mapy are pixels of assets.levelid, not of the site. A position whose level is unknown renders "level unknown" and is never drawn on the default level, because a marker on the wrong floor plan looks entirely correct while pointing at the wrong place. Repositioning in bulk: filter by unplaced, needs-review or level, search, place, confirm. Landmark recalibration solves the transform PER AXIS from landmark pairs and never from image dimensions - the canvas grew taller without rescaling, so a dimension-derived scale would stretch Y by 1.57 and be wrong everywhere. It defaults to a dry run, reports what would land off the drawing, snapshots before applying, and clears mapverifiedat because a transform is a guess awaiting review. Snapshots restore, including the level and the review state, and a restore snapshots first so an undo is undoable. Search: gaugelabreference was matched only for measuring tools and maintenancereference was matched nowhere at all, for any asset type, while Settings happily offers both identifiers on machines and PCs. A tag an operator is told to record has to be findable or it is a write-only field. USB devices and printed items were unreachable from search entirely - neither is an asset, so the generic asset search could not see them and no searcher existed; they now match on serial, asset tag, label, bin code and gage-lab tag, honouring isactive, with Settings toggles and result labels to match. The retired-application rule was half a rule: GET /api/knowledgebase hid articles whose topic application is retired while global search still returned them and printed the retired application as the subject. A filter is only real if every path that reaches the row applies it. Contract to 0.20.0 (additive): Asset gained levelid and mapverifiedat, Location gained levelid, and resolve_asset_position returns the levelid belonging to whichever source supplied the coordinates. The five plugins that write a map position are re-pinned. The install-list text format gained levelid as a NINTH field, appended, because the shipped Pascal installer reads fields 0-7 by index. That installer still compiles in one drawing's dimensions and bundles one blueprint, so its map is accurate for the default level only; /api/maplevels is deliberately unauthenticated so it can read both at runtime once rebuilt. Recorded in PRINTER-INSTALLER.md section 6 along with the other known gaps. Migration 7d33 converts an existing single-map site into one building and one default level carrying the old map_* settings, then assigns every placed asset and location to it. Nothing moves on screen. Old settings rows are kept so a rollback still finds them. Verified end to end on MySQL 5.6 from a production-shaped database.
144 lines
7.0 KiB
Markdown
144 lines
7.0 KiB
Markdown
# ADR-017: Buildings and levels as the map model
|
|
|
|
- Status: ACCEPTED
|
|
- Date: 2026-08-17
|
|
- Deciders: ShopDB maintainers
|
|
- Relates to: ADR-001 (asset as the platform contract), ADR-004 (per-site
|
|
instances), ADR-010 (frontend plugin hooks), ADR-015 (site-specific
|
|
configuration)
|
|
- Supersedes: the site-wide `map_blueprint_light` / `map_blueprint_dark` /
|
|
`map_width` / `map_height` settings
|
|
|
|
## Context
|
|
|
|
A site's floor map is one image. Four settings describe it - two blueprints
|
|
(light and dark) and the native pixel width and height - and `assets.mapx` /
|
|
`assets.mapy` are absolute pixel coordinates in that image's space.
|
|
|
|
That model has one floor in one building, which was true when it was written and
|
|
is no longer. The reference site is adding a second level, and a second building
|
|
is likely within a year or two. The immediate trigger is a new blueprint at
|
|
3308x4000 where the old one was 3300x2550.
|
|
|
|
The obvious cheap answer is to stack levels on one canvas: draw the second level
|
|
below the first and keep one image. It works, and it was seriously considered.
|
|
It was rejected because it makes the level an inference rather than a fact:
|
|
|
|
- "Which level is this asset on" becomes `mapy > 2550`. Every per-level filter,
|
|
count, report and export has to know that constant, and re-exporting the
|
|
drawing at a different height silently changes the answer everywhere. Not
|
|
broken - wrong, which is worse.
|
|
- Distance between two markers becomes computable and meaningless, so anything
|
|
doing nearest-asset or clustering quietly answers nonsense across levels.
|
|
- One canvas forces one scale. A mezzanine drawn at a different scale than the
|
|
floor below cannot be expressed at all.
|
|
- 3308x4000 is portrait for a building whose floor is landscape. At a zoom where
|
|
a marker is clickable, roughly half a level is visible, and "fit to level" is
|
|
not expressible.
|
|
- A third level makes each of these worse, and adds a second threshold.
|
|
|
|
Timing decided it. Because the level layout changed and machines moved, every
|
|
marker is going to be repositioned anyway. Introducing levels now costs one pass
|
|
over the positions; stacking now and splitting later costs two, and in between
|
|
every position placed encodes the threshold into real data - which a later
|
|
migration would then have to un-guess by comparing Y against it.
|
|
|
|
## Decision
|
|
|
|
Two tables, and assets reference the level.
|
|
|
|
```
|
|
buildings buildingid, buildingname, sortorder, isactive
|
|
maplevels levelid, buildingid, levelname, sortorder,
|
|
blueprintlight, blueprintdark, mapwidth, mapheight,
|
|
isdefault, isactive
|
|
assets levelid (nullable; the default level for existing rows)
|
|
```
|
|
|
|
**1. An asset references the level, never the building.** The building derives
|
|
from the level, so the two cannot disagree. Storing both would be a fact
|
|
recorded twice with no difference in granularity to justify it.
|
|
|
|
**2. Blueprints and native dimensions belong to the level.** This is what the
|
|
site-wide settings could not express: two levels in one building may be drawn at
|
|
different sizes and scales, and two buildings certainly are.
|
|
|
|
**3. Name and order are separate.** `levelname` is text and `sortorder` is an
|
|
integer. Levels are not reliably numbered - basement, ground, mezzanine, roof,
|
|
tunnel - and sort order gives adjacency and up/down navigation without
|
|
pretending the names are ordinal. It also lets a mezzanine be inserted between
|
|
two existing levels without renumbering anything.
|
|
|
|
**4. `mapx` / `mapy` keep their meaning, scoped to the level.** They stay
|
|
absolute pixels in the native coordinate space of the level's blueprint. No
|
|
normalisation to fractions: pixels are what the drawing tools produce, what an
|
|
operator can read off an image, and what the existing data already holds.
|
|
|
|
**5. A level is required to render a position, and absence is not a default.**
|
|
Where a payload carries a position without a level, the UI renders "level
|
|
unknown" rather than falling back to the default level. Falling back draws one
|
|
building's ground floor with a marker positioned for another building's
|
|
mezzanine: it renders perfectly and points at the wrong place, and nothing about
|
|
the result looks wrong. A visible gap is worth more than a confident wrong
|
|
answer.
|
|
|
|
**6. The level list is readable without authentication.** The printer installer
|
|
map runs optional-auth, before anyone logs in, and it needs a blueprint. This
|
|
follows the precedent already set for printer install-list and the slide feed.
|
|
Blueprint paths and level names are not secrets; the positions of assets on them
|
|
already render on public kiosk pages.
|
|
|
|
**7. Levels are not Locations.** Assets carry `locationid` already, and reusing
|
|
it is tempting. A Location answers "which operation owns this"; a level answers
|
|
"which drawing renders it, at what native size". Overloading Location with
|
|
blueprint images and pixel dimensions makes both concepts worse. They coexist:
|
|
an asset on level 2 in operation 0613.
|
|
|
|
## Migration
|
|
|
|
The existing four settings become one building and one level, marked default,
|
|
and every asset with a position points at it. Nothing renders differently on the
|
|
day it lands. The settings keys are then retired rather than left as a second
|
|
source of truth that can disagree with the rows.
|
|
|
|
## Consequences
|
|
|
|
Positive:
|
|
|
|
- A second building costs a row and a blueprint. So does a third level.
|
|
- Per-level export, per-level zoom, and per-level calibration all become
|
|
expressible. Calibrating one level against its own landmarks is more correct
|
|
than transforming a whole site at once.
|
|
- Level becomes queryable - counts and filters per level or per building are
|
|
ordinary queries rather than coordinate arithmetic.
|
|
|
|
Negative:
|
|
|
|
- Every surface that draws a marker learns there is more than one drawing. That
|
|
is two components, seven views, four asset position pickers, the printer
|
|
installer map, the PDF export, and eight API payloads that must emit `levelid`
|
|
beside `mapx`. A payload that forgets it produces the silent-wrong-map failure
|
|
described above, which is why rule 5 exists and why a build gate checks that
|
|
the two fields travel together.
|
|
- `get_map_overlays` gains level context, so this is a plugin contract change
|
|
(0.20.0) and plugins rendering overlays need to declare which level they are
|
|
for.
|
|
- One more admin surface: levels and buildings have to be managed somewhere, and
|
|
the existing floor-map settings page becomes that.
|
|
|
|
## Alternatives considered
|
|
|
|
**Stack levels on one canvas.** Rejected above. Zero cost today, and it makes
|
|
the level an inference over a magic number.
|
|
|
|
**Normalise coordinates to fractions of the image.** Would make a rescaled
|
|
blueprint self-correcting, which is genuinely attractive. Rejected for now
|
|
because it converts every existing integer position through a lossy division,
|
|
and because it does not help the actual problem: the levels changed and machines
|
|
moved, so the positions need human review regardless. Worth revisiting
|
|
independently.
|
|
|
|
**A `level` string on the asset, with one blueprint per level in settings.**
|
|
Rejected: settings keyed by level name is a table with extra steps, and it gives
|
|
no place for per-level dimensions or ordering.
|