@pioneer_zmc/dsh-workbench 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/README.zh.md +100 -0
- package/client.js +122 -0
- package/cordis.patch.yml +8 -0
- package/dist/check-building.js +1669 -0
- package/dist/check-room.js +980 -0
- package/dist/index.js +2873 -0
- package/package.json +60 -0
- package/skills/specdev-building/SKILL.md +85 -0
- package/skills/specdev-building/assets/layouts/court.json +53 -0
- package/skills/specdev-building/assets/layouts/row.json +48 -0
- package/skills/specdev-building/assets/layouts/single.json +27 -0
- package/skills/specdev-building/assets/layouts/tower-9.json +71 -0
- package/skills/specdev-building/assets/layouts/tower.json +59 -0
- package/skills/specdev-building/references/blueprint-contract.md +126 -0
- package/skills/specdev-building/references/diagnostics.md +101 -0
- package/skills/specdev-building/references/layouts.md +112 -0
- package/skills/specdev-business/SKILL.md +91 -0
- package/skills/specdev-business/assets/plan-template.md +66 -0
- package/skills/specdev-business/assets/spec-template.md +111 -0
- package/skills/specdev-business/assets/tasks-template.md +36 -0
- package/skills/specdev-business/references/change.md +117 -0
- package/skills/specdev-business/references/evidence-review.md +125 -0
- package/skills/specdev-business/references/implementation.md +67 -0
- package/skills/specdev-business/references/project-contract.md +211 -0
- package/skills/specdev-business/references/source-map.md +110 -0
- package/skills/specdev-business/references/specification.md +93 -0
- package/skills/specdev-business/references/workflow.md +126 -0
- package/skills/specdev-room/SKILL.md +77 -0
- package/skills/specdev-room/assets/intro-room.json +14 -0
- package/skills/specdev-room/references/diagnostics.md +21 -0
- package/skills/specdev-room/references/room-contract.md +59 -0
- package/vendor/archify-renderer/VERSION.md +52 -0
- package/vendor/archify-renderer/archify/LICENSE +22 -0
- package/vendor/archify-renderer/archify/THIRD_PARTY_NOTICES.md +56 -0
- package/vendor/archify-renderer/archify/assets/template.html +14787 -0
- package/vendor/archify-renderer/archify/bin/archify.mjs +1990 -0
- package/vendor/archify-renderer/archify/renderers/shared/brand-marks.mjs +563 -0
- package/vendor/archify-renderer/archify/renderers/shared/cli.mjs +218 -0
- package/vendor/archify-renderer/archify/renderers/shared/desktop-readability.mjs +26 -0
- package/vendor/archify-renderer/archify/renderers/shared/diagnostics.mjs +127 -0
- package/vendor/archify-renderer/archify/renderers/shared/engineering-profiles.mjs +157 -0
- package/vendor/archify-renderer/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- package/vendor/archify-renderer/archify/renderers/shared/generated-validators.mjs +13 -0
- package/vendor/archify-renderer/archify/renderers/shared/geometry.mjs +1423 -0
- package/vendor/archify-renderer/archify/renderers/shared/i18n.mjs +594 -0
- package/vendor/archify-renderer/archify/renderers/shared/legend.mjs +217 -0
- package/vendor/archify-renderer/archify/renderers/shared/output-path.mjs +321 -0
- package/vendor/archify-renderer/archify/renderers/shared/repository-evidence.mjs +235 -0
- package/vendor/archify-renderer/archify/renderers/shared/text-fit.mjs +49 -0
- package/vendor/archify-renderer/archify/renderers/shared/utils.mjs +232 -0
- package/vendor/archify-renderer/archify/renderers/shared/validator.mjs +86 -0
- package/vendor/archify-renderer/archify/renderers/workflow/render-workflow.mjs +35 -0
- package/vendor/archify-renderer/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
- package/vendor/archify-renderer/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- package/vendor/archify-renderer/archify/schemas/common.schema.json +115 -0
- package/vendor/archify-renderer/archify/schemas/workflow.schema.json +428 -0
- package/vendor/archify-renderer/archify/scripts/check-render-output.mjs +835 -0
- package/vendor/archify-renderer/shims/process.mjs +18 -0
- package/vendor/archify-renderer/stubs/child_process.mjs +2 -0
- package/vendor/archify-renderer/stubs/crypto.mjs +3 -0
- package/vendor/archify-renderer/stubs/dns-promises.mjs +2 -0
- package/vendor/archify-renderer/stubs/fs.mjs +3 -0
- package/vendor/archify-renderer/stubs/http.mjs +3 -0
- package/vendor/archify-renderer/stubs/https.mjs +3 -0
- package/vendor/archify-renderer/stubs/net.mjs +3 -0
- package/vendor/archify-renderer/stubs/path.mjs +3 -0
- package/vendor/archify-renderer/stubs/url.mjs +3 -0
- package/web/assets/building-kit.js +1157 -0
- package/web/assets/building-look.js +185 -0
- package/web/assets/building-plan.js +495 -0
- package/web/assets/building-scene.js +529 -0
- package/web/assets/building-walk.js +248 -0
- package/web/assets/building.js +859 -0
- package/web/assets/business.js +124 -0
- package/web/assets/chart-link.js +165 -0
- package/web/assets/common.js +302 -0
- package/web/assets/details.js +197 -0
- package/web/assets/evidence.js +30 -0
- package/web/assets/furniture/SOURCES.md +24 -0
- package/web/assets/furniture/assets/ceramic-display-stand-01/asset.json +25 -0
- package/web/assets/furniture/assets/ceramic-display-stand-01/model.js +116 -0
- package/web/assets/furniture/assets/ceramic-display-stand-01/preview.webp +0 -0
- package/web/assets/furniture/assets/ceramic-floor-lamp-01/asset.json +61 -0
- package/web/assets/furniture/assets/ceramic-floor-lamp-01/model.js +56 -0
- package/web/assets/furniture/assets/ceramic-floor-lamp-01/preview.webp +0 -0
- package/web/assets/furniture/assets/ceramic-low-cabinet-01/asset.json +81 -0
- package/web/assets/furniture/assets/ceramic-low-cabinet-01/model.js +67 -0
- package/web/assets/furniture/assets/ceramic-low-cabinet-01/preview.webp +0 -0
- package/web/assets/furniture/assets/ceramic-vase-01/asset.json +21 -0
- package/web/assets/furniture/assets/ceramic-vase-01/model.js +119 -0
- package/web/assets/furniture/assets/ceramic-vase-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-bed-01/asset.json +61 -0
- package/web/assets/furniture/assets/fairy-bed-01/model.js +63 -0
- package/web/assets/furniture/assets/fairy-bed-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-book-stack-01/asset.json +21 -0
- package/web/assets/furniture/assets/fairy-book-stack-01/model.js +71 -0
- package/web/assets/furniture/assets/fairy-book-stack-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-bookshelf-01/asset.json +32 -0
- package/web/assets/furniture/assets/fairy-bookshelf-01/model.js +168 -0
- package/web/assets/furniture/assets/fairy-bookshelf-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-desk-01/asset.json +25 -0
- package/web/assets/furniture/assets/fairy-desk-01/model.js +132 -0
- package/web/assets/furniture/assets/fairy-desk-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-notice-board-01/asset.json +23 -0
- package/web/assets/furniture/assets/fairy-notice-board-01/model.js +125 -0
- package/web/assets/furniture/assets/fairy-notice-board-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-potted-plant-01/asset.json +21 -0
- package/web/assets/furniture/assets/fairy-potted-plant-01/model.js +128 -0
- package/web/assets/furniture/assets/fairy-potted-plant-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-reading-chair-01/asset.json +61 -0
- package/web/assets/furniture/assets/fairy-reading-chair-01/model.js +66 -0
- package/web/assets/furniture/assets/fairy-reading-chair-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-table-lamp-01/asset.json +21 -0
- package/web/assets/furniture/assets/fairy-table-lamp-01/model.js +182 -0
- package/web/assets/furniture/assets/fairy-table-lamp-01/preview.webp +0 -0
- package/web/assets/furniture/catalog.js +137 -0
- package/web/assets/furniture/index.json +110 -0
- package/web/assets/furniture/registry.js +72 -0
- package/web/assets/furniture-page.css +68 -0
- package/web/assets/furniture-page.js +302 -0
- package/web/assets/home.js +65 -0
- package/web/assets/node-detail.js +148 -0
- package/web/assets/panel-link.js +47 -0
- package/web/assets/read.js +746 -0
- package/web/assets/room-scene.js +386 -0
- package/web/assets/room.css +287 -0
- package/web/assets/room.js +437 -0
- package/web/assets/rooms/bindings.js +449 -0
- package/web/assets/rooms/ceramic-reflection.js +193 -0
- package/web/assets/rooms/ceramic-room.js +302 -0
- package/web/assets/rooms/door.js +35 -0
- package/web/assets/rooms/fairy-room.js +492 -0
- package/web/assets/rooms/furniture-assembly.js +113 -0
- package/web/assets/rooms/navigation.js +262 -0
- package/web/assets/rooms/placeholder-catalog.json +71 -0
- package/web/assets/rooms/placeholder-furniture.js +76 -0
- package/web/assets/rooms/placement.js +588 -0
- package/web/assets/rooms/reader.js +441 -0
- package/web/assets/rooms/reading-gate.js +157 -0
- package/web/assets/rooms/room-lighting.js +50 -0
- package/web/assets/rooms/templates.json +50 -0
- package/web/assets/rooms/walk-frame.js +64 -0
- package/web/assets/rooms/walk-session.js +138 -0
- package/web/assets/save-result.js +89 -0
- package/web/assets/screenshots/business.png +0 -0
- package/web/assets/screenshots/node-detail.png +0 -0
- package/web/assets/screenshots/project-home.png +0 -0
- package/web/assets/screenshots/workflow.png +0 -0
- package/web/assets/showcase.js +232 -0
- package/web/assets/style.css +836 -0
- package/web/assets/template.js +7 -0
- package/web/assets/theme.js +29 -0
- package/web/assets/three/OrbitControls.js +1972 -0
- package/web/assets/three/VERSION.md +12 -0
- package/web/assets/three/three.core.js +60586 -0
- package/web/assets/three/three.module.js +19719 -0
- package/web/assets/workspaces.css +21 -0
- package/web/assets/workspaces.js +81 -0
- package/web/building.html +131 -0
- package/web/business.html +43 -0
- package/web/furniture.html +47 -0
- package/web/index.html +57 -0
- package/web/read.html +107 -0
- package/web/room.html +146 -0
- package/web/showcase.html +50 -0
- package/web/workspaces.html +32 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
// 独立房间 · 家具摆放校验与装配的纯逻辑(产品模块;P1a 自 E2b 实验迁入,来源提交 d80bea1,
|
|
2
|
+
// 迁移对照与检查入口见同目录 README.md 与 tests/room-placement.test.mjs)。
|
|
3
|
+
// 不依赖 DOM/WebGL/Three.js/服务器/文件系统,Node 与页面共用同一份。
|
|
4
|
+
// 数据约定:米制、+Y 向上、正面 +Z、底面中心原点;房间模板管区域与禁放区,家具目录管
|
|
5
|
+
// 尺寸、操作留空与承载面,布置只写实例、位置与依附。surface 实例只携带
|
|
6
|
+
// kind/parentInstanceId/surfaceId/offsetX/offsetZ/yawDeg;世界位置=父变换 × 承载面原点
|
|
7
|
+
// (矩形中心 + 高度)× 子局部偏移,世界 yaw=父 yaw+子 yaw 对 360 取模,子项不允许自填 Y。
|
|
8
|
+
// 校验与绘制必须共用本模块产出的同一份解析结果;本模块不改写任何输入对象。
|
|
9
|
+
|
|
10
|
+
export const TOLERANCE = 1e-5; // 米;只消除浮点误差,边界接触允许、正厚度相交才拒绝
|
|
11
|
+
const SUPPORTED_VERSION = 1;
|
|
12
|
+
const GLOBAL_YAW_WHITELIST = [0, 90, 180, 270]; // 首版只接受直角朝向
|
|
13
|
+
const SUPPORTED_ZONE_KINDS = ['floor']; // 墙面区域是以后单独的一步
|
|
14
|
+
const SUPPORTED_PLACEMENT_KINDS = ['floor', 'surface']; // E2b 起支持桌面承载;wall 仍不支持
|
|
15
|
+
const RESERVED_PURPOSES = ['entry', 'corridor', 'fixture']; // 入口/通路/固定构件
|
|
16
|
+
const WALK_PURPOSES = ['entry', 'corridor']; // 通路并集=入口带+通行带
|
|
17
|
+
// placement 按 kind 使用字段白名单:floor 五项、surface 六项。其他自有字段(y、position、
|
|
18
|
+
// scale、floor 的 surface 字段、surface 的 floor 字段等)一律 STRUCT_INVALID,不静默忽略——
|
|
19
|
+
// 配置写了高度而程序按另一个高度显示,比直接拒绝更危险。
|
|
20
|
+
const FLOOR_FIELDS = ['kind', 'zoneId', 'x', 'z', 'yawDeg'];
|
|
21
|
+
const SURFACE_FIELDS = ['kind', 'parentInstanceId', 'surfaceId', 'offsetX', 'offsetZ', 'yawDeg'];
|
|
22
|
+
|
|
23
|
+
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
24
|
+
const isBox = (value) => Boolean(value) && ['minX', 'maxX', 'minY', 'maxY', 'minZ', 'maxZ'].every((key) => finite(value[key]));
|
|
25
|
+
// 正尺寸:三轴都要求 min < max(零厚度、负厚度都是结构错误)
|
|
26
|
+
const boxPositive = (value) => isBox(value) && value.minX < value.maxX && value.minY < value.maxY && value.minZ < value.maxZ;
|
|
27
|
+
// 承载面矩形:只有 X/Z 四向(高度由单独的 y 字段表达)
|
|
28
|
+
const rectPositive = (value) => Boolean(value) && ['minX', 'maxX', 'minZ', 'maxZ'].every((key) => finite(value[key])) && value.minX < value.maxX && value.minZ < value.maxZ;
|
|
29
|
+
const diag = (code, message, { instanceIds = [], targetId = null, fieldPath = '' } = {}) => ({ code, message, instanceIds, targetId, fieldPath });
|
|
30
|
+
|
|
31
|
+
/** 直角旋转(度):θ=90 时本地 +Z(正面)转向 +X。取整三角值,消除浮点误差。 */
|
|
32
|
+
export function rotateXZ(x, z, yawDeg) {
|
|
33
|
+
const rad = (yawDeg * Math.PI) / 180;
|
|
34
|
+
const c = Math.round(Math.cos(rad));
|
|
35
|
+
const s = Math.round(Math.sin(rad));
|
|
36
|
+
return { x: x * c + z * s, z: -x * s + z * c };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 本地直立盒绕 Y 直角旋转后平移到房间坐标。position 携带真实世界 Y(地面家具 y=0,
|
|
41
|
+
* 桌面子项 y=承载面高度);旧调用只传 {x,z} 时按地面处理。
|
|
42
|
+
*/
|
|
43
|
+
export function worldBox(local, position, yawDeg) {
|
|
44
|
+
const py = position.y ?? 0;
|
|
45
|
+
const corners = [
|
|
46
|
+
rotateXZ(local.minX, local.minZ, yawDeg), rotateXZ(local.minX, local.maxZ, yawDeg),
|
|
47
|
+
rotateXZ(local.maxX, local.minZ, yawDeg), rotateXZ(local.maxX, local.maxZ, yawDeg),
|
|
48
|
+
];
|
|
49
|
+
return {
|
|
50
|
+
minX: position.x + Math.min(...corners.map((p) => p.x)), maxX: position.x + Math.max(...corners.map((p) => p.x)),
|
|
51
|
+
minY: py + local.minY, maxY: py + local.maxY,
|
|
52
|
+
minZ: position.z + Math.min(...corners.map((p) => p.z)), maxZ: position.z + Math.max(...corners.map((p) => p.z)),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** inner 完整落在 outer 内(允许 tol 贴边)。 */
|
|
57
|
+
const boxInside = (outer, inner, tol = TOLERANCE) =>
|
|
58
|
+
inner.minX >= outer.minX - tol && inner.maxX <= outer.maxX + tol &&
|
|
59
|
+
inner.minY >= outer.minY - tol && inner.maxY <= outer.maxY + tol &&
|
|
60
|
+
inner.minZ >= outer.minZ - tol && inner.maxZ <= outer.maxZ + tol;
|
|
61
|
+
|
|
62
|
+
/** 三轴都存在超过 tol 的正厚度交集才算相交;仅接触返回 false。 */
|
|
63
|
+
const boxIntersects = (a, b, tol = TOLERANCE) =>
|
|
64
|
+
Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX) > tol &&
|
|
65
|
+
Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY) > tol &&
|
|
66
|
+
Math.min(a.maxZ, b.maxZ) - Math.max(a.minZ, b.minZ) > tol;
|
|
67
|
+
|
|
68
|
+
// ───────────────────────── 结构与引用校验 ─────────────────────────
|
|
69
|
+
|
|
70
|
+
function checkVersion(value, source, path, out) {
|
|
71
|
+
if (value?.schemaVersion !== SUPPORTED_VERSION) {
|
|
72
|
+
out.push(diag('VERSION_UNSUPPORTED', `${source} 的 schemaVersion 必须是 ${SUPPORTED_VERSION},当前为 ${JSON.stringify(value?.schemaVersion)}。`, { fieldPath: `${path}.schemaVersion` }));
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function checkTemplate(template, out) {
|
|
79
|
+
const problems = [];
|
|
80
|
+
for (const key of ['templateId', 'styleId', 'name']) {
|
|
81
|
+
if (typeof template?.[key] !== 'string' || !template[key].trim()) problems.push([`template.${key}`, `${key} 必须是非空字符串`]);
|
|
82
|
+
}
|
|
83
|
+
if (!boxPositive(template?.interiorBounds)) problems.push(['template.interiorBounds', '室内净空必须是六向有限数值且三轴正尺寸(min < max)的盒子']);
|
|
84
|
+
if (!Array.isArray(template?.zones) || template.zones.length === 0) problems.push(['template.zones', '至少要有一个摆放区域']);
|
|
85
|
+
const zones = Array.isArray(template?.zones) ? template.zones : [];
|
|
86
|
+
const zoneIds = new Set();
|
|
87
|
+
for (const [index, zone] of zones.entries()) {
|
|
88
|
+
if (typeof zone?.zoneId !== 'string' || !zone.zoneId.trim()) { problems.push([`template.zones[${index}]`, 'zoneId 必须是非空字符串']); continue; }
|
|
89
|
+
if (zoneIds.has(zone.zoneId)) out.push(diag('ID_DUPLICATE', `摆放区域 ID「${zone.zoneId}」重复。`, { fieldPath: `template.zones[${index}].zoneId` }));
|
|
90
|
+
zoneIds.add(zone.zoneId);
|
|
91
|
+
if (zone.kind === 'wall') {
|
|
92
|
+
out.push(diag('PLACEMENT_UNSUPPORTED', `区域「${zone.zoneId}」的 kind 是 wall:当前版本不支持墙面摆放。`, { fieldPath: `template.zones[${index}].kind`, targetId: zone.zoneId }));
|
|
93
|
+
} else if (!SUPPORTED_ZONE_KINDS.includes(zone.kind)) {
|
|
94
|
+
problems.push([`template.zones[${index}].kind`, `区域「${zone.zoneId}」的 kind 必须是 ${SUPPORTED_ZONE_KINDS.join('/')},当前为 ${JSON.stringify(zone.kind)}`]);
|
|
95
|
+
}
|
|
96
|
+
if (!boxPositive(zone?.bounds)) problems.push([`template.zones[${index}].bounds`, '区域范围必须是三轴正尺寸的盒子']);
|
|
97
|
+
}
|
|
98
|
+
const entry = template?.entry;
|
|
99
|
+
if (!finite(entry?.door?.centerX) || !finite(entry?.door?.width) || !finite(entry?.door?.height) || entry.door.width <= 0 || entry.door.height <= 0) {
|
|
100
|
+
problems.push(['template.entry.door', '门洞需要有限的 centerX、正的 width 与 height']);
|
|
101
|
+
}
|
|
102
|
+
if (!finite(entry?.spawn?.x) || !finite(entry?.spawn?.z)) problems.push(['template.entry.spawn', '出生点需要有限的 x、z']);
|
|
103
|
+
if (!Array.isArray(template?.reservedVolumes)) problems.push(['template.reservedVolumes', 'reservedVolumes 必须是数组']);
|
|
104
|
+
const volumes = Array.isArray(template?.reservedVolumes) ? template.reservedVolumes : [];
|
|
105
|
+
const volumeIds = new Set();
|
|
106
|
+
for (const [index, volume] of volumes.entries()) {
|
|
107
|
+
if (typeof volume?.id !== 'string' || !volume.id.trim()) { problems.push([`template.reservedVolumes[${index}]`, '禁放区需要非空 id']); continue; }
|
|
108
|
+
if (volumeIds.has(volume.id)) out.push(diag('ID_DUPLICATE', `禁放区 ID「${volume.id}」重复。`, { fieldPath: `template.reservedVolumes[${index}].id` }));
|
|
109
|
+
volumeIds.add(volume.id);
|
|
110
|
+
if (!RESERVED_PURPOSES.includes(volume?.purpose)) problems.push([`template.reservedVolumes[${index}].purpose`, `禁放区「${volume.id}」的 purpose 必须是 ${RESERVED_PURPOSES.join('/')}`]);
|
|
111
|
+
if (!boxPositive(volume?.bounds)) problems.push([`template.reservedVolumes[${index}].bounds`, `禁放区「${volume.id}」的范围必须是三轴正尺寸的盒子`]);
|
|
112
|
+
}
|
|
113
|
+
if (!finite(template?.walkProfile?.radius) || template.walkProfile.radius <= 0 || !finite(template.walkProfile?.eyeClearance) || template.walkProfile.eyeClearance <= 0) {
|
|
114
|
+
problems.push(['template.walkProfile', 'walkProfile 需要正的 radius 与 eyeClearance']);
|
|
115
|
+
}
|
|
116
|
+
for (const [fieldPath, message] of problems) out.push(diag('STRUCT_INVALID', message, { fieldPath }));
|
|
117
|
+
return problems.length === 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function checkCatalog(catalog, out) {
|
|
121
|
+
const problems = [];
|
|
122
|
+
if (!Array.isArray(catalog?.assets) || catalog.assets.length === 0) problems.push(['catalog.assets', '至少要登记一款家具']);
|
|
123
|
+
const assets = Array.isArray(catalog?.assets) ? catalog.assets : [];
|
|
124
|
+
const assetIds = new Set();
|
|
125
|
+
for (const [index, asset] of assets.entries()) {
|
|
126
|
+
const at = `catalog.assets[${index}]`;
|
|
127
|
+
if (typeof asset?.assetId !== 'string' || !asset.assetId.trim()) { problems.push([at, 'assetId 必须是非空字符串']); continue; }
|
|
128
|
+
if (assetIds.has(asset.assetId)) out.push(diag('ID_DUPLICATE', `资产 ID「${asset.assetId}」重复。`, { fieldPath: `${at}.assetId` }));
|
|
129
|
+
assetIds.add(asset.assetId);
|
|
130
|
+
for (const key of ['name', 'category', 'description']) {
|
|
131
|
+
if (typeof asset?.[key] !== 'string' || !asset[key].trim()) problems.push([`${at}.${key}`, `资产「${asset.assetId}」的 ${key} 必须是非空字符串`]);
|
|
132
|
+
}
|
|
133
|
+
if (!Array.isArray(asset?.styleIds) || asset.styleIds.length === 0 || asset.styleIds.some((id) => typeof id !== 'string')) {
|
|
134
|
+
problems.push([`${at}.styleIds`, `资产「${asset.assetId}」需要非空的风格标签`]);
|
|
135
|
+
}
|
|
136
|
+
if (typeof asset?.modelRef !== 'string' || !asset.modelRef.trim()) problems.push([`${at}.modelRef`, `资产「${asset.assetId}」需要已登记的 modelRef`]);
|
|
137
|
+
if (!boxPositive(asset?.bounds)) problems.push([`${at}.bounds`, `资产「${asset.assetId}」的包围盒需要三轴都为正尺寸(min < max)`]);
|
|
138
|
+
// 原点约定:占位与导入模型都以底面中心为原点,Y 由地面/承载面算出;偏离则数据与绘制对不上
|
|
139
|
+
if (boxPositive(asset?.bounds) && (Math.abs(asset.bounds.minY) > TOLERANCE || Math.abs(asset.bounds.minX + asset.bounds.maxX) > TOLERANCE || Math.abs(asset.bounds.minZ + asset.bounds.maxZ) > TOLERANCE)) {
|
|
140
|
+
problems.push([`${at}.bounds`, `资产「${asset.assetId}」的包围盒必须以底面中心为原点(minY 为 0,X/Z 居中)`]);
|
|
141
|
+
}
|
|
142
|
+
const kinds = asset?.placement?.allowedKinds;
|
|
143
|
+
if (!Array.isArray(kinds) || kinds.length === 0 || kinds.some((kind) => !['floor', 'surface'].includes(kind))) {
|
|
144
|
+
problems.push([`${at}.placement.allowedKinds`, `资产「${asset.assetId}」的 allowedKinds 只能登记真正支持的值(floor/surface)`]);
|
|
145
|
+
}
|
|
146
|
+
const yaws = asset?.placement?.allowedYawDeg;
|
|
147
|
+
if (!Array.isArray(yaws) || yaws.length === 0 || yaws.some((yaw) => !GLOBAL_YAW_WHITELIST.includes(yaw))) {
|
|
148
|
+
problems.push([`${at}.placement.allowedYawDeg`, `资产「${asset.assetId}」的 allowedYawDeg 只能取 ${GLOBAL_YAW_WHITELIST.join('/')} 的子集`]);
|
|
149
|
+
}
|
|
150
|
+
if (!Array.isArray(asset?.clearanceBoxes)) problems.push([`${at}.clearanceBoxes`, `资产「${asset.assetId}」的 clearanceBoxes 必须是数组(无要求则空数组)`]);
|
|
151
|
+
for (const [ci, clearance] of (Array.isArray(asset?.clearanceBoxes) ? asset.clearanceBoxes : []).entries()) {
|
|
152
|
+
if (typeof clearance?.name !== 'string' || !clearance.name.trim()) problems.push([`${at}.clearanceBoxes[${ci}].name`, '操作留空需要中文名称']);
|
|
153
|
+
if (!boxPositive(clearance?.bounds)) problems.push([`${at}.clearanceBoxes[${ci}].bounds`, `资产「${asset.assetId}」的操作留空「${clearance?.name}」需要三轴正尺寸`]);
|
|
154
|
+
}
|
|
155
|
+
// 地面资产必须自带操作站位;纯 surface 资产(如占位台灯)没有独立地面站位,沿用父家具的站位
|
|
156
|
+
if (Array.isArray(kinds) && kinds.includes('floor') && (!finite(asset?.approachPoint?.x) || !finite(asset?.approachPoint?.z))) {
|
|
157
|
+
problems.push([`${at}.approachPoint`, `资产「${asset.assetId}」提供 floor 放置,需要本地站位 (x,z)`]);
|
|
158
|
+
}
|
|
159
|
+
if (!Array.isArray(asset?.surfaces)) {
|
|
160
|
+
problems.push([`${at}.surfaces`, `资产「${asset.assetId}」的 surfaces 必须是数组(无承载面则空数组)`]);
|
|
161
|
+
} else {
|
|
162
|
+
const surfaceIds = new Set();
|
|
163
|
+
for (const [si, surface] of asset.surfaces.entries()) {
|
|
164
|
+
if (typeof surface?.surfaceId !== 'string' || !surface.surfaceId.trim()) { problems.push([`${at}.surfaces[${si}]`, '承载面需要非空 surfaceId']); continue; }
|
|
165
|
+
if (surfaceIds.has(surface.surfaceId)) out.push(diag('SURFACE_DUPLICATE', `资产「${asset.assetId}」的承载面 ID「${surface.surfaceId}」重复。`, { fieldPath: `${at}.surfaces[${si}].surfaceId`, targetId: surface.surfaceId }));
|
|
166
|
+
surfaceIds.add(surface.surfaceId);
|
|
167
|
+
if (typeof surface?.name !== 'string' || !surface.name.trim()) problems.push([`${at}.surfaces[${si}].name`, `承载面「${surface.surfaceId}」需要中文名称`]);
|
|
168
|
+
if (!rectPositive(surface?.rect)) problems.push([`${at}.surfaces[${si}].rect`, `承载面「${surface.surfaceId}」的矩形需要四向有限数值且正尺寸(min < max)`]);
|
|
169
|
+
if (!finite(surface?.y)) problems.push([`${at}.surfaces[${si}].y`, `承载面「${surface.surfaceId}」需要有限的高度 y`]);
|
|
170
|
+
if (!Array.isArray(surface?.allowedCategories) || surface.allowedCategories.length === 0 || surface.allowedCategories.some((item) => typeof item !== 'string' || !item.trim())) {
|
|
171
|
+
problems.push([`${at}.surfaces[${si}].allowedCategories`, `承载面「${surface.surfaceId}」需要非空的类别白名单`]);
|
|
172
|
+
}
|
|
173
|
+
// 首版只支持水平顶面承载:矩形完整落在父资产包围盒的 X/Z 投影内,y 必须等于包围盒顶面
|
|
174
|
+
if (rectPositive(surface?.rect) && boxPositive(asset?.bounds)) {
|
|
175
|
+
const { bounds, rect } = { bounds: asset.bounds, rect: surface.rect };
|
|
176
|
+
if (rect.minX < bounds.minX - TOLERANCE || rect.maxX > bounds.maxX + TOLERANCE || rect.minZ < bounds.minZ - TOLERANCE || rect.maxZ > bounds.maxZ + TOLERANCE) {
|
|
177
|
+
problems.push([`${at}.surfaces[${si}].rect`, `承载面「${surface.surfaceId}」的矩形必须完整落在父资产「${asset.assetId}」包围盒的 X/Z 投影内`]);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (finite(surface?.y) && boxPositive(asset?.bounds) && Math.abs(surface.y - asset.bounds.maxY) > TOLERANCE) {
|
|
181
|
+
problems.push([`${at}.surfaces[${si}].y`, `承载面「${surface.surfaceId}」的高度 y 必须等于父资产「${asset.assetId}」包围盒顶面 maxY(当前版本只支持水平顶面承载)`]);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (!Array.isArray(asset?.capabilities)) problems.push([`${at}.capabilities`, `资产「${asset.assetId}」的 capabilities 必须是数组,且不得写可执行代码`]);
|
|
186
|
+
}
|
|
187
|
+
for (const [fieldPath, message] of problems) out.push(diag('STRUCT_INVALID', message, { fieldPath }));
|
|
188
|
+
return problems.length === 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function checkLayout(layout, out) {
|
|
192
|
+
const problems = [];
|
|
193
|
+
if (typeof layout?.roomId !== 'string' || !layout.roomId.trim()) problems.push(['layout.roomId', 'roomId 必须是非空字符串']);
|
|
194
|
+
if (typeof layout?.templateRef !== 'string' || !layout.templateRef.trim()) problems.push(['layout.templateRef', 'templateRef 必须是非空字符串']);
|
|
195
|
+
if (!Array.isArray(layout?.instances)) problems.push(['layout.instances', 'instances 必须是数组']);
|
|
196
|
+
const instances = Array.isArray(layout?.instances) ? layout.instances : [];
|
|
197
|
+
const seen = new Set();
|
|
198
|
+
for (const [index, instance] of instances.entries()) {
|
|
199
|
+
const at = `layout.instances[${index}]`;
|
|
200
|
+
if (typeof instance?.instanceId !== 'string' || !instance.instanceId.trim()) { problems.push([at, 'instanceId 必须是非空字符串']); continue; }
|
|
201
|
+
if (seen.has(instance.instanceId)) out.push(diag('ID_DUPLICATE', `实例 ID「${instance.instanceId}」在房间内重复。`, { fieldPath: `${at}.instanceId`, instanceIds: [instance.instanceId] }));
|
|
202
|
+
seen.add(instance.instanceId);
|
|
203
|
+
if (typeof instance?.assetId !== 'string' || !instance.assetId.trim()) problems.push([`${at}.assetId`, `实例「${instance.instanceId}」缺少 assetId`]);
|
|
204
|
+
const placement = instance?.placement;
|
|
205
|
+
if (placement?.kind === 'wall') {
|
|
206
|
+
out.push(diag('PLACEMENT_UNSUPPORTED', `实例「${instance.instanceId}」的摆放类型是 wall:当前版本不支持墙面摆放。`, { fieldPath: `${at}.placement.kind`, instanceIds: [instance.instanceId] }));
|
|
207
|
+
} else if (placement?.kind === 'floor') {
|
|
208
|
+
if (typeof placement.zoneId !== 'string' || !placement.zoneId.trim()) problems.push([`${at}.placement.zoneId`, `实例「${instance.instanceId}」缺少 zoneId`]);
|
|
209
|
+
for (const axis of ['x', 'z']) {
|
|
210
|
+
if (!finite(placement?.[axis])) problems.push([`${at}.placement.${axis}`, `实例「${instance.instanceId}」的 ${axis} 坐标必须是有限数值,当前为 ${JSON.stringify(placement?.[axis])}`]);
|
|
211
|
+
}
|
|
212
|
+
for (const field of Object.keys(placement)) {
|
|
213
|
+
if (!FLOOR_FIELDS.includes(field)) problems.push([`${at}.placement.${field}`, `floor 摆放不接受字段 ${field}(placement 只允许 ${FLOOR_FIELDS.join('/')})`]);
|
|
214
|
+
}
|
|
215
|
+
} else if (placement?.kind === 'surface') {
|
|
216
|
+
// surface 只携带依附字段;世界位置由父项算出,不接受第二份可编辑坐标或自由高度
|
|
217
|
+
for (const field of ['parentInstanceId', 'surfaceId']) {
|
|
218
|
+
if (typeof placement?.[field] !== 'string' || !placement[field].trim()) problems.push([`${at}.placement.${field}`, `surface 实例「${instance.instanceId}」缺少字符串字段 ${field}`]);
|
|
219
|
+
}
|
|
220
|
+
for (const axis of ['offsetX', 'offsetZ']) {
|
|
221
|
+
if (!finite(placement?.[axis])) problems.push([`${at}.placement.${axis}`, `实例「${instance.instanceId}」的 ${axis} 必须是有限数值,当前为 ${JSON.stringify(placement?.[axis])}`]);
|
|
222
|
+
}
|
|
223
|
+
for (const field of Object.keys(placement)) {
|
|
224
|
+
if (!SURFACE_FIELDS.includes(field)) problems.push([`${at}.placement.${field}`, `surface 摆放不接受字段 ${field}(placement 只允许 ${SURFACE_FIELDS.join('/')},位置与高度由父项与承载面决定)`]);
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
problems.push([`${at}.placement.kind`, `实例「${instance.instanceId}」的摆放类型必须是 floor 或 surface,当前为 ${JSON.stringify(placement?.kind)}`]);
|
|
228
|
+
}
|
|
229
|
+
if (!GLOBAL_YAW_WHITELIST.includes(placement?.yawDeg)) {
|
|
230
|
+
out.push(diag('YAW_INVALID', `实例「${instance.instanceId}」的朝向必须是 ${GLOBAL_YAW_WHITELIST.join('/')} 度,当前为 ${JSON.stringify(placement?.yawDeg)}。`, { fieldPath: `${at}.placement.yawDeg`, instanceIds: [instance.instanceId] }));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const [fieldPath, message] of problems) out.push(diag('STRUCT_INVALID', message, { fieldPath }));
|
|
234
|
+
return problems.length === 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ───────────────────────── 解析与空间校验 ─────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 三遍解析:先按 ID 建映射(与输入顺序无关),再检查依附引用(自依附/父缺失/环/
|
|
241
|
+
* 层级/承载面/类别),最后按「先全部 floor、再全部 surface」算世界变换。引用失败的
|
|
242
|
+
* 子项不落地、不参与后续空间检查(不因错误父项让孩子落到地面)。
|
|
243
|
+
*/
|
|
244
|
+
function resolveInstances(template, assets, layout, out) {
|
|
245
|
+
const zones = new Map(template.zones.map((zone) => [zone.zoneId, zone]));
|
|
246
|
+
const nodes = new Map(); // instanceId -> { instance, asset, placement, surface? }
|
|
247
|
+
for (const instance of layout.instances) {
|
|
248
|
+
const placement = instance.placement ?? {};
|
|
249
|
+
const asset = assets.get(instance.assetId);
|
|
250
|
+
if (!asset) {
|
|
251
|
+
out.push(diag('REF_UNKNOWN', `实例「${instance.instanceId}」引用了未知资产「${instance.assetId}」。`, { fieldPath: 'catalog.assets', instanceIds: [instance.instanceId], targetId: instance.assetId }));
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (nodes.has(instance.instanceId)) continue; // 重复 ID 已由 checkLayout 报过,不重复解析
|
|
255
|
+
if (placement.kind === 'floor' && !zones.has(placement.zoneId)) {
|
|
256
|
+
out.push(diag('REF_UNKNOWN', `实例「${instance.instanceId}」引用了未知摆放区域「${placement.zoneId}」。`, { fieldPath: 'template.zones', instanceIds: [instance.instanceId], targetId: placement.zoneId }));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
// E4-0(2026-09-28 作者裁决):家具 styleIds 只是自身风格标签,不再与模板 styleId 比对——
|
|
260
|
+
// 跨风格混搭允许,空间与依附规则照常校验。
|
|
261
|
+
// 实例放置类型必须同时被当前版本与资产允许;两项独立检查,不因类型是 floor 就放行
|
|
262
|
+
if (!asset.placement.allowedKinds.includes(placement.kind)) {
|
|
263
|
+
out.push(diag('PLACEMENT_UNSUPPORTED', `资产「${asset.assetId}」不提供 ${placement.kind} 放置类型,实例「${instance.instanceId}」被拒绝。`, { fieldPath: 'catalog.assets', instanceIds: [instance.instanceId], targetId: asset.assetId }));
|
|
264
|
+
}
|
|
265
|
+
if (!asset.placement.allowedYawDeg.includes(placement.yawDeg)) {
|
|
266
|
+
out.push(diag('YAW_INVALID', `资产「${asset.assetId}」不提供 ${placement.yawDeg}° 朝向。`, { fieldPath: 'catalog.assets', instanceIds: [instance.instanceId], targetId: asset.assetId }));
|
|
267
|
+
}
|
|
268
|
+
// 结构坏掉的实例(坐标非有限、朝向越白名单、依附字段缺失)已在 checkLayout 报过,这里跳过几何解析。
|
|
269
|
+
// 纯 surface 资产(如占位台灯)没有 approachPoint:按 floor 强行解析会在站位上崩溃,这里一并跳过
|
|
270
|
+
//(allowedKinds 不含 floor 的组合已由上方 PLACEMENT_UNSUPPORTED 记录)。
|
|
271
|
+
const usable = placement.kind === 'floor'
|
|
272
|
+
? finite(placement.x) && finite(placement.z) && GLOBAL_YAW_WHITELIST.includes(placement.yawDeg) && finite(asset?.approachPoint?.x) && finite(asset?.approachPoint?.z)
|
|
273
|
+
: typeof placement.parentInstanceId === 'string' && typeof placement.surfaceId === 'string' && finite(placement.offsetX) && finite(placement.offsetZ) && GLOBAL_YAW_WHITELIST.includes(placement.yawDeg);
|
|
274
|
+
if (!usable) continue;
|
|
275
|
+
nodes.set(instance.instanceId, { instance, asset, placement });
|
|
276
|
+
}
|
|
277
|
+
// 第二遍:依附引用检查。当前版本只允许「地面家具 → 桌面物品」一层。
|
|
278
|
+
const rejected = new Set();
|
|
279
|
+
for (const [id, node] of nodes) {
|
|
280
|
+
if (node.placement.kind !== 'surface') continue;
|
|
281
|
+
const parentId = node.placement.parentInstanceId;
|
|
282
|
+
if (parentId === id) {
|
|
283
|
+
out.push(diag('SELF_ATTACH', `实例「${id}」的 parentInstanceId 指向自己,不能依附自身。`, { instanceIds: [id], fieldPath: 'layout.instances' }));
|
|
284
|
+
rejected.add(id);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const parent = nodes.get(parentId);
|
|
288
|
+
if (!parent) {
|
|
289
|
+
out.push(diag('PARENT_UNKNOWN', `surface 实例「${id}」引用的父实例「${parentId}」不存在或不可解析;子物品不落地,整份拒绝。`, { instanceIds: [id], targetId: parentId, fieldPath: 'layout.instances' }));
|
|
290
|
+
rejected.add(id);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
let cyclic = false;
|
|
294
|
+
let cursor = parent;
|
|
295
|
+
for (let guard = 0; cursor && cursor.placement.kind === 'surface' && guard <= nodes.size; guard += 1) {
|
|
296
|
+
if (cursor.placement.parentInstanceId === id) { cyclic = true; break; }
|
|
297
|
+
cursor = nodes.get(cursor.placement.parentInstanceId);
|
|
298
|
+
}
|
|
299
|
+
if (cyclic) {
|
|
300
|
+
out.push(diag('ATTACH_CYCLE', `实例「${id}」与「${parentId}」构成依附环,世界位置无法确定。`, { instanceIds: [id, parentId], fieldPath: 'layout.instances' }));
|
|
301
|
+
rejected.add(id);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (parent.placement.kind !== 'floor') {
|
|
305
|
+
out.push(diag('ATTACH_DEPTH', `实例「${id}」想依附 surface 实例「${parentId}」:当前版本只支持地面家具 → 桌面物品一层依附。`, { instanceIds: [id, parentId], fieldPath: 'layout.instances' }));
|
|
306
|
+
rejected.add(id);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const surface = (Array.isArray(parent.asset.surfaces) ? parent.asset.surfaces : []).find((item) => item?.surfaceId === node.placement.surfaceId);
|
|
310
|
+
if (!surface) {
|
|
311
|
+
out.push(diag('SURFACE_UNKNOWN', `实例「${id}」引用的承载面「${node.placement.surfaceId}」不属于父实例「${parentId}」的资产「${parent.asset.assetId}」。`, { instanceIds: [id, parentId], targetId: node.placement.surfaceId, fieldPath: 'catalog.assets' }));
|
|
312
|
+
rejected.add(id);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (!Array.isArray(surface.allowedCategories) || !surface.allowedCategories.includes(node.asset.category)) {
|
|
316
|
+
out.push(diag('CATEGORY_NOT_ALLOWED', `承载面「${surface.surfaceId}」只接受类别 ${JSON.stringify(surface.allowedCategories ?? [])},实例「${id}」的类别是「${node.asset.category}」。`, { instanceIds: [id, parentId], targetId: surface.surfaceId, fieldPath: 'catalog.assets' }));
|
|
317
|
+
rejected.add(id);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
node.surface = surface;
|
|
321
|
+
}
|
|
322
|
+
// 第三遍:世界变换。先算全部地面实例,再算 surface 子项,结果与输入数组顺序无关。
|
|
323
|
+
const resolved = [];
|
|
324
|
+
const byId = new Map();
|
|
325
|
+
for (const node of nodes.values()) {
|
|
326
|
+
if (node.placement.kind !== 'floor') continue;
|
|
327
|
+
const { x, z, yawDeg } = node.placement;
|
|
328
|
+
const entry = {
|
|
329
|
+
instance: node.instance, asset: node.asset, placement: node.placement, surface: null,
|
|
330
|
+
worldYaw: yawDeg,
|
|
331
|
+
position: { x, y: 0, z },
|
|
332
|
+
solid: worldBox(node.asset.bounds, { x, y: 0, z }, yawDeg),
|
|
333
|
+
clearances: (node.asset.clearanceBoxes ?? []).map((item) => ({ name: item.name, bounds: worldBox(item.bounds, { x, y: 0, z }, yawDeg) })),
|
|
334
|
+
approach: (() => {
|
|
335
|
+
const rotated = rotateXZ(node.asset.approachPoint.x, node.asset.approachPoint.z, yawDeg);
|
|
336
|
+
return { x: x + rotated.x, z: z + rotated.z };
|
|
337
|
+
})(),
|
|
338
|
+
standingSource: null,
|
|
339
|
+
};
|
|
340
|
+
resolved.push(entry);
|
|
341
|
+
byId.set(node.instance.instanceId, entry);
|
|
342
|
+
}
|
|
343
|
+
for (const node of nodes.values()) {
|
|
344
|
+
const id = node.instance.instanceId;
|
|
345
|
+
if (node.placement.kind !== 'surface' || rejected.has(id)) continue;
|
|
346
|
+
const parent = byId.get(node.placement.parentInstanceId);
|
|
347
|
+
const rect = node.surface.rect;
|
|
348
|
+
// 承载面原点=矩形中心 + 高度 y;offset 从该中心起算(中心非零时同样成立),子项不允许自填 Y
|
|
349
|
+
const originX = (rect.minX + rect.maxX) / 2 + node.placement.offsetX;
|
|
350
|
+
const originZ = (rect.minZ + rect.maxZ) / 2 + node.placement.offsetZ;
|
|
351
|
+
const rotated = rotateXZ(originX, originZ, parent.worldYaw);
|
|
352
|
+
const position = { x: parent.position.x + rotated.x, y: parent.position.y + node.surface.y, z: parent.position.z + rotated.z };
|
|
353
|
+
const worldYaw = (((parent.worldYaw + node.placement.yawDeg) % 360) + 360) % 360;
|
|
354
|
+
const entry = {
|
|
355
|
+
instance: node.instance, asset: node.asset, placement: node.placement, surface: node.surface,
|
|
356
|
+
worldYaw, position,
|
|
357
|
+
solid: worldBox(node.asset.bounds, position, worldYaw),
|
|
358
|
+
clearances: (node.asset.clearanceBoxes ?? []).map((item) => ({ name: item.name, bounds: worldBox(item.bounds, position, worldYaw) })),
|
|
359
|
+
approach: null, // 桌面物品没有独立地面站位,站位来源记录为父家具
|
|
360
|
+
standingSource: parent.instance.instanceId,
|
|
361
|
+
};
|
|
362
|
+
resolved.push(entry);
|
|
363
|
+
byId.set(id, entry);
|
|
364
|
+
}
|
|
365
|
+
return { resolved, byId };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 桌面局部检查:子物品按自身局部朝向旋转后的完整外形投影必须落在承载矩形内(不允许悬空探头)。 */
|
|
369
|
+
function surfaceChecks(resolved, byId, out) {
|
|
370
|
+
for (const entry of resolved) {
|
|
371
|
+
if (entry.placement.kind !== 'surface' || !entry.surface) continue;
|
|
372
|
+
const rect = entry.surface.rect;
|
|
373
|
+
const parent = byId.get(entry.placement.parentInstanceId);
|
|
374
|
+
const bounds = entry.asset.bounds;
|
|
375
|
+
const yaw = entry.placement.yawDeg; // 足迹由子局部朝向决定,与父朝向无关
|
|
376
|
+
const corners = [
|
|
377
|
+
rotateXZ(bounds.minX, bounds.minZ, yaw), rotateXZ(bounds.minX, bounds.maxZ, yaw),
|
|
378
|
+
rotateXZ(bounds.maxX, bounds.minZ, yaw), rotateXZ(bounds.maxX, bounds.maxZ, yaw),
|
|
379
|
+
];
|
|
380
|
+
const centerX = (rect.minX + rect.maxX) / 2 + entry.placement.offsetX;
|
|
381
|
+
const centerZ = (rect.minZ + rect.maxZ) / 2 + entry.placement.offsetZ;
|
|
382
|
+
const occupied = {
|
|
383
|
+
minX: centerX + Math.min(...corners.map((p) => p.x)), maxX: centerX + Math.max(...corners.map((p) => p.x)),
|
|
384
|
+
minZ: centerZ + Math.min(...corners.map((p) => p.z)), maxZ: centerZ + Math.max(...corners.map((p) => p.z)),
|
|
385
|
+
};
|
|
386
|
+
const inside = occupied.minX >= rect.minX - TOLERANCE && occupied.maxX <= rect.maxX + TOLERANCE
|
|
387
|
+
&& occupied.minZ >= rect.minZ - TOLERANCE && occupied.maxZ <= rect.maxZ + TOLERANCE;
|
|
388
|
+
if (!inside) {
|
|
389
|
+
out.push(diag('SURFACE_OVERHANG', `实例「${entry.instance.instanceId}」旋转 ${yaw}° 后的完整外形伸出了承载面「${entry.placement.surfaceId}」(父实例「${parent.instance.instanceId}」);中心仍在表面内也不允许探头。`, { instanceIds: [entry.instance.instanceId, parent.instance.instanceId], targetId: entry.placement.surfaceId, fieldPath: 'layout.instances' }));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function spatialChecks(template, resolved, out) {
|
|
395
|
+
const volumes = template.reservedVolumes ?? [];
|
|
396
|
+
const walkSet = volumes.filter((volume) => WALK_PURPOSES.includes(volume.purpose) && volume.bounds.maxY - volume.bounds.minY >= template.walkProfile.eyeClearance);
|
|
397
|
+
const corridors = walkSet.filter((volume) => volume.purpose === 'corridor');
|
|
398
|
+
for (const entry of resolved) {
|
|
399
|
+
// 地面实例须落在所登记的摆放区内;桌面子项不重新求地面区域,由承载面与室内净空约束
|
|
400
|
+
if (entry.placement.kind === 'floor') {
|
|
401
|
+
const zone = template.zones.find((item) => item.zoneId === entry.placement.zoneId);
|
|
402
|
+
if (!boxInside(zone.bounds, entry.solid)) out.push(diag('OUT_OF_BOUNDS', `实例「${entry.instance.instanceId}」的完整包围盒越出了摆放区「${zone.zoneId}」(只查中心点不合格)。`, { instanceIds: [entry.instance.instanceId], targetId: zone.zoneId, fieldPath: 'layout.instances' }));
|
|
403
|
+
}
|
|
404
|
+
if (!boxInside(template.interiorBounds, entry.solid)) out.push(diag('OUT_OF_BOUNDS', `实例「${entry.instance.instanceId}」的完整包围盒越出了室内净空。`, { instanceIds: [entry.instance.instanceId], targetId: 'interior', fieldPath: 'template.interiorBounds' }));
|
|
405
|
+
for (const volume of volumes) {
|
|
406
|
+
if (boxIntersects(entry.solid, volume.bounds)) {
|
|
407
|
+
const kindName = volume.purpose === 'entry' ? '入口' : volume.purpose === 'corridor' ? '通路' : '固定构件';
|
|
408
|
+
out.push(diag('RESERVED_BLOCKED', `实例「${entry.instance.instanceId}」占用了${kindName}禁放区「${volume.id}」。`, { instanceIds: [entry.instance.instanceId], targetId: volume.id, fieldPath: 'template.reservedVolumes' }));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
for (const { name, bounds } of entry.clearances) {
|
|
412
|
+
if (!boxInside(template.interiorBounds, bounds)) out.push(diag('CLEARANCE_BLOCKED', `实例「${entry.instance.instanceId}」的操作留空「${name}」越出了室内净空。`, { instanceIds: [entry.instance.instanceId], targetId: 'interior', fieldPath: 'catalog.assets' }));
|
|
413
|
+
for (const other of resolved) {
|
|
414
|
+
if (other === entry) continue;
|
|
415
|
+
if (boxIntersects(bounds, other.solid)) out.push(diag('CLEARANCE_BLOCKED', `实例「${other.instance.instanceId}」的实体占用了实例「${entry.instance.instanceId}」的操作留空「${name}」。`, { instanceIds: [entry.instance.instanceId], targetId: other.instance.instanceId, fieldPath: 'layout.instances' }));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// 站位按保守人形盒判断:中心 ± 半径(X/Z),Y [0, 净高];须完整落在自身操作区与同一条固定通路内。
|
|
419
|
+
// 桌面子项没有独立地面站位(approach 为 null),沿用父家具的站位,不在此检查。
|
|
420
|
+
if (entry.approach) {
|
|
421
|
+
const radius = template.walkProfile.radius;
|
|
422
|
+
const human = { minX: entry.approach.x - radius, maxX: entry.approach.x + radius, minY: 0, maxY: template.walkProfile.eyeClearance, minZ: entry.approach.z - radius, maxZ: entry.approach.z + radius };
|
|
423
|
+
if (!entry.clearances.some(({ bounds }) => boxInside(bounds, human))) {
|
|
424
|
+
out.push(diag('STAND_UNREACHABLE', `实例「${entry.instance.instanceId}」站位周围的人形范围(半径 ${radius} 米、净高 ${template.walkProfile.eyeClearance} 米)未完整落在自身操作留空内。`, { instanceIds: [entry.instance.instanceId], fieldPath: 'catalog.assets' }));
|
|
425
|
+
}
|
|
426
|
+
const covered = corridors.some((volume) => boxInside(volume.bounds, human));
|
|
427
|
+
if (!covered) out.push(diag('STAND_UNREACHABLE', `实例「${entry.instance.instanceId}」站位周围的人形范围未完整落入固定通路之一(含头顶净高)。`, { instanceIds: [entry.instance.instanceId], targetId: 'corridor', fieldPath: 'template.reservedVolumes' }));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// 全部实体统一碰撞(含桌面子项):父子只做接触不做豁免;仅接触允许、正厚度相交拒绝
|
|
431
|
+
for (let i = 0; i < resolved.length; i += 1) {
|
|
432
|
+
for (let j = i + 1; j < resolved.length; j += 1) {
|
|
433
|
+
if (boxIntersects(resolved[i].solid, resolved[j].solid)) {
|
|
434
|
+
out.push(diag('FURNITURE_OVERLAP', `实例「${resolved[i].instance.instanceId}」与「${resolved[j].instance.instanceId}」的实体盒相交(仅接触允许)。`, { instanceIds: [resolved[i].instance.instanceId, resolved[j].instance.instanceId], fieldPath: 'layout.instances' }));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return walkSet;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* 出生点到全部地面站位的连续路径证据(固定模板,不是通用寻路):
|
|
443
|
+
* 路点 = 出生点 → 入口带与主通路衔接点 → 主通路上的转竖点 → 各站位(按 X 降序)。
|
|
444
|
+
* 每一段用两端点人形盒的联合包围盒做保守包含:整段扫过范围必须完整落在**单一**通行盒内,
|
|
445
|
+
* 相邻段共享同一个合法站立位置。端点合法而中途越出会被拒绝。
|
|
446
|
+
* 只有需要地面站位的实例(floor)纳入路径;桌面子项沿用父家具站位,不重复计入。
|
|
447
|
+
*/
|
|
448
|
+
function routeCheck(template, resolved, walkSet, out) {
|
|
449
|
+
const radius = template.walkProfile.radius;
|
|
450
|
+
const stands = resolved.filter((entry) => entry.approach).map((entry) => ({ instanceId: entry.instance.instanceId, x: entry.approach.x, z: entry.approach.z })).sort((a, b) => b.x - a.x);
|
|
451
|
+
const spawn = template.entry.spawn;
|
|
452
|
+
const human = (x, z) => ({ minX: x - radius, maxX: x + radius, minY: 0, maxY: template.walkProfile.eyeClearance, minZ: z - radius, maxZ: z + radius });
|
|
453
|
+
const entryVolume = walkSet.find((volume) => volume.purpose === 'entry' && boxInside(volume.bounds, human(spawn.x, spawn.z)));
|
|
454
|
+
let join = { x: spawn.x, z: spawn.z };
|
|
455
|
+
if (entryVolume) {
|
|
456
|
+
for (const volume of walkSet) {
|
|
457
|
+
if (volume === entryVolume) continue;
|
|
458
|
+
const zLow = Math.max(entryVolume.bounds.minZ, volume.bounds.minZ);
|
|
459
|
+
const zHigh = Math.min(entryVolume.bounds.maxZ, volume.bounds.maxZ);
|
|
460
|
+
if (zHigh - zLow <= TOLERANCE) continue;
|
|
461
|
+
const candidate = { x: spawn.x, z: (zLow + zHigh) / 2 };
|
|
462
|
+
if (boxInside(volume.bounds, human(candidate.x, candidate.z)) && boxInside(entryVolume.bounds, human(candidate.x, candidate.z))) { join = candidate; break; }
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const zRoute = stands.length ? [...stands].sort((a, b) => a.z - b.z)[Math.floor(stands.length / 2)].z : spawn.z;
|
|
466
|
+
const waypoints = [{ x: spawn.x, z: spawn.z }, join, { x: spawn.x, z: zRoute }, ...stands];
|
|
467
|
+
const inOneVolume = (box) => walkSet.some((volume) => boxInside(volume.bounds, box));
|
|
468
|
+
for (let i = 0; i < waypoints.length - 1; i += 1) {
|
|
469
|
+
const a = human(waypoints[i].x, waypoints[i].z);
|
|
470
|
+
const b = human(waypoints[i + 1].x, waypoints[i + 1].z);
|
|
471
|
+
const swept = { minX: Math.min(a.minX, b.minX), maxX: Math.max(a.maxX, b.maxX), minY: 0, maxY: template.walkProfile.eyeClearance, minZ: Math.min(a.minZ, b.minZ), maxZ: Math.max(a.maxZ, b.maxZ) };
|
|
472
|
+
if (!inOneVolume(swept)) {
|
|
473
|
+
out.push(diag('STAND_UNREACHABLE', `出生点到站位的路径第 ${i + 1} 段扫过的人形范围未完整落在单一通行盒内(保守包含判定),区间 X[${swept.minX.toFixed(2)}, ${swept.maxX.toFixed(2)}] Z[${swept.minZ.toFixed(2)}, ${swept.maxZ.toFixed(2)}]。`, { instanceIds: waypoints[i + 1].instanceId ? [waypoints[i + 1].instanceId] : [], targetId: 'corridor', fieldPath: 'template.reservedVolumes' }));
|
|
474
|
+
return waypoints;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return waypoints;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ───────────────────────── 对外入口 ─────────────────────────
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* 整体校验一份布置:结构 → 引用 → 解析(含依附)→ 桌面投影 → 空间检查 → 通路检查。
|
|
484
|
+
* 通过返回 { ok: true, assembly }(绘制与点击映射必须复用 assembly 的同一份数据);
|
|
485
|
+
* 失败返回 { ok: false, diagnostics }(含全部真实错误,不因第一个错误吞掉其余)。
|
|
486
|
+
*/
|
|
487
|
+
export function validatePlacement(template, catalog, layout) {
|
|
488
|
+
const diagnostics = [];
|
|
489
|
+
const versionsOk = [
|
|
490
|
+
checkVersion(template, '房间模板', 'template', diagnostics),
|
|
491
|
+
checkVersion(catalog, '家具目录', 'catalog', diagnostics),
|
|
492
|
+
checkVersion(layout, '房间布置', 'layout', diagnostics),
|
|
493
|
+
].every(Boolean);
|
|
494
|
+
if (!versionsOk) return { ok: false, diagnostics };
|
|
495
|
+
const structureOk = [checkTemplate(template, diagnostics), checkCatalog(catalog, diagnostics), checkLayout(layout, diagnostics)].every(Boolean);
|
|
496
|
+
if (!structureOk) return { ok: false, diagnostics };
|
|
497
|
+
if (layout.templateRef !== template.templateId) {
|
|
498
|
+
diagnostics.push(diag('REF_UNKNOWN', `布置引用的模板「${layout.templateRef}」与提供的模板「${template.templateId}」不一致。`, { fieldPath: 'layout.templateRef', targetId: layout.templateRef }));
|
|
499
|
+
return { ok: false, diagnostics };
|
|
500
|
+
}
|
|
501
|
+
const assets = new Map(catalog.assets.map((asset) => [asset.assetId, asset]));
|
|
502
|
+
const { resolved, byId } = resolveInstances(template, assets, layout, diagnostics);
|
|
503
|
+
surfaceChecks(resolved, byId, diagnostics);
|
|
504
|
+
const walkSet = spatialChecks(template, resolved, diagnostics);
|
|
505
|
+
const route = routeCheck(template, resolved, walkSet, diagnostics);
|
|
506
|
+
if (diagnostics.length) return { ok: false, diagnostics };
|
|
507
|
+
return {
|
|
508
|
+
ok: true,
|
|
509
|
+
diagnostics: [],
|
|
510
|
+
assembly: {
|
|
511
|
+
templateId: template.templateId,
|
|
512
|
+
styleId: template.styleId,
|
|
513
|
+
roomId: layout.roomId,
|
|
514
|
+
spawn: { x: template.entry.spawn.x, z: template.entry.spawn.z },
|
|
515
|
+
route,
|
|
516
|
+
instances: resolved.map((entry) => ({
|
|
517
|
+
instanceId: entry.instance.instanceId,
|
|
518
|
+
assetId: entry.asset.assetId,
|
|
519
|
+
name: entry.asset.name,
|
|
520
|
+
placeholder: entry.asset.placeholder === true,
|
|
521
|
+
placementKind: entry.placement.kind,
|
|
522
|
+
parentId: entry.placement.kind === 'surface' ? entry.placement.parentInstanceId : null,
|
|
523
|
+
surfaceId: entry.placement.kind === 'surface' ? entry.placement.surfaceId : null,
|
|
524
|
+
yawDeg: entry.worldYaw,
|
|
525
|
+
position: { x: entry.position.x, y: entry.position.y, z: entry.position.z },
|
|
526
|
+
solid: entry.solid,
|
|
527
|
+
clearances: entry.clearances,
|
|
528
|
+
approach: entry.approach,
|
|
529
|
+
standingSource: entry.standingSource,
|
|
530
|
+
mesh: {
|
|
531
|
+
kind: 'placeholder-box',
|
|
532
|
+
modelRef: entry.asset.modelRef,
|
|
533
|
+
width: entry.asset.bounds.maxX - entry.asset.bounds.minX,
|
|
534
|
+
height: entry.asset.bounds.maxY - entry.asset.bounds.minY,
|
|
535
|
+
depth: entry.asset.bounds.maxZ - entry.asset.bounds.minZ,
|
|
536
|
+
},
|
|
537
|
+
})),
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** 诊断视图用:尽量解析每个实例的世界盒(结构坏掉的实例跳过),只画红框说明被拒位置,不当有效家具。 */
|
|
543
|
+
export function previewBoxes(template, catalog, layout) {
|
|
544
|
+
const assets = new Map((Array.isArray(catalog?.assets) ? catalog.assets : []).map((asset) => [asset?.assetId, asset]));
|
|
545
|
+
const instances = Array.isArray(layout?.instances) ? layout.instances : [];
|
|
546
|
+
const out = [];
|
|
547
|
+
const floors = [];
|
|
548
|
+
for (const instance of instances) {
|
|
549
|
+
const asset = assets.get(instance?.assetId);
|
|
550
|
+
const placement = instance?.placement;
|
|
551
|
+
if (!asset || !isBox(asset.bounds) || placement?.kind !== 'floor') continue;
|
|
552
|
+
if (!finite(placement?.x) || !finite(placement?.z) || !GLOBAL_YAW_WHITELIST.includes(placement?.yawDeg)) continue;
|
|
553
|
+
floors.push({ instance, asset, placement });
|
|
554
|
+
out.push({
|
|
555
|
+
instanceId: instance.instanceId,
|
|
556
|
+
solid: worldBox(asset.bounds, placement, placement.yawDeg),
|
|
557
|
+
// 诊断预览不得因主校验已拒绝的坏数据(null/缺 bounds 的元素、误拼别名字段)再抛异常;
|
|
558
|
+
// 只读规范字段 clearanceBoxes,元素先验证再取 bounds,坏元素安全跳过
|
|
559
|
+
clearances: (Array.isArray(asset.clearanceBoxes) ? asset.clearanceBoxes : [])
|
|
560
|
+
.filter((entry) => entry && typeof entry.name === 'string' && isBox(entry.bounds))
|
|
561
|
+
.map((entry) => ({ name: entry.name, bounds: worldBox(entry.bounds, placement, placement.yawDeg) })),
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
// 桌面子项只有父项可解析时才画红框(父项缺失/结构坏掉时跳过,不让孩子落到地面)
|
|
565
|
+
for (const instance of instances) {
|
|
566
|
+
const asset = assets.get(instance?.assetId);
|
|
567
|
+
const placement = instance?.placement;
|
|
568
|
+
if (!asset || !isBox(asset.bounds) || placement?.kind !== 'surface') continue;
|
|
569
|
+
if (!finite(placement?.offsetX) || !finite(placement?.offsetZ) || !GLOBAL_YAW_WHITELIST.includes(placement?.yawDeg)) continue;
|
|
570
|
+
if (typeof placement.parentInstanceId !== 'string' || typeof placement.surfaceId !== 'string') continue;
|
|
571
|
+
const parent = floors.find((item) => item.instance.instanceId === placement.parentInstanceId);
|
|
572
|
+
const surface = (Array.isArray(parent?.asset?.surfaces) ? parent.asset.surfaces : []).find((item) => item?.surfaceId === placement.surfaceId);
|
|
573
|
+
if (!surface || !rectPositive(surface.rect) || !finite(surface.y)) continue;
|
|
574
|
+
const originX = (surface.rect.minX + surface.rect.maxX) / 2 + placement.offsetX;
|
|
575
|
+
const originZ = (surface.rect.minZ + surface.rect.maxZ) / 2 + placement.offsetZ;
|
|
576
|
+
const rotated = rotateXZ(originX, originZ, parent.placement.yawDeg);
|
|
577
|
+
const position = { x: parent.placement.x + rotated.x, y: surface.y, z: parent.placement.z + rotated.z };
|
|
578
|
+
const worldYaw = (((parent.placement.yawDeg + placement.yawDeg) % 360) + 360) % 360;
|
|
579
|
+
out.push({ instanceId: instance.instanceId, solid: worldBox(asset.bounds, position, worldYaw), clearances: [] });
|
|
580
|
+
}
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** 场景状态机(纯函数):整份合法才替换;失败保留上一份合法场景,首次失败则是空底座。 */
|
|
585
|
+
export function nextSceneState(state, candidate) {
|
|
586
|
+
if (candidate.ok) return { assembly: candidate.assembly, diagnostics: [] };
|
|
587
|
+
return { assembly: state?.assembly ?? null, diagnostics: candidate.diagnostics ?? [] };
|
|
588
|
+
}
|