From 3a4a91ebf15c3233c8fd6b8ab401f60b73d7af4f Mon Sep 17 00:00:00 2001 From: SuprexDE <46200229+SuprexDE@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:26:56 +0200 Subject: [PATCH] feat(editor): add support for shape drawing --- .../trials/cancel-the-autopsy.json | 3 +- .../trials/eliminate-the-past.json | 3 +- .../trials/kill-the-snitch.json | 3 +- .../trials/release-the-prisoners.json | 3 +- .../trials/sabotage-the-lockdown.json | 3 +- .../trials/teach-the-police-officer.json | 3 +- public/schemas/trial.schema.json | 34 ++- src/core/constants.ts | 4 + src/core/interaction/hitTest.ts | 9 +- src/core/model/roomPath.ts | 10 + src/core/model/shapes.ts | 53 ++++ src/core/model/types.ts | 25 ++ src/core/model/validation.ts | 17 +- src/core/render/FloorLayer.vue | 15 +- src/core/render/ShapeOutline.vue | 74 +++++ src/editor/EditorCanvas.vue | 4 +- src/editor/EditorToolbar.vue | 22 +- src/editor/ToolOverlayLayer.vue | 12 +- src/editor/panels/PropertiesPanel.vue | 2 + src/editor/panels/ShapeProperties.vue | 273 ++++++++++++++++++ src/editor/panels/ShortcutHelpDialog.vue | 1 + src/editor/store/documentIO.ts | 12 + src/editor/store/editorStore.ts | 21 +- src/editor/tools/clipboardPayload.ts | 28 +- src/editor/tools/toolTypes.ts | 4 +- src/editor/tools/useCanvasEvents.ts | 1 + src/editor/tools/useClipboard.ts | 11 +- src/editor/tools/useOpenPathDrawing.ts | 55 ++++ src/editor/tools/useRouteTool.ts | 45 +-- src/editor/tools/useSelectTool.ts | 26 +- src/editor/tools/useShapeTool.ts | 107 +++++++ 31 files changed, 806 insertions(+), 77 deletions(-) create mode 100644 src/core/model/shapes.ts create mode 100644 src/core/render/ShapeOutline.vue create mode 100644 src/editor/panels/ShapeProperties.vue create mode 100644 src/editor/tools/useOpenPathDrawing.ts create mode 100644 src/editor/tools/useShapeTool.ts diff --git a/public/data/maps/police-station/trials/cancel-the-autopsy.json b/public/data/maps/police-station/trials/cancel-the-autopsy.json index abc2362..a1d200e 100644 --- a/public/data/maps/police-station/trials/cancel-the-autopsy.json +++ b/public/data/maps/police-station/trials/cancel-the-autopsy.json @@ -82,5 +82,6 @@ ], "rooms": [], "placements": [], - "routes": [] + "routes": [], + "shapes": [] } diff --git a/public/data/maps/police-station/trials/eliminate-the-past.json b/public/data/maps/police-station/trials/eliminate-the-past.json index 78ada58..d165b2d 100644 --- a/public/data/maps/police-station/trials/eliminate-the-past.json +++ b/public/data/maps/police-station/trials/eliminate-the-past.json @@ -82,5 +82,6 @@ ], "rooms": [], "placements": [], - "routes": [] + "routes": [], + "shapes": [] } diff --git a/public/data/maps/police-station/trials/kill-the-snitch.json b/public/data/maps/police-station/trials/kill-the-snitch.json index b65ea66..3c105bd 100644 --- a/public/data/maps/police-station/trials/kill-the-snitch.json +++ b/public/data/maps/police-station/trials/kill-the-snitch.json @@ -82,5 +82,6 @@ ], "rooms": [], "placements": [], - "routes": [] + "routes": [], + "shapes": [] } diff --git a/public/data/maps/police-station/trials/release-the-prisoners.json b/public/data/maps/police-station/trials/release-the-prisoners.json index 483551c..21bcf11 100644 --- a/public/data/maps/police-station/trials/release-the-prisoners.json +++ b/public/data/maps/police-station/trials/release-the-prisoners.json @@ -82,5 +82,6 @@ ], "rooms": [], "placements": [], - "routes": [] + "routes": [], + "shapes": [] } diff --git a/public/data/maps/police-station/trials/sabotage-the-lockdown.json b/public/data/maps/police-station/trials/sabotage-the-lockdown.json index 600c6ee..5dda1cd 100644 --- a/public/data/maps/police-station/trials/sabotage-the-lockdown.json +++ b/public/data/maps/police-station/trials/sabotage-the-lockdown.json @@ -82,5 +82,6 @@ ], "rooms": [], "placements": [], - "routes": [] + "routes": [], + "shapes": [] } diff --git a/public/data/maps/police-station/trials/teach-the-police-officer.json b/public/data/maps/police-station/trials/teach-the-police-officer.json index 4c3bb64..674098b 100644 --- a/public/data/maps/police-station/trials/teach-the-police-officer.json +++ b/public/data/maps/police-station/trials/teach-the-police-officer.json @@ -82,5 +82,6 @@ ], "rooms": [], "placements": [], - "routes": [] + "routes": [], + "shapes": [] } diff --git a/public/schemas/trial.schema.json b/public/schemas/trial.schema.json index b8d0594..008b536 100644 --- a/public/schemas/trial.schema.json +++ b/public/schemas/trial.schema.json @@ -3,7 +3,7 @@ "$id": "https://maps.outlasttrialsstats.com/schemas/trial.schema.json", "title": "Trial-Definition", "type": "object", - "required": ["mapId", "trialId", "floors", "filters", "rooms", "placements", "routes"], + "required": ["mapId", "trialId", "floors", "filters", "rooms", "placements", "routes", "shapes"], "additionalProperties": false, "properties": { "$schema": { "type": "string" }, @@ -51,6 +51,10 @@ "routes": { "type": "array", "items": { "$ref": "#/definitions/route" } + }, + "shapes": { + "type": "array", + "items": { "$ref": "#/definitions/shape" } } }, "definitions": { @@ -205,6 +209,34 @@ "props": { "type": "object" } } }, + "shape": { + "description": "Free-standing decorative outline (circle, rectangle or open polyline); color/strokeWidth/dashed override the render defaults.", + "type": "object", + "required": ["id", "floor"], + "additionalProperties": false, + "oneOf": [ + { "required": ["pos", "radius"] }, + { "required": ["pos", "size"] }, + { "required": ["path"] } + ], + "properties": { + "id": { "$ref": "#/definitions/kebabId" }, + "floor": { "type": "integer" }, + "color": { "$ref": "#/definitions/color" }, + "strokeWidth": { "type": "number", "exclusiveMinimum": 0 }, + "dashed": { "type": "boolean" }, + "pos": { "$ref": "#/definitions/vec2" }, + "radius": { "type": "number", "exclusiveMinimum": 0 }, + "size": { + "type": "array", + "items": { "type": "number", "exclusiveMinimum": 0 }, + "minItems": 2, + "maxItems": 2 + }, + "rotation": { "type": "number", "multipleOf": 45 }, + "path": { "$ref": "#/definitions/svgPath" } + } + }, "route": { "type": "object", "required": ["id", "name", "floor", "path", "style"], diff --git a/src/core/constants.ts b/src/core/constants.ts index 5632261..fdc0c16 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -138,6 +138,10 @@ export const DISABLED_ROOM_OPACITY = 0.45 export const FALLBACK_ZONE_FILL = '#4a4a4a' export const FALLBACK_ZONE_WALLS = '#111111' export const DEFAULT_LABEL_FONT_SIZE = 8 +/** Default outline of free-standing shapes; per-shape overrides are optional. */ +export const SHAPE_DEFAULT_COLOR = '#85858c' +export const SHAPE_DEFAULT_STROKE_WIDTH = 1 +export const SHAPE_LINE_DASH = '3 2' export const UNKNOWN_ELEMENT_COLOR = '#7f8c8d' /** Font size of the placeholder initials relative to the icon size. */ export const PLACEHOLDER_FONT_RATIO = 0.45 diff --git a/src/core/interaction/hitTest.ts b/src/core/interaction/hitTest.ts index 84d4708..f6ebbcf 100644 --- a/src/core/interaction/hitTest.ts +++ b/src/core/interaction/hitTest.ts @@ -1,12 +1,12 @@ -export type EntityKind = 'room' | 'placement' | 'route' +const ENTITY_KINDS = ['room', 'placement', 'route', 'shape'] as const + +export type EntityKind = (typeof ENTITY_KINDS)[number] export interface HitTarget { kind: EntityKind id: string } -const ENTITY_KINDS: readonly string[] = ['room', 'placement', 'route'] - /** * Determines the hit map object via event delegation: the render components * mark their root group with `data-entity-kind` and `data-entity-id` and stay @@ -22,7 +22,8 @@ export function hitFromEventTarget(target: EventTarget | null): HitTarget | null } const kind = entityEl.getAttribute('data-entity-kind') const id = entityEl.getAttribute('data-entity-id') - if (!kind || !id || !ENTITY_KINDS.includes(kind)) { + const kinds: readonly string[] = ENTITY_KINDS + if (!kind || !id || !kinds.includes(kind)) { return null } return { kind: kind as EntityKind, id } diff --git a/src/core/model/roomPath.ts b/src/core/model/roomPath.ts index c07cb66..2ffbb53 100644 --- a/src/core/model/roomPath.ts +++ b/src/core/model/roomPath.ts @@ -132,6 +132,16 @@ export function parseOpenPath(path: string): Vec2[] | null { return parsed.points } +/** Points of an open path (routes, shapes); unparsable paths fall back to their start point. */ +export function openPathPoints(path: string): Vec2[] { + const points = parseOpenPath(path) + if (points) { + return points + } + const start = absolutePathStart(path) + return start ? [start] : [] +} + function relativeSegments(points: Vec2[]): string[] { const parts: string[] = [] for (let i = 1; i < points.length; i += 1) { diff --git a/src/core/model/shapes.ts b/src/core/model/shapes.ts new file mode 100644 index 0000000..b981641 --- /dev/null +++ b/src/core/model/shapes.ts @@ -0,0 +1,53 @@ +import { openPathPoints, translateAbsolutePathStart } from './roomPath' +import type { MapShape, Vec2 } from './types' + +export function isCircleShape(shape: MapShape): shape is Extract { + return 'radius' in shape +} + +export function isRectShape(shape: MapShape): shape is Extract { + return 'size' in shape +} + +export function isLineShape(shape: MapShape): shape is Extract { + return 'path' in shape +} + +/** + * Characteristic points in world coordinates: circle extremes, rotated rect + * corners, polyline vertices. + */ +export function shapeWorldPoints(shape: MapShape): Vec2[] { + if (isCircleShape(shape)) { + const [x, y] = shape.pos + const r = shape.radius + return [ + [x - r, y], + [x + r, y], + [x, y - r], + [x, y + r], + ] + } + if (isRectShape(shape)) { + const [cx, cy] = shape.pos + const [hw, hh] = [shape.size[0] / 2, shape.size[1] / 2] + const angle = ((shape.rotation ?? 0) * Math.PI) / 180 + const [cos, sin] = [Math.cos(angle), Math.sin(angle)] + const corners: Vec2[] = [ + [-hw, -hh], + [hw, -hh], + [hw, hh], + [-hw, hh], + ] + return corners.map(([x, y]): Vec2 => [cx + x * cos - y * sin, cy + x * sin + y * cos]) + } + return openPathPoints(shape.path) +} + +export function translateShape(shape: MapShape, delta: Vec2): void { + if (isLineShape(shape)) { + shape.path = translateAbsolutePathStart(shape.path, delta) ?? shape.path + return + } + shape.pos = [shape.pos[0] + delta[0], shape.pos[1] + delta[1]] +} diff --git a/src/core/model/types.ts b/src/core/model/types.ts index ae4076e..2a347a2 100644 --- a/src/core/model/types.ts +++ b/src/core/model/types.ts @@ -77,6 +77,7 @@ export interface TrialDocument { rooms: Room[] placements: Placement[] routes: RouteLine[] + shapes: MapShape[] } export interface MapMeta { @@ -221,6 +222,30 @@ export interface RouteLine { style: RouteLineStyle } +// --------------------------------------------------------------------------- +// Shapes +// --------------------------------------------------------------------------- + +/** + * Free-standing decorative outline (tables, scaffolding …) — neither a room + * nor a library element, never filled. Variants: circle (`pos` = center), + * rectangle (`pos` = center, rotation in 45° steps in sync with the schema + * `multipleOf`) or an open absolute path. + */ +export type MapShapeGeometry = + | { pos: Vec2; radius: number } + | { pos: Vec2; size: Vec2; rotation?: number } + | { path: string } + +export type MapShape = { + id: string + floor: number + /** Stroke overrides — `SHAPE_DEFAULT_*` constants apply and are not stored. */ + color?: string + strokeWidth?: number + dashed?: boolean +} & MapShapeGeometry + // --------------------------------------------------------------------------- // Zone library — public/data/zones.json (global across all maps) // --------------------------------------------------------------------------- diff --git a/src/core/model/validation.ts b/src/core/model/validation.ts index c06a67a..9c41d33 100644 --- a/src/core/model/validation.ts +++ b/src/core/model/validation.ts @@ -268,6 +268,7 @@ export function collectTrialLogicIssues( ['rooms', 'room', trial.rooms.map((room) => room.id)], ['placements', 'placement', trial.placements.map((placement) => placement.id)], ['routes', 'route', trial.routes.map((route) => route.id)], + ['shapes', 'shape', trial.shapes.map((shape) => shape.id)], ] for (const [path, label, ids] of uniqueIdChecks) { checkUniqueIds(issues, path, label, ids) @@ -277,11 +278,17 @@ export function collectTrialLogicIssues( trial.placements.forEach((placement, index) => issues.push(...collectPlacementIssues(placement, index, context)), ) - trial.routes.forEach((route, index) => { - if (!context.floorIndexes.has(route.floor)) { - issues.push({ path: `routes[${index}].floor`, message: `unknown floor ${route.floor}` }) - } - }) + const floorChecks: Array<[string, Array<{ floor: number }>]> = [ + ['routes', trial.routes], + ['shapes', trial.shapes], + ] + for (const [path, items] of floorChecks) { + items.forEach((item, index) => { + if (!context.floorIndexes.has(item.floor)) { + issues.push({ path: `${path}[${index}].floor`, message: `unknown floor ${item.floor}` }) + } + }) + } trial.filters.forEach((filter, filterIndex) => { filter.categories.forEach((category) => { if (!context.categoryIds.has(category)) { diff --git a/src/core/render/FloorLayer.vue b/src/core/render/FloorLayer.vue index 949edad..7636e9a 100644 --- a/src/core/render/FloorLayer.vue +++ b/src/core/render/FloorLayer.vue @@ -6,6 +6,7 @@ import CalloutMarker from './CalloutMarker.vue' import PlacementMarker from './PlacementMarker.vue' import RoomShape from './RoomShape.vue' import RoutePath from './RoutePath.vue' +import ShapeOutline from './ShapeOutline.vue' const props = defineProps<{ trial: TrialDocument @@ -14,8 +15,8 @@ const props = defineProps<{ zones: ReadonlyMap selectedIds?: ReadonlySet hiddenCategories?: ReadonlySet - /** Editor: routes get an invisible wide hit stroke for clicking/double-clicking. */ - interactiveRoutes?: boolean + /** Editor: routes and shapes get an invisible wide hit stroke along their outline. */ + interactive?: boolean }>() function visible(items: T[]): T[] { @@ -30,6 +31,7 @@ const placements = computed(() => }), ) const routes = computed(() => visible(props.trial.routes)) +const shapes = computed(() => visible(props.trial.shapes)) /** Drawn after all placements so callouts never disappear behind a neighbour. */ const markedPlacements = computed(() => placements.value.filter((placement) => placement.marker)) @@ -43,12 +45,19 @@ const markedPlacements = computed(() => placements.value.filter((placement) => p :zone="zones.get(room.zone)" :selected="selectedIds?.has(room.id)" /> + +import { computed } from 'vue' +import { + SELECTION_COLOR, + SHAPE_DEFAULT_COLOR, + SHAPE_DEFAULT_STROKE_WIDTH, + SHAPE_LINE_DASH, +} from '../constants' +import { isCircleShape, isRectShape } from '../model/shapes' +import type { MapShape } from '../model/types' + +const props = defineProps<{ + shape: MapShape + selected?: boolean + /** Adds an invisible wide stroke so the thin outline is clickable (editor). */ + hitArea?: boolean +}>() + +const geometry = computed(() => { + const shape = props.shape + if (isCircleShape(shape)) { + return { is: 'circle', attrs: { cx: shape.pos[0], cy: shape.pos[1], r: shape.radius } } + } + if (isRectShape(shape)) { + const [cx, cy] = shape.pos + const [width, height] = shape.size + return { + is: 'rect', + attrs: { + x: cx - width / 2, + y: cy - height / 2, + width, + height, + transform: shape.rotation ? `rotate(${shape.rotation} ${cx} ${cy})` : undefined, + }, + } + } + return { is: 'path', attrs: { d: shape.path } } +}) + +const strokeAttrs = computed(() => ({ + stroke: props.selected ? SELECTION_COLOR : (props.shape.color ?? SHAPE_DEFAULT_COLOR), + 'stroke-width': props.shape.strokeWidth ?? SHAPE_DEFAULT_STROKE_WIDTH, + 'stroke-dasharray': props.shape.dashed ? SHAPE_LINE_DASH : undefined, +})) + + + + + diff --git a/src/editor/EditorCanvas.vue b/src/editor/EditorCanvas.vue index 5d993af..4deb62c 100644 --- a/src/editor/EditorCanvas.vue +++ b/src/editor/EditorCanvas.vue @@ -17,6 +17,7 @@ import { usePlacementTool } from './tools/usePlacementTool' import { useRoomTool } from './tools/useRoomTool' import { useRouteTool } from './tools/useRouteTool' import { useSelectTool } from './tools/useSelectTool' +import { useShapeTool } from './tools/useShapeTool' const editor = useEditorStore() const libraryStore = useLibraryStore() @@ -35,6 +36,7 @@ const tools = { room: useRoomTool(), placement: usePlacementTool(), route: useRouteTool(), + shape: useShapeTool(), } const { @@ -108,7 +110,7 @@ defineExpose({ fitToDocument, zoomBy, cursorWorld, zoom, isFineGrid }) :element-index="libraryStore.elementIndex" :zones="zonesStore.zonesById" :selected-ids="editor.selectedIds" - interactive-routes + interactive /> { id: 'room', label: 'Room', icon: 'pi pi-home', hotkey: '2' }, { id: 'placement', label: 'Element', icon: 'pi pi-map-marker', hotkey: '3' }, { id: 'route', label: 'Route', icon: 'pi pi-directions', hotkey: '4' }, + { id: 'shape', label: 'Shape', icon: 'pi pi-pencil', hotkey: '5' }, ] const roomModes: Array<{ id: RoomToolMode; label: string; icon: string; hint: string }> = [ @@ -35,6 +36,12 @@ const roomModes: Array<{ id: RoomToolMode; label: string; icon: string; hint: st hint: 'Wall gaps — cut openings into the outer wall', }, ] + +const shapeModes: Array<{ id: ShapeToolMode; label: string; icon: string; hint: string }> = [ + { id: 'circle', label: 'Circle', icon: 'pi pi-circle', hint: 'Circle — center, then radius' }, + { id: 'rect', label: 'Rectangle', icon: 'pi pi-stop', hint: 'Rectangle — two clicks' }, + { id: 'line', label: 'Line', icon: 'pi pi-minus', hint: 'Line — Enter/double-click ends' }, +]