@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.
@@ -0,0 +1,595 @@
1
+ export const PROCEDURAL_WORLD_PROFILE = "procedural-world-v1";
2
+ import { validateSpatialTemplateConfiguration, } from "./spatial-templates.js";
3
+ /**
4
+ * 该字符串绑定 profile、算法版本与下方 canonical 规则字节的 SHA-256。
5
+ * 算法语义变化时必须发布新 identity,禁止让旧 seed 静默产生不同世界。
6
+ */
7
+ export const PROCEDURAL_GENERATOR_RULES_CANONICAL = '{"building":"parcel-entry-v1","city":"clipped-orthogonal-lattice-v1","hash":"fnv1a32-avalanche-v1","interior":"central-corridor-v1","landUse":"weighted-hash-v1","metrics":"meters","terrain":"parcel-buildability-sampler-v1"}';
8
+ export const PROCEDURAL_GENERATOR_IDENTITY = "chat.worldengine.procedural-city/v1;rules-sha256=8bcf4d18e9827049e4addca1b2c43480325d904bb1cb1fbe9b09a170b4152632";
9
+ export const PROCEDURAL_CITY_BUDGET = Object.freeze({
10
+ maxCitiesPerWorld: 256,
11
+ maxRoadsPerCity: 2_048,
12
+ maxParcelsPerCity: 65_536,
13
+ maxRoadsPerWorld: 16_384,
14
+ maxParcelsPerWorld: 262_144,
15
+ maxBuildingsPerParcel: 4,
16
+ maxRoomsPerBuilding: 24,
17
+ });
18
+ /** 城市用途颜色的唯一 authority。 */
19
+ export const PALETTE = Object.freeze({
20
+ residential: Object.freeze({ label: "居住", color: "#d8b384" }),
21
+ commercial: Object.freeze({ label: "商业", color: "#d87878" }),
22
+ industrial: Object.freeze({ label: "工业", color: "#8c9299" }),
23
+ civic: Object.freeze({ label: "公共设施", color: "#7c8fce" }),
24
+ park: Object.freeze({ label: "公园", color: "#6fa66f" }),
25
+ });
26
+ const LAND_USES = Object.freeze([
27
+ "residential", "commercial", "industrial", "civic", "park",
28
+ ]);
29
+ const WORLD_KEYS = ["profile", "identity", "worldSeed", "cities", "templates"];
30
+ const WORLD_REQUIRED_KEYS = ["profile", "identity", "worldSeed", "cities"];
31
+ const CITY_KEYS = ["id", "label", "x", "y", "seed", "parameters"];
32
+ const PARAMETER_KEYS = ["radiusMeters", "blockSizeMeters", "roadWidthMeters", "landUseWeights"];
33
+ const WEIGHT_KEYS = ["residential", "commercial", "industrial", "civic", "park"];
34
+ const PARCEL_KEYS = ["id", "cityId", "landUse", "bounds"];
35
+ const PARCEL_ALLOWED_KEYS = [...PARCEL_KEYS, "polygon"];
36
+ const BUILDING_KEYS = [
37
+ "id", "cityId", "parcelId", "landUse", "footprint", "entrance", "floors", "heightMeters",
38
+ ];
39
+ const BOUNDS_KEYS = ["x", "y", "width", "height"];
40
+ const ENTRANCE_KEYS = ["x", "y", "edge"];
41
+ function isRecord(value) {
42
+ return typeof value === "object" && value !== null && !Array.isArray(value);
43
+ }
44
+ function assertRecord(value, label) {
45
+ if (!isRecord(value))
46
+ throw new TypeError(`${label} 必须是对象`);
47
+ }
48
+ function assertExactKeys(value, expected, label) {
49
+ const expectedSet = new Set(expected);
50
+ const unknown = Object.keys(value).filter((key) => !expectedSet.has(key));
51
+ if (unknown.length > 0)
52
+ throw new TypeError(`${label} 包含未知字段:${unknown.join(", ")}`);
53
+ const missing = expected.filter((key) => !Object.hasOwn(value, key));
54
+ if (missing.length > 0)
55
+ throw new TypeError(`${label} 缺少字段:${missing.join(", ")}`);
56
+ }
57
+ function finiteNumber(value, label) {
58
+ if (typeof value !== "number" || !Number.isFinite(value))
59
+ throw new TypeError(`${label} 必须是有限数`);
60
+ return value;
61
+ }
62
+ function boundedNumber(value, label, minimum, maximum) {
63
+ const number = finiteNumber(value, label);
64
+ if (number < minimum || number > maximum) {
65
+ throw new RangeError(`${label} 必须位于 ${minimum}..${maximum}`);
66
+ }
67
+ return number;
68
+ }
69
+ function uint32(value, label) {
70
+ const number = finiteNumber(value, label);
71
+ if (!Number.isInteger(number) || number < 0 || number > 0xffff_ffff) {
72
+ throw new RangeError(`${label} 必须是 uint32`);
73
+ }
74
+ return number;
75
+ }
76
+ function identifier(value, label) {
77
+ if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) {
78
+ throw new TypeError(`${label} 必须是 1..128 字符的稳定 identifier`);
79
+ }
80
+ return value;
81
+ }
82
+ function labelText(value, label) {
83
+ if (typeof value !== "string" || value.trim().length === 0 || value.trim().length > 128) {
84
+ throw new TypeError(`${label} 必须是 1..128 字符的非空文本`);
85
+ }
86
+ return value.trim();
87
+ }
88
+ function metric(value) {
89
+ return Math.round(value * 1_000_000) / 1_000_000;
90
+ }
91
+ function freezePoint(x, y) {
92
+ return Object.freeze({ x: metric(x), y: metric(y) });
93
+ }
94
+ function freezeBounds(x, y, width, height) {
95
+ return Object.freeze({ x: metric(x), y: metric(y), width: metric(width), height: metric(height) });
96
+ }
97
+ function parseBounds(value, label) {
98
+ assertRecord(value, label);
99
+ assertExactKeys(value, BOUNDS_KEYS, label);
100
+ const bounds = freezeBounds(finiteNumber(value.x, `${label}.x`), finiteNumber(value.y, `${label}.y`), finiteNumber(value.width, `${label}.width`), finiteNumber(value.height, `${label}.height`));
101
+ if (bounds.width <= 0 || bounds.height <= 0)
102
+ throw new RangeError(`${label} 尺寸必须大于 0`);
103
+ return bounds;
104
+ }
105
+ function parseParcelPolygon(value, label) {
106
+ if (!Array.isArray(value) || value.length < 3)
107
+ throw new TypeError(`${label} 必须是至少三个点的数组`);
108
+ const polygon = value.map((candidate, index) => {
109
+ assertRecord(candidate, `${label}[${index}]`);
110
+ assertExactKeys(candidate, ["x", "y"], `${label}[${index}]`);
111
+ return freezePoint(finiteNumber(candidate.x, `${label}[${index}].x`), finiteNumber(candidate.y, `${label}[${index}].y`));
112
+ });
113
+ let twiceArea = 0;
114
+ for (let index = 0; index < polygon.length; index += 1) {
115
+ const current = polygon[index];
116
+ const next = polygon[(index + 1) % polygon.length];
117
+ if (current === undefined || next === undefined)
118
+ continue;
119
+ twiceArea += current.x * next.y - next.x * current.y;
120
+ }
121
+ if (Math.abs(twiceArea) <= 0.000001)
122
+ throw new RangeError(`${label} 面积必须大于 0`);
123
+ return Object.freeze(polygon);
124
+ }
125
+ function hash32(parts) {
126
+ const input = parts.join("\u001f");
127
+ let hash = 0x811c9dc5;
128
+ for (let index = 0; index < input.length; index += 1) {
129
+ hash ^= input.charCodeAt(index);
130
+ hash = Math.imul(hash, 0x01000193);
131
+ }
132
+ hash ^= hash >>> 16;
133
+ hash = Math.imul(hash, 0x7feb352d);
134
+ hash ^= hash >>> 15;
135
+ hash = Math.imul(hash, 0x846ca68b);
136
+ return (hash ^ (hash >>> 16)) >>> 0;
137
+ }
138
+ function unit(parts) {
139
+ return hash32(parts) / 0x1_0000_0000;
140
+ }
141
+ function stableId(prefix, parts) {
142
+ const first = hash32(["id-a", ...parts]).toString(16).padStart(8, "0");
143
+ const second = hash32(["id-b", ...parts]).toString(16).padStart(8, "0");
144
+ return `${prefix}_${first}${second}`;
145
+ }
146
+ function citySeedParts(worldSeed, city) {
147
+ return [PROCEDURAL_GENERATOR_IDENTITY, worldSeed, city.seed, city.id];
148
+ }
149
+ function estimateCityBudget(city) {
150
+ const halfSpan = Math.floor(city.parameters.radiusMeters / city.parameters.blockSizeMeters);
151
+ const lineCount = halfSpan * 2 + 1;
152
+ return Object.freeze({ roads: lineCount * 2, parcels: (lineCount - 1) ** 2 });
153
+ }
154
+ function validateLandUseWeights(value, label) {
155
+ assertRecord(value, label);
156
+ assertExactKeys(value, WEIGHT_KEYS, label);
157
+ const weights = Object.freeze({
158
+ residential: boundedNumber(value.residential, `${label}.residential`, 0, 1_000_000),
159
+ commercial: boundedNumber(value.commercial, `${label}.commercial`, 0, 1_000_000),
160
+ industrial: boundedNumber(value.industrial, `${label}.industrial`, 0, 1_000_000),
161
+ civic: boundedNumber(value.civic, `${label}.civic`, 0, 1_000_000),
162
+ park: boundedNumber(value.park, `${label}.park`, 0, 1_000_000),
163
+ });
164
+ const total = LAND_USES.reduce((sum, landUse) => sum + weights[landUse], 0);
165
+ if (total <= 0)
166
+ throw new RangeError(`${label} 至少需要一个正权重`);
167
+ return weights;
168
+ }
169
+ function validateCityDescriptor(value, label) {
170
+ assertRecord(value, label);
171
+ assertExactKeys(value, CITY_KEYS, label);
172
+ assertRecord(value.parameters, `${label}.parameters`);
173
+ assertExactKeys(value.parameters, PARAMETER_KEYS, `${label}.parameters`);
174
+ const radiusMeters = boundedNumber(value.parameters.radiusMeters, `${label}.parameters.radiusMeters`, 32, 200_000);
175
+ const blockSizeMeters = boundedNumber(value.parameters.blockSizeMeters, `${label}.parameters.blockSizeMeters`, 16, 2_000);
176
+ const roadWidthMeters = boundedNumber(value.parameters.roadWidthMeters, `${label}.parameters.roadWidthMeters`, 3, 40);
177
+ if (radiusMeters < blockSizeMeters * 2) {
178
+ throw new RangeError(`${label}.parameters.radiusMeters 必须至少容纳两个 blockSizeMeters`);
179
+ }
180
+ if (roadWidthMeters > blockSizeMeters * 0.45 || blockSizeMeters - roadWidthMeters < 8) {
181
+ throw new RangeError(`${label}.parameters.roadWidthMeters 过宽,无法形成可用地块`);
182
+ }
183
+ const city = Object.freeze({
184
+ id: identifier(value.id, `${label}.id`),
185
+ label: labelText(value.label, `${label}.label`),
186
+ x: boundedNumber(value.x, `${label}.x`, -1_000_000_000, 1_000_000_000),
187
+ y: boundedNumber(value.y, `${label}.y`, -1_000_000_000, 1_000_000_000),
188
+ seed: uint32(value.seed, `${label}.seed`),
189
+ parameters: Object.freeze({
190
+ radiusMeters,
191
+ blockSizeMeters,
192
+ roadWidthMeters,
193
+ landUseWeights: validateLandUseWeights(value.parameters.landUseWeights, `${label}.parameters.landUseWeights`),
194
+ }),
195
+ });
196
+ const estimate = estimateCityBudget(city);
197
+ if (estimate.roads > PROCEDURAL_CITY_BUDGET.maxRoadsPerCity) {
198
+ throw new RangeError(`${label} 道路预算超限:${estimate.roads} > ${PROCEDURAL_CITY_BUDGET.maxRoadsPerCity}`);
199
+ }
200
+ if (estimate.parcels > PROCEDURAL_CITY_BUDGET.maxParcelsPerCity) {
201
+ throw new RangeError(`${label} 地块预算超限:${estimate.parcels} > ${PROCEDURAL_CITY_BUDGET.maxParcelsPerCity}`);
202
+ }
203
+ return city;
204
+ }
205
+ /** 严格验证 exact-key 世界描述;返回不可变 canonical 副本。 */
206
+ export function validateProceduralWorld(input, extent) {
207
+ assertRecord(input, "ProceduralWorldV1");
208
+ const unknown = Object.keys(input).filter((key) => !WORLD_KEYS.includes(key));
209
+ const missing = WORLD_REQUIRED_KEYS.filter((key) => !Object.hasOwn(input, key));
210
+ if (unknown.length > 0)
211
+ throw new TypeError(`ProceduralWorldV1 包含未知字段:${unknown.join(", ")}`);
212
+ if (missing.length > 0)
213
+ throw new TypeError(`ProceduralWorldV1 缺少字段:${missing.join(", ")}`);
214
+ if (input.profile !== PROCEDURAL_WORLD_PROFILE)
215
+ throw new TypeError(`profile 必须为 ${PROCEDURAL_WORLD_PROFILE}`);
216
+ if (input.identity !== PROCEDURAL_GENERATOR_IDENTITY) {
217
+ throw new TypeError(`identity 必须为 ${PROCEDURAL_GENERATOR_IDENTITY}`);
218
+ }
219
+ if (!Array.isArray(input.cities))
220
+ throw new TypeError("cities 必须是数组");
221
+ if (input.cities.length > PROCEDURAL_CITY_BUDGET.maxCitiesPerWorld) {
222
+ throw new RangeError(`城市预算超限:${input.cities.length} > ${PROCEDURAL_CITY_BUDGET.maxCitiesPerWorld}`);
223
+ }
224
+ const cities = input.cities.map((city, index) => validateCityDescriptor(city, `cities[${index}]`));
225
+ const ids = new Set();
226
+ let roadBudget = 0;
227
+ let parcelBudget = 0;
228
+ for (const city of cities) {
229
+ if (ids.has(city.id))
230
+ throw new TypeError(`city id 重复:${city.id}`);
231
+ ids.add(city.id);
232
+ const estimate = estimateCityBudget(city);
233
+ roadBudget += estimate.roads;
234
+ parcelBudget += estimate.parcels;
235
+ }
236
+ if (roadBudget > PROCEDURAL_CITY_BUDGET.maxRoadsPerWorld) {
237
+ throw new RangeError(`世界道路预算超限:${roadBudget} > ${PROCEDURAL_CITY_BUDGET.maxRoadsPerWorld}`);
238
+ }
239
+ if (parcelBudget > PROCEDURAL_CITY_BUDGET.maxParcelsPerWorld) {
240
+ throw new RangeError(`世界地块预算超限:${parcelBudget} > ${PROCEDURAL_CITY_BUDGET.maxParcelsPerWorld}`);
241
+ }
242
+ if (extent !== undefined) {
243
+ const width = boundedNumber(extent.widthMeters, "extent.widthMeters", 1, 1_000_000_000);
244
+ const height = boundedNumber(extent.heightMeters, "extent.heightMeters", 1, 1_000_000_000);
245
+ for (const city of cities) {
246
+ const radius = city.parameters.radiusMeters;
247
+ if (city.x - radius < 0 || city.y - radius < 0 || city.x + radius > width || city.y + radius > height) {
248
+ throw new RangeError(`城市 ${city.id} 超出 world extent`);
249
+ }
250
+ }
251
+ }
252
+ const templates = Object.hasOwn(input, "templates")
253
+ ? validateSpatialTemplateConfiguration(input.templates)
254
+ : undefined;
255
+ return Object.freeze({
256
+ profile: PROCEDURAL_WORLD_PROFILE,
257
+ identity: PROCEDURAL_GENERATOR_IDENTITY,
258
+ worldSeed: uint32(input.worldSeed, "worldSeed"),
259
+ cities: Object.freeze(cities),
260
+ ...(templates === undefined ? {} : { templates }),
261
+ });
262
+ }
263
+ function weightedLandUse(seedParts, weights) {
264
+ const total = LAND_USES.reduce((sum, landUse) => sum + weights[landUse], 0);
265
+ let cursor = unit(["land-use", ...seedParts]) * total;
266
+ for (const landUse of LAND_USES) {
267
+ cursor -= weights[landUse];
268
+ if (cursor < 0)
269
+ return landUse;
270
+ }
271
+ return LAND_USES[LAND_USES.length - 1] ?? "park";
272
+ }
273
+ function boundsInsideCircle(bounds, city) {
274
+ const radiusSquared = city.parameters.radiusMeters ** 2;
275
+ const corners = [
276
+ [bounds.x, bounds.y],
277
+ [bounds.x + bounds.width, bounds.y],
278
+ [bounds.x, bounds.y + bounds.height],
279
+ [bounds.x + bounds.width, bounds.y + bounds.height],
280
+ ];
281
+ return corners.every(([x, y]) => (x - city.x) ** 2 + (y - city.y) ** 2 <= radiusSquared);
282
+ }
283
+ function assertUniqueIds(items, label) {
284
+ const ids = new Set();
285
+ for (const item of items) {
286
+ if (ids.has(item.id))
287
+ throw new Error(`${label} 产生重复 id:${item.id}`);
288
+ ids.add(item.id);
289
+ }
290
+ }
291
+ /** 只生成可重建的道路与地块 cache;绝不提前生成建筑。 */
292
+ export function generateCityLayout(worldSeedInput, cityInput, options = {}) {
293
+ const worldSeed = uint32(worldSeedInput, "worldSeed");
294
+ const city = validateCityDescriptor(cityInput, "city");
295
+ const parcelTemplate = options.templates === undefined
296
+ ? undefined
297
+ : validateSpatialTemplateConfiguration(options.templates).parcel?.parameters;
298
+ const { radiusMeters: radius, blockSizeMeters: block, roadWidthMeters: roadWidth } = city.parameters;
299
+ const halfSpan = Math.floor(radius / block);
300
+ const offsets = Array.from({ length: halfSpan * 2 + 1 }, (_, index) => (index - halfSpan) * block);
301
+ const seeds = citySeedParts(worldSeed, city);
302
+ const roads = [];
303
+ for (let index = -halfSpan; index <= halfSpan; index += 1) {
304
+ const offset = index * block;
305
+ const chord = Math.sqrt(Math.max(0, radius ** 2 - offset ** 2));
306
+ if (chord <= roadWidth / 2)
307
+ continue;
308
+ roads.push(Object.freeze({
309
+ id: stableId("road", [...seeds, "vertical", index]),
310
+ points: Object.freeze([
311
+ freezePoint(city.x + offset, city.y - chord),
312
+ freezePoint(city.x + offset, city.y + chord),
313
+ ]),
314
+ widthMeters: roadWidth,
315
+ }));
316
+ roads.push(Object.freeze({
317
+ id: stableId("road", [...seeds, "horizontal", index]),
318
+ points: Object.freeze([
319
+ freezePoint(city.x - chord, city.y + offset),
320
+ freezePoint(city.x + chord, city.y + offset),
321
+ ]),
322
+ widthMeters: roadWidth,
323
+ }));
324
+ }
325
+ if (roads.length > PROCEDURAL_CITY_BUDGET.maxRoadsPerCity)
326
+ throw new RangeError("道路预算超限");
327
+ const parcels = [];
328
+ const roadHalfWidth = roadWidth / 2;
329
+ for (let yIndex = 0; yIndex < offsets.length - 1; yIndex += 1) {
330
+ for (let xIndex = 0; xIndex < offsets.length - 1; xIndex += 1) {
331
+ const left = offsets[xIndex];
332
+ const top = offsets[yIndex];
333
+ if (left === undefined || top === undefined)
334
+ continue;
335
+ const latticeX = xIndex - halfSpan;
336
+ const latticeY = yIndex - halfSpan;
337
+ const bounds = freezeBounds(city.x + left + roadHalfWidth, city.y + top + roadHalfWidth, block - roadWidth, block - roadWidth);
338
+ if (!boundsInsideCircle(bounds, city))
339
+ continue;
340
+ const sample = Object.freeze({ cityId: city.id, latticeX, latticeY, bounds });
341
+ if (options.buildabilitySampler !== undefined) {
342
+ const buildable = options.buildabilitySampler(sample);
343
+ if (typeof buildable !== "boolean")
344
+ throw new TypeError("buildabilitySampler 必须返回 boolean");
345
+ if (!buildable)
346
+ continue;
347
+ }
348
+ const subdivision = parcelTemplate?.subdivision ?? 1;
349
+ if (parcelTemplate === undefined) {
350
+ parcels.push(Object.freeze({
351
+ id: stableId("parcel", [...seeds, latticeX, latticeY]),
352
+ cityId: city.id,
353
+ landUse: weightedLandUse([...seeds, latticeX, latticeY], city.parameters.landUseWeights),
354
+ bounds,
355
+ }));
356
+ continue;
357
+ }
358
+ for (let splitY = 0; splitY < subdivision; splitY += 1) {
359
+ for (let splitX = 0; splitX < subdivision; splitX += 1) {
360
+ const childBounds = subdivision === 1 ? bounds : freezeBounds(bounds.x + bounds.width * splitX / subdivision, bounds.y + bounds.height * splitY / subdivision, bounds.width / subdivision, bounds.height / subdivision);
361
+ parcels.push(Object.freeze({
362
+ id: stableId("parcel", [...seeds, latticeX, latticeY, "template-subdivision", subdivision, splitX, splitY]),
363
+ cityId: city.id,
364
+ landUse: weightedLandUse([...seeds, latticeX, latticeY, splitX, splitY], city.parameters.landUseWeights),
365
+ bounds: childBounds,
366
+ }));
367
+ }
368
+ }
369
+ }
370
+ }
371
+ if (parcels.length === 0)
372
+ throw new RangeError(`城市 ${city.id} 没有可建地块`);
373
+ if (parcels.length > PROCEDURAL_CITY_BUDGET.maxParcelsPerCity)
374
+ throw new RangeError("地块预算超限");
375
+ assertUniqueIds(roads, "道路");
376
+ assertUniqueIds(parcels, "地块");
377
+ return Object.freeze({ roads: Object.freeze(roads), parcels: Object.freeze(parcels) });
378
+ }
379
+ function validateParcel(value, city) {
380
+ assertRecord(value, "parcel");
381
+ const unknown = Object.keys(value).filter((key) => !PARCEL_ALLOWED_KEYS.includes(key));
382
+ if (unknown.length > 0)
383
+ throw new TypeError(`parcel 包含未知字段:${unknown.join(", ")}`);
384
+ const missing = PARCEL_KEYS.filter((key) => !Object.hasOwn(value, key));
385
+ if (missing.length > 0)
386
+ throw new TypeError(`parcel 缺少字段:${missing.join(", ")}`);
387
+ if (!LAND_USES.includes(value.landUse))
388
+ throw new TypeError("parcel.landUse 非法");
389
+ const polygon = Object.hasOwn(value, "polygon") ? parseParcelPolygon(value.polygon, "parcel.polygon") : undefined;
390
+ const parcel = Object.freeze({
391
+ id: identifier(value.id, "parcel.id"),
392
+ cityId: identifier(value.cityId, "parcel.cityId"),
393
+ landUse: value.landUse,
394
+ bounds: parseBounds(value.bounds, "parcel.bounds"),
395
+ ...(polygon === undefined ? {} : { polygon }),
396
+ });
397
+ if (parcel.cityId !== city.id)
398
+ throw new TypeError("parcel.cityId 与 city.id 不匹配");
399
+ if (!boundsInsideCircle(parcel.bounds, city))
400
+ throw new RangeError("parcel.bounds 超出城市半径");
401
+ return parcel;
402
+ }
403
+ const BUILDING_PROFILES = Object.freeze({
404
+ residential: Object.freeze({ maxBuildings: 4, minimumFloors: 2, maximumFloors: 12, floorHeight: 3, fill: 0.78 }),
405
+ commercial: Object.freeze({ maxBuildings: 2, minimumFloors: 3, maximumFloors: 30, floorHeight: 3.6, fill: 0.88 }),
406
+ industrial: Object.freeze({ maxBuildings: 2, minimumFloors: 1, maximumFloors: 4, floorHeight: 4.8, fill: 0.86 }),
407
+ civic: Object.freeze({ maxBuildings: 2, minimumFloors: 2, maximumFloors: 8, floorHeight: 3.8, fill: 0.74 }),
408
+ });
409
+ /** 按地块进入 gate 调用;park 固定为空,其余用途生成 1-N 个稳定 footprint。 */
410
+ export function generateParcelBuildings(worldSeedInput, cityInput, parcelInput, options = {}) {
411
+ const worldSeed = uint32(worldSeedInput, "worldSeed");
412
+ const city = validateCityDescriptor(cityInput, "city");
413
+ const parcel = validateParcel(parcelInput, city);
414
+ if (parcel.landUse === "park")
415
+ return Object.freeze([]);
416
+ const profile = BUILDING_PROFILES[parcel.landUse];
417
+ const template = options.templates === undefined
418
+ ? undefined
419
+ : validateSpatialTemplateConfiguration(options.templates).building?.parameters;
420
+ const seeds = [...citySeedParts(worldSeed, city), parcel.id];
421
+ let count = template === undefined
422
+ ? 1 + Math.floor(unit(["building-count", ...seeds]) * profile.maxBuildings)
423
+ : Math.min(profile.maxBuildings, template.maxBuildings);
424
+ const outerMargin = Math.max(2, Math.min(parcel.bounds.width, parcel.bounds.height) * 0.08);
425
+ const gap = Math.max(1.5, Math.min(parcel.bounds.width, parcel.bounds.height) * 0.035);
426
+ while (count > 1) {
427
+ const columns = Math.ceil(Math.sqrt(count));
428
+ const rows = Math.ceil(count / columns);
429
+ const cellWidth = (parcel.bounds.width - outerMargin * 2 - gap * (columns - 1)) / columns;
430
+ const cellHeight = (parcel.bounds.height - outerMargin * 2 - gap * (rows - 1)) / rows;
431
+ const fill = profile.fill * (template?.fillScale ?? 1);
432
+ if (cellWidth * fill >= 4 && cellHeight * fill >= 4)
433
+ break;
434
+ count -= 1;
435
+ }
436
+ const columns = Math.ceil(Math.sqrt(count));
437
+ const rows = Math.ceil(count / columns);
438
+ const cellWidth = (parcel.bounds.width - outerMargin * 2 - gap * (columns - 1)) / columns;
439
+ const cellHeight = (parcel.bounds.height - outerMargin * 2 - gap * (rows - 1)) / rows;
440
+ const fill = profile.fill * (template?.fillScale ?? 1);
441
+ if (cellWidth * fill < 4 || cellHeight * fill < 4) {
442
+ throw new RangeError(`地块 ${parcel.id} 太小,无法生成有效建筑`);
443
+ }
444
+ const buildings = [];
445
+ for (let index = 0; index < count; index += 1) {
446
+ const column = index % columns;
447
+ const row = Math.floor(index / columns);
448
+ const width = cellWidth * fill;
449
+ const height = cellHeight * fill;
450
+ const x = parcel.bounds.x + outerMargin + column * (cellWidth + gap) + (cellWidth - width) / 2;
451
+ const y = parcel.bounds.y + outerMargin + row * (cellHeight + gap) + (cellHeight - height) / 2;
452
+ const floorRange = profile.maximumFloors - profile.minimumFloors + 1;
453
+ const floors = profile.minimumFloors + Math.floor(unit(["floors", ...seeds, index]) * floorRange);
454
+ const edge = unit(["entrance-edge", ...seeds, index]) < 0.5 ? "west" : "east";
455
+ const footprint = freezeBounds(x, y, width, height);
456
+ const entrance = Object.freeze({
457
+ x: edge === "west" ? footprint.x : metric(footprint.x + footprint.width),
458
+ y: metric(footprint.y + footprint.height / 2),
459
+ edge,
460
+ });
461
+ buildings.push(Object.freeze({
462
+ id: stableId("building", [...seeds, index]),
463
+ cityId: city.id,
464
+ parcelId: parcel.id,
465
+ landUse: parcel.landUse,
466
+ footprint,
467
+ entrance,
468
+ floors,
469
+ heightMeters: metric(floors * profile.floorHeight),
470
+ }));
471
+ }
472
+ if (buildings.length > PROCEDURAL_CITY_BUDGET.maxBuildingsPerParcel)
473
+ throw new RangeError("建筑预算超限");
474
+ assertUniqueIds(buildings, "建筑");
475
+ return Object.freeze(buildings);
476
+ }
477
+ function validateBuilding(value) {
478
+ assertRecord(value, "building");
479
+ assertExactKeys(value, BUILDING_KEYS, "building");
480
+ if (value.landUse === "park" || !LAND_USES.includes(value.landUse)) {
481
+ throw new TypeError("building.landUse 非法");
482
+ }
483
+ const footprint = parseBounds(value.footprint, "building.footprint");
484
+ assertRecord(value.entrance, "building.entrance");
485
+ assertExactKeys(value.entrance, ENTRANCE_KEYS, "building.entrance");
486
+ const edge = value.entrance.edge;
487
+ if (edge !== "west" && edge !== "east")
488
+ throw new TypeError("building.entrance.edge 非法");
489
+ const entrance = Object.freeze({
490
+ x: finiteNumber(value.entrance.x, "building.entrance.x"),
491
+ y: finiteNumber(value.entrance.y, "building.entrance.y"),
492
+ edge,
493
+ });
494
+ const expectedX = edge === "west" ? footprint.x : footprint.x + footprint.width;
495
+ if (Math.abs(entrance.x - expectedX) > 0.000001
496
+ || entrance.y < footprint.y || entrance.y > footprint.y + footprint.height) {
497
+ throw new RangeError("building.entrance 必须位于 footprint 的东西外墙");
498
+ }
499
+ const floors = finiteNumber(value.floors, "building.floors");
500
+ if (!Number.isSafeInteger(floors) || floors < 1 || floors > 256)
501
+ throw new RangeError("building.floors 非法");
502
+ const heightMeters = boundedNumber(value.heightMeters, "building.heightMeters", 2, 2_000);
503
+ return Object.freeze({
504
+ id: identifier(value.id, "building.id"),
505
+ cityId: identifier(value.cityId, "building.cityId"),
506
+ parcelId: identifier(value.parcelId, "building.parcelId"),
507
+ landUse: value.landUse,
508
+ footprint,
509
+ entrance,
510
+ floors,
511
+ heightMeters,
512
+ });
513
+ }
514
+ const ROOM_KINDS = Object.freeze({
515
+ residential: Object.freeze(["living", "kitchen", "bedroom", "bedroom", "bathroom"]),
516
+ commercial: Object.freeze(["lobby", "retail", "office", "office", "storage"]),
517
+ industrial: Object.freeze(["workshop", "storage", "utility", "office"]),
518
+ civic: Object.freeze(["lobby", "hall", "office", "service"]),
519
+ });
520
+ function rectangleInside(inner, outer) {
521
+ const epsilon = 0.000001;
522
+ return inner.x + epsilon >= outer.x && inner.y + epsilon >= outer.y
523
+ && inner.x + inner.width <= outer.x + outer.width + epsilon
524
+ && inner.y + inner.height <= outer.y + outer.height + epsilon;
525
+ }
526
+ /** 按建筑进入 gate 调用;返回有界且通过中央走廊全连通的房间/门 cache。 */
527
+ export function generateBuildingInterior(worldSeedInput, buildingInput, options = {}) {
528
+ const worldSeed = uint32(worldSeedInput, "worldSeed");
529
+ const building = validateBuilding(buildingInput);
530
+ const footprint = building.footprint;
531
+ const template = options.templates === undefined
532
+ ? undefined
533
+ : validateSpatialTemplateConfiguration(options.templates).interior?.parameters;
534
+ if (footprint.width < 4 || footprint.height < 4)
535
+ throw new RangeError("building footprint 太小,无法生成连通室内");
536
+ const seedParts = [PROCEDURAL_GENERATOR_IDENTITY, worldSeed, building.id];
537
+ const corridorRatio = template?.corridorRatio ?? 0.18;
538
+ const corridorHeight = metric(Math.min(2.4, Math.max(1.2, footprint.height * corridorRatio)));
539
+ const corridorBounds = freezeBounds(footprint.x, footprint.y + (footprint.height - corridorHeight) / 2, footprint.width, corridorHeight);
540
+ const corridorId = stableId("room", [...seedParts, "corridor"]);
541
+ const rooms = [Object.freeze({ id: corridorId, kind: "corridor", bounds: corridorBounds })];
542
+ const maximumPerSide = Math.max(1, Math.floor(footprint.width / 3));
543
+ const maximumRooms = Math.min(PROCEDURAL_CITY_BUDGET.maxRoomsPerBuilding - 1, maximumPerSide * 2);
544
+ const areaTarget = Math.max(2, Math.floor((footprint.width * footprint.height) / (28 / (template?.roomDensity ?? 1))));
545
+ const desiredRooms = Math.min(maximumRooms, areaTarget + Math.floor(unit(["room-count", ...seedParts]) * 3));
546
+ const roomCount = Math.max(2, desiredRooms);
547
+ const topCount = Math.ceil(roomCount / 2);
548
+ const bottomCount = Math.floor(roomCount / 2);
549
+ const roomKinds = ROOM_KINDS[building.landUse];
550
+ const kindOffset = Math.floor(unit(["room-kind-offset", ...seedParts]) * roomKinds.length);
551
+ const doors = [];
552
+ let roomOrdinal = 0;
553
+ for (const side of ["north", "south"]) {
554
+ const sideCount = side === "north" ? topCount : bottomCount;
555
+ for (let index = 0; index < sideCount; index += 1) {
556
+ const x = footprint.x + footprint.width * index / sideCount;
557
+ const nextX = footprint.x + footprint.width * (index + 1) / sideCount;
558
+ const y = side === "north" ? footprint.y : corridorBounds.y + corridorBounds.height;
559
+ const height = side === "north"
560
+ ? corridorBounds.y - footprint.y
561
+ : footprint.y + footprint.height - (corridorBounds.y + corridorBounds.height);
562
+ const bounds = freezeBounds(x, y, nextX - x, height);
563
+ const roomId = stableId("room", [...seedParts, side, index]);
564
+ const kind = roomKinds[(kindOffset + roomOrdinal) % roomKinds.length] ?? roomKinds[0] ?? "service";
565
+ rooms.push(Object.freeze({ id: roomId, kind, bounds }));
566
+ const doorY = side === "north" ? corridorBounds.y : corridorBounds.y + corridorBounds.height;
567
+ doors.push(Object.freeze({
568
+ id: stableId("door", [...seedParts, side, index]),
569
+ kind: "interior",
570
+ position: freezePoint(bounds.x + bounds.width / 2, doorY),
571
+ connects: Object.freeze([roomId, corridorId]),
572
+ }));
573
+ roomOrdinal += 1;
574
+ }
575
+ }
576
+ doors.push(Object.freeze({
577
+ id: stableId("door", [...seedParts, "entrance"]),
578
+ kind: "entrance",
579
+ position: freezePoint(building.entrance.x, building.entrance.y),
580
+ connects: Object.freeze([corridorId]),
581
+ }));
582
+ if (rooms.length > PROCEDURAL_CITY_BUDGET.maxRoomsPerBuilding)
583
+ throw new RangeError("房间预算超限");
584
+ if (rooms.some((room) => !rectangleInside(room.bounds, footprint)))
585
+ throw new Error("室内房间越出建筑 footprint");
586
+ assertUniqueIds(rooms, "房间");
587
+ assertUniqueIds(doors, "门");
588
+ const connected = new Set([corridorId]);
589
+ for (const door of doors)
590
+ for (const roomId of door.connects)
591
+ connected.add(roomId);
592
+ if (rooms.some((room) => !connected.has(room.id)))
593
+ throw new Error("室内房间图不连通");
594
+ return Object.freeze({ buildingId: building.id, rooms: Object.freeze(rooms), doors: Object.freeze(doors) });
595
+ }
@@ -0,0 +1,57 @@
1
+ import { type PlaneGenerationV1 } from "./plane-generation.js";
2
+ import { type ProceduralWorldV1 } from "./procedural-city.js";
3
+ import { type RoadGenerationV1 } from "./road-generation.js";
4
+ import type { SpatialObjectKind } from "./schema.js";
5
+ import { type SpatialTemplateConfigurationV1, type SpatialTemplateLibraryV1 } from "./spatial-templates.js";
6
+ /**
7
+ * `world_search.spatial_world` 的无损作者侧投影。
8
+ *
9
+ * 这是唯一给 Web/desktop 地图编辑器的 seam:调用者只修改已建模字段,再由
10
+ * encode 恢复原有 protobuf 顺序、未知 field 和未修改 object 的原始 bytes。
11
+ * 它不解释也不暴露 ScenarioPart 的其余字段。
12
+ */
13
+ export type SpatialReferenceProtobufV1 = {
14
+ ownerId: string;
15
+ ownerKind: "ladybug" | "trigger" | "scenario";
16
+ fieldPath: string;
17
+ };
18
+ export type SpatialObjectProtobufV1 = {
19
+ id: string;
20
+ key: string;
21
+ kind: SpatialObjectKind;
22
+ label: string;
23
+ x: number;
24
+ y: number;
25
+ references: SpatialReferenceProtobufV1[];
26
+ /** 私有 round-trip witness;编辑器不得删除或重写。 */
27
+ _wireField?: number;
28
+ _wireBase64?: string;
29
+ _wireSignature?: string;
30
+ _authorSettings?: Record<string, unknown>;
31
+ };
32
+ export type SpatialTopologyProtobufV1 = {
33
+ kind: "grid" | "freeform";
34
+ width: number;
35
+ height: number;
36
+ cellSize?: number;
37
+ generation?: PlaneGenerationV1;
38
+ roadGeneration?: RoadGenerationV1;
39
+ procedural?: ProceduralWorldV1;
40
+ controlledActorId?: string | null;
41
+ templateLibrary?: SpatialTemplateLibraryV1;
42
+ templates?: SpatialTemplateConfigurationV1;
43
+ [key: string]: unknown;
44
+ };
45
+ export type SpatialWorldProtobufV1 = {
46
+ schemaVersion: 1;
47
+ worldId: string;
48
+ spatialRevision: number;
49
+ topology: SpatialTopologyProtobufV1;
50
+ objects: SpatialObjectProtobufV1[];
51
+ /** 私有 round-trip witness;编辑器不得删除或重写。 */
52
+ _wireBase64?: string;
53
+ _topologyWireBase64?: string;
54
+ _topologyWireKind?: number;
55
+ };
56
+ export declare function decodeSpatialWorldProtobuf(bytes: Uint8Array | undefined): SpatialWorldProtobufV1 | undefined;
57
+ export declare function encodeSpatialWorldProtobuf(world: SpatialWorldProtobufV1 | undefined): Uint8Array | undefined;