@world-engines/spatial-authoring 0.1.0-alpha.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 +46 -0
- package/dist/SpatialWorldCanvas.d.ts +18 -0
- package/dist/SpatialWorldCanvas.js +24 -0
- package/dist/SpatialWorldEditor.d.ts +38 -0
- package/dist/SpatialWorldEditor.js +61 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/plane-generation.d.ts +48 -0
- package/dist/plane-generation.js +126 -0
- package/dist/plane-generator/package.json +15 -0
- package/dist/plane-generator/plane-generator.d.ts +57 -0
- package/dist/plane-generator/plane-generator.js +310 -0
- package/dist/plane-generator/plane-generator_bg.wasm +0 -0
- package/dist/plane-generator/plane-generator_bg.wasm.d.ts +13 -0
- package/dist/procedural-city.d.ts +135 -0
- package/dist/procedural-city.js +595 -0
- package/dist/protobuf-source-codec.d.ts +57 -0
- package/dist/protobuf-source-codec.js +317 -0
- package/dist/road-generation.d.ts +52 -0
- package/dist/road-generation.js +129 -0
- package/dist/road-parcels.d.ts +83 -0
- package/dist/road-parcels.js +1584 -0
- package/dist/schema.d.ts +47 -0
- package/dist/schema.js +82 -0
- package/dist/source-adapter.d.ts +15 -0
- package/dist/source-adapter.js +23 -0
- package/dist/spatial-runtime.d.ts +210 -0
- package/dist/spatial-runtime.js +1231 -0
- package/dist/spatial-templates.d.ts +117 -0
- package/dist/spatial-templates.js +200 -0
- package/dist/worker.d.ts +13 -0
- package/dist/worker.js +9 -0
- package/package.json +155 -0
|
@@ -0,0 +1,1584 @@
|
|
|
1
|
+
const EPSILON = 0.000001;
|
|
2
|
+
/**
|
|
3
|
+
* Point2D 会量化到 EPSILON 网格;两条量化后的平移线交点相对原法向可能
|
|
4
|
+
* 向道路回退数个微米。v10 在退界时额外向地块内侧收 4ε:两个端点各最多
|
|
5
|
+
* 0.5ε/轴的舍入,叠加法向与 miter 的投影误差后仍有余量。验收侧的道路
|
|
6
|
+
* clearance 门不放宽,frontage 线同步移动,避免把量化误差伪装成临街。
|
|
7
|
+
*/
|
|
8
|
+
const V10_INSET_QUANTIZATION_GUARD = 4 * EPSILON;
|
|
9
|
+
/**
|
|
10
|
+
* 递归裁切的两侧都会经过坐标量化;若共享同一 cut,独立量化可把 sibling
|
|
11
|
+
* 推到对方一侧并制造极小正面积交叠。仅 v10 在 cut 两侧各退 4ε,留下
|
|
12
|
+
* 对称 8ε 缝隙;这比放宽全局 overlap 判定更局部,且不改变真实道路 frontage。
|
|
13
|
+
*/
|
|
14
|
+
const V10_SPLIT_QUANTIZATION_GAP = 4 * EPSILON;
|
|
15
|
+
const DEFAULT_MAX_FACE_AREA = 22_500;
|
|
16
|
+
const DEFAULT_MIN_PARCEL_AREA = 36;
|
|
17
|
+
const DEFAULT_MAX_SLOPE = 0.22;
|
|
18
|
+
const MAX_TERRAIN_GRID_SIDE = 257;
|
|
19
|
+
const MIN_SAFE_BUILDING_DIMENSION = 10;
|
|
20
|
+
const DEFAULT_MAX_ASPECT_RATIO = 4;
|
|
21
|
+
const DEFAULT_MIN_SHORT_SIDE_METERS = 26;
|
|
22
|
+
function metric(value) {
|
|
23
|
+
return Math.round(value * 1_000_000) / 1_000_000;
|
|
24
|
+
}
|
|
25
|
+
function point(x, y) {
|
|
26
|
+
return Object.freeze({ x: metric(x), y: metric(y) });
|
|
27
|
+
}
|
|
28
|
+
function bounds(x, y, width, height) {
|
|
29
|
+
return Object.freeze({ x: metric(x), y: metric(y), width: metric(width), height: metric(height) });
|
|
30
|
+
}
|
|
31
|
+
function stableHash(parts) {
|
|
32
|
+
const input = parts.join("\u001f");
|
|
33
|
+
let hash = 0x811c9dc5;
|
|
34
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
35
|
+
hash ^= input.charCodeAt(index);
|
|
36
|
+
hash = Math.imul(hash, 0x01000193);
|
|
37
|
+
}
|
|
38
|
+
hash ^= hash >>> 16;
|
|
39
|
+
hash = Math.imul(hash, 0x7feb352d);
|
|
40
|
+
hash ^= hash >>> 15;
|
|
41
|
+
hash = Math.imul(hash, 0x846ca68b);
|
|
42
|
+
return (hash ^ (hash >>> 16)) >>> 0;
|
|
43
|
+
}
|
|
44
|
+
function stableId(parts) {
|
|
45
|
+
const first = stableHash(["parcel-a", ...parts]).toString(16).padStart(8, "0");
|
|
46
|
+
const second = stableHash(["parcel-b", ...parts]).toString(16).padStart(8, "0");
|
|
47
|
+
return `parcel_${first}${second}`;
|
|
48
|
+
}
|
|
49
|
+
function unit(parts) {
|
|
50
|
+
return stableHash(parts) / 0x1_0000_0000;
|
|
51
|
+
}
|
|
52
|
+
function assertFinite(value, label) {
|
|
53
|
+
if (!Number.isFinite(value))
|
|
54
|
+
throw new TypeError(`${label} 必须是有限数`);
|
|
55
|
+
}
|
|
56
|
+
function coordinateKey(value) {
|
|
57
|
+
return `${metric(value.x)},${metric(value.y)}`;
|
|
58
|
+
}
|
|
59
|
+
function polygonArea(polygon) {
|
|
60
|
+
let twiceArea = 0;
|
|
61
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
62
|
+
const current = polygon[index];
|
|
63
|
+
const next = polygon[(index + 1) % polygon.length];
|
|
64
|
+
if (current === undefined || next === undefined)
|
|
65
|
+
continue;
|
|
66
|
+
twiceArea += current.x * next.y - next.x * current.y;
|
|
67
|
+
}
|
|
68
|
+
return twiceArea / 2;
|
|
69
|
+
}
|
|
70
|
+
function polygonBounds(polygon) {
|
|
71
|
+
const xs = polygon.map((candidate) => candidate.x);
|
|
72
|
+
const ys = polygon.map((candidate) => candidate.y);
|
|
73
|
+
const left = Math.min(...xs);
|
|
74
|
+
const top = Math.min(...ys);
|
|
75
|
+
return bounds(left, top, Math.max(...xs) - left, Math.max(...ys) - top);
|
|
76
|
+
}
|
|
77
|
+
function signedCross(a, b, c) {
|
|
78
|
+
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
|
79
|
+
}
|
|
80
|
+
function pointInPolygon(candidate, polygon) {
|
|
81
|
+
let inside = false;
|
|
82
|
+
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index, index += 1) {
|
|
83
|
+
const a = polygon[index];
|
|
84
|
+
const b = polygon[previous];
|
|
85
|
+
if (a === undefined || b === undefined)
|
|
86
|
+
continue;
|
|
87
|
+
const onEdge = Math.abs(signedCross(b, a, candidate)) <= EPSILON
|
|
88
|
+
&& candidate.x >= Math.min(a.x, b.x) - EPSILON && candidate.x <= Math.max(a.x, b.x) + EPSILON
|
|
89
|
+
&& candidate.y >= Math.min(a.y, b.y) - EPSILON && candidate.y <= Math.max(a.y, b.y) + EPSILON;
|
|
90
|
+
if (onEdge)
|
|
91
|
+
return true;
|
|
92
|
+
const crosses = (a.y > candidate.y) !== (b.y > candidate.y)
|
|
93
|
+
&& candidate.x < (b.x - a.x) * (candidate.y - a.y) / (b.y - a.y) + a.x;
|
|
94
|
+
if (crosses)
|
|
95
|
+
inside = !inside;
|
|
96
|
+
}
|
|
97
|
+
return inside;
|
|
98
|
+
}
|
|
99
|
+
function pointInBounds(candidate, candidateBounds) {
|
|
100
|
+
return candidate.x >= candidateBounds.x - EPSILON && candidate.x <= candidateBounds.x + candidateBounds.width + EPSILON
|
|
101
|
+
&& candidate.y >= candidateBounds.y - EPSILON && candidate.y <= candidateBounds.y + candidateBounds.height + EPSILON;
|
|
102
|
+
}
|
|
103
|
+
function orientation(a, b, c) {
|
|
104
|
+
const value = signedCross(a, b, c);
|
|
105
|
+
return Math.abs(value) <= EPSILON ? 0 : value > 0 ? 1 : -1;
|
|
106
|
+
}
|
|
107
|
+
function pointOnSegment(candidate, a, b) {
|
|
108
|
+
return orientation(a, b, candidate) === 0
|
|
109
|
+
&& candidate.x >= Math.min(a.x, b.x) - EPSILON && candidate.x <= Math.max(a.x, b.x) + EPSILON
|
|
110
|
+
&& candidate.y >= Math.min(a.y, b.y) - EPSILON && candidate.y <= Math.max(a.y, b.y) + EPSILON;
|
|
111
|
+
}
|
|
112
|
+
function segmentsIntersect(a, b, c, d) {
|
|
113
|
+
const first = orientation(a, b, c);
|
|
114
|
+
const second = orientation(a, b, d);
|
|
115
|
+
const third = orientation(c, d, a);
|
|
116
|
+
const fourth = orientation(c, d, b);
|
|
117
|
+
if (first !== second && third !== fourth)
|
|
118
|
+
return true;
|
|
119
|
+
return (first === 0 && pointOnSegment(c, a, b)) || (second === 0 && pointOnSegment(d, a, b))
|
|
120
|
+
|| (third === 0 && pointOnSegment(a, c, d)) || (fourth === 0 && pointOnSegment(b, c, d));
|
|
121
|
+
}
|
|
122
|
+
/** 包含边界接触;terrain cell 只要接触 parcel 就必须参与水/坡度判定。 */
|
|
123
|
+
function polygonIntersectsBounds(polygon, candidateBounds) {
|
|
124
|
+
const corners = [
|
|
125
|
+
point(candidateBounds.x, candidateBounds.y),
|
|
126
|
+
point(candidateBounds.x + candidateBounds.width, candidateBounds.y),
|
|
127
|
+
point(candidateBounds.x + candidateBounds.width, candidateBounds.y + candidateBounds.height),
|
|
128
|
+
point(candidateBounds.x, candidateBounds.y + candidateBounds.height),
|
|
129
|
+
];
|
|
130
|
+
if (corners.some((corner) => pointInPolygon(corner, polygon)) || polygon.some((candidate) => pointInBounds(candidate, candidateBounds)))
|
|
131
|
+
return true;
|
|
132
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
133
|
+
const from = polygon[index];
|
|
134
|
+
const to = polygon[(index + 1) % polygon.length];
|
|
135
|
+
if (from === undefined || to === undefined)
|
|
136
|
+
continue;
|
|
137
|
+
for (let edge = 0; edge < corners.length; edge += 1) {
|
|
138
|
+
const a = corners[edge];
|
|
139
|
+
const b = corners[(edge + 1) % corners.length];
|
|
140
|
+
if (a !== undefined && b !== undefined && segmentsIntersect(from, to, a, b))
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
function distancePointToSegment(candidate, a, b) {
|
|
147
|
+
const dx = b.x - a.x;
|
|
148
|
+
const dy = b.y - a.y;
|
|
149
|
+
const denominator = dx * dx + dy * dy;
|
|
150
|
+
if (denominator <= EPSILON)
|
|
151
|
+
return Math.hypot(candidate.x - a.x, candidate.y - a.y);
|
|
152
|
+
const ratio = Math.max(0, Math.min(1, ((candidate.x - a.x) * dx + (candidate.y - a.y) * dy) / denominator));
|
|
153
|
+
return Math.hypot(candidate.x - (a.x + ratio * dx), candidate.y - (a.y + ratio * dy));
|
|
154
|
+
}
|
|
155
|
+
function segmentDistance(a, b, c, d) {
|
|
156
|
+
if (segmentsIntersect(a, b, c, d))
|
|
157
|
+
return 0;
|
|
158
|
+
return Math.min(distancePointToSegment(a, c, d), distancePointToSegment(b, c, d), distancePointToSegment(c, a, b), distancePointToSegment(d, a, b));
|
|
159
|
+
}
|
|
160
|
+
function polygonCentroid(polygon) {
|
|
161
|
+
const area = polygonArea(polygon);
|
|
162
|
+
if (Math.abs(area) <= EPSILON) {
|
|
163
|
+
const total = polygon.reduce((sum, candidate) => ({ x: sum.x + candidate.x, y: sum.y + candidate.y }), { x: 0, y: 0 });
|
|
164
|
+
return point(total.x / polygon.length, total.y / polygon.length);
|
|
165
|
+
}
|
|
166
|
+
let x = 0;
|
|
167
|
+
let y = 0;
|
|
168
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
169
|
+
const current = polygon[index];
|
|
170
|
+
const next = polygon[(index + 1) % polygon.length];
|
|
171
|
+
if (current === undefined || next === undefined)
|
|
172
|
+
continue;
|
|
173
|
+
const cross = current.x * next.y - next.x * current.y;
|
|
174
|
+
x += (current.x + next.x) * cross;
|
|
175
|
+
y += (current.y + next.y) * cross;
|
|
176
|
+
}
|
|
177
|
+
return point(x / (6 * area), y / (6 * area));
|
|
178
|
+
}
|
|
179
|
+
/** 保守地把 block 边界向中心收缩,保留曲线/斜边拓扑同时清出道路走廊。 */
|
|
180
|
+
function insetPolygon(polygon, margin) {
|
|
181
|
+
const center = polygonCentroid(polygon);
|
|
182
|
+
if (!pointInPolygon(center, polygon))
|
|
183
|
+
return undefined;
|
|
184
|
+
let nearest = Number.POSITIVE_INFINITY;
|
|
185
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
186
|
+
const a = polygon[index];
|
|
187
|
+
const b = polygon[(index + 1) % polygon.length];
|
|
188
|
+
if (a !== undefined && b !== undefined)
|
|
189
|
+
nearest = Math.min(nearest, distancePointToSegment(center, a, b));
|
|
190
|
+
}
|
|
191
|
+
if (!Number.isFinite(nearest) || nearest <= margin + EPSILON)
|
|
192
|
+
return undefined;
|
|
193
|
+
const factor = Math.max(0.05, 1 - (margin / nearest) * 1.35);
|
|
194
|
+
const result = polygon.map((candidate) => point(center.x + (candidate.x - center.x) * factor, center.y + (candidate.y - center.y) * factor));
|
|
195
|
+
return Math.abs(polygonArea(result)) > EPSILON ? Object.freeze(result) : undefined;
|
|
196
|
+
}
|
|
197
|
+
/** 仅 structured-v8 使用:凸的道路闭合面按每条道路边的内侧半平面退让。
|
|
198
|
+
* legacy-v6 的 centroid 缩放保持原样,凹面不尝试猜测 offset 拓扑。 */
|
|
199
|
+
function insetConvexFace(face, setback) {
|
|
200
|
+
const source = canonicalRing(face.polygon);
|
|
201
|
+
const simplified = source.filter((candidate, index) => {
|
|
202
|
+
const previous = source[(index + source.length - 1) % source.length];
|
|
203
|
+
const next = source[(index + 1) % source.length];
|
|
204
|
+
if (previous === undefined || next === undefined)
|
|
205
|
+
return false;
|
|
206
|
+
const tolerance = EPSILON * Math.max(1, Math.hypot(candidate.x - previous.x, candidate.y - previous.y), Math.hypot(next.x - candidate.x, next.y - candidate.y)) ** 2;
|
|
207
|
+
return Math.abs(signedCross(previous, candidate, next)) > tolerance;
|
|
208
|
+
});
|
|
209
|
+
const polygon = canonicalRing(simplified.length >= 3 ? simplified : source);
|
|
210
|
+
const turns = [];
|
|
211
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
212
|
+
const a = polygon[index];
|
|
213
|
+
const b = polygon[(index + 1) % polygon.length];
|
|
214
|
+
const c = polygon[(index + 2) % polygon.length];
|
|
215
|
+
if (a === undefined || b === undefined || c === undefined)
|
|
216
|
+
return undefined;
|
|
217
|
+
const turn = signedCross(a, b, c);
|
|
218
|
+
if (Math.abs(turn) <= EPSILON)
|
|
219
|
+
continue;
|
|
220
|
+
turns.push(turn);
|
|
221
|
+
}
|
|
222
|
+
if (turns.length === 0 || turns.some((turn) => turn < -EPSILON))
|
|
223
|
+
return undefined;
|
|
224
|
+
const shifted = [];
|
|
225
|
+
const frontageEdges = [];
|
|
226
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
227
|
+
const a = polygon[index];
|
|
228
|
+
const b = polygon[(index + 1) % polygon.length];
|
|
229
|
+
if (a === undefined || b === undefined)
|
|
230
|
+
return undefined;
|
|
231
|
+
const dx = b.x - a.x;
|
|
232
|
+
const dy = b.y - a.y;
|
|
233
|
+
const length = Math.hypot(dx, dy);
|
|
234
|
+
if (length <= EPSILON)
|
|
235
|
+
return undefined;
|
|
236
|
+
const normal = { x: -dy / length, y: dx / length };
|
|
237
|
+
const matching = [];
|
|
238
|
+
for (const sourceEdge of face.frontageEdges) {
|
|
239
|
+
const tolerance = EPSILON * Math.max(1, length, Math.hypot(sourceEdge.b.x - sourceEdge.a.x, sourceEdge.b.y - sourceEdge.a.y)) ** 2;
|
|
240
|
+
if (Math.abs(signedCross(a, b, sourceEdge.a)) <= tolerance && Math.abs(signedCross(a, b, sourceEdge.b)) <= tolerance)
|
|
241
|
+
matching.push(sourceEdge);
|
|
242
|
+
}
|
|
243
|
+
if (matching.length === 0)
|
|
244
|
+
return undefined;
|
|
245
|
+
const margin = Math.max(...matching.map((edge) => edge.widthMeters / 2 + setback));
|
|
246
|
+
const from = point(a.x + normal.x * margin, a.y + normal.y * margin);
|
|
247
|
+
const to = point(b.x + normal.x * margin, b.y + normal.y * margin);
|
|
248
|
+
shifted.push([from, to]);
|
|
249
|
+
for (const sourceEdge of matching) {
|
|
250
|
+
frontageEdges.push(Object.freeze({
|
|
251
|
+
...sourceEdge,
|
|
252
|
+
a: point(sourceEdge.a.x + normal.x * margin, sourceEdge.a.y + normal.y * margin),
|
|
253
|
+
b: point(sourceEdge.b.x + normal.x * margin, sourceEdge.b.y + normal.y * margin),
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const result = [];
|
|
258
|
+
for (let index = 0; index < shifted.length; index += 1) {
|
|
259
|
+
const previous = shifted[(index + shifted.length - 1) % shifted.length];
|
|
260
|
+
const current = shifted[index];
|
|
261
|
+
if (previous === undefined || current === undefined)
|
|
262
|
+
return undefined;
|
|
263
|
+
const [a, b] = previous;
|
|
264
|
+
const [c, d] = current;
|
|
265
|
+
if (a === undefined || b === undefined || c === undefined || d === undefined)
|
|
266
|
+
return undefined;
|
|
267
|
+
const r = { x: b.x - a.x, y: b.y - a.y }, s = { x: d.x - c.x, y: d.y - c.y };
|
|
268
|
+
const denominator = r.x * s.y - r.y * s.x;
|
|
269
|
+
if (Math.abs(denominator) <= EPSILON)
|
|
270
|
+
return undefined;
|
|
271
|
+
const t = ((c.x - a.x) * s.y - (c.y - a.y) * s.x) / denominator;
|
|
272
|
+
result.push(point(a.x + r.x * t, a.y + r.y * t));
|
|
273
|
+
}
|
|
274
|
+
const inset = canonicalRing(result);
|
|
275
|
+
return Math.abs(polygonArea(inset)) > EPSILON && inset.every((candidate) => pointInPolygon(candidate, polygon))
|
|
276
|
+
? Object.freeze({ ...face, polygon: inset, frontageEdges: Object.freeze(frontageEdges) }) : undefined;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* DCEL 环会保留路由器的采样点。对同一直线上的相邻采样点归并,避免两条
|
|
280
|
+
* 平行的退界线没有唯一交点;后续仍按原 GraphEdge 计算最大道路宽度,不能
|
|
281
|
+
* 因为归并而降低任何道路走廊。
|
|
282
|
+
*/
|
|
283
|
+
function simplifyCollinearRing(polygon) {
|
|
284
|
+
const source = canonicalRing(polygon);
|
|
285
|
+
const simplified = source.filter((candidate, index) => {
|
|
286
|
+
const previous = source[(index + source.length - 1) % source.length];
|
|
287
|
+
const next = source[(index + 1) % source.length];
|
|
288
|
+
if (previous === undefined || next === undefined)
|
|
289
|
+
return false;
|
|
290
|
+
const tolerance = EPSILON * Math.max(1, Math.hypot(candidate.x - previous.x, candidate.y - previous.y), Math.hypot(next.x - candidate.x, next.y - candidate.y)) ** 2;
|
|
291
|
+
return Math.abs(signedCross(previous, candidate, next)) > tolerance;
|
|
292
|
+
});
|
|
293
|
+
return canonicalRing(simplified.length >= 3 ? simplified : source);
|
|
294
|
+
}
|
|
295
|
+
function collinearOverlappingEdges(a, b, edge) {
|
|
296
|
+
const length = Math.hypot(b.x - a.x, b.y - a.y);
|
|
297
|
+
const edgeLength = Math.hypot(edge.b.x - edge.a.x, edge.b.y - edge.a.y);
|
|
298
|
+
if (length <= EPSILON || edgeLength <= EPSILON)
|
|
299
|
+
return false;
|
|
300
|
+
const tolerance = EPSILON * Math.max(1, length, edgeLength) ** 2;
|
|
301
|
+
if (Math.abs(signedCross(a, b, edge.a)) > tolerance || Math.abs(signedCross(a, b, edge.b)) > tolerance)
|
|
302
|
+
return false;
|
|
303
|
+
const unitX = (b.x - a.x) / length;
|
|
304
|
+
const unitY = (b.y - a.y) / length;
|
|
305
|
+
const first = (edge.a.x - a.x) * unitX + (edge.a.y - a.y) * unitY;
|
|
306
|
+
const second = (edge.b.x - a.x) * unitX + (edge.b.y - a.y) * unitY;
|
|
307
|
+
return Math.min(Math.max(first, second), length) - Math.max(Math.min(first, second), 0) > EPSILON;
|
|
308
|
+
}
|
|
309
|
+
/** inset 边不能在凹口跨越外环,也不能侵入任何真实道路走廊。 */
|
|
310
|
+
function insetKeepsFaceAndRoadClearance(inset, source, frontageEdges, setback) {
|
|
311
|
+
if (!polygonWithinBoundary(inset, source))
|
|
312
|
+
return false;
|
|
313
|
+
for (const edge of frontageEdges) {
|
|
314
|
+
const clearance = edge.widthMeters / 2 + setback;
|
|
315
|
+
for (let index = 0; index < inset.length; index += 1) {
|
|
316
|
+
const from = inset[index];
|
|
317
|
+
const to = inset[(index + 1) % inset.length];
|
|
318
|
+
if (from === undefined || to === undefined || segmentDistance(from, to, edge.a, edge.b) < clearance - EPSILON)
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
/** shape-filled-v10 的 simple 单环凹面路径:先在真实外街环上退界,
|
|
325
|
+
* 再分解已经退界的轮廓。内部 chord 不在这里出现,因而没有退界或 frontage。 */
|
|
326
|
+
function insetSimpleFace(face, setback) {
|
|
327
|
+
const source = canonicalRing(face.polygon);
|
|
328
|
+
const polygon = simplifyCollinearRing(source);
|
|
329
|
+
const shifted = [];
|
|
330
|
+
const frontageEdges = [];
|
|
331
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
332
|
+
const a = polygon[index];
|
|
333
|
+
const b = polygon[(index + 1) % polygon.length];
|
|
334
|
+
if (a === undefined || b === undefined)
|
|
335
|
+
return undefined;
|
|
336
|
+
const dx = b.x - a.x;
|
|
337
|
+
const dy = b.y - a.y;
|
|
338
|
+
const length = Math.hypot(dx, dy);
|
|
339
|
+
if (length <= EPSILON)
|
|
340
|
+
return undefined;
|
|
341
|
+
const matching = face.frontageEdges.filter((edge) => collinearOverlappingEdges(a, b, edge));
|
|
342
|
+
if (matching.length === 0)
|
|
343
|
+
return undefined;
|
|
344
|
+
const margin = Math.max(...matching.map((edge) => edge.widthMeters / 2 + setback)) + V10_INSET_QUANTIZATION_GUARD;
|
|
345
|
+
const normal = { x: -dy / length, y: dx / length };
|
|
346
|
+
shifted.push([point(a.x + normal.x * margin, a.y + normal.y * margin), point(b.x + normal.x * margin, b.y + normal.y * margin)]);
|
|
347
|
+
for (const edge of matching)
|
|
348
|
+
frontageEdges.push(Object.freeze({
|
|
349
|
+
...edge,
|
|
350
|
+
a: point(edge.a.x + normal.x * margin, edge.a.y + normal.y * margin),
|
|
351
|
+
b: point(edge.b.x + normal.x * margin, edge.b.y + normal.y * margin),
|
|
352
|
+
}));
|
|
353
|
+
}
|
|
354
|
+
const result = [];
|
|
355
|
+
for (let index = 0; index < shifted.length; index += 1) {
|
|
356
|
+
const previous = shifted[(index + shifted.length - 1) % shifted.length];
|
|
357
|
+
const current = shifted[index];
|
|
358
|
+
if (previous === undefined || current === undefined)
|
|
359
|
+
return undefined;
|
|
360
|
+
const [a, b] = previous;
|
|
361
|
+
const [c, d] = current;
|
|
362
|
+
if (a === undefined || b === undefined || c === undefined || d === undefined)
|
|
363
|
+
return undefined;
|
|
364
|
+
const r = { x: b.x - a.x, y: b.y - a.y }, s = { x: d.x - c.x, y: d.y - c.y };
|
|
365
|
+
const denominator = r.x * s.y - r.y * s.x;
|
|
366
|
+
if (Math.abs(denominator) <= EPSILON)
|
|
367
|
+
return undefined;
|
|
368
|
+
const t = ((c.x - a.x) * s.y - (c.y - a.y) * s.x) / denominator;
|
|
369
|
+
result.push(point(a.x + r.x * t, a.y + r.y * t));
|
|
370
|
+
}
|
|
371
|
+
const inset = canonicalRing(result);
|
|
372
|
+
if (Math.abs(polygonArea(inset)) <= EPSILON || !insetKeepsFaceAndRoadClearance(inset, source, face.frontageEdges, setback))
|
|
373
|
+
return undefined;
|
|
374
|
+
for (let index = 0; index < inset.length; index += 1)
|
|
375
|
+
for (let other = index + 1; other < inset.length; other += 1) {
|
|
376
|
+
if (other === index || other === (index + 1) % inset.length || index === (other + 1) % inset.length)
|
|
377
|
+
continue;
|
|
378
|
+
const a = inset[index];
|
|
379
|
+
const b = inset[(index + 1) % inset.length];
|
|
380
|
+
const c = inset[other];
|
|
381
|
+
const d = inset[(other + 1) % inset.length];
|
|
382
|
+
if (a !== undefined && b !== undefined && c !== undefined && d !== undefined && segmentsCrossWithArea(a, b, c, d))
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
return Object.freeze({ ...face, polygon: inset, frontageEdges: Object.freeze(frontageEdges) });
|
|
386
|
+
}
|
|
387
|
+
function facePiecesAfterInset(face) {
|
|
388
|
+
const turns = face.polygon.map((candidate, index) => {
|
|
389
|
+
const previous = face.polygon[(index + face.polygon.length - 1) % face.polygon.length];
|
|
390
|
+
const next = face.polygon[(index + 1) % face.polygon.length];
|
|
391
|
+
return previous === undefined || next === undefined ? 0 : signedCross(previous, candidate, next);
|
|
392
|
+
}).filter((turn) => Math.abs(turn) > EPSILON);
|
|
393
|
+
if (turns.length > 0 && turns.every((turn) => turn > 0))
|
|
394
|
+
return Object.freeze([face]);
|
|
395
|
+
const pieces = [...triangulate(face.polygon)];
|
|
396
|
+
if (pieces.length === 0)
|
|
397
|
+
return Object.freeze([]);
|
|
398
|
+
const isConvex = (polygon) => {
|
|
399
|
+
const turns = polygon.map((candidate, index) => {
|
|
400
|
+
const previous = polygon[(index + polygon.length - 1) % polygon.length];
|
|
401
|
+
const next = polygon[(index + 1) % polygon.length];
|
|
402
|
+
return previous === undefined || next === undefined ? 0 : signedCross(previous, candidate, next);
|
|
403
|
+
}).filter((turn) => Math.abs(turn) > EPSILON);
|
|
404
|
+
return turns.length > 0 && turns.every((turn) => turn > 0);
|
|
405
|
+
};
|
|
406
|
+
const merge = (first, second) => {
|
|
407
|
+
const edges = new Map();
|
|
408
|
+
for (const polygon of [first, second])
|
|
409
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
410
|
+
const from = polygon[index];
|
|
411
|
+
const to = polygon[(index + 1) % polygon.length];
|
|
412
|
+
if (from === undefined || to === undefined)
|
|
413
|
+
return undefined;
|
|
414
|
+
const key = `${coordinateKey(from)}>${coordinateKey(to)}`;
|
|
415
|
+
const reverse = `${coordinateKey(to)}>${coordinateKey(from)}`;
|
|
416
|
+
if (edges.has(reverse))
|
|
417
|
+
edges.delete(reverse);
|
|
418
|
+
else
|
|
419
|
+
edges.set(key, { from, to });
|
|
420
|
+
}
|
|
421
|
+
if (edges.size < 3)
|
|
422
|
+
return undefined;
|
|
423
|
+
const firstEdge = edges.values().next().value;
|
|
424
|
+
if (firstEdge === undefined)
|
|
425
|
+
return undefined;
|
|
426
|
+
const ring = [firstEdge.from];
|
|
427
|
+
let cursor = firstEdge.to;
|
|
428
|
+
edges.delete(`${coordinateKey(firstEdge.from)}>${coordinateKey(firstEdge.to)}`);
|
|
429
|
+
while (coordinateKey(cursor) !== coordinateKey(ring[0])) {
|
|
430
|
+
ring.push(cursor);
|
|
431
|
+
const next = [...edges.values()].find((edge) => coordinateKey(edge.from) === coordinateKey(cursor));
|
|
432
|
+
if (next === undefined)
|
|
433
|
+
return undefined;
|
|
434
|
+
edges.delete(`${coordinateKey(next.from)}>${coordinateKey(next.to)}`);
|
|
435
|
+
cursor = next.to;
|
|
436
|
+
if (ring.length > first.length + second.length)
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
const output = canonicalRing(ring);
|
|
440
|
+
const tolerance = EPSILON * Math.max(1, Math.abs(polygonArea(first)), Math.abs(polygonArea(second)));
|
|
441
|
+
return edges.size === 0 && Math.abs(Math.abs(polygonArea(output)) - Math.abs(polygonArea(first)) - Math.abs(polygonArea(second))) <= tolerance && isConvex(output)
|
|
442
|
+
? output : undefined;
|
|
443
|
+
};
|
|
444
|
+
let changed = true;
|
|
445
|
+
while (changed) {
|
|
446
|
+
changed = false;
|
|
447
|
+
outer: for (let first = 0; first < pieces.length; first += 1)
|
|
448
|
+
for (let second = first + 1; second < pieces.length; second += 1) {
|
|
449
|
+
const merged = merge(pieces[first], pieces[second]);
|
|
450
|
+
if (merged === undefined)
|
|
451
|
+
continue;
|
|
452
|
+
pieces.splice(second, 1);
|
|
453
|
+
pieces.splice(first, 1, merged);
|
|
454
|
+
changed = true;
|
|
455
|
+
break outer;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return Object.freeze(pieces.map((polygon) => {
|
|
459
|
+
const frontageEdges = face.frontageEdges.filter((edge) => polygon.some((from, index) => {
|
|
460
|
+
const to = polygon[(index + 1) % polygon.length];
|
|
461
|
+
if (to === undefined)
|
|
462
|
+
return false;
|
|
463
|
+
const length = Math.hypot(to.x - from.x, to.y - from.y);
|
|
464
|
+
const tolerance = EPSILON * Math.max(1, length, Math.hypot(edge.b.x - edge.a.x, edge.b.y - edge.a.y)) ** 2;
|
|
465
|
+
return Math.abs(signedCross(from, to, edge.a)) <= tolerance && Math.abs(signedCross(from, to, edge.b)) <= tolerance;
|
|
466
|
+
}));
|
|
467
|
+
return Object.freeze({
|
|
468
|
+
polygon: canonicalRing(polygon),
|
|
469
|
+
boundaryIds: face.boundaryIds,
|
|
470
|
+
frontageEdges: Object.freeze(frontageEdges),
|
|
471
|
+
widestRoadMeters: frontageEdges.length ? Math.max(...frontageEdges.map((edge) => edge.widthMeters)) : face.widestRoadMeters,
|
|
472
|
+
});
|
|
473
|
+
}));
|
|
474
|
+
}
|
|
475
|
+
function canonicalRing(polygon) {
|
|
476
|
+
const ring = polygonArea(polygon) < 0 ? [...polygon].reverse() : [...polygon];
|
|
477
|
+
let start = 0;
|
|
478
|
+
for (let index = 1; index < ring.length; index += 1) {
|
|
479
|
+
const candidate = ring[index];
|
|
480
|
+
const current = ring[start];
|
|
481
|
+
if (candidate !== undefined && current !== undefined
|
|
482
|
+
&& (candidate.x < current.x || (candidate.x === current.x && candidate.y < current.y)))
|
|
483
|
+
start = index;
|
|
484
|
+
}
|
|
485
|
+
return Object.freeze(Array.from({ length: ring.length }, (_, index) => ring[(start + index) % ring.length]));
|
|
486
|
+
}
|
|
487
|
+
function clipPolygon(polygon, normal, limit, keepLower) {
|
|
488
|
+
const result = [];
|
|
489
|
+
const projection = (candidate) => candidate.x * normal.x + candidate.y * normal.y;
|
|
490
|
+
const isInside = (candidate) => keepLower
|
|
491
|
+
? projection(candidate) <= limit + EPSILON
|
|
492
|
+
: projection(candidate) >= limit - EPSILON;
|
|
493
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
494
|
+
const from = polygon[index];
|
|
495
|
+
const to = polygon[(index + 1) % polygon.length];
|
|
496
|
+
if (from === undefined || to === undefined)
|
|
497
|
+
continue;
|
|
498
|
+
const fromInside = isInside(from);
|
|
499
|
+
const toInside = isInside(to);
|
|
500
|
+
if (fromInside)
|
|
501
|
+
result.push(from);
|
|
502
|
+
if (fromInside !== toInside) {
|
|
503
|
+
const divisor = projection(to) - projection(from);
|
|
504
|
+
if (Math.abs(divisor) > EPSILON) {
|
|
505
|
+
const ratio = (limit - projection(from)) / divisor;
|
|
506
|
+
result.push(point(from.x + (to.x - from.x) * ratio, from.y + (to.y - from.y) * ratio));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const unique = result.filter((candidate, index) => index === 0 || coordinateKey(candidate) !== coordinateKey(result[index - 1]));
|
|
511
|
+
return unique.length >= 3 ? Object.freeze(unique) : Object.freeze([]);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* 取 face 边方向的稳定主轴:长边/整体路网走向优先,近似各向同性时以 canonical 首长边打破平局。
|
|
515
|
+
* 这避免 oversized block 固定沿世界 X/Y 切成棋盘。
|
|
516
|
+
*/
|
|
517
|
+
function faceSplitDirection(polygon) {
|
|
518
|
+
let xx = 0;
|
|
519
|
+
let xy = 0;
|
|
520
|
+
let yy = 0;
|
|
521
|
+
let longest;
|
|
522
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
523
|
+
const from = polygon[index];
|
|
524
|
+
const to = polygon[(index + 1) % polygon.length];
|
|
525
|
+
if (from === undefined || to === undefined)
|
|
526
|
+
continue;
|
|
527
|
+
const dx = to.x - from.x;
|
|
528
|
+
const dy = to.y - from.y;
|
|
529
|
+
const length = Math.hypot(dx, dy);
|
|
530
|
+
if (length <= EPSILON)
|
|
531
|
+
continue;
|
|
532
|
+
const ux = dx / length;
|
|
533
|
+
const uy = dy / length;
|
|
534
|
+
xx += length * ux * ux;
|
|
535
|
+
xy += length * ux * uy;
|
|
536
|
+
yy += length * uy * uy;
|
|
537
|
+
if (longest === undefined || length > longest.length + EPSILON)
|
|
538
|
+
longest = { dx: ux, dy: uy, length };
|
|
539
|
+
}
|
|
540
|
+
if (longest === undefined)
|
|
541
|
+
return point(1, 0);
|
|
542
|
+
const difference = xx - yy;
|
|
543
|
+
const magnitude = Math.hypot(difference, 2 * xy);
|
|
544
|
+
let x;
|
|
545
|
+
let y;
|
|
546
|
+
if (magnitude <= EPSILON) {
|
|
547
|
+
x = longest.dx;
|
|
548
|
+
y = longest.dy;
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
const angle = Math.atan2(2 * xy, difference) / 2;
|
|
552
|
+
x = Math.cos(angle);
|
|
553
|
+
y = Math.sin(angle);
|
|
554
|
+
}
|
|
555
|
+
if (x < -EPSILON || (Math.abs(x) <= EPSILON && y < 0)) {
|
|
556
|
+
x = -x;
|
|
557
|
+
y = -y;
|
|
558
|
+
}
|
|
559
|
+
return point(x, y);
|
|
560
|
+
}
|
|
561
|
+
function shapeOptions(settings) {
|
|
562
|
+
const maxAspectRatio = settings?.maxAspectRatio ?? DEFAULT_MAX_ASPECT_RATIO;
|
|
563
|
+
const minShortSideMeters = settings?.minShortSideMeters ?? DEFAULT_MIN_SHORT_SIDE_METERS;
|
|
564
|
+
if (!Number.isFinite(maxAspectRatio) || maxAspectRatio < 1 || !Number.isFinite(minShortSideMeters) || minShortSideMeters < DEFAULT_MIN_SHORT_SIDE_METERS) {
|
|
565
|
+
throw new RangeError("地块 shape 参数必须为正有限数");
|
|
566
|
+
}
|
|
567
|
+
return Object.freeze({ maxAspectRatio, minShortSideMeters });
|
|
568
|
+
}
|
|
569
|
+
function convexHull(points) {
|
|
570
|
+
const unique = [...new Map(points.map((candidate) => [coordinateKey(candidate), candidate])).values()]
|
|
571
|
+
.sort((a, b) => a.x - b.x || a.y - b.y);
|
|
572
|
+
if (unique.length <= 2)
|
|
573
|
+
return Object.freeze(unique);
|
|
574
|
+
const build = (input) => {
|
|
575
|
+
const output = [];
|
|
576
|
+
for (const candidate of input) {
|
|
577
|
+
while (output.length >= 2 && signedCross(output[output.length - 2], output[output.length - 1], candidate) <= EPSILON)
|
|
578
|
+
output.pop();
|
|
579
|
+
output.push(candidate);
|
|
580
|
+
}
|
|
581
|
+
return output;
|
|
582
|
+
};
|
|
583
|
+
const lower = build(unique);
|
|
584
|
+
const upper = build([...unique].reverse());
|
|
585
|
+
return Object.freeze([...lower.slice(0, -1), ...upper.slice(0, -1)]);
|
|
586
|
+
}
|
|
587
|
+
function rotatedBounds(polygon) {
|
|
588
|
+
const hull = convexHull(polygon);
|
|
589
|
+
if (hull.length < 3)
|
|
590
|
+
return undefined;
|
|
591
|
+
let best;
|
|
592
|
+
for (let index = 0; index < hull.length; index += 1) {
|
|
593
|
+
const from = hull[index];
|
|
594
|
+
const to = hull[(index + 1) % hull.length];
|
|
595
|
+
if (from === undefined || to === undefined)
|
|
596
|
+
continue;
|
|
597
|
+
const dx = to.x - from.x;
|
|
598
|
+
const dy = to.y - from.y;
|
|
599
|
+
const length = Math.hypot(dx, dy);
|
|
600
|
+
if (length <= EPSILON)
|
|
601
|
+
continue;
|
|
602
|
+
const ux = dx / length;
|
|
603
|
+
const uy = dy / length;
|
|
604
|
+
const along = hull.map((candidate) => candidate.x * ux + candidate.y * uy);
|
|
605
|
+
const across = hull.map((candidate) => -candidate.x * uy + candidate.y * ux);
|
|
606
|
+
const width = Math.max(...along) - Math.min(...along);
|
|
607
|
+
const height = Math.max(...across) - Math.min(...across);
|
|
608
|
+
const shortSideMeters = Math.min(width, height);
|
|
609
|
+
const longSideMeters = Math.max(width, height);
|
|
610
|
+
const longAxis = width >= height ? point(ux, uy) : point(-uy, ux);
|
|
611
|
+
const area = width * height;
|
|
612
|
+
if (best === undefined || area < best.area - EPSILON
|
|
613
|
+
|| (Math.abs(area - best.area) <= EPSILON && coordinateKey(longAxis).localeCompare(coordinateKey(best.longAxis)) < 0)) {
|
|
614
|
+
best = { shortSideMeters, longSideMeters, area, longAxis };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return best === undefined ? undefined : Object.freeze(best);
|
|
618
|
+
}
|
|
619
|
+
function hasValidShape(polygon, options) {
|
|
620
|
+
const boundsValue = rotatedBounds(polygon);
|
|
621
|
+
return boundsValue !== undefined && boundsValue.shortSideMeters + EPSILON >= options.minShortSideMeters
|
|
622
|
+
&& boundsValue.longSideMeters <= boundsValue.shortSideMeters * options.maxAspectRatio + EPSILON;
|
|
623
|
+
}
|
|
624
|
+
function shapePolygonBuildable(polygon, context) {
|
|
625
|
+
if (!shapePartitionNodeBuildable(polygon, context) || !hasValidShape(polygon, context.options))
|
|
626
|
+
return false;
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
function shapePartitionNodeBuildable(polygon, context) {
|
|
630
|
+
if (Math.abs(polygonArea(polygon)) < context.minimumArea)
|
|
631
|
+
return false;
|
|
632
|
+
if (!polygonWithinBoundary(polygon, context.boundary) || !polygonClearOfEdges(polygon, context.forbiddenEdges, context.setback))
|
|
633
|
+
return false;
|
|
634
|
+
const safeBounds = safeBoundsInsidePolygon(polygon);
|
|
635
|
+
if (safeBounds === undefined || safeBounds.width < context.options.minShortSideMeters || safeBounds.height < context.options.minShortSideMeters)
|
|
636
|
+
return false;
|
|
637
|
+
return polygonTerrainBuildable(context.terrain, polygon, context.maxSlope);
|
|
638
|
+
}
|
|
639
|
+
function frontageLength(polygon, edges) {
|
|
640
|
+
let longest = 0;
|
|
641
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
642
|
+
const from = polygon[index];
|
|
643
|
+
const to = polygon[(index + 1) % polygon.length];
|
|
644
|
+
if (from === undefined || to === undefined)
|
|
645
|
+
continue;
|
|
646
|
+
const dx = to.x - from.x;
|
|
647
|
+
const dy = to.y - from.y;
|
|
648
|
+
const length = Math.hypot(dx, dy);
|
|
649
|
+
if (length <= EPSILON)
|
|
650
|
+
continue;
|
|
651
|
+
const intervals = [];
|
|
652
|
+
for (const edge of edges) {
|
|
653
|
+
const edgeLength = Math.hypot(edge.b.x - edge.a.x, edge.b.y - edge.a.y);
|
|
654
|
+
const tolerance = EPSILON * Math.max(1, length, edgeLength) ** 2;
|
|
655
|
+
if (Math.abs(signedCross(from, to, edge.a)) > tolerance || Math.abs(signedCross(from, to, edge.b)) > tolerance)
|
|
656
|
+
continue;
|
|
657
|
+
const first = ((edge.a.x - from.x) * dx + (edge.a.y - from.y) * dy) / (length * length);
|
|
658
|
+
const second = ((edge.b.x - from.x) * dx + (edge.b.y - from.y) * dy) / (length * length);
|
|
659
|
+
const low = Math.max(0, Math.min(first, second));
|
|
660
|
+
const high = Math.min(1, Math.max(first, second));
|
|
661
|
+
if (high - low > EPSILON)
|
|
662
|
+
intervals.push([low, high]);
|
|
663
|
+
}
|
|
664
|
+
intervals.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
665
|
+
let start;
|
|
666
|
+
let end;
|
|
667
|
+
for (const [low, high] of intervals) {
|
|
668
|
+
if (start === undefined || end === undefined || low > end + EPSILON) {
|
|
669
|
+
if (start !== undefined && end !== undefined)
|
|
670
|
+
longest = Math.max(longest, (end - start) * length);
|
|
671
|
+
start = low;
|
|
672
|
+
end = high;
|
|
673
|
+
}
|
|
674
|
+
else
|
|
675
|
+
end = Math.max(end, high);
|
|
676
|
+
}
|
|
677
|
+
if (start !== undefined && end !== undefined)
|
|
678
|
+
longest = Math.max(longest, (end - start) * length);
|
|
679
|
+
}
|
|
680
|
+
return longest;
|
|
681
|
+
}
|
|
682
|
+
function frontageBalancedCuts(polygon, edges) {
|
|
683
|
+
const candidates = [];
|
|
684
|
+
for (const edge of edges) {
|
|
685
|
+
const edgeDx = edge.b.x - edge.a.x;
|
|
686
|
+
const edgeDy = edge.b.y - edge.a.y;
|
|
687
|
+
const edgeLength = Math.hypot(edgeDx, edgeDy);
|
|
688
|
+
if (edgeLength <= EPSILON)
|
|
689
|
+
continue;
|
|
690
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
691
|
+
const from = polygon[index];
|
|
692
|
+
const to = polygon[(index + 1) % polygon.length];
|
|
693
|
+
if (from === undefined || to === undefined)
|
|
694
|
+
continue;
|
|
695
|
+
const polygonLength = Math.hypot(to.x - from.x, to.y - from.y);
|
|
696
|
+
const tolerance = EPSILON * Math.max(1, edgeLength, polygonLength) ** 2;
|
|
697
|
+
if (Math.abs(signedCross(edge.a, edge.b, from)) > tolerance || Math.abs(signedCross(edge.a, edge.b, to)) > tolerance)
|
|
698
|
+
continue;
|
|
699
|
+
const first = ((from.x - edge.a.x) * edgeDx + (from.y - edge.a.y) * edgeDy) / (edgeLength * edgeLength);
|
|
700
|
+
const second = ((to.x - edge.a.x) * edgeDx + (to.y - edge.a.y) * edgeDy) / (edgeLength * edgeLength);
|
|
701
|
+
const low = Math.max(0, Math.min(first, second));
|
|
702
|
+
const high = Math.min(1, Math.max(first, second));
|
|
703
|
+
if (high - low <= EPSILON)
|
|
704
|
+
continue;
|
|
705
|
+
let normal = point(edgeDx / edgeLength, edgeDy / edgeLength);
|
|
706
|
+
if (normal.x < -EPSILON || (Math.abs(normal.x) <= EPSILON && normal.y < 0))
|
|
707
|
+
normal = point(-normal.x, -normal.y);
|
|
708
|
+
const midpoint = point(edge.a.x + edgeDx * (low + high) / 2, edge.a.y + edgeDy * (low + high) / 2);
|
|
709
|
+
candidates.push(Object.freeze({ normal, cut: midpoint.x * normal.x + midpoint.y * normal.y, length: (high - low) * edgeLength, id: edge.id }));
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return Object.freeze(candidates.sort((a, b) => b.length - a.length || a.id.localeCompare(b.id) || coordinateKey(a.normal).localeCompare(coordinateKey(b.normal))));
|
|
713
|
+
}
|
|
714
|
+
function splitFaceCandidate(face, normal, cut, maximumArea, minimumArea, structured, frontageBalanced, shapeContext, depth) {
|
|
715
|
+
const lower = clipPolygon(face.polygon, normal, shapeContext === undefined ? cut : cut - V10_SPLIT_QUANTIZATION_GAP, true);
|
|
716
|
+
const upper = clipPolygon(face.polygon, normal, shapeContext === undefined ? cut : cut + V10_SPLIT_QUANTIZATION_GAP, false);
|
|
717
|
+
if (lower.length < 3 || upper.length < 3 || Math.abs(polygonArea(lower)) < minimumArea || Math.abs(polygonArea(upper)) < minimumArea)
|
|
718
|
+
return undefined;
|
|
719
|
+
const minimumFrontage = Math.max(10, Math.min(20, face.widestRoadMeters));
|
|
720
|
+
if (structured && (frontageLength(lower, face.frontageEdges) + EPSILON < minimumFrontage
|
|
721
|
+
|| frontageLength(upper, face.frontageEdges) + EPSILON < minimumFrontage))
|
|
722
|
+
return undefined;
|
|
723
|
+
if (shapeContext !== undefined && (!shapePartitionNodeBuildable(lower, shapeContext) || !shapePartitionNodeBuildable(upper, shapeContext)))
|
|
724
|
+
return undefined;
|
|
725
|
+
const directionKey = `${metric(normal.x)},${metric(normal.y)}`;
|
|
726
|
+
const lowerPartition = splitFace({ ...face, polygon: canonicalRing(lower), split: `${face.split}/d${directionKey}0` }, maximumArea, minimumArea, structured, frontageBalanced, shapeContext, depth + 1);
|
|
727
|
+
const upperPartition = splitFace({ ...face, polygon: canonicalRing(upper), split: `${face.split}/d${directionKey}1` }, maximumArea, minimumArea, structured, frontageBalanced, shapeContext, depth + 1);
|
|
728
|
+
if (shapeContext !== undefined && (lowerPartition.length === 0 || upperPartition.length === 0))
|
|
729
|
+
return undefined;
|
|
730
|
+
return Object.freeze([...lowerPartition, ...upperPartition]);
|
|
731
|
+
}
|
|
732
|
+
function splitFace(face, maximumArea, minimumArea, structured, frontageBalanced, shapeContext, depth = 0) {
|
|
733
|
+
const area = Math.abs(polygonArea(face.polygon));
|
|
734
|
+
const shapeValid = shapeContext === undefined || hasValidShape(face.polygon, shapeContext.options);
|
|
735
|
+
if (area <= maximumArea && shapeValid && (shapeContext === undefined || shapePolygonBuildable(face.polygon, shapeContext)))
|
|
736
|
+
return Object.freeze([face]);
|
|
737
|
+
if (depth >= 8)
|
|
738
|
+
return shapeValid && (shapeContext === undefined || shapePolygonBuildable(face.polygon, shapeContext)) ? Object.freeze([face]) : Object.freeze([]);
|
|
739
|
+
const directions = [];
|
|
740
|
+
const shapeBounds = shapeContext === undefined ? undefined : rotatedBounds(face.polygon);
|
|
741
|
+
if (shapeBounds !== undefined)
|
|
742
|
+
directions.push(shapeBounds.longAxis);
|
|
743
|
+
directions.push(faceSplitDirection(face.polygon));
|
|
744
|
+
const triedPrimary = new Set();
|
|
745
|
+
let primary;
|
|
746
|
+
for (const direction of directions) {
|
|
747
|
+
const key = coordinateKey(direction);
|
|
748
|
+
if (triedPrimary.has(key))
|
|
749
|
+
continue;
|
|
750
|
+
triedPrimary.add(key);
|
|
751
|
+
const projections = face.polygon.map((candidate) => candidate.x * direction.x + candidate.y * direction.y);
|
|
752
|
+
const cut = (Math.min(...projections) + Math.max(...projections)) / 2;
|
|
753
|
+
primary = splitFaceCandidate(face, direction, cut, maximumArea, minimumArea, structured, frontageBalanced, shapeContext, depth);
|
|
754
|
+
if (primary !== undefined && primary.length > 0)
|
|
755
|
+
return primary;
|
|
756
|
+
}
|
|
757
|
+
if (!structured || (!frontageBalanced && shapeContext === undefined))
|
|
758
|
+
return shapeValid ? Object.freeze([face]) : Object.freeze([]);
|
|
759
|
+
// PCA 中线无法让两侧均保留 frontage 时,沿最长且稳定排序的真实
|
|
760
|
+
// frontage 切线投影平分。这样 cut 横穿 frontage,而不是把唯一街边
|
|
761
|
+
// 完整留给一侧;仍复用同一面积、terrain、走廊与递归 hard gate。
|
|
762
|
+
const candidates = frontageBalancedCuts(face.polygon, face.frontageEdges);
|
|
763
|
+
const tried = new Set();
|
|
764
|
+
for (const { normal, cut: edgeCut } of candidates) {
|
|
765
|
+
const key = `${coordinateKey(normal)}|${metric(edgeCut)}`;
|
|
766
|
+
if (tried.has(key))
|
|
767
|
+
continue;
|
|
768
|
+
tried.add(key);
|
|
769
|
+
const balanced = splitFaceCandidate(face, normal, edgeCut, maximumArea, minimumArea, true, true, shapeContext, depth);
|
|
770
|
+
if (balanced !== undefined && balanced.length > 0)
|
|
771
|
+
return balanced;
|
|
772
|
+
}
|
|
773
|
+
return shapeValid ? Object.freeze([face]) : Object.freeze([]);
|
|
774
|
+
}
|
|
775
|
+
function pointInTriangle(candidate, triangle) {
|
|
776
|
+
const [a, b, c] = triangle;
|
|
777
|
+
if (a === undefined || b === undefined || c === undefined)
|
|
778
|
+
return false;
|
|
779
|
+
const signs = [signedCross(a, b, candidate), signedCross(b, c, candidate), signedCross(c, a, candidate)];
|
|
780
|
+
return signs.every((value) => value >= -EPSILON) || signs.every((value) => value <= EPSILON);
|
|
781
|
+
}
|
|
782
|
+
function squareInsidePolygon(center, half, polygon) {
|
|
783
|
+
const square = [
|
|
784
|
+
point(center.x - half, center.y - half), point(center.x + half, center.y - half),
|
|
785
|
+
point(center.x + half, center.y + half), point(center.x - half, center.y + half),
|
|
786
|
+
];
|
|
787
|
+
if (!square.every((candidate) => pointInPolygon(candidate, polygon)))
|
|
788
|
+
return false;
|
|
789
|
+
for (let polygonIndex = 0; polygonIndex < polygon.length; polygonIndex += 1) {
|
|
790
|
+
const a = polygon[polygonIndex];
|
|
791
|
+
const b = polygon[(polygonIndex + 1) % polygon.length];
|
|
792
|
+
if (a === undefined || b === undefined)
|
|
793
|
+
continue;
|
|
794
|
+
for (let squareIndex = 0; squareIndex < square.length; squareIndex += 1) {
|
|
795
|
+
const c = square[squareIndex];
|
|
796
|
+
const d = square[(squareIndex + 1) % square.length];
|
|
797
|
+
if (c !== undefined && d !== undefined && segmentsIntersect(a, b, c, d))
|
|
798
|
+
return false;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
function triangulate(polygon) {
|
|
804
|
+
const ring = canonicalRing(polygon);
|
|
805
|
+
const vertices = ring.map((_, index) => index);
|
|
806
|
+
const triangles = [];
|
|
807
|
+
while (vertices.length > 3) {
|
|
808
|
+
let clipped = false;
|
|
809
|
+
for (let index = 0; index < vertices.length; index += 1) {
|
|
810
|
+
const previous = vertices[(index + vertices.length - 1) % vertices.length];
|
|
811
|
+
const current = vertices[index];
|
|
812
|
+
const next = vertices[(index + 1) % vertices.length];
|
|
813
|
+
const a = ring[previous];
|
|
814
|
+
const b = ring[current];
|
|
815
|
+
const c = ring[next];
|
|
816
|
+
if (a === undefined || b === undefined || c === undefined || signedCross(a, b, c) <= EPSILON)
|
|
817
|
+
continue;
|
|
818
|
+
const triangle = [a, b, c];
|
|
819
|
+
const hasInteriorVertex = vertices.some((vertex) => vertex !== previous && vertex !== current && vertex !== next
|
|
820
|
+
&& pointInTriangle(ring[vertex], triangle));
|
|
821
|
+
if (hasInteriorVertex)
|
|
822
|
+
continue;
|
|
823
|
+
triangles.push(triangle);
|
|
824
|
+
vertices.splice(index, 1);
|
|
825
|
+
clipped = true;
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
828
|
+
if (!clipped)
|
|
829
|
+
return Object.freeze([]);
|
|
830
|
+
}
|
|
831
|
+
if (vertices.length === 3) {
|
|
832
|
+
const triangle = vertices.map((index) => ring[index]);
|
|
833
|
+
if (Math.abs(polygonArea(triangle)) > EPSILON)
|
|
834
|
+
triangles.push(triangle);
|
|
835
|
+
}
|
|
836
|
+
return Object.freeze(triangles.map((triangle) => Object.freeze(triangle)));
|
|
837
|
+
}
|
|
838
|
+
function safeBoundsInsidePolygon(polygon) {
|
|
839
|
+
let best;
|
|
840
|
+
const envelope = polygonBounds(polygon);
|
|
841
|
+
const centers = [
|
|
842
|
+
polygonCentroid(polygon),
|
|
843
|
+
point(envelope.x + envelope.width / 2, envelope.y + envelope.height / 2),
|
|
844
|
+
...triangulate(polygon).map((triangle) => point(((triangle[0]?.x ?? 0) + (triangle[1]?.x ?? 0) + (triangle[2]?.x ?? 0)) / 3, ((triangle[0]?.y ?? 0) + (triangle[1]?.y ?? 0) + (triangle[2]?.y ?? 0)) / 3)),
|
|
845
|
+
];
|
|
846
|
+
for (const center of centers) {
|
|
847
|
+
if (!pointInPolygon(center, polygon))
|
|
848
|
+
continue;
|
|
849
|
+
let low = 0;
|
|
850
|
+
let high = Math.min(envelope.width, envelope.height) / 2;
|
|
851
|
+
for (let iteration = 0; iteration < 30; iteration += 1) {
|
|
852
|
+
const half = (low + high) / 2;
|
|
853
|
+
if (squareInsidePolygon(center, half, polygon))
|
|
854
|
+
low = half;
|
|
855
|
+
else
|
|
856
|
+
high = half;
|
|
857
|
+
}
|
|
858
|
+
if (low <= EPSILON)
|
|
859
|
+
continue;
|
|
860
|
+
const inset = Math.min(0.0001, low / 2);
|
|
861
|
+
const candidate = bounds(center.x - low + inset, center.y - low + inset, (low - inset) * 2, (low - inset) * 2);
|
|
862
|
+
if (best === undefined || candidate.width * candidate.height > best.width * best.height)
|
|
863
|
+
best = candidate;
|
|
864
|
+
}
|
|
865
|
+
return best;
|
|
866
|
+
}
|
|
867
|
+
function weightedLandUse(worldSeed, city, boundaryIds, split) {
|
|
868
|
+
const weights = city.parameters.landUseWeights;
|
|
869
|
+
const kinds = ["residential", "commercial", "industrial", "civic", "park"];
|
|
870
|
+
const total = kinds.reduce((sum, kind) => sum + weights[kind], 0);
|
|
871
|
+
let cursor = unit(["road-land-use", worldSeed, city.seed, city.id, ...boundaryIds, split]) * total;
|
|
872
|
+
for (const kind of kinds) {
|
|
873
|
+
cursor -= weights[kind];
|
|
874
|
+
if (cursor < 0)
|
|
875
|
+
return kind;
|
|
876
|
+
}
|
|
877
|
+
return "park";
|
|
878
|
+
}
|
|
879
|
+
function validBoundary(boundary) {
|
|
880
|
+
if (boundary?.status !== "bounded")
|
|
881
|
+
return false;
|
|
882
|
+
const points = boundary.points;
|
|
883
|
+
if (points.length < 3 || !points.every((candidate) => Number.isFinite(candidate.x) && Number.isFinite(candidate.y)))
|
|
884
|
+
return false;
|
|
885
|
+
if (coordinateKey(points[0]) === coordinateKey(points[points.length - 1]))
|
|
886
|
+
return false;
|
|
887
|
+
if (points.some((candidate, index) => index > 0 && coordinateKey(candidate) === coordinateKey(points[index - 1])))
|
|
888
|
+
return false;
|
|
889
|
+
return polygonArea(points) > EPSILON;
|
|
890
|
+
}
|
|
891
|
+
function polygonWithinBoundary(polygon, boundary) {
|
|
892
|
+
if (!polygon.every((candidate) => pointInPolygon(candidate, boundary)))
|
|
893
|
+
return false;
|
|
894
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
895
|
+
const from = polygon[index];
|
|
896
|
+
const to = polygon[(index + 1) % polygon.length];
|
|
897
|
+
if (from === undefined || to === undefined)
|
|
898
|
+
continue;
|
|
899
|
+
if (!pointInPolygon(point((from.x + to.x) / 2, (from.y + to.y) / 2), boundary))
|
|
900
|
+
return false;
|
|
901
|
+
for (let boundaryIndex = 0; boundaryIndex < boundary.length; boundaryIndex += 1) {
|
|
902
|
+
const boundaryFrom = boundary[boundaryIndex];
|
|
903
|
+
const boundaryTo = boundary[(boundaryIndex + 1) % boundary.length];
|
|
904
|
+
if (boundaryFrom !== undefined && boundaryTo !== undefined && segmentsCrossWithArea(from, to, boundaryFrom, boundaryTo))
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
return true;
|
|
909
|
+
}
|
|
910
|
+
function isBuildableStreet(road, city) {
|
|
911
|
+
return road.cityId === city.id && (road.kind === "local" || road.kind === "arterial") && road.roadClass !== "motorway";
|
|
912
|
+
}
|
|
913
|
+
function citySurfaceRoads(city, roads) {
|
|
914
|
+
return Object.freeze([...roads]
|
|
915
|
+
.filter((road) => isBuildableStreet(road, city))
|
|
916
|
+
.sort((a, b) => a.id.localeCompare(b.id)));
|
|
917
|
+
}
|
|
918
|
+
function collectSurfaceGraph(city, roads) {
|
|
919
|
+
const points = new Map();
|
|
920
|
+
const edges = [];
|
|
921
|
+
for (const road of citySurfaceRoads(city, roads)) {
|
|
922
|
+
// 地块只由本城的 at-grade arterial/local 道路围合。城际 road 即使穿过
|
|
923
|
+
// city bbox 也只是连接基础设施,不能改变城市 parcel topology。
|
|
924
|
+
assertFinite(road.widthMeters, `road ${road.id}.widthMeters`);
|
|
925
|
+
for (let segmentIndex = 0; segmentIndex < road.segments.length; segmentIndex += 1) {
|
|
926
|
+
const segment = road.segments[segmentIndex];
|
|
927
|
+
if (segment === undefined || segment.kind !== "surface")
|
|
928
|
+
continue;
|
|
929
|
+
for (let pointIndex = 0; pointIndex < segment.points.length - 1; pointIndex += 1) {
|
|
930
|
+
const fromRaw = segment.points[pointIndex];
|
|
931
|
+
const toRaw = segment.points[pointIndex + 1];
|
|
932
|
+
if (fromRaw === undefined || toRaw === undefined)
|
|
933
|
+
continue;
|
|
934
|
+
assertFinite(fromRaw.x, `road ${road.id}.point.x`);
|
|
935
|
+
assertFinite(fromRaw.y, `road ${road.id}.point.y`);
|
|
936
|
+
assertFinite(toRaw.x, `road ${road.id}.point.x`);
|
|
937
|
+
assertFinite(toRaw.y, `road ${road.id}.point.y`);
|
|
938
|
+
const from = point(fromRaw.x, fromRaw.y);
|
|
939
|
+
const to = point(toRaw.x, toRaw.y);
|
|
940
|
+
if (coordinateKey(from) === coordinateKey(to))
|
|
941
|
+
continue;
|
|
942
|
+
const fromKey = coordinateKey(from);
|
|
943
|
+
const toKey = coordinateKey(to);
|
|
944
|
+
points.set(fromKey, from);
|
|
945
|
+
points.set(toKey, to);
|
|
946
|
+
edges.push(Object.freeze({
|
|
947
|
+
id: `${road.id}:${segmentIndex}:${pointIndex}`,
|
|
948
|
+
roadId: road.id,
|
|
949
|
+
widthMeters: road.widthMeters,
|
|
950
|
+
from: fromKey,
|
|
951
|
+
to: toKey,
|
|
952
|
+
a: from,
|
|
953
|
+
b: to,
|
|
954
|
+
}));
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return Object.freeze({ points, edges: Object.freeze(edges) });
|
|
959
|
+
}
|
|
960
|
+
function collectSurfaceRuns(city, roads) {
|
|
961
|
+
const runs = [];
|
|
962
|
+
for (const road of citySurfaceRoads(city, roads))
|
|
963
|
+
for (let segmentIndex = 0; segmentIndex < road.segments.length; segmentIndex += 1) {
|
|
964
|
+
const segment = road.segments[segmentIndex];
|
|
965
|
+
if (segment === undefined || segment.kind !== "surface" || segment.points.length < 2)
|
|
966
|
+
continue;
|
|
967
|
+
const points = segment.points.map((candidate) => point(candidate.x, candidate.y));
|
|
968
|
+
if (points.some((candidate, index) => index > 0 && coordinateKey(candidate) === coordinateKey(points[index - 1])))
|
|
969
|
+
continue;
|
|
970
|
+
runs.push(Object.freeze({ id: `${road.id}:${segmentIndex}`, widthMeters: road.widthMeters, points: Object.freeze(points) }));
|
|
971
|
+
}
|
|
972
|
+
return Object.freeze(runs);
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* 高速/城际与本城未知等级道路不能围地,但其 surface 中心线仍是不可建走廊。
|
|
976
|
+
* 这同时防御带有伪造 cityId 的 intercity 输入与遗留缺 kind 输入。
|
|
977
|
+
*/
|
|
978
|
+
function collectForbiddenCorridorEdges(city, roads) {
|
|
979
|
+
const edges = [];
|
|
980
|
+
for (const road of [...roads].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
981
|
+
const forbidden = road.kind === "intercity" || road.roadClass === "motorway"
|
|
982
|
+
|| (road.cityId === city.id && !isBuildableStreet(road, city));
|
|
983
|
+
if (!forbidden)
|
|
984
|
+
continue;
|
|
985
|
+
assertFinite(road.widthMeters, `road ${road.id}.widthMeters`);
|
|
986
|
+
for (let segmentIndex = 0; segmentIndex < road.segments.length; segmentIndex += 1) {
|
|
987
|
+
const segment = road.segments[segmentIndex];
|
|
988
|
+
if (segment === undefined || segment.kind !== "surface")
|
|
989
|
+
continue;
|
|
990
|
+
for (let pointIndex = 0; pointIndex < segment.points.length - 1; pointIndex += 1) {
|
|
991
|
+
const fromRaw = segment.points[pointIndex];
|
|
992
|
+
const toRaw = segment.points[pointIndex + 1];
|
|
993
|
+
if (fromRaw === undefined || toRaw === undefined)
|
|
994
|
+
continue;
|
|
995
|
+
const from = point(fromRaw.x, fromRaw.y);
|
|
996
|
+
const to = point(toRaw.x, toRaw.y);
|
|
997
|
+
if (coordinateKey(from) === coordinateKey(to))
|
|
998
|
+
continue;
|
|
999
|
+
edges.push(Object.freeze({
|
|
1000
|
+
id: `forbidden:${road.id}:${segmentIndex}:${pointIndex}`,
|
|
1001
|
+
roadId: road.id,
|
|
1002
|
+
widthMeters: road.widthMeters,
|
|
1003
|
+
from: coordinateKey(from), to: coordinateKey(to), a: from, b: to,
|
|
1004
|
+
}));
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
return Object.freeze(edges);
|
|
1009
|
+
}
|
|
1010
|
+
/** 只切严格内部 XY 交点;端点本来已由 canonical coordinate key 连接。 */
|
|
1011
|
+
function strictIntersection(a, b, c, d) {
|
|
1012
|
+
const abX = b.x - a.x;
|
|
1013
|
+
const abY = b.y - a.y;
|
|
1014
|
+
const cdX = d.x - c.x;
|
|
1015
|
+
const cdY = d.y - c.y;
|
|
1016
|
+
const denominator = abX * cdY - abY * cdX;
|
|
1017
|
+
if (Math.abs(denominator) <= EPSILON)
|
|
1018
|
+
return undefined;
|
|
1019
|
+
const acX = c.x - a.x;
|
|
1020
|
+
const acY = c.y - a.y;
|
|
1021
|
+
const first = (acX * cdY - acY * cdX) / denominator;
|
|
1022
|
+
const second = (acX * abY - acY * abX) / denominator;
|
|
1023
|
+
if (first <= EPSILON || first >= 1 - EPSILON || second <= EPSILON || second >= 1 - EPSILON)
|
|
1024
|
+
return undefined;
|
|
1025
|
+
return Object.freeze({ first, second });
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* terrain router 的采样线可在 XY 上相交而不共享同一采样点。DCEL 前必须切分,
|
|
1029
|
+
* 否则会把实际 junction 当成两条穿越线,遗漏可闭合 block。
|
|
1030
|
+
*/
|
|
1031
|
+
function planarizeGraph(city, graph) {
|
|
1032
|
+
const splits = new Map();
|
|
1033
|
+
for (const edge of graph.edges)
|
|
1034
|
+
splits.set(edge.id, [0, 1]);
|
|
1035
|
+
const buckets = new Map();
|
|
1036
|
+
const cellSize = Math.max(16, city.parameters.blockSizeMeters);
|
|
1037
|
+
for (let index = 0; index < graph.edges.length; index += 1) {
|
|
1038
|
+
const edge = graph.edges[index];
|
|
1039
|
+
if (edge === undefined)
|
|
1040
|
+
continue;
|
|
1041
|
+
const minColumn = Math.floor(Math.min(edge.a.x, edge.b.x) / cellSize);
|
|
1042
|
+
const maxColumn = Math.floor(Math.max(edge.a.x, edge.b.x) / cellSize);
|
|
1043
|
+
const minRow = Math.floor(Math.min(edge.a.y, edge.b.y) / cellSize);
|
|
1044
|
+
const maxRow = Math.floor(Math.max(edge.a.y, edge.b.y) / cellSize);
|
|
1045
|
+
for (let column = minColumn; column <= maxColumn; column += 1)
|
|
1046
|
+
for (let row = minRow; row <= maxRow; row += 1) {
|
|
1047
|
+
const key = `${column}:${row}`;
|
|
1048
|
+
const current = buckets.get(key) ?? [];
|
|
1049
|
+
current.push(index);
|
|
1050
|
+
buckets.set(key, current);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
const checked = new Set();
|
|
1054
|
+
for (const entries of buckets.values()) {
|
|
1055
|
+
for (let firstIndex = 0; firstIndex < entries.length; firstIndex += 1)
|
|
1056
|
+
for (let secondIndex = firstIndex + 1; secondIndex < entries.length; secondIndex += 1) {
|
|
1057
|
+
const first = entries[firstIndex];
|
|
1058
|
+
const second = entries[secondIndex];
|
|
1059
|
+
if (first === undefined || second === undefined)
|
|
1060
|
+
continue;
|
|
1061
|
+
const key = first < second ? `${first}:${second}` : `${second}:${first}`;
|
|
1062
|
+
if (checked.has(key))
|
|
1063
|
+
continue;
|
|
1064
|
+
checked.add(key);
|
|
1065
|
+
const a = graph.edges[first];
|
|
1066
|
+
const b = graph.edges[second];
|
|
1067
|
+
if (a === undefined || b === undefined)
|
|
1068
|
+
continue;
|
|
1069
|
+
const intersection = strictIntersection(a.a, a.b, b.a, b.b);
|
|
1070
|
+
if (intersection === undefined)
|
|
1071
|
+
continue;
|
|
1072
|
+
splits.get(a.id)?.push(intersection.first);
|
|
1073
|
+
splits.get(b.id)?.push(intersection.second);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
const points = new Map();
|
|
1077
|
+
const edges = [];
|
|
1078
|
+
for (const edge of graph.edges) {
|
|
1079
|
+
const parameters = [...new Set((splits.get(edge.id) ?? [0, 1]).map((value) => metric(value)))].sort((a, b) => a - b);
|
|
1080
|
+
for (let index = 0; index < parameters.length - 1; index += 1) {
|
|
1081
|
+
const fromParameter = parameters[index];
|
|
1082
|
+
const toParameter = parameters[index + 1];
|
|
1083
|
+
if (fromParameter === undefined || toParameter === undefined || toParameter - fromParameter <= EPSILON)
|
|
1084
|
+
continue;
|
|
1085
|
+
const from = point(edge.a.x + (edge.b.x - edge.a.x) * fromParameter, edge.a.y + (edge.b.y - edge.a.y) * fromParameter);
|
|
1086
|
+
const to = point(edge.a.x + (edge.b.x - edge.a.x) * toParameter, edge.a.y + (edge.b.y - edge.a.y) * toParameter);
|
|
1087
|
+
const fromKey = coordinateKey(from);
|
|
1088
|
+
const toKey = coordinateKey(to);
|
|
1089
|
+
if (fromKey === toKey)
|
|
1090
|
+
continue;
|
|
1091
|
+
points.set(fromKey, from);
|
|
1092
|
+
points.set(toKey, to);
|
|
1093
|
+
edges.push(Object.freeze({ ...edge, id: `${edge.id}@${index}`, from: fromKey, to: toKey, a: from, b: to }));
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
return Object.freeze({ points, edges: Object.freeze(edges) });
|
|
1097
|
+
}
|
|
1098
|
+
function extractFaces(city, boundary, roads) {
|
|
1099
|
+
const graph = planarizeGraph(city, collectSurfaceGraph(city, roads));
|
|
1100
|
+
const adjacency = new Map();
|
|
1101
|
+
for (const edge of graph.edges) {
|
|
1102
|
+
const from = adjacency.get(edge.from) ?? [];
|
|
1103
|
+
from.push(Object.freeze({ edge, to: edge.to }));
|
|
1104
|
+
adjacency.set(edge.from, from);
|
|
1105
|
+
const to = adjacency.get(edge.to) ?? [];
|
|
1106
|
+
to.push(Object.freeze({ edge, to: edge.from }));
|
|
1107
|
+
adjacency.set(edge.to, to);
|
|
1108
|
+
}
|
|
1109
|
+
for (const [key, entries] of adjacency) {
|
|
1110
|
+
const origin = graph.points.get(key);
|
|
1111
|
+
if (origin === undefined)
|
|
1112
|
+
continue;
|
|
1113
|
+
entries.sort((a, b) => {
|
|
1114
|
+
const aPoint = graph.points.get(a.to);
|
|
1115
|
+
const bPoint = graph.points.get(b.to);
|
|
1116
|
+
const angleA = Math.atan2((aPoint?.y ?? 0) - origin.y, (aPoint?.x ?? 0) - origin.x);
|
|
1117
|
+
const angleB = Math.atan2((bPoint?.y ?? 0) - origin.y, (bPoint?.x ?? 0) - origin.x);
|
|
1118
|
+
return angleA - angleB || a.edge.id.localeCompare(b.edge.id) || a.to.localeCompare(b.to);
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
const directed = [...graph.edges].flatMap((edge) => [
|
|
1122
|
+
Object.freeze({ edge, from: edge.from, to: edge.to }), Object.freeze({ edge, from: edge.to, to: edge.from }),
|
|
1123
|
+
]);
|
|
1124
|
+
const visited = new Set();
|
|
1125
|
+
const faces = [];
|
|
1126
|
+
for (const start of directed) {
|
|
1127
|
+
const startKey = `${start.edge.id}|${start.from}|${start.to}`;
|
|
1128
|
+
if (visited.has(startKey))
|
|
1129
|
+
continue;
|
|
1130
|
+
const ring = [];
|
|
1131
|
+
const boundaryEdgeIds = [];
|
|
1132
|
+
let current = start;
|
|
1133
|
+
for (let guard = 0; guard <= directed.length + 1; guard += 1) {
|
|
1134
|
+
const currentKey = `${current.edge.id}|${current.from}|${current.to}`;
|
|
1135
|
+
if (visited.has(currentKey))
|
|
1136
|
+
break;
|
|
1137
|
+
visited.add(currentKey);
|
|
1138
|
+
const currentPoint = graph.points.get(current.from);
|
|
1139
|
+
if (currentPoint === undefined)
|
|
1140
|
+
break;
|
|
1141
|
+
ring.push(currentPoint);
|
|
1142
|
+
boundaryEdgeIds.push(current.edge.id);
|
|
1143
|
+
const nextEntries = adjacency.get(current.to);
|
|
1144
|
+
if (nextEntries === undefined)
|
|
1145
|
+
break;
|
|
1146
|
+
const reverseIndex = nextEntries.findIndex((entry) => entry.edge.id === current.edge.id && entry.to === current.from);
|
|
1147
|
+
if (reverseIndex < 0)
|
|
1148
|
+
break;
|
|
1149
|
+
const next = nextEntries[(reverseIndex + nextEntries.length - 1) % nextEntries.length];
|
|
1150
|
+
if (next === undefined)
|
|
1151
|
+
break;
|
|
1152
|
+
current = Object.freeze({ edge: next.edge, from: current.to, to: next.to });
|
|
1153
|
+
if (current.edge.id === start.edge.id && current.from === start.from && current.to === start.to)
|
|
1154
|
+
break;
|
|
1155
|
+
}
|
|
1156
|
+
const clean = ring.filter((candidate, index) => index === 0 || coordinateKey(candidate) !== coordinateKey(ring[index - 1]));
|
|
1157
|
+
const area = polygonArea(clean);
|
|
1158
|
+
if (clean.length < 3 || area <= EPSILON)
|
|
1159
|
+
continue; // 负面积为外部无限 face。
|
|
1160
|
+
if (!polygonWithinBoundary(clean, boundary))
|
|
1161
|
+
continue;
|
|
1162
|
+
const byId = new Map(graph.edges.map((edge) => [edge.id, edge]));
|
|
1163
|
+
const frontageEdges = boundaryEdgeIds.map((id) => byId.get(id)).filter((edge) => edge !== undefined);
|
|
1164
|
+
const widestRoadMeters = Math.max(...frontageEdges.map((edge) => edge.widthMeters));
|
|
1165
|
+
faces.push(Object.freeze({
|
|
1166
|
+
polygon: canonicalRing(clean),
|
|
1167
|
+
boundaryIds: Object.freeze([...new Set(boundaryEdgeIds)].sort()),
|
|
1168
|
+
frontageEdges: Object.freeze(frontageEdges),
|
|
1169
|
+
widestRoadMeters,
|
|
1170
|
+
}));
|
|
1171
|
+
}
|
|
1172
|
+
return Object.freeze(faces);
|
|
1173
|
+
}
|
|
1174
|
+
function polygonClearOfEdges(polygon, edges, setback) {
|
|
1175
|
+
for (const edge of edges) {
|
|
1176
|
+
const clearance = edge.widthMeters / 2 + setback;
|
|
1177
|
+
if (pointInPolygon(edge.a, polygon) || pointInPolygon(edge.b, polygon))
|
|
1178
|
+
return false;
|
|
1179
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
1180
|
+
const from = polygon[index];
|
|
1181
|
+
const to = polygon[(index + 1) % polygon.length];
|
|
1182
|
+
if (from === undefined || to === undefined)
|
|
1183
|
+
continue;
|
|
1184
|
+
if (segmentDistance(from, to, edge.a, edge.b) <= clearance + EPSILON)
|
|
1185
|
+
return false;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return true;
|
|
1189
|
+
}
|
|
1190
|
+
function polygonClearOfRoadCorridors(polygon, sourceRunId, streetEdges, forbiddenEdges, setback) {
|
|
1191
|
+
const otherStreets = streetEdges.filter((edge) => !edge.id.startsWith(`${sourceRunId}:`));
|
|
1192
|
+
return polygonClearOfEdges(polygon, otherStreets, setback) && polygonClearOfEdges(polygon, forbiddenEdges, setback);
|
|
1193
|
+
}
|
|
1194
|
+
function strictlyInsidePolygon(candidate, polygon) {
|
|
1195
|
+
if (!pointInPolygon(candidate, polygon))
|
|
1196
|
+
return false;
|
|
1197
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
1198
|
+
const a = polygon[index];
|
|
1199
|
+
const b = polygon[(index + 1) % polygon.length];
|
|
1200
|
+
if (a !== undefined && b !== undefined && pointOnSegment(candidate, a, b))
|
|
1201
|
+
return false;
|
|
1202
|
+
}
|
|
1203
|
+
return true;
|
|
1204
|
+
}
|
|
1205
|
+
function segmentsCrossWithArea(a, b, c, d) {
|
|
1206
|
+
const first = signedCross(a, b, c);
|
|
1207
|
+
const second = signedCross(a, b, d);
|
|
1208
|
+
const third = signedCross(c, d, a);
|
|
1209
|
+
const fourth = signedCross(c, d, b);
|
|
1210
|
+
return (first > EPSILON && second < -EPSILON || first < -EPSILON && second > EPSILON)
|
|
1211
|
+
&& (third > EPSILON && fourth < -EPSILON || third < -EPSILON && fourth > EPSILON);
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* 边界 touch 可共存;只要外轮廓有正面积交集即为冲突。
|
|
1215
|
+
* 绝不能以内部 building safe bounds 作早退,因为两个外轮廓可相交而两个 safe square 分离。
|
|
1216
|
+
*/
|
|
1217
|
+
export function roadParcelPolygonsOverlapWithArea(a, b) {
|
|
1218
|
+
if (a.length < 3 || b.length < 3)
|
|
1219
|
+
return false;
|
|
1220
|
+
const firstEnvelope = polygonBounds(a);
|
|
1221
|
+
const secondEnvelope = polygonBounds(b);
|
|
1222
|
+
if (firstEnvelope.x + firstEnvelope.width <= secondEnvelope.x + EPSILON || secondEnvelope.x + secondEnvelope.width <= firstEnvelope.x + EPSILON
|
|
1223
|
+
|| firstEnvelope.y + firstEnvelope.height <= secondEnvelope.y + EPSILON || secondEnvelope.y + secondEnvelope.height <= firstEnvelope.y + EPSILON)
|
|
1224
|
+
return false;
|
|
1225
|
+
if (a.some((candidate) => strictlyInsidePolygon(candidate, b)) || b.some((candidate) => strictlyInsidePolygon(candidate, a)))
|
|
1226
|
+
return true;
|
|
1227
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
1228
|
+
const from = a[index];
|
|
1229
|
+
const to = a[(index + 1) % a.length];
|
|
1230
|
+
if (from === undefined || to === undefined)
|
|
1231
|
+
continue;
|
|
1232
|
+
for (let otherIndex = 0; otherIndex < b.length; otherIndex += 1) {
|
|
1233
|
+
const otherFrom = b[otherIndex];
|
|
1234
|
+
const otherTo = b[(otherIndex + 1) % b.length];
|
|
1235
|
+
if (otherFrom !== undefined && otherTo !== undefined && segmentsCrossWithArea(from, to, otherFrom, otherTo))
|
|
1236
|
+
return true;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
return false;
|
|
1240
|
+
}
|
|
1241
|
+
function parcelsOverlapWithArea(first, second) {
|
|
1242
|
+
const a = first.polygon;
|
|
1243
|
+
const b = second.polygon;
|
|
1244
|
+
return a !== undefined && b !== undefined && roadParcelPolygonsOverlapWithArea(a, b);
|
|
1245
|
+
}
|
|
1246
|
+
function withoutParcelOverlaps(parcels) {
|
|
1247
|
+
const accepted = [];
|
|
1248
|
+
for (const parcel of [...parcels].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
1249
|
+
if (!accepted.some((candidate) => parcelsOverlapWithArea(candidate, parcel)))
|
|
1250
|
+
accepted.push(parcel);
|
|
1251
|
+
}
|
|
1252
|
+
return Object.freeze(accepted);
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* 已验收的 block face 优先保留;frontage 仅填补其外部未覆盖的普通街带。
|
|
1256
|
+
* 不允许按 ID 让 later frontage 淘汰 face,也不允许通过该合并绕过正面积无交门。
|
|
1257
|
+
*/
|
|
1258
|
+
function appendUncoveredFrontage(acceptedFaces, frontageCandidates) {
|
|
1259
|
+
const accepted = [...acceptedFaces].sort((a, b) => a.id.localeCompare(b.id));
|
|
1260
|
+
for (const frontage of [...frontageCandidates].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
1261
|
+
if (!accepted.some((candidate) => parcelsOverlapWithArea(candidate, frontage)))
|
|
1262
|
+
accepted.push(frontage);
|
|
1263
|
+
}
|
|
1264
|
+
accepted.sort((a, b) => a.id.localeCompare(b.id));
|
|
1265
|
+
return Object.freeze(accepted);
|
|
1266
|
+
}
|
|
1267
|
+
function polylineLength(points) {
|
|
1268
|
+
let total = 0;
|
|
1269
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
1270
|
+
const previous = points[index - 1];
|
|
1271
|
+
const current = points[index];
|
|
1272
|
+
if (previous !== undefined && current !== undefined)
|
|
1273
|
+
total += Math.hypot(current.x - previous.x, current.y - previous.y);
|
|
1274
|
+
}
|
|
1275
|
+
return total;
|
|
1276
|
+
}
|
|
1277
|
+
/** 将长 route segment 分为固定上限的连续采样段;不按世界坐标切格。 */
|
|
1278
|
+
function frontageSlices(run, maxLength = 42, minimumLength = 16) {
|
|
1279
|
+
const slices = [];
|
|
1280
|
+
let start = 0;
|
|
1281
|
+
let ordinal = 0;
|
|
1282
|
+
while (start < run.points.length - 1) {
|
|
1283
|
+
const points = [run.points[start]];
|
|
1284
|
+
let length = 0;
|
|
1285
|
+
let end = start + 1;
|
|
1286
|
+
for (; end < run.points.length; end += 1) {
|
|
1287
|
+
const previous = run.points[end - 1];
|
|
1288
|
+
const current = run.points[end];
|
|
1289
|
+
if (previous === undefined || current === undefined)
|
|
1290
|
+
continue;
|
|
1291
|
+
const nextLength = length + Math.hypot(current.x - previous.x, current.y - previous.y);
|
|
1292
|
+
if (points.length > 1 && nextLength > maxLength)
|
|
1293
|
+
break;
|
|
1294
|
+
points.push(current);
|
|
1295
|
+
length = nextLength;
|
|
1296
|
+
}
|
|
1297
|
+
if (points.length >= 2 && length >= minimumLength)
|
|
1298
|
+
slices.push(Object.freeze({ id: `${run.id}:frontage:${ordinal}`, points: Object.freeze(points) }));
|
|
1299
|
+
if (end >= run.points.length)
|
|
1300
|
+
break;
|
|
1301
|
+
start = Math.max(start + 1, end - 1);
|
|
1302
|
+
ordinal += 1;
|
|
1303
|
+
}
|
|
1304
|
+
return Object.freeze(slices);
|
|
1305
|
+
}
|
|
1306
|
+
function subdividePolyline(points, maximumStep) {
|
|
1307
|
+
const output = [];
|
|
1308
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
1309
|
+
const from = points[index - 1];
|
|
1310
|
+
const to = points[index];
|
|
1311
|
+
if (from === undefined || to === undefined)
|
|
1312
|
+
continue;
|
|
1313
|
+
if (output.length === 0)
|
|
1314
|
+
output.push(from);
|
|
1315
|
+
const length = Math.hypot(to.x - from.x, to.y - from.y);
|
|
1316
|
+
const steps = Math.max(1, Math.ceil(length / maximumStep));
|
|
1317
|
+
for (let step = 1; step <= steps; step += 1)
|
|
1318
|
+
output.push(point(from.x + (to.x - from.x) * step / steps, from.y + (to.y - from.y) * step / steps));
|
|
1319
|
+
}
|
|
1320
|
+
return Object.freeze(output);
|
|
1321
|
+
}
|
|
1322
|
+
function frontagePolygon(points, side, inner, outer) {
|
|
1323
|
+
if (points.length < 2 || polylineLength(points) <= EPSILON)
|
|
1324
|
+
return undefined;
|
|
1325
|
+
const offset = (index, distance) => {
|
|
1326
|
+
const current = points[index];
|
|
1327
|
+
const previous = points[Math.max(0, index - 1)];
|
|
1328
|
+
const next = points[Math.min(points.length - 1, index + 1)];
|
|
1329
|
+
if (current === undefined || previous === undefined || next === undefined)
|
|
1330
|
+
return undefined;
|
|
1331
|
+
const beforeLength = Math.hypot(current.x - previous.x, current.y - previous.y);
|
|
1332
|
+
const afterLength = Math.hypot(next.x - current.x, next.y - current.y);
|
|
1333
|
+
const beforeX = beforeLength <= EPSILON ? 0 : (current.x - previous.x) / beforeLength;
|
|
1334
|
+
const beforeY = beforeLength <= EPSILON ? 0 : (current.y - previous.y) / beforeLength;
|
|
1335
|
+
const afterX = afterLength <= EPSILON ? beforeX : (next.x - current.x) / afterLength;
|
|
1336
|
+
const afterY = afterLength <= EPSILON ? beforeY : (next.y - current.y) / afterLength;
|
|
1337
|
+
const tangentLength = Math.hypot(beforeX + afterX, beforeY + afterY);
|
|
1338
|
+
const tangentX = tangentLength <= EPSILON ? afterX : (beforeX + afterX) / tangentLength;
|
|
1339
|
+
const tangentY = tangentLength <= EPSILON ? afterY : (beforeY + afterY) / tangentLength;
|
|
1340
|
+
if (Math.hypot(tangentX, tangentY) <= EPSILON)
|
|
1341
|
+
return undefined;
|
|
1342
|
+
return point(current.x - tangentY * side * distance, current.y + tangentX * side * distance);
|
|
1343
|
+
};
|
|
1344
|
+
const innerLine = points.map((_, index) => offset(index, inner));
|
|
1345
|
+
const outerLine = points.map((_, index) => offset(index, outer));
|
|
1346
|
+
if (innerLine.some((candidate) => candidate === undefined) || outerLine.some((candidate) => candidate === undefined))
|
|
1347
|
+
return undefined;
|
|
1348
|
+
const polygon = canonicalRing([...innerLine, ...outerLine.reverse()]);
|
|
1349
|
+
return Math.abs(polygonArea(polygon)) > EPSILON ? polygon : undefined;
|
|
1350
|
+
}
|
|
1351
|
+
function polygonClearOfSourceRun(polygon, sourceRunId, edges, clearance) {
|
|
1352
|
+
const sourceEdges = edges.filter((edge) => edge.id.startsWith(`${sourceRunId}:`));
|
|
1353
|
+
if (sourceEdges.length === 0)
|
|
1354
|
+
return false;
|
|
1355
|
+
for (const candidate of polygon) {
|
|
1356
|
+
if (sourceEdges.some((edge) => distancePointToSegment(candidate, edge.a, edge.b) < clearance - 0.2))
|
|
1357
|
+
return false;
|
|
1358
|
+
}
|
|
1359
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
1360
|
+
const from = polygon[index];
|
|
1361
|
+
const to = polygon[(index + 1) % polygon.length];
|
|
1362
|
+
if (from === undefined || to === undefined)
|
|
1363
|
+
continue;
|
|
1364
|
+
if (sourceEdges.some((edge) => segmentsIntersect(from, to, edge.a, edge.b)))
|
|
1365
|
+
return false;
|
|
1366
|
+
}
|
|
1367
|
+
return true;
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* 没有可建 block 时才使用:沿真实 surface elementary edge 的两侧生成少量 frontage,
|
|
1371
|
+
* 不做坐标网格分块,也不会跨越其他 road corridor、水面或陡坡。
|
|
1372
|
+
*/
|
|
1373
|
+
function extractFrontageParcels(worldSeed, city, terrain, boundary, roads, minimumArea, maxSlope, setback, shape) {
|
|
1374
|
+
const graph = planarizeGraph(city, collectSurfaceGraph(city, roads));
|
|
1375
|
+
const forbiddenEdges = collectForbiddenCorridorEdges(city, roads);
|
|
1376
|
+
const parcels = [];
|
|
1377
|
+
const requiredStripDimension = shape === undefined ? 0 : shape.minShortSideMeters * Math.SQRT2 + 1;
|
|
1378
|
+
const depth = shape === undefined
|
|
1379
|
+
? Math.max(10, Math.min(20, city.parameters.blockSizeMeters * 0.18))
|
|
1380
|
+
: Math.max(city.parameters.blockSizeMeters * 0.18, requiredStripDimension);
|
|
1381
|
+
// 高最短边约束会让 10m 取样后的累计长度错过刚好合格的临街段,预留一个取样步长。
|
|
1382
|
+
const maximumSliceLength = shape === undefined
|
|
1383
|
+
? 42
|
|
1384
|
+
: Math.max(requiredStripDimension + (requiredStripDimension > 42 ? 10 : 0), Math.min(42, shape.maxAspectRatio * depth));
|
|
1385
|
+
for (const run of collectSurfaceRuns(city, roads)) {
|
|
1386
|
+
const slicedRun = shape === undefined ? run : Object.freeze({ ...run, points: subdividePolyline(run.points, Math.max(1, Math.min(10, maximumSliceLength))) });
|
|
1387
|
+
for (const slice of frontageSlices(slicedRun, maximumSliceLength, shape === undefined ? 16 : requiredStripDimension)) {
|
|
1388
|
+
const clearance = run.widthMeters / 2 + setback;
|
|
1389
|
+
for (const side of [-1, 1]) {
|
|
1390
|
+
const polygon = frontagePolygon(slice.points, side, clearance + 0.01, clearance + depth);
|
|
1391
|
+
if (polygon === undefined)
|
|
1392
|
+
continue;
|
|
1393
|
+
if (shape !== undefined && !hasValidShape(polygon, shape))
|
|
1394
|
+
continue;
|
|
1395
|
+
if (!polygonWithinBoundary(polygon, boundary))
|
|
1396
|
+
continue;
|
|
1397
|
+
if (!polygonClearOfSourceRun(polygon, run.id, graph.edges, clearance))
|
|
1398
|
+
continue;
|
|
1399
|
+
if (!polygonClearOfRoadCorridors(polygon, run.id, graph.edges, forbiddenEdges, setback))
|
|
1400
|
+
continue;
|
|
1401
|
+
if (!polygonTerrainBuildable(terrain, polygon, maxSlope))
|
|
1402
|
+
continue;
|
|
1403
|
+
const safeBounds = safeBoundsInsidePolygon(polygon);
|
|
1404
|
+
const minimumSafeDimension = shape?.minShortSideMeters ?? MIN_SAFE_BUILDING_DIMENSION;
|
|
1405
|
+
if (safeBounds === undefined || safeBounds.width < minimumSafeDimension || safeBounds.height < minimumSafeDimension
|
|
1406
|
+
|| safeBounds.width * safeBounds.height < minimumArea)
|
|
1407
|
+
continue;
|
|
1408
|
+
const split = `frontage-v1:${side < 0 ? "left" : "right"}`;
|
|
1409
|
+
const boundaryIds = Object.freeze([slice.id]);
|
|
1410
|
+
parcels.push(Object.freeze({
|
|
1411
|
+
id: stableId([worldSeed, city.seed, city.id, ...boundaryIds, split]),
|
|
1412
|
+
cityId: city.id,
|
|
1413
|
+
landUse: weightedLandUse(worldSeed, city, boundaryIds, split),
|
|
1414
|
+
bounds: safeBounds,
|
|
1415
|
+
polygon: Object.freeze(polygon.map((candidate) => Object.freeze(candidate))),
|
|
1416
|
+
}));
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
return withoutParcelOverlaps(parcels);
|
|
1421
|
+
}
|
|
1422
|
+
function validTerrainGrid(grid, maxSlope) {
|
|
1423
|
+
return Number.isInteger(grid.width) && Number.isInteger(grid.height)
|
|
1424
|
+
&& grid.width >= 2 && grid.height >= 2 && grid.width <= MAX_TERRAIN_GRID_SIDE && grid.height <= MAX_TERRAIN_GRID_SIDE
|
|
1425
|
+
&& grid.elevationsMeters.length === grid.width * grid.height && grid.waterMask.length === grid.width * grid.height
|
|
1426
|
+
&& Number.isFinite(grid.widthMeters) && Number.isFinite(grid.heightMeters) && grid.widthMeters > 0 && grid.heightMeters > 0
|
|
1427
|
+
&& Number.isFinite(grid.originX) && Number.isFinite(grid.originY) && Number.isFinite(maxSlope) && maxSlope >= 0;
|
|
1428
|
+
}
|
|
1429
|
+
function terrainCellBuildable(grid, column, row, maxSlope) {
|
|
1430
|
+
if (!validTerrainGrid(grid, maxSlope) || column < 0 || row < 0 || column >= grid.width || row >= grid.height)
|
|
1431
|
+
return false;
|
|
1432
|
+
const cellWidth = grid.widthMeters / (grid.width - 1);
|
|
1433
|
+
const cellHeight = grid.heightMeters / (grid.height - 1);
|
|
1434
|
+
const index = row * grid.width + column;
|
|
1435
|
+
if (Boolean(grid.waterMask[index]))
|
|
1436
|
+
return false;
|
|
1437
|
+
const elevation = grid.elevationsMeters[index];
|
|
1438
|
+
return Number.isFinite(elevation) && cellWidth > 0 && cellHeight > 0;
|
|
1439
|
+
}
|
|
1440
|
+
function terrainCellBounds(grid, column, row) {
|
|
1441
|
+
const stepX = grid.widthMeters / (grid.width - 1);
|
|
1442
|
+
const stepY = grid.heightMeters / (grid.height - 1);
|
|
1443
|
+
const centerX = grid.originX + column * stepX;
|
|
1444
|
+
const centerY = grid.originY + row * stepY;
|
|
1445
|
+
const minX = Math.max(grid.originX, centerX - stepX / 2);
|
|
1446
|
+
const minY = Math.max(grid.originY, centerY - stepY / 2);
|
|
1447
|
+
const maxX = Math.min(grid.originX + grid.widthMeters, centerX + stepX / 2);
|
|
1448
|
+
const maxY = Math.min(grid.originY + grid.heightMeters, centerY + stepY / 2);
|
|
1449
|
+
return bounds(minX, minY, maxX - minX, maxY - minY);
|
|
1450
|
+
}
|
|
1451
|
+
/** 固定 city bbox grid 的最近点采样;水和过陡的 cell 均不可建。 */
|
|
1452
|
+
export function sampleRoadParcelTerrain(grid, candidate, maxSlope = DEFAULT_MAX_SLOPE) {
|
|
1453
|
+
if (!validTerrainGrid(grid, maxSlope))
|
|
1454
|
+
return false;
|
|
1455
|
+
const cellWidth = grid.widthMeters / (grid.width - 1);
|
|
1456
|
+
const cellHeight = grid.heightMeters / (grid.height - 1);
|
|
1457
|
+
const column = Math.round((candidate.x - grid.originX) / cellWidth);
|
|
1458
|
+
const row = Math.round((candidate.y - grid.originY) / cellHeight);
|
|
1459
|
+
return terrainCellBuildable(grid, column, row, maxSlope);
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* 不仅采样 center/corners:扫描所有与 polygon 接触的 terrain cells。
|
|
1463
|
+
* 这让内部小湖、沿边的细水带和孤立坡度尖峰都成为硬排除条件。
|
|
1464
|
+
*/
|
|
1465
|
+
function polygonTerrainBuildable(grid, polygon, maxSlope) {
|
|
1466
|
+
if (!validTerrainGrid(grid, maxSlope))
|
|
1467
|
+
return false;
|
|
1468
|
+
const terrainBounds = bounds(grid.originX, grid.originY, grid.widthMeters, grid.heightMeters);
|
|
1469
|
+
if (polygon.some((candidate) => !pointInBounds(candidate, terrainBounds)))
|
|
1470
|
+
return false;
|
|
1471
|
+
const envelope = polygonBounds(polygon);
|
|
1472
|
+
const stepX = grid.widthMeters / (grid.width - 1);
|
|
1473
|
+
const stepY = grid.heightMeters / (grid.height - 1);
|
|
1474
|
+
const firstColumn = Math.max(0, Math.ceil((envelope.x - grid.originX) / stepX - 0.5 - EPSILON));
|
|
1475
|
+
const lastColumn = Math.min(grid.width - 1, Math.floor((envelope.x + envelope.width - grid.originX) / stepX + 0.5 + EPSILON));
|
|
1476
|
+
const firstRow = Math.max(0, Math.ceil((envelope.y - grid.originY) / stepY - 0.5 - EPSILON));
|
|
1477
|
+
const lastRow = Math.min(grid.height - 1, Math.floor((envelope.y + envelope.height - grid.originY) / stepY + 0.5 + EPSILON));
|
|
1478
|
+
const touched = new Set();
|
|
1479
|
+
for (let row = firstRow; row <= lastRow; row += 1) {
|
|
1480
|
+
for (let column = firstColumn; column <= lastColumn; column += 1) {
|
|
1481
|
+
if (!polygonIntersectsBounds(polygon, terrainCellBounds(grid, column, row)))
|
|
1482
|
+
continue;
|
|
1483
|
+
if (!terrainCellBuildable(grid, column, row, maxSlope))
|
|
1484
|
+
return false;
|
|
1485
|
+
touched.add(row * grid.width + column);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
for (const index of touched) {
|
|
1489
|
+
const column = index % grid.width;
|
|
1490
|
+
const row = Math.floor(index / grid.width);
|
|
1491
|
+
const elevation = grid.elevationsMeters[index];
|
|
1492
|
+
for (const [nextColumn, nextRow, distance] of [[column + 1, row, stepX], [column, row + 1, stepY]]) {
|
|
1493
|
+
if (nextColumn >= grid.width || nextRow >= grid.height)
|
|
1494
|
+
continue;
|
|
1495
|
+
const nextIndex = nextRow * grid.width + nextColumn;
|
|
1496
|
+
if (!touched.has(nextIndex))
|
|
1497
|
+
continue;
|
|
1498
|
+
const nextElevation = grid.elevationsMeters[nextIndex];
|
|
1499
|
+
if (!Number.isFinite(elevation) || !Number.isFinite(nextElevation)
|
|
1500
|
+
|| Math.abs(nextElevation - elevation) / distance > maxSlope + EPSILON)
|
|
1501
|
+
return false;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
return true;
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* 从 surface road 的 planar faces 生成可重建的真实地块。缺少可采样 terrain 时有意返回空,
|
|
1508
|
+
* 而非退回矩形棋盘或猜测水面;建筑需在角色进入 gate 后再以 parcel.bounds 生成。
|
|
1509
|
+
*/
|
|
1510
|
+
export function generateRoadParcels(worldSeed, city, input) {
|
|
1511
|
+
if (!Number.isInteger(worldSeed) || worldSeed < 0 || worldSeed > 0xffff_ffff)
|
|
1512
|
+
throw new RangeError("worldSeed 必须是 uint32");
|
|
1513
|
+
if (input.algorithm !== undefined && input.algorithm !== "legacy-v6" && input.algorithm !== "structured-v8" && input.algorithm !== "frontage-balanced-v9" && input.algorithm !== "shape-filled-v10") {
|
|
1514
|
+
throw new TypeError("road parcel algorithm 非法");
|
|
1515
|
+
}
|
|
1516
|
+
if (input.terrain === undefined || !validBoundary(input.boundary))
|
|
1517
|
+
return Object.freeze({ parcels: Object.freeze([]) });
|
|
1518
|
+
const maximumArea = input.settings?.maxFaceAreaMeters ?? DEFAULT_MAX_FACE_AREA;
|
|
1519
|
+
const minimumArea = input.settings?.minParcelAreaMeters ?? DEFAULT_MIN_PARCEL_AREA;
|
|
1520
|
+
const maxSlope = input.settings?.maxSlope ?? DEFAULT_MAX_SLOPE;
|
|
1521
|
+
const requestedSetback = input.settings?.setbackMeters ?? 0;
|
|
1522
|
+
const setback = Math.max(0, requestedSetback);
|
|
1523
|
+
if (!Number.isFinite(maximumArea) || maximumArea < minimumArea || minimumArea <= 0)
|
|
1524
|
+
throw new RangeError("地块面积设置非法");
|
|
1525
|
+
if (!Number.isFinite(maxSlope) || maxSlope < 0 || !Number.isFinite(requestedSetback))
|
|
1526
|
+
throw new RangeError("地块地形设置非法");
|
|
1527
|
+
const boundary = input.boundary.points;
|
|
1528
|
+
const forbiddenEdges = collectForbiddenCorridorEdges(city, input.roads);
|
|
1529
|
+
const faces = extractFaces(city, boundary, input.roads);
|
|
1530
|
+
const shapeFilled = input.algorithm === "shape-filled-v10";
|
|
1531
|
+
const shapeContext = shapeFilled ? Object.freeze({
|
|
1532
|
+
boundary,
|
|
1533
|
+
forbiddenEdges,
|
|
1534
|
+
terrain: input.terrain,
|
|
1535
|
+
maxSlope,
|
|
1536
|
+
setback,
|
|
1537
|
+
minimumArea,
|
|
1538
|
+
options: shapeOptions(input.settings),
|
|
1539
|
+
}) : undefined;
|
|
1540
|
+
const structured = input.algorithm === "structured-v8" || input.algorithm === "frontage-balanced-v9" || shapeFilled;
|
|
1541
|
+
const frontageBalanced = input.algorithm === "frontage-balanced-v9";
|
|
1542
|
+
const candidates = faces
|
|
1543
|
+
.flatMap((face) => {
|
|
1544
|
+
if (!structured)
|
|
1545
|
+
return splitFace({ ...face, split: "root" }, maximumArea, minimumArea, false, false, undefined);
|
|
1546
|
+
if (shapeFilled) {
|
|
1547
|
+
const inset = insetSimpleFace(face, setback);
|
|
1548
|
+
return inset === undefined ? [] : facePiecesAfterInset(inset)
|
|
1549
|
+
.flatMap((piece, index) => splitFace({ ...piece, split: `root/c${index}` }, maximumArea, minimumArea, true, true, shapeContext));
|
|
1550
|
+
}
|
|
1551
|
+
const inset = insetConvexFace(face, setback);
|
|
1552
|
+
return inset === undefined ? [] : splitFace({ ...inset, split: "root" }, maximumArea, minimumArea, true, frontageBalanced, shapeContext);
|
|
1553
|
+
});
|
|
1554
|
+
const parcels = [];
|
|
1555
|
+
for (const face of candidates) {
|
|
1556
|
+
const polygon = structured ? face.polygon : insetPolygon(face.polygon, face.widestRoadMeters / 2 + setback);
|
|
1557
|
+
if (polygon === undefined || Math.abs(polygonArea(polygon)) < minimumArea)
|
|
1558
|
+
continue;
|
|
1559
|
+
if (!polygonWithinBoundary(polygon, boundary) || !polygonClearOfEdges(polygon, forbiddenEdges, setback))
|
|
1560
|
+
continue;
|
|
1561
|
+
const safeBounds = safeBoundsInsidePolygon(polygon);
|
|
1562
|
+
const minimumSafeDimension = shapeContext?.options.minShortSideMeters ?? MIN_SAFE_BUILDING_DIMENSION;
|
|
1563
|
+
if (safeBounds === undefined || safeBounds.width < minimumSafeDimension || safeBounds.height < minimumSafeDimension
|
|
1564
|
+
|| safeBounds.width * safeBounds.height < Math.min(4, minimumArea))
|
|
1565
|
+
continue;
|
|
1566
|
+
if (!polygonTerrainBuildable(input.terrain, polygon, maxSlope))
|
|
1567
|
+
continue;
|
|
1568
|
+
const boundaryIds = face.boundaryIds;
|
|
1569
|
+
parcels.push(Object.freeze({
|
|
1570
|
+
id: stableId([worldSeed, city.seed, city.id, ...boundaryIds, face.split]),
|
|
1571
|
+
cityId: city.id,
|
|
1572
|
+
landUse: weightedLandUse(worldSeed, city, boundaryIds, face.split),
|
|
1573
|
+
bounds: safeBounds,
|
|
1574
|
+
polygon: Object.freeze(polygon.map((candidate) => Object.freeze(candidate))),
|
|
1575
|
+
}));
|
|
1576
|
+
}
|
|
1577
|
+
const nonOverlappingFaces = withoutParcelOverlaps(parcels);
|
|
1578
|
+
// face 只覆盖闭合 block;同城剩余 open-street 区域可追加经过相同 hard gate 的 frontage。
|
|
1579
|
+
// 这不是网格回退:frontage 仍只沿 qualifying street polyline,且只能填补 face 未覆盖区域。
|
|
1580
|
+
const frontage = extractFrontageParcels(worldSeed, city, input.terrain, boundary, input.roads, minimumArea, maxSlope, setback, shapeContext?.options);
|
|
1581
|
+
return Object.freeze({
|
|
1582
|
+
parcels: appendUncoveredFrontage(nonOverlappingFaces, frontage),
|
|
1583
|
+
});
|
|
1584
|
+
}
|