From 3044db2e87ab13821d754402d49c992a2d08b321 Mon Sep 17 00:00:00 2001
From: SuprexDE <46200229+SuprexDE@users.noreply.github.com>
Date: Tue, 4 Aug 2026 19:25:41 +0200
Subject: [PATCH] chore: Cleanup Code
---
scripts/validate-data.mjs | 85 +++----
src/App.vue | 86 +------
src/core/constants.ts | 31 ++-
src/core/interaction/usePanZoom.ts | 32 ++-
src/core/interaction/viewTransform.ts | 6 -
src/core/model/contributorIndex.ts | 2 +-
src/core/model/dataPaths.ts | 16 +-
src/core/model/dataSource.ts | 27 ++-
src/core/model/elementIndex.ts | 5 +-
src/core/model/roomCameras.ts | 9 +
src/core/model/roomPath.ts | 158 ++++++------
src/core/model/types.ts | 6 +
src/core/model/validation.ts | 174 ++++++++-----
src/core/model/vec2.ts | 11 +
src/core/model/wallGaps.ts | 32 ++-
src/core/render/CalloutMarker.vue | 4 +-
src/core/render/PlacementIcon.vue | 2 +-
src/core/render/StructuralMarker.vue | 45 ++--
src/core/render/structuralShapes.ts | 24 +-
src/core/render/useIconFallback.ts | 6 +-
src/core/ui/CustomCursorOverlay.vue | 87 +++++++
src/editor/EditorCanvas.vue | 45 ++--
src/editor/EditorShell.vue | 63 +----
src/editor/EditorToolbar.vue | 12 +-
src/editor/ToolOverlayLayer.vue | 14 +-
src/editor/editor-forms.css | 62 +++++
src/editor/panels/ElementCrudDialogs.vue | 40 +++
src/editor/panels/ElementDialog.vue | 68 ++----
src/editor/panels/ElementGroupList.vue | 47 ++++
src/editor/panels/ElementsManager.vue | 64 +----
src/editor/panels/ExportDialog.vue | 7 +-
src/editor/panels/FiltersEditor.vue | 47 +---
src/editor/panels/FloorsEditor.vue | 13 +-
src/editor/panels/ImportDialog.vue | 9 +-
src/editor/panels/MapMetaForm.vue | 18 +-
src/editor/panels/PaletteElementButton.vue | 2 +-
src/editor/panels/PalettePanel.vue | 70 ++----
src/editor/panels/PlacementMarkerEditor.vue | 49 +---
src/editor/panels/PlacementProperties.vue | 40 +--
src/editor/panels/PropsSchemaForm.vue | 11 -
src/editor/panels/RoomImagesEditor.vue | 25 +-
src/editor/panels/RoomInnerLinesEditor.vue | 11 +-
src/editor/panels/RoomProperties.vue | 55 +----
src/editor/panels/RoomWallGapsEditor.vue | 35 +--
src/editor/panels/RouteProperties.vue | 38 +--
src/editor/panels/ShortcutHelpDialog.vue | 13 +-
src/editor/panels/StartDialog.vue | 131 +---------
src/editor/panels/TrialStep.vue | 138 +++++++++++
src/editor/panels/TrialsEditor.vue | 13 +-
src/editor/panels/UsageDeleteDialog.vue | 10 -
src/editor/panels/useIconField.ts | 14 ++
src/editor/store/documentIO.ts | 2 +-
src/editor/store/editorStore.ts | 40 ++-
src/editor/store/libraryStore.ts | 4 +
src/editor/store/useExportArtifacts.ts | 34 +--
src/editor/store/useValidationIssues.ts | 45 ++++
src/editor/tools/toolTypes.ts | 8 +-
src/editor/tools/useCameraPickMode.ts | 8 +-
src/editor/tools/useClipboard.ts | 4 +-
src/editor/tools/usePlacementTool.ts | 3 +-
src/editor/tools/useRoomResize.ts | 50 ++--
src/editor/tools/useRoomTool.ts | 204 +++-------------
src/editor/tools/useRoomVertexMode.ts | 168 +++++++++++++
src/editor/tools/useRouteEditMode.ts | 64 +++--
src/editor/tools/useRouteTool.ts | 5 +-
src/editor/tools/useSelectTool.ts | 29 ++-
src/editor/tools/useWallGapMode.ts | 30 +--
src/theme/tokens.css | 31 ++-
src/viewer/ContributorsSection.vue | 4 -
src/viewer/CursorSeverAnimation.vue | 226 +++++++++++++++++
src/viewer/CursorToggle.vue | 255 ++------------------
src/viewer/HeroBanner.vue | 4 +-
src/viewer/MapCard.vue | 8 +-
src/viewer/MapOverview.vue | 50 +---
src/viewer/MapViewer.vue | 36 +--
src/viewer/PageBackdrop.vue | 45 ++++
src/viewer/ViewerCanvas.vue | 22 +-
src/viewer/panels/MapCredits.vue | 11 +-
src/viewer/panels/ViewerPanel.vue | 28 +--
src/viewer/store/viewerStore.ts | 46 ++--
src/viewer/useViewerUrlSync.ts | 31 +--
81 files changed, 1683 insertions(+), 1824 deletions(-)
create mode 100644 src/core/model/roomCameras.ts
create mode 100644 src/core/model/vec2.ts
create mode 100644 src/core/ui/CustomCursorOverlay.vue
create mode 100644 src/editor/editor-forms.css
create mode 100644 src/editor/panels/ElementCrudDialogs.vue
create mode 100644 src/editor/panels/ElementGroupList.vue
create mode 100644 src/editor/panels/TrialStep.vue
create mode 100644 src/editor/panels/useIconField.ts
create mode 100644 src/editor/store/useValidationIssues.ts
create mode 100644 src/editor/tools/useRoomVertexMode.ts
create mode 100644 src/viewer/CursorSeverAnimation.vue
create mode 100644 src/viewer/PageBackdrop.vue
diff --git a/scripts/validate-data.mjs b/scripts/validate-data.mjs
index f06a487..1ce4032 100644
--- a/scripts/validate-data.mjs
+++ b/scripts/validate-data.mjs
@@ -9,12 +9,20 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
import { dirname, extname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import Ajv from 'ajv'
-import { mapManifestPath, trialDocumentPath } from '../src/core/model/dataPaths.ts'
import {
- checkUniqueIds,
+ CONTRIBUTORS_PATH,
+ ELEMENT_LIBRARY_PATH,
+ MAPS_INDEX_PATH,
+ ZONE_LIBRARY_PATH,
+ mapManifestPath,
+ trialDocumentPath,
+ trialsDirPath,
+} from '../src/core/model/dataPaths.ts'
+import {
collectContributorIssues,
collectLibraryIssues,
collectManifestIssues,
+ collectMapsIndexIssues,
collectTrialLogicIssues,
collectZoneLibraryIssues,
} from '../src/core/model/validation.ts'
@@ -27,22 +35,14 @@ const SCREENSHOT_MAX_BYTES = 500 * 1024
const SCREENSHOT_WARN_BYTES = 300 * 1024
const SCREENSHOT_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp'])
-const SCHEMA_IDS = {
- mapsIndex: 'https://maps.outlasttrialsstats.com/schemas/maps-index.schema.json',
- map: 'https://maps.outlasttrialsstats.com/schemas/map.schema.json',
- trial: 'https://maps.outlasttrialsstats.com/schemas/trial.schema.json',
- elements: 'https://maps.outlasttrialsstats.com/schemas/elements.schema.json',
- zones: 'https://maps.outlasttrialsstats.com/schemas/zones.schema.json',
- contributors: 'https://maps.outlasttrialsstats.com/schemas/contributors.schema.json',
-}
-
const ajv = new Ajv({ allErrors: true })
-ajv.addSchema(readJson('public/schemas/maps-index.schema.json'))
-ajv.addSchema(readJson('public/schemas/map.schema.json'))
-ajv.addSchema(readJson('public/schemas/trial.schema.json'))
-ajv.addSchema(readJson('public/schemas/elements.schema.json'))
-ajv.addSchema(readJson('public/schemas/zones.schema.json'))
-ajv.addSchema(readJson('public/schemas/contributors.schema.json'))
+// All $refs are schema-local, so plain per-file compilation is enough.
+const validators = Object.fromEntries(
+ ['maps-index', 'map', 'trial', 'elements', 'zones', 'contributors'].map((name) => [
+ name,
+ ajv.compile(readJson(`public/schemas/${name}.schema.json`)),
+ ]),
+)
const errors = []
const warnings = []
@@ -65,8 +65,7 @@ function checkScreenshot(relPath) {
}
}
-function validateSchema(relPath, schemaId, data) {
- const validate = ajv.getSchema(schemaId)
+function validateSchema(relPath, validate, data) {
if (!validate(data)) {
for (const err of validate.errors) {
errors.push(`${relPath}${err.instancePath}: ${err.message}`)
@@ -82,28 +81,22 @@ function reportIssues(relPath, issues) {
}
}
-const library = readJson('public/data/elements.json')
-if (validateSchema('public/data/elements.json', SCHEMA_IDS.elements, library)) {
- reportIssues('public/data/elements.json', collectLibraryIssues(library))
+const libraryPath = `public/data/${ELEMENT_LIBRARY_PATH}`
+const library = readJson(libraryPath)
+if (validateSchema(libraryPath, validators.elements, library)) {
+ reportIssues(libraryPath, collectLibraryIssues(library))
}
-const zones = readJson('public/data/zones.json')
-if (validateSchema('public/data/zones.json', SCHEMA_IDS.zones, zones)) {
- reportIssues('public/data/zones.json', collectZoneLibraryIssues(zones))
+const zonesPath = `public/data/${ZONE_LIBRARY_PATH}`
+const zones = readJson(zonesPath)
+if (validateSchema(zonesPath, validators.zones, zones)) {
+ reportIssues(zonesPath, collectZoneLibraryIssues(zones))
}
-const mapsIndex = readJson('public/data/maps/index.json')
-validateSchema('public/data/maps/index.json', SCHEMA_IDS.mapsIndex, mapsIndex)
-{
- const indexIssues = []
- checkUniqueIds(
- indexIssues,
- 'maps',
- 'map',
- mapsIndex.maps.map((entry) => entry.id),
- )
- reportIssues('public/data/maps/index.json', indexIssues)
-}
+const mapsIndexPath = `public/data/${MAPS_INDEX_PATH}`
+const mapsIndex = readJson(mapsIndexPath)
+validateSchema(mapsIndexPath, validators['maps-index'], mapsIndex)
+reportIssues(mapsIndexPath, collectMapsIndexIssues(mapsIndex))
let validatedManifests = 0
let validatedTrials = 0
@@ -120,7 +113,7 @@ for (const entry of mapsIndex.maps) {
continue
}
const manifest = readJson(relPath)
- if (!validateSchema(relPath, SCHEMA_IDS.map, manifest)) {
+ if (!validateSchema(relPath, validators.map, manifest)) {
continue
}
if (manifest.id !== entry.id) {
@@ -130,7 +123,7 @@ for (const entry of mapsIndex.maps) {
reportIssues(relPath, collectManifestIssues(manifest))
validatedManifests += 1
- const trialsDir = dirname(`public/data/${trialDocumentPath(entry.id, 'x')}`)
+ const trialsDir = `public/data/${trialsDirPath(entry.id)}`
const manifestTrialIds = new Set(manifest.trials.map((trial) => trial.id))
for (const trial of manifest.trials) {
const trialPath = `public/data/${trialDocumentPath(entry.id, trial.id)}`
@@ -141,7 +134,7 @@ for (const entry of mapsIndex.maps) {
continue
}
const trialDoc = readJson(trialPath)
- if (!validateSchema(trialPath, SCHEMA_IDS.trial, trialDoc)) {
+ if (!validateSchema(trialPath, validators.trial, trialDoc)) {
continue
}
if (trialDoc.mapId !== entry.id) {
@@ -173,20 +166,18 @@ for (const entry of mapsIndex.maps) {
}
}
-const contributors = readJson('public/data/contributors.json')
-if (validateSchema('public/data/contributors.json', SCHEMA_IDS.contributors, contributors)) {
+const contributorsPath = `public/data/${CONTRIBUTORS_PATH}`
+const contributors = readJson(contributorsPath)
+if (validateSchema(contributorsPath, validators.contributors, contributors)) {
const knownMapIds = new Set(mapsIndex.maps.map((entry) => entry.id))
- reportIssues(
- 'public/data/contributors.json',
- collectContributorIssues(contributors, authorsByMapId, knownMapIds),
- )
+ reportIssues(contributorsPath, collectContributorIssues(contributors, authorsByMapId, knownMapIds))
// Missing profiles do not block a PR — they show the maintainer what is still open.
const credited = new Set(contributors.contributors.map((entry) => entry.name))
for (const [mapId, authors] of authorsByMapId) {
for (const author of authors) {
if (!credited.has(author)) {
warnings.push(
- `public/data/contributors.json: "${author}" (map "${mapId}") has no entry — the start page shows no profile link`,
+ `${contributorsPath}: "${author}" (map "${mapId}") has no entry — the start page shows no profile link`,
)
}
}
diff --git a/src/App.vue b/src/App.vue
index d478211..31bf559 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,95 +1,13 @@
-
+
-
-
diff --git a/src/core/constants.ts b/src/core/constants.ts
index f895ce1..5632261 100644
--- a/src/core/constants.ts
+++ b/src/core/constants.ts
@@ -1,9 +1,16 @@
+import {
+ CONTRIBUTORS_PATH,
+ ELEMENT_LIBRARY_PATH,
+ MAPS_INDEX_PATH,
+ ZONE_LIBRARY_PATH,
+} from './model/dataPaths'
+
/** Base URL of all content fetched at runtime (public/data); BASE_URL always ends with '/'. */
export const DATA_BASE_URL = `${import.meta.env.BASE_URL}data`
-export const MAPS_INDEX_URL = `${DATA_BASE_URL}/maps/index.json`
-export const ELEMENT_LIBRARY_URL = `${DATA_BASE_URL}/elements.json`
-export const ZONE_LIBRARY_URL = `${DATA_BASE_URL}/zones.json`
-export const CONTRIBUTORS_URL = `${DATA_BASE_URL}/contributors.json`
+export const MAPS_INDEX_URL = `${DATA_BASE_URL}/${MAPS_INDEX_PATH}`
+export const ELEMENT_LIBRARY_URL = `${DATA_BASE_URL}/${ELEMENT_LIBRARY_PATH}`
+export const ZONE_LIBRARY_URL = `${DATA_BASE_URL}/${ZONE_LIBRARY_PATH}`
+export const CONTRIBUTORS_URL = `${DATA_BASE_URL}/${CONTRIBUTORS_PATH}`
export const SCHEMA_BASE_URL = `${import.meta.env.BASE_URL}schemas`
/** Repo behind the join link of the contributors section. */
@@ -50,7 +57,10 @@ export const EDITOR_AUTOSAVE_KEY = 'outlasttrials-maps:editor-autosave'
export const EDITOR_AUTOSAVE_VERSION = 4
export const CURSOR_STORAGE_KEY = 'outlasttrials-maps:custom-cursor'
+/** The custom cursor image points slightly left of its top-left corner. */
+export const CURSOR_HOTSPOT_OFFSET_PX = 2
export const AUTOSAVE_DEBOUNCE_MS = 1000
+export const TOAST_LIFE_MS = 5000
export const UNDO_STACK_LIMIT = 100
/** Commits with the same coalesce key within this window share one undo snapshot. */
export const UNDO_COALESCE_MS = 800
@@ -82,9 +92,11 @@ export const VIEW_PAN_STEP_LARGE_PX = 200
* Rotations are limited to fixed steps (R key and properties select);
* in sync with the `multipleOf` of the `rotation` field in the map schema.
*/
+export const FULL_CIRCLE_DEG = 360
+export const HALF_CIRCLE_DEG = 180
export const ROTATION_STEP_DEG = 45
export const ROTATION_VALUES = Array.from(
- { length: 360 / ROTATION_STEP_DEG },
+ { length: FULL_CIRCLE_DEG / ROTATION_STEP_DEG },
(_, index) => index * ROTATION_STEP_DEG,
)
@@ -100,6 +112,11 @@ export const MARQUEE_MIN_DRAG_PX = 4
/** Smallest width/height a room can be resized to, in map units (one grid cell). */
export const ROOM_RESIZE_MIN_SIZE = 5
+/** Open polylines (routes, inner lines) need at least a start and an end point. */
+export const MIN_OPEN_PATH_POINTS = 2
+/** A polygon room needs at least a triangle. */
+export const MIN_POLYGON_POINTS = 3
+
/** Wall gaps (openings in a room outline), in map units. */
export const WALL_GAP_DEFAULT_LENGTH = 8
export const WALL_GAP_MIN_LENGTH = 1
@@ -138,6 +155,10 @@ export const BARRICADE_PLANK_GAP = 0.5
export const BARRICADE_HATCH_SPACING = 2
/** Spacing of the slanted bars of a crawl passage. */
export const CRAWL_BAR_SPACING = 1.6
+/** Spacing of the step rungs on stairs. */
+export const STAIRS_RUNG_SPACING = 3
+/** Spacing of the teeth on obstacles. */
+export const OBSTACLE_TOOTH_SPACING = 2.5
// Number marker (dot → leader line → diamond badge with a number)
export const MARKER_COLOR = '#aaaaaa'
diff --git a/src/core/interaction/usePanZoom.ts b/src/core/interaction/usePanZoom.ts
index d7c19d2..2501d1d 100644
--- a/src/core/interaction/usePanZoom.ts
+++ b/src/core/interaction/usePanZoom.ts
@@ -8,15 +8,11 @@ import {
ZOOM_MAX,
ZOOM_MIN,
} from '../constants'
-import type { Vec2 } from '../model/types'
+import type { Bounds, Vec2 } from '../model/types'
+import { clamp, distance } from '../model/vec2'
import { isEditableTarget } from './eventTargets'
import type { ViewTransform } from './viewTransform'
-export interface WorldBounds {
- min: Vec2
- max: Vec2
-}
-
export interface PanZoomOptions {
/** Pan with the left mouse button as well — in the editor it stays reserved for the tools. */
dragPan?: boolean
@@ -84,7 +80,7 @@ export function usePanZoom(svgRef: Readonly>, options?
if (!rightDragStart || rightDragMoved) {
return
}
- const travel = Math.hypot(event.clientX - rightDragStart[0], event.clientY - rightDragStart[1])
+ const travel = distance([event.clientX, event.clientY], rightDragStart)
rightDragMoved = travel > RIGHT_DRAG_PAN_THRESHOLD_PX
}
/** `rightDragMoved` has to survive this: `contextmenu` only fires after the pointer-up. */
@@ -180,27 +176,26 @@ export function usePanZoom(svgRef: Readonly>, options?
}
}
- /** Fits the view to the given world bounds (without bounds: identity). */
- function resetView(bounds?: WorldBounds): void {
+ function resetView(): void {
+ if (behavior && selection) {
+ selection.call(behavior.transform, zoomIdentity)
+ }
+ }
+
+ function fitBounds(bounds: Bounds): void {
const svg = svgRef.value
if (!svg || !behavior || !selection) {
return
}
- if (!bounds) {
- selection.call(behavior.transform, zoomIdentity)
- return
- }
const rect = svg.getBoundingClientRect()
const width = bounds.max[0] - bounds.min[0]
const height = bounds.max[1] - bounds.min[1]
if (rect.width === 0 || rect.height === 0 || width <= 0 || height <= 0) {
return
}
- const scale = Math.min(
- Math.max(
- Math.min(rect.width / width, rect.height / height) * FIT_VIEW_PADDING_RATIO,
- ZOOM_MIN,
- ),
+ const scale = clamp(
+ Math.min(rect.width / width, rect.height / height) * FIT_VIEW_PADDING_RATIO,
+ ZOOM_MIN,
ZOOM_MAX,
)
const center: Vec2 = [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2]
@@ -218,6 +213,7 @@ export function usePanZoom(svgRef: Readonly>, options?
isSpacePanning: readonly(isSpacePanning),
isPanning: readonly(isPanning),
resetView,
+ fitBounds,
zoomBy,
panBy,
}
diff --git a/src/core/interaction/viewTransform.ts b/src/core/interaction/viewTransform.ts
index 8f60fe6..faa7c44 100644
--- a/src/core/interaction/viewTransform.ts
+++ b/src/core/interaction/viewTransform.ts
@@ -7,12 +7,6 @@ export interface ViewTransform {
k: number
}
-export const IDENTITY_TRANSFORM: ViewTransform = { x: 0, y: 0, k: 1 }
-
export function screenToWorld(point: Vec2, transform: ViewTransform): Vec2 {
return [(point[0] - transform.x) / transform.k, (point[1] - transform.y) / transform.k]
}
-
-export function worldToScreen(point: Vec2, transform: ViewTransform): Vec2 {
- return [point[0] * transform.k + transform.x, point[1] * transform.k + transform.y]
-}
diff --git a/src/core/model/contributorIndex.ts b/src/core/model/contributorIndex.ts
index e9d6543..a11d5b3 100644
--- a/src/core/model/contributorIndex.ts
+++ b/src/core/model/contributorIndex.ts
@@ -1,6 +1,6 @@
import type { Contributor, Contributors } from './types'
-export type ContributorIndex = ReadonlyMap
+type ContributorIndex = ReadonlyMap
/** Lookup table author name → contributor (link to `meta.authors`). */
export function buildContributorIndex(contributors: Contributors | null): ContributorIndex {
diff --git a/src/core/model/dataPaths.ts b/src/core/model/dataPaths.ts
index 4f5a6d7..55ca2a3 100644
--- a/src/core/model/dataPaths.ts
+++ b/src/core/model/dataPaths.ts
@@ -1,13 +1,23 @@
/**
- * Layout of a map's files relative to the data root — the single source for
+ * Layout of the data files relative to the data root — the single source for
* fetch URLs, export repo paths and the CI script. Import-free on purpose:
* scripts/validate-data.mjs loads it via Node type stripping.
*/
+export const MAPS_INDEX_PATH = 'maps/index.json'
+export const ELEMENT_LIBRARY_PATH = 'elements.json'
+export const ZONE_LIBRARY_PATH = 'zones.json'
+export const CONTRIBUTORS_PATH = 'contributors.json'
+export const MAP_MANIFEST_FILENAME = 'map.json'
+
export function mapManifestPath(mapId: string): string {
- return `maps/${mapId}/map.json`
+ return `maps/${mapId}/${MAP_MANIFEST_FILENAME}`
+}
+
+export function trialsDirPath(mapId: string): string {
+ return `maps/${mapId}/trials`
}
export function trialDocumentPath(mapId: string, trialId: string): string {
- return `maps/${mapId}/trials/${trialId}.json`
+ return `${trialsDirPath(mapId)}/${trialId}.json`
}
diff --git a/src/core/model/dataSource.ts b/src/core/model/dataSource.ts
index 1c78011..b0098d9 100644
--- a/src/core/model/dataSource.ts
+++ b/src/core/model/dataSource.ts
@@ -25,21 +25,22 @@ export async function fetchJson(url: string): Promise {
return (await response.json()) as T
}
-export function loadMapsIndex(): Promise {
- return fetchJson(MAPS_INDEX_URL)
-}
-
-export function loadElementLibrary(): Promise {
- return fetchJson(ELEMENT_LIBRARY_URL)
-}
-
-export function loadZoneLibrary(): Promise {
- return fetchJson(ZONE_LIBRARY_URL)
+/** Shares one fetch per session for static files; a failure clears the cache so retries work. */
+function once(load: () => Promise): () => Promise {
+ let promise: Promise | null = null
+ return () => {
+ promise ??= load().catch((error: unknown) => {
+ promise = null
+ throw error
+ })
+ return promise
+ }
}
-export function loadContributors(): Promise {
- return fetchJson(CONTRIBUTORS_URL)
-}
+export const loadMapsIndex = once(() => fetchJson(MAPS_INDEX_URL))
+export const loadElementLibrary = once(() => fetchJson(ELEMENT_LIBRARY_URL))
+export const loadZoneLibrary = once(() => fetchJson(ZONE_LIBRARY_URL))
+export const loadContributors = once(() => fetchJson(CONTRIBUTORS_URL))
export function loadMapManifest(mapId: string): Promise {
return fetchJson(`${DATA_BASE_URL}/${mapManifestPath(mapId)}`)
diff --git a/src/core/model/elementIndex.ts b/src/core/model/elementIndex.ts
index 7e16f70..f79f366 100644
--- a/src/core/model/elementIndex.ts
+++ b/src/core/model/elementIndex.ts
@@ -1,11 +1,12 @@
import type { ElementDefinition, ElementLibrary, Zone, ZoneLibrary } from './types'
-export type ElementIndex = Map
+export type ElementIndex = ReadonlyMap
+export type ZoneIndex = ReadonlyMap
export function buildElementIndex(library: ElementLibrary | null): ElementIndex {
return new Map((library?.elements ?? []).map((def) => [def.id, def]))
}
-export function buildZoneIndex(library: ZoneLibrary | null): ReadonlyMap {
+export function buildZoneIndex(library: ZoneLibrary | null): ZoneIndex {
return new Map((library?.zones ?? []).map((zone) => [zone.id, zone]))
}
diff --git a/src/core/model/roomCameras.ts b/src/core/model/roomCameras.ts
new file mode 100644
index 0000000..74ae158
--- /dev/null
+++ b/src/core/model/roomCameras.ts
@@ -0,0 +1,9 @@
+import type { CameraInfo, Room } from './types'
+
+/** Cameras of the room's images, shown only while its floor is the active one. */
+export function roomCameras(room: Room | null | undefined, activeFloor: number): CameraInfo[] {
+ if (!room || room.floor !== activeFloor) {
+ return []
+ }
+ return (room.info?.images ?? []).flatMap((image) => (image.camera ? [image.camera] : []))
+}
diff --git a/src/core/model/roomPath.ts b/src/core/model/roomPath.ts
index a16d991..c07cb66 100644
--- a/src/core/model/roomPath.ts
+++ b/src/core/model/roomPath.ts
@@ -1,4 +1,4 @@
-import type { Room, RoomShape, Vec2 } from './types'
+import type { Bounds, Room, RoomShape, Vec2 } from './types'
/**
* Minimal path parser for room outlines — paths are only parsed for editing in
@@ -11,7 +11,18 @@ const COMMAND_RE = /^[A-Za-z]$/
const TOKEN_RE = /[A-Za-z]|-?(?:\d*\.\d+|\d+)(?:[eE][+-]?\d+)?/g
const CHARSET_RE = /^[MmLlHhVvZz0-9eE+,.\s-]*$/
-const fmt = (value: number): string => String(Math.round(value * 1000) / 1000)
+/** Path numbers are rounded to 3 decimals so emitted `d` strings stay compact. */
+export const formatPathNumber = (value: number): string =>
+ String(Math.round(value * 1000) / 1000)
+
+const LINETO = {
+ L: ([x, y]: number[], current: Vec2, absolute: boolean): Vec2 =>
+ absolute ? [x, y] : [current[0] + x, current[1] + y],
+ H: ([x]: number[], current: Vec2, absolute: boolean): Vec2 =>
+ absolute ? [x, current[1]] : [current[0] + x, current[1]],
+ V: ([y]: number[], current: Vec2, absolute: boolean): Vec2 =>
+ absolute ? [current[0], y] : [current[0], current[1] + y],
+}
function parsePathPoints(path: string): { points: Vec2[]; closed: boolean } | null {
if (!CHARSET_RE.test(path)) {
@@ -29,13 +40,20 @@ function parsePathPoints(path: string): { points: Vec2[]; closed: boolean } | nu
let index = 0
const isNumberNext = (): boolean => index < tokens.length && !COMMAND_RE.test(tokens[index])
- const nextNumber = (): number | null => {
- if (!isNumberNext()) {
- return null
+ const readNumbers = (count: number): number[] | null => {
+ const values: number[] = []
+ while (values.length < count) {
+ if (!isNumberNext()) {
+ return null
+ }
+ const value = Number(tokens[index])
+ index += 1
+ if (!Number.isFinite(value)) {
+ return null
+ }
+ values.push(value)
}
- const value = Number(tokens[index])
- index += 1
- return Number.isFinite(value) ? value : null
+ return values
}
const push = (point: Vec2): void => {
current = point
@@ -52,69 +70,38 @@ function parsePathPoints(path: string): { points: Vec2[]; closed: boolean } | nu
return null
}
index += 1
- switch (command) {
- case 'M':
- case 'm': {
- if (started) {
+ const upper = command.toUpperCase()
+ const absolute = command === upper
+ if (upper === 'M') {
+ if (started) {
+ return null
+ }
+ const args = readNumbers(2)
+ if (!args) {
+ return null
+ }
+ current = absolute ? [args[0], args[1]] : [current[0] + args[0], current[1] + args[1]]
+ points[0] = current
+ // Further coordinate pairs after M/m are implicit linetos.
+ while (isNumberNext()) {
+ const lineArgs = readNumbers(2)
+ if (!lineArgs) {
return null
}
- const x = nextNumber()
- const y = nextNumber()
- if (x === null || y === null) {
+ push(LINETO.L(lineArgs, current, absolute))
+ }
+ } else if (upper === 'L' || upper === 'H' || upper === 'V') {
+ do {
+ const args = readNumbers(upper === 'L' ? 2 : 1)
+ if (!args) {
return null
}
- current = command === 'M' ? [x, y] : [current[0] + x, current[1] + y]
- points[0] = current
- while (isNumberNext()) {
- const lx = nextNumber()
- const ly = nextNumber()
- if (lx === null || ly === null) {
- return null
- }
- push(command === 'M' ? [lx, ly] : [current[0] + lx, current[1] + ly])
- }
- break
- }
- case 'L':
- case 'l': {
- do {
- const x = nextNumber()
- const y = nextNumber()
- if (x === null || y === null) {
- return null
- }
- push(command === 'L' ? [x, y] : [current[0] + x, current[1] + y])
- } while (isNumberNext())
- break
- }
- case 'H':
- case 'h': {
- do {
- const x = nextNumber()
- if (x === null) {
- return null
- }
- push(command === 'H' ? [x, current[1]] : [current[0] + x, current[1]])
- } while (isNumberNext())
- break
- }
- case 'V':
- case 'v': {
- do {
- const y = nextNumber()
- if (y === null) {
- return null
- }
- push(command === 'V' ? [current[0], y] : [current[0], current[1] + y])
- } while (isNumberNext())
- break
- }
- case 'Z':
- case 'z':
- closed = true
- break
- default:
- return null
+ push(LINETO[upper](args, current, absolute))
+ } while (isNumberNext())
+ } else if (upper === 'Z') {
+ closed = true
+ } else {
+ return null
}
}
@@ -122,7 +109,7 @@ function parsePathPoints(path: string): { points: Vec2[]; closed: boolean } | nu
}
/** Points in shape-local coordinates (relative to the origin, implicit "M 0 0"). */
-export function parseRoomPath(path: string): Vec2[] | null {
+function parseRoomPath(path: string): Vec2[] | null {
const parsed = parsePathPoints(path)
if (!parsed || parsed.points.length < 3) {
return null
@@ -145,7 +132,8 @@ export function parseOpenPath(path: string): Vec2[] | null {
return parsed.points
}
-function appendRelativeSegments(parts: string[], points: Vec2[]): void {
+function relativeSegments(points: Vec2[]): string[] {
+ const parts: string[] = []
for (let i = 1; i < points.length; i += 1) {
const dx = points[i][0] - points[i - 1][0]
const dy = points[i][1] - points[i - 1][1]
@@ -153,24 +141,23 @@ function appendRelativeSegments(parts: string[], points: Vec2[]): void {
continue
}
if (dy === 0) {
- parts.push(`h${fmt(dx)}`)
+ parts.push(`h${formatPathNumber(dx)}`)
} else if (dx === 0) {
- parts.push(`v${fmt(dy)}`)
+ parts.push(`v${formatPathNumber(dy)}`)
} else {
- parts.push(`l${fmt(dx)},${fmt(dy)}`)
+ parts.push(`l${formatPathNumber(dx)},${formatPathNumber(dy)}`)
}
}
+ return parts
}
export function pointsToRelativePath(points: Vec2[]): string {
- const parts: string[] = []
const start = points[0]
- if (start[0] !== 0 || start[1] !== 0) {
- parts.push(`M${fmt(start[0])},${fmt(start[1])}`)
- }
- appendRelativeSegments(parts, points)
- parts.push('z')
- return parts.join(' ')
+ const prefix =
+ start[0] !== 0 || start[1] !== 0
+ ? [`M${formatPathNumber(start[0])},${formatPathNumber(start[1])}`]
+ : []
+ return [...prefix, ...relativeSegments(points), 'z'].join(' ')
}
/** Open path (inner lines, routes): explicit M to the first point, then h/v/l. */
@@ -178,9 +165,10 @@ export function pointsToOpenPath(points: Vec2[]): string {
if (points.length === 0) {
return ''
}
- const parts: string[] = [`M${fmt(points[0][0])},${fmt(points[0][1])}`]
- appendRelativeSegments(parts, points)
- return parts.join(' ')
+ return [
+ `M${formatPathNumber(points[0][0])},${formatPathNumber(points[0][1])}`,
+ ...relativeSegments(points),
+ ].join(' ')
}
const ABSOLUTE_START_RE = /^\s*M\s*(-?(?:\d*\.\d+|\d+))[\s,]+(-?(?:\d*\.\d+|\d+))([\s\S]*)$/
@@ -208,7 +196,7 @@ export function translateAbsolutePathStart(path: string, delta: Vec2): string |
if (!parts) {
return null
}
- return `M${fmt(parts.start[0] + delta[0])},${fmt(parts.start[1] + delta[1])}${parts.rest}`
+ return `M${formatPathNumber(parts.start[0] + delta[0])},${formatPathNumber(parts.start[1] + delta[1])}${parts.rest}`
}
/** Vertices of a room shape in local coordinates; `rect` becomes 4 points. */
@@ -225,7 +213,7 @@ export function shapeToPoints(shape: RoomShape): Vec2[] | null {
return parseRoomPath(shape.path)
}
-export function pointsBounds(points: Vec2[]): { min: Vec2; max: Vec2 } {
+export function pointsBounds(points: Vec2[]): Bounds {
const min: Vec2 = [Infinity, Infinity]
const max: Vec2 = [-Infinity, -Infinity]
for (const [x, y] of points) {
@@ -243,7 +231,7 @@ export function roomWorldPoints(room: Room): Vec2[] {
return points.map(([x, y]): Vec2 => [x + room.shape.origin[0], y + room.shape.origin[1]])
}
-export function roomsBounds(rooms: Room[]): { min: Vec2; max: Vec2 } | null {
+export function roomsBounds(rooms: Room[]): Bounds | null {
const worldPoints = rooms.flatMap(roomWorldPoints)
return worldPoints.length > 0 ? pointsBounds(worldPoints) : null
}
diff --git a/src/core/model/types.ts b/src/core/model/types.ts
index 26289cd..ae4076e 100644
--- a/src/core/model/types.ts
+++ b/src/core/model/types.ts
@@ -6,6 +6,12 @@
/** Point or size in map units, [x, y] or [w, h]. */
export type Vec2 = [number, number]
+/** Axis-aligned bounding box in world or shape-local coordinates. */
+export interface Bounds {
+ min: Vec2
+ max: Vec2
+}
+
// ---------------------------------------------------------------------------
// Map registry — public/data/maps/index.json
// ---------------------------------------------------------------------------
diff --git a/src/core/model/validation.ts b/src/core/model/validation.ts
index df2ef1a..c06a67a 100644
--- a/src/core/model/validation.ts
+++ b/src/core/model/validation.ts
@@ -3,8 +3,11 @@
import { shapeToPoints } from './roomPath.ts'
import type {
Contributors,
+ ElementDefinition,
ElementLibrary,
MapManifest,
+ MapsIndex,
+ Placement,
Room,
TrialDocument,
ZoneLibrary,
@@ -23,7 +26,7 @@ export interface ValidationIssue {
message: string
}
-export function checkUniqueIds(
+function checkUniqueIds(
issues: ValidationIssue[],
path: string,
label: string,
@@ -75,6 +78,17 @@ export function collectZoneLibraryIssues(zones: ZoneLibrary): ValidationIssue[]
return issues
}
+export function collectMapsIndexIssues(mapsIndex: MapsIndex): ValidationIssue[] {
+ const issues: ValidationIssue[] = []
+ checkUniqueIds(
+ issues,
+ 'maps',
+ 'map',
+ mapsIndex.maps.map((entry) => entry.id),
+ )
+ return issues
+}
+
/**
* Checks of the contributor list (contributors.json). The link runs through the
* name, so every entry must appear in `meta.authors` of the map it names.
@@ -163,88 +177,114 @@ export function collectManifestIssues(manifest: MapManifest): ValidationIssue[]
return issues
}
-export function collectTrialLogicIssues(
- trial: TrialDocument,
- library: ElementLibrary | null,
- zones: ZoneLibrary | null,
-): ValidationIssue[] {
- const issues: ValidationIssue[] = []
-
- const floorIndexes = new Set(trial.floors.map((floor) => floor.index))
- const zoneIds = new Set((zones?.zones ?? []).map((zone) => zone.id))
- const categoryIds = new Set((library?.categories ?? []).map((category) => category.id))
- const elementsById = new Map((library?.elements ?? []).map((element) => [element.id, element]))
- const roomsById = new Map(trial.rooms.map((room) => [room.id, room]))
-
- checkUniqueIds(issues, 'floors', 'floor', trial.floors.map((floor) => floor.index))
- checkUniqueIds(issues, 'filters', 'filter', trial.filters.map((filter) => filter.id))
- checkUniqueIds(issues, 'rooms', 'room', trial.rooms.map((room) => room.id))
- checkUniqueIds(issues, 'placements', 'placement', trial.placements.map((p) => p.id))
- checkUniqueIds(issues, 'routes', 'route', trial.routes.map((route) => route.id))
+/** Lookup sets shared by the per-entity collectors of `collectTrialLogicIssues`. */
+interface TrialContext {
+ floorIndexes: ReadonlySet
+ zoneIds: ReadonlySet
+ categoryIds: ReadonlySet
+ elementsById: ReadonlyMap
+ roomsById: ReadonlyMap
+}
- trial.rooms.forEach((room, index) => {
- if (!floorIndexes.has(room.floor)) {
- issues.push({ path: `rooms[${index}].floor`, message: `unknown floor ${room.floor}` })
- }
- if (!zoneIds.has(room.zone)) {
- issues.push({ path: `rooms[${index}].zone`, message: `unknown zone "${room.zone}"` })
- }
- issues.push(...collectWallGapIssues(room, `rooms[${index}]`))
- })
+function collectRoomIssues(room: Room, index: number, context: TrialContext): ValidationIssue[] {
+ const issues: ValidationIssue[] = []
+ if (!context.floorIndexes.has(room.floor)) {
+ issues.push({ path: `rooms[${index}].floor`, message: `unknown floor ${room.floor}` })
+ }
+ if (!context.zoneIds.has(room.zone)) {
+ issues.push({ path: `rooms[${index}].zone`, message: `unknown zone "${room.zone}"` })
+ }
+ issues.push(...collectWallGapIssues(room, `rooms[${index}]`))
+ return issues
+}
- trial.placements.forEach((placement, index) => {
- const element = elementsById.get(placement.element)
- if (!element) {
+function collectPlacementIssues(
+ placement: Placement,
+ index: number,
+ context: TrialContext,
+): ValidationIssue[] {
+ const issues: ValidationIssue[] = []
+ const element = context.elementsById.get(placement.element)
+ if (!element) {
+ issues.push({
+ path: `placements[${index}].element`,
+ message: `unknown element "${placement.element}"`,
+ })
+ }
+ if (placement.size !== undefined) {
+ if (element && !element.render) {
issues.push({
- path: `placements[${index}].element`,
- message: `unknown element "${placement.element}"`,
+ path: `placements[${index}].size`,
+ message: `"size" is only allowed on structural elements (element "${placement.element}" has none)`,
})
}
- if (placement.size !== undefined) {
- if (element && !element.render) {
- issues.push({
- path: `placements[${index}].size`,
- message: `"size" is only allowed on structural elements (element "${placement.element}" has none)`,
- })
- }
- if (placement.size.some((value) => value <= 0)) {
- issues.push({
- path: `placements[${index}].size`,
- message: 'size values must be greater than 0',
- })
- }
- }
- if (!floorIndexes.has(placement.floor)) {
+ if (placement.size.some((value) => value <= 0)) {
issues.push({
- path: `placements[${index}].floor`,
- message: `unknown floor ${placement.floor}`,
+ path: `placements[${index}].size`,
+ message: 'size values must be greater than 0',
})
}
- if (placement.roomId !== undefined) {
- const room = roomsById.get(placement.roomId)
- if (!room) {
- issues.push({
- path: `placements[${index}].roomId`,
- message: `unknown room "${placement.roomId}"`,
- })
- } else if (room.floor !== placement.floor) {
- issues.push({
- path: `placements[${index}].roomId`,
- message: `placement is on floor ${placement.floor} but room "${room.id}" is on floor ${room.floor}`,
- })
- }
+ }
+ if (!context.floorIndexes.has(placement.floor)) {
+ issues.push({
+ path: `placements[${index}].floor`,
+ message: `unknown floor ${placement.floor}`,
+ })
+ }
+ if (placement.roomId !== undefined) {
+ const room = context.roomsById.get(placement.roomId)
+ if (!room) {
+ issues.push({
+ path: `placements[${index}].roomId`,
+ message: `unknown room "${placement.roomId}"`,
+ })
+ } else if (room.floor !== placement.floor) {
+ issues.push({
+ path: `placements[${index}].roomId`,
+ message: `placement is on floor ${placement.floor} but room "${room.id}" is on floor ${room.floor}`,
+ })
}
- })
+ }
+ return issues
+}
+
+export function collectTrialLogicIssues(
+ trial: TrialDocument,
+ library: ElementLibrary | null,
+ zones: ZoneLibrary | null,
+): ValidationIssue[] {
+ const issues: ValidationIssue[] = []
+ const context: TrialContext = {
+ floorIndexes: new Set(trial.floors.map((floor) => floor.index)),
+ zoneIds: new Set((zones?.zones ?? []).map((zone) => zone.id)),
+ categoryIds: new Set((library?.categories ?? []).map((category) => category.id)),
+ elementsById: new Map((library?.elements ?? []).map((element) => [element.id, element])),
+ roomsById: new Map(trial.rooms.map((room) => [room.id, room])),
+ }
+ const uniqueIdChecks: Array<[string, string, Array]> = [
+ ['floors', 'floor', trial.floors.map((floor) => floor.index)],
+ ['filters', 'filter', trial.filters.map((filter) => filter.id)],
+ ['rooms', 'room', trial.rooms.map((room) => room.id)],
+ ['placements', 'placement', trial.placements.map((placement) => placement.id)],
+ ['routes', 'route', trial.routes.map((route) => route.id)],
+ ]
+ for (const [path, label, ids] of uniqueIdChecks) {
+ checkUniqueIds(issues, path, label, ids)
+ }
+
+ trial.rooms.forEach((room, index) => issues.push(...collectRoomIssues(room, index, context)))
+ trial.placements.forEach((placement, index) =>
+ issues.push(...collectPlacementIssues(placement, index, context)),
+ )
trial.routes.forEach((route, index) => {
- if (!floorIndexes.has(route.floor)) {
+ if (!context.floorIndexes.has(route.floor)) {
issues.push({ path: `routes[${index}].floor`, message: `unknown floor ${route.floor}` })
}
})
-
trial.filters.forEach((filter, filterIndex) => {
filter.categories.forEach((category) => {
- if (!categoryIds.has(category)) {
+ if (!context.categoryIds.has(category)) {
issues.push({
path: `filters[${filterIndex}].categories`,
message: `unknown category "${category}"`,
diff --git a/src/core/model/vec2.ts b/src/core/model/vec2.ts
new file mode 100644
index 0000000..fa99e79
--- /dev/null
+++ b/src/core/model/vec2.ts
@@ -0,0 +1,11 @@
+import type { Vec2 } from './types'
+
+export const distance = (a: Vec2, b: Vec2): number => Math.hypot(b[0] - a[0], b[1] - a[1])
+
+export const midpoint = (a: Vec2, b: Vec2): Vec2 => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
+
+export const withinRadius = (a: Vec2, b: Vec2, radius: number): boolean =>
+ distance(a, b) <= radius
+
+export const clamp = (value: number, min: number, max: number): number =>
+ Math.min(Math.max(value, min), max)
diff --git a/src/core/model/wallGaps.ts b/src/core/model/wallGaps.ts
index f648874..14a126d 100644
--- a/src/core/model/wallGaps.ts
+++ b/src/core/model/wallGaps.ts
@@ -1,14 +1,14 @@
-import type { Vec2, WallGap } from './types'
+import type { Room, Vec2, WallGap } from './types'
+import { formatPathNumber } from './roomPath.ts'
+import { clamp, distance } from './vec2.ts'
/**
* Wall gap geometry: a gap is anchored to one edge of the closed room outline,
- * measured from that edge's start point. Import-free on purpose (only types):
+ * measured from that edge's start point. Imports use explicit .ts specifiers:
* scripts/validate-data.mjs reaches this module through validation.ts via Node
* type stripping.
*/
-const fmt = (value: number): string => String(Math.round(value * 1000) / 1000)
-
const samePoint = (a: Vec2, b: Vec2): boolean => a[0] === b[0] && a[1] === b[1]
/** Edges of the closed outline, including the closing edge back to point 0. */
@@ -17,7 +17,7 @@ export function edgeSegments(points: Vec2[]): Array<[Vec2, Vec2]> {
}
export function edgeLength([from, to]: [Vec2, Vec2]): number {
- return Math.hypot(to[0] - from[0], to[1] - from[1])
+ return distance(from, to)
}
export function pointOnEdge([from, to]: [Vec2, Vec2], distance: number): Vec2 {
@@ -38,12 +38,11 @@ export function projectOnEdge([from, to]: [Vec2, Vec2], point: Vec2): number {
return 0
}
const ratio = ((point[0] - from[0]) * dx + (point[1] - from[1]) * dy) / lengthSq
- return Math.min(Math.max(ratio, 0), 1) * Math.sqrt(lengthSq)
+ return clamp(ratio, 0, 1) * Math.sqrt(lengthSq)
}
export function distanceToEdge(edge: [Vec2, Vec2], point: Vec2): number {
- const projected = pointOnEdge(edge, projectOnEdge(edge, point))
- return Math.hypot(point[0] - projected[0], point[1] - projected[1])
+ return distance(point, pointOnEdge(edge, projectOnEdge(edge, point)))
}
/** Start and end point of a gap in shape-local coordinates. */
@@ -86,8 +85,10 @@ function mergedIntervals(gaps: WallGap[], edgeIndex: number, length: number): In
function runToPath(run: Vec2[], closed: boolean): string {
const [first, ...rest] = run
- const line = rest.map((point) => `L${fmt(point[0])},${fmt(point[1])}`).join('')
- return `M${fmt(first[0])},${fmt(first[1])}${line}${closed ? ' z' : ''}`
+ const line = rest
+ .map((point) => `L${formatPathNumber(point[0])},${formatPathNumber(point[1])}`)
+ .join('')
+ return `M${formatPathNumber(first[0])},${formatPathNumber(first[1])}${line}${closed ? ' z' : ''}`
}
/**
@@ -141,6 +142,15 @@ export function wallRunsPath(points: Vec2[], gaps: WallGap[]): string {
return runs.map((entry) => runToPath(entry, false)).join(' ')
}
+/** Writes the gap list back to the room; an empty list removes the property. */
+export function setWallGaps(room: Room, gaps: WallGap[]): void {
+ if (gaps.length === 0) {
+ delete room.wallGaps
+ } else {
+ room.wallGaps = gaps
+ }
+}
+
/** Drops gaps on edges that no longer exist and clamps them to their edge length. */
export function clampWallGaps(points: Vec2[], gaps: WallGap[], minLength: number): WallGap[] {
const edges = edgeSegments(points)
@@ -150,7 +160,7 @@ export function clampWallGaps(points: Vec2[], gaps: WallGap[], minLength: number
}
const edge = edges[gap.edge]
const length = edgeLength(edge)
- const start = Math.min(Math.max(gap.start, 0), length)
+ const start = clamp(gap.start, 0, length)
const gapLength = Math.min(gap.length, length - start)
return gapLength >= minLength ? [{ edge: gap.edge, start, length: gapLength }] : []
})
diff --git a/src/core/render/CalloutMarker.vue b/src/core/render/CalloutMarker.vue
index 44c81e9..a8add96 100644
--- a/src/core/render/CalloutMarker.vue
+++ b/src/core/render/CalloutMarker.vue
@@ -34,7 +34,7 @@ const label = computed(() => ('label' in props.marker ? props.marker.label : nul
const iconUrl = computed(() =>
'icon' in props.marker ? elementIconUrl(props.marker.icon) : undefined,
)
-const { showIcon, onIconError } = useIconFallback(() => iconUrl.value)
+const { showIcon, onIconError } = useIconFallback(iconUrl)
/** Diamond centered on the origin, `radius` = half diagonal. */
function diamondPath(radius: number): string {
@@ -61,7 +61,7 @@ const innerDiamond = diamondPath(MARKER_BADGE_INNER_RADIUS)
elementIconUrl(props.element?.icon))
-const { showIcon, onIconError } = useIconFallback(() => iconUrl.value)
+const { showIcon, onIconError } = useIconFallback(iconUrl)
const size = computed(() => props.element?.size ?? ICON_DEFAULT_SIZE)
diff --git a/src/core/render/StructuralMarker.vue b/src/core/render/StructuralMarker.vue
index a612de1..c3b2c23 100644
--- a/src/core/render/StructuralMarker.vue
+++ b/src/core/render/StructuralMarker.vue
@@ -34,23 +34,24 @@ const props = defineProps<{
const iconUrl = computed(() => elementIconUrl(props.element.icon))
-const { showIcon, onIconError } = useIconFallback(() => iconUrl.value)
+const { showIcon, onIconError } = useIconFallback(iconUrl)
-const render = computed(() => props.element.render)
-const kind = computed(() => render.value?.kind)
+const kind = computed(() => props.element.render?.kind)
const meta = computed(() => (kind.value ? STRUCTURAL_META[kind.value] : undefined))
-const dims = computed<[number, number]>(() => {
- const fallback: [number, number] = [render.value?.length ?? 0, render.value?.thickness ?? 0]
+const dims = computed(() => {
+ const render = props.element.render
+ const fallback = { length: render?.length ?? 0, thickness: render?.thickness ?? 0 }
if (!meta.value?.resizable) {
return fallback
}
- return [props.placement.size?.[0] ?? fallback[0], props.placement.size?.[1] ?? fallback[1]]
+ return {
+ length: props.placement.size?.[0] ?? fallback.length,
+ thickness: props.placement.size?.[1] ?? fallback.thickness,
+ }
})
-const length = computed(() => dims.value[0])
-const thickness = computed(() => dims.value[1])
-const rectPath = computed(() => centeredRectPath(length.value, thickness.value))
+const rectPath = computed(() => centeredRectPath(dims.value.length, dims.value.thickness))
const ascending = computed(() => props.placement.props?.direction !== 'down')
/** Door/window/stairs variants differ only by the library color, not by code. */
@@ -67,12 +68,12 @@ const icon = computed(() => {
return undefined
}
const size = props.element.size ?? ICON_DEFAULT_SIZE
- const centerY = meta.value?.anchor === 'edge' ? -thickness.value / 2 : 0
+ const centerY = meta.value?.anchor === 'edge' ? -dims.value.thickness / 2 : 0
return { url, size, x: -size / 2, y: centerY - size / 2 }
})
const selectionBounds = computed(() => {
- const [l, t] = dims.value
+ const { length: l, thickness: t } = dims.value
const offset = SELECTION_RING_OFFSET
return {
x: -l / 2 - offset,
@@ -92,35 +93,35 @@ const selectionBounds = computed(() => {
>
-
+
-
-
+
+
-
+
-
+
-
-
+
+
-
-
+
+
-
+
diff --git a/src/core/render/structuralShapes.ts b/src/core/render/structuralShapes.ts
index 8e70697..aeb36a0 100644
--- a/src/core/render/structuralShapes.ts
+++ b/src/core/render/structuralShapes.ts
@@ -4,6 +4,8 @@ import {
BARRICADE_PLANK_GAP,
BARRICADE_PLANK_THICKNESS,
CRAWL_BAR_SPACING,
+ OBSTACLE_TOOTH_SPACING,
+ STAIRS_RUNG_SPACING,
} from '../constants'
import type { Placement, StructuralKind } from '../model/types'
@@ -13,11 +15,6 @@ import type { Placement, StructuralKind } from '../model/types'
* the orientation comes from the rotation of the placement.
*/
-/** Spacing of the step rungs on stairs, in map units. */
-const STAIRS_RUNG_SPACING = 3
-/** Spacing of the teeth on obstacles, in map units. */
-const OBSTACLE_TOOTH_SPACING = 2.5
-
/**
* Properties per structural kind: `resizable` allows `placement.size`;
* `anchor: 'edge'` anchors the shape at the anchor edge (y=0) instead of centered;
@@ -74,24 +71,21 @@ export function barricadeHatchPath(length: number, thickness: number): string {
const width = length + 2 * BARRICADE_OVERHANG
const top = plankTop(thickness)
const count = Math.max(1, Math.floor(width / BARRICADE_HATCH_SPACING))
- const segments: string[] = []
- for (let index = 0; index < count; index += 1) {
+ return Array.from({ length: count }, (_, index) => {
const x = -width / 2 + index * BARRICADE_HATCH_SPACING
const slant = Math.min(BARRICADE_PLANK_THICKNESS, width / 2 - x)
- segments.push(`M${x},${top} l${slant},${slant}`)
- }
- return segments.join(' ')
+ return `M${x},${top} l${slant},${slant}`
+ }).join(' ')
}
/** Slanted bars of a crawl passage, spanning the full thickness. */
export function crawlBarsPath(length: number, thickness: number): string {
const count = Math.max(1, Math.floor(length / CRAWL_BAR_SPACING))
const slant = length / count
- const segments: string[] = []
- for (let index = 0; index < count; index += 1) {
- segments.push(`M${-length / 2 + index * slant},${-thickness / 2} l${slant},${thickness}`)
- }
- return segments.join(' ')
+ return Array.from(
+ { length: count },
+ (_, index) => `M${-length / 2 + index * slant},${-thickness / 2} l${slant},${thickness}`,
+ ).join(' ')
}
/** Row of teeth along the upper long edge (obstacle). */
diff --git a/src/core/render/useIconFallback.ts b/src/core/render/useIconFallback.ts
index 5ae3b60..d242b87 100644
--- a/src/core/render/useIconFallback.ts
+++ b/src/core/render/useIconFallback.ts
@@ -1,12 +1,12 @@
-import { computed, ref, watch } from 'vue'
+import { computed, ref, watch, type Ref } from 'vue'
/** Icon load-failure state of a marker; resets when the URL changes. */
-export function useIconFallback(iconUrl: () => string | undefined) {
+export function useIconFallback(iconUrl: Readonly>) {
const iconFailed = ref(false)
watch(iconUrl, () => {
iconFailed.value = false
})
- const showIcon = computed(() => Boolean(iconUrl()) && !iconFailed.value)
+ const showIcon = computed(() => Boolean(iconUrl.value) && !iconFailed.value)
const onIconError = () => {
iconFailed.value = true
}
diff --git a/src/core/ui/CustomCursorOverlay.vue b/src/core/ui/CustomCursorOverlay.vue
new file mode 100644
index 0000000..981e93e
--- /dev/null
+++ b/src/core/ui/CustomCursorOverlay.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
diff --git a/src/editor/EditorCanvas.vue b/src/editor/EditorCanvas.vue
index 5076c6a..5d993af 100644
--- a/src/editor/EditorCanvas.vue
+++ b/src/editor/EditorCanvas.vue
@@ -2,8 +2,8 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useElementSize } from '../core/interaction/useElementSize'
import { usePanZoom } from '../core/interaction/usePanZoom'
+import { roomCameras } from '../core/model/roomCameras'
import { roomsBounds } from '../core/model/roomPath'
-import type { CameraInfo, Vec2 } from '../core/model/types'
import CameraMarker from '../core/render/CameraMarker.vue'
import FloorLayer from '../core/render/FloorLayer.vue'
import GridLayer from '../core/render/GridLayer.vue'
@@ -18,21 +18,16 @@ import { useRoomTool } from './tools/useRoomTool'
import { useRouteTool } from './tools/useRouteTool'
import { useSelectTool } from './tools/useSelectTool'
-const emit = defineEmits<{
- cursorMove: [pos: Vec2 | null]
- zoomChange: [k: number]
- fineGridChange: [fine: boolean]
-}>()
-
const editor = useEditorStore()
const libraryStore = useLibraryStore()
const zonesStore = useZonesStore()
const canvas = ref | null>(null)
const svgEl = computed(() => canvas.value?.svgEl ?? null)
-const { transform, isSpacePanning, isPanning, resetView, zoomBy, panBy } = usePanZoom(svgEl, {
- rightDragPan: true,
-})
+const { transform, isSpacePanning, isPanning, resetView, fitBounds, zoomBy, panBy } = usePanZoom(
+ svgEl,
+ { rightDragPan: true },
+)
const viewport = useElementSize(svgEl)
const tools = {
@@ -57,29 +52,14 @@ const {
transform,
isSpacePanning,
tools,
- fitView: () => fitToDocument(),
+ fitView: fitToDocument,
panBy,
})
-watch(cursorWorld, (pos) => emit('cursorMove', pos))
-watch(
- () => transform.value.k,
- (k) => emit('zoomChange', k),
- { immediate: true },
-)
-watch(isFineGrid, (fine) => emit('fineGridChange', fine))
-
+const zoom = computed(() => transform.value.k)
const isDrawTool = computed(() => editor.activeTool !== 'select')
-const selectedRoomCameras = computed(() => {
- const room = editor.selectedRoom
- if (!room || room.floor !== editor.activeFloor) {
- return []
- }
- return (room.info?.images ?? [])
- .map((image) => image.camera)
- .filter((camera): camera is CameraInfo => Boolean(camera))
-})
+const selectedRoomCameras = computed(() => roomCameras(editor.selectedRoom, editor.activeFloor))
onMounted(() => fitToDocument())
watch(
@@ -93,10 +73,15 @@ function fitToDocument(): void {
if (!doc) {
return
}
- resetView(roomsBounds(doc.rooms) ?? undefined)
+ const bounds = roomsBounds(doc.rooms)
+ if (bounds) {
+ fitBounds(bounds)
+ } else {
+ resetView()
+ }
}
-defineExpose({ fitToDocument, zoomBy })
+defineExpose({ fitToDocument, zoomBy, cursorWorld, zoom, isFineGrid })
diff --git a/src/editor/EditorShell.vue b/src/editor/EditorShell.vue
index 9dc3866..5d1975d 100644
--- a/src/editor/EditorShell.vue
+++ b/src/editor/EditorShell.vue
@@ -2,16 +2,11 @@
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import Toast from 'primevue/toast'
-import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
-import { VALIDATION_DEBOUNCE_MS, ZOOM_BUTTON_FACTOR } from '../core/constants'
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+import { ZOOM_BUTTON_FACTOR } from '../core/constants'
import { isUiOwnedTarget } from '../core/interaction/eventTargets'
-import type { Vec2 } from '../core/model/types'
-import {
- collectManifestIssues,
- collectTrialLogicIssues,
- type ValidationIssue,
-} from '../core/model/validation'
import ControlsLegend from '../core/ui/ControlsLegend.vue'
+import './editor-forms.css'
import EditorCanvas from './EditorCanvas.vue'
import EditorStatusBar from './EditorStatusBar.vue'
import EditorToolbar from './EditorToolbar.vue'
@@ -25,17 +20,15 @@ import ShortcutHelpDialog from './panels/ShortcutHelpDialog.vue'
import StartDialog from './panels/StartDialog.vue'
import ToolbarSeparator from './ToolbarSeparator.vue'
import ZoomControls from './ZoomControls.vue'
-import { useEditorStore } from './store/editorStore'
+import { NEEDS_DOCUMENT_HINT, useEditorStore } from './store/editorStore'
import { useLibraryStore } from './store/libraryStore'
+import { useValidationIssues } from './store/useValidationIssues'
import { useZonesStore } from './store/zonesStore'
const editor = useEditorStore()
const libraryStore = useLibraryStore()
const zonesStore = useZonesStore()
-const cursor = ref(null)
-const zoom = ref(1)
-const fineGrid = ref(false)
const canvasRef = ref | null>(null)
const showImportDialog = ref(false)
const showExportDialog = ref(false)
@@ -44,31 +37,11 @@ const showLibraryDialog = ref(false)
const showShortcutHelp = ref(false)
const needsDocumentHint = computed(() => ({
- value: 'Load or create a map first',
+ value: NEEDS_DOCUMENT_HINT,
disabled: Boolean(editor.document),
}))
-const validationIssues = ref([])
-let validationTimer: number | undefined
-
-// `revision` instead of a deep watcher: the 100+ KB document is never traversed.
-watch(
- [() => editor.document, () => editor.revision, () => libraryStore.library, () => zonesStore.zoneLibrary],
- ([doc]) => {
- window.clearTimeout(validationTimer)
- if (!doc) {
- validationIssues.value = []
- return
- }
- validationTimer = window.setTimeout(() => {
- validationIssues.value = [
- ...(editor.manifest ? collectManifestIssues(editor.manifest) : []),
- ...collectTrialLogicIssues(doc, libraryStore.library, zonesStore.zoneLibrary),
- ]
- }, VALIDATION_DEBOUNCE_MS)
- },
- { immediate: true },
-)
+const validationIssues = useValidationIssues()
function onHelpHotkey(event: KeyboardEvent): void {
if (event.key === '?' && !isUiOwnedTarget(event.target)) {
@@ -175,12 +148,7 @@ onBeforeUnmount(() => {
-
+ {
@@ -216,7 +184,7 @@ onBeforeUnmount(() => {
The current trial has unsaved changes. It stays in the browser autosave and can be
continued from the start dialog — export it first to keep a file.