planscript 0.1.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 (55) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +277 -0
  3. package/dist/ast/types.d.ts +195 -0
  4. package/dist/ast/types.d.ts.map +1 -0
  5. package/dist/ast/types.js +5 -0
  6. package/dist/ast/types.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +100 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/compiler.d.ts +30 -0
  12. package/dist/compiler.d.ts.map +1 -0
  13. package/dist/compiler.js +80 -0
  14. package/dist/compiler.js.map +1 -0
  15. package/dist/exporters/json.d.ts +13 -0
  16. package/dist/exporters/json.d.ts.map +1 -0
  17. package/dist/exporters/json.js +17 -0
  18. package/dist/exporters/json.js.map +1 -0
  19. package/dist/exporters/svg.d.ts +27 -0
  20. package/dist/exporters/svg.d.ts.map +1 -0
  21. package/dist/exporters/svg.js +684 -0
  22. package/dist/exporters/svg.js.map +1 -0
  23. package/dist/geometry/index.d.ts +13 -0
  24. package/dist/geometry/index.d.ts.map +1 -0
  25. package/dist/geometry/index.js +333 -0
  26. package/dist/geometry/index.js.map +1 -0
  27. package/dist/geometry/types.d.ts +34 -0
  28. package/dist/geometry/types.d.ts.map +1 -0
  29. package/dist/geometry/types.js +2 -0
  30. package/dist/geometry/types.js.map +1 -0
  31. package/dist/index.d.ts +10 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +20 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/lowering/index.d.ts +25 -0
  36. package/dist/lowering/index.d.ts.map +1 -0
  37. package/dist/lowering/index.js +217 -0
  38. package/dist/lowering/index.js.map +1 -0
  39. package/dist/parser/grammar.d.ts +168 -0
  40. package/dist/parser/grammar.d.ts.map +1 -0
  41. package/dist/parser/grammar.js +7341 -0
  42. package/dist/parser/grammar.js.map +1 -0
  43. package/dist/parser/index.d.ts +27 -0
  44. package/dist/parser/index.d.ts.map +1 -0
  45. package/dist/parser/index.js +26 -0
  46. package/dist/parser/index.js.map +1 -0
  47. package/dist/validation/index.d.ts +22 -0
  48. package/dist/validation/index.d.ts.map +1 -0
  49. package/dist/validation/index.js +243 -0
  50. package/dist/validation/index.js.map +1 -0
  51. package/examples/house-with-dimensions.svg +106 -0
  52. package/examples/house.json +478 -0
  53. package/examples/house.psc +86 -0
  54. package/examples/house.svg +57 -0
  55. package/package.json +54 -0
