diff --git a/src/core/constants.ts b/src/core/constants.ts index 5632261..72033e1 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -121,6 +121,9 @@ export const MIN_POLYGON_POINTS = 3 export const WALL_GAP_DEFAULT_LENGTH = 8 export const WALL_GAP_MIN_LENGTH = 1 +/** Max. distance a wall gap may sit off a merged outline before it is dropped, in map units. */ +export const ROOM_MERGE_GAP_TOLERANCE = 0.05 + /** Touch: a long press opens the viewer context menu. */ export const LONG_PRESS_MS = 500 export const LONG_PRESS_MOVE_TOLERANCE_PX = 10 diff --git a/src/core/model/polygonUnion.ts b/src/core/model/polygonUnion.ts new file mode 100644 index 0000000..7956d10 --- /dev/null +++ b/src/core/model/polygonUnion.ts @@ -0,0 +1,268 @@ +import { MIN_POLYGON_POINTS } from '../constants' +import type { Vec2 } from './types' +import { midpoint } from './vec2' +import { edgeLength, edgeSegments } from './wallGaps' + +/** + * Union of two simple polygons, restricted to what a room outline can express: + * one closed ring without holes. Both outlines are split at every crossing and + * each resulting sub-edge is kept or dropped by probing its two sides — that + * way collinear overlaps (two rooms sharing a wall) need no traversal rules of + * their own. + */ + +export type PolygonUnionFailure = 'disjoint' | 'corner-touch' | 'hole' | 'degenerate' + +export type PolygonUnionResult = + | { ok: true; points: Vec2[] } + | { ok: false; reason: PolygonUnionFailure } + +type Edge = [Vec2, Vec2] + +/** + * Vertices snap to the 3 decimals the path writer emits. Every tolerance below + * follows from that grid: side probes stay well inside half a quantum, so they + * can never reach another edge, and genuinely collinear input yields an exact + * zero rather than something needing a threshold. + */ +const QUANTUM_SCALE = 1000 +const PROBE_DISTANCE = 2e-4 +const PARALLEL_SINE = 1e-6 +const COLLINEAR_DISTANCE = 5e-4 +const COLLINEAR_CROSS = 1e-6 +const PARAM_EPSILON = 1e-9 + +const quantize = (value: number): number => Math.round(value * QUANTUM_SCALE) / QUANTUM_SCALE +const quantized = ([x, y]: Vec2): Vec2 => [quantize(x), quantize(y)] +const pointKey = ([x, y]: Vec2): string => `${x},${y}` +const samePoint = (a: Vec2, b: Vec2): boolean => a[0] === b[0] && a[1] === b[1] +const subtract = (a: Vec2, b: Vec2): Vec2 => [a[0] - b[0], a[1] - b[1]] +const cross = (a: Vec2, b: Vec2): number => a[0] * b[1] - a[1] * b[0] +const dot = (a: Vec2, b: Vec2): number => a[0] * b[0] + a[1] * b[1] +const pointAt = ([from, to]: Edge, ratio: number): Vec2 => [ + from[0] + (to[0] - from[0]) * ratio, + from[1] + (to[1] - from[1]) * ratio, +] +const onSegment = (ratio: number): boolean => ratio >= -PARAM_EPSILON && ratio <= 1 + PARAM_EPSILON + +function toRing(points: Vec2[]): Vec2[] | null { + const ring: Vec2[] = [] + for (const point of points) { + const next = quantized(point) + if (ring.length === 0 || !samePoint(ring[ring.length - 1], next)) { + ring.push(next) + } + } + if (ring.length > 1 && samePoint(ring[0], ring[ring.length - 1])) { + ring.pop() + } + return ring.length >= MIN_POLYGON_POINTS ? ring : null +} + +/** Even-odd ray casting; only ever called with probes that are off the boundary. */ +function containsPoint(ring: Vec2[], [x, y]: Vec2): boolean { + let inside = false + for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index, index += 1) { + const [xi, yi] = ring[index] + const [xj, yj] = ring[previous] + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) { + inside = !inside + } + } + return inside +} + +/** Where `point` projects onto the edge: 0 at its start, 1 at its end. */ +function ratioOnEdge(edge: Edge, point: Vec2): number { + const direction = subtract(edge[1], edge[0]) + return dot(subtract(point, edge[0]), direction) / dot(direction, direction) +} + +function overlapSplits(edgeA: Edge, edgeB: Edge): { a: number[]; b: number[] } | null { + const first = ratioOnEdge(edgeA, edgeB[0]) + const second = ratioOnEdge(edgeA, edgeB[1]) + const from = Math.max(0, Math.min(first, second)) + const to = Math.min(1, Math.max(first, second)) + if (to < from) { + return null + } + return { + a: [from, to], + b: [ratioOnEdge(edgeB, pointAt(edgeA, from)), ratioOnEdge(edgeB, pointAt(edgeA, to))], + } +} + +function edgeSplits(edgeA: Edge, edgeB: Edge): { a: number[]; b: number[] } | null { + const lengthA = edgeLength(edgeA) + const lengthB = edgeLength(edgeB) + if (lengthA === 0 || lengthB === 0) { + return null + } + const directionA = subtract(edgeA[1], edgeA[0]) + const directionB = subtract(edgeB[1], edgeB[0]) + const delta = subtract(edgeB[0], edgeA[0]) + const denominator = cross(directionA, directionB) + if (Math.abs(denominator) > PARALLEL_SINE * lengthA * lengthB) { + const a = cross(delta, directionB) / denominator + const b = cross(delta, directionA) / denominator + return onSegment(a) && onSegment(b) ? { a: [a], b: [b] } : null + } + return Math.abs(cross(delta, directionA)) <= COLLINEAR_DISTANCE * lengthA + ? overlapSplits(edgeA, edgeB) + : null +} + +function splitEdge(edge: Edge, ratios: number[]): Edge[] { + const inner = ratios.filter((ratio) => ratio > 0 && ratio < 1).sort((a, b) => a - b) + const points = [edge[0], ...inner.map((ratio) => quantized(pointAt(edge, ratio))), edge[1]] + const parts: Edge[] = [] + for (let index = 1; index < points.length; index += 1) { + if (!samePoint(points[index - 1], points[index])) { + parts.push([points[index - 1], points[index]]) + } + } + return parts +} + +/** + * A sub-edge survives when exactly one of its sides lies inside — the wall two + * rooms share has both sides inside and drops out. Orienting every survivor + * with the interior on its left also collapses the duplicate that the second + * room contributes for a shared stretch. + */ +function boundaryEdges(a: Vec2[], b: Vec2[], candidates: Edge[]): Edge[] { + const inside = (point: Vec2): boolean => containsPoint(a, point) || containsPoint(b, point) + const kept = new Map() + for (const [from, to] of candidates) { + const size = edgeLength([from, to]) + if (size === 0) { + continue + } + const direction = subtract(to, from) + const offset: Vec2 = [ + (-direction[1] / size) * PROBE_DISTANCE, + (direction[0] / size) * PROBE_DISTANCE, + ] + const middle = midpoint(from, to) + const left = inside([middle[0] + offset[0], middle[1] + offset[1]]) + const right = inside([middle[0] - offset[0], middle[1] - offset[1]]) + if (left === right) { + continue + } + const edge: Edge = left ? [from, to] : [to, from] + kept.set(`${pointKey(edge[0])}|${pointKey(edge[1])}`, edge) + } + return [...kept.values()] +} + +/** A point with two ways out pinches, and edges left over after one loop are a hole. */ +function stitch(boundary: Edge[]): PolygonUnionResult { + if (boundary.length < MIN_POLYGON_POINTS) { + return { ok: false, reason: 'degenerate' } + } + const outgoing = new Map() + const incoming = new Set() + for (const edge of boundary) { + const from = pointKey(edge[0]) + const to = pointKey(edge[1]) + if (outgoing.has(from) || incoming.has(to)) { + return { ok: false, reason: 'corner-touch' } + } + outgoing.set(from, edge) + incoming.add(to) + } + const points: Vec2[] = [] + let edge = boundary[0] + for (let step = 0; step < boundary.length; step += 1) { + points.push(edge[0]) + const next = outgoing.get(pointKey(edge[1])) + if (!next) { + return { ok: false, reason: 'degenerate' } + } + if (next === boundary[0]) { + return points.length === boundary.length + ? { ok: true, points } + : { ok: false, reason: 'hole' } + } + edge = next + } + return { ok: false, reason: 'degenerate' } +} + +/** Drops vertices in the middle of a straight run, including across the closing edge. */ +function withoutCollinear(points: Vec2[]): Vec2[] { + const ring = [...points] + let index = 0 + while (ring.length > MIN_POLYGON_POINTS && index < ring.length) { + const previous = ring[(index + ring.length - 1) % ring.length] + const current = ring[index] + const next = ring[(index + 1) % ring.length] + if (Math.abs(cross(subtract(current, previous), subtract(next, current))) <= COLLINEAR_CROSS) { + ring.splice(index, 1) + index = Math.max(0, index - 1) + } else { + index += 1 + } + } + return ring +} + +function signedArea(ring: Vec2[]): number { + let sum = 0 + for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index, index += 1) { + sum += (ring[previous][0] - ring[index][0]) * (ring[previous][1] + ring[index][1]) + } + return sum / 2 +} + +/** Both outlines cut into sub-edges at every point where they meet the other one. */ +function splitAtCrossings(a: Vec2[], b: Vec2[]): { candidates: Edge[]; touching: boolean } { + const edgesA = edgeSegments(a) + const edgesB = edgeSegments(b) + const splitsA: number[][] = edgesA.map(() => []) + const splitsB: number[][] = edgesB.map(() => []) + let touching = false + edgesA.forEach((edgeA, indexA) => { + edgesB.forEach((edgeB, indexB) => { + const splits = edgeSplits(edgeA, edgeB) + if (splits) { + touching = true + splitsA[indexA].push(...splits.a) + splitsB[indexB].push(...splits.b) + } + }) + }) + return { + touching, + candidates: [ + ...edgesA.flatMap((edge, index) => splitEdge(edge, splitsA[index])), + ...edgesB.flatMap((edge, index) => splitEdge(edge, splitsB[index])), + ], + } +} + +export function unionSimplePolygons(first: Vec2[], second: Vec2[]): PolygonUnionResult { + const a = toRing(first) + const b = toRing(second) + if (!a || !b) { + return { ok: false, reason: 'degenerate' } + } + const { candidates, touching } = splitAtCrossings(a, b) + if (!touching && !containsPoint(a, b[0]) && !containsPoint(b, a[0])) { + return { ok: false, reason: 'disjoint' } + } + const stitched = stitch(boundaryEdges(a, b, candidates)) + if (!stitched.ok) { + return stitched + } + const points = withoutCollinear(stitched.points) + if (points.length < MIN_POLYGON_POINTS) { + return { ok: false, reason: 'degenerate' } + } + // Keeping the winding of the first polygon keeps its wall gaps on their side. + return { + ok: true, + points: + signedArea(points) * signedArea(a) < 0 ? [points[0], ...points.slice(1).reverse()] : points, + } +} diff --git a/src/core/model/roomMerge.ts b/src/core/model/roomMerge.ts new file mode 100644 index 0000000..2dc9d6c --- /dev/null +++ b/src/core/model/roomMerge.ts @@ -0,0 +1,139 @@ +import { ROOM_MERGE_GAP_TOLERANCE, WALL_GAP_MIN_LENGTH } from '../constants' +import { unionSimplePolygons, type PolygonUnionFailure } from './polygonUnion' +import { + parseOpenPath, + pointsToOpenPath, + pointsToRelativePath, + roomWorldPoints, + shapeToPoints, +} from './roomPath' +import type { RoomGeometry } from './roomScale' +import type { InnerLine, Room, Vec2, WallGap } from './types' +import { clampWallGaps, distanceToEdge, edgeSegments, gapEndpoints, projectOnEdge } from './wallGaps' + +export type RoomMergeFailure = 'different-floors' | 'unsupported-path' | PolygonUnionFailure + +export type RoomMergeResult = + | { ok: true; geometry: RoomGeometry } + | { ok: false; reason: RoomMergeFailure } + +interface ParsedRoom { + room: Room + points: Vec2[] + innerPoints: Vec2[][] +} + +function parseRoom(room: Room): ParsedRoom | null { + const points = shapeToPoints(room.shape) + if (!points) { + return null + } + const innerPoints: Vec2[][] = [] + for (const line of room.innerLines ?? []) { + const parsed = parseOpenPath(line.path) + if (!parsed) { + return null + } + innerPoints.push(parsed) + } + return { room, points, innerPoints } +} + +/** Moves shape-local points of `source` onto the merged origin. */ +function rebaser(source: ParsedRoom, origin: Vec2): (point: Vec2) => Vec2 { + const dx = source.room.shape.origin[0] - origin[0] + const dy = source.room.shape.origin[1] - origin[1] + return ([x, y]) => [x + dx, y + dy] +} + +/** + * Gaps keep their world position: both endpoints are re-attached to the merged + * edge they still sit on. Those on the wall the two rooms shared find no edge + * within the tolerance and drop out — that opening is interior now. + */ +function reprojectGaps( + source: ParsedRoom, + target: Vec2[], + rebase: (point: Vec2) => Vec2, +): WallGap[] { + const edges = edgeSegments(target) + return (source.room.wallGaps ?? []).flatMap((gap) => { + const endpoints = gapEndpoints(source.points, gap) + if (!endpoints) { + return [] + } + const [start, end] = endpoints.map(rebase) + let nearest = -1 + let nearestDistance = Infinity + edges.forEach((edge, index) => { + const distance = Math.max(distanceToEdge(edge, start), distanceToEdge(edge, end)) + if (distance < nearestDistance) { + nearestDistance = distance + nearest = index + } + }) + if (nearestDistance > ROOM_MERGE_GAP_TOLERANCE) { + return [] + } + const from = projectOnEdge(edges[nearest], start) + const to = projectOnEdge(edges[nearest], end) + return [{ edge: nearest, start: Math.min(from, to), length: Math.abs(to - from) }] + }) +} + +function rebaseInnerLines(source: ParsedRoom, rebase: (point: Vec2) => Vec2): InnerLine[] { + return (source.room.innerLines ?? []).map((line, index) => ({ + ...line, + path: pointsToOpenPath(source.innerPoints[index].map(rebase)), + })) +} + +/** + * Outline, wall gaps, inner lines and label of `survivor` ⊕ `absorbed`. The + * survivor's identity — id, zone, flags, info — is not touched here. + */ +export function mergeRoomGeometry(survivor: Room, absorbed: Room): RoomMergeResult { + if (survivor.floor !== absorbed.floor) { + return { ok: false, reason: 'different-floors' } + } + const survivorParts = parseRoom(survivor) + const absorbedParts = parseRoom(absorbed) + if (!survivorParts || !absorbedParts) { + return { ok: false, reason: 'unsupported-path' } + } + const union = unionSimplePolygons(roomWorldPoints(survivor), roomWorldPoints(absorbed)) + if (!union.ok) { + return { ok: false, reason: union.reason } + } + + const origin = union.points[0] + const points = union.points.map(([x, y]): Vec2 => [x - origin[0], y - origin[1]]) + const rebaseSurvivor = rebaser(survivorParts, origin) + const rebaseAbsorbed = rebaser(absorbedParts, origin) + const geometry: RoomGeometry = { shape: { origin, path: pointsToRelativePath(points) } } + + const gaps = clampWallGaps( + points, + [ + ...reprojectGaps(survivorParts, points, rebaseSurvivor), + ...reprojectGaps(absorbedParts, points, rebaseAbsorbed), + ], + WALL_GAP_MIN_LENGTH, + ) + if (gaps.length > 0) { + geometry.wallGaps = gaps + } + const innerLines = [ + ...rebaseInnerLines(survivorParts, rebaseSurvivor), + ...rebaseInnerLines(absorbedParts, rebaseAbsorbed), + ] + if (innerLines.length > 0) { + geometry.innerLines = innerLines + } + const label = survivor.label ?? absorbed.label + if (label) { + const rebase = survivor.label ? rebaseSurvivor : rebaseAbsorbed + geometry.label = { ...label, pos: rebase(label.pos) } + } + return { ok: true, geometry } +} diff --git a/src/core/model/roomScale.ts b/src/core/model/roomScale.ts index 8b362b9..430857f 100644 --- a/src/core/model/roomScale.ts +++ b/src/core/model/roomScale.ts @@ -1,7 +1,7 @@ import { WALL_GAP_MIN_LENGTH } from '../constants' import { parseOpenPath, pointsToOpenPath, pointsToRelativePath, shapeToPoints } from './roomPath' import type { InnerLine, Room, RoomLabel, RoomShape, Vec2, WallGap } from './types' -import { clampWallGaps, edgeLength, edgeSegments } from './wallGaps' +import { clampWallGaps, edgeLength, edgeSegments, setWallGaps } from './wallGaps' /** Everything of a room that has to scale together when it is resized. */ export interface RoomGeometry { @@ -11,6 +11,18 @@ export interface RoomGeometry { label?: RoomLabel } +/** Writes a recomputed geometry back to the room; an empty gap list drops the property. */ +export function applyRoomGeometry(room: Room, geometry: RoomGeometry): void { + room.shape = geometry.shape + setWallGaps(room, geometry.wallGaps ?? []) + if (geometry.innerLines) { + room.innerLines = geometry.innerLines + } + if (geometry.label) { + room.label = geometry.label + } +} + /** Resizing needs every path in parseable form — same restriction as vertex editing. */ export function isRoomScalable(room: Room): boolean { if (!shapeToPoints(room.shape)) { diff --git a/src/editor/panels/PropertiesPanel.vue b/src/editor/panels/PropertiesPanel.vue index b2cb811..6f21e55 100644 --- a/src/editor/panels/PropertiesPanel.vue +++ b/src/editor/panels/PropertiesPanel.vue @@ -2,6 +2,7 @@ import Button from 'primevue/button' import { computed } from 'vue' import { useClipboard } from '../tools/useClipboard' +import { useMergeRooms } from '../tools/useMergeRooms' import { useEditorStore } from '../store/editorStore' import PlacementProperties from './PlacementProperties.vue' import RoomProperties from './RoomProperties.vue' @@ -10,6 +11,7 @@ import RouteProperties from './RouteProperties.vue' const store = useEditorStore() const { canPaste, copyToSystemClipboard, cutToSystemClipboard, pasteFromSystemClipboard } = useClipboard() +const { canMerge, mergeSelection } = useMergeRooms() const hasSelection = computed(() => store.selection.length > 0) @@ -60,6 +62,22 @@ function paste(): void { :disabled="!canPaste" @click="paste()" /> +