@ahrowe/ui 0.25.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/esm/common/floorPlan/floorPlan.faces.mjs +2 -0
  2. package/dist/esm/common/floorPlan/floorPlan.faces.mjs.map +1 -0
  3. package/dist/esm/common/floorPlan/floorPlan.geometry.mjs +2 -0
  4. package/dist/esm/common/floorPlan/floorPlan.geometry.mjs.map +1 -0
  5. package/dist/esm/common/floorPlan/floorPlan.graph.mjs +2 -0
  6. package/dist/esm/common/floorPlan/floorPlan.graph.mjs.map +1 -0
  7. package/dist/esm/common/floorPlan/floorPlan.hit.mjs +2 -0
  8. package/dist/esm/common/floorPlan/floorPlan.hit.mjs.map +1 -0
  9. package/dist/esm/common/floorPlan/floorPlan.json.mjs +2 -0
  10. package/dist/esm/common/floorPlan/floorPlan.json.mjs.map +1 -0
  11. package/dist/esm/common/floorPlan/floorPlan.path.mjs +2 -0
  12. package/dist/esm/common/floorPlan/floorPlan.path.mjs.map +1 -0
  13. package/dist/esm/common/floorPlan/floorPlan.plan.mjs +2 -0
  14. package/dist/esm/common/floorPlan/floorPlan.plan.mjs.map +1 -0
  15. package/dist/esm/common/floorPlan/floorPlan.rooms.mjs +2 -0
  16. package/dist/esm/common/floorPlan/floorPlan.rooms.mjs.map +1 -0
  17. package/dist/esm/common/floorPlan/floorPlan.snap.mjs +2 -0
  18. package/dist/esm/common/floorPlan/floorPlan.snap.mjs.map +1 -0
  19. package/dist/esm/common/floorPlan/floorPlan.types.mjs +2 -0
  20. package/dist/esm/common/floorPlan/floorPlan.types.mjs.map +1 -0
  21. package/dist/esm/common/floorPlan/legacySvg.mjs +2 -0
  22. package/dist/esm/common/floorPlan/legacySvg.mjs.map +1 -0
  23. package/dist/esm/common/hooks/useSheet.mjs +1 -1
  24. package/dist/esm/common/hooks/useSheet.mjs.map +1 -1
  25. package/dist/esm/common/planCanvas/planCanvas.mjs +2 -0
  26. package/dist/esm/common/planCanvas/planCanvas.mjs.map +1 -0
  27. package/dist/esm/common/planCanvas/planCanvas.module.mjs +2 -0
  28. package/dist/esm/common/planCanvas/planCanvas.module.mjs.map +1 -0
  29. package/dist/esm/common/roomDrawer/roomDrawer.mjs +1 -1
  30. package/dist/esm/common/roomDrawer/roomDrawer.mjs.map +1 -1
  31. package/dist/esm/common/roomDrawer/roomDrawer.module.mjs +1 -1
  32. package/dist/esm/common/roomDrawer/roomDrawer.module.mjs.map +1 -1
  33. package/dist/esm/common/roomDrawer/usePlanHistory.mjs +2 -0
  34. package/dist/esm/common/roomDrawer/usePlanHistory.mjs.map +1 -0
  35. package/dist/esm/common/roomViewer/roomViewer.mjs +1 -1
  36. package/dist/esm/common/roomViewer/roomViewer.mjs.map +1 -1
  37. package/dist/esm/common/roomViewer/roomViewer.module.mjs +1 -1
  38. package/dist/esm/common/roomViewer/roomViewer.module.mjs.map +1 -1
  39. package/dist/esm/common/styles/sheet.module.mjs.map +1 -1
  40. package/dist/esm/index.mjs +1 -1
  41. package/dist/index.cjs +3 -7
  42. package/dist/index.cjs.map +1 -1
  43. package/dist/style.css +1 -1
  44. package/dist/types/package/common/configProvider/configProvider.types.d.ts +4 -0
  45. package/dist/types/package/common/floorPlan/floorPlan.faces.d.ts +33 -0
  46. package/dist/types/package/common/floorPlan/floorPlan.fixtures.d.ts +35 -0
  47. package/dist/types/package/common/floorPlan/floorPlan.geometry.d.ts +111 -0
  48. package/dist/types/package/common/floorPlan/floorPlan.graph.d.ts +139 -0
  49. package/dist/types/package/common/floorPlan/floorPlan.hit.d.ts +26 -0
  50. package/dist/types/package/common/floorPlan/floorPlan.json.d.ts +23 -0
  51. package/dist/types/package/common/floorPlan/floorPlan.path.d.ts +59 -0
  52. package/dist/types/package/common/floorPlan/floorPlan.plan.d.ts +98 -0
  53. package/dist/types/package/common/floorPlan/floorPlan.rooms.d.ts +47 -0
  54. package/dist/types/package/common/floorPlan/floorPlan.snap.d.ts +101 -0
  55. package/dist/types/package/common/floorPlan/floorPlan.types.d.ts +276 -0
  56. package/dist/types/package/common/floorPlan/index.d.ts +28 -0
  57. package/dist/types/package/common/floorPlan/legacySvg.d.ts +27 -0
  58. package/dist/types/package/common/hooks/useSheet.d.ts +4 -1
  59. package/dist/types/package/common/planCanvas/index.d.ts +2 -0
  60. package/dist/types/package/common/planCanvas/planCanvas.d.ts +7 -0
  61. package/dist/types/package/common/planCanvas/planCanvas.types.d.ts +62 -0
  62. package/dist/types/package/common/roomDrawer/roomDrawer.d.ts +3 -3
  63. package/dist/types/package/common/roomDrawer/roomDrawer.types.d.ts +295 -15
  64. package/dist/types/package/common/roomDrawer/usePlanHistory.d.ts +24 -0
  65. package/dist/types/package/common/roomViewer/index.d.ts +1 -0
  66. package/dist/types/package/common/roomViewer/roomViewer.d.ts +9 -2
  67. package/dist/types/package/common/roomViewer/roomViewer.types.d.ts +88 -9
  68. package/dist/types/package/index.d.ts +1 -0
  69. package/docs/CLAUDE.md +1 -0
  70. package/docs/ConfigProvider.md +1 -0
  71. package/docs/Dropdown.md +1 -1
  72. package/docs/FloatingMenu.md +1 -1
  73. package/docs/FloorPlan.md +214 -0
  74. package/docs/Modal.md +2 -2
  75. package/docs/Popover.md +1 -1
  76. package/docs/RoomDrawer.md +330 -30
  77. package/docs/RoomViewer.md +80 -27
  78. package/package.json +1 -1
  79. package/dist/esm/common/roomDrawer/roomDrawer.utils.mjs +0 -6
  80. package/dist/esm/common/roomDrawer/roomDrawer.utils.mjs.map +0 -1
  81. package/dist/types/package/common/roomDrawer/roomDrawer.utils.d.ts +0 -32
  82. package/docs/room-drawing-analysis.md +0 -337
