{"version":3,"file":"floorPlan.rooms.mjs","names":[],"sources":["../../../../package/common/floorPlan/floorPlan.rooms.ts"],"sourcesContent":["/**\n * Room identity: attaching stored metadata (name, colour) to derived faces.\n *\n * Identity is primarily the SET OF BOUNDARY WALL IDS, with the anchor point as a\n * secondary signal. That ordering is the important design decision here. Anchors alone\n * look sufficient but fail in three ways: a whole-plan drag leaves every absolute\n * anchor behind, a merge gives two anchors one face with no tie-break, and dragging a\n * node can move a boundary past its own anchor. Wall ids survive every operation that\n * only moves geometry, so reshaping and translating match perfectly without anyone\n * having to remember to transform anchors.\n */\n\nimport { deriveFaces } from './floorPlan.faces';\nimport { pointInRings, representativePoint } from './floorPlan.geometry';\nimport { freshName, newId } from './floorPlan.graph';\nimport { PlanIssueKind } from './floorPlan.types';\nimport type { DerivedRoom, PlanDerivation, PlanFace, PlanFloor, PlanIssue, PlanRoom } from './floorPlan.types';\n\n/** Relative weights of the three identity signals. */\nexport const MATCH_WEIGHTS = {\n  /** Wall ids survive every geometry-only edit, so they carry the most weight. */\n  boundary: 2,\n  /** Decides a split, where both children share half the old boundary. */\n  anchor: 1,\n  /** Breaks a merge towards the larger of the two rooms. */\n  area: 0.25,\n} as const;\n\n/** Below this, a pair is not the same room at all. */\nexport const MATCH_THRESHOLD = 0.15;\n\nfunction jaccard(a: ReadonlySet<string>, b: ReadonlySet<string>): number {\n  if (a.size === 0 && b.size === 0) return 0;\n  let intersection = 0;\n  for (const id of a) if (b.has(id)) intersection += 1;\n  const union = a.size + b.size - intersection;\n  return union === 0 ? 0 : intersection / union;\n}\n\nfunction scorePair(room: PlanRoom, face: PlanFace, faceWalls: ReadonlySet<string>): number {\n  const boundary = jaccard(new Set(room.wallIds), faceWalls);\n  const inside = pointInRings(room.anchor, face.outer, face.holes) ? 1 : 0;\n  const areaRatio =\n    room.area > 0 && face.area > 0 ? Math.min(room.area, face.area) / Math.max(room.area, face.area) : 0;\n  return MATCH_WEIGHTS.boundary * boundary + MATCH_WEIGHTS.anchor * inside + MATCH_WEIGHTS.area * areaRatio;\n}\n\n/**\n * Extracts faces and attaches stored room metadata to them.\n *\n * Pure: returns a new floor whose room match caches and anchors have been refreshed.\n * It never creates or deletes a `PlanRoom`, so it is safe to call on every render.\n * Minting rooms for newly enclosed faces is `commitDerivation`'s job, called once per\n * committed edit.\n */\nexport function derivePlan(floor: PlanFloor): PlanDerivation {\n  const faces = deriveFaces(floor);\n  const issues: PlanIssue[] = [];\n  const rooms = Object.values(floor.rooms);\n\n  const faceWallSets = faces.map((f) => new Set(f.wallIds));\n\n  interface Pair {\n    roomIndex: number;\n    faceIndex: number;\n    score: number;\n  }\n\n  const pairs: Pair[] = [];\n  for (let r = 0; r < rooms.length; r++) {\n    for (let f = 0; f < faces.length; f++) {\n      const score = scorePair(rooms[r], faces[f], faceWallSets[f]);\n      if (score > MATCH_THRESHOLD) pairs.push({ roomIndex: r, faceIndex: f, score });\n    }\n  }\n\n  // Greedy one-to-one. The seq/id tie-breaks keep the result deterministic, which\n  // matters because a merge is decided here: two rooms both score around 0.5 against\n  // the merged face, and exactly one of them must win.\n  pairs.sort((x, y) => {\n    if (y.score !== x.score) return y.score - x.score;\n    const seqDiff = rooms[x.roomIndex].seq - rooms[y.roomIndex].seq;\n    if (seqDiff !== 0) return seqDiff;\n    return faces[x.faceIndex].id < faces[y.faceIndex].id ? -1 : 1;\n  });\n\n  const roomTaken = new Array<boolean>(rooms.length).fill(false);\n  const faceToRoom = new Array<number>(faces.length).fill(-1);\n  for (const pair of pairs) {\n    if (roomTaken[pair.roomIndex]) continue;\n    if (faceToRoom[pair.faceIndex] !== -1) continue;\n    roomTaken[pair.roomIndex] = true;\n    faceToRoom[pair.faceIndex] = pair.roomIndex;\n  }\n\n  const nextRooms: Record<string, PlanRoom> = { ...floor.rooms };\n  const derived: DerivedRoom[] = faces.map((face, f) => {\n    const anchorFallback = representativePoint(face.outer, face.holes);\n    const roomIndex = faceToRoom[f];\n    if (roomIndex === -1) {\n      return { ...face, roomId: null, meta: null, anchor: anchorFallback };\n    }\n\n    const room = rooms[roomIndex];\n    const anchor = room.anchorPinned ? room.anchor : anchorFallback;\n    const updated: PlanRoom = {\n      ...room,\n      anchor,\n      // Refreshing the cache is what lets the next edit match against where the room is\n      // now rather than where it was when it was created.\n      wallIds: [...new Set(face.wallIds)],\n      area: face.area,\n    };\n    nextRooms[room.id] = updated;\n    return { ...face, roomId: room.id, meta: updated, anchor };\n  });\n\n  for (let r = 0; r < rooms.length; r++) {\n    if (roomTaken[r]) continue;\n    // Kept, never deleted. Re-adding a deleted wall re-splits the face and this room\n    // matches its child again by anchor, so the name comes back by itself. Dropping\n    // orphans is the one way this model loses data irrecoverably.\n    issues.push({\n      kind: PlanIssueKind.UnmatchedRoom,\n      message: `\"${rooms[r].name}\" is not currently enclosed by any walls.`,\n      roomIds: [rooms[r].id],\n    });\n  }\n\n  return { floor: { ...floor, rooms: nextRooms }, rooms: derived, issues };\n}\n\nlet roomSeq = 0;\n\n/** Test-only: makes the room sequence counter reproducible. */\nexport function __resetRoomSeq(): void {\n  roomSeq = 0;\n}\n\nexport interface CommitDerivationOptions {\n  /**\n   * Names a new room. `n` is the floor's room count plus one, moved past any number whose\n   * name is taken, so `` (n) => `Raum ${n}` `` numbers rooms the way the default does.\n   * Default `` (n) => `Room ${n}` ``.\n   */\n  nameFor?: (n: number, face: DerivedRoom) => string;\n}\n\nconst defaultRoomName = (n: number) => `Room ${n}`;\n\n/**\n * Mints a `PlanRoom` for every face that matched nothing.\n *\n * Separate from `derivePlan` so that derivation stays free of id minting and can run on\n * every render without inflating the document. Call it once per committed edit.\n */\nexport function commitDerivation(\n  floor: PlanFloor,\n  options: CommitDerivationOptions = {},\n): { floor: PlanFloor; created: PlanRoom[] } {\n  const { rooms: derived, floor: refreshed } = derivePlan(floor);\n  const nextRooms = { ...refreshed.rooms };\n  const created: PlanRoom[] = [];\n\n  const nameFor = options.nameFor ?? defaultRoomName;\n  const existingNames = new Set(Object.values(nextRooms).map((r) => r.name));\n\n  for (const face of derived) {\n    if (face.roomId) continue;\n    roomSeq += 1;\n\n    const name = freshName((n) => nameFor(n, face), Object.keys(nextRooms).length + 1, existingNames);\n    existingNames.add(name);\n\n    const room: PlanRoom = {\n      id: newId('r'),\n      name,\n      anchor: face.anchor,\n      anchorPinned: false,\n      seq: roomSeq,\n      wallIds: [...new Set(face.wallIds)],\n      area: face.area,\n    };\n    nextRooms[room.id] = room;\n    created.push(room);\n  }\n\n  return { floor: { ...refreshed, rooms: nextRooms }, created };\n}\n\n/** Removes room metadata that matched no face. Never called automatically. */\nexport function pruneUnmatchedRooms(floor: PlanFloor): { floor: PlanFloor; removed: string[] } {\n  const { rooms: derived } = derivePlan(floor);\n  const matched = new Set(derived.map((d) => d.roomId).filter(Boolean) as string[]);\n  const nextRooms: Record<string, PlanRoom> = {};\n  const removed: string[] = [];\n  for (const room of Object.values(floor.rooms)) {\n    if (matched.has(room.id)) nextRooms[room.id] = room;\n    else removed.push(room.id);\n  }\n  return { floor: { ...floor, rooms: nextRooms }, removed };\n}\n\n/** Convenience: the derived room whose polygon contains `point`, if any. */\nexport function roomAtPoint(derived: DerivedRoom[], point: { x: number; y: number }): DerivedRoom | null {\n  let best: DerivedRoom | null = null;\n  for (const room of derived) {\n    if (!pointInRings(point, room.outer, room.holes)) continue;\n    // Smallest wins, so a room nested inside another is selectable.\n    if (!best || room.area < best.area) best = room;\n  }\n  return best;\n}\n"],"mappings":"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"}