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.
- package/LICENSE +19 -0
- package/README.md +277 -0
- package/dist/ast/types.d.ts +195 -0
- package/dist/ast/types.d.ts.map +1 -0
- package/dist/ast/types.js +5 -0
- package/dist/ast/types.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +100 -0
- package/dist/cli.js.map +1 -0
- package/dist/compiler.d.ts +30 -0
- package/dist/compiler.d.ts.map +1 -0
- package/dist/compiler.js +80 -0
- package/dist/compiler.js.map +1 -0
- package/dist/exporters/json.d.ts +13 -0
- package/dist/exporters/json.d.ts.map +1 -0
- package/dist/exporters/json.js +17 -0
- package/dist/exporters/json.js.map +1 -0
- package/dist/exporters/svg.d.ts +27 -0
- package/dist/exporters/svg.d.ts.map +1 -0
- package/dist/exporters/svg.js +684 -0
- package/dist/exporters/svg.js.map +1 -0
- package/dist/geometry/index.d.ts +13 -0
- package/dist/geometry/index.d.ts.map +1 -0
- package/dist/geometry/index.js +333 -0
- package/dist/geometry/index.js.map +1 -0
- package/dist/geometry/types.d.ts +34 -0
- package/dist/geometry/types.d.ts.map +1 -0
- package/dist/geometry/types.js +2 -0
- package/dist/geometry/types.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/lowering/index.d.ts +25 -0
- package/dist/lowering/index.d.ts.map +1 -0
- package/dist/lowering/index.js +217 -0
- package/dist/lowering/index.js.map +1 -0
- package/dist/parser/grammar.d.ts +168 -0
- package/dist/parser/grammar.d.ts.map +1 -0
- package/dist/parser/grammar.js +7341 -0
- package/dist/parser/grammar.js.map +1 -0
- package/dist/parser/index.d.ts +27 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +26 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/validation/index.d.ts +22 -0
- package/dist/validation/index.d.ts.map +1 -0
- package/dist/validation/index.js +243 -0
- package/dist/validation/index.js.map +1 -0
- package/examples/house-with-dimensions.svg +106 -0
- package/examples/house.json +478 -0
- package/examples/house.psc +86 -0
- package/examples/house.svg +57 -0
- 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, '<')
|
|
74
|
+
.replace(/>/g, '>')
|
|
75
|
+
.replace(/"/g, '"')
|
|
76
|
+
.replace(/'/g, ''');
|
|
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
|