@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 +2 -1
- package/src/index.js +1 -0
- package/src/objects/Roof.js +21 -0
- package/src/segmentVoronoi.scratch.test.js +51 -0
- package/src/straightSkeleton.js +530 -0
- package/src/tests/straightSkeleton/roofStraightSkeleton.spec.js +45 -0
- package/src/tests/straightSkeleton/straightSkeleton.spec.js +188 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eturnity/eturnity_maths",
|
|
3
|
-
"version": "9.25.0-panel-placement.
|
|
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
package/src/objects/Roof.js
CHANGED
|
@@ -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
|
+
})
|