@@ -0,0 +1,684 @@
1
+ const defaultOptions = {
2
+ width: 1000,
3
+ height: 800,
4
+ padding: 60,
5
+ scale: 1,
6
+ showLabels: true,
7
+ showDimensions: false,
8
+ showFootprintDimensions: true,
9
+ backgroundColor: '#ffffff',
10
+ wallColor: '#2c3e50',
11
+ wallWidth: 3,
12
+ roomFillColor: '#ecf0f1',
13
+ roomStrokeColor: '#bdc3c7',
14
+ roomStrokeWidth: 1,
15
+ doorColor: '#e74c3c',
16
+ windowColor: '#3498db',
17
+ footprintColor: '#7f8c8d',
18
+ labelFontSize: 14,
19
+ labelColor: '#2c3e50',
20
+ dimensionColor: '#666666',
21
+ dimensionFontSize: 10,
22
+ dimensionOffset: 20,
23
+ dimensionStaggerStep: 15,
24
+ };
25
+ function createTransform(geometry, opts) {
26
+ // Calculate bounds
27
+ let minX = Infinity;
28
+ let minY = Infinity;
29
+ let maxX = -Infinity;
30
+ let maxY = -Infinity;
31
+ for (const point of geometry.footprint.points) {
32
+ minX = Math.min(minX, point.x);
33
+ minY = Math.min(minY, point.y);
34
+ maxX = Math.max(maxX, point.x);
35
+ maxY = Math.max(maxY, point.y);
36
+ }
37
+ const contentWidth = maxX - minX;
38
+ const contentHeight = maxY - minY;
39
+ // Calculate effective padding - need extra space for dimensions
40
+ // Estimate: base padding + dimension offset + potential stagger levels + text
41
+ const dimensionSpace = opts.showDimensions
42
+ ? opts.dimensionOffset + opts.dimensionStaggerStep * 4 + 30 // room for footprint dims + text
43
+ : 0;
44
+ const effectivePadding = opts.padding + dimensionSpace;
45
+ // Calculate scale to fit content in SVG with padding
46
+ const availableWidth = opts.width - effectivePadding * 2;
47
+ const availableHeight = opts.height - effectivePadding * 2;
48
+ const scaleX = availableWidth / contentWidth;
49
+ const scaleY = availableHeight / contentHeight;
50
+ const scale = Math.min(scaleX, scaleY) * opts.scale;
51
+ // Center the content
52
+ const scaledWidth = contentWidth * scale;
53
+ const scaledHeight = contentHeight * scale;
54
+ const offsetX = effectivePadding + (availableWidth - scaledWidth) / 2 - minX * scale;
55
+ const offsetY = effectivePadding + (availableHeight - scaledHeight) / 2 - minY * scale;
56
+ return { scale, offsetX, offsetY, height: opts.height };
57
+ }
58
+ function transformPoint(p, t) {
59
+ return {
60
+ x: p.x * t.scale + t.offsetX,
61
+ y: t.height - (p.y * t.scale + t.offsetY), // Flip Y
62
+ };
63
+ }
64
+ function transformPolygon(points, t) {
65
+ return points.map((p) => transformPoint(p, t));
66
+ }
67
+ // ============================================================================
68
+ // SVG Helpers
69
+ // ============================================================================
70
+ function escapeXml(str) {
71
+ return str
72
+ .replace(/&/g, '&')
73
+ .replace(/</g, '&lt;')
74
+ .replace(/>/g, '&gt;')
75
+ .replace(/"/g, '&quot;')
76
+ .replace(/'/g, '&apos;');
77
+ }
78
+ function pointsToPath(points) {
79
+ if (points.length === 0)
80
+ return '';
81
+ const commands = points.map((p, i) => (i === 0 ? `M ${p.x.toFixed(2)} ${p.y.toFixed(2)}` : `L ${p.x.toFixed(2)} ${p.y.toFixed(2)}`));
82
+ commands.push('Z');
83
+ return commands.join(' ');
84
+ }
85
+ function calculatePolygonCenter(points) {
86
+ const x = points.reduce((sum, p) => sum + p.x, 0) / points.length;
87
+ const y = points.reduce((sum, p) => sum + p.y, 0) / points.length;
88
+ return { x, y };
89
+ }
90
+ function calculatePolygonBounds(points) {
91
+ const xs = points.map(p => p.x);
92
+ const ys = points.map(p => p.y);
93
+ return {
94
+ width: Math.max(...xs) - Math.min(...xs),
95
+ height: Math.max(...ys) - Math.min(...ys),
96
+ };
97
+ }
98
+ // ============================================================================
99
+ // SVG Element Generation
100
+ // ============================================================================
101
+ function generateFootprintSVG(footprint, t, opts) {
102
+ const points = transformPolygon(footprint.points, t);
103
+ const path = pointsToPath(points);
104
+ return `<path d="${path}" fill="none" stroke="${opts.footprintColor}" stroke-width="2" stroke-dasharray="8,4" />`;
105
+ }
106
+ function generateRoomsSVG(rooms, t, opts) {
107
+ const elements = [];
108
+ for (const room of rooms) {
109
+ const points = transformPolygon(room.polygon.points, t);
110
+ const path = pointsToPath(points);
111
+ // Room fill
112
+ elements.push(`<path d="${path}" fill="${opts.roomFillColor}" stroke="${opts.roomStrokeColor}" stroke-width="${opts.roomStrokeWidth}" />`);
113
+ }
114
+ return elements.join('\n ');
115
+ }
116
+ function generateLabelsSVG(rooms, t, opts) {
117
+ if (!opts.showLabels)
118
+ return '';
119
+ const elements = [];
120
+ for (const room of rooms) {
121
+ if (!room.label)
122
+ continue;
123
+ const worldCenter = calculatePolygonCenter(room.polygon.points);
124
+ const screenCenter = transformPoint(worldCenter, t);
125
+ // Calculate room size in screen coordinates to adapt font size
126
+ const worldBounds = calculatePolygonBounds(room.polygon.points);
127
+ const screenWidth = worldBounds.width * t.scale;
128
+ const screenHeight = worldBounds.height * t.scale;
129
+ const minDimension = Math.min(screenWidth, screenHeight);
130
+ // Estimate text width (rough approximation: ~0.6 * fontSize * charCount)
131
+ const label = room.label;
132
+ const baseFontSize = opts.labelFontSize;
133
+ const estimatedTextWidth = 0.6 * baseFontSize * label.length;
134
+ // Scale font to fit within room (with some padding)
135
+ const maxTextWidth = screenWidth * 0.85;
136
+ const maxTextHeight = screenHeight * 0.4;
137
+ let fontSize = baseFontSize;
138
+ if (estimatedTextWidth > maxTextWidth) {
139
+ fontSize = Math.floor((maxTextWidth / label.length) / 0.6);
140
+ }
141
+ // Also limit by height
142
+ fontSize = Math.min(fontSize, maxTextHeight);
143
+ // Ensure minimum readable size
144
+ fontSize = Math.max(fontSize, 6);
145
+ // Cap at base font size
146
+ fontSize = Math.min(fontSize, baseFontSize);
147
+ elements.push(`<text x="${screenCenter.x.toFixed(2)}" y="${screenCenter.y.toFixed(2)}" ` +
148
+ `font-size="${fontSize}" fill="${opts.labelColor}" ` +
149
+ `text-anchor="middle" dominant-baseline="middle" font-family="Arial, sans-serif">` +
150
+ `${escapeXml(label)}</text>`);
151
+ }
152
+ return elements.join('\n ');
153
+ }
154
+ function generateWallsSVG(walls, t, opts) {
155
+ const elements = [];
156
+ for (const wall of walls) {
157
+ const start = transformPoint(wall.start, t);
158
+ const end = transformPoint(wall.end, t);
159
+ elements.push(`<line x1="${start.x.toFixed(2)}" y1="${start.y.toFixed(2)}" ` +
160
+ `x2="${end.x.toFixed(2)}" y2="${end.y.toFixed(2)}" ` +
161
+ `stroke="${opts.wallColor}" stroke-width="${opts.wallWidth}" stroke-linecap="round" />`);
162
+ }
163
+ return elements.join('\n ');
164
+ }
165
+ function generateOpeningsSVG(openings, walls, t, opts) {
166
+ const elements = [];
167
+ for (const opening of openings) {
168
+ const wall = walls.find((w) => w.id === opening.wallId);
169
+ if (!wall)
170
+ continue;
171
+ // Calculate opening position on wall (in world coordinates)
172
+ const dx = wall.end.x - wall.start.x;
173
+ const dy = wall.end.y - wall.start.y;
174
+ const wallLength = Math.sqrt(dx * dx + dy * dy);
175
+ const ratio = opening.position / wallLength;
176
+ const centerX = wall.start.x + dx * ratio;
177
+ const centerY = wall.start.y + dy * ratio;
178
+ // Unit vector along wall
179
+ const ux = dx / wallLength;
180
+ const uy = dy / wallLength;
181
+ const halfWidth = opening.width / 2;
182
+ // Opening endpoints in world coordinates
183
+ const worldP1 = { x: centerX - ux * halfWidth, y: centerY - uy * halfWidth };
184
+ const worldP2 = { x: centerX + ux * halfWidth, y: centerY + uy * halfWidth };
185
+ // Transform to screen coordinates
186
+ const p1 = transformPoint(worldP1, t);
187
+ const p2 = transformPoint(worldP2, t);
188
+ const color = opening.type === 'door' ? opts.doorColor : opts.windowColor;
189
+ const strokeWidth = opening.type === 'door' ? 4 : 3;
190
+ // Draw opening as a gap with colored indicator
191
+ elements.push(`<line x1="${p1.x.toFixed(2)}" y1="${p1.y.toFixed(2)}" ` +
192
+ `x2="${p2.x.toFixed(2)}" y2="${p2.y.toFixed(2)}" ` +
193
+ `stroke="${color}" stroke-width="${strokeWidth}" stroke-linecap="round" />`);
194
+ }
195
+ return elements.join('\n ');
196
+ }
197
+ function getBounds(points) {
198
+ const xs = points.map(p => p.x);
199
+ const ys = points.map(p => p.y);
200
+ const minX = Math.min(...xs);
201
+ const maxX = Math.max(...xs);
202
+ const minY = Math.min(...ys);
203
+ const maxY = Math.max(...ys);
204
+ return {
205
+ minX, maxX, minY, maxY,
206
+ width: maxX - minX,
207
+ height: maxY - minY,
208
+ };
209
+ }
210
+ function getRoomArea(points) {
211
+ // Shoelace formula for polygon area
212
+ let area = 0;
213
+ const n = points.length;
214
+ for (let i = 0; i < n; i++) {
215
+ const j = (i + 1) % n;
216
+ area += points[i].x * points[j].y;
217
+ area -= points[j].x * points[i].y;
218
+ }
219
+ return Math.abs(area) / 2;
220
+ }
221
+ function isEdgeAdjacentToRoom(edge, roomBounds, otherRooms, tolerance = 0.01) {
222
+ for (const other of otherRooms) {
223
+ const otherBounds = getBounds(other.polygon.points);
224
+ switch (edge) {
225
+ case 'north': // Check if another room is above (shares maxY edge)
226
+ if (Math.abs(otherBounds.minY - roomBounds.maxY) < tolerance) {
227
+ // Check horizontal overlap
228
+ if (otherBounds.maxX > roomBounds.minX + tolerance &&
229
+ otherBounds.minX < roomBounds.maxX - tolerance) {
230
+ return true;
231
+ }
232
+ }
233
+ break;
234
+ case 'south': // Check if another room is below (shares minY edge)
235
+ if (Math.abs(otherBounds.maxY - roomBounds.minY) < tolerance) {
236
+ if (otherBounds.maxX > roomBounds.minX + tolerance &&
237
+ otherBounds.minX < roomBounds.maxX - tolerance) {
238
+ return true;
239
+ }
240
+ }
241
+ break;
242
+ case 'east': // Check if another room is to the right (shares maxX edge)
243
+ if (Math.abs(otherBounds.minX - roomBounds.maxX) < tolerance) {
244
+ if (otherBounds.maxY > roomBounds.minY + tolerance &&
245
+ otherBounds.minY < roomBounds.maxY - tolerance) {
246
+ return true;
247
+ }
248
+ }
249
+ break;
250
+ case 'west': // Check if another room is to the left (shares minX edge)
251
+ if (Math.abs(otherBounds.maxX - roomBounds.minX) < tolerance) {
252
+ if (otherBounds.maxY > roomBounds.minY + tolerance &&
253
+ otherBounds.minY < roomBounds.maxY - tolerance) {
254
+ return true;
255
+ }
256
+ }
257
+ break;
258
+ }
259
+ }
260
+ return false;
261
+ }
262
+ function getExteriorSides(room, allRooms, fpBounds) {
263
+ const roomBounds = getBounds(room.polygon.points);
264
+ const otherRooms = allRooms.filter(r => r.name !== room.name);
265
+ const tolerance = 0.01;
266
+ return {
267
+ north: !isEdgeAdjacentToRoom('north', roomBounds, otherRooms, tolerance),
268
+ south: !isEdgeAdjacentToRoom('south', roomBounds, otherRooms, tolerance),
269
+ east: !isEdgeAdjacentToRoom('east', roomBounds, otherRooms, tolerance),
270
+ west: !isEdgeAdjacentToRoom('west', roomBounds, otherRooms, tolerance),
271
+ };
272
+ }
273
+ function shouldSkipDimension(roomDim, footprintDim, tolerance = 0.5) {
274
+ return Math.abs(roomDim - footprintDim) < tolerance;
275
+ }
276
+ /**
277
+ * Check if a room's dimension on a given axis is redundant and should be skipped.
278
+ * A dimension is redundant if:
279
+ * 1. It equals the sum of contiguous adjacent rooms (e.g., Hallway 12m = Living 8m + Kitchen 4m)
280
+ * 2. It equals an adjacent room's dimension on the same axis, and that room is larger
281
+ * (e.g., Kitchen height 6m = Living Room height 6m, but Living Room is larger)
282
+ */
283
+ function isDimensionRedundant(room, allRooms, axis, tolerance = 0.1) {
284
+ const roomBounds = getBounds(room.polygon.points);
285
+ const roomArea = getRoomArea(room.polygon.points);
286
+ const roomMin = axis === 'width' ? roomBounds.minX : roomBounds.minY;
287
+ const roomMax = axis === 'width' ? roomBounds.maxX : roomBounds.maxY;
288
+ const roomDim = axis === 'width' ? roomBounds.width : roomBounds.height;
289
+ // Check 1: Is this dimension equal to an adjacent larger room's same dimension?
290
+ // (for height, check east/west neighbors; for width, check north/south neighbors)
291
+ const sameAxisEdges = axis === 'height' ? ['east', 'west'] : ['north', 'south'];
292
+ for (const edge of sameAxisEdges) {
293
+ for (const other of allRooms) {
294
+ if (other.name === room.name)
295
+ continue;
296
+ const otherBounds = getBounds(other.polygon.points);
297
+ const otherArea = getRoomArea(other.polygon.points);
298
+ const otherDim = axis === 'width' ? otherBounds.width : otherBounds.height;
299
+ // Check if rooms are adjacent on this edge
300
+ let isAdjacent = false;
301
+ if (edge === 'east' && Math.abs(otherBounds.minX - roomBounds.maxX) < tolerance) {
302
+ isAdjacent = true;
303
+ }
304
+ else if (edge === 'west' && Math.abs(otherBounds.maxX - roomBounds.minX) < tolerance) {
305
+ isAdjacent = true;
306
+ }
307
+ else if (edge === 'north' && Math.abs(otherBounds.minY - roomBounds.maxY) < tolerance) {
308
+ isAdjacent = true;
309
+ }
310
+ else if (edge === 'south' && Math.abs(otherBounds.maxY - roomBounds.minY) < tolerance) {
311
+ isAdjacent = true;
312
+ }
313
+ if (isAdjacent) {
314
+ // Check if they have the same dimension and the other room is larger
315
+ if (Math.abs(otherDim - roomDim) < tolerance && otherArea > roomArea) {
316
+ return true;
317
+ }
318
+ }
319
+ }
320
+ }
321
+ // Check 2: Is this dimension the sum of contiguous adjacent rooms?
322
+ const perpendicularEdges = axis === 'width' ? ['north', 'south'] : ['east', 'west'];
323
+ for (const edge of perpendicularEdges) {
324
+ // Find all rooms adjacent on this edge
325
+ const adjacentRooms = [];
326
+ for (const other of allRooms) {
327
+ if (other.name === room.name)
328
+ continue;
329
+ const otherBounds = getBounds(other.polygon.points);
330
+ // Check if other room shares the specified edge with this room
331
+ let sharesEdge = false;
332
+ if (edge === 'north' && Math.abs(otherBounds.minY - roomBounds.maxY) < tolerance) {
333
+ sharesEdge = true;
334
+ }
335
+ else if (edge === 'south' && Math.abs(otherBounds.maxY - roomBounds.minY) < tolerance) {
336
+ sharesEdge = true;
337
+ }
338
+ else if (edge === 'east' && Math.abs(otherBounds.minX - roomBounds.maxX) < tolerance) {
339
+ sharesEdge = true;
340
+ }
341
+ else if (edge === 'west' && Math.abs(otherBounds.maxX - roomBounds.minX) < tolerance) {
342
+ sharesEdge = true;
343
+ }
344
+ if (sharesEdge) {
345
+ const otherMin = axis === 'width' ? otherBounds.minX : otherBounds.minY;
346
+ const otherMax = axis === 'width' ? otherBounds.maxX : otherBounds.maxY;
347
+ adjacentRooms.push({ bounds: otherBounds, min: otherMin, max: otherMax });
348
+ }
349
+ }
350
+ if (adjacentRooms.length < 2)
351
+ continue;
352
+ // Check if adjacent rooms are contiguous and fully cover this room's span
353
+ adjacentRooms.sort((a, b) => a.min - b.min);
354
+ let coveredMin = adjacentRooms[0].min;
355
+ let coveredMax = adjacentRooms[0].max;
356
+ for (let i = 1; i < adjacentRooms.length; i++) {
357
+ const curr = adjacentRooms[i];
358
+ if (curr.min <= coveredMax + tolerance) {
359
+ coveredMax = Math.max(coveredMax, curr.max);
360
+ }
361
+ else {
362
+ break;
363
+ }
364
+ }
365
+ // If the adjacent rooms fully cover this room's span, dimension is redundant
366
+ if (coveredMin <= roomMin + tolerance && coveredMax >= roomMax - tolerance) {
367
+ return true;
368
+ }
369
+ }
370
+ return false;
371
+ }
372
+ function formatDimension(meters) {
373
+ if (meters >= 1) {
374
+ // Show as meters with 1 decimal if needed
375
+ const rounded = Math.round(meters * 10) / 10;
376
+ return rounded % 1 === 0 ? `${rounded}m` : `${rounded.toFixed(1)}m`;
377
+ }
378
+ else {
379
+ // Show as centimeters
380
+ const cm = Math.round(meters * 100);
381
+ return `${cm}cm`;
382
+ }
383
+ }
384
+ function generateDimensionLine(p1, p2, offset, label, transform, opts, side) {
385
+ const screenP1 = transformPoint(p1, transform);
386
+ const screenP2 = transformPoint(p2, transform);
387
+ // Calculate dimension line position (offset from the edge)
388
+ let dimP1, dimP2;
389
+ let textX, textY;
390
+ let textRotation = 0;
391
+ const tickLength = 6;
392
+ if (side === 'bottom' || side === 'top') {
393
+ // Horizontal dimension line
394
+ const yOffset = side === 'bottom' ? offset : -offset;
395
+ dimP1 = { x: screenP1.x, y: screenP1.y + yOffset };
396
+ dimP2 = { x: screenP2.x, y: screenP2.y + yOffset };
397
+ textX = (dimP1.x + dimP2.x) / 2;
398
+ textY = dimP1.y; // Centered on the line
399
+ }
400
+ else {
401
+ // Vertical dimension line
402
+ const xOffset = side === 'right' ? offset : -offset;
403
+ dimP1 = { x: screenP1.x + xOffset, y: screenP1.y };
404
+ dimP2 = { x: screenP2.x + xOffset, y: screenP2.y };
405
+ textX = dimP1.x; // Centered on the line
406
+ textY = (dimP1.y + dimP2.y) / 2;
407
+ textRotation = -90;
408
+ }
409
+ const elements = [];
410
+ // Main dimension line
411
+ elements.push(`<line x1="${dimP1.x.toFixed(2)}" y1="${dimP1.y.toFixed(2)}" ` +
412
+ `x2="${dimP2.x.toFixed(2)}" y2="${dimP2.y.toFixed(2)}" ` +
413
+ `stroke="${opts.dimensionColor}" stroke-width="1" />`);
414
+ // Tick marks at ends
415
+ if (side === 'bottom' || side === 'top') {
416
+ // Vertical ticks for horizontal lines
417
+ const tickDir = side === 'bottom' ? -1 : 1;
418
+ elements.push(`<line x1="${dimP1.x.toFixed(2)}" y1="${(dimP1.y + tickDir * tickLength).toFixed(2)}" ` +
419
+ `x2="${dimP1.x.toFixed(2)}" y2="${(dimP1.y - tickDir * tickLength).toFixed(2)}" ` +
420
+ `stroke="${opts.dimensionColor}" stroke-width="1" />`);
421
+ elements.push(`<line x1="${dimP2.x.toFixed(2)}" y1="${(dimP2.y + tickDir * tickLength).toFixed(2)}" ` +
422
+ `x2="${dimP2.x.toFixed(2)}" y2="${(dimP2.y - tickDir * tickLength).toFixed(2)}" ` +
423
+ `stroke="${opts.dimensionColor}" stroke-width="1" />`);
424
+ }
425
+ else {
426
+ // Horizontal ticks for vertical lines
427
+ const tickDir = side === 'right' ? -1 : 1;
428
+ elements.push(`<line x1="${(dimP1.x + tickDir * tickLength).toFixed(2)}" y1="${dimP1.y.toFixed(2)}" ` +
429
+ `x2="${(dimP1.x - tickDir * tickLength).toFixed(2)}" y2="${dimP1.y.toFixed(2)}" ` +
430
+ `stroke="${opts.dimensionColor}" stroke-width="1" />`);
431
+ elements.push(`<line x1="${(dimP2.x + tickDir * tickLength).toFixed(2)}" y1="${dimP2.y.toFixed(2)}" ` +
432
+ `x2="${(dimP2.x - tickDir * tickLength).toFixed(2)}" y2="${dimP2.y.toFixed(2)}" ` +
433
+ `stroke="${opts.dimensionColor}" stroke-width="1" />`);
434
+ }
435
+ // Dimension text - centered on the line with white background
436
+ const textAnchor = 'middle';
437
+ const transform_attr = textRotation !== 0 ? ` transform="rotate(${textRotation}, ${textX.toFixed(2)}, ${textY.toFixed(2)})"` : '';
438
+ // Estimate text width for background (approximate: fontSize * 0.6 per character)
439
+ const textWidth = label.length * opts.dimensionFontSize * 0.6 + 4;
440
+ const textHeight = opts.dimensionFontSize + 2;
441
+ // Add white background rectangle behind text
442
+ if (textRotation !== 0) {
443
+ // For rotated text, we need to rotate the background too
444
+ elements.push(`<rect x="${(textX - textWidth / 2).toFixed(2)}" y="${(textY - textHeight / 2).toFixed(2)}" ` +
445
+ `width="${textWidth.toFixed(2)}" height="${textHeight.toFixed(2)}" ` +
446
+ `fill="${opts.backgroundColor}"${transform_attr} />`);
447
+ }
448
+ else {
449
+ elements.push(`<rect x="${(textX - textWidth / 2).toFixed(2)}" y="${(textY - textHeight / 2).toFixed(2)}" ` +
450
+ `width="${textWidth.toFixed(2)}" height="${textHeight.toFixed(2)}" ` +
451
+ `fill="${opts.backgroundColor}" />`);
452
+ }
453
+ elements.push(`<text x="${textX.toFixed(2)}" y="${textY.toFixed(2)}" ` +
454
+ `font-size="${opts.dimensionFontSize}" fill="${opts.dimensionColor}" ` +
455
+ `text-anchor="${textAnchor}" dominant-baseline="middle" ` +
456
+ `font-family="Arial, sans-serif"${transform_attr}>${escapeXml(label)}</text>`);
457
+ return elements.join('\n ');
458
+ }
459
+ function areContiguous(a, b, tolerance = 0.01) {
460
+ // Two dimensions are contiguous if they're on the same line and don't overlap
461
+ // For horizontal dims (top/bottom): same Y coordinate, and X ranges are adjacent
462
+ // For vertical dims (left/right): same X coordinate, and Y ranges are adjacent
463
+ if (a.side !== b.side)
464
+ return false;
465
+ if (a.side === 'top' || a.side === 'bottom') {
466
+ // Horizontal dimensions - check if same Y and X ranges are adjacent (touching or nearly touching)
467
+ if (Math.abs(a.coordinate - b.coordinate) > tolerance)
468
+ return false;
469
+ const aMinX = Math.min(a.p1.x, a.p2.x);
470
+ const aMaxX = Math.max(a.p1.x, a.p2.x);
471
+ const bMinX = Math.min(b.p1.x, b.p2.x);
472
+ const bMaxX = Math.max(b.p1.x, b.p2.x);
473
+ // Adjacent if one ends where the other starts (with small tolerance)
474
+ return Math.abs(aMaxX - bMinX) < tolerance || Math.abs(bMaxX - aMinX) < tolerance;
475
+ }
476
+ else {
477
+ // Vertical dimensions - check if same X and Y ranges are adjacent
478
+ if (Math.abs(a.coordinate - b.coordinate) > tolerance)
479
+ return false;
480
+ const aMinY = Math.min(a.p1.y, a.p2.y);
481
+ const aMaxY = Math.max(a.p1.y, a.p2.y);
482
+ const bMinY = Math.min(b.p1.y, b.p2.y);
483
+ const bMaxY = Math.max(b.p1.y, b.p2.y);
484
+ return Math.abs(aMaxY - bMinY) < tolerance || Math.abs(bMaxY - aMinY) < tolerance;
485
+ }
486
+ }
487
+ function doRangesOverlap(a, b, tolerance = 0.5) {
488
+ // Check if two dimensions would overlap if placed at the same offset
489
+ if (a.side === 'top' || a.side === 'bottom') {
490
+ const aMinX = Math.min(a.p1.x, a.p2.x);
491
+ const aMaxX = Math.max(a.p1.x, a.p2.x);
492
+ const bMinX = Math.min(b.p1.x, b.p2.x);
493
+ const bMaxX = Math.max(b.p1.x, b.p2.x);
494
+ return aMinX < bMaxX - tolerance && aMaxX > bMinX + tolerance;
495
+ }
496
+ else {
497
+ const aMinY = Math.min(a.p1.y, a.p2.y);
498
+ const aMaxY = Math.max(a.p1.y, a.p2.y);
499
+ const bMinY = Math.min(b.p1.y, b.p2.y);
500
+ const bMaxY = Math.max(b.p1.y, b.p2.y);
501
+ return aMinY < bMaxY - tolerance && aMaxY > bMinY + tolerance;
502
+ }
503
+ }
504
+ function groupAndStaggerDimensions(placements, baseOffset, staggerStep) {
505
+ // Group by side
506
+ const groups = new Map();
507
+ for (const p of placements) {
508
+ const key = p.side;
509
+ if (!groups.has(key)) {
510
+ groups.set(key, []);
511
+ }
512
+ groups.get(key).push(p);
513
+ }
514
+ const result = new Map();
515
+ for (const [side, group] of groups) {
516
+ // Sort by area descending (largest rooms get priority for closest offset)
517
+ const sorted = [...group].sort((a, b) => b.roomArea - a.roomArea);
518
+ // Assign offsets: contiguous dimensions share the same offset level
519
+ // Non-contiguous but overlapping dimensions need different offset levels
520
+ const withOffsets = [];
521
+ const offsetLevels = [];
522
+ for (const placement of sorted) {
523
+ // Find if this placement can share an offset level with existing placements
524
+ let assignedLevel = -1;
525
+ for (let i = 0; i < offsetLevels.length; i++) {
526
+ const level = offsetLevels[i];
527
+ // Check if this placement is contiguous with any in this level
528
+ const isContiguousWithLevel = level.placements.some(p => areContiguous(p, placement));
529
+ // Check if this placement overlaps with any in this level
530
+ const overlapsWithLevel = level.placements.some(p => doRangesOverlap(p, placement));
531
+ if (isContiguousWithLevel || !overlapsWithLevel) {
532
+ // Can share this level - either contiguous or non-overlapping
533
+ assignedLevel = i;
534
+ break;
535
+ }
536
+ }
537
+ if (assignedLevel === -1) {
538
+ // Need a new offset level
539
+ assignedLevel = offsetLevels.length;
540
+ offsetLevels.push({ level: assignedLevel, placements: [] });
541
+ }
542
+ offsetLevels[assignedLevel].placements.push(placement);
543
+ withOffsets.push({
544
+ placement,
545
+ offset: baseOffset + assignedLevel * staggerStep,
546
+ });
547
+ }
548
+ result.set(side, withOffsets);
549
+ }
550
+ return result;
551
+ }
552
+ function generateDimensionsSVG(rooms, footprint, transform, opts) {
553
+ if (!opts.showDimensions)
554
+ return '';
555
+ const elements = [];
556
+ const fpBounds = getBounds(footprint.points);
557
+ // Collect all dimension placements
558
+ const placements = [];
559
+ for (const room of rooms) {
560
+ const points = room.polygon.points;
561
+ if (points.length < 3)
562
+ continue;
563
+ const roomBounds = getBounds(points);
564
+ const roomArea = getRoomArea(points);
565
+ const exterior = getExteriorSides(room, rooms, fpBounds);
566
+ // Width dimension (horizontal)
567
+ // Skip if: spans full footprint width, OR is sum of adjacent contiguous rooms
568
+ const skipWidth = shouldSkipDimension(roomBounds.width, fpBounds.width) ||
569
+ isDimensionRedundant(room, rooms, 'width');
570
+ if (!skipWidth) {
571
+ // Prefer exterior edge: north (top) > south (bottom)
572
+ let side;
573
+ let y;
574
+ if (exterior.north) {
575
+ side = 'top';
576
+ y = roomBounds.maxY;
577
+ }
578
+ else {
579
+ side = 'bottom';
580
+ y = roomBounds.minY;
581
+ }
582
+ placements.push({
583
+ type: 'width',
584
+ roomName: room.name,
585
+ roomArea,
586
+ side,
587
+ p1: { x: roomBounds.minX, y },
588
+ p2: { x: roomBounds.maxX, y },
589
+ value: roomBounds.width,
590
+ coordinate: y,
591
+ });
592
+ }
593
+ // Height dimension (vertical)
594
+ // Skip if: spans full footprint height, OR is sum of adjacent contiguous rooms
595
+ const skipHeight = shouldSkipDimension(roomBounds.height, fpBounds.height) ||
596
+ isDimensionRedundant(room, rooms, 'height');
597
+ if (!skipHeight) {
598
+ // Prefer exterior edge: west (left) > east (right)
599
+ let side;
600
+ let x;
601
+ if (exterior.west) {
602
+ side = 'left';
603
+ x = roomBounds.minX;
604
+ }
605
+ else {
606
+ side = 'right';
607
+ x = roomBounds.maxX;
608
+ }
609
+ placements.push({
610
+ type: 'height',
611
+ roomName: room.name,
612
+ roomArea,
613
+ side,
614
+ p1: { x, y: roomBounds.minY },
615
+ p2: { x, y: roomBounds.maxY },
616
+ value: roomBounds.height,
617
+ coordinate: x,
618
+ });
619
+ }
620
+ }
621
+ // Group and stagger dimensions
622
+ const staggered = groupAndStaggerDimensions(placements, opts.dimensionOffset, opts.dimensionStaggerStep);
623
+ // Track max offset for each side (for footprint dimensions)
624
+ const maxOffsets = {
625
+ top: opts.dimensionOffset,
626
+ bottom: opts.dimensionOffset,
627
+ left: opts.dimensionOffset,
628
+ right: opts.dimensionOffset,
629
+ };
630
+ // Generate room dimension lines
631
+ for (const [side, group] of staggered) {
632
+ for (const { placement, offset } of group) {
633
+ elements.push(generateDimensionLine(placement.p1, placement.p2, offset, formatDimension(placement.value), transform, opts, placement.side));
634
+ maxOffsets[side] = Math.max(maxOffsets[side], offset + opts.dimensionStaggerStep);
635
+ }
636
+ }
637
+ // Generate footprint dimensions (furthest out)
638
+ if (opts.showFootprintDimensions) {
639
+ // Width (bottom of footprint)
640
+ elements.push(generateDimensionLine({ x: fpBounds.minX, y: fpBounds.minY }, { x: fpBounds.maxX, y: fpBounds.minY }, maxOffsets.bottom + opts.dimensionStaggerStep, formatDimension(fpBounds.width), transform, opts, 'bottom'));
641
+ // Height (left of footprint)
642
+ elements.push(generateDimensionLine({ x: fpBounds.minX, y: fpBounds.minY }, { x: fpBounds.minX, y: fpBounds.maxY }, maxOffsets.left + opts.dimensionStaggerStep, formatDimension(fpBounds.height), transform, opts, 'left'));
643
+ }
644
+ return elements.join('\n ');
645
+ }
646
+ // ============================================================================
647
+ // Main Export Function
648
+ // ============================================================================
649
+ export function exportSVG(geometry, options = {}) {
650
+ const opts = { ...defaultOptions, ...options };
651
+ const transform = createTransform(geometry, opts);
652
+ const dimensionsSVG = generateDimensionsSVG(geometry.rooms, geometry.footprint, transform, opts);
653
+ const svg = `<?xml version="1.0" encoding="UTF-8"?>
654
+ <svg xmlns="http://www.w3.org/2000/svg" width="${opts.width}" height="${opts.height}" viewBox="0 0 ${opts.width} ${opts.height}">
655
+ <defs>
656
+ <style>
657
+ .room-label { font-family: Arial, sans-serif; font-weight: 500; }
658
+ </style>
659
+ </defs>
660
+
661
+ <!-- Background -->
662
+ <rect width="${opts.width}" height="${opts.height}" fill="${opts.backgroundColor}" />
663
+
664
+ <!-- Footprint (boundary) -->
665
+ ${generateFootprintSVG(geometry.footprint, transform, opts)}
666
+
667
+ <!-- Rooms -->
668
+ ${generateRoomsSVG(geometry.rooms, transform, opts)}
669
+
670
+ <!-- Walls -->
671
+ ${generateWallsSVG(geometry.walls, transform, opts)}
672
+
673
+ <!-- Openings (doors/windows) -->
674
+ ${generateOpeningsSVG(geometry.openings, geometry.walls, transform, opts)}
675
+
676
+ <!-- Labels -->
677
+ ${generateLabelsSVG(geometry.rooms, transform, opts)}
678
+
679
+ <!-- Dimensions -->
680
+ ${dimensionsSVG}
681
+ </svg>`;
682
+ return svg;
683
+ }
684
+ //# sourceMappingURL=svg.js.map