@@ -0,0 +1,2 @@
1
+ import{dist as e,perimeter as t,pointInRing as n,representativePoint as r,signedArea as i}from"./floorPlan.geometry.mjs";function a(t){return Object.values(t.walls).filter(n=>{if(n.a===n.b)return!1;let r=t.nodes[n.a],i=t.nodes[n.b];return!r||!i?!1:e(r,i)>=10})}function o(e){let t=a(e),n=Array(t.length*2);for(let e=0;e<t.length;e++){let r=t[e];n[2*e]={wallId:r.id,from:r.a,to:r.b},n[2*e+1]={wallId:r.id,from:r.b,to:r.a}}let r=t=>e.nodes[t],i=new Map;for(let e=0;e<n.length;e++){let t=i.get(n[e].from);t?t.push(e):i.set(n[e].from,[e])}let o=e=>{let t=r(n[e].from),i=r(n[e].to);return Math.atan2(i.y-t.y,i.x-t.x)};for(let e of i.values())e.sort((e,t)=>{let r=o(e),i=o(t);if(r!==i)return r-i;let a=n[e].wallId,s=n[t].wallId;return a===s?e-t:a<s?-1:1});let s=new Int32Array(n.length);for(let e of i.values())for(let t=0;t<e.length;t++)s[e[t]]=t;return{darts:n,outgoing:i,ringIndex:s,nodeOf:r}}function s(e,t){let n=e.outgoing.get(e.darts[t].to);return!n||n.length===0?t^1:n[(e.ringIndex[t^1]-1+n.length)%n.length]}function c(e){let t=new Uint8Array(e.darts.length),n=[];for(let r=0;r<e.darts.length;r++){if(t[r])continue;let i=[],a=r,o=0;do if(t[a]=1,i.push(a),a=s(e,a),++o>e.darts.length+1)break;while(a!==r&&!t[a]);n.push(i)}return n}function l(e){let t=new Map,n=e=>{let n=e;for(;t.get(n)!==n;)n=t.get(n)??n;let r=e;for(;t.get(r)!==n;){let e=t.get(r)??n;t.set(r,n),r=e}return n};for(let n of e.outgoing.keys())t.set(n,n);for(let r=0;r<e.darts.length;r+=2){let i=n(e.darts[r].from),a=n(e.darts[r].to);i!==a&&t.set(i,a)}let r=new Map;for(let t of e.outgoing.keys())r.set(t,n(t));return r}function u(e,t){return t.map(t=>{let n=e.nodeOf(e.darts[t].from);return{x:n.x,y:n.y}})}function d(e,t){let n=``;for(let r of t){let t=`${e.darts[r].wallId}>${e.darts[r].from}`;(n===``||t<n)&&(n=t)}return`f:${n}`}function f(e){let a=o(e);if(a.darts.length===0)return[];let s=l(a),f=c(a),p=[],m=new Map;for(let e of f){let n=u(a,e),r=i(n),o=s.get(a.darts[e[0]].from)??``;r>100?p.push({face:{id:d(a,e),outer:n,holes:[],area:r,perimeter:t(n),wallIds:e.map(e=>a.darts[e].wallId),nodeIds:e.map(e=>a.darts[e].from)},component:o,outerArea:r}):r<-100&&m.set(o,n)}for(let[e,t]of m){let a=Math.abs(i(t));if(a<=100)continue;let o=r(t),s=null;for(let t of p)t.component!==e&&(t.outerArea<=a||n(o,t.face.outer)&&(!s||t.outerArea<s.outerArea)&&(s=t));s&&s.face.holes.push(t)}for(let e of p){let t=e.outerArea;for(let n of e.face.holes)t-=Math.abs(i(n));e.face.area=t}return p.map(e=>e.face)}function p(e){let t=new Map;for(let n of a(e))t.set(n.a,(t.get(n.a)??0)+1),t.set(n.b,(t.get(n.b)??0)+1);return t}export{f as deriveFaces,p as nodeDegrees};
2
+ //# sourceMappingURL=floorPlan.faces.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"floorPlan.faces.mjs","names":[],"sources":["../../../../package/common/floorPlan/floorPlan.faces.ts"],"sourcesContent":["/**\n * Planar face extraction: turns the wall graph into the enclosed rooms it forms.\n *\n * Each wall becomes two darts (directed half-edges), wall `i` owning darts `2i` and\n * `2i + 1`, so `twin(d) === d ^ 1` with no lookup. The darts leaving each node are\n * sorted by angle, and the face walk is:\n *\n * next(d) = the cyclic PREDECESSOR of twin(d) in the sorted ring at head(d)\n *\n * Because the shoelace sum runs on the same raw coordinates as the angular sort, the\n * rule needs no orientation convention: bounded faces come out with POSITIVE signed\n * area and each connected component's outer boundary comes out negative, whichever way\n * you read the y axis.\n *\n * A dangling wall (one endpoint of degree 1) is handled by the rule itself: a\n * one-element ring's cyclic predecessor is itself, so the walk goes out along the spur\n * and straight back. Both of its darts land in the same cycle, no extra face appears,\n * and the face gains a zero-width slit that contributes nothing to area, fill or\n * point-in-polygon.\n */\n\nimport {\n EPS_AREA,\n MIN_WALL_LENGTH,\n areaCentroid,\n dist,\n perimeter as ringPerimeter,\n pointInRing,\n representativePoint,\n signedArea,\n} from './floorPlan.geometry';\nimport type { PlanFace, PlanFloor, PlanNode, PlanPoint, PlanWall } from './floorPlan.types';\n\ninterface Dart {\n wallId: string;\n from: string;\n to: string;\n}\n\ninterface DartGraph {\n darts: Dart[];\n /** Node id to its outgoing dart indices, sorted by angle ascending. */\n outgoing: Map<string, number[]>;\n /** Dart index to its position within its own outgoing ring. */\n ringIndex: Int32Array;\n nodeOf: (id: string) => PlanNode;\n}\n\n/** Walls that are safe to traverse: real nodes at both ends, and long enough to have an angle. */\nfunction usableWalls(floor: PlanFloor): PlanWall[] {\n return Object.values(floor.walls).filter((w) => {\n if (w.a === w.b) return false;\n const a = floor.nodes[w.a];\n const b = floor.nodes[w.b];\n if (!a || !b) return false;\n return dist(a, b) >= MIN_WALL_LENGTH;\n });\n}\n\nfunction buildDartGraph(floor: PlanFloor): DartGraph {\n const walls = usableWalls(floor);\n const darts: Dart[] = new Array(walls.length * 2);\n\n for (let i = 0; i < walls.length; i++) {\n const w = walls[i];\n darts[2 * i] = { wallId: w.id, from: w.a, to: w.b };\n darts[2 * i + 1] = { wallId: w.id, from: w.b, to: w.a };\n }\n\n const nodeOf = (id: string): PlanNode => floor.nodes[id];\n\n const outgoing = new Map<string, number[]>();\n for (let d = 0; d < darts.length; d++) {\n const list = outgoing.get(darts[d].from);\n if (list) list.push(d);\n else outgoing.set(darts[d].from, [d]);\n }\n\n const angleOf = (d: number): number => {\n const from = nodeOf(darts[d].from);\n const to = nodeOf(darts[d].to);\n return Math.atan2(to.y - from.y, to.x - from.x);\n };\n\n for (const ring of outgoing.values()) {\n ring.sort((x, y) => {\n const ax = angleOf(x);\n const ay = angleOf(y);\n if (ax !== ay) return ax - ay;\n // Deterministic tie-break. A genuine tie means two walls leave this node in the\n // same direction, which validatePlan reports as OverlappingWalls: the sort stays\n // stable so the walk cannot hang, but the resulting face is still wrong.\n const wx = darts[x].wallId;\n const wy = darts[y].wallId;\n if (wx !== wy) return wx < wy ? -1 : 1;\n return x - y;\n });\n }\n\n const ringIndex = new Int32Array(darts.length);\n for (const ring of outgoing.values()) {\n for (let i = 0; i < ring.length; i++) ringIndex[ring[i]] = i;\n }\n\n return { darts, outgoing, ringIndex, nodeOf };\n}\n\nfunction nextDart(g: DartGraph, d: number): number {\n const ring = g.outgoing.get(g.darts[d].to);\n /* c8 ignore next -- head() always has the twin in its ring by construction */\n if (!ring || ring.length === 0) return d ^ 1;\n const i = g.ringIndex[d ^ 1];\n return ring[(i - 1 + ring.length) % ring.length];\n}\n\n/** Every dart belongs to exactly one cycle. */\nfunction walkCycles(g: DartGraph): number[][] {\n const visited = new Uint8Array(g.darts.length);\n const cycles: number[][] = [];\n\n for (let d0 = 0; d0 < g.darts.length; d0++) {\n if (visited[d0]) continue;\n const cycle: number[] = [];\n let d = d0;\n let guard = 0;\n do {\n visited[d] = 1;\n cycle.push(d);\n d = nextDart(g, d);\n // Only reachable if the dart permutation is corrupt (duplicate walls producing\n // an exact angular tie). Bail rather than spin.\n if (++guard > g.darts.length + 1) break;\n } while (d !== d0 && !visited[d]);\n cycles.push(cycle);\n }\n\n return cycles;\n}\n\n/** Union-find over node ids, joined by each wall. */\nfunction connectedComponents(g: DartGraph): Map<string, string> {\n const parent = new Map<string, string>();\n const find = (x: string): string => {\n let root = x;\n while (parent.get(root) !== root) root = parent.get(root) ?? root;\n let cur = x;\n while (parent.get(cur) !== root) {\n const nxt = parent.get(cur) ?? root;\n parent.set(cur, root);\n cur = nxt;\n }\n return root;\n };\n\n for (const nodeId of g.outgoing.keys()) parent.set(nodeId, nodeId);\n for (let d = 0; d < g.darts.length; d += 2) {\n const ra = find(g.darts[d].from);\n const rb = find(g.darts[d].to);\n if (ra !== rb) parent.set(ra, rb);\n }\n\n const out = new Map<string, string>();\n for (const nodeId of g.outgoing.keys()) out.set(nodeId, find(nodeId));\n return out;\n}\n\nfunction cycleRing(g: DartGraph, cycle: number[]): PlanPoint[] {\n return cycle.map((d) => {\n const n = g.nodeOf(g.darts[d].from);\n return { x: n.x, y: n.y };\n });\n}\n\n/**\n * A face id derived from the boundary itself, so it stays stable across derives for as\n * long as the geometry is unchanged.\n */\nfunction faceId(g: DartGraph, cycle: number[]): string {\n let best = '';\n for (const d of cycle) {\n const key = `${g.darts[d].wallId}>${g.darts[d].from}`;\n if (best === '' || key < best) best = key;\n }\n return `f:${best}`;\n}\n\n/**\n * Extracts every enclosed face from the wall graph, with nested components attached as\n * holes.\n *\n * Returned faces are positively wound (visually clockwise in SVG space) and their\n * `area` already has holes subtracted.\n */\nexport function deriveFaces(floor: PlanFloor): PlanFace[] {\n const g = buildDartGraph(floor);\n if (g.darts.length === 0) return [];\n\n const componentOf = connectedComponents(g);\n const cycles = walkCycles(g);\n\n interface Candidate {\n face: PlanFace;\n component: string;\n outerArea: number;\n }\n\n const candidates: Candidate[] = [];\n /** One outer (negatively wound) cycle per connected component. */\n const outerRings = new Map<string, PlanPoint[]>();\n\n for (const cycle of cycles) {\n const ring = cycleRing(g, cycle);\n const area = signedArea(ring);\n const component = componentOf.get(g.darts[cycle[0]].from) ?? '';\n\n if (area > EPS_AREA) {\n candidates.push({\n face: {\n id: faceId(g, cycle),\n outer: ring,\n holes: [],\n area,\n perimeter: ringPerimeter(ring),\n wallIds: cycle.map((d) => g.darts[d].wallId),\n nodeIds: cycle.map((d) => g.darts[d].from),\n },\n component,\n outerArea: area,\n });\n } else if (area < -EPS_AREA) {\n // This component's outer boundary. Kept at its negative winding, which is what\n // `PlanFace.holes` promises: opposite to the parent's positively wound `outer`.\n outerRings.set(component, ring);\n }\n // |area| <= EPS_AREA encloses nothing: a tree component or an isolated stub.\n }\n\n // Attach each component's outer boundary as a hole in the smallest face from a\n // different component that encloses it. Smallest-containing-area is what makes\n // arbitrary nesting depth work: with A > B > C, C's ring goes to B, never to A.\n for (const [component, ring] of outerRings) {\n const ringArea = Math.abs(signedArea(ring));\n if (ringArea <= EPS_AREA) continue;\n // An interior probe rather than a vertex, so two components that happen to touch\n // do not produce an on-boundary coin flip.\n const probe = representativePoint(ring);\n let parent: Candidate | null = null;\n for (const cand of candidates) {\n if (cand.component === component) continue;\n // The probe test alone is not sufficient: with one component nested inside\n // another, the OUTER component's probe also falls inside the inner component's\n // face, which would attach the big room as a hole of the small one. A face can\n // only enclose a ring smaller than itself.\n if (cand.outerArea <= ringArea) continue;\n if (!pointInRing(probe, cand.face.outer)) continue;\n if (!parent || cand.outerArea < parent.outerArea) parent = cand;\n }\n // No parent simply means this is a top-level component.\n if (parent) parent.face.holes.push(ring);\n }\n\n for (const cand of candidates) {\n let area = cand.outerArea;\n for (const hole of cand.face.holes) area -= Math.abs(signedArea(hole));\n cand.face.area = area;\n }\n\n return candidates.map((c) => c.face);\n}\n\n/**\n * Euler's formula per connected component: `V - E + F === 2C`, counting every cycle as\n * a face, including each component's own outer boundary.\n *\n * Note it is `2C`, not the `1 + C` of a single connected embedding. The dart walk\n * cannot bridge between disconnected components, so the region between two of them is\n * produced once per component rather than once in total.\n *\n * Exported for tests and dev assertions: it is the cheapest whole-graph check that the\n * dart permutation is intact.\n */\nexport function eulerCheck(floor: PlanFloor): {\n v: number;\n e: number;\n f: number;\n c: number;\n holds: boolean;\n} {\n const g = buildDartGraph(floor);\n const componentOf = connectedComponents(g);\n const v = g.outgoing.size;\n const e = g.darts.length / 2;\n const f = walkCycles(g).length;\n const c = new Set(componentOf.values()).size;\n return { v, e, f, c, holds: v - e + f === 2 * c };\n}\n\n/** Test/diagnostic helper: how many cycles each dart appears in. Must be all 1s. */\nexport function dartCycleCounts(floor: PlanFloor): number[] {\n const g = buildDartGraph(floor);\n const counts = new Array<number>(g.darts.length).fill(0);\n for (const cycle of walkCycles(g)) for (const d of cycle) counts[d] += 1;\n return counts;\n}\n\n/** Degree of each node, by id. Used by wall-path chaining and by validation. */\nexport function nodeDegrees(floor: PlanFloor): Map<string, number> {\n const degrees = new Map<string, number>();\n for (const w of usableWalls(floor)) {\n degrees.set(w.a, (degrees.get(w.a) ?? 0) + 1);\n degrees.set(w.b, (degrees.get(w.b) ?? 0) + 1);\n }\n return degrees;\n}\n\nexport { areaCentroid };\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ var e=10,t=1e-4,n=100;function r(e,t){return Math.hypot(e.x-t.x,e.y-t.y)}function i(e,t,n){return(t.x-e.x)*(n.y-e.y)-(t.y-e.y)*(n.x-e.x)}function a(e,t,n){let i=n.x-t.x,a=n.y-t.y,o=i*i+a*a;if(o===0)return{dist:r(e,t),t:0,point:{x:t.x,y:t.y}};let s=((e.x-t.x)*i+(e.y-t.y)*a)/o,c=s<0?0:s>1?1:s,l={x:t.x+c*i,y:t.y+c*a};return{dist:r(e,l),t:c,point:l}}function o(e,t,n){return a(e,t,n).point}function s(e,t,n,a,o=1){let s=r(e,t),c=r(n,a);if(s<1e-9||c<1e-9)return{kind:`none`};let l=t.x-e.x,u=t.y-e.y,d=a.x-n.x,f=a.y-n.y,p=l*f-u*d,m=Math.abs(i(e,t,n))/s,h=Math.abs(i(e,t,a))/s;if(Math.abs(p)<1e-12||m<o&&h<o){if(m>=o||h>=o)return{kind:`none`};let t=l*l+u*u,r=((n.x-e.x)*l+(n.y-e.y)*u)/t,i=((a.x-e.x)*l+(a.y-e.y)*u)/t,c=Math.min(r,i),d=Math.max(r,i),f=o/s;return d<f||c>1-f||Math.min(d,1)-Math.max(c,0)<f?{kind:`collinearDisjoint`}:{kind:`collinearOverlap`}}let g=((n.x-e.x)*f-(n.y-e.y)*d)/p,_=((n.x-e.x)*u-(n.y-e.y)*l)/p,v=o/s,y=o/c;if(g<-v||g>1+v||_<-y||_>1+y)return{kind:`none`};let b={x:e.x+g*l,y:e.y+g*u},x=g<=v||g>=1-v,S=_<=y||_>=1-y;return x||S?{kind:`touch`,t:g,s:_,point:b}:{kind:`proper`,t:g,s:_,point:b}}function c(e){let t=e.length;if(t<3)return 0;let n=0;for(let r=0,i=t-1;r<t;i=r++)n+=e[i].x*e[r].y-e[r].x*e[i].y;return n/2}function l(e){let t=e.length;if(t<2)return 0;let n=0;for(let i=0,a=t-1;i<t;a=i++)n+=r(e[a],e[i]);return n}function u(e){let t=e.length;if(t===0)return{x:0,y:0};if(t<3)return{x:e.reduce((e,t)=>e+t.x,0)/t,y:e.reduce((e,t)=>e+t.y,0)/t};let n=0,r=0,i=0;for(let a=0,o=t-1;a<t;o=a++){let t=e[o].x*e[a].y-e[a].x*e[o].y;n+=t,r+=(e[o].x+e[a].x)*t,i+=(e[o].y+e[a].y)*t}return Math.abs(n)<1e-9?{x:e.reduce((e,t)=>e+t.x,0)/t,y:e.reduce((e,t)=>e+t.y,0)/t}:{x:r/(3*n),y:i/(3*n)}}function d(e,t){let n=!1;for(let r=0,i=t.length-1;r<t.length;i=r++){let a=t[r].x,o=t[r].y,s=t[i].x,c=t[i].y;o>e.y!=c>e.y&&e.x<(s-a)*(e.y-o)/(c-o)+a&&(n=!n)}return n}function f(e,t,n=[]){if(!d(e,t))return!1;for(let t of n)if(d(e,t))return!1;return!0}function p(e,t=[]){if(e.length===0)return{x:0,y:0};let n=u(e);if(f(n,e,t))return n;let r=1/0,i=-1/0;for(let t of e)t.y<r&&(r=t.y),t.y>i&&(i=t.y);if(!Number.isFinite(r)||i-r<1e-9)return n;let a=[e,...t],o=null;for(let e=1;e<=7;e++){let t=r+(i-r)*e/8,n=[];for(let e of a)for(let r=0,i=e.length-1;r<e.length;i=r++){let a=e[i],o=e[r];a.y>t!=o.y>t&&n.push(a.x+(t-a.y)*(o.x-a.x)/(o.y-a.y))}n.sort((e,t)=>e-t);for(let e=0;e+1<n.length;e+=2){let r=n[e+1]-n[e];r>2&&(!o||r>o.width)&&(o={point:{x:(n[e]+n[e+1])/2,y:t},width:r})}}return o?o.point:n}function m(e){if(e.length===0)return null;let t=1/0,n=1/0,r=-1/0,i=-1/0;for(let a of e)a.x<t&&(t=a.x),a.y<n&&(n=a.y),a.x>r&&(r=a.x),a.y>i&&(i=a.y);return{minX:t,minY:n,maxX:r,maxY:i}}export{t as EPS_ANGLE,n as MIN_OPENING_WIDTH,e as MIN_WALL_LENGTH,u as areaCentroid,m as boundsOf,i as cross,r as dist,l as perimeter,d as pointInRing,f as pointInRings,a as pointToSegment,o as projectOnSegment,p as representativePoint,s as segmentIntersection,c as signedArea};
2
+ //# sourceMappingURL=floorPlan.geometry.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"floorPlan.geometry.mjs","names":[],"sources":["../../../../package/common/floorPlan/floorPlan.geometry.ts"],"sourcesContent":["/**\n * Pure vector and polygon primitives. Nothing here knows about the plan model.\n *\n * All lengths are millimetres (see floorPlan.types.ts).\n */\n\nimport type { PlanPoint } from './floorPlan.types';\n\n// ─── Model-integrity epsilons ──────────────────────────────────────────────────\n// These are FIXED millimetre values describing when the model itself is degenerate.\n// They are a separate family from the user-intent tolerances in floorPlan.snap.ts,\n// which are screen pixels converted through the current zoom. Conflating the two\n// gives you either a model that accumulates 0.2mm walls, or snapping that stops\n// working when zoomed in.\n\n/** Shortest wall the graph will accept. Keeps zero-length walls out entirely. */\nexport const MIN_WALL_LENGTH = 10;\n/** Two points closer than this are the same point. */\nexport const EPS_COINCIDENT = 1;\n/** A ring with |area| below this encloses nothing (0.0001 m²). */\nexport const EPS_AREA = 100;\n/** Angular tolerance for \"these two walls are collinear\", in radians. */\nexport const EPS_ANGLE = 1e-4;\n/** Narrowest scanline span that counts as room interior rather than a slit. */\nexport const EPS_SPAN = 2;\n/** Narrowest opening worth keeping when a wall shortens under it. */\nexport const MIN_OPENING_WIDTH = 100;\n\n// ─── Vectors ───────────────────────────────────────────────────────────────────\n\nexport function dist(a: PlanPoint, b: PlanPoint): number {\n return Math.hypot(a.x - b.x, a.y - b.y);\n}\n\nexport function distSq(a: PlanPoint, b: PlanPoint): number {\n const dx = a.x - b.x;\n const dy = a.y - b.y;\n return dx * dx + dy * dy;\n}\n\n/** Cross product of ab and ac. Positive when c is clockwise of b in SVG's y-down space. */\nexport function cross(a: PlanPoint, b: PlanPoint, c: PlanPoint): number {\n return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);\n}\n\nexport function lerp(a: PlanPoint, b: PlanPoint, t: number): PlanPoint {\n return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };\n}\n\n/** Unit vector from a to b, or null when they are coincident. */\nexport function direction(a: PlanPoint, b: PlanPoint): PlanPoint | null {\n const dx = b.x - a.x;\n const dy = b.y - a.y;\n const len = Math.hypot(dx, dy);\n if (len < 1e-9) return null;\n return { x: dx / len, y: dy / len };\n}\n\n/** Left-hand normal of the direction from a to b, or null when coincident. */\nexport function normal(a: PlanPoint, b: PlanPoint): PlanPoint | null {\n const d = direction(a, b);\n return d ? { x: -d.y, y: d.x } : null;\n}\n\n// ─── Point / segment ───────────────────────────────────────────────────────────\n\n/**\n * Distance from p to segment ab, plus the clamped parameter t of the closest point.\n * `t` is clamped to [0, 1], so the closest point is always on the segment.\n */\nexport function pointToSegment(\n p: PlanPoint,\n a: PlanPoint,\n b: PlanPoint,\n): { dist: number; t: number; point: PlanPoint } {\n const dx = b.x - a.x;\n const dy = b.y - a.y;\n const len2 = dx * dx + dy * dy;\n if (len2 === 0) return { dist: dist(p, a), t: 0, point: { x: a.x, y: a.y } };\n const raw = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;\n const t = raw < 0 ? 0 : raw > 1 ? 1 : raw;\n const point = { x: a.x + t * dx, y: a.y + t * dy };\n return { dist: dist(p, point), t, point };\n}\n\n/**\n * Exact projection of p onto segment ab, clamped to the segment.\n *\n * Used after a fuzzy hit tolerance has decided that p belongs to this segment: intent\n * is allowed to be approximate, the stored model never is.\n */\nexport function projectOnSegment(p: PlanPoint, a: PlanPoint, b: PlanPoint): PlanPoint {\n return pointToSegment(p, a, b).point;\n}\n\nexport type SegmentIntersectionKind =\n /** The segments cross strictly inside both. */\n | 'proper'\n /** They are parallel and do not touch. */\n | 'none'\n /** They lie on the same line and overlap over a non-zero length. */\n | 'collinearOverlap'\n /** They lie on the same line but do not overlap. */\n | 'collinearDisjoint'\n /** An endpoint of one lies on the other, or they share an endpoint. */\n | 'touch';\n\nexport interface SegmentIntersection {\n kind: SegmentIntersectionKind;\n /** Parameter along ab, for 'proper' and 'touch'. */\n t?: number;\n /** Parameter along cd, for 'proper' and 'touch'. */\n s?: number;\n point?: PlanPoint;\n}\n\n/**\n * Classifies how segments ab and cd meet.\n *\n * `eps` is a millimetre distance tolerance, not a parameter tolerance, so the result\n * does not change with segment length.\n */\nexport function segmentIntersection(\n a: PlanPoint,\n b: PlanPoint,\n c: PlanPoint,\n d: PlanPoint,\n eps = EPS_COINCIDENT,\n): SegmentIntersection {\n const abLen = dist(a, b);\n const cdLen = dist(c, d);\n if (abLen < 1e-9 || cdLen < 1e-9) return { kind: 'none' };\n\n const rx = b.x - a.x;\n const ry = b.y - a.y;\n const sx = d.x - c.x;\n const sy = d.y - c.y;\n const denom = rx * sy - ry * sx;\n\n // Perpendicular distance of c and d from line ab, used to tell parallel from collinear.\n const distC = Math.abs(cross(a, b, c)) / abLen;\n const distD = Math.abs(cross(a, b, d)) / abLen;\n\n if (Math.abs(denom) < 1e-12 || (distC < eps && distD < eps)) {\n if (distC >= eps || distD >= eps) return { kind: 'none' };\n // Collinear: compare parameters along ab.\n const len2 = rx * rx + ry * ry;\n const tc = ((c.x - a.x) * rx + (c.y - a.y) * ry) / len2;\n const td = ((d.x - a.x) * rx + (d.y - a.y) * ry) / len2;\n const lo = Math.min(tc, td);\n const hi = Math.max(tc, td);\n const epsT = eps / abLen;\n if (hi < epsT || lo > 1 - epsT) return { kind: 'collinearDisjoint' };\n if (Math.min(hi, 1) - Math.max(lo, 0) < epsT) return { kind: 'collinearDisjoint' };\n return { kind: 'collinearOverlap' };\n }\n\n const t = ((c.x - a.x) * sy - (c.y - a.y) * sx) / denom;\n const s = ((c.x - a.x) * ry - (c.y - a.y) * rx) / denom;\n const epsT = eps / abLen;\n const epsS = eps / cdLen;\n\n if (t < -epsT || t > 1 + epsT || s < -epsS || s > 1 + epsS) return { kind: 'none' };\n\n const point = { x: a.x + t * rx, y: a.y + t * ry };\n const atAbEnd = t <= epsT || t >= 1 - epsT;\n const atCdEnd = s <= epsS || s >= 1 - epsS;\n if (atAbEnd || atCdEnd) return { kind: 'touch', t, s, point };\n return { kind: 'proper', t, s, point };\n}\n\n// ─── Rings ─────────────────────────────────────────────────────────────────────\n\n/**\n * Shoelace area of a closed ring (first vertex not repeated).\n *\n * Positive means visually clockwise in SVG's y-down space. See `PlanFace.outer`.\n */\nexport function signedArea(ring: readonly PlanPoint[]): number {\n const n = ring.length;\n if (n < 3) return 0;\n let sum = 0;\n for (let i = 0, j = n - 1; i < n; j = i++) {\n sum += ring[j].x * ring[i].y - ring[i].x * ring[j].y;\n }\n return sum / 2;\n}\n\nexport function perimeter(ring: readonly PlanPoint[]): number {\n const n = ring.length;\n if (n < 2) return 0;\n let sum = 0;\n for (let i = 0, j = n - 1; i < n; j = i++) sum += dist(ring[j], ring[i]);\n return sum;\n}\n\n/**\n * Area-weighted centroid of a ring.\n *\n * Deliberately not the vertex mean: a dangling wall adds an out-and-back vertex pair\n * that skews the mean but contributes nothing to the area moment. Falls back to the\n * vertex mean only for a degenerate (zero-area) ring.\n */\nexport function areaCentroid(ring: readonly PlanPoint[]): PlanPoint {\n const n = ring.length;\n if (n === 0) return { x: 0, y: 0 };\n if (n < 3) {\n return {\n x: ring.reduce((s, p) => s + p.x, 0) / n,\n y: ring.reduce((s, p) => s + p.y, 0) / n,\n };\n }\n let a = 0;\n let cx = 0;\n let cy = 0;\n for (let i = 0, j = n - 1; i < n; j = i++) {\n const f = ring[j].x * ring[i].y - ring[i].x * ring[j].y;\n a += f;\n cx += (ring[j].x + ring[i].x) * f;\n cy += (ring[j].y + ring[i].y) * f;\n }\n if (Math.abs(a) < 1e-9) {\n return {\n x: ring.reduce((s, p) => s + p.x, 0) / n,\n y: ring.reduce((s, p) => s + p.y, 0) / n,\n };\n }\n return { x: cx / (3 * a), y: cy / (3 * a) };\n}\n\n/** Ray-casting point-in-ring test. Points exactly on an edge are unspecified. */\nexport function pointInRing(p: PlanPoint, ring: readonly PlanPoint[]): boolean {\n let inside = false;\n for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {\n const xi = ring[i].x;\n const yi = ring[i].y;\n const xj = ring[j].x;\n const yj = ring[j].y;\n if (yi > p.y !== yj > p.y && p.x < ((xj - xi) * (p.y - yi)) / (yj - yi) + xi) {\n inside = !inside;\n }\n }\n return inside;\n}\n\n/** Inside `outer` and outside every hole. */\nexport function pointInRings(\n p: PlanPoint,\n outer: readonly PlanPoint[],\n holes: readonly (readonly PlanPoint[])[] = [],\n): boolean {\n if (!pointInRing(p, outer)) return false;\n for (const hole of holes) if (pointInRing(p, hole)) return false;\n return true;\n}\n\n/**\n * A point guaranteed to lie strictly inside the ring set, placed somewhere visually\n * central where possible.\n *\n * The area centroid is preferred, but it is not guaranteed to be inside a concave\n * polygon (an L-shaped room's centroid can land in the notch), so a scanline fallback\n * picks the widest interior span across several horizontal lines. The EPS_SPAN test on\n * that span is what stops an anchor landing inside the zero-width slit that a dangling\n * wall leaves in a face.\n */\nexport function representativePoint(\n outer: readonly PlanPoint[],\n holes: readonly (readonly PlanPoint[])[] = [],\n): PlanPoint {\n if (outer.length === 0) return { x: 0, y: 0 };\n\n const centre = areaCentroid(outer);\n if (pointInRings(centre, outer, holes)) return centre;\n\n let ymin = Infinity;\n let ymax = -Infinity;\n for (const p of outer) {\n if (p.y < ymin) ymin = p.y;\n if (p.y > ymax) ymax = p.y;\n }\n if (!Number.isFinite(ymin) || ymax - ymin < 1e-9) return centre;\n\n const rings: readonly (readonly PlanPoint[])[] = [outer, ...holes];\n let best: { point: PlanPoint; width: number } | null = null;\n\n const SAMPLES = 7;\n for (let k = 1; k <= SAMPLES; k++) {\n const y = ymin + ((ymax - ymin) * k) / (SAMPLES + 1);\n const xs: number[] = [];\n // One crossing list across ALL rings, so even-odd pairing yields interior spans\n // directly. Running the scanline per ring would not compose with holes.\n for (const ring of rings) {\n for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {\n const p = ring[j];\n const q = ring[i];\n if (p.y > y !== q.y > y) {\n xs.push(p.x + ((y - p.y) * (q.x - p.x)) / (q.y - p.y));\n }\n }\n }\n xs.sort((m, n) => m - n);\n for (let i = 0; i + 1 < xs.length; i += 2) {\n const width = xs[i + 1] - xs[i];\n if (width > EPS_SPAN && (!best || width > best.width)) {\n best = { point: { x: (xs[i] + xs[i + 1]) / 2, y }, width };\n }\n }\n }\n\n return best ? best.point : centre;\n}\n\n/**\n * Removes out-and-back spurs (`... P, Q, P ...`) from a ring, repeatedly until none\n * remain.\n *\n * A face containing a dangling wall is a valid fill but is not a simple polygon, which\n * breaks polygon offsetting and makes some triangulators emit zero-area triangles. Run\n * this before triangulating a floor slab; keep the raw ring for the 2D fill, where the\n * slit is harmless and `wallIds` alignment matters.\n */\nexport function stripSpurs(ring: readonly PlanPoint[]): PlanPoint[] {\n let out = ring.slice();\n let changed = true;\n while (changed && out.length > 3) {\n changed = false;\n const next: PlanPoint[] = [];\n const n = out.length;\n for (let i = 0; i < n; i++) {\n const prev = out[(i - 1 + n) % n];\n const nextPt = out[(i + 1) % n];\n // The middle of an out-and-back is the vertex whose neighbours coincide.\n if (dist(prev, nextPt) < EPS_COINCIDENT) {\n changed = true;\n continue;\n }\n next.push(out[i]);\n }\n if (changed) {\n // Drop the now-duplicated turning point left behind by each removed spur tip.\n out = next.filter((p, i) => dist(p, next[(i + 1) % next.length]) >= EPS_COINCIDENT);\n }\n }\n return out;\n}\n\nexport function rectToPoints(x: number, y: number, w: number, h: number): PlanPoint[] {\n return [\n { x, y },\n { x: x + w, y },\n { x: x + w, y: y + h },\n { x, y: y + h },\n ];\n}\n\nexport function boundsOf(points: readonly PlanPoint[]): {\n minX: number;\n minY: number;\n maxX: number;\n maxY: number;\n} | null {\n if (points.length === 0) return null;\n let minX = Infinity;\n let minY = Infinity;\n let maxX = -Infinity;\n let maxY = -Infinity;\n for (const p of points) {\n if (p.x < minX) minX = p.x;\n if (p.y < minY) minY = p.y;\n if (p.x > maxX) maxX = p.x;\n if (p.y > maxY) maxY = p.y;\n }\n return { minX, minY, maxX, maxY };\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{PlanIssueKind as e,WallType as t}from"./floorPlan.types.mjs";import{cross as n,dist as r,pointToSegment as i,projectOnSegment as a,segmentIntersection as o}from"./floorPlan.geometry.mjs";var s=0;function c(e){return s+=1,`${e}_${typeof crypto<`u`&&typeof crypto.randomUUID==`function`?crypto.randomUUID().slice(0,8):Math.random().toString(36).slice(2,10)}${s.toString(36)}`}function l(e){return{...e,nodes:{...e.nodes},walls:{...e.walls},openings:{...e.openings},rooms:{...e.rooms}}}function u(e,t,n={}){return{kind:e,message:t,...n}}function d(e,t){let n=e.nodes[t.a],i=e.nodes[t.b];return!n||!i?0:r(n,i)}function f(e,t){return Object.values(e.walls).filter(e=>e.a===t||e.b===t)}function p(e,t,n){for(let r of Object.values(e.walls))if(r.a===t&&r.b===n||r.a===n&&r.b===t)return r;return null}var m=250;function h(e,t){return`${Math.round(e/m)}|${Math.round(t/m)}`}function g(e){let t=new Map;for(let n of Object.values(e.nodes)){let e=h(n.x,n.y),r=t.get(e);r?r.push(n):t.set(e,[n])}return t}function _(e,t,n){let i=g(e),a=Math.round(t.x/m),o=Math.round(t.y/m),s=null,c=n,l=Math.max(1,Math.ceil(n/m));for(let e=-l;e<=l;e++)for(let n=-l;n<=l;n++)for(let l of i.get(`${a+e}|${o+n}`)??[]){let e=r(t,l);e<=c&&(s=l,c=e)}return s}function v(e,t,n,r){let a=null;for(let o of Object.values(e.walls)){if(r?.has(o.id))continue;let s=e.nodes[o.a],c=e.nodes[o.b];if(!s||!c)continue;let l=i(t,s,c);l.dist<=n&&(!a||l.dist<a.dist)&&(a={wall:o,t:l.t,point:l.point,dist:l.dist})}return a}function y(t,n,i){let o=t.walls[n];if(!o)return{floor:t,result:{nodeId:``,wallIds:null,droppedOpeningIds:[]},issues:[]};let s=t.nodes[o.a],d=t.nodes[o.b];if(!s||!d)return{floor:t,result:{nodeId:``,wallIds:null,droppedOpeningIds:[]},issues:[]};let f=r(s,d),p=a(typeof i==`number`?{x:s.x+(d.x-s.x)*i,y:s.y+(d.y-s.y)*i}:i,s,d),m=r(s,p);if(m<10)return{floor:t,result:{nodeId:o.a,wallIds:null,droppedOpeningIds:[]},issues:[]};if(f-m<10)return{floor:t,result:{nodeId:o.b,wallIds:null,droppedOpeningIds:[]},issues:[]};let h=l(t),g=c(`n`);h.nodes[g]={id:g,x:p.x,y:p.y};let _=c(`w`),v=c(`w`),y={thickness:o.thickness,type:o.type,height:o.height,baseHeight:o.baseHeight};h.walls[_]={...y,id:_,a:o.a,b:g},h.walls[v]={...y,id:v,a:g,b:o.b},delete h.walls[n];let b=[],x=[];for(let r of Object.values(t.openings)){if(r.wallId!==n)continue;let t=r.offset<=m,i=t?_:v,a=t?m:f-m,o=t?r.offset:r.offset-m,s=Math.min(r.width,a-2,o*2,(a-o)*2);if(s<100){delete h.openings[r.id],b.push(r.id),x.push(u(e.OpeningDropped,`An opening was removed: the wall it sat on became too short for it.`,{openingIds:[r.id],wallIds:[n]}));continue}h.openings[r.id]={...r,wallId:i,offset:o,width:s}}return{floor:h,result:{nodeId:g,wallIds:[_,v],droppedOpeningIds:b},issues:x}}function b(e,t,n){let r=_(e,t,n);if(r)return{floor:e,result:{nodeId:r.id,createdNode:!1,splitWallId:null},issues:[]};let i=v(e,t,n);if(i){let t=y(e,i.wall.id,i.point);if(t.result.nodeId)return{floor:t.floor,result:{nodeId:t.result.nodeId,createdNode:t.result.wallIds!==null,splitWallId:t.result.wallIds?i.wall.id:null},issues:t.issues}}let a=l(e),o=c(`n`);return a.nodes[o]={id:o,x:t.x,y:t.y},{floor:a,result:{nodeId:o,createdNode:!0,splitWallId:null},issues:[]}}var x={thickness:200,type:t.Interior};function S(t,n,a,s=x,l=1){let d=[];if(r(n,a)<10)return{floor:t,result:{wallIds:[],nodeIds:[]},issues:d};let f=b(t,n,l),m=f.floor;d.push(...f.issues);let h=b(m,a,l);m=h.floor,d.push(...h.issues);let g=f.result.nodeId,_=h.result.nodeId;if(g===_)return{floor:t,result:{wallIds:[],nodeIds:[]},issues:d};let v=m.nodes[g],S=m.nodes[_],C=r(v,S);if(C<10)return{floor:t,result:{wallIds:[],nodeIds:[]},issues:d};let w=[{t:0,nodeId:g},{t:1,nodeId:_}],T=new Map,E=[],D=e=>i(e,v,S).t;for(let e of Object.values(m.walls)){let t=m.nodes[e.a],n=m.nodes[e.b];if(!t||!n)continue;let r=o(v,S,t,n,l);if(r.kind===`collinearOverlap`){let e=D(t),r=D(n);E.push([Math.min(e,r),Math.max(e,r)]);for(let[i,a]of[[t,e],[n,r]])a*C>10&&(1-a)*C>10&&w.push({t:a,nodeId:i.id});continue}if(r.kind===`proper`&&r.t!==void 0&&r.s!==void 0){let t=T.get(e.id);t?t.push(r.s):T.set(e.id,[r.s]),w.push({t:r.t,nodeId:null,pending:{wallId:e.id,s:r.s}});continue}if(r.kind===`touch`&&r.t!==void 0&&r.s!==void 0){let e=r.s<=.5?t:n,a=D(e);i(e,v,S).dist<=l&&a*C>10&&(1-a)*C>10&&w.push({t:a,nodeId:e.id})}}for(let e of Object.values(m.nodes)){if(e.id===g||e.id===_)continue;let t=i(e,v,S);t.dist>l||t.t*C<=10||(1-t.t)*C<=10||w.push({t:t.t,nodeId:e.id})}for(let[e,n]of T){let r=[...n].sort((e,t)=>t-e),i=e,a=new Set(r);for(let n of r){let r=m.walls[i];if(!r)break;let o=m.nodes[r.a],s=m.nodes[r.b];if(!o||!s)break;let c=t.walls[e],l=m.nodes[c?.a??r.a]??o,u=m.nodes[c?.b??r.b]??s,f={x:l.x+(u.x-l.x)*n,y:l.y+(u.y-l.y)*n},p=y(m,i,f);m=p.floor,d.push(...p.issues),a.delete(n);for(let t of w)t.pending&&t.pending.wallId===e&&t.pending.s===n&&(t.nodeId=p.result.nodeId,t.pending=void 0);if(p.result.wallIds){let[e,t]=p.result.wallIds,r=Math.max(...a,-1);i=r>=0&&r>n?t:e}}}let O=w.filter(e=>!!e.nodeId);O.sort((e,t)=>e.t-t.t);let k=[];for(let e of O){let t=k[k.length-1];if(!t){k.push(e);continue}if(t.nodeId!==e.nodeId){if((e.t-t.t)*C<10){e.t>=.999999999&&(k[k.length-1]=e);continue}k.push(e)}}let A=[];for(let e=0;e+1<k.length;e++){let t=k[e],n=k[e+1],i=(t.t+n.t)/2;if(E.some(([e,t])=>i>e+1e-9&&i<t-1e-9)||p(m,t.nodeId,n.nodeId))continue;let a=m.nodes[t.nodeId],o=m.nodes[n.nodeId];if(!a||!o||r(a,o)<10)continue;let l=c(`w`);m={...m,walls:{...m.walls,[l]:{...s,id:l,a:t.nodeId,b:n.nodeId}}},A.push(l)}return A.length===0&&E.length>0&&d.push(u(e.DuplicateWall,`A wall already covers that span, so nothing was added.`)),{floor:m,result:{wallIds:A,nodeIds:k.map(e=>e.nodeId)},issues:d}}function C(e,t,n,r=x,i=1){let a=Math.min(t.x,n.x),o=Math.max(t.x,n.x),s=Math.min(t.y,n.y),c=Math.max(t.y,n.y);if(o-a<10||c-s<10)return{floor:e,result:{wallIds:[]},issues:[]};let l=[{x:a,y:s},{x:o,y:s},{x:o,y:c},{x:a,y:c}],u=e,d=[],f=[];for(let e=0;e<4;e++){let t=S(u,l[e],l[(e+1)%4],r,i);u=t.floor,d.push(...t.result.wallIds),f.push(...t.issues)}return{floor:u,result:{wallIds:d},issues:f}}function w(t,n,r){if(n===r||!t.nodes[n]||!t.nodes[r])return{floor:t,result:{removedWallIds:[]},issues:[]};let i=l(t),a=[],o=[];for(let e of Object.values(i.walls))e.a===r&&(i.walls[e.id]={...i.walls[e.id],a:n}),e.b===r&&(i.walls[e.id]={...i.walls[e.id],b:n});for(let e of Object.values(i.walls))if(e.a===e.b){delete i.walls[e.id],a.push(e.id);for(let t of Object.values(i.openings))t.wallId===e.id&&delete i.openings[t.id]}let s=new Map;for(let t of Object.values(i.walls)){let n=t.a<t.b?`${t.a}|${t.b}`:`${t.b}|${t.a}`,r=s.get(n);if(!r){s.set(n,t);continue}let c=t.thickness>=r.thickness?t:r,l=c===t?r:t;for(let e of Object.values(i.openings))e.wallId===l.id&&(i.openings[e.id]={...e,wallId:c.id});delete i.walls[l.id],a.push(l.id),s.set(n,c),o.push(u(e.DuplicateWall,`Two walls became the same wall and were merged.`,{wallIds:[c.id,l.id]}))}return delete i.nodes[r],{floor:i,result:{removedWallIds:a},issues:o}}function T(t,i,a={}){let{force:o=!1}=a,s=t.nodes[i];if(!s)return{floor:t,result:null,issues:[]};let p=f(t,i);if(p.length!==2)return{floor:t,result:null,issues:[]};let[m,h]=p,g=m.thickness===h.thickness&&m.type===h.type&&m.height===h.height&&m.baseHeight===h.baseHeight;if(!g&&!o)return{floor:t,result:null,issues:[]};let _=m.a===i?m.b:m.a,v=h.a===i?h.b:h.a;if(_===v)return{floor:t,result:null,issues:[]};let y=t.nodes[_],b=t.nodes[v];if(!y||!b)return{floor:t,result:null,issues:[]};let x=r(y,b);if(x<10||!o&&Math.abs(n(y,b,s))/x>Math.max(1,x*1e-4))return{floor:t,result:null,issues:[]};let S=!g&&m.thickness<h.thickness?h:m,C=l(t),w=c(`w`);C.walls[w]={id:w,a:_,b:v,thickness:S.thickness,type:S.type,height:S.height,baseHeight:S.baseHeight};let T=r(y,s),E=r(s,b),D=T+E>0?x/(T+E):1,O=[];for(let n of Object.values(t.openings)){if(n.wallId!==m.id&&n.wallId!==h.id)continue;let r=n.wallId===m.id,a=r?m:h,o=a.a===i,s=d(t,a),c=o?n.offset:s-n.offset,l=(r?T-c:T+c)*D,f=Math.min(n.width*D,x-2);if(f<100){O.push(u(e.OpeningDropped,`An opening was removed: the wall it sat on became too short for it.`,{openingIds:[n.id],wallIds:[a.id]})),delete C.openings[n.id];continue}C.openings[n.id]={...n,wallId:w,width:f,offset:Math.min(Math.max(l,f/2),x-f/2)}}return delete C.walls[m.id],delete C.walls[h.id],delete C.nodes[i],{floor:C,result:{wallId:w},issues:O}}function E(e,t,n={}){let{pruneOrphanNodes:r=!0,dissolveCollinear:i=!0}=n,a=e.walls[t];if(!a)return{floor:e,result:{removedNodeIds:[],dissolvedWallIds:[]},issues:[]};let o=l(e);delete o.walls[t];for(let e of Object.values(o.openings))e.wallId===t&&delete o.openings[e.id];let s=[],c=[],u=[];for(let e of[a.a,a.b]){let t=f(o,e).length;if(t===0&&r)delete o.nodes[e],s.push(e);else if(t===2&&i){let t=T(o,e);t.result&&(o=t.floor,c.push(t.result.wallId),s.push(e),u.push(...t.issues))}}return{floor:o,result:{removedNodeIds:s,dissolvedWallIds:c},issues:u}}function D(e,t,n){let r=e.nodes[t];if(!r)return{floor:e,result:void 0,issues:[]};let i=l(e);return i.nodes[t]={...r,x:n.x,y:n.y},{floor:i,result:void 0,issues:[]}}function O(e,t,n){let r=l(e);for(let i of Object.values(e.nodes))r.nodes[i.id]={...i,x:i.x+t,y:i.y+n};for(let i of Object.values(e.rooms))r.rooms[i.id]={...i,anchor:{x:i.anchor.x+t,y:i.anchor.y+n}};return r}function k(e,t){let n=l(e);for(let r of Object.values(e.nodes))n.nodes[r.id]={...r,x:r.x*t,y:r.y*t};for(let r of Object.values(e.walls))n.walls[r.id]={...r,thickness:r.thickness*t,height:r.height===void 0?void 0:r.height*t,baseHeight:r.baseHeight===void 0?void 0:r.baseHeight*t};for(let r of Object.values(e.openings))n.openings[r.id]={...r,offset:r.offset*t,width:r.width*t,sill:r.sill*t,head:r.head*t};for(let r of Object.values(e.rooms))n.rooms[r.id]={...r,anchor:{x:r.anchor.x*t,y:r.anchor.y*t},area:r.area*t*t};return n}function A(t,n){let r=t.walls[n.wallId];if(!r)return{floor:t,result:{openingId:null},issues:[]};let i=d(t,r),a=Math.min(n.width,i-2);if(a<100)return{floor:t,result:{openingId:null},issues:[u(e.OpeningDropped,`That wall is too short for an opening.`,{wallIds:[r.id]})]};let o=Math.min(Math.max(n.offset,a/2),i-a/2),s=l(t),f=c(`o`);return s.openings[f]={...n,id:f,offset:o,width:a},{floor:s,result:{openingId:f},issues:[]}}function j(e,t,n){let r=e.openings[t];if(!r)return{floor:e,result:void 0,issues:[]};let i=e.walls[r.wallId];if(!i)return{floor:e,result:void 0,issues:[]};let a=d(e,i),o={...r,...n},s=Math.min(Math.max(o.width,100),Math.max(a-2,100)),c=Math.min(Math.max(o.offset,s/2),Math.max(a-s/2,s/2)),u=l(e);return u.openings[t]={...o,width:s,offset:c},{floor:u,result:void 0,issues:[]}}function M(e,t){if(!e.openings[t])return{floor:e,result:void 0,issues:[]};let n=l(e);return delete n.openings[t],{floor:n,result:void 0,issues:[]}}function N(t){let n=[],a=Object.values(t.walls);for(let i of a){let a=t.nodes[i.a],o=t.nodes[i.b];if(i.a===i.b){n.push(u(e.SelfLoopWall,`A wall starts and ends at the same node.`,{wallIds:[i.id]}));continue}if(!a||!o){n.push(u(e.ZeroLengthWall,`A wall references a missing node.`,{wallIds:[i.id]}));continue}r(a,o)<10&&n.push(u(e.ZeroLengthWall,`A wall is shorter than the minimum length.`,{wallIds:[i.id]}))}let s=new Map;for(let t of a){let r=t.a<t.b?`${t.a}|${t.b}`:`${t.b}|${t.a}`,i=s.get(r);i?n.push(u(e.DuplicateWall,`Two walls connect the same pair of nodes.`,{wallIds:[i,t.id]})):s.set(r,t.id)}let c=Object.values(t.nodes);for(let t=0;t<c.length;t++)for(let i=t+1;i<c.length;i++)r(c[t],c[i])<1&&n.push(u(e.CoincidentNodes,`Two nodes occupy the same position.`,{nodeIds:[c[t].id,c[i].id]}));for(let r=0;r<a.length;r++)for(let i=r+1;i<a.length;i++){let s=a[r],c=a[i],l=t.nodes[s.a],d=t.nodes[s.b],f=t.nodes[c.a],p=t.nodes[c.b];if(!l||!d||!f||!p)continue;let m=o(l,d,f,p);m.kind===`proper`?n.push(u(e.UnsplitCrossing,`Two walls cross with no node at the intersection.`,{wallIds:[s.id,c.id]})):m.kind===`collinearOverlap`&&n.push(u(e.OverlappingWalls,`Two walls overlap along the same line.`,{wallIds:[s.id,c.id]}))}for(let r of a){let a=t.nodes[r.a],o=t.nodes[r.b];if(!(!a||!o))for(let t of c){if(t.id===r.a||t.id===r.b)continue;let s=i(t,a,o);s.dist<1&&s.t>0&&s.t<1&&n.push(u(e.UnsplitNodeOnWall,`A node sits on a wall that was never split there.`,{wallIds:[r.id],nodeIds:[t.id]}))}}for(let r of Object.values(t.openings))t.walls[r.wallId]||n.push(u(e.DanglingOpening,`An opening references a wall that no longer exists.`,{openingIds:[r.id]}));let l=new Set;for(let e of a)l.add(e.a),l.add(e.b);for(let t of c)l.has(t.id)||n.push(u(e.OrphanNode,`A node has no walls.`,{nodeIds:[t.id]}));return n}function P(t){let n=l(t),i=[];for(let t of Object.values(n.walls)){let a=n.nodes[t.a],o=n.nodes[t.b];(t.a===t.b||!a||!o||r(a,o)<10)&&(delete n.walls[t.id],i.push(u(e.ZeroLengthWall,`Removed a degenerate wall.`,{wallIds:[t.id]})))}let a=new Map;for(let t of Object.values(n.walls)){let r=t.a<t.b?`${t.a}|${t.b}`:`${t.b}|${t.a}`,o=a.get(r);if(!o){a.set(r,t);continue}let s=t.thickness>=o.thickness?t:o,c=s===t?o:t;for(let e of Object.values(n.openings))e.wallId===c.id&&(n.openings[e.id]={...e,wallId:s.id});delete n.walls[c.id],a.set(r,s),i.push(u(e.DuplicateWall,`Removed a duplicate wall.`,{wallIds:[c.id]}))}for(let t of Object.values(n.openings))n.walls[t.wallId]||(delete n.openings[t.id],i.push(u(e.DanglingOpening,`Removed an opening with no wall.`,{openingIds:[t.id]})));let o=new Set;for(let e of Object.values(n.walls))o.add(e.a),o.add(e.b);for(let t of Object.values(n.nodes))o.has(t.id)||(delete n.nodes[t.id],i.push(u(e.OrphanNode,`Removed a node with no walls.`,{nodeIds:[t.id]})));return{floor:n,result:i,issues:i}}export{x as DEFAULT_WALL_ATTRS,A as addOpening,C as addRect,S as addWall,P as cleanupPlan,T as dissolveNode,w as mergeNodes,D as moveNode,c as newId,M as removeOpening,E as removeWall,k as scalePlan,y as splitWall,O as transformPlan,j as updateOpening,N as validatePlan,d as wallLength};
2
+ //# sourceMappingURL=floorPlan.graph.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"floorPlan.graph.mjs","names":[],"sources":["../../../../package/common/floorPlan/floorPlan.graph.ts"],"sourcesContent":["/**\n * Graph maintenance: the operations that keep the wall graph a valid planar embedding\n * while the user draws.\n *\n * The invariant every function here protects: no two walls may cross without a node at\n * the intersection, and no node may lie on a wall's interior without that wall being\n * split there. Break either and the face walk silently merges two rooms into one\n * figure-eight face, with no error anywhere.\n *\n * Every operation is pure and returns a new floor, for direct use as React state.\n */\n\nimport {\n EPS_ANGLE,\n EPS_COINCIDENT,\n MIN_OPENING_WIDTH,\n MIN_WALL_LENGTH,\n cross,\n dist,\n pointToSegment,\n projectOnSegment,\n segmentIntersection,\n} from './floorPlan.geometry';\nimport { PlanIssueKind, WallType } from './floorPlan.types';\nimport type {\n PlanEdit,\n PlanFloor,\n PlanIssue,\n PlanNode,\n PlanOpening,\n PlanPoint,\n PlanWall,\n} from './floorPlan.types';\n\n// ─── Ids ───────────────────────────────────────────────────────────────────────\n\nlet idSeq = 0;\n\n/**\n * Ids are deliberately non-numeric strings, so `Record` iteration keeps insertion\n * order (JavaScript reorders integer-like keys), which is what makes derivation\n * reproducible.\n */\nexport function newId(prefix: string): string {\n idSeq += 1;\n const rand =\n typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'\n ? crypto.randomUUID().slice(0, 8)\n : Math.random().toString(36).slice(2, 10);\n return `${prefix}_${rand}${idSeq.toString(36)}`;\n}\n\n/** Test-only: makes generated ids reproducible within a single test. */\nexport function __resetIdSeq(): void {\n idSeq = 0;\n}\n\n// ─── Internals ─────────────────────────────────────────────────────────────────\n\nfunction cloneFloor(floor: PlanFloor): PlanFloor {\n return {\n ...floor,\n nodes: { ...floor.nodes },\n walls: { ...floor.walls },\n openings: { ...floor.openings },\n rooms: { ...floor.rooms },\n };\n}\n\nfunction issue(kind: PlanIssueKind, message: string, extra: Partial<PlanIssue> = {}): PlanIssue {\n return { kind, message, ...extra };\n}\n\nfunction wallLength(floor: PlanFloor, wall: PlanWall): number {\n const a = floor.nodes[wall.a];\n const b = floor.nodes[wall.b];\n if (!a || !b) return 0;\n return dist(a, b);\n}\n\nfunction wallsAtNode(floor: PlanFloor, nodeId: string): PlanWall[] {\n return Object.values(floor.walls).filter((w) => w.a === nodeId || w.b === nodeId);\n}\n\nfunction findWallBetween(floor: PlanFloor, a: string, b: string): PlanWall | null {\n for (const w of Object.values(floor.walls)) {\n if ((w.a === a && w.b === b) || (w.a === b && w.b === a)) return w;\n }\n return null;\n}\n\n// ─── Node spatial hash ─────────────────────────────────────────────────────────\n\n/**\n * Bucket size for the node hash, in millimetres. Any lookup checks the 3x3\n * neighbourhood, so this only has to exceed the largest snap tolerance in world units.\n */\nconst HASH_CELL = 250;\n\nfunction hashKey(x: number, y: number): string {\n return `${Math.round(x / HASH_CELL)}|${Math.round(y / HASH_CELL)}`;\n}\n\nfunction buildNodeHash(floor: PlanFloor): Map<string, PlanNode[]> {\n const map = new Map<string, PlanNode[]>();\n for (const node of Object.values(floor.nodes)) {\n const key = hashKey(node.x, node.y);\n const list = map.get(key);\n if (list) list.push(node);\n else map.set(key, [node]);\n }\n return map;\n}\n\n/** Nearest existing node within `tol`, using the spatial hash. */\nexport function findNodeNear(floor: PlanFloor, p: PlanPoint, tol: number): PlanNode | null {\n const hash = buildNodeHash(floor);\n const cx = Math.round(p.x / HASH_CELL);\n const cy = Math.round(p.y / HASH_CELL);\n let best: PlanNode | null = null;\n let bestDist = tol;\n const span = Math.max(1, Math.ceil(tol / HASH_CELL));\n for (let dx = -span; dx <= span; dx++) {\n for (let dy = -span; dy <= span; dy++) {\n for (const node of hash.get(`${cx + dx}|${cy + dy}`) ?? []) {\n const d = dist(p, node);\n if (d <= bestDist) {\n best = node;\n bestDist = d;\n }\n }\n }\n }\n return best;\n}\n\n/** Nearest wall whose centreline passes within `tol` of `p`. */\nexport function findWallNear(\n floor: PlanFloor,\n p: PlanPoint,\n tol: number,\n exclude?: ReadonlySet<string>,\n): { wall: PlanWall; t: number; point: PlanPoint; dist: number } | null {\n let best: { wall: PlanWall; t: number; point: PlanPoint; dist: number } | null = null;\n for (const wall of Object.values(floor.walls)) {\n if (exclude?.has(wall.id)) continue;\n const a = floor.nodes[wall.a];\n const b = floor.nodes[wall.b];\n if (!a || !b) continue;\n const r = pointToSegment(p, a, b);\n if (r.dist <= tol && (!best || r.dist < best.dist)) {\n best = { wall, t: r.t, point: r.point, dist: r.dist };\n }\n }\n return best;\n}\n\n// ─── splitWall ─────────────────────────────────────────────────────────────────\n\nexport interface SplitWallResult {\n nodeId: string;\n wallIds: [string, string] | null;\n droppedOpeningIds: string[];\n}\n\n/**\n * Splits a wall at a point or parameter, returning the node at the cut.\n *\n * Refuses to cut within `MIN_WALL_LENGTH` of either end, returning that endpoint's\n * existing node instead. That single rule is what keeps zero-length walls out of the\n * graph entirely, which in turn is what stops the angular sort from being fed a\n * direction-less dart.\n */\nexport function splitWall(\n floor: PlanFloor,\n wallId: string,\n at: PlanPoint | number,\n): PlanEdit<SplitWallResult> {\n const wall = floor.walls[wallId];\n if (!wall) {\n return { floor, result: { nodeId: '', wallIds: null, droppedOpeningIds: [] }, issues: [] };\n }\n const a = floor.nodes[wall.a];\n const b = floor.nodes[wall.b];\n if (!a || !b) {\n return { floor, result: { nodeId: '', wallIds: null, droppedOpeningIds: [] }, issues: [] };\n }\n\n const len = dist(a, b);\n const point = typeof at === 'number' ? { x: a.x + (b.x - a.x) * at, y: a.y + (b.y - a.y) * at } : at;\n // Project exactly, so the two children stay perfectly collinear and dissolveNode can\n // undo the split cleanly later.\n const exact = projectOnSegment(point, a, b);\n const fromA = dist(a, exact);\n\n if (fromA < MIN_WALL_LENGTH) {\n return { floor, result: { nodeId: wall.a, wallIds: null, droppedOpeningIds: [] }, issues: [] };\n }\n if (len - fromA < MIN_WALL_LENGTH) {\n return { floor, result: { nodeId: wall.b, wallIds: null, droppedOpeningIds: [] }, issues: [] };\n }\n\n const next = cloneFloor(floor);\n const nodeId = newId('n');\n next.nodes[nodeId] = { id: nodeId, x: exact.x, y: exact.y };\n\n const firstId = newId('w');\n const secondId = newId('w');\n const base = { thickness: wall.thickness, type: wall.type, height: wall.height, baseHeight: wall.baseHeight };\n next.walls[firstId] = { ...base, id: firstId, a: wall.a, b: nodeId };\n next.walls[secondId] = { ...base, id: secondId, a: nodeId, b: wall.b };\n delete next.walls[wallId];\n\n // Reassign openings by the side their CENTRE falls on. An opening straddling the cut\n // is a genuine conflict: move it wholly into that child and clamp it, or drop it and\n // say so. Silently deleting a window is not acceptable.\n const droppedOpeningIds: string[] = [];\n const issues: PlanIssue[] = [];\n for (const opening of Object.values(floor.openings)) {\n if (opening.wallId !== wallId) continue;\n const intoFirst = opening.offset <= fromA;\n const childId = intoFirst ? firstId : secondId;\n const childLen = intoFirst ? fromA : len - fromA;\n const offset = intoFirst ? opening.offset : opening.offset - fromA;\n\n const maxWidth = Math.min(opening.width, childLen - 2, offset * 2, (childLen - offset) * 2);\n if (maxWidth < MIN_OPENING_WIDTH) {\n delete next.openings[opening.id];\n droppedOpeningIds.push(opening.id);\n issues.push(\n issue(\n PlanIssueKind.OpeningDropped,\n 'An opening was removed: the wall it sat on became too short for it.',\n { openingIds: [opening.id], wallIds: [wallId] },\n ),\n );\n continue;\n }\n next.openings[opening.id] = { ...opening, wallId: childId, offset, width: maxWidth };\n }\n\n return { floor: next, result: { nodeId, wallIds: [firstId, secondId], droppedOpeningIds }, issues };\n}\n\n// ─── resolveNodeAt ─────────────────────────────────────────────────────────────\n\nexport interface ResolveNodeResult {\n nodeId: string;\n createdNode: boolean;\n splitWallId: string | null;\n}\n\n/**\n * Returns the node to use at `p`: an existing one within `tol`, a new one created by\n * splitting a wall that passes under `p`, or a brand new free-standing node.\n */\nexport function resolveNodeAt(\n floor: PlanFloor,\n p: PlanPoint,\n tol: number,\n): PlanEdit<ResolveNodeResult> {\n const existing = findNodeNear(floor, p, tol);\n if (existing) {\n return { floor, result: { nodeId: existing.id, createdNode: false, splitWallId: null }, issues: [] };\n }\n\n const onWall = findWallNear(floor, p, tol);\n if (onWall) {\n const split = splitWall(floor, onWall.wall.id, onWall.point);\n if (split.result.nodeId) {\n return {\n floor: split.floor,\n result: {\n nodeId: split.result.nodeId,\n createdNode: split.result.wallIds !== null,\n splitWallId: split.result.wallIds ? onWall.wall.id : null,\n },\n issues: split.issues,\n };\n }\n }\n\n const next = cloneFloor(floor);\n const nodeId = newId('n');\n next.nodes[nodeId] = { id: nodeId, x: p.x, y: p.y };\n return { floor: next, result: { nodeId, createdNode: true, splitWallId: null }, issues: [] };\n}\n\n// ─── addWall ───────────────────────────────────────────────────────────────────\n\nexport type WallAttrs = Pick<PlanWall, 'thickness' | 'type'> &\n Partial<Pick<PlanWall, 'height' | 'baseHeight'>>;\n\nexport const DEFAULT_WALL_ATTRS: WallAttrs = { thickness: 200, type: WallType.Interior };\n\ninterface Cut {\n t: number;\n nodeId: string | null;\n /** Set while the node does not exist yet because its wall has not been split. */\n pending?: { wallId: string; s: number };\n}\n\n/**\n * Adds a wall from `a` to `b`, splitting anything it crosses so the graph stays planar.\n *\n * Collect-then-apply, in two phases: the naive version splits walls while iterating\n * over walls, which both invalidates the iteration and reparameterises the very\n * segments it is about to test.\n */\nexport function addWall(\n floor: PlanFloor,\n a: PlanPoint,\n b: PlanPoint,\n attrs: WallAttrs = DEFAULT_WALL_ATTRS,\n tol: number = EPS_COINCIDENT,\n): PlanEdit<{ wallIds: string[]; nodeIds: string[] }> {\n const issues: PlanIssue[] = [];\n if (dist(a, b) < MIN_WALL_LENGTH) {\n return { floor, result: { wallIds: [], nodeIds: [] }, issues };\n }\n\n // 1. Endpoints. Each may reuse a node, split a wall, or create a node.\n const startEdit = resolveNodeAt(floor, a, tol);\n let work = startEdit.floor;\n issues.push(...startEdit.issues);\n const endEdit = resolveNodeAt(work, b, tol);\n work = endEdit.floor;\n issues.push(...endEdit.issues);\n\n const na = startEdit.result.nodeId;\n const nb = endEdit.result.nodeId;\n if (na === nb) return { floor, result: { wallIds: [], nodeIds: [] }, issues };\n\n const A = work.nodes[na];\n const B = work.nodes[nb];\n const len = dist(A, B);\n if (len < MIN_WALL_LENGTH) return { floor, result: { wallIds: [], nodeIds: [] }, issues };\n\n // 2. COLLECT. Nothing is mutated in this phase.\n const cuts: Cut[] = [\n { t: 0, nodeId: na },\n { t: 1, nodeId: nb },\n ];\n const splits = new Map<string, number[]>();\n const overlaps: Array<[number, number]> = [];\n\n const paramAlong = (p: PlanPoint): number => pointToSegment(p, A, B).t;\n\n for (const wall of Object.values(work.walls)) {\n const wa = work.nodes[wall.a];\n const wb = work.nodes[wall.b];\n if (!wa || !wb) continue;\n\n const hit = segmentIntersection(A, B, wa, wb, tol);\n\n if (hit.kind === 'collinearOverlap') {\n const t0 = paramAlong(wa);\n const t1 = paramAlong(wb);\n overlaps.push([Math.min(t0, t1), Math.max(t0, t1)]);\n // Endpoints of the overlapping wall that fall strictly inside AB still need cuts,\n // or the new wall would run straight past them.\n for (const [node, t] of [\n [wa, t0],\n [wb, t1],\n ] as const) {\n if (t * len > MIN_WALL_LENGTH && (1 - t) * len > MIN_WALL_LENGTH) {\n cuts.push({ t, nodeId: node.id });\n }\n }\n continue;\n }\n\n if (hit.kind === 'proper' && hit.t !== undefined && hit.s !== undefined) {\n const list = splits.get(wall.id);\n if (list) list.push(hit.s);\n else splits.set(wall.id, [hit.s]);\n cuts.push({ t: hit.t, nodeId: null, pending: { wallId: wall.id, s: hit.s } });\n continue;\n }\n\n if (hit.kind === 'touch' && hit.t !== undefined && hit.s !== undefined) {\n // A T junction where one of the wall's own endpoints sits on AB: reuse it.\n const atWallStart = hit.s <= 0.5;\n const endpoint = atWallStart ? wa : wb;\n const tOnAb = paramAlong(endpoint);\n if (\n pointToSegment(endpoint, A, B).dist <= tol &&\n tOnAb * len > MIN_WALL_LENGTH &&\n (1 - tOnAb) * len > MIN_WALL_LENGTH\n ) {\n cuts.push({ t: tOnAb, nodeId: endpoint.id });\n }\n }\n }\n\n // 3. Nodes lying on AB that no crossing test found. NOT redundant with step 2: a node\n // whose incident walls all run away from AB is invisible to every crossing test, yet\n // failing to cut AB there leaves a node on a wall interior, which silently merges the\n // two rooms either side of it.\n for (const node of Object.values(work.nodes)) {\n if (node.id === na || node.id === nb) continue;\n const r = pointToSegment(node, A, B);\n if (r.dist > tol) continue;\n if (r.t * len <= MIN_WALL_LENGTH || (1 - r.t) * len <= MIN_WALL_LENGTH) continue;\n cuts.push({ t: r.t, nodeId: node.id });\n }\n\n // 4. APPLY splits, per wall, in DESCENDING parameter order so that earlier\n // parameters stay valid as later ones are consumed.\n for (const [wallId, params] of splits) {\n const sorted = [...params].sort((x, y) => y - x);\n let currentWallId = wallId;\n const remaining = new Set(sorted);\n for (const s of sorted) {\n const target = work.walls[currentWallId];\n if (!target) break;\n const wa = work.nodes[target.a];\n const wb = work.nodes[target.b];\n if (!wa || !wb) break;\n // Re-project onto the wall as it stands now, rather than reusing the original\n // parameter, which the previous split may have reparameterised.\n const original = floor.walls[wallId];\n const oa = work.nodes[original?.a ?? target.a] ?? wa;\n const ob = work.nodes[original?.b ?? target.b] ?? wb;\n const worldPoint = { x: oa.x + (ob.x - oa.x) * s, y: oa.y + (ob.y - oa.y) * s };\n const edit = splitWall(work, currentWallId, worldPoint);\n work = edit.floor;\n issues.push(...edit.issues);\n remaining.delete(s);\n for (const cut of cuts) {\n if (cut.pending && cut.pending.wallId === wallId && cut.pending.s === s) {\n cut.nodeId = edit.result.nodeId;\n cut.pending = undefined;\n }\n }\n // Continue splitting whichever child still holds the remaining parameters.\n if (edit.result.wallIds) {\n const [first, second] = edit.result.wallIds;\n const nextS = Math.max(...[...remaining], -1);\n currentWallId = nextS >= 0 && nextS > s ? second : first;\n }\n }\n }\n\n // 5. Chain the cuts into walls.\n const resolved = cuts.filter((c): c is Cut & { nodeId: string } => Boolean(c.nodeId));\n resolved.sort((x, y) => x.t - y.t);\n\n const chain: Array<Cut & { nodeId: string }> = [];\n for (const cut of resolved) {\n const last = chain[chain.length - 1];\n if (!last) {\n chain.push(cut);\n continue;\n }\n if (last.nodeId === cut.nodeId) continue;\n if ((cut.t - last.t) * len < MIN_WALL_LENGTH) {\n // Too close to be a separate wall. Prefer whichever end is an endpoint of AB.\n if (cut.t >= 1 - 1e-9) chain[chain.length - 1] = cut;\n continue;\n }\n chain.push(cut);\n }\n\n const createdWallIds: string[] = [];\n for (let i = 0; i + 1 < chain.length; i++) {\n const from = chain[i];\n const to = chain[i + 1];\n const midT = (from.t + to.t) / 2;\n if (overlaps.some(([lo, hi]) => midT > lo + 1e-9 && midT < hi - 1e-9)) {\n // Already walled. Do not create a duplicate, and do not rewrite the existing\n // wall's attributes from a stroke that was aimed elsewhere.\n continue;\n }\n if (findWallBetween(work, from.nodeId, to.nodeId)) continue;\n const wa = work.nodes[from.nodeId];\n const wb = work.nodes[to.nodeId];\n if (!wa || !wb || dist(wa, wb) < MIN_WALL_LENGTH) continue;\n\n const id = newId('w');\n work = { ...work, walls: { ...work.walls, [id]: { ...attrs, id, a: from.nodeId, b: to.nodeId } } };\n createdWallIds.push(id);\n }\n\n if (createdWallIds.length === 0 && overlaps.length > 0) {\n issues.push(\n issue(PlanIssueKind.DuplicateWall, 'A wall already covers that span, so nothing was added.'),\n );\n }\n\n return {\n floor: work,\n result: { wallIds: createdWallIds, nodeIds: chain.map((c) => c.nodeId) },\n issues,\n };\n}\n\n/** Draws the four walls of an axis-aligned rectangle, welding onto anything already there. */\nexport function addRect(\n floor: PlanFloor,\n corner: PlanPoint,\n opposite: PlanPoint,\n attrs: WallAttrs = DEFAULT_WALL_ATTRS,\n tol: number = EPS_COINCIDENT,\n): PlanEdit<{ wallIds: string[] }> {\n const x0 = Math.min(corner.x, opposite.x);\n const x1 = Math.max(corner.x, opposite.x);\n const y0 = Math.min(corner.y, opposite.y);\n const y1 = Math.max(corner.y, opposite.y);\n if (x1 - x0 < MIN_WALL_LENGTH || y1 - y0 < MIN_WALL_LENGTH) {\n return { floor, result: { wallIds: [] }, issues: [] };\n }\n\n const corners: PlanPoint[] = [\n { x: x0, y: y0 },\n { x: x1, y: y0 },\n { x: x1, y: y1 },\n { x: x0, y: y1 },\n ];\n\n let work = floor;\n const wallIds: string[] = [];\n const issues: PlanIssue[] = [];\n for (let i = 0; i < 4; i++) {\n const edit = addWall(work, corners[i], corners[(i + 1) % 4], attrs, tol);\n work = edit.floor;\n wallIds.push(...edit.result.wallIds);\n issues.push(...edit.issues);\n }\n return { floor: work, result: { wallIds }, issues };\n}\n\n// ─── mergeNodes ────────────────────────────────────────────────────────────────\n\n/**\n * Merges `removeId` into `keepId`, as when a node is dragged onto another.\n *\n * Order matters, and step 3 is mandatory rather than cosmetic: a duplicate wall between\n * the same pair of nodes is exactly the identical-angle degeneracy that corrupts the\n * face walk's angular sort.\n */\nexport function mergeNodes(\n floor: PlanFloor,\n keepId: string,\n removeId: string,\n): PlanEdit<{ removedWallIds: string[] }> {\n if (keepId === removeId || !floor.nodes[keepId] || !floor.nodes[removeId]) {\n return { floor, result: { removedWallIds: [] }, issues: [] };\n }\n\n const next = cloneFloor(floor);\n const removedWallIds: string[] = [];\n const issues: PlanIssue[] = [];\n\n // 1. Rewire.\n for (const wall of Object.values(next.walls)) {\n if (wall.a === removeId) next.walls[wall.id] = { ...next.walls[wall.id], a: keepId };\n if (wall.b === removeId) next.walls[wall.id] = { ...next.walls[wall.id], b: keepId };\n }\n\n // 2. Drop self-loops, and their openings with them.\n for (const wall of Object.values(next.walls)) {\n if (wall.a !== wall.b) continue;\n delete next.walls[wall.id];\n removedWallIds.push(wall.id);\n for (const opening of Object.values(next.openings)) {\n if (opening.wallId === wall.id) delete next.openings[opening.id];\n }\n }\n\n // 3. Deduplicate walls that now share the same unordered node pair.\n const seen = new Map<string, PlanWall>();\n for (const wall of Object.values(next.walls)) {\n const key = wall.a < wall.b ? `${wall.a}|${wall.b}` : `${wall.b}|${wall.a}`;\n const prior = seen.get(key);\n if (!prior) {\n seen.set(key, wall);\n continue;\n }\n // Keep the thicker wall, and carry both walls' openings onto it.\n const keep = wall.thickness >= prior.thickness ? wall : prior;\n const drop = keep === wall ? prior : wall;\n for (const opening of Object.values(next.openings)) {\n if (opening.wallId === drop.id) {\n next.openings[opening.id] = { ...opening, wallId: keep.id };\n }\n }\n delete next.walls[drop.id];\n removedWallIds.push(drop.id);\n seen.set(key, keep);\n issues.push(\n issue(PlanIssueKind.DuplicateWall, 'Two walls became the same wall and were merged.', {\n wallIds: [keep.id, drop.id],\n }),\n );\n }\n\n // 4. Drop the now-unreferenced node.\n delete next.nodes[removeId];\n\n return { floor: next, result: { removedWallIds }, issues };\n}\n\n// ─── dissolveNode ──────────────────────────────────────────────────────────────\n\nexport interface DissolveNodeOptions {\n /**\n * Rejoin the two walls even when the corner is bent or the walls differ in\n * construction, taking the thicker wall's attributes and rescaling openings onto the\n * shorter merged wall.\n *\n * This is \"delete this corner\" as a user means it. The default, unforced behaviour is\n * the stricter one used by cleanup and by `removeWall`, where dissolving must not\n * change the drawing's geometry at all.\n */\n force?: boolean;\n}\n\n/**\n * Merges the two walls meeting at a degree-2 node back into one, the natural inverse of\n * `splitWall`.\n *\n * Returns `null` unless the node is genuinely a seam: degree exactly 2, both walls\n * identical in construction, and the turn within `EPS_ANGLE` of straight. Pass\n * `{ force: true }` to rejoin a bent corner as well. Never call it mid-drag: it destroys\n * node ids the user may be holding.\n */\nexport function dissolveNode(\n floor: PlanFloor,\n nodeId: string,\n options: DissolveNodeOptions = {},\n): PlanEdit<{ wallId: string } | null> {\n const { force = false } = options;\n const node = floor.nodes[nodeId];\n if (!node) return { floor, result: null, issues: [] };\n\n const incident = wallsAtNode(floor, nodeId);\n if (incident.length !== 2) return { floor, result: null, issues: [] };\n\n const [w1, w2] = incident;\n const sameConstruction =\n w1.thickness === w2.thickness &&\n w1.type === w2.type &&\n w1.height === w2.height &&\n w1.baseHeight === w2.baseHeight;\n if (!sameConstruction && !force) return { floor, result: null, issues: [] };\n\n const far1 = w1.a === nodeId ? w1.b : w1.a;\n const far2 = w2.a === nodeId ? w2.b : w2.a;\n if (far1 === far2) return { floor, result: null, issues: [] };\n\n const p1 = floor.nodes[far1];\n const p2 = floor.nodes[far2];\n if (!p1 || !p2) return { floor, result: null, issues: [] };\n\n const span = dist(p1, p2);\n if (span < MIN_WALL_LENGTH) return { floor, result: null, issues: [] };\n\n if (!force) {\n // Straightness by perpendicular offset rather than raw angle, so the tolerance stays\n // meaningful at any wall length.\n const offset = Math.abs(cross(p1, p2, node)) / span;\n if (offset > Math.max(EPS_COINCIDENT, span * EPS_ANGLE)) {\n return { floor, result: null, issues: [] };\n }\n }\n\n // Forced over a bent corner, the survivor is the thicker of the two, since that is the\n // one whose construction the rest of the plan is more likely to depend on.\n const keep = !sameConstruction && w1.thickness < w2.thickness ? w2 : w1;\n\n const next = cloneFloor(floor);\n const id = newId('w');\n next.walls[id] = {\n id,\n a: far1,\n b: far2,\n thickness: keep.thickness,\n type: keep.type,\n height: keep.height,\n baseHeight: keep.baseHeight,\n };\n\n // Re-express every opening's offset against the merged wall's direction (far1 -> far2).\n const len1 = dist(p1, node);\n const len2 = dist(node, p2);\n // Cutting a bent corner shortens the run, so positions are scaled along it rather than\n // carried over, which would push an opening off the end of its own wall.\n const scale = len1 + len2 > 0 ? span / (len1 + len2) : 1;\n const issues: PlanIssue[] = [];\n\n for (const opening of Object.values(floor.openings)) {\n if (opening.wallId !== w1.id && opening.wallId !== w2.id) continue;\n const onFirst = opening.wallId === w1.id;\n const wall = onFirst ? w1 : w2;\n const startsAtNode = wall.a === nodeId;\n const lenOfWall = wallLength(floor, wall);\n // Distance from the seam, then from the merged wall's own start.\n const fromSeam = startsAtNode ? opening.offset : lenOfWall - opening.offset;\n const alongOriginal = onFirst ? len1 - fromSeam : len1 + fromSeam;\n const offsetOnMerged = alongOriginal * scale;\n\n const width = Math.min(opening.width * scale, span - 2);\n if (width < MIN_OPENING_WIDTH) {\n issues.push(\n issue(\n PlanIssueKind.OpeningDropped,\n 'An opening was removed: the wall it sat on became too short for it.',\n { openingIds: [opening.id], wallIds: [wall.id] },\n ),\n );\n delete next.openings[opening.id];\n continue;\n }\n\n next.openings[opening.id] = {\n ...opening,\n wallId: id,\n width,\n offset: Math.min(Math.max(offsetOnMerged, width / 2), span - width / 2),\n };\n }\n\n delete next.walls[w1.id];\n delete next.walls[w2.id];\n delete next.nodes[nodeId];\n\n return { floor: next, result: { wallId: id }, issues };\n}\n\n// ─── removeWall / moveNode ─────────────────────────────────────────────────────\n\nexport function removeWall(\n floor: PlanFloor,\n wallId: string,\n opts: { pruneOrphanNodes?: boolean; dissolveCollinear?: boolean } = {},\n): PlanEdit<{ removedNodeIds: string[]; dissolvedWallIds: string[] }> {\n const { pruneOrphanNodes = true, dissolveCollinear = true } = opts;\n const wall = floor.walls[wallId];\n if (!wall) return { floor, result: { removedNodeIds: [], dissolvedWallIds: [] }, issues: [] };\n\n let next = cloneFloor(floor);\n delete next.walls[wallId];\n for (const opening of Object.values(next.openings)) {\n if (opening.wallId === wallId) delete next.openings[opening.id];\n }\n\n const removedNodeIds: string[] = [];\n const dissolvedWallIds: string[] = [];\n const issues: PlanIssue[] = [];\n\n for (const endpoint of [wall.a, wall.b]) {\n const degree = wallsAtNode(next, endpoint).length;\n if (degree === 0 && pruneOrphanNodes) {\n delete next.nodes[endpoint];\n removedNodeIds.push(endpoint);\n } else if (degree === 2 && dissolveCollinear) {\n // The natural inverse of the split that created this seam.\n const edit = dissolveNode(next, endpoint);\n if (edit.result) {\n next = edit.floor;\n dissolvedWallIds.push(edit.result.wallId);\n removedNodeIds.push(endpoint);\n issues.push(...edit.issues);\n }\n }\n }\n\n return { floor: next, result: { removedNodeIds, dissolvedWallIds }, issues };\n}\n\nexport function moveNode(floor: PlanFloor, nodeId: string, to: PlanPoint): PlanEdit {\n const node = floor.nodes[nodeId];\n if (!node) return { floor, result: undefined, issues: [] };\n const next = cloneFloor(floor);\n next.nodes[nodeId] = { ...node, x: to.x, y: to.y };\n return { floor: next, result: undefined, issues: [] };\n}\n\n/**\n * Translates every node, and every room anchor with it.\n *\n * Room matching does not depend on this (it is primarily wall-id based), but a plan\n * that is dragged and then saved without a derive should still have sane anchors.\n */\nexport function transformPlan(floor: PlanFloor, dx: number, dy: number): PlanFloor {\n const next = cloneFloor(floor);\n for (const node of Object.values(floor.nodes)) {\n next.nodes[node.id] = { ...node, x: node.x + dx, y: node.y + dy };\n }\n for (const room of Object.values(floor.rooms)) {\n next.rooms[room.id] = { ...room, anchor: { x: room.anchor.x + dx, y: room.anchor.y + dy } };\n }\n return next;\n}\n\n/**\n * Multiplies every coordinate and dimension by `factor`.\n *\n * The one place a scale factor belongs: converting a unitless legacy plan into the\n * module's millimetre invariant, once, at the import boundary. The caller picks the\n * factor, because nothing in the old data says what it should be.\n */\nexport function scalePlan(floor: PlanFloor, factor: number): PlanFloor {\n const next = cloneFloor(floor);\n for (const node of Object.values(floor.nodes)) {\n next.nodes[node.id] = { ...node, x: node.x * factor, y: node.y * factor };\n }\n for (const wall of Object.values(floor.walls)) {\n next.walls[wall.id] = {\n ...wall,\n thickness: wall.thickness * factor,\n height: wall.height === undefined ? undefined : wall.height * factor,\n baseHeight: wall.baseHeight === undefined ? undefined : wall.baseHeight * factor,\n };\n }\n for (const opening of Object.values(floor.openings)) {\n next.openings[opening.id] = {\n ...opening,\n offset: opening.offset * factor,\n width: opening.width * factor,\n sill: opening.sill * factor,\n head: opening.head * factor,\n };\n }\n for (const room of Object.values(floor.rooms)) {\n next.rooms[room.id] = {\n ...room,\n anchor: { x: room.anchor.x * factor, y: room.anchor.y * factor },\n area: room.area * factor * factor,\n };\n }\n return next;\n}\n\n// ─── Openings ──────────────────────────────────────────────────────────────────\n\nexport function addOpening(\n floor: PlanFloor,\n opening: Omit<PlanOpening, 'id'>,\n): PlanEdit<{ openingId: string | null }> {\n const wall = floor.walls[opening.wallId];\n if (!wall) return { floor, result: { openingId: null }, issues: [] };\n\n const len = wallLength(floor, wall);\n const width = Math.min(opening.width, len - 2);\n if (width < MIN_OPENING_WIDTH) {\n return {\n floor,\n result: { openingId: null },\n issues: [\n issue(PlanIssueKind.OpeningDropped, 'That wall is too short for an opening.', {\n wallIds: [wall.id],\n }),\n ],\n };\n }\n\n const offset = Math.min(Math.max(opening.offset, width / 2), len - width / 2);\n const next = cloneFloor(floor);\n const id = newId('o');\n next.openings[id] = { ...opening, id, offset, width };\n return { floor: next, result: { openingId: id }, issues: [] };\n}\n\nexport function updateOpening(\n floor: PlanFloor,\n openingId: string,\n patch: Partial<Omit<PlanOpening, 'id' | 'wallId'>>,\n): PlanEdit {\n const opening = floor.openings[openingId];\n if (!opening) return { floor, result: undefined, issues: [] };\n const wall = floor.walls[opening.wallId];\n if (!wall) return { floor, result: undefined, issues: [] };\n\n const len = wallLength(floor, wall);\n const merged = { ...opening, ...patch };\n const width = Math.min(Math.max(merged.width, MIN_OPENING_WIDTH), Math.max(len - 2, MIN_OPENING_WIDTH));\n const offset = Math.min(Math.max(merged.offset, width / 2), Math.max(len - width / 2, width / 2));\n\n const next = cloneFloor(floor);\n next.openings[openingId] = { ...merged, width, offset };\n return { floor: next, result: undefined, issues: [] };\n}\n\nexport function removeOpening(floor: PlanFloor, openingId: string): PlanEdit {\n if (!floor.openings[openingId]) return { floor, result: undefined, issues: [] };\n const next = cloneFloor(floor);\n delete next.openings[openingId];\n return { floor: next, result: undefined, issues: [] };\n}\n\n// ─── Validation and cleanup ────────────────────────────────────────────────────\n\n/**\n * Reports every way the graph currently violates the planar invariant.\n *\n * O(W²), so run it in tests and in development, not on every keystroke.\n */\nexport function validatePlan(floor: PlanFloor): PlanIssue[] {\n const issues: PlanIssue[] = [];\n const walls = Object.values(floor.walls);\n\n for (const wall of walls) {\n const a = floor.nodes[wall.a];\n const b = floor.nodes[wall.b];\n if (wall.a === wall.b) {\n issues.push(issue(PlanIssueKind.SelfLoopWall, 'A wall starts and ends at the same node.', { wallIds: [wall.id] }));\n continue;\n }\n if (!a || !b) {\n issues.push(issue(PlanIssueKind.ZeroLengthWall, 'A wall references a missing node.', { wallIds: [wall.id] }));\n continue;\n }\n if (dist(a, b) < MIN_WALL_LENGTH) {\n issues.push(issue(PlanIssueKind.ZeroLengthWall, 'A wall is shorter than the minimum length.', { wallIds: [wall.id] }));\n }\n }\n\n const pairSeen = new Map<string, string>();\n for (const wall of walls) {\n const key = wall.a < wall.b ? `${wall.a}|${wall.b}` : `${wall.b}|${wall.a}`;\n const prior = pairSeen.get(key);\n if (prior) {\n issues.push(\n issue(PlanIssueKind.DuplicateWall, 'Two walls connect the same pair of nodes.', {\n wallIds: [prior, wall.id],\n }),\n );\n } else {\n pairSeen.set(key, wall.id);\n }\n }\n\n const nodes = Object.values(floor.nodes);\n for (let i = 0; i < nodes.length; i++) {\n for (let j = i + 1; j < nodes.length; j++) {\n if (dist(nodes[i], nodes[j]) < EPS_COINCIDENT) {\n issues.push(\n issue(PlanIssueKind.CoincidentNodes, 'Two nodes occupy the same position.', {\n nodeIds: [nodes[i].id, nodes[j].id],\n }),\n );\n }\n }\n }\n\n for (let i = 0; i < walls.length; i++) {\n for (let j = i + 1; j < walls.length; j++) {\n const w1 = walls[i];\n const w2 = walls[j];\n const a = floor.nodes[w1.a];\n const b = floor.nodes[w1.b];\n const c = floor.nodes[w2.a];\n const d = floor.nodes[w2.b];\n if (!a || !b || !c || !d) continue;\n const hit = segmentIntersection(a, b, c, d);\n if (hit.kind === 'proper') {\n issues.push(\n issue(PlanIssueKind.UnsplitCrossing, 'Two walls cross with no node at the intersection.', {\n wallIds: [w1.id, w2.id],\n }),\n );\n } else if (hit.kind === 'collinearOverlap') {\n issues.push(\n issue(PlanIssueKind.OverlappingWalls, 'Two walls overlap along the same line.', {\n wallIds: [w1.id, w2.id],\n }),\n );\n }\n }\n }\n\n for (const wall of walls) {\n const a = floor.nodes[wall.a];\n const b = floor.nodes[wall.b];\n if (!a || !b) continue;\n for (const node of nodes) {\n if (node.id === wall.a || node.id === wall.b) continue;\n const r = pointToSegment(node, a, b);\n if (r.dist < EPS_COINCIDENT && r.t > 0 && r.t < 1) {\n issues.push(\n issue(PlanIssueKind.UnsplitNodeOnWall, 'A node sits on a wall that was never split there.', {\n wallIds: [wall.id],\n nodeIds: [node.id],\n }),\n );\n }\n }\n }\n\n for (const opening of Object.values(floor.openings)) {\n if (!floor.walls[opening.wallId]) {\n issues.push(\n issue(PlanIssueKind.DanglingOpening, 'An opening references a wall that no longer exists.', {\n openingIds: [opening.id],\n }),\n );\n }\n }\n\n const referenced = new Set<string>();\n for (const wall of walls) {\n referenced.add(wall.a);\n referenced.add(wall.b);\n }\n for (const node of nodes) {\n if (!referenced.has(node.id)) {\n issues.push(issue(PlanIssueKind.OrphanNode, 'A node has no walls.', { nodeIds: [node.id] }));\n }\n }\n\n return issues;\n}\n\n/**\n * Removes what validation can safely remove: degenerate walls, orphan nodes, dangling\n * openings, and duplicate walls. Idempotent.\n *\n * Deliberately does not touch unsplit crossings: fixing one changes the room structure,\n * which is the user's call, not a cleanup pass's.\n */\nexport function cleanupPlan(floor: PlanFloor): PlanEdit<PlanIssue[]> {\n const next = cloneFloor(floor);\n const fixed: PlanIssue[] = [];\n\n for (const wall of Object.values(next.walls)) {\n const a = next.nodes[wall.a];\n const b = next.nodes[wall.b];\n if (wall.a === wall.b || !a || !b || dist(a, b) < MIN_WALL_LENGTH) {\n delete next.walls[wall.id];\n fixed.push(issue(PlanIssueKind.ZeroLengthWall, 'Removed a degenerate wall.', { wallIds: [wall.id] }));\n }\n }\n\n const pairSeen = new Map<string, PlanWall>();\n for (const wall of Object.values(next.walls)) {\n const key = wall.a < wall.b ? `${wall.a}|${wall.b}` : `${wall.b}|${wall.a}`;\n const prior = pairSeen.get(key);\n if (!prior) {\n pairSeen.set(key, wall);\n continue;\n }\n const keep = wall.thickness >= prior.thickness ? wall : prior;\n const drop = keep === wall ? prior : wall;\n for (const opening of Object.values(next.openings)) {\n if (opening.wallId === drop.id) next.openings[opening.id] = { ...opening, wallId: keep.id };\n }\n delete next.walls[drop.id];\n pairSeen.set(key, keep);\n fixed.push(issue(PlanIssueKind.DuplicateWall, 'Removed a duplicate wall.', { wallIds: [drop.id] }));\n }\n\n for (const opening of Object.values(next.openings)) {\n if (!next.walls[opening.wallId]) {\n delete next.openings[opening.id];\n fixed.push(\n issue(PlanIssueKind.DanglingOpening, 'Removed an opening with no wall.', {\n openingIds: [opening.id],\n }),\n );\n }\n }\n\n // One pass to count degrees, rather than scanning every wall per node: the naive form\n // is O(nodes x walls), which is the dominant cost on a large plan.\n const referenced = new Set<string>();\n for (const wall of Object.values(next.walls)) {\n referenced.add(wall.a);\n referenced.add(wall.b);\n }\n for (const node of Object.values(next.nodes)) {\n if (!referenced.has(node.id)) {\n delete next.nodes[node.id];\n fixed.push(issue(PlanIssueKind.OrphanNode, 'Removed a node with no walls.', { nodeIds: [node.id] }));\n }\n }\n\n return { floor: next, result: fixed, issues: fixed };\n}\n\nexport { wallLength, wallsAtNode, findWallBetween };\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{dist as e,pointToSegment as t}from"./floorPlan.geometry.mjs";import{roomAtPoint as n}from"./floorPlan.rooms.mjs";import{openingGeometry as r}from"./floorPlan.path.mjs";var i={node:10,opening:9,wall:8},a={node:18,opening:16,wall:14};function o(e,t){let n=t>0?t:1;return{node:e.node/n,opening:e.opening/n,wall:e.wall/n}}function s(i,a,o,s,c={}){let l=e=>!c.accept||c.accept.includes(e),u=e=>c.excludeIds?.has(e)??!1;if(l(`node`)){let t=null;for(let n of Object.values(i.nodes)){if(u(n.id))continue;let r=e(o,n);r<=s.node&&(!t||r<t.d)&&(t={id:n.id,d:r})}if(t)return{type:`node`,id:t.id}}if(l(`opening`)){let e=null;for(let n of Object.values(i.openings)){if(u(n.id))continue;let a=r(i,n);if(!a)continue;let c=t(o,a.p1,a.p2).dist;c<=s.opening&&(!e||c<e.d)&&(e={id:n.id,d:c})}if(e)return{type:`opening`,id:e.id}}if(l(`wall`)){let e=null;for(let n of Object.values(i.walls)){if(u(n.id))continue;let r=i.nodes[n.a],a=i.nodes[n.b];if(!r||!a)continue;let c=Math.max(s.wall,n.thickness/2),l=t(o,r,a).dist;l<=c&&(!e||l<e.d)&&(e={id:n.id,d:l})}if(e)return{type:`wall`,id:e.id}}if(l(`room`)){let e=n(a,o);if(e?.roomId&&!u(e.roomId))return{type:`room`,id:e.roomId}}return null}function c(e,t){return e===t?!0:!e||!t?!1:e.type===t.type&&e.id===t.id}export{i as HIT_PX,a as HIT_PX_COARSE,s as hitTest,c as selectionsEqual,o as worldTolerances};
2
+ //# sourceMappingURL=floorPlan.hit.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"floorPlan.hit.mjs","names":[],"sources":["../../../../package/common/floorPlan/floorPlan.hit.ts"],"sourcesContent":["/**\n * Hit testing against a derived plan.\n *\n * Tolerances arrive in WORLD units and are expected to have been converted from screen\n * pixels by the caller (`HIT_PX / view.zoom`), so a handle stays the same size on screen\n * at any zoom level.\n */\n\nimport { dist, pointToSegment } from './floorPlan.geometry';\nimport { openingGeometry } from './floorPlan.path';\nimport { roomAtPoint } from './floorPlan.rooms';\nimport type { DerivedRoom, PlanFloor, PlanPoint, PlanSelection } from './floorPlan.types';\n\nexport interface HitTolerances {\n node: number;\n opening: number;\n wall: number;\n}\n\n/** Screen-pixel hit radii, divided by `view.zoom` to get world tolerances. */\nexport const HIT_PX: HitTolerances = { node: 10, opening: 9, wall: 8 };\n/** Wider targets for touch, where there is no pixel-accurate pointer. */\nexport const HIT_PX_COARSE: HitTolerances = { node: 18, opening: 16, wall: 14 };\n\nexport function worldTolerances(px: HitTolerances, zoom: number): HitTolerances {\n const safeZoom = zoom > 0 ? zoom : 1;\n return { node: px.node / safeZoom, opening: px.opening / safeZoom, wall: px.wall / safeZoom };\n}\n\nexport interface HitTestOptions {\n /** Restricts what can be hit. Defaults to everything. */\n accept?: Array<PlanSelection['type']>;\n excludeIds?: ReadonlySet<string>;\n}\n\n/**\n * Finds what is under `point`, in priority order: node handle, opening, wall, then room\n * fill.\n *\n * The order is what makes editing feel right. A node sits on top of the walls that meet\n * there, and an opening sits on top of the wall it cuts, so the smaller, more specific\n * target always wins over the larger one underneath it.\n */\nexport function hitTest(\n floor: PlanFloor,\n rooms: DerivedRoom[],\n point: PlanPoint,\n tolerances: HitTolerances,\n options: HitTestOptions = {},\n): PlanSelection | null {\n const accepts = (type: PlanSelection['type']) => !options.accept || options.accept.includes(type);\n const excluded = (id: string) => options.excludeIds?.has(id) ?? false;\n\n if (accepts('node')) {\n let best: { id: string; d: number } | null = null;\n for (const node of Object.values(floor.nodes)) {\n if (excluded(node.id)) continue;\n const d = dist(point, node);\n if (d <= tolerances.node && (!best || d < best.d)) best = { id: node.id, d };\n }\n if (best) return { type: 'node', id: best.id };\n }\n\n if (accepts('opening')) {\n let best: { id: string; d: number } | null = null;\n for (const opening of Object.values(floor.openings)) {\n if (excluded(opening.id)) continue;\n const geo = openingGeometry(floor, opening);\n if (!geo) continue;\n const d = pointToSegment(point, geo.p1, geo.p2).dist;\n if (d <= tolerances.opening && (!best || d < best.d)) best = { id: opening.id, d };\n }\n if (best) return { type: 'opening', id: best.id };\n }\n\n if (accepts('wall')) {\n let best: { id: string; d: number } | null = null;\n for (const wall of Object.values(floor.walls)) {\n if (excluded(wall.id)) continue;\n const a = floor.nodes[wall.a];\n const b = floor.nodes[wall.b];\n if (!a || !b) continue;\n // Half the wall's own thickness, so a thick wall is grabbable across its body\n // rather than only along its centreline.\n const reach = Math.max(tolerances.wall, wall.thickness / 2);\n const d = pointToSegment(point, a, b).dist;\n if (d <= reach && (!best || d < best.d)) best = { id: wall.id, d };\n }\n if (best) return { type: 'wall', id: best.id };\n }\n\n if (accepts('room')) {\n const room = roomAtPoint(rooms, point);\n if (room?.roomId && !excluded(room.roomId)) return { type: 'room', id: room.roomId };\n }\n\n return null;\n}\n\nexport function selectionsEqual(a: PlanSelection | null, b: PlanSelection | null): boolean {\n if (a === b) return true;\n if (!a || !b) return false;\n return a.type === b.type && a.id === b.id;\n}\n"],"mappings":"+KAoBA,IAAa,EAAwB,CAAE,KAAM,GAAI,QAAS,EAAG,KAAM,CAAE,EAExD,EAA+B,CAAE,KAAM,GAAI,QAAS,GAAI,KAAM,EAAG,EAE9E,SAAgB,EAAgB,EAAmB,EAA6B,CAC9E,IAAM,EAAW,EAAO,EAAI,EAAO,EACnC,MAAO,CAAE,KAAM,EAAG,KAAO,EAAU,QAAS,EAAG,QAAU,EAAU,KAAM,EAAG,KAAO,CAAS,CAC9F,CAgBA,SAAgB,EACd,EACA,EACA,EACA,EACA,EAA0B,CAAC,EACL,CACtB,IAAM,EAAW,GAAgC,CAAC,EAAQ,QAAU,EAAQ,OAAO,SAAS,CAAI,EAC1F,EAAY,GAAe,EAAQ,YAAY,IAAI,CAAE,GAAK,GAEhE,GAAI,EAAQ,MAAM,EAAG,CACnB,IAAI,EAAyC,KAC7C,IAAK,IAAM,KAAQ,OAAO,OAAO,EAAM,KAAK,EAAG,CAC7C,GAAI,EAAS,EAAK,EAAE,EAAG,SACvB,IAAM,EAAI,EAAK,EAAO,CAAI,EACtB,GAAK,EAAW,OAAS,CAAC,GAAQ,EAAI,EAAK,KAAI,EAAO,CAAE,GAAI,EAAK,GAAI,GAAE,EAC7E,CACA,GAAI,EAAM,MAAO,CAAE,KAAM,OAAQ,GAAI,EAAK,EAAG,CAC/C,CAEA,GAAI,EAAQ,SAAS,EAAG,CACtB,IAAI,EAAyC,KAC7C,IAAK,IAAM,KAAW,OAAO,OAAO,EAAM,QAAQ,EAAG,CACnD,GAAI,EAAS,EAAQ,EAAE,EAAG,SAC1B,IAAM,EAAM,EAAgB,EAAO,CAAO,EAC1C,GAAI,CAAC,EAAK,SACV,IAAM,EAAI,EAAe,EAAO,EAAI,GAAI,EAAI,EAAE,EAAE,KAC5C,GAAK,EAAW,UAAY,CAAC,GAAQ,EAAI,EAAK,KAAI,EAAO,CAAE,GAAI,EAAQ,GAAI,GAAE,EACnF,CACA,GAAI,EAAM,MAAO,CAAE,KAAM,UAAW,GAAI,EAAK,EAAG,CAClD,CAEA,GAAI,EAAQ,MAAM,EAAG,CACnB,IAAI,EAAyC,KAC7C,IAAK,IAAM,KAAQ,OAAO,OAAO,EAAM,KAAK,EAAG,CAC7C,GAAI,EAAS,EAAK,EAAE,EAAG,SACvB,IAAM,EAAI,EAAM,MAAM,EAAK,GACrB,EAAI,EAAM,MAAM,EAAK,GAC3B,GAAI,CAAC,GAAK,CAAC,EAAG,SAGd,IAAM,EAAQ,KAAK,IAAI,EAAW,KAAM,EAAK,UAAY,CAAC,EACpD,EAAI,EAAe,EAAO,EAAG,CAAC,EAAE,KAClC,GAAK,IAAU,CAAC,GAAQ,EAAI,EAAK,KAAI,EAAO,CAAE,GAAI,EAAK,GAAI,GAAE,EACnE,CACA,GAAI,EAAM,MAAO,CAAE,KAAM,OAAQ,GAAI,EAAK,EAAG,CAC/C,CAEA,GAAI,EAAQ,MAAM,EAAG,CACnB,IAAM,EAAO,EAAY,EAAO,CAAK,EACrC,GAAI,GAAM,QAAU,CAAC,EAAS,EAAK,MAAM,EAAG,MAAO,CAAE,KAAM,OAAQ,GAAI,EAAK,MAAO,CACrF,CAEA,OAAO,IACT,CAEA,SAAgB,EAAgB,EAAyB,EAAkC,CAGzF,OAFI,IAAM,EAAU,GAChB,CAAC,GAAK,CAAC,EAAU,GACd,EAAE,OAAS,EAAE,MAAQ,EAAE,KAAO,EAAE,EACzC"}
@@ -0,0 +1,2 @@
1
+ import{DoorSwing as e,OpeningKind as t,PlanUnitSystem as n,WallType as r}from"./floorPlan.types.mjs";import{cleanupPlan as i,newId as a}from"./floorPlan.graph.mjs";import{emptyFloor as o}from"./floorPlan.plan.mjs";var s=1;function c(e,t={}){return JSON.stringify(e,null,t.pretty===!1?void 0:2)}function l(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function u(e,t){let n=typeof e==`string`?Number(e):e;return typeof n==`number`&&Number.isFinite(n)?n:t}function d(e,t){return typeof e==`string`?e:t}function f(e,t){return typeof e==`boolean`?e:t}function p(e,t,n){return typeof e==`string`&&t.includes(e)?e:n}function m(e,t){return l(e)?{x:u(e.x,t.x),y:u(e.y,t.y)}:t}function h(e){return Array.isArray(e)?e:l(e)?Object.values(e):[]}function g(e){let t={};for(let n of e)t[n.id]=n;return t}function _(n,a,s){let c=`floor ${a+1}`,_=l(n)?n:{};l(n)||s.push(`${c} was not an object, so it was read as empty`);let v=o();v.id=d(_.id,v.id),v.label=d(_.label,v.label),v.elevation=u(_.elevation,0),v.height=u(_.height,v.height);let y=[];for(let e of h(_.nodes))!l(e)||typeof e.id!=`string`||y.push({id:e.id,x:u(e.x,0),y:u(e.y,0)});v.nodes=g(y);let b=[],x=0;for(let e of h(_.walls)){if(!l(e)||typeof e.id!=`string`)continue;let t=d(e.a,``),n=d(e.b,``);if(!v.nodes[t]||!v.nodes[n]||t===n){x+=1;continue}b.push({id:e.id,a:t,b:n,thickness:Math.max(1,u(e.thickness,100)),height:e.height===void 0?void 0:u(e.height,0),baseHeight:e.baseHeight===void 0?void 0:u(e.baseHeight,0),type:p(e.type,Object.values(r),r.Interior)})}v.walls=g(b),x>0&&s.push(`${c}: dropped ${x} wall(s) whose corners were missing`);let S=[],C=0;for(let n of h(_.openings)){if(!l(n)||typeof n.id!=`string`)continue;let r=d(n.wallId,``);if(!v.walls[r]){C+=1;continue}S.push({id:n.id,wallId:r,kind:p(n.kind,Object.values(t),t.Door),offset:u(n.offset,0),width:Math.max(1,u(n.width,800)),sill:u(n.sill,0),head:u(n.head,2100),swing:n.swing===void 0?void 0:p(n.swing,Object.values(e),e.StartLeft)})}v.openings=g(S),C>0&&s.push(`${c}: dropped ${C} opening(s) with no wall to sit in`);let w=[];for(let e of h(_.rooms))!l(e)||typeof e.id!=`string`||w.push({id:e.id,name:d(e.name,``),colour:typeof e.colour==`string`?e.colour:void 0,usage:typeof e.usage==`string`?e.usage:void 0,anchor:m(e.anchor,{x:0,y:0}),anchorPinned:f(e.anchorPinned,!1),seq:u(e.seq,w.length),wallIds:Array.isArray(e.wallIds)?e.wallIds.filter(e=>typeof e==`string`&&!!v.walls[e]):[],area:u(e.area,0)});v.rooms=g(w);let T=i(v);return T.result.length>0&&s.push(`${c}: repaired ${T.result.length} inconsistency(ies) in the geometry`),T.floor}function v(e){let t=[],r;try{r=JSON.parse(e)}catch(e){return{plan:null,issues:[`Not valid JSON: ${e.message}`]}}if(!l(r))return{plan:null,issues:[`Not a floor plan: expected an object`]};if(r.version!==void 0&&r.version!==1)return{plan:null,issues:[`Plan format version ${String(r.version)} is not supported (this build reads version 1)`]};r.version===void 0&&t.push(`No format version, read as version 1`);let i=Array.isArray(r.floors)?r.floors:[];if(i.length===0)if(r.floors!==void 0)t.push(`No floors in the file, started an empty one`);else return{plan:null,issues:["Not a floor plan: no `floors`"]};let o=(i.length>0?i:[void 0]).map((e,n)=>_(e,n,t));return{plan:{id:d(r.id,a(`plan`)),name:typeof r.name==`string`?r.name:void 0,version:1,unitSystem:p(r.unitSystem,Object.values(n),n.Metric),floors:o,revision:u(r.revision,0)+1},issues:t}}export{s as PLAN_FORMAT_VERSION,v as planFromJson,c as planToJson};
2
+ //# sourceMappingURL=floorPlan.json.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"floorPlan.json.mjs","names":[],"sources":["../../../../package/common/floorPlan/floorPlan.json.ts"],"sourcesContent":["/**\n * Reading and writing a plan as JSON, which is the interchange format.\n *\n * Writing is trivial. Reading is not: the text comes off a disk, so it is untrusted, and\n * the component that receives the result renders it immediately. `planFromJson` therefore\n * rebuilds the plan field by field rather than casting the parsed object, so nothing that\n * was not checked can reach the renderer, and returns everything it had to fix so a\n * caller can tell the difference between a clean file and a salvaged one.\n *\n * It repairs rather than rejects wherever a repair is unambiguous: a wall whose node is\n * missing is dropped, a thickness of `\"200\"` is read as 200, an unknown wall type becomes\n * `Interior`. A file is only refused when there is nothing to salvage, because a plan a\n * user spent an hour on should not be lost to one bad field.\n */\n\nimport { cleanupPlan, newId } from './floorPlan.graph';\nimport { emptyFloor } from './floorPlan.plan';\nimport { DoorSwing, OpeningKind, PlanUnitSystem, WallType } from './floorPlan.types';\nimport type {\n FloorPlan,\n PlanFloor,\n PlanNode,\n PlanOpening,\n PlanPoint,\n PlanRoom,\n PlanWall,\n} from './floorPlan.types';\n\n/** The only `version` a file may carry. Bump only for a breaking shape change. */\nexport const PLAN_FORMAT_VERSION = 1;\n\nexport interface PlanParseResult {\n /** `null` only when the file could not be read as a plan at all. */\n plan: FloorPlan | null;\n /**\n * Everything that had to be repaired or dropped, in plain language. Empty means the\n * file was clean. A non-empty list with a plan means it was salvaged.\n */\n issues: string[];\n}\n\n/** A plan as JSON text, ready to write to a file. Indented unless told otherwise. */\nexport function planToJson(plan: FloorPlan, options: { pretty?: boolean } = {}): string {\n return JSON.stringify(plan, null, options.pretty === false ? undefined : 2);\n}\n\n// ─── Field readers ─────────────────────────────────────────────────────────────\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/** A finite number, also accepting the numeric strings a hand-edited file tends to grow. */\nfunction num(value: unknown, fallback: number): number {\n const n = typeof value === 'string' ? Number(value) : value;\n return typeof n === 'number' && Number.isFinite(n) ? n : fallback;\n}\n\nfunction str(value: unknown, fallback: string): string {\n return typeof value === 'string' ? value : fallback;\n}\n\nfunction bool(value: unknown, fallback: boolean): boolean {\n return typeof value === 'boolean' ? value : fallback;\n}\n\nfunction oneOf<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {\n return typeof value === 'string' && (allowed as readonly string[]).includes(value)\n ? (value as T)\n : fallback;\n}\n\nfunction point(value: unknown, fallback: PlanPoint): PlanPoint {\n if (!isRecord(value)) return fallback;\n return { x: num(value.x, fallback.x), y: num(value.y, fallback.y) };\n}\n\n/**\n * The entries of a `Record<string, T>`, tolerating the array a different exporter might\n * have written. Entries without a usable `id` are dropped by the caller.\n */\nfunction entries(value: unknown): unknown[] {\n if (Array.isArray(value)) return value;\n if (isRecord(value)) return Object.values(value);\n return [];\n}\n\nfunction byId<T extends { id: string }>(items: T[]): Record<string, T> {\n const out: Record<string, T> = {};\n for (const item of items) out[item.id] = item;\n return out;\n}\n\n// ─── Floor ─────────────────────────────────────────────────────────────────────\n\nfunction readFloor(raw: unknown, index: number, issues: string[]): PlanFloor {\n const where = `floor ${index + 1}`;\n const source = isRecord(raw) ? raw : {};\n if (!isRecord(raw)) issues.push(`${where} was not an object, so it was read as empty`);\n\n const floor = emptyFloor();\n floor.id = str(source.id, floor.id);\n floor.label = str(source.label, floor.label);\n floor.elevation = num(source.elevation, 0);\n floor.height = num(source.height, floor.height);\n\n const nodes: PlanNode[] = [];\n for (const raw of entries(source.nodes)) {\n if (!isRecord(raw) || typeof raw.id !== 'string') continue;\n nodes.push({ id: raw.id, x: num(raw.x, 0), y: num(raw.y, 0) });\n }\n floor.nodes = byId(nodes);\n\n const walls: PlanWall[] = [];\n let orphanWalls = 0;\n for (const raw of entries(source.walls)) {\n if (!isRecord(raw) || typeof raw.id !== 'string') continue;\n const a = str(raw.a, '');\n const b = str(raw.b, '');\n // A wall with no node to stand on would break face extraction, which assumes every\n // wall resolves. There is no way to guess where it was meant to go.\n if (!floor.nodes[a] || !floor.nodes[b] || a === b) {\n orphanWalls += 1;\n continue;\n }\n walls.push({\n id: raw.id,\n a,\n b,\n thickness: Math.max(1, num(raw.thickness, 100)),\n height: raw.height === undefined ? undefined : num(raw.height, 0),\n baseHeight: raw.baseHeight === undefined ? undefined : num(raw.baseHeight, 0),\n type: oneOf(raw.type, Object.values(WallType), WallType.Interior),\n });\n }\n floor.walls = byId(walls);\n if (orphanWalls > 0) {\n issues.push(`${where}: dropped ${orphanWalls} wall(s) whose corners were missing`);\n }\n\n const openings: PlanOpening[] = [];\n let orphanOpenings = 0;\n for (const raw of entries(source.openings)) {\n if (!isRecord(raw) || typeof raw.id !== 'string') continue;\n const wallId = str(raw.wallId, '');\n if (!floor.walls[wallId]) {\n orphanOpenings += 1;\n continue;\n }\n openings.push({\n id: raw.id,\n wallId,\n kind: oneOf(raw.kind, Object.values(OpeningKind), OpeningKind.Door),\n offset: num(raw.offset, 0),\n width: Math.max(1, num(raw.width, 800)),\n sill: num(raw.sill, 0),\n head: num(raw.head, 2100),\n swing:\n raw.swing === undefined\n ? undefined\n : oneOf(raw.swing, Object.values(DoorSwing), DoorSwing.StartLeft),\n });\n }\n floor.openings = byId(openings);\n if (orphanOpenings > 0) {\n issues.push(`${where}: dropped ${orphanOpenings} opening(s) with no wall to sit in`);\n }\n\n const rooms: PlanRoom[] = [];\n for (const raw of entries(source.rooms)) {\n if (!isRecord(raw) || typeof raw.id !== 'string') continue;\n // A room is only a name attached to a face, and the face is recomputed on load, so a\n // room whose walls have gone is kept rather than dropped: re-adding the wall brings\n // its name back, and dropping it is the one way this model loses data for good.\n rooms.push({\n id: raw.id,\n name: str(raw.name, ''),\n colour: typeof raw.colour === 'string' ? raw.colour : undefined,\n usage: typeof raw.usage === 'string' ? raw.usage : undefined,\n anchor: point(raw.anchor, { x: 0, y: 0 }),\n anchorPinned: bool(raw.anchorPinned, false),\n seq: num(raw.seq, rooms.length),\n wallIds: Array.isArray(raw.wallIds)\n ? raw.wallIds.filter((id): id is string => typeof id === 'string' && !!floor.walls[id])\n : [],\n area: num(raw.area, 0),\n });\n }\n floor.rooms = byId(rooms);\n\n const cleaned = cleanupPlan(floor);\n if (cleaned.result.length > 0) {\n issues.push(`${where}: repaired ${cleaned.result.length} inconsistency(ies) in the geometry`);\n }\n return cleaned.floor;\n}\n\n// ─── Document ──────────────────────────────────────────────────────────────────\n\n/**\n * Reads JSON text as a plan, repairing what it can and reporting what it could not.\n *\n * `plan` is `null` only when the text is not JSON, is not an object, or carries a\n * `version` this build does not know. Everything else comes back as a usable plan.\n */\nexport function planFromJson(text: string): PlanParseResult {\n const issues: string[] = [];\n\n let raw: unknown;\n try {\n raw = JSON.parse(text);\n } catch (error) {\n return { plan: null, issues: [`Not valid JSON: ${(error as Error).message}`] };\n }\n\n if (!isRecord(raw)) return { plan: null, issues: ['Not a floor plan: expected an object'] };\n\n if (raw.version !== undefined && raw.version !== PLAN_FORMAT_VERSION) {\n // Refusing beats guessing: a future version may mean something different by the same\n // field names, and silently misreading it loses the file's real contents.\n return {\n plan: null,\n issues: [\n `Plan format version ${String(raw.version)} is not supported ` +\n `(this build reads version ${PLAN_FORMAT_VERSION})`,\n ],\n };\n }\n if (raw.version === undefined) issues.push('No format version, read as version 1');\n\n const rawFloors = Array.isArray(raw.floors) ? raw.floors : [];\n if (rawFloors.length === 0) {\n if (raw.floors !== undefined) issues.push('No floors in the file, started an empty one');\n else return { plan: null, issues: ['Not a floor plan: no `floors`'] };\n }\n\n const floors = (rawFloors.length > 0 ? rawFloors : [undefined]).map((floor, i) =>\n readFloor(floor, i, issues),\n );\n\n return {\n plan: {\n id: str(raw.id, newId('plan')),\n name: typeof raw.name === 'string' ? raw.name : undefined,\n version: PLAN_FORMAT_VERSION,\n unitSystem: oneOf(raw.unitSystem, Object.values(PlanUnitSystem), PlanUnitSystem.Metric),\n floors,\n // Bumped, not carried over: the plan in hand is not the one the file described if\n // anything was repaired, and `derivePlan` memoises on this.\n revision: num(raw.revision, 0) + 1,\n },\n issues,\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{dist as e}from"./floorPlan.geometry.mjs";import{nodeDegrees as t}from"./floorPlan.faces.mjs";var n=1e-6;function r(e,t,n){return{x:e.x+(t.x-e.x)*n,y:e.y+(t.y-e.y)*n}}function i(e){return Number.isInteger(e)?String(e):e.toFixed(3).replace(/\.?0+$/,``)}function a(e,t){return Math.abs(e.x-t.x)<.001&&Math.abs(e.y-t.y)<.001}function o(t,r){let i=t.nodes[r.a],a=t.nodes[r.b];if(!i||!a)return[];let o=e(i,a);if(o<n)return[];let s=[];for(let e of Object.values(t.openings)){if(e.wallId!==r.id)continue;let t=e.width/2,n=Math.max(0,e.offset-t),i=Math.min(o,e.offset+t);i>n&&s.push([n,i])}if(s.length===0)return[[0,o]];s.sort((e,t)=>e[0]-t[0]);let c=[s[0]];for(let e=1;e<s.length;e++){let t=c[c.length-1];s[e][0]<=t[1]?t[1]=Math.max(t[1],s[e][1]):c.push(s[e])}let l=[],u=0;for(let[e,t]of c)e-u>n&&l.push([u,e]),u=t;return o-u>n&&l.push([u,o]),l}function s(t,r){let i=t.walls[r.wallId];if(!i)return null;let a=t.nodes[i.a],o=t.nodes[i.b];if(!a||!o)return null;let s=e(a,o);if(s<n)return null;let c={x:(o.x-a.x)/s,y:(o.y-a.y)/s},l={x:-c.y,y:c.x},u=Math.min(r.width/2,s/2),d=Math.min(Math.max(r.offset,u),s-u),f={x:a.x+c.x*d,y:a.y+c.y*d};return{centre:f,dir:c,normal:l,p1:{x:f.x-c.x*u,y:f.y-c.y*u},p2:{x:f.x+c.x*u,y:f.y+c.y*u}}}function c(e,t,n){let r=new Map(t.map(e=>[e.id,e])),i=new Map;for(let e of t)for(let t of[e.a,e.b]){let n=i.get(t);n?n.push(e):i.set(t,[e])}let a=e=>{if((n.get(e)??0)!==2)return null;let t=i.get(e);return!t||t.length!==2?null:t},o=new Set,s=[],c=(e,t)=>e.a===t?e.b:e.a;for(let e of t){if(o.has(e.id))continue;let t=e,n=e.a,i=new Set([e.id]);for(;;){let e=a(n);if(!e)break;let r=e[0].id===t.id?e[1]:e[0];if(i.has(r.id))break;i.add(r.id),t=r,n=c(r,n)}let l=[],u=t,d=n;for(;!o.has(u.id);){o.add(u.id);let e=c(u,d);l.push({wall:u,from:d,to:e});let t=a(e);if(!t)break;let n=t[0].id===u.id?t[1]:t[0];if(o.has(n.id)||(u=n,d=e,!r.has(u.id)))break}l.length>0&&s.push(l)}return s}function l(t,r,a){let s=new Map,c=(e,t)=>{let n=s.get(e);n?n.push(t):s.set(e,[t])};for(let i of r){let r=t.nodes[i.a],a=t.nodes[i.b],s=e(r,a);if(s<n)continue;let l=o(t,i);if(l.length===0)continue;let u={x:(a.x-r.x)/s,y:(a.y-r.y)/s},d=l[0],f=l[l.length-1];c(i.a,{wall:i,dir:u,angle:Math.atan2(u.y,u.x),reach:d[0]<n?d[1]:0}),c(i.b,{wall:i,dir:{x:-u.x,y:-u.y},angle:Math.atan2(-u.y,-u.x),reach:f[1]>s-n?s-f[0]:0})}let l=[],u=Math.PI*2;for(let[e,r]of s){if(r.length<2||(a.get(e)??0)===2&&r.length===2&&r[0].wall.thickness===r[1].wall.thickness)continue;r.sort((e,t)=>e.angle-t.angle);let o=0,s=-1;for(let e=0;e<r.length;e++){let t=r[(e+1)%r.length],n=e===r.length-1?t.angle+u-r[e].angle:t.angle-r[e].angle;n>s&&(s=n,o=e)}if(s<=Math.PI+n)continue;let c=r[o],d=r[(o+1)%r.length],f=Math.min(c.wall.thickness,d.wall.thickness),p=Math.min(f/2,c.reach),m=Math.min(f/2,d.reach);if(p<n||m<n)continue;let h=t.nodes[e];l.push({thickness:f,d:`M ${i(h.x+c.dir.x*p)},${i(h.y+c.dir.y*p)} L ${i(h.x)},${i(h.y)} L ${i(h.x+d.dir.x*m)},${i(h.y+d.dir.y*m)}`})}return l}function u(s,u={}){let{wallIds:d,capFreeEnds:f=!0,capThicknessSteps:p=!0}=u,m=Object.values(s.walls).filter(t=>{if(d&&!d.has(t.id))return!1;let r=s.nodes[t.a],i=s.nodes[t.b];return r&&i&&e(r,i)>n&&t.a!==t.b});if(m.length===0)return{groups:[],caps:[]};let h=t(s),g=[],_=new Map;for(let e of m){let t=_.get(e.thickness);t?t.push(e):_.set(e.thickness,[e])}let v=new Map,y=(e,t)=>{let n=v.get(e);n?n.push(t):v.set(e,[t])};for(let[t,l]of _)for(let u of c(s,l,h)){let c=[],l=()=>{c.length>=2&&y(t,c.map((e,t)=>`${t===0?`M`:`L`} ${i(e.x)},${i(e.y)}`).join(` `)),c=[]};for(let t of u){let i=s.nodes[t.from],u=s.nodes[t.to],d=e(i,u);if(d<n)continue;let f=o(s,t.wall);t.from!==t.wall.a&&(f=f.map(([e,t])=>[d-t,d-e]).reverse());for(let[e,t]of f){let n=r(i,u,e/d),o=r(i,u,t/d);c.length>0&&a(c[c.length-1],n)?c.push(o):(l(),c=[n,o])}(f.length===0||f[f.length-1][1]<d-n)&&l()}let d=u[0],f=u[u.length-1];d.from===f.to&&c.length>=3&&a(c[0],c[c.length-1])?(y(t,`${c.slice(0,-1).map((e,t)=>`${t===0?`M`:`L`} ${i(e.x)},${i(e.y)}`).join(` `)} Z`),c=[]):l()}for(let e of l(s,m,h))y(e.thickness,e.d);let b=(t,r,i)=>{let a=s.nodes[t.a],o=s.nodes[t.b],c=e(a,o);if(c<n)return;let l={x:(o.x-a.x)/c,y:(o.y-a.y)/c},u={x:-l.y,y:l.x},d={x:a.x+l.x*r,y:a.y+l.y*r},f=t.thickness/2;g.push({a:{x:d.x-u.x*f,y:d.y-u.y*f},b:{x:d.x+u.x*f,y:d.y+u.y*f},reason:i,wallId:t.id})};for(let t of m){let r=s.nodes[t.a],i=s.nodes[t.b],a=e(r,i);for(let[e,r]of o(s,t))e>n?b(t,e,`jamb`):f&&(h.get(t.a)??0)===1&&b(t,0,`freeEnd`),r<a-n?b(t,r,`jamb`):f&&(h.get(t.b)??0)===1&&b(t,a,`freeEnd`)}if(p){let t=new Map;for(let e of m)for(let n of[e.a,e.b]){let r=t.get(n);r?r.push(e):t.set(n,[e])}for(let[n,r]of t){if(r.length!==2)continue;let[t,i]=r;if(t.thickness===i.thickness)continue;let a=t.thickness>=i.thickness?t:i;b(a,a.a===n?0:e(s.nodes[a.a],s.nodes[a.b]),`thicknessStep`)}}return{groups:[...v.entries()].sort((e,t)=>e[0]-t[0]).map(([e,t])=>({thickness:e,d:t.join(` `)})),caps:g}}export{u as buildWallPaths,s as openingGeometry,o as wallCentrelineSpans};
2
+ //# sourceMappingURL=floorPlan.path.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"floorPlan.path.mjs","names":[],"sources":["../../../../package/common/floorPlan/floorPlan.path.ts"],"sourcesContent":["/**\n * Turns walls into SVG path data for the two-pass thick-wall rendering.\n *\n * The trick: both passes stroke the SAME path data, the outline pass at `thickness` and\n * the fill pass at `thickness - 2 * outlineWidth`. The visible outline is not drawn at\n * all, it is what is left of the outline pass after the fill pass covers its middle.\n * That is why the two must never diverge, and why every outline path has to be drawn\n * before every fill path (one `<g>` each, never interleaved pairs).\n *\n * Three things do not fall out of that trick and are handled explicitly:\n *\n * 1. Nothing is ever drawn across a butt end, because there both passes terminate at\n * the same coordinate. Openings and free ends therefore need explicit cap lines.\n * 2. Two butt-capped walls meeting at an angle leave an uncovered notch at the outer\n * corner. Walls are emitted as CHAINS through degree-2 nodes so `stroke-linejoin`\n * miters the corner, and where a chain has to stop, `reflexCornerStubs` mitres the\n * one corner a junction cannot close by itself.\n * 3. Where two walls of different thickness meet end to end, the drawing steps from\n * thick fill straight to background with no edge, so that joint needs a cap too.\n */\n\nimport { nodeDegrees } from './floorPlan.faces';\nimport { dist } from './floorPlan.geometry';\nimport type { PlanFloor, PlanOpening, PlanPoint, PlanWall } from './floorPlan.types';\n\nexport interface WallPathGroup {\n /** Millimetres. The stroke width for the outline pass. */\n thickness: number;\n /**\n * Path data, to be handed VERBATIM to both passes. The outline pass strokes it at\n * `thickness`, the fill pass at `thickness - 2 * outlineWidth`.\n */\n d: string;\n}\n\nexport type WallCapReason = 'jamb' | 'freeEnd' | 'thicknessStep';\n\nexport interface WallCapLine {\n a: PlanPoint;\n b: PlanPoint;\n reason: WallCapReason;\n wallId: string;\n}\n\nexport interface WallPathResult {\n /** Render every group's outline first, then every group's fill. */\n groups: WallPathGroup[];\n /** Drawn last, in the outline colour, at the outline width. */\n caps: WallCapLine[];\n}\n\n\nexport interface BuildWallPathsOptions {\n /**\n * Build paths for only these walls, as if the rest were not drawn.\n *\n * Node degrees still come from the WHOLE floor, so chains break and corners are filled\n * exactly where they are for the real walls. That is what lets a subset be painted over\n * the top of the full drawing and land on it precisely: a highlight of a room's\n * boundary is the same geometry as the walls themselves, openings and all.\n */\n wallIds?: ReadonlySet<string>;\n /** Cap the free end of a wall that stops in mid-air. Default true. */\n capFreeEnds?: boolean;\n /** Cap the joint where two walls of different thickness meet. Default true. */\n capThicknessSteps?: boolean;\n}\n\nconst EPS = 1e-6;\n\nfunction lerpPoint(a: PlanPoint, b: PlanPoint, t: number): PlanPoint {\n return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };\n}\n\nfunction fmt(n: number): string {\n return Number.isInteger(n) ? String(n) : n.toFixed(3).replace(/\\.?0+$/, '');\n}\n\nfunction samePoint(a: PlanPoint, b: PlanPoint): boolean {\n return Math.abs(a.x - b.x) < 0.001 && Math.abs(a.y - b.y) < 0.001;\n}\n\n/**\n * The parts of a wall's centreline that are actually wall, as `[start, end]` offsets in\n * millimetres from node `a`.\n *\n * Overlapping openings merge into one gap; an opening reaching an end removes that span\n * entirely. An empty result means the opening spans the whole wall.\n */\nexport function wallCentrelineSpans(floor: PlanFloor, wall: PlanWall): Array<[number, number]> {\n const a = floor.nodes[wall.a];\n const b = floor.nodes[wall.b];\n if (!a || !b) return [];\n const len = dist(a, b);\n if (len < EPS) return [];\n\n const gaps: Array<[number, number]> = [];\n for (const opening of Object.values(floor.openings)) {\n if (opening.wallId !== wall.id) continue;\n const half = opening.width / 2;\n const start = Math.max(0, opening.offset - half);\n const end = Math.min(len, opening.offset + half);\n if (end > start) gaps.push([start, end]);\n }\n if (gaps.length === 0) return [[0, len]];\n\n gaps.sort((x, y) => x[0] - y[0]);\n const merged: Array<[number, number]> = [gaps[0]];\n for (let i = 1; i < gaps.length; i++) {\n const last = merged[merged.length - 1];\n if (gaps[i][0] <= last[1]) last[1] = Math.max(last[1], gaps[i][1]);\n else merged.push(gaps[i]);\n }\n\n const spans: Array<[number, number]> = [];\n let cursor = 0;\n for (const [start, end] of merged) {\n if (start - cursor > EPS) spans.push([cursor, start]);\n cursor = end;\n }\n if (len - cursor > EPS) spans.push([cursor, len]);\n return spans;\n}\n\n/** Everything a renderer needs to draw a door leaf, swing arc or window symbol. */\nexport function openingGeometry(\n floor: PlanFloor,\n opening: PlanOpening,\n): { centre: PlanPoint; dir: PlanPoint; normal: PlanPoint; p1: PlanPoint; p2: PlanPoint } | null {\n const wall = floor.walls[opening.wallId];\n if (!wall) return null;\n const a = floor.nodes[wall.a];\n const b = floor.nodes[wall.b];\n if (!a || !b) return null;\n const len = dist(a, b);\n if (len < EPS) return null;\n\n const dir = { x: (b.x - a.x) / len, y: (b.y - a.y) / len };\n const normal = { x: -dir.y, y: dir.x };\n const half = Math.min(opening.width / 2, len / 2);\n const offset = Math.min(Math.max(opening.offset, half), len - half);\n const centre = { x: a.x + dir.x * offset, y: a.y + dir.y * offset };\n\n return {\n centre,\n dir,\n normal,\n p1: { x: centre.x - dir.x * half, y: centre.y - dir.y * half },\n p2: { x: centre.x + dir.x * half, y: centre.y + dir.y * half },\n };\n}\n\n/** One directed step along a chain. */\ninterface Step {\n wall: PlanWall;\n from: string;\n to: string;\n}\n\n/**\n * Maximal runs of same-thickness walls joined through degree-2 nodes.\n *\n * A chain may only continue through a node whose TOTAL degree is 2 and whose two walls\n * share a thickness: a different stroke width means a different path, and a degree-3+\n * node is a junction rather than a corner.\n */\nfunction buildChains(floor: PlanFloor, walls: PlanWall[], degrees: Map<string, number>): Step[][] {\n const byId = new Map(walls.map((w) => [w.id, w]));\n const atNode = new Map<string, PlanWall[]>();\n for (const wall of walls) {\n for (const nodeId of [wall.a, wall.b]) {\n const list = atNode.get(nodeId);\n if (list) list.push(wall);\n else atNode.set(nodeId, [wall]);\n }\n }\n\n const continuesThrough = (nodeId: string): PlanWall[] | null => {\n if ((degrees.get(nodeId) ?? 0) !== 2) return null;\n const here = atNode.get(nodeId);\n // Both walls must be in this thickness group, or the chain has to break here.\n if (!here || here.length !== 2) return null;\n return here;\n };\n\n const used = new Set<string>();\n const chains: Step[][] = [];\n\n const other = (wall: PlanWall, nodeId: string): string => (wall.a === nodeId ? wall.b : wall.a);\n\n for (const wall of walls) {\n if (used.has(wall.id)) continue;\n\n // Walk backwards to the start of the run, unless it closes on itself.\n let startWall = wall;\n let startNode = wall.a;\n const seenBack = new Set<string>([wall.id]);\n for (;;) {\n const pair = continuesThrough(startNode);\n if (!pair) break;\n const prev = pair[0].id === startWall.id ? pair[1] : pair[0];\n if (seenBack.has(prev.id)) break; // closed loop\n seenBack.add(prev.id);\n startWall = prev;\n startNode = other(prev, startNode);\n }\n\n // Walk forwards, collecting directed steps.\n const steps: Step[] = [];\n let current = startWall;\n let from = startNode;\n for (;;) {\n if (used.has(current.id)) break;\n used.add(current.id);\n const to = other(current, from);\n steps.push({ wall: current, from, to });\n const pair = continuesThrough(to);\n if (!pair) break;\n const next = pair[0].id === current.id ? pair[1] : pair[0];\n if (used.has(next.id)) break;\n current = next;\n from = to;\n if (!byId.has(current.id)) break;\n }\n\n if (steps.length > 0) chains.push(steps);\n }\n\n return chains;\n}\n\n/**\n * The outer corner of the one REFLEX gap at a node, as a two-legged stub.\n *\n * A chain's `stroke-linejoin` mitres the corner wherever a wall bends, but a chain has\n * to stop at a junction, and at a thickness change, so something else has to close the\n * corner there. Most junction corners need nothing: where the angular gap between two\n * neighbouring walls is at most 180 degrees, the two bands reach across it and already\n * meet at the mitre point, because both start AT the node rather than short of it.\n *\n * A gap WIDER than 180 degrees is the exception, and it is the only one. There the two\n * faces bounding the gap diverge instead of crossing, so nothing spans the gap and what\n * is left between the two butt ends is an open wedge biting into the node. It shows up\n * whenever every wall at a node points into the same half-plane: a wall running east, a\n * wall running north, and a third splitting the two leaves a quarter-circle bitten out\n * of the south-west side. Because the gaps around a node sum to 360 degrees, at most\n * one can be reflex, so a node never needs more than one of these.\n *\n * The stub is two legs laid back along the two walls bounding that gap. Stroked at the\n * wall thickness it stays within their own bands, adding nothing anywhere else, and its\n * mitre join fills the wedge and bevels under the same `stroke-miterlimit` as every\n * other corner. Filling every gap this way instead of only the reflex one is what used\n * to fire spikes out of narrow junctions: those gaps had nothing to fill, so the join\n * ran outside the walls, the further the tighter the angle.\n */\nfunction reflexCornerStubs(\n floor: PlanFloor,\n walls: PlanWall[],\n degrees: Map<string, number>,\n): Array<{ thickness: number; d: string }> {\n interface Arm {\n wall: PlanWall;\n dir: PlanPoint;\n angle: number;\n /** Millimetres of wall between the node and the first opening, if any. */\n reach: number;\n }\n\n const armsAt = new Map<string, Arm[]>();\n const addArm = (nodeId: string, arm: Arm) => {\n const list = armsAt.get(nodeId);\n if (list) list.push(arm);\n else armsAt.set(nodeId, [arm]);\n };\n\n for (const wall of walls) {\n const a = floor.nodes[wall.a];\n const b = floor.nodes[wall.b];\n const len = dist(a, b);\n if (len < EPS) continue;\n const spans = wallCentrelineSpans(floor, wall);\n if (spans.length === 0) continue;\n\n const dir = { x: (b.x - a.x) / len, y: (b.y - a.y) / len };\n const first = spans[0];\n const last = spans[spans.length - 1];\n addArm(wall.a, {\n wall,\n dir,\n angle: Math.atan2(dir.y, dir.x),\n reach: first[0] < EPS ? first[1] : 0,\n });\n addArm(wall.b, {\n wall,\n dir: { x: -dir.x, y: -dir.y },\n angle: Math.atan2(-dir.y, -dir.x),\n reach: last[1] > len - EPS ? len - last[0] : 0,\n });\n }\n\n const stubs: Array<{ thickness: number; d: string }> = [];\n const TAU = Math.PI * 2;\n\n for (const [nodeId, arms] of armsAt) {\n if (arms.length < 2) continue;\n /*\n * A chain runs straight through a plain corner, and its own join closes it. The test\n * has to be the one `buildChains` uses, which reads the degree of the node in the\n * WHOLE floor: drawing a subset of the walls, two arms at a T junction look like a\n * corner but the chain still breaks there, and skipping the fill left a notch bitten\n * out of the join.\n */\n const chainRunsThrough =\n (degrees.get(nodeId) ?? 0) === 2 &&\n arms.length === 2 &&\n arms[0].wall.thickness === arms[1].wall.thickness;\n if (chainRunsThrough) continue;\n\n arms.sort((p, q) => p.angle - q.angle);\n\n let widest = 0;\n let widestGap = -1;\n for (let i = 0; i < arms.length; i++) {\n const next = arms[(i + 1) % arms.length];\n const gap = i === arms.length - 1 ? next.angle + TAU - arms[i].angle : next.angle - arms[i].angle;\n if (gap > widestGap) {\n widestGap = gap;\n widest = i;\n }\n }\n if (widestGap <= Math.PI + EPS) continue;\n\n const from = arms[widest];\n const to = arms[(widest + 1) % arms.length];\n // The thinner of the two, so the stub can never bulge out past the thinner wall.\n const thickness = Math.min(from.wall.thickness, to.wall.thickness);\n // Half a thickness of leg is enough to carry the join, and an opening close to the\n // node shortens it so the stub cannot paint across the jamb.\n const legFrom = Math.min(thickness / 2, from.reach);\n const legTo = Math.min(thickness / 2, to.reach);\n if (legFrom < EPS || legTo < EPS) continue;\n\n const node = floor.nodes[nodeId];\n stubs.push({\n thickness,\n d:\n `M ${fmt(node.x + from.dir.x * legFrom)},${fmt(node.y + from.dir.y * legFrom)}` +\n ` L ${fmt(node.x)},${fmt(node.y)}` +\n ` L ${fmt(node.x + to.dir.x * legTo)},${fmt(node.y + to.dir.y * legTo)}`,\n });\n }\n\n return stubs;\n}\n\n/**\n * Builds the stroke path data for every wall, grouped by thickness, plus the cap lines\n * the two-pass trick cannot produce on its own.\n */\nexport function buildWallPaths(\n floor: PlanFloor,\n options: BuildWallPathsOptions = {},\n): WallPathResult {\n const { wallIds, capFreeEnds = true, capThicknessSteps = true } = options;\n\n const walls = Object.values(floor.walls).filter((w) => {\n if (wallIds && !wallIds.has(w.id)) return false;\n const a = floor.nodes[w.a];\n const b = floor.nodes[w.b];\n return a && b && dist(a, b) > EPS && w.a !== w.b;\n });\n if (walls.length === 0) return { groups: [], caps: [] };\n\n const degrees = nodeDegrees(floor);\n const caps: WallCapLine[] = [];\n\n // ── Group by thickness: one stroke width per path. ────────────────────────────\n const byThickness = new Map<number, PlanWall[]>();\n for (const wall of walls) {\n const list = byThickness.get(wall.thickness);\n if (list) list.push(wall);\n else byThickness.set(wall.thickness, [wall]);\n }\n\n const subpathsByThickness = new Map<number, string[]>();\n const pushSubpath = (thickness: number, d: string) => {\n const list = subpathsByThickness.get(thickness);\n if (list) list.push(d);\n else subpathsByThickness.set(thickness, [d]);\n };\n\n for (const [thickness, groupWalls] of byThickness) {\n for (const chain of buildChains(floor, groupWalls, degrees)) {\n let current: PlanPoint[] = [];\n const flush = () => {\n if (current.length >= 2) {\n pushSubpath(\n thickness,\n current.map((p, i) => `${i === 0 ? 'M' : 'L'} ${fmt(p.x)},${fmt(p.y)}`).join(' '),\n );\n }\n current = [];\n };\n\n for (const step of chain) {\n const a = floor.nodes[step.from];\n const b = floor.nodes[step.to];\n const len = dist(a, b);\n if (len < EPS) continue;\n\n // Spans come back in the wall's own a->b direction; flip them when the chain\n // traverses the wall the other way.\n let spans = wallCentrelineSpans(floor, step.wall);\n if (step.from !== step.wall.a) {\n spans = spans.map(([s, e]) => [len - e, len - s] as [number, number]).reverse();\n }\n\n for (const [s, e] of spans) {\n const p1 = lerpPoint(a, b, s / len);\n const p2 = lerpPoint(a, b, e / len);\n if (current.length > 0 && samePoint(current[current.length - 1], p1)) {\n current.push(p2);\n } else {\n flush();\n current = [p1, p2];\n }\n }\n // An opening reaching the far end leaves nothing to continue from.\n if (spans.length === 0 || spans[spans.length - 1][1] < len - EPS) flush();\n }\n\n // A chain that returns to where it started closes properly, so the final corner\n // gets a miter join like every other corner rather than two butt caps.\n const first = chain[0];\n const last = chain[chain.length - 1];\n const closed = first.from === last.to;\n if (closed && current.length >= 3 && samePoint(current[0], current[current.length - 1])) {\n const ring = current.slice(0, -1);\n pushSubpath(\n thickness,\n `${ring.map((p, i) => `${i === 0 ? 'M' : 'L'} ${fmt(p.x)},${fmt(p.y)}`).join(' ')} Z`,\n );\n current = [];\n } else {\n flush();\n }\n }\n }\n\n for (const stub of reflexCornerStubs(floor, walls, degrees)) pushSubpath(stub.thickness, stub.d);\n\n // ── Caps. ─────────────────────────────────────────────────────────────────────\n const capAt = (wall: PlanWall, offset: number, reason: WallCapReason) => {\n const a = floor.nodes[wall.a];\n const b = floor.nodes[wall.b];\n const len = dist(a, b);\n if (len < EPS) return;\n const dir = { x: (b.x - a.x) / len, y: (b.y - a.y) / len };\n const normal = { x: -dir.y, y: dir.x };\n const centre = { x: a.x + dir.x * offset, y: a.y + dir.y * offset };\n const half = wall.thickness / 2;\n caps.push({\n a: { x: centre.x - normal.x * half, y: centre.y - normal.y * half },\n b: { x: centre.x + normal.x * half, y: centre.y + normal.y * half },\n reason,\n wallId: wall.id,\n });\n };\n\n for (const wall of walls) {\n const a = floor.nodes[wall.a];\n const b = floor.nodes[wall.b];\n const len = dist(a, b);\n for (const [s, e] of wallCentrelineSpans(floor, wall)) {\n if (s > EPS) capAt(wall, s, 'jamb');\n else if (capFreeEnds && (degrees.get(wall.a) ?? 0) === 1) capAt(wall, 0, 'freeEnd');\n\n if (e < len - EPS) capAt(wall, e, 'jamb');\n else if (capFreeEnds && (degrees.get(wall.b) ?? 0) === 1) capAt(wall, len, 'freeEnd');\n }\n }\n\n if (capThicknessSteps) {\n const atNode = new Map<string, PlanWall[]>();\n for (const wall of walls) {\n for (const nodeId of [wall.a, wall.b]) {\n const list = atNode.get(nodeId);\n if (list) list.push(wall);\n else atNode.set(nodeId, [wall]);\n }\n }\n for (const [nodeId, incident] of atNode) {\n if (incident.length !== 2) continue;\n const [w1, w2] = incident;\n if (w1.thickness === w2.thickness) continue;\n // Draw the thicker wall's full cross-section. It overdraws a line across the\n // thinner wall's interior, which is the usual CAD convention for a joint between\n // two wall constructions, and far better than leaving no edge at all.\n const thicker = w1.thickness >= w2.thickness ? w1 : w2;\n const offset = thicker.a === nodeId ? 0 : dist(floor.nodes[thicker.a], floor.nodes[thicker.b]);\n capAt(thicker, offset, 'thicknessStep');\n }\n }\n\n const groups: WallPathGroup[] = [...subpathsByThickness.entries()]\n .sort((x, y) => x[0] - y[0])\n .map(([thickness, subpaths]) => ({ thickness, d: subpaths.join(' ') }));\n\n return { groups, caps };\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{PlanUnitSystem as e}from"./floorPlan.types.mjs";import{boundsOf as t}from"./floorPlan.geometry.mjs";import{newId as n}from"./floorPlan.graph.mjs";function r(e={}){return{id:e.floorId??n(`f`),label:e.floorLabel??`Ground floor`,elevation:0,height:e.height??2500,nodes:{},walls:{},openings:{},rooms:{}}}function i(t={}){return{id:t.id??n(`plan`),name:t.name,version:1,unitSystem:t.unitSystem??e.Metric,floors:[r(t)],revision:0}}function a(e,t){return t?e.floors.find(e=>e.id===t)??null:e.floors[0]??null}function o(e,t,n){let i=a(e,t);if(!i)return{plan:e,floor:r(),result:void 0,issues:[]};let o=n(i);return o.floor===i?{...o,plan:e}:{...o,plan:{...e,floors:e.floors.map(e=>e.id===i.id?o.floor:e),revision:e.revision+1}}}function s(e,t){let n=e.floors.some(e=>e.id===t.id);return{...e,floors:n?e.floors.map(e=>e.id===t.id?t:e):[...e.floors,t],revision:e.revision+1}}function c(e,t){return e.floors.findIndex(e=>e.id===t)}function l(e,t){let n=c(e,t);return n>0?e.floors[n-1]:null}function u(e,t={}){let i=e.floors[0]?.elevation??0,a=Math.max(0,Math.min(t.index??e.floors.length,e.floors.length)),o=e.floors[a-1],s={...r({height:t.height??o?.height??2500}),id:t.id??n(`f`),label:t.label??v(e),elevation:o?o.elevation+o.height:0},c=[...e.floors.slice(0,a),s,...e.floors.slice(a)];return{plan:{...e,floors:_(c,i),revision:e.revision+1},floor:s}}function d(e,t){if(e.floors.length<=1)return e;let n=e.floors[0].elevation,r=e.floors.filter(e=>e.id!==t);return r.length===e.floors.length?e:{...e,floors:_(r,n),revision:e.revision+1}}function f(e,t,r={}){let i=a(e,t);if(!i)return{plan:e,floor:null};let o=new Map(Object.keys(i.nodes).map(e=>[e,n(`n`)])),s=new Map(Object.keys(i.walls).map(e=>[e,n(`w`)])),l={...i,id:n(`f`),label:r.label??`${i.label} copy`,nodes:{},walls:{},openings:{},rooms:{}};for(let e of Object.values(i.nodes)){let t=o.get(e.id);l.nodes[t]={...e,id:t}}for(let e of Object.values(i.walls)){let t=s.get(e.id);l.walls[t]={...e,id:t,a:o.get(e.a),b:o.get(e.b)}}for(let e of Object.values(i.openings)){let t=s.get(e.wallId);if(!t)continue;let r=n(`o`);l.openings[r]={...e,id:r,wallId:t}}for(let e of Object.values(i.rooms)){let t=n(`r`);l.rooms[t]={...e,id:t,anchor:{...e.anchor},wallIds:e.wallIds.map(e=>s.get(e)).filter(e=>!!e)}}let u=e.floors[0].elevation,d=c(e,t)+1,f=[...e.floors.slice(0,d),l,...e.floors.slice(d)];return{plan:{...e,floors:_(f,u),revision:e.revision+1},floor:l}}var p=500;function m(e,t,n){let r=a(e,t),i=Math.max(500,Math.round(n));if(!r||r.height===i)return e;let o=e.floors[0].elevation,s=e.floors.map(e=>e.id===r.id?{...e,height:i}:e);return{...e,floors:_(s,o),revision:e.revision+1}}function h(e,t,n){let r=a(e,t);return!r||r.label===n?e:s(e,{...r,label:n})}function g(e,t,n){let r=c(e,t),i=Math.max(0,Math.min(n,e.floors.length-1));if(r<0||r===i)return e;let a=e.floors[0].elevation,o=[...e.floors],[s]=o.splice(r,1);return o.splice(i,0,s),{...e,floors:_(o,a),revision:e.revision+1}}function _(e,t){let n=t;return e.map(e=>{let t=e.elevation===n?e:{...e,elevation:n};return n+=e.height,t})}function v(e){let t=new Set(e.floors.map(e=>e.label));for(let n=e.floors.length+1;;n++){let e=`Floor ${n}`;if(!t.has(e))return e}}function y(e){let n=t(Object.values(e.nodes).map(e=>({x:e.x,y:e.y})));if(!n)return null;let r=0;for(let t of Object.values(e.walls))t.thickness>r&&(r=t.thickness);let i=r/2,a=n.minX-i,o=n.minY-i,s=n.maxX+i,c=n.maxY+i;return{minX:a,minY:o,maxX:s,maxY:c,width:s-a,height:c-o}}export{p as MIN_FLOOR_HEIGHT,u as addFloor,f as duplicateFloor,r as emptyFloor,i as emptyFloorPlan,l as floorBelow,y as floorBounds,c as floorIndex,a as getFloor,g as moveFloor,d as removeFloor,h as renameFloor,s as setFloor,m as setFloorHeight,o as updateFloor};
2
+ //# sourceMappingURL=floorPlan.plan.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"floorPlan.plan.mjs","names":[],"sources":["../../../../package/common/floorPlan/floorPlan.plan.ts"],"sourcesContent":["/**\n * Document-level helpers: creating a plan, addressing a floor within it, and the\n * revision counter that memoisation keys off.\n */\n\nimport { boundsOf } from './floorPlan.geometry';\nimport { newId } from './floorPlan.graph';\nimport { PlanUnitSystem } from './floorPlan.types';\nimport type { FloorPlan, PlanEdit, PlanFloor, PlanPoint } from './floorPlan.types';\n\nexport interface EmptyFloorPlanOptions {\n id?: string;\n name?: string;\n floorId?: string;\n floorLabel?: string;\n unitSystem?: PlanUnitSystem;\n /** Default storey height in millimetres. */\n height?: number;\n}\n\nexport function emptyFloor(options: Omit<EmptyFloorPlanOptions, 'id' | 'name'> = {}): PlanFloor {\n return {\n id: options.floorId ?? newId('f'),\n label: options.floorLabel ?? 'Ground floor',\n elevation: 0,\n height: options.height ?? 2500,\n nodes: {},\n walls: {},\n openings: {},\n rooms: {},\n };\n}\n\nexport function emptyFloorPlan(options: EmptyFloorPlanOptions = {}): FloorPlan {\n return {\n id: options.id ?? newId('plan'),\n name: options.name,\n version: 1,\n unitSystem: options.unitSystem ?? PlanUnitSystem.Metric,\n floors: [emptyFloor(options)],\n revision: 0,\n };\n}\n\nexport function getFloor(plan: FloorPlan, floorId?: string): PlanFloor | null {\n if (!floorId) return plan.floors[0] ?? null;\n return plan.floors.find((f) => f.id === floorId) ?? null;\n}\n\n/**\n * Applies a floor-level edit to one floor of a plan, bumping `revision` so memoised\n * derivations know to recompute.\n *\n * The revision only moves when the edit actually returned a different floor, so a\n * gesture that changed nothing does not invalidate anything.\n */\nexport function updateFloor<T>(\n plan: FloorPlan,\n floorId: string | undefined,\n edit: (floor: PlanFloor) => PlanEdit<T>,\n): PlanEdit<T> & { plan: FloorPlan } {\n const floor = getFloor(plan, floorId);\n if (!floor) {\n return { plan, floor: emptyFloor(), result: undefined as T, issues: [] };\n }\n\n const applied = edit(floor);\n if (applied.floor === floor) {\n return { ...applied, plan };\n }\n\n return {\n ...applied,\n plan: {\n ...plan,\n floors: plan.floors.map((f) => (f.id === floor.id ? applied.floor : f)),\n revision: plan.revision + 1,\n },\n };\n}\n\n/** Replaces one floor outright, bumping the revision. */\nexport function setFloor(plan: FloorPlan, floor: PlanFloor): FloorPlan {\n const exists = plan.floors.some((f) => f.id === floor.id);\n return {\n ...plan,\n floors: exists ? plan.floors.map((f) => (f.id === floor.id ? floor : f)) : [...plan.floors, floor],\n revision: plan.revision + 1,\n };\n}\n\n// ─── Floors ────────────────────────────────────────────────────────────────────\n\n/**\n * Floors run bottom to top: index 0 is the lowest storey, and `elevation` rises with\n * the index. Nothing enforces that beyond these helpers, but `floorBelow` and the\n * drawer's underlay both read it, so an out-of-order `floors` array shows the wrong\n * storey underneath.\n */\nexport function floorIndex(plan: FloorPlan, floorId: string): number {\n return plan.floors.findIndex((f) => f.id === floorId);\n}\n\n/** The storey directly under this one, which is what an underlay traces. */\nexport function floorBelow(plan: FloorPlan, floorId: string): PlanFloor | null {\n const i = floorIndex(plan, floorId);\n return i > 0 ? plan.floors[i - 1] : null;\n}\n\nexport interface AddFloorOptions {\n label?: string;\n /** Millimetres. Defaults to the storey height of the floor it is added above. */\n height?: number;\n /** Where to put it. Defaults to directly above `aboveFloorId`, else on top. */\n index?: number;\n id?: string;\n}\n\n/**\n * Adds an empty storey and returns the plan with it, plus the floor itself.\n *\n * Its elevation is the top of the storey below, so a plan stays physically coherent\n * without the caller doing arithmetic. Everything above is pushed up by the new storey's\n * height, since inserting a floor into a building raises the ones over it.\n */\nexport function addFloor(\n plan: FloorPlan,\n options: AddFloorOptions = {},\n): { plan: FloorPlan; floor: PlanFloor } {\n const base = plan.floors[0]?.elevation ?? 0;\n const at = Math.max(0, Math.min(options.index ?? plan.floors.length, plan.floors.length));\n const below = plan.floors[at - 1];\n const height = options.height ?? below?.height ?? 2500;\n\n const floor: PlanFloor = {\n ...emptyFloor({ height }),\n id: options.id ?? newId('f'),\n label: options.label ?? nextFloorLabel(plan),\n elevation: below ? below.elevation + below.height : 0,\n };\n\n const floors = [...plan.floors.slice(0, at), floor, ...plan.floors.slice(at)];\n return { plan: { ...plan, floors: reElevate(floors, base), revision: plan.revision + 1 }, floor };\n}\n\n/**\n * Removes a storey, unless it is the last one: a plan with no floors has nowhere to\n * draw, and every consumer would need a special case for it.\n */\nexport function removeFloor(plan: FloorPlan, floorId: string): FloorPlan {\n if (plan.floors.length <= 1) return plan;\n const base = plan.floors[0].elevation;\n const floors = plan.floors.filter((f) => f.id !== floorId);\n if (floors.length === plan.floors.length) return plan;\n return { ...plan, floors: reElevate(floors, base), revision: plan.revision + 1 };\n}\n\n/**\n * Copies a storey, including its geometry, directly above itself.\n *\n * Every id is regenerated and every reference rewired to the copy, so the two storeys\n * share nothing: an edit to one must not reach through into the other. This is the usual\n * way to start an upper floor, since the walls below are mostly where the walls above go.\n */\nexport function duplicateFloor(\n plan: FloorPlan,\n floorId: string,\n options: { label?: string } = {},\n): { plan: FloorPlan; floor: PlanFloor | null } {\n const source = getFloor(plan, floorId);\n if (!source) return { plan, floor: null };\n\n const nodeIds = new Map(Object.keys(source.nodes).map((id) => [id, newId('n')]));\n const wallIds = new Map(Object.keys(source.walls).map((id) => [id, newId('w')]));\n\n const copy: PlanFloor = {\n ...source,\n id: newId('f'),\n label: options.label ?? `${source.label} copy`,\n nodes: {},\n walls: {},\n openings: {},\n rooms: {},\n };\n\n for (const node of Object.values(source.nodes)) {\n const id = nodeIds.get(node.id)!;\n copy.nodes[id] = { ...node, id };\n }\n for (const wall of Object.values(source.walls)) {\n const id = wallIds.get(wall.id)!;\n copy.walls[id] = { ...wall, id, a: nodeIds.get(wall.a)!, b: nodeIds.get(wall.b)! };\n }\n for (const opening of Object.values(source.openings)) {\n const wallId = wallIds.get(opening.wallId);\n if (!wallId) continue;\n const id = newId('o');\n copy.openings[id] = { ...opening, id, wallId };\n }\n for (const room of Object.values(source.rooms)) {\n const id = newId('r');\n copy.rooms[id] = {\n ...room,\n id,\n anchor: { ...room.anchor },\n // Room identity is matched on these, so they have to point at the copy's own walls\n // or the first derive would strand every room in it.\n wallIds: room.wallIds.map((w) => wallIds.get(w)).filter((w): w is string => !!w),\n };\n }\n\n const base = plan.floors[0].elevation;\n const at = floorIndex(plan, floorId) + 1;\n const floors = [...plan.floors.slice(0, at), copy, ...plan.floors.slice(at)];\n return { plan: { ...plan, floors: reElevate(floors, base), revision: plan.revision + 1 }, floor: copy };\n}\n\n/** Millimetres. Low enough for a crawl space, high enough to stop a zero-height storey. */\nexport const MIN_FLOOR_HEIGHT = 500;\n\n/**\n * Sets a storey's height, and drops or lifts everything above it to suit.\n *\n * This is why it is not a plain `setFloor`: a storey's height is what the next one sits\n * on, so changing it without re-stacking leaves the floors above either buried in it or\n * floating over it, which nothing in 2D would ever show.\n */\nexport function setFloorHeight(plan: FloorPlan, floorId: string, height: number): FloorPlan {\n const floor = getFloor(plan, floorId);\n const next = Math.max(MIN_FLOOR_HEIGHT, Math.round(height));\n if (!floor || floor.height === next) return plan;\n\n const base = plan.floors[0].elevation;\n const floors = plan.floors.map((f) => (f.id === floor.id ? { ...f, height: next } : f));\n return { ...plan, floors: reElevate(floors, base), revision: plan.revision + 1 };\n}\n\n/** Renames a storey. Its own field, since nothing else about a floor is text. */\nexport function renameFloor(plan: FloorPlan, floorId: string, label: string): FloorPlan {\n const floor = getFloor(plan, floorId);\n if (!floor || floor.label === label) return plan;\n return setFloor(plan, { ...floor, label });\n}\n\n/** Moves a storey up or down the stack, re-stacking the elevations behind it. */\nexport function moveFloor(plan: FloorPlan, floorId: string, toIndex: number): FloorPlan {\n const from = floorIndex(plan, floorId);\n const to = Math.max(0, Math.min(toIndex, plan.floors.length - 1));\n if (from < 0 || from === to) return plan;\n const base = plan.floors[0].elevation;\n const floors = [...plan.floors];\n const [moved] = floors.splice(from, 1);\n floors.splice(to, 0, moved);\n return { ...plan, floors: reElevate(floors, base), revision: plan.revision + 1 };\n}\n\n/**\n * Re-stacks elevations so each storey sits on the one below. Without this, adding or\n * removing a floor leaves a gap or an overlap in the building that only shows up in 3D.\n *\n * `base` is the building's own ground level and must come from the plan BEFORE the\n * edit: reading it off whichever floor ends up lowest moves the whole building whenever\n * two storeys of different heights are reordered.\n */\nfunction reElevate(floors: PlanFloor[], base: number): PlanFloor[] {\n let elevation = base;\n return floors.map((floor) => {\n const next = floor.elevation === elevation ? floor : { ...floor, elevation };\n elevation += floor.height;\n return next;\n });\n}\n\n/** `Floor 2`, `Floor 3`, … skipping any number already used. */\nfunction nextFloorLabel(plan: FloorPlan): string {\n const used = new Set(plan.floors.map((f) => f.label));\n for (let n = plan.floors.length + 1; ; n++) {\n const label = `Floor ${n}`;\n if (!used.has(label)) return label;\n }\n}\n\nexport interface PlanBounds {\n minX: number;\n minY: number;\n maxX: number;\n maxY: number;\n width: number;\n height: number;\n}\n\n/**\n * Bounding box of a floor's geometry, padded by half the thickest wall so the outer\n * face of every wall is inside it rather than its centreline.\n */\nexport function floorBounds(floor: PlanFloor): PlanBounds | null {\n const points: PlanPoint[] = Object.values(floor.nodes).map((n) => ({ x: n.x, y: n.y }));\n const raw = boundsOf(points);\n if (!raw) return null;\n\n let maxThickness = 0;\n for (const wall of Object.values(floor.walls)) {\n if (wall.thickness > maxThickness) maxThickness = wall.thickness;\n }\n const pad = maxThickness / 2;\n\n const minX = raw.minX - pad;\n const minY = raw.minY - pad;\n const maxX = raw.maxX + pad;\n const maxY = raw.maxY + pad;\n return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{PlanIssueKind as e}from"./floorPlan.types.mjs";import{pointInRings as t,representativePoint as n}from"./floorPlan.geometry.mjs";import{newId as r}from"./floorPlan.graph.mjs";import{deriveFaces as i}from"./floorPlan.faces.mjs";var a={boundary:2,anchor:1,area:.25};function o(e,t){if(e.size===0&&t.size===0)return 0;let n=0;for(let r of e)t.has(r)&&(n+=1);let r=e.size+t.size-n;return r===0?0:n/r}function s(e,n,r){let i=o(new Set(e.wallIds),r),s=+!!t(e.anchor,n.outer,n.holes),c=e.area>0&&n.area>0?Math.min(e.area,n.area)/Math.max(e.area,n.area):0;return a.boundary*i+a.anchor*s+a.area*c}function c(t){let r=i(t),a=[],o=Object.values(t.rooms),c=r.map(e=>new Set(e.wallIds)),l=[];for(let e=0;e<o.length;e++)for(let t=0;t<r.length;t++){let n=s(o[e],r[t],c[t]);n>.15&&l.push({roomIndex:e,faceIndex:t,score:n})}l.sort((e,t)=>{if(t.score!==e.score)return t.score-e.score;let n=o[e.roomIndex].seq-o[t.roomIndex].seq;return n===0?r[e.faceIndex].id<r[t.faceIndex].id?-1:1:n});let u=Array(o.length).fill(!1),d=Array(r.length).fill(-1);for(let e of l)u[e.roomIndex]||d[e.faceIndex]===-1&&(u[e.roomIndex]=!0,d[e.faceIndex]=e.roomIndex);let f={...t.rooms},p=r.map((e,t)=>{let r=n(e.outer,e.holes),i=d[t];if(i===-1)return{...e,roomId:null,meta:null,anchor:r};let a=o[i],s=a.anchorPinned?a.anchor:r,c={...a,anchor:s,wallIds:[...new Set(e.wallIds)],area:e.area};return f[a.id]=c,{...e,roomId:a.id,meta:c,anchor:s}});for(let t=0;t<o.length;t++)u[t]||a.push({kind:e.UnmatchedRoom,message:`"${o[t].name}" is not currently enclosed by any walls.`,roomIds:[o[t].id]});return{floor:{...t,rooms:f},rooms:p,issues:a}}var l=0;function u(e,t={}){let{rooms:n,floor:i}=c(e),a={...i.rooms},o=[],s=0,u=new Set(Object.values(a).map(e=>e.name));for(let e of n){if(e.roomId)continue;s+=1,l+=1;let n=t.nameFor?.(s,e);if(!n){let e=Object.keys(a).length+1;for(n=`Room ${e}`;u.has(n);)e+=1,n=`Room ${e}`}u.add(n);let i={id:r(`r`),name:n,anchor:e.anchor,anchorPinned:!1,seq:l,wallIds:[...new Set(e.wallIds)],area:e.area};a[i.id]=i,o.push(i)}return{floor:{...i,rooms:a},created:o}}function d(e){let{rooms:t}=c(e),n=new Set(t.map(e=>e.roomId).filter(Boolean)),r={},i=[];for(let t of Object.values(e.rooms))n.has(t.id)?r[t.id]=t:i.push(t.id);return{floor:{...e,rooms:r},removed:i}}function f(e,n){let r=null;for(let i of e)t(n,i.outer,i.holes)&&(!r||i.area<r.area)&&(r=i);return r}export{u as commitDerivation,c as derivePlan,d as pruneUnmatchedRooms,f as roomAtPoint};
2
+ //# sourceMappingURL=floorPlan.rooms.mjs.map
@@ -0,0 +1 @@
1
+ {"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 { newId } from './floorPlan.graph';\nimport { PlanIssueKind } from './floorPlan.types';\nimport type {\n DerivedRoom,\n PlanDerivation,\n PlanFace,\n PlanFloor,\n PlanIssue,\n PlanRoom,\n} 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\n ? Math.min(room.area, face.area) / Math.max(room.area, face.area)\n : 0;\n return (\n MATCH_WEIGHTS.boundary * boundary +\n MATCH_WEIGHTS.anchor * inside +\n MATCH_WEIGHTS.area * areaRatio\n );\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 /** Names new rooms. Receives the 1-based index among rooms created in this call. */\n nameFor?: (index: number, face: DerivedRoom) => string;\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 let index = 0;\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 index += 1;\n roomSeq += 1;\n\n let name = options.nameFor?.(index, face);\n if (!name) {\n let n = Object.keys(nextRooms).length + 1;\n name = `Room ${n}`;\n while (existingNames.has(name)) {\n n += 1;\n name = `Room ${n}`;\n }\n }\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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"}
@@ -0,0 +1,2 @@
1
+ import{dist as e,pointToSegment as t,projectOnSegment as n,segmentIntersection as r}from"./floorPlan.geometry.mjs";var i=function(e){return e.None=`none`,e.Node=`node`,e.Wall=`wall`,e.Intersection=`intersection`,e.Extension=`extension`,e.Alignment=`alignment`,e.Angle=`angle`,e.Grid=`grid`,e}({}),a={enabled:[`node`,`intersection`,`wall`,`angle`,`extension`,`alignment`,`grid`],gridSize:100,angleStepDeg:0,toleranceScreenPx:10,nodeToleranceScreenPx:12};function o(e){let t=e.getScreenCTM();if(!t)return 1;let n=Math.hypot(t.a,t.b);return n===0?1:1/n}function s(e,t,n,r){return{x:r.x+(e-n.left)/r.zoom,y:r.y+(t-n.top)/r.zoom}}function c(e,t){let n=e.x-t.point.x,r=e.y-t.point.y;return Math.abs(n*t.dir.y-r*t.dir.x)}function l(e,t){let n=e.x-t.point.x,r=e.y-t.point.y,i=n*t.dir.x+r*t.dir.y;return{x:t.point.x+t.dir.x*i,y:t.point.y+t.dir.y*i}}function u(e,t){let n=e.dir.x*t.dir.y-e.dir.y*t.dir.x;if(Math.abs(n)<1e-9)return null;let r=t.point.x-e.point.x,i=t.point.y-e.point.y,a=(r*t.dir.y-i*t.dir.x)/n;return{x:e.point.x+e.dir.x*a,y:e.point.y+e.dir.y*a}}function d(e,t){let n=Math.abs(e.dir.x*t.dir.x+e.dir.y*t.dir.y);return Math.acos(Math.min(1,n))}function f(e,t){return t>0?Math.round(e/t)*t:e}function p(i,o){let s={...a,...o.options},p=new Set(s.enabled),m=e=>{if(!o.origin)return e;let t=e.point.x-o.origin.x,n=e.point.y-o.origin.y;return{...e,angle:Math.atan2(n,t),distance:Math.hypot(t,n)}};if(p.size===0)return m({point:i,kind:`none`,guides:[]});let h=s.toleranceScreenPx*o.pxToWorld,g=(s.nodeToleranceScreenPx??s.toleranceScreenPx)*o.pxToWorld,{floor:_}=o,v=(()=>{if(!p.has(`angle`)||!o.origin||s.angleStepDeg<=0)return null;let e=s.angleStepDeg*Math.PI/180,t=Math.atan2(i.y-o.origin.y,i.x-o.origin.x),n=[{angle:0,relativeTo:`world`}];o.referenceAngle!==void 0&&n.push({angle:o.referenceAngle,relativeTo:`reference`});let r=null;for(let a of n){let n=a.angle+Math.round((t-a.angle)/e)*e,s={x:Math.cos(n),y:Math.sin(n)},l={kind:`angle`,point:o.origin,dir:s,residual:0,relativeTo:a.relativeTo};l.residual=c(i,l),(!r||l.residual<r.residual)&&(r=l)}return r})(),y=e=>!v||c(e,v)<=h;if(p.has(`node`)){let t=null;for(let n of Object.values(_.nodes)){if(o.excludeNodeIds?.has(n.id))continue;let r=e(i,n);r<=g&&y(n)&&(!t||r<t.d)&&(t={id:n.id,point:{x:n.x,y:n.y},d:r})}if(t)return m({point:t.point,kind:`node`,nodeId:t.id,guides:[]})}let b=Object.values(_.walls).filter(e=>!o.excludeWallIds?.has(e.id));if(p.has(`intersection`)){let t=null;for(let n=0;n<b.length;n++)for(let a=n+1;a<b.length;a++){let o=_.nodes[b[n].a],s=_.nodes[b[n].b],c=_.nodes[b[a].a],l=_.nodes[b[a].b];if(!o||!s||!c||!l)continue;let u=r(o,s,c,l);if(!u.point)continue;let d=e(i,u.point);d<=h&&y(u.point)&&(!t||d<t.d)&&(t={point:u.point,d})}if(t)return m({point:t.point,kind:`intersection`,guides:[]})}if(p.has(`wall`)){let e=null;for(let r of b){let a=_.nodes[r.a],o=_.nodes[r.b];if(!a||!o)continue;let s=t(i,a,o);s.dist<=h&&y(s.point)&&(!e||s.dist<e.d)&&(e={wallId:r.id,point:n(i,a,o),t:s.t,d:s.dist})}if(e)return m({point:e.point,kind:`wall`,wallId:e.wallId,t:e.t,guides:[]})}let x=[];if(p.has(`extension`)){let n=null;for(let r of b){let a=_.nodes[r.a],o=_.nodes[r.b];if(!a||!o)continue;let s=e(a,o);if(s<1e-6)continue;let l={x:(o.x-a.x)/s,y:(o.y-a.y)/s},u={kind:`extension`,point:{x:a.x,y:a.y},dir:l,residual:0};u.residual=c(i,u);let d=t(i,a,o).dist<=h;u.residual<=h&&!d&&(!n||u.residual<n.residual)&&(n=u)}n&&x.push(n)}if(p.has(`alignment`)){let e=null,t=null;for(let n of Object.values(_.nodes)){if(o.excludeNodeIds?.has(n.id))continue;let r=Math.abs(i.x-n.x);r<=h&&(!e||r<e.residual)&&(e={kind:`alignment`,point:{x:n.x,y:n.y},dir:{x:0,y:1},residual:r});let a=Math.abs(i.y-n.y);a<=h&&(!t||a<t.residual)&&(t={kind:`alignment`,point:{x:n.x,y:n.y},dir:{x:1,y:0},residual:a})}e&&x.push(e),t&&x.push(t)}let S=(e,t)=>({kind:e.kind,a:{x:t.x-e.dir.x*1e5,y:t.y-e.dir.y*1e5},b:{x:t.x+e.dir.x*1e5,y:t.y+e.dir.y*1e5},infinite:!0});if(v){let t=x.filter(e=>e.residual<=h&&d(v,e)>15*Math.PI/180).sort((e,t)=>e.residual-t.residual)[0];if(t){let n=u(v,t);if(n&&e(n,i)<=h*4)return m({point:n,kind:`angle`,guides:[S(v,n),S(t,n)],angleRelativeTo:v.relativeTo})}let n=l(i,v);if(p.has(`grid`)&&s.gridSize>0){let e=f((n.x-v.point.x)*v.dir.x+(n.y-v.point.y)*v.dir.y,s.gridSize);n={x:v.point.x+v.dir.x*e,y:v.point.y+v.dir.y*e}}return m({point:n,kind:`angle`,guides:[S(v,n)],angleRelativeTo:v.relativeTo})}if(x.length>=2){let[t,n]=[...x].sort((e,t)=>e.residual-t.residual),r=u(t,n);if(r&&d(t,n)>15*Math.PI/180&&e(r,i)<=h*2)return m({point:r,kind:`intersection`,guides:[S(t,r),S(n,r)]})}if(x.length>=1){let e=[...x].sort((e,t)=>e.residual-t.residual)[0],t=l(i,e);if(p.has(`grid`)&&s.gridSize>0){let n=f((t.x-e.point.x)*e.dir.x+(t.y-e.point.y)*e.dir.y,s.gridSize);t={x:e.point.x+e.dir.x*n,y:e.point.y+e.dir.y*n}}return m({point:t,kind:e.kind,guides:[S(e,t)],angleRelativeTo:e.relativeTo})}return p.has(`grid`)&&s.gridSize>0?m({point:{x:f(i.x,s.gridSize),y:f(i.y,s.gridSize)},kind:`grid`,guides:[]}):m({point:i,kind:`none`,guides:[]})}export{a as DEFAULT_SNAP_OPTIONS,i as SnapKind,s as clientToWorld,p as resolveSnap,o as screenScale};
2
+ //# sourceMappingURL=floorPlan.snap.mjs.map