@eturnity/eturnity_maths 9.25.0-panel-placement.0 → 9.25.0-panel-placement.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eturnity/eturnity_maths",
3
- "version": "9.25.0-panel-placement.0",
3
+ "version": "9.25.0-panel-placement.1",
4
4
  "author": "Eturnity Team",
5
5
  "main": "src/index.js",
6
6
  "private": false,
@@ -19,6 +19,7 @@
19
19
  "dependencies": {
20
20
  "cdt2d": "1.0.0",
21
21
  "lodash": "4.17.21",
22
+ "earcut": "2.2.4",
22
23
  "planar-face-discovery": "2.0.7",
23
24
  "point-in-polygon": "1.1.0",
24
25
  "rbush": "3.0.1",
package/src/index.js CHANGED
@@ -18,3 +18,4 @@ export * from './panelFunctions'
18
18
  export * from './stringPatchMatching'
19
19
  export * from './plane'
20
20
  export * from './gridStAffine'
21
+ export * from './straightSkeleton'
@@ -1,4 +1,5 @@
1
1
  import { Polygon } from './Polygon'
2
+ import { computeStraightSkeleton } from '../straightSkeleton'
2
3
 
3
4
  export class Roof extends Polygon {
4
5
  constructor(outline = [], isClosed = true) {
@@ -6,6 +7,26 @@ export class Roof extends Polygon {
6
7
  this.moduleFields = []
7
8
  }
8
9
 
10
+ // Straight skeleton (ridges/hips/valleys + roof facets) of the roof outline,
11
+ // projected on the horizontal xy plane. See computeStraightSkeleton.
12
+ computeStraightSkeleton(options) {
13
+ return computeStraightSkeleton(this.outline, options)
14
+ }
15
+
16
+ // Cached straight skeleton, only recomputed when the geometry (version)
17
+ // changes. Mirrors getAABB.
18
+ getStraightSkeleton(options) {
19
+ if (
20
+ this._straightSkeleton &&
21
+ this._straightSkeletonVersion === this.version
22
+ ) {
23
+ return this._straightSkeleton
24
+ }
25
+ this._straightSkeleton = this.computeStraightSkeleton(options)
26
+ this._straightSkeletonVersion = this.version
27
+ return this._straightSkeleton
28
+ }
29
+
9
30
  _serializeExtra() {
10
31
  return {
11
32
  moduleFields: this.moduleFields.map((m) => ({ id: m.id })),
@@ -0,0 +1,51 @@
1
+ import { computeSegmentVoronoiRegions } from './segmentVoronoi'
2
+ import { calcPolygonArea } from './geometry'
3
+
4
+ const square = [
5
+ { x: 0, y: 0 },
6
+ { x: 10, y: 0 },
7
+ { x: 10, y: 10 },
8
+ { x: 0, y: 10 },
9
+ ]
10
+
11
+ function totalArea(region) {
12
+ return region.outlines.reduce((s, o) => s + calcPolygonArea(o), 0)
13
+ }
14
+
15
+ test('single segment -> region covers whole outline', () => {
16
+ const segs = [[{ x: 2, y: 5 }, { x: 8, y: 5 }]]
17
+ const regions = computeSegmentVoronoiRegions(square, segs)
18
+ expect(regions).toHaveLength(1)
19
+ expect(totalArea(regions[0])).toBeCloseTo(100, 0)
20
+ })
21
+
22
+ test('two horizontal segments -> split roughly in half', () => {
23
+ const segs = [
24
+ [{ x: 2, y: 2.5 }, { x: 8, y: 2.5 }],
25
+ [{ x: 2, y: 7.5 }, { x: 8, y: 7.5 }],
26
+ ]
27
+ const regions = computeSegmentVoronoiRegions(square, segs)
28
+ expect(regions).toHaveLength(2)
29
+ const a0 = totalArea(regions[0])
30
+ const a1 = totalArea(regions[1])
31
+ console.log('areas', a0, a1, 'loops', regions.map((r) => r.outlines.length))
32
+ expect(a0).toBeCloseTo(50, 0)
33
+ expect(a1).toBeCloseTo(50, 0)
34
+ })
35
+
36
+ test('four points (vertical+horizontal cross via 4 short segs) areas sum to total', () => {
37
+ const segs = [
38
+ [{ x: 2.5, y: 2.5 }, { x: 2.5, y: 2.5 }],
39
+ [{ x: 7.5, y: 2.5 }, { x: 7.5, y: 2.5 }],
40
+ [{ x: 2.5, y: 7.5 }, { x: 2.5, y: 7.5 }],
41
+ [{ x: 7.5, y: 7.5 }, { x: 7.5, y: 7.5 }],
42
+ ]
43
+ const regions = computeSegmentVoronoiRegions(square, segs)
44
+ const total = regions.reduce((s, r) => s + totalArea(r), 0)
45
+ console.log(
46
+ 'quad areas',
47
+ regions.map((r) => totalArea(r).toFixed(2))
48
+ )
49
+ expect(total).toBeCloseTo(100, 0)
50
+ regions.forEach((r) => expect(totalArea(r)).toBeCloseTo(25, 0))
51
+ })
@@ -0,0 +1,530 @@
1
+ import TinyQueue from 'tinyqueue'
2
+ import { getOutlineList } from './splitMergePolygons'
3
+ import { calcPolygonArea, get2DBoundOfPolygon } from './geometry'
4
+
5
+ /**
6
+ * Computes the straight skeleton of a closed outline (projected on the
7
+ * horizontal xy plane) and the roof facets it induces.
8
+ *
9
+ * The straight skeleton is the uniform-pitch "roof" of a simple polygon: every
10
+ * outline edge sweeps inward at the same speed and the traced corners form the
11
+ * ridges, hips and valleys. Because the boundaries are angle bisectors of
12
+ * straight edges, every skeleton edge is a straight segment (no parabolic arcs,
13
+ * unlike a segment Voronoi diagram / medial axis). Concave (reflex) outlines
14
+ * are supported through split events.
15
+ *
16
+ * Each output point carries a z coordinate forming a uniform-pitch roof of
17
+ * slope `options.slopeDegrees`: the eaves (outline) sit at z = 0 and every
18
+ * ridge/hip node rises by `(horizontal distance from the eaves) * tan(slope)`.
19
+ *
20
+ * @param {Array<{x:number,y:number,z?:number}>} outline - Closed simple polygon
21
+ * (>= 3 points, no holes, no self-intersection). Only x/y are used.
22
+ * @param {Object} [options]
23
+ * @param {number} [options.slopeDegrees=30] - Roof pitch in degrees, used to
24
+ * derive the z (height) of the skeleton/facet vertices. 0 gives a flat roof.
25
+ * @param {number} [options.tolerance] - Geometric tolerance. Defaults to a
26
+ * small fraction of the outline bounding-box diagonal.
27
+ * @returns {{skeleton: Array<[{x,y,z},{x,y,z}]>, faces: Array<Array<{x,y,z}>>}}
28
+ * `skeleton` is the list of internal edges (ridges/hips/valleys) as point
29
+ * pairs; `faces` is one outline per roof facet. z encodes the roof height.
30
+ */
31
+ export function computeStraightSkeleton(outline, options = {}) {
32
+ if (!Array.isArray(outline) || outline.length < 3) {
33
+ throw new Error('computeStraightSkeleton: outline must have >= 3 points')
34
+ }
35
+ const bound = get2DBoundOfPolygon(outline)
36
+ const diagonal = Math.hypot(bound.xMax - bound.xMin, bound.yMax - bound.yMin)
37
+ const eps =
38
+ options.tolerance > 0 ? options.tolerance : Math.max(1e-9, diagonal * 1e-9)
39
+
40
+ const ring = normalizeOutline(outline, eps)
41
+ if (ring.length < 3) {
42
+ return { skeleton: [], faces: [] }
43
+ }
44
+
45
+ // Symbolic perturbation: rectilinear roof outlines are riddled with parallel
46
+ // edges, right angles and simultaneous events, all degenerate for the
47
+ // event-driven skeleton. A sub-millimetre jitter breaks those degeneracies
48
+ // so the generic edge/split events apply; faces are then rebuilt against the
49
+ // *original* outline so the perturbation never reaches the output geometry.
50
+ const jitter = diagonal * 1e-6
51
+ const perturbed = ring.map((p, i) => ({
52
+ x: p.x + (hash(i * 2 + 1) * 2 - 1) * jitter,
53
+ y: p.y + (hash(i * 2 + 2) * 2 - 1) * jitter,
54
+ }))
55
+
56
+ // Roof height: each skeleton node carries its wavefront "time" t, i.e. its
57
+ // horizontal distance from the eaves, so for a uniform slope the height is
58
+ // z = t * tan(slope). Eaves (the outline) stay at z = 0.
59
+ const slopeDegrees = Number.isFinite(options.slopeDegrees)
60
+ ? options.slopeDegrees
61
+ : 30
62
+ const slopeFactor = Math.tan((slopeDegrees * Math.PI) / 180)
63
+
64
+ const rawSkeleton = buildSkeleton(perturbed, eps)
65
+ // Weld skeleton nodes (and snap them onto the original outline vertices) to
66
+ // erase the perturbation, then rebuild the facets from the clean geometry.
67
+ const cleaned = cleanSkeleton(rawSkeleton, ring, jitter * 8)
68
+ const faces = buildFaces(
69
+ ring,
70
+ cleaned,
71
+ Math.max(eps, jitter * 8),
72
+ slopeFactor
73
+ )
74
+ const skeleton = cleaned.map(([a, b]) => [
75
+ { x: a.x, y: a.y, z: a.t * slopeFactor },
76
+ { x: b.x, y: b.y, z: b.t * slopeFactor },
77
+ ])
78
+ return { skeleton, faces }
79
+ }
80
+
81
+ // --- outline normalization -------------------------------------------------
82
+
83
+ /** Drops duplicate / collinear points and forces counter-clockwise order. */
84
+ function normalizeOutline(outline, eps) {
85
+ let pts = outline.map((p) => ({ x: p.x, y: p.y }))
86
+ // remove consecutive duplicates
87
+ pts = pts.filter((p, i) => {
88
+ const q = pts[(i - 1 + pts.length) % pts.length]
89
+ return Math.hypot(p.x - q.x, p.y - q.y) > eps
90
+ })
91
+ // ensure CCW (positive signed area)
92
+ if (signedArea(pts) < 0) pts.reverse()
93
+ return pts
94
+ }
95
+
96
+ /** Deterministic pseudo-random in [0,1) used for symbolic perturbation. */
97
+ function hash(i) {
98
+ const s = Math.sin(i * 12.9898) * 43758.5453
99
+ return s - Math.floor(s)
100
+ }
101
+
102
+ function signedArea(pts) {
103
+ let s = 0
104
+ for (let i = 0; i < pts.length; i++) {
105
+ const a = pts[i]
106
+ const b = pts[(i + 1) % pts.length]
107
+ s += a.x * b.y - b.x * a.y
108
+ }
109
+ return s / 2
110
+ }
111
+
112
+ // --- linear algebra helpers ------------------------------------------------
113
+
114
+ const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y })
115
+ const add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y })
116
+ const scale = (a, s) => ({ x: a.x * s, y: a.y * s })
117
+ const dot = (a, b) => a.x * b.x + a.y * b.y
118
+ const cross = (a, b) => a.x * b.y - a.y * b.x
119
+ const len = (a) => Math.hypot(a.x, a.y)
120
+ const norm = (a) => {
121
+ const l = len(a)
122
+ return l === 0 ? { x: 0, y: 0 } : { x: a.x / l, y: a.y / l }
123
+ }
124
+
125
+ /** Inward (left) unit normal of an edge directed a -> b (CCW polygon). */
126
+ function inwardNormal(a, b) {
127
+ const d = norm(sub(b, a))
128
+ return { x: -d.y, y: d.x }
129
+ }
130
+
131
+ /**
132
+ * Velocity of the vertex sitting between edges with inward normals nL, nR:
133
+ * the vector v with v·nL = 1 and v·nR = 1 (stays on both inward-moving lines).
134
+ */
135
+ function bisectorVelocity(nL, nR) {
136
+ const det = nL.x * nR.y - nL.y * nR.x
137
+ if (Math.abs(det) < 1e-12) {
138
+ // (anti)parallel edges -> straight angle, move along the shared normal
139
+ return { x: nL.x, y: nL.y }
140
+ }
141
+ return {
142
+ x: (nR.y - nL.y) / det,
143
+ y: (nL.x - nR.x) / det,
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Intersection parameter `s` along ray (p1, d1) with ray (p2, d2):
149
+ * p1 + s*d1 = p2 + u*d2. Returns {s, u} or null when parallel.
150
+ */
151
+ function rayRay(p1, d1, p2, d2) {
152
+ const det = d2.x * d1.y - d1.x * d2.y
153
+ if (Math.abs(det) < 1e-15) return null
154
+ const dx = p2.x - p1.x
155
+ const dy = p2.y - p1.y
156
+ const s = (-dx * d2.y + d2.x * dy) / det
157
+ const u = (-dx * d1.y + d1.x * dy) / det
158
+ return { s, u }
159
+ }
160
+
161
+ // --- skeleton construction (Felkel & Obdrzalek) ----------------------------
162
+
163
+ function buildSkeleton(ring, eps) {
164
+ const n = ring.length
165
+ // original edges as { p, dir, normal }
166
+ const edges = ring.map((p, i) => {
167
+ const q = ring[(i + 1) % n]
168
+ return { p, dir: norm(sub(q, p)), normal: inwardNormal(p, q) }
169
+ })
170
+
171
+ // initial vertices: vertex i sits between edge i-1 (left) and edge i (right)
172
+ let uid = 0
173
+ const vertices = ring.map((p, i) => ({
174
+ id: uid++,
175
+ pos: { x: p.x, y: p.y },
176
+ time: 0,
177
+ edgeLeft: edges[(i - 1 + n) % n],
178
+ edgeRight: edges[i],
179
+ prev: null,
180
+ next: null,
181
+ bisector: null,
182
+ reflex: false,
183
+ processed: false,
184
+ }))
185
+ for (let i = 0; i < n; i++) {
186
+ vertices[i].prev = vertices[(i - 1 + n) % n]
187
+ vertices[i].next = vertices[(i + 1) % n]
188
+ }
189
+ vertices.forEach((v) => initVertex(v))
190
+
191
+ const allEdges = edges
192
+ const skeleton = []
193
+ const queue = new TinyQueue([], (a, b) => a.time - b.time)
194
+
195
+ vertices.forEach((v) => {
196
+ const ev = nextEventForVertex(v, allEdges, eps)
197
+ if (ev) queue.push(ev)
198
+ })
199
+
200
+ let guard = 0
201
+ const guardMax = 100 * n * n + 1000
202
+ while (queue.length > 0) {
203
+ if (guard++ > guardMax) break
204
+ const ev = queue.pop()
205
+ if (ev.type === 'edge') {
206
+ const { va, vb } = ev
207
+ if (va.processed || vb.processed) continue
208
+ if (va.next !== vb) continue
209
+ handleEdgeEvent(ev, skeleton, allEdges, queue, eps, () => uid++)
210
+ } else {
211
+ const { v } = ev
212
+ if (v.processed) continue
213
+ // re-locate the live edge instance that contains the split point
214
+ const span = findEdgeSpan(v, ev.edge, ev.point, eps)
215
+ if (!span) continue
216
+ handleSplitEvent(ev, span, skeleton, allEdges, queue, eps, () => uid++)
217
+ }
218
+ }
219
+
220
+ return skeleton.map(([a, b]) => [
221
+ { x: a.x, y: a.y, t: a.t },
222
+ { x: b.x, y: b.y, t: b.t },
223
+ ])
224
+ }
225
+
226
+ function initVertex(v) {
227
+ v.bisector = bisectorVelocity(v.edgeLeft.normal, v.edgeRight.normal)
228
+ // reflex if the turn from incoming to outgoing edge is a right turn (CCW poly)
229
+ v.reflex = cross(v.edgeLeft.dir, v.edgeRight.dir) < 0
230
+ }
231
+
232
+ function posAt(v, t) {
233
+ return add(v.pos, scale(v.bisector, t - v.time))
234
+ }
235
+
236
+ /** Earliest of: edge event with v.next, and (if reflex) a split event. */
237
+ function nextEventForVertex(v, allEdges, eps) {
238
+ const candidates = []
239
+ const e = edgeEvent(v, eps)
240
+ if (e) candidates.push(e)
241
+ if (v.reflex) {
242
+ const s = splitEvent(v, allEdges, eps)
243
+ if (s) candidates.push(s)
244
+ }
245
+ if (candidates.length === 0) return null
246
+ return candidates.reduce((a, b) => (a.time <= b.time ? a : b))
247
+ }
248
+
249
+ function edgeEvent(v, eps) {
250
+ const b = v.next
251
+ if (!b || b === v) return null
252
+ const r = rayRay(v.pos, v.bisector, b.pos, b.bisector)
253
+ if (!r) return null
254
+ const t = v.time + r.s
255
+ if (r.s < -eps) return null
256
+ if (t < v.time - eps || t < b.time - eps) return null
257
+ return { type: 'edge', time: t, point: posAt(v, t), va: v, vb: b }
258
+ }
259
+
260
+ function splitEvent(v, allEdges, eps) {
261
+ let best = null
262
+ for (const E of allEdges) {
263
+ if (E === v.edgeLeft || E === v.edgeRight) continue
264
+ const denom = 1 - dot(v.bisector, E.normal)
265
+ if (Math.abs(denom) < 1e-12) continue
266
+ const s = (dot(sub(v.pos, E.p), E.normal) - v.time) / denom
267
+ if (s < eps) continue
268
+ const t = v.time + s
269
+ const P = posAt(v, t)
270
+ // must be on the interior side of E
271
+ if (dot(sub(P, E.p), E.normal) < eps) continue
272
+ // crude pre-filter: P roughly on E's line span will be re-checked at pop
273
+ if (!best || t < best.time) {
274
+ best = { type: 'split', time: t, point: P, v, edge: E }
275
+ }
276
+ }
277
+ return best
278
+ }
279
+
280
+ /**
281
+ * Finds the currently-active vertex pair (va, va.next) whose shared edge is the
282
+ * original edge `E` and whose bisector wedge contains `point`. Returns null
283
+ * when the split point is no longer valid for this edge.
284
+ */
285
+ function findEdgeSpan(v, E, point, eps) {
286
+ // walk v's own LAV looking for the edge instance
287
+ const start = v
288
+ let cur = start
289
+ let guard = 0
290
+ do {
291
+ if (!cur.processed && cur.edgeRight === E) {
292
+ const va = cur
293
+ const vb = cur.next
294
+ if (vb && !vb.processed && pointInWedge(point, va, vb, E, eps)) {
295
+ return { va, vb }
296
+ }
297
+ }
298
+ cur = cur.next
299
+ guard++
300
+ } while (cur && cur !== start && guard < 100000)
301
+ return null
302
+ }
303
+
304
+ /** True when `P` lies in the live trapezoid of edge E bounded by the bisectors
305
+ * of its two end vertices. */
306
+ function pointInWedge(P, va, vb, E, eps) {
307
+ if (dot(sub(P, E.p), E.normal) < -eps) return false
308
+ // left bound: bisector leaving va; right bound: bisector leaving vb
309
+ const leftOk = cross(va.bisector, sub(P, va.pos)) <= eps
310
+ const rightOk = cross(vb.bisector, sub(P, vb.pos)) >= -eps
311
+ return leftOk && rightOk
312
+ }
313
+
314
+ function handleEdgeEvent(ev, skeleton, allEdges, queue, eps, nextUid) {
315
+ const { va, vb, point, time } = ev
316
+ va.processed = true
317
+ vb.processed = true
318
+ pushArc(skeleton, withTime(va.pos, va.time), withTime(point, time), eps)
319
+ pushArc(skeleton, withTime(vb.pos, vb.time), withTime(point, time), eps)
320
+
321
+ const prev = va.prev
322
+ const next = vb.next
323
+
324
+ // triangle / final collapse
325
+ if (prev === vb || next === va || prev === next) {
326
+ // remaining wavefront collapses to this point
327
+ if (prev && prev !== va && prev !== vb) {
328
+ prev.processed = true
329
+ pushArc(
330
+ skeleton,
331
+ withTime(prev.pos, prev.time),
332
+ withTime(point, time),
333
+ eps
334
+ )
335
+ }
336
+ return
337
+ }
338
+
339
+ const nv = {
340
+ id: nextUid(),
341
+ pos: { x: point.x, y: point.y },
342
+ time,
343
+ edgeLeft: va.edgeLeft,
344
+ edgeRight: vb.edgeRight,
345
+ prev,
346
+ next,
347
+ bisector: null,
348
+ reflex: false,
349
+ processed: false,
350
+ }
351
+ prev.next = nv
352
+ next.prev = nv
353
+ initVertex(nv)
354
+
355
+ const e = nextEventForVertex(nv, allEdges, eps)
356
+ if (e) queue.push(e)
357
+ }
358
+
359
+ function handleSplitEvent(ev, span, skeleton, allEdges, queue, eps, nextUid) {
360
+ const { v, point, time } = ev
361
+ const { va, vb } = span
362
+ v.processed = true
363
+ pushArc(skeleton, withTime(v.pos, v.time), withTime(point, time), eps)
364
+
365
+ // chain A: v.prev -> v1 -> vb -> ... (v1 carries v.edgeLeft and edge E)
366
+ const v1 = {
367
+ id: nextUid(),
368
+ pos: { x: point.x, y: point.y },
369
+ time,
370
+ edgeLeft: v.edgeLeft,
371
+ edgeRight: ev.edge,
372
+ prev: v.prev,
373
+ next: vb,
374
+ bisector: null,
375
+ reflex: false,
376
+ processed: false,
377
+ }
378
+ // chain B: va -> v2 -> v.next -> ... (v2 carries edge E and v.edgeRight)
379
+ const v2 = {
380
+ id: nextUid(),
381
+ pos: { x: point.x, y: point.y },
382
+ time,
383
+ edgeLeft: ev.edge,
384
+ edgeRight: v.edgeRight,
385
+ prev: va,
386
+ next: v.next,
387
+ bisector: null,
388
+ reflex: false,
389
+ processed: false,
390
+ }
391
+ v.prev.next = v1
392
+ vb.prev = v1
393
+ va.next = v2
394
+ v.next.prev = v2
395
+ initVertex(v1)
396
+ initVertex(v2)
397
+
398
+ collapseIfDegenerate(v1, skeleton, eps)
399
+ collapseIfDegenerate(v2, skeleton, eps)
400
+
401
+ for (const nv of [v1, v2]) {
402
+ if (nv.processed) continue
403
+ const e = nextEventForVertex(nv, allEdges, eps)
404
+ if (e) queue.push(e)
405
+ }
406
+ }
407
+
408
+ /** Handles a fresh vertex that is already part of a 1- or 2-vertex loop. */
409
+ function collapseIfDegenerate(nv, skeleton, eps) {
410
+ if (nv.processed) return
411
+ if (nv.next === nv) {
412
+ nv.processed = true
413
+ return
414
+ }
415
+ if (nv.next && nv.next.next === nv) {
416
+ // 2-vertex loop: both edges collapse onto the segment between them
417
+ const other = nv.next
418
+ nv.processed = true
419
+ other.processed = true
420
+ pushArc(
421
+ skeleton,
422
+ withTime(nv.pos, nv.time),
423
+ withTime(other.pos, other.time),
424
+ eps
425
+ )
426
+ }
427
+ }
428
+
429
+ // Tags a position with the wavefront time it was reached at (= its horizontal
430
+ // distance from the eaves), used downstream to derive the roof height.
431
+ function withTime(p, t) {
432
+ return { x: p.x, y: p.y, t }
433
+ }
434
+
435
+ function pushArc(skeleton, a, b, eps) {
436
+ if (Math.hypot(a.x - b.x, a.y - b.y) <= eps) return
437
+ skeleton.push([
438
+ { x: a.x, y: a.y, t: a.t },
439
+ { x: b.x, y: b.y, t: b.t },
440
+ ])
441
+ }
442
+
443
+ /**
444
+ * Clusters skeleton endpoints (welding the perturbed duplicates), snaps any
445
+ * cluster that sits on an original outline vertex back onto it, and drops
446
+ * degenerate segments.
447
+ */
448
+ function cleanSkeleton(skeleton, ring, tol) {
449
+ const reps = []
450
+ const snap = (p) => {
451
+ for (const r of reps) {
452
+ if (Math.hypot(r.x - p.x, r.y - p.y) <= tol) return r
453
+ }
454
+ const r = { x: p.x, y: p.y, t: p.t || 0 }
455
+ reps.push(r)
456
+ return r
457
+ }
458
+ // seed clusters with the exact outline vertices (height 0) so endpoints snap
459
+ // onto them
460
+ ring.forEach((p) => reps.push({ x: p.x, y: p.y, t: 0 }))
461
+
462
+ const out = []
463
+ const seen = new Set()
464
+ skeleton.forEach(([a, b]) => {
465
+ const ra = snap(a)
466
+ const rb = snap(b)
467
+ if (Math.hypot(ra.x - rb.x, ra.y - rb.y) <= tol) return
468
+ const key = `${reps.indexOf(ra)}_${reps.indexOf(rb)}`
469
+ const rev = `${reps.indexOf(rb)}_${reps.indexOf(ra)}`
470
+ if (seen.has(key) || seen.has(rev)) return
471
+ seen.add(key)
472
+ out.push([
473
+ { x: ra.x, y: ra.y, t: ra.t },
474
+ { x: rb.x, y: rb.y, t: rb.t },
475
+ ])
476
+ })
477
+ return out
478
+ }
479
+
480
+ // --- facet extraction ------------------------------------------------------
481
+
482
+ /**
483
+ * Builds roof facets by overlaying the outline and the skeleton segments into a
484
+ * planar arrangement and reading back its bounded faces.
485
+ */
486
+ function buildFaces(ring, skeleton, eps, slopeFactor) {
487
+ const n = ring.length
488
+ const rawEdges = []
489
+ for (let i = 0; i < n; i++) {
490
+ rawEdges.push([ring[i], ring[(i + 1) % n]])
491
+ }
492
+ skeleton.forEach(([a, b]) => rawEdges.push([a, b]))
493
+
494
+ // weld endpoints into a node list; z is the roof height derived from each
495
+ // node's wavefront time t (outline vertices have no t -> eave height 0).
496
+ const nodes = []
497
+ const weld = (p) => {
498
+ for (let i = 0; i < nodes.length; i++) {
499
+ if (Math.hypot(nodes[i].x - p.x, nodes[i].y - p.y) <= eps * 100) return i
500
+ }
501
+ nodes.push({ x: p.x, y: p.y, z: (p.t || 0) * slopeFactor })
502
+ return nodes.length - 1
503
+ }
504
+ const edgeList = []
505
+ const seen = new Set()
506
+ rawEdges.forEach(([a, b]) => {
507
+ const ia = weld(a)
508
+ const ib = weld(b)
509
+ if (ia === ib) return
510
+ const key = ia < ib ? `${ia}_${ib}` : `${ib}_${ia}`
511
+ if (seen.has(key)) return
512
+ seen.add(key)
513
+ edgeList.push([ia, ib])
514
+ })
515
+
516
+ let faces
517
+ try {
518
+ faces = getOutlineList(nodes, edgeList).map((f) => f.outline)
519
+ } catch (err) {
520
+ return []
521
+ }
522
+
523
+ const totalArea = Math.abs(calcPolygonArea(ring))
524
+ return faces
525
+ .filter((f) => {
526
+ const a = calcPolygonArea(f)
527
+ return a > totalArea * 1e-4 && a < totalArea * (1 - 1e-4)
528
+ })
529
+ .map((f) => f.map((p) => ({ x: p.x, y: p.y, z: p.z || 0 })))
530
+ }
@@ -0,0 +1,45 @@
1
+ import { Roof, calcPolygonArea } from '../../index'
2
+
3
+ const area = (poly) => Math.abs(calcPolygonArea(poly))
4
+ const sumFaces = (faces) => faces.reduce((s, f) => s + area(f), 0)
5
+
6
+ const rectangle = [
7
+ { x: 0, y: 0, z: 0 },
8
+ { x: 20, y: 0, z: 0 },
9
+ { x: 20, y: 10, z: 0 },
10
+ { x: 0, y: 10, z: 0 },
11
+ ]
12
+
13
+ describe('Roof.getStraightSkeleton', () => {
14
+ test('computes facets that tile the roof outline', () => {
15
+ const roof = new Roof(rectangle)
16
+ const { skeleton, faces } = roof.getStraightSkeleton()
17
+ expect(skeleton.length).toBeGreaterThan(0)
18
+ expect(sumFaces(faces)).toBeCloseTo(200, 4)
19
+ })
20
+
21
+ test('caches the result for the same version', () => {
22
+ const roof = new Roof(rectangle)
23
+ const first = roof.getStraightSkeleton()
24
+ const second = roof.getStraightSkeleton()
25
+ expect(second).toBe(first) // same reference, not recomputed
26
+ })
27
+
28
+ test('recomputes when the version changes', () => {
29
+ const roof = new Roof(rectangle)
30
+ const first = roof.getStraightSkeleton()
31
+
32
+ // simulate a geometry edit
33
+ roof.outline = [
34
+ { x: 0, y: 0, z: 0 },
35
+ { x: 30, y: 0, z: 0 },
36
+ { x: 30, y: 10, z: 0 },
37
+ { x: 0, y: 10, z: 0 },
38
+ ]
39
+ roof.version += 1
40
+
41
+ const second = roof.getStraightSkeleton()
42
+ expect(second).not.toBe(first)
43
+ expect(sumFaces(second.faces)).toBeCloseTo(300, 4)
44
+ })
45
+ })
@@ -0,0 +1,188 @@
1
+ import {
2
+ computeStraightSkeleton,
3
+ calcPolygonArea,
4
+ isInsidePolygon,
5
+ } from '../../index'
6
+
7
+ const area = (poly) => Math.abs(calcPolygonArea(poly))
8
+ const sumFaces = (faces) => faces.reduce((s, f) => s + area(f), 0)
9
+
10
+ // Every skeleton node must lie inside (or on) the outline.
11
+ const skeletonInside = (skeleton, outline) =>
12
+ skeleton.every(([a, b]) =>
13
+ [a, b].every(
14
+ (p) =>
15
+ isInsidePolygon({ x: p.x, y: p.y, z: 0 }, outline) ||
16
+ distanceToOutline(p, outline) < 1e-6
17
+ )
18
+ )
19
+
20
+ const distanceToOutline = (p, outline) => {
21
+ let min = Infinity
22
+ for (let i = 0; i < outline.length; i++) {
23
+ const a = outline[i]
24
+ const b = outline[(i + 1) % outline.length]
25
+ min = Math.min(min, distToSeg(p, a, b))
26
+ }
27
+ return min
28
+ }
29
+ const distToSeg = (p, a, b) => {
30
+ const dx = b.x - a.x
31
+ const dy = b.y - a.y
32
+ const l2 = dx * dx + dy * dy
33
+ let t = l2 ? ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2 : 0
34
+ t = Math.max(0, Math.min(1, t))
35
+ return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))
36
+ }
37
+
38
+ describe('computeStraightSkeleton', () => {
39
+ const cases = [
40
+ {
41
+ name: 'rectangle',
42
+ outline: [
43
+ { x: 0, y: 0 },
44
+ { x: 20, y: 0 },
45
+ { x: 20, y: 10 },
46
+ { x: 0, y: 10 },
47
+ ],
48
+ expectedArea: 200,
49
+ expectedFaces: 4,
50
+ },
51
+ {
52
+ name: 'square',
53
+ outline: [
54
+ { x: 0, y: 0 },
55
+ { x: 10, y: 0 },
56
+ { x: 10, y: 10 },
57
+ { x: 0, y: 10 },
58
+ ],
59
+ expectedArea: 100,
60
+ },
61
+ {
62
+ name: 'right triangle',
63
+ outline: [
64
+ { x: 0, y: 0 },
65
+ { x: 12, y: 0 },
66
+ { x: 0, y: 9 },
67
+ ],
68
+ expectedArea: 54,
69
+ expectedFaces: 3,
70
+ },
71
+ {
72
+ name: 'L-shape (1 reflex / split)',
73
+ outline: [
74
+ { x: 0, y: 0 },
75
+ { x: 10, y: 0 },
76
+ { x: 10, y: 4 },
77
+ { x: 4, y: 4 },
78
+ { x: 4, y: 10 },
79
+ { x: 0, y: 10 },
80
+ ],
81
+ expectedArea: 64,
82
+ expectedFaces: 6,
83
+ },
84
+ {
85
+ name: 'T-shape (2 reflex)',
86
+ outline: [
87
+ { x: 0, y: 6 },
88
+ { x: 4, y: 6 },
89
+ { x: 4, y: 0 },
90
+ { x: 6, y: 0 },
91
+ { x: 6, y: 6 },
92
+ { x: 10, y: 6 },
93
+ { x: 10, y: 9 },
94
+ { x: 0, y: 9 },
95
+ ],
96
+ expectedArea: 2 * 6 + 10 * 3, // stem 2x6=12 + bar 10x3=30
97
+ },
98
+ {
99
+ name: 'plus / cross (4 reflex)',
100
+ outline: [
101
+ { x: 1, y: 0 },
102
+ { x: 2, y: 0 },
103
+ { x: 2, y: 1 },
104
+ { x: 3, y: 1 },
105
+ { x: 3, y: 2 },
106
+ { x: 2, y: 2 },
107
+ { x: 2, y: 3 },
108
+ { x: 1, y: 3 },
109
+ { x: 1, y: 2 },
110
+ { x: 0, y: 2 },
111
+ { x: 0, y: 1 },
112
+ { x: 1, y: 1 },
113
+ ],
114
+ expectedArea: 5,
115
+ },
116
+ {
117
+ name: 'rotated (non axis-aligned) quad',
118
+ outline: [
119
+ { x: 0, y: 0 },
120
+ { x: 8, y: 3 },
121
+ { x: 5, y: 11 },
122
+ { x: -3, y: 8 },
123
+ ],
124
+ // area via shoelace of the rotated square (side ~8.54) = ~73
125
+ expectedArea: undefined,
126
+ },
127
+ ]
128
+
129
+ cases.forEach(({ name, outline, expectedArea, expectedFaces }) => {
130
+ test(`${name}: facets tile the outline and skeleton stays inside`, () => {
131
+ const { skeleton, faces } = computeStraightSkeleton(outline)
132
+ const total = area(outline)
133
+ expect(faces.length).toBeGreaterThanOrEqual(3)
134
+ expect(sumFaces(faces)).toBeCloseTo(total, 4)
135
+ if (expectedArea !== undefined) {
136
+ expect(total).toBeCloseTo(expectedArea, 4)
137
+ }
138
+ if (expectedFaces !== undefined) {
139
+ expect(faces.length).toBe(expectedFaces)
140
+ }
141
+ expect(skeletonInside(skeleton, outline)).toBe(true)
142
+ })
143
+ })
144
+
145
+ test('throws on degenerate outline', () => {
146
+ expect(() => computeStraightSkeleton([{ x: 0, y: 0 }])).toThrow()
147
+ })
148
+
149
+ test('z encodes the requested slope (peak height = inradius * tan(slope))', () => {
150
+ const square = [
151
+ { x: 0, y: 0 },
152
+ { x: 10, y: 0 },
153
+ { x: 10, y: 10 },
154
+ { x: 0, y: 10 },
155
+ ]
156
+ const inradius = 5
157
+ // default 30°
158
+ const { skeleton, faces } = computeStraightSkeleton(square)
159
+ const peakZ = Math.max(...skeleton.flat().map((p) => p.z))
160
+ expect(peakZ).toBeCloseTo(inradius * Math.tan((30 * Math.PI) / 180), 3)
161
+ // eaves stay at 0
162
+ const allFaceZ = faces.flat().map((p) => p.z)
163
+ expect(Math.min(...allFaceZ)).toBeCloseTo(0, 6)
164
+ expect(Math.max(...allFaceZ)).toBeCloseTo(peakZ, 3)
165
+
166
+ // custom slope is honoured; flat roof gives z = 0
167
+ const at45 = computeStraightSkeleton(square, { slopeDegrees: 45 })
168
+ expect(Math.max(...at45.skeleton.flat().map((p) => p.z))).toBeCloseTo(
169
+ inradius * Math.tan((45 * Math.PI) / 180),
170
+ 3
171
+ )
172
+ const flat = computeStraightSkeleton(square, { slopeDegrees: 0 })
173
+ expect(Math.max(...flat.skeleton.flat().map((p) => p.z))).toBeCloseTo(0, 6)
174
+ })
175
+
176
+ test('rectangle ridge sits at half-span height', () => {
177
+ const rect = [
178
+ { x: 0, y: 0 },
179
+ { x: 20, y: 0 },
180
+ { x: 20, y: 10 },
181
+ { x: 0, y: 10 },
182
+ ]
183
+ const { skeleton } = computeStraightSkeleton(rect, { slopeDegrees: 30 })
184
+ const peakZ = Math.max(...skeleton.flat().map((p) => p.z))
185
+ // ridge runs at y=5, half-span 5 from the long eaves
186
+ expect(peakZ).toBeCloseTo(5 * Math.tan((30 * Math.PI) / 180), 3)
187
+ })
188
+ })