@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.
Files changed (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/README.zh.md +100 -0
  4. package/client.js +122 -0
  5. package/cordis.patch.yml +8 -0
  6. package/dist/check-building.js +1669 -0
  7. package/dist/check-room.js +980 -0
  8. package/dist/index.js +2873 -0
  9. package/package.json +60 -0
  10. package/skills/specdev-building/SKILL.md +85 -0
  11. package/skills/specdev-building/assets/layouts/court.json +53 -0
  12. package/skills/specdev-building/assets/layouts/row.json +48 -0
  13. package/skills/specdev-building/assets/layouts/single.json +27 -0
  14. package/skills/specdev-building/assets/layouts/tower-9.json +71 -0
  15. package/skills/specdev-building/assets/layouts/tower.json +59 -0
  16. package/skills/specdev-building/references/blueprint-contract.md +126 -0
  17. package/skills/specdev-building/references/diagnostics.md +101 -0
  18. package/skills/specdev-building/references/layouts.md +112 -0
  19. package/skills/specdev-business/SKILL.md +91 -0
  20. package/skills/specdev-business/assets/plan-template.md +66 -0
  21. package/skills/specdev-business/assets/spec-template.md +111 -0
  22. package/skills/specdev-business/assets/tasks-template.md +36 -0
  23. package/skills/specdev-business/references/change.md +117 -0
  24. package/skills/specdev-business/references/evidence-review.md +125 -0
  25. package/skills/specdev-business/references/implementation.md +67 -0
  26. package/skills/specdev-business/references/project-contract.md +211 -0
  27. package/skills/specdev-business/references/source-map.md +110 -0
  28. package/skills/specdev-business/references/specification.md +93 -0
  29. package/skills/specdev-business/references/workflow.md +126 -0
  30. package/skills/specdev-room/SKILL.md +77 -0
  31. package/skills/specdev-room/assets/intro-room.json +14 -0
  32. package/skills/specdev-room/references/diagnostics.md +21 -0
  33. package/skills/specdev-room/references/room-contract.md +59 -0
  34. package/vendor/archify-renderer/VERSION.md +52 -0
  35. package/vendor/archify-renderer/archify/LICENSE +22 -0
  36. package/vendor/archify-renderer/archify/THIRD_PARTY_NOTICES.md +56 -0
  37. package/vendor/archify-renderer/archify/assets/template.html +14787 -0
  38. package/vendor/archify-renderer/archify/bin/archify.mjs +1990 -0
  39. package/vendor/archify-renderer/archify/renderers/shared/brand-marks.mjs +563 -0
  40. package/vendor/archify-renderer/archify/renderers/shared/cli.mjs +218 -0
  41. package/vendor/archify-renderer/archify/renderers/shared/desktop-readability.mjs +26 -0
  42. package/vendor/archify-renderer/archify/renderers/shared/diagnostics.mjs +127 -0
  43. package/vendor/archify-renderer/archify/renderers/shared/engineering-profiles.mjs +157 -0
  44. package/vendor/archify-renderer/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  45. package/vendor/archify-renderer/archify/renderers/shared/generated-validators.mjs +13 -0
  46. package/vendor/archify-renderer/archify/renderers/shared/geometry.mjs +1423 -0
  47. package/vendor/archify-renderer/archify/renderers/shared/i18n.mjs +594 -0
  48. package/vendor/archify-renderer/archify/renderers/shared/legend.mjs +217 -0
  49. package/vendor/archify-renderer/archify/renderers/shared/output-path.mjs +321 -0
  50. package/vendor/archify-renderer/archify/renderers/shared/repository-evidence.mjs +235 -0
  51. package/vendor/archify-renderer/archify/renderers/shared/text-fit.mjs +49 -0
  52. package/vendor/archify-renderer/archify/renderers/shared/utils.mjs +232 -0
  53. package/vendor/archify-renderer/archify/renderers/shared/validator.mjs +86 -0
  54. package/vendor/archify-renderer/archify/renderers/workflow/render-workflow.mjs +35 -0
  55. package/vendor/archify-renderer/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  56. package/vendor/archify-renderer/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  57. package/vendor/archify-renderer/archify/schemas/common.schema.json +115 -0
  58. package/vendor/archify-renderer/archify/schemas/workflow.schema.json +428 -0
  59. package/vendor/archify-renderer/archify/scripts/check-render-output.mjs +835 -0
  60. package/vendor/archify-renderer/shims/process.mjs +18 -0
  61. package/vendor/archify-renderer/stubs/child_process.mjs +2 -0
  62. package/vendor/archify-renderer/stubs/crypto.mjs +3 -0
  63. package/vendor/archify-renderer/stubs/dns-promises.mjs +2 -0
  64. package/vendor/archify-renderer/stubs/fs.mjs +3 -0
  65. package/vendor/archify-renderer/stubs/http.mjs +3 -0
  66. package/vendor/archify-renderer/stubs/https.mjs +3 -0
  67. package/vendor/archify-renderer/stubs/net.mjs +3 -0
  68. package/vendor/archify-renderer/stubs/path.mjs +3 -0
  69. package/vendor/archify-renderer/stubs/url.mjs +3 -0
  70. package/web/assets/building-kit.js +1157 -0
  71. package/web/assets/building-look.js +185 -0
  72. package/web/assets/building-plan.js +495 -0
  73. package/web/assets/building-scene.js +529 -0
  74. package/web/assets/building-walk.js +248 -0
  75. package/web/assets/building.js +859 -0
  76. package/web/assets/business.js +124 -0
  77. package/web/assets/chart-link.js +165 -0
  78. package/web/assets/common.js +302 -0
  79. package/web/assets/details.js +197 -0
  80. package/web/assets/evidence.js +30 -0
  81. package/web/assets/furniture/SOURCES.md +24 -0
  82. package/web/assets/furniture/assets/ceramic-display-stand-01/asset.json +25 -0
  83. package/web/assets/furniture/assets/ceramic-display-stand-01/model.js +116 -0
  84. package/web/assets/furniture/assets/ceramic-display-stand-01/preview.webp +0 -0
  85. package/web/assets/furniture/assets/ceramic-floor-lamp-01/asset.json +61 -0
  86. package/web/assets/furniture/assets/ceramic-floor-lamp-01/model.js +56 -0
  87. package/web/assets/furniture/assets/ceramic-floor-lamp-01/preview.webp +0 -0
  88. package/web/assets/furniture/assets/ceramic-low-cabinet-01/asset.json +81 -0
  89. package/web/assets/furniture/assets/ceramic-low-cabinet-01/model.js +67 -0
  90. package/web/assets/furniture/assets/ceramic-low-cabinet-01/preview.webp +0 -0
  91. package/web/assets/furniture/assets/ceramic-vase-01/asset.json +21 -0
  92. package/web/assets/furniture/assets/ceramic-vase-01/model.js +119 -0
  93. package/web/assets/furniture/assets/ceramic-vase-01/preview.webp +0 -0
  94. package/web/assets/furniture/assets/fairy-bed-01/asset.json +61 -0
  95. package/web/assets/furniture/assets/fairy-bed-01/model.js +63 -0
  96. package/web/assets/furniture/assets/fairy-bed-01/preview.webp +0 -0
  97. package/web/assets/furniture/assets/fairy-book-stack-01/asset.json +21 -0
  98. package/web/assets/furniture/assets/fairy-book-stack-01/model.js +71 -0
  99. package/web/assets/furniture/assets/fairy-book-stack-01/preview.webp +0 -0
  100. package/web/assets/furniture/assets/fairy-bookshelf-01/asset.json +32 -0
  101. package/web/assets/furniture/assets/fairy-bookshelf-01/model.js +168 -0
  102. package/web/assets/furniture/assets/fairy-bookshelf-01/preview.webp +0 -0
  103. package/web/assets/furniture/assets/fairy-desk-01/asset.json +25 -0
  104. package/web/assets/furniture/assets/fairy-desk-01/model.js +132 -0
  105. package/web/assets/furniture/assets/fairy-desk-01/preview.webp +0 -0
  106. package/web/assets/furniture/assets/fairy-notice-board-01/asset.json +23 -0
  107. package/web/assets/furniture/assets/fairy-notice-board-01/model.js +125 -0
  108. package/web/assets/furniture/assets/fairy-notice-board-01/preview.webp +0 -0
  109. package/web/assets/furniture/assets/fairy-potted-plant-01/asset.json +21 -0
  110. package/web/assets/furniture/assets/fairy-potted-plant-01/model.js +128 -0
  111. package/web/assets/furniture/assets/fairy-potted-plant-01/preview.webp +0 -0
  112. package/web/assets/furniture/assets/fairy-reading-chair-01/asset.json +61 -0
  113. package/web/assets/furniture/assets/fairy-reading-chair-01/model.js +66 -0
  114. package/web/assets/furniture/assets/fairy-reading-chair-01/preview.webp +0 -0
  115. package/web/assets/furniture/assets/fairy-table-lamp-01/asset.json +21 -0
  116. package/web/assets/furniture/assets/fairy-table-lamp-01/model.js +182 -0
  117. package/web/assets/furniture/assets/fairy-table-lamp-01/preview.webp +0 -0
  118. package/web/assets/furniture/catalog.js +137 -0
  119. package/web/assets/furniture/index.json +110 -0
  120. package/web/assets/furniture/registry.js +72 -0
  121. package/web/assets/furniture-page.css +68 -0
  122. package/web/assets/furniture-page.js +302 -0
  123. package/web/assets/home.js +65 -0
  124. package/web/assets/node-detail.js +148 -0
  125. package/web/assets/panel-link.js +47 -0
  126. package/web/assets/read.js +746 -0
  127. package/web/assets/room-scene.js +386 -0
  128. package/web/assets/room.css +287 -0
  129. package/web/assets/room.js +437 -0
  130. package/web/assets/rooms/bindings.js +449 -0
  131. package/web/assets/rooms/ceramic-reflection.js +193 -0
  132. package/web/assets/rooms/ceramic-room.js +302 -0
  133. package/web/assets/rooms/door.js +35 -0
  134. package/web/assets/rooms/fairy-room.js +492 -0
  135. package/web/assets/rooms/furniture-assembly.js +113 -0
  136. package/web/assets/rooms/navigation.js +262 -0
  137. package/web/assets/rooms/placeholder-catalog.json +71 -0
  138. package/web/assets/rooms/placeholder-furniture.js +76 -0
  139. package/web/assets/rooms/placement.js +588 -0
  140. package/web/assets/rooms/reader.js +441 -0
  141. package/web/assets/rooms/reading-gate.js +157 -0
  142. package/web/assets/rooms/room-lighting.js +50 -0
  143. package/web/assets/rooms/templates.json +50 -0
  144. package/web/assets/rooms/walk-frame.js +64 -0
  145. package/web/assets/rooms/walk-session.js +138 -0
  146. package/web/assets/save-result.js +89 -0
  147. package/web/assets/screenshots/business.png +0 -0
  148. package/web/assets/screenshots/node-detail.png +0 -0
  149. package/web/assets/screenshots/project-home.png +0 -0
  150. package/web/assets/screenshots/workflow.png +0 -0
  151. package/web/assets/showcase.js +232 -0
  152. package/web/assets/style.css +836 -0
  153. package/web/assets/template.js +7 -0
  154. package/web/assets/theme.js +29 -0
  155. package/web/assets/three/OrbitControls.js +1972 -0
  156. package/web/assets/three/VERSION.md +12 -0
  157. package/web/assets/three/three.core.js +60586 -0
  158. package/web/assets/three/three.module.js +19719 -0
  159. package/web/assets/workspaces.css +21 -0
  160. package/web/assets/workspaces.js +81 -0
  161. package/web/building.html +131 -0
  162. package/web/business.html +43 -0
  163. package/web/furniture.html +47 -0
  164. package/web/index.html +57 -0
  165. package/web/read.html +107 -0
  166. package/web/room.html +146 -0
  167. package/web/showcase.html +50 -0
  168. package/web/workspaces.html +32 -0
@@ -0,0 +1,182 @@
1
+ // 林间暖光台灯:自制程序化候选模型,不依赖 DOM、网络或实验文件。
2
+ // 米制、+Y 向上、+Z 正面;底面中心原点。沿用占位台灯空间契约(X±0.12、Y0–0.42、Z±0.15)。
3
+ // 设计剖面(检查按此独立手算):灯罩外半轴底(0.104,0.138)→顶(0.080,0.106)、Y 0.242→0.398,
4
+ // 壁厚 0.006;罩中部 Y=0.32 处外半轴 0.092、内半轴 0.086。灯杆中轴贯穿;支架自
5
+ // 灯头套环(r0.0185,Y0.310)伸至顶口内缘(0.074,0.100,Y0.3985)。
6
+ import * as THREE from 'three';
7
+
8
+ function woodTexture() {
9
+ const size = 256;
10
+ const data = new Uint8Array(size * size * 4);
11
+ let seed = 20260930;
12
+ const random = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; };
13
+ for (let v = 0; v < size; v++) for (let u = 0; u < size; u++) {
14
+ // 细木纹沿 U 延伸,供小底座近看;低对比,不做节疤。
15
+ const wave = v + 1.4 * Math.sin(u * 0.031) + 0.5 * Math.sin(u * 0.083);
16
+ const tone = 208 + 4 * Math.sin(wave * 0.55) + 2 * Math.sin(wave * 1.9) + (random() - 0.5) * 2.5;
17
+ const i = (v * size + u) * 4;
18
+ data[i] = tone; data[i + 1] = tone - 24; data[i + 2] = tone - 58; data[i + 3] = 255;
19
+ }
20
+ const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat);
21
+ texture.colorSpace = THREE.SRGBColorSpace;
22
+ texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
23
+ texture.magFilter = THREE.LinearFilter;
24
+ texture.minFilter = THREE.LinearMipmapLinearFilter;
25
+ texture.generateMipmaps = true;
26
+ texture.needsUpdate = true;
27
+ return texture;
28
+ }
29
+
30
+ function fabricTexture() {
31
+ const size = 256;
32
+ const data = new Uint8Array(size * size * 4);
33
+ let seed = 20260931;
34
+ const random = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; };
35
+ for (let v = 0; v < size; v++) for (let u = 0; u < size; u++) {
36
+ // 暖米色低对比细布纹:经纬轻起伏加细噪,不出现条纹或大花色。
37
+ const weave = 1.6 * Math.sin(u * 0.246) + 1.4 * Math.sin(v * 0.212) + 1.1 * Math.sin((u + v) * 0.147) + (random() - 0.5) * 2.2;
38
+ const tone = 224 + weave;
39
+ const i = (v * size + u) * 4;
40
+ data[i] = tone + 6; data[i + 1] = tone - 4; data[i + 2] = tone - 26; data[i + 3] = 255;
41
+ }
42
+ const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat);
43
+ texture.colorSpace = THREE.SRGBColorSpace;
44
+ texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
45
+ texture.magFilter = THREE.LinearFilter;
46
+ texture.minFilter = THREE.LinearMipmapLinearFilter;
47
+ texture.generateMipmaps = true;
48
+ texture.needsUpdate = true;
49
+ return texture;
50
+ }
51
+
52
+ // 椭圆环带侧壁:相邻环之间张成真实曲面(自下而上排列),outward 决定法线朝外或朝轴。
53
+ function ellipseWall(rings, segments, outward, uRepeat) {
54
+ const positions = [], uvs = [], indices = [];
55
+ const ringIds = rings.map((ring, level) => {
56
+ const ids = [];
57
+ for (let i = 0; i < segments; i++) {
58
+ const t = i / segments * Math.PI * 2;
59
+ const id = positions.length / 3;
60
+ positions.push(ring[0] * Math.cos(t), ring[1], ring[2] * Math.sin(t));
61
+ uvs.push(i / segments * uRepeat, level / (rings.length - 1));
62
+ ids.push(id);
63
+ }
64
+ return ids;
65
+ });
66
+ for (let level = 0; level < rings.length - 1; level++) for (let i = 0; i < segments; i++) {
67
+ const j = (i + 1) % segments;
68
+ const A = ringIds[level][i], B = ringIds[level][j], C = ringIds[level + 1][j], D = ringIds[level + 1][i];
69
+ if (outward) indices.push(A, C, B, A, D, C); else indices.push(A, B, C, A, C, D);
70
+ }
71
+ const geometry = new THREE.BufferGeometry();
72
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
73
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
74
+ geometry.setIndex(indices);
75
+ geometry.computeVertexNormals();
76
+ return geometry;
77
+ }
78
+
79
+ // 椭圆扁平环(包边):外缘到内缘的真实窄带,up 决定法线朝上或朝下。
80
+ function annulus(y, aO, bO, aI, bI, segments, up) {
81
+ const positions = [], uvs = [], indices = [];
82
+ const ringOf = (a, b) => { const ids = []; for (let i = 0; i < segments; i++) {
83
+ const t = i / segments * Math.PI * 2;
84
+ const id = positions.length / 3;
85
+ positions.push(a * Math.cos(t), y, b * Math.sin(t));
86
+ uvs.push(a * Math.cos(t) / 0.3 + 0.5, b * Math.sin(t) / 0.3 + 0.5);
87
+ ids.push(id);
88
+ } return ids; };
89
+ const outer = ringOf(aO, bO), inner = ringOf(aI, bI);
90
+ for (let i = 0; i < segments; i++) {
91
+ const j = (i + 1) % segments;
92
+ if (up) indices.push(outer[i], inner[i], outer[j], inner[i], inner[j], outer[j]);
93
+ else indices.push(outer[i], outer[j], inner[i], inner[i], outer[j], inner[j]);
94
+ }
95
+ const geometry = new THREE.BufferGeometry();
96
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
97
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
98
+ geometry.setIndex(indices);
99
+ geometry.computeVertexNormals();
100
+ return geometry;
101
+ }
102
+
103
+ // 圆盘(底座上下封板):真实封板,不是悬空轮廓。
104
+ function disc(y, r, segments, up) {
105
+ const positions = [], uvs = [], indices = [];
106
+ const center = positions.length / 3;
107
+ positions.push(0, y, 0); uvs.push(0.5, 0.5);
108
+ const ring = [];
109
+ for (let i = 0; i < segments; i++) {
110
+ const t = i / segments * Math.PI * 2;
111
+ const id = positions.length / 3;
112
+ positions.push(r * Math.cos(t), y, r * Math.sin(t));
113
+ uvs.push(Math.cos(t) * 0.5 + 0.5, Math.sin(t) * 0.5 + 0.5);
114
+ ring.push(id);
115
+ }
116
+ for (let i = 0; i < segments; i++) {
117
+ const j = (i + 1) % segments;
118
+ if (up) indices.push(center, ring[j], ring[i]); else indices.push(center, ring[i], ring[j]);
119
+ }
120
+ const geometry = new THREE.BufferGeometry();
121
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
122
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
123
+ geometry.setIndex(indices);
124
+ geometry.computeVertexNormals();
125
+ return geometry;
126
+ }
127
+
128
+ export function createFairyTableLamp() {
129
+ const group = new THREE.Group();
130
+ group.name = 'fairy-table-lamp-01';
131
+ const owned = new Set();
132
+ const keep = resource => { owned.add(resource); return resource; };
133
+ let disposed = false;
134
+ const dispose = () => {
135
+ if (disposed) return;
136
+ disposed = true;
137
+ group.removeFromParent();
138
+ for (const resource of owned) resource.dispose();
139
+ owned.clear(); group.clear();
140
+ };
141
+ try {
142
+ const wood = keep(woodTexture());
143
+ const cloth = keep(fabricTexture());
144
+ const honey = keep(new THREE.MeshStandardMaterial({ map: wood, bumpMap: wood, bumpScale: 0.0015, roughness: 0.66, metalness: 0.01 }));
145
+ const brass = keep(new THREE.MeshStandardMaterial({ color: '#c19a5e', metalness: 0.7, roughness: 0.34 }));
146
+ const fabric = keep(new THREE.MeshStandardMaterial({ map: cloth, bumpMap: cloth, bumpScale: 0.0012, roughness: 0.94 }));
147
+ const lining = keep(new THREE.MeshStandardMaterial({ color: '#f2e3c2', roughness: 0.97 }));
148
+ const trim = keep(new THREE.MeshStandardMaterial({ color: '#d9c49c', roughness: 0.88 }));
149
+ const glow = keep(new THREE.MeshStandardMaterial({ color: '#ffe7c4', emissive: '#ffb46a', emissiveIntensity: 0.5, roughness: 0.4 }));
150
+ const add = (geometry, material, x, y, z, name) => {
151
+ const mesh = new THREE.Mesh(keep(geometry), material);
152
+ mesh.name = name; mesh.position.set(x, y, z); mesh.castShadow = mesh.receiveShadow = true;
153
+ group.add(mesh); return mesh;
154
+ };
155
+ // 低矮圆润蜂蜜木底座:下盘触地 Y=0,侧壁上收出柔和圆肩,顶盘承接黄铜套环。
156
+ add(disc(0, 0.0905, 40, false), honey, 0, 0, 0, '底座下盘');
157
+ add(ellipseWall([[0.0905, 0, 0.0905], [0.0905, 0.028, 0.0905], [0.088, 0.0325, 0.088], [0.0825, 0.035, 0.0825]], 40, true, 3), honey, 0, 0, 0, '底座侧壁');
158
+ add(disc(0.035, 0.0825, 40, true), honey, 0, 0, 0, '底座顶盘');
159
+ const leaf = add(new THREE.SphereGeometry(1, 12, 6), brass, 0, 0.0175, 0.0885, '黄铜叶纹嵌片');
160
+ leaf.scale.set(0.024, 0.009, 0.004); leaf.rotation.z = 0.35;
161
+ // 黄铜灯杆经两道套环真实连接底座与灯头,罩、杆都不悬浮。
162
+ add(new THREE.CylinderGeometry(0.017, 0.019, 0.016, 14), brass, 0, 0.041, 0, '底座黄铜套环');
163
+ add(new THREE.CylinderGeometry(0.0095, 0.0095, 0.254, 10), brass, 0, 0.173, 0, '灯杆');
164
+ add(new THREE.CylinderGeometry(0.0185, 0.0185, 0.016, 14), brass, 0, 0.305, 0, '灯头黄铜套环');
165
+ add(new THREE.CylinderGeometry(0.0155, 0.0155, 0.035, 12), brass, 0, 0.3285, 0, '灯座');
166
+ add(new THREE.SphereGeometry(0.030, 16, 12), glow, 0, 0.358, 0, '灯泡');
167
+ // 三根细支架从灯头套环伸向罩顶内缘,灯罩由此真实承托;上下口保持敞开。
168
+ for (const degree of [30, 150, 270]) {
169
+ const t = degree * Math.PI / 180, c = Math.cos(t), s = Math.sin(t);
170
+ const from = new THREE.Vector3(0.0185 * c, 0.310, 0.0185 * s);
171
+ const to = new THREE.Vector3(0.074 * c, 0.3985, 0.100 * s);
172
+ const strut = add(new THREE.BoxGeometry(0.008, from.distanceTo(to), 0.008), brass, (from.x + to.x) / 2, (from.y + to.y) / 2, (from.z + to.z) / 2, '灯罩支架');
173
+ strut.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), to.clone().sub(from).normalize());
174
+ }
175
+ // 微锥布灯罩:外壁、内衬与上下薄包边各有真实几何与厚度,上下开口。
176
+ add(ellipseWall([[0.104, 0.242, 0.138], [0.080, 0.398, 0.106]], 48, true, 4), fabric, 0, 0, 0, '灯罩外壁');
177
+ add(ellipseWall([[0.098, 0.242, 0.132], [0.074, 0.398, 0.100]], 48, false, 1), lining, 0, 0, 0, '灯罩内衬');
178
+ add(annulus(0.398, 0.080, 0.106, 0.074, 0.100, 48, true), trim, 0, 0, 0, '上包边');
179
+ add(annulus(0.242, 0.104, 0.138, 0.098, 0.132, 48, false), trim, 0, 0, 0, '下包边');
180
+ return { group, dispose };
181
+ } catch (error) { dispose(); throw error; }
182
+ }
@@ -0,0 +1,137 @@
1
+ // 家具资产目录统一读取(E4b-1):index.json(人工维护的总索引,不复制资产字段)→
2
+ // 各资产包 asset.json(单件说明的唯一可编辑来源)→ 一个可被 rooms/placement.js 与
3
+ // rooms/bindings.js 直接消费的目录。纯数据层:不加载模型、不读业务资料、不预取预览图、
4
+ // 不碰 Three;空间精确约束仍归 placement、动作与文档身份仍归 bindings,这里不复制第二套
5
+ // 算法,load 成功也不等于任意布局合法。fetchJson 由调用者注入(可衔接页面既有 JSON 读取器)。
6
+ import { hasFurnitureModel } from './registry.js';
7
+
8
+ const INDEX_URL = '/specdev-workbench/assets/furniture/index.json';
9
+ const ROOT_PREFIX = '/specdev-workbench/assets/furniture/';
10
+ const ASSET_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
11
+ const RESERVED_ASSET_KEYS = ['constructor', 'toString', '__proto__'];
12
+
13
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ const isNonEmptyString = (value) => typeof value === 'string' && value !== '';
15
+ const typeName = (value) => (value === null ? 'null' : Array.isArray(value) ? '数组' : typeof value);
16
+ const describe = (value) => {
17
+ try { const text = JSON.stringify(value); return text === undefined ? String(value) : text; } catch { return String(value); }
18
+ };
19
+ const reasonOf = (error) => (error instanceof Error ? error.message : String(error));
20
+ const deepCopyOfJson = (value) => JSON.parse(JSON.stringify(value));
21
+ const diag = (code, message, fieldPath) => ({ code, message, fieldPath });
22
+ const failure = (code, message, fieldPath) => ({ ok: false, diagnostics: [diag(code, message, fieldPath)], catalog: null });
23
+
24
+ /** 完整校验索引结构;合格返回 { leafTypes, entries },否则返回 { problem }(调用方此时不得发任何元数据请求)。 */
25
+ function validateIndex(index) {
26
+ if (!isObject(index)) return { problem: diag('LIBRARY_INDEX', `索引必须是 JSON 对象(实际是 ${typeName(index)})`, 'index') };
27
+ if (index.schemaVersion !== 1) return { problem: diag('LIBRARY_INDEX', `索引 schemaVersion 必须为 1(实际 ${describe(index.schemaVersion)})`, 'index.schemaVersion') };
28
+ if (!Array.isArray(index.categories)) return { problem: diag('LIBRARY_INDEX', `索引 categories 必须是数组(实际是 ${typeName(index.categories)})`, 'index.categories') };
29
+ if (!Array.isArray(index.assets)) return { problem: diag('LIBRARY_INDEX', `索引 assets 必须是数组(实际是 ${typeName(index.assets)})`, 'index.assets') };
30
+ const leafTypes = new Map(); // 叶类型 → 所属大类 id(分类保存在数据里,不作为路径一部分)
31
+ const categoryIds = new Set();
32
+ for (const [position, category] of index.categories.entries()) {
33
+ const at = `index.categories[${position}]`;
34
+ if (!isObject(category)) return { problem: diag('LIBRARY_INDEX', `分类条目必须是对象(${at} 实际是 ${typeName(category)})`, at) };
35
+ if (!isNonEmptyString(category.id)) return { problem: diag('LIBRARY_INDEX', `分类 id 必须是非空字符串(实际 ${describe(category.id)})`, `${at}.id`) };
36
+ if (categoryIds.has(category.id)) return { problem: diag('LIBRARY_INDEX', `分类 id 重复登记:${category.id}`, `${at}.id`) };
37
+ categoryIds.add(category.id);
38
+ if (!isNonEmptyString(category.name)) return { problem: diag('LIBRARY_INDEX', `分类 ${category.id} 的 name 必须是非空字符串`, `${at}.name`) };
39
+ if (!Array.isArray(category.types) || category.types.length === 0 || !category.types.every(isNonEmptyString)) {
40
+ return { problem: diag('LIBRARY_INDEX', `分类 ${category.id} 的 types 必须是非空字符串组成的非空数组(实际 ${describe(category.types)})`, `${at}.types`) };
41
+ }
42
+ for (const type of category.types) {
43
+ if (leafTypes.has(type)) return { problem: diag('LIBRARY_INDEX', `叶类型 ${type} 重复登记(同时出现在 ${leafTypes.get(type)} 与 ${category.id})`, `${at}.types`) };
44
+ leafTypes.set(type, category.id);
45
+ }
46
+ }
47
+ const entries = [];
48
+ const seenIds = new Set();
49
+ for (const [position, entry] of index.assets.entries()) {
50
+ const at = `index.assets[${position}]`;
51
+ if (!isObject(entry)) return { problem: diag('LIBRARY_INDEX', `资产索引条目必须是对象(${at} 实际是 ${typeName(entry)})`, at) };
52
+ const { assetId, metadataRef } = entry;
53
+ if (!isNonEmptyString(assetId) || !ASSET_ID_PATTERN.test(assetId) || RESERVED_ASSET_KEYS.includes(assetId)) {
54
+ return { problem: diag('LIBRARY_INDEX', `assetId 必须匹配 ${ASSET_ID_PATTERN} 且不得是保留键(实际 ${describe(assetId)})`, `${at}.assetId`) };
55
+ }
56
+ if (seenIds.has(assetId)) return { problem: diag('LIBRARY_INDEX', `assetId 重复登记:${assetId}`, `${at}.assetId`) };
57
+ seenIds.add(assetId);
58
+ if (typeof metadataRef !== 'string' || metadataRef !== `assets/${assetId}/asset.json`) {
59
+ return { problem: diag('LIBRARY_INDEX', `${assetId} 的 metadataRef 必须严格等于 assets/${assetId}/asset.json(实际 ${describe(metadataRef)}),不接受绝对路径、外部地址、编码绕行、反斜线、查询串或 ../`, `${at}.metadataRef`) };
60
+ }
61
+ entries.push({ assetId, metadataRef });
62
+ }
63
+ return { leafTypes, entries };
64
+ }
65
+
66
+ /** 校验单份资产说明的管理信息,返回首个 { field, message } 或 null。空间字段只查形态存在,精确约束由 placement 负责。 */
67
+ function validateAsset(asset, expectedId, leafTypes) {
68
+ if (!isObject(asset)) return { field: '', message: `资产说明必须是 JSON 对象(实际是 ${typeName(asset)})` };
69
+ const problems = [
70
+ [asset.schemaVersion !== 1, 'schemaVersion', `schemaVersion 必须为 1(实际 ${describe(asset.schemaVersion)})`],
71
+ [!Number.isSafeInteger(asset.assetVersion) || asset.assetVersion <= 0, 'assetVersion', `assetVersion 必须是正的安全整数(实际 ${describe(asset.assetVersion)})`],
72
+ [asset.status !== 'internal', 'status', `status 目前只接受 internal,内部登记不代表发布(实际 ${describe(asset.status)})`],
73
+ [asset.assetId !== expectedId, 'assetId', `assetId 必须与索引一致(索引 ${expectedId},实际 ${describe(asset.assetId)})`],
74
+ [!isNonEmptyString(asset.category) || !leafTypes.has(asset.category), 'category', `category 必须是索引中已登记的叶类型(实际 ${describe(asset.category)})`],
75
+ [!isNonEmptyString(asset.modelRef), 'modelRef', 'modelRef 必须是非空字符串'],
76
+ [isNonEmptyString(asset.modelRef) && !hasFurnitureModel(asset.modelRef), 'modelRef', `modelRef 未在模型注册表登记,属未知模型:${describe(asset.modelRef)}`],
77
+ [!isNonEmptyString(asset.name), 'name', 'name 必须是非空字符串'],
78
+ [!isNonEmptyString(asset.description), 'description', 'description 必须是非空字符串'],
79
+ [!Array.isArray(asset.styleIds) || asset.styleIds.length === 0 || !asset.styleIds.every(isNonEmptyString), 'styleIds', `styleIds 必须是非空字符串组成的非空数组(实际 ${describe(asset.styleIds)})`],
80
+ [!isObject(asset.recommendations) || !isNonEmptyString(asset.recommendations.note), 'recommendations.note', 'recommendations.note 必须是非空字符串'],
81
+ [!isObject(asset.source) || !isNonEmptyString(asset.source.origin) || !isNonEmptyString(asset.source.license) || !isNonEmptyString(asset.source.note), 'source', 'source 的 origin/license/note 都必须是非空字符串'],
82
+ [asset.placeholder !== false, 'placeholder', `正式家具目录只收非占位资产,placeholder 必须为 false(实际 ${describe(asset.placeholder)})`],
83
+ [asset.previewRef !== null && asset.previewRef !== `assets/${expectedId}/preview.webp`, 'previewRef', `previewRef 只能是 null 或严格等于 assets/${expectedId}/preview.webp(实际 ${describe(asset.previewRef)})`],
84
+ [!isObject(asset.bounds), 'bounds', 'bounds 必须是对象(精确空间约束由 placement 校验)'],
85
+ [!isObject(asset.placement), 'placement', 'placement 必须是对象(精确空间约束由 placement 校验)'],
86
+ [!Array.isArray(asset.clearanceBoxes), 'clearanceBoxes', 'clearanceBoxes 必须是数组'],
87
+ [!Array.isArray(asset.surfaces), 'surfaces', 'surfaces 必须是数组'],
88
+ [!Array.isArray(asset.capabilities), 'capabilities', 'capabilities 必须是数组'],
89
+ [!(asset.approachPoint === null || isObject(asset.approachPoint)), 'approachPoint', 'approachPoint 必须是对象或 null'],
90
+ ];
91
+ for (const [broken, field, message] of problems) if (broken) return { field, message };
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * 读取统一家具目录:先读并完整校验 index.json(失败不发任何元数据请求),合格后按索引
97
+ * 顺序读取各资产 asset.json;任一资产读取或校验失败则整次失败,不静默跳过坏资产。
98
+ * @param {{ fetchJson: (url: string) => Promise<unknown> }} options 调用者注入的异步 JSON 读取器。
99
+ * @returns {Promise<{ ok: boolean,
100
+ * diagnostics: Array<{ code: string, message: string, fieldPath: string }>,
101
+ * catalog: { schemaVersion: 1, assets: object[] } | null }>}
102
+ * 成功时 assets 为按索引顺序的资产深拷贝(调用者改动不污染下次读取,输入对象也不被改写);
103
+ * 失败时 catalog=null,code 取 LIBRARY_INPUT/LIBRARY_READ/LIBRARY_INDEX/LIBRARY_ASSET。
104
+ */
105
+ export async function loadFurnitureCatalog(options) {
106
+ if (!isObject(options) || typeof options.fetchJson !== 'function') {
107
+ return failure('LIBRARY_INPUT', `参数必须是包含函数 fetchJson 的对象(参数实际是 ${typeName(options)},fetchJson 实际是 ${isObject(options) ? typeName(options.fetchJson) : '缺失'},不发请求)`, 'fetchJson');
108
+ }
109
+ const { fetchJson } = options;
110
+
111
+ let index;
112
+ try {
113
+ index = await fetchJson(INDEX_URL);
114
+ } catch (error) {
115
+ return failure('LIBRARY_READ', `读取家具索引失败:${INDEX_URL}(原因:${reasonOf(error)})`, 'index');
116
+ }
117
+
118
+ const validated = validateIndex(index);
119
+ if (validated.problem) return { ok: false, diagnostics: [validated.problem], catalog: null };
120
+
121
+ const assets = [];
122
+ for (const { assetId, metadataRef } of validated.entries) {
123
+ const url = ROOT_PREFIX + metadataRef;
124
+ let asset;
125
+ try {
126
+ asset = await fetchJson(url);
127
+ } catch (error) {
128
+ return failure('LIBRARY_READ', `读取资产说明失败:${url}(原因:${reasonOf(error)})`, `assets[${assetId}]`);
129
+ }
130
+ const problem = validateAsset(asset, assetId, validated.leafTypes);
131
+ if (problem) {
132
+ return failure('LIBRARY_ASSET', `资产 ${assetId} 的${problem.field ? ` ${problem.field} ` : ''}有问题:${problem.message}`, `assets[${assetId}]${problem.field ? '.' + problem.field : ''}`);
133
+ }
134
+ assets.push(deepCopyOfJson(asset)); // 有效 JSON 深拷贝:不改写 fetchJson 的输入,返回目录与输入脱钩
135
+ }
136
+ return { ok: true, diagnostics: [], catalog: { schemaVersion: 1, assets } };
137
+ }
@@ -0,0 +1,110 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "categories": [
4
+ {
5
+ "id": "storage",
6
+ "name": "收纳",
7
+ "types": [
8
+ "shelf",
9
+ "cabinet",
10
+ "chest"
11
+ ]
12
+ },
13
+ {
14
+ "id": "tables",
15
+ "name": "桌台",
16
+ "types": [
17
+ "desk",
18
+ "table",
19
+ "display-stand"
20
+ ]
21
+ },
22
+ {
23
+ "id": "seating-sleeping",
24
+ "name": "坐卧",
25
+ "types": [
26
+ "chair",
27
+ "sofa",
28
+ "bed"
29
+ ]
30
+ },
31
+ {
32
+ "id": "lighting",
33
+ "name": "照明",
34
+ "types": [
35
+ "lamp",
36
+ "floor-lamp",
37
+ "wall-lamp"
38
+ ]
39
+ },
40
+ {
41
+ "id": "decoration",
42
+ "name": "装饰",
43
+ "types": [
44
+ "vase",
45
+ "picture",
46
+ "plant",
47
+ "ornament"
48
+ ]
49
+ },
50
+ {
51
+ "id": "signage",
52
+ "name": "展示标识",
53
+ "types": [
54
+ "notice-board",
55
+ "sign",
56
+ "display-screen"
57
+ ]
58
+ }
59
+ ],
60
+ "assets": [
61
+ {
62
+ "assetId": "fairy-bookshelf-01",
63
+ "metadataRef": "assets/fairy-bookshelf-01/asset.json"
64
+ },
65
+ {
66
+ "assetId": "fairy-desk-01",
67
+ "metadataRef": "assets/fairy-desk-01/asset.json"
68
+ },
69
+ {
70
+ "assetId": "fairy-table-lamp-01",
71
+ "metadataRef": "assets/fairy-table-lamp-01/asset.json"
72
+ },
73
+ {
74
+ "assetId": "fairy-notice-board-01",
75
+ "metadataRef": "assets/fairy-notice-board-01/asset.json"
76
+ },
77
+ {
78
+ "assetId": "ceramic-display-stand-01",
79
+ "metadataRef": "assets/ceramic-display-stand-01/asset.json"
80
+ },
81
+ {
82
+ "assetId": "ceramic-vase-01",
83
+ "metadataRef": "assets/ceramic-vase-01/asset.json"
84
+ },
85
+ {
86
+ "assetId": "fairy-potted-plant-01",
87
+ "metadataRef": "assets/fairy-potted-plant-01/asset.json"
88
+ },
89
+ {
90
+ "assetId": "fairy-book-stack-01",
91
+ "metadataRef": "assets/fairy-book-stack-01/asset.json"
92
+ },
93
+ {
94
+ "assetId": "fairy-bed-01",
95
+ "metadataRef": "assets/fairy-bed-01/asset.json"
96
+ },
97
+ {
98
+ "assetId": "fairy-reading-chair-01",
99
+ "metadataRef": "assets/fairy-reading-chair-01/asset.json"
100
+ },
101
+ {
102
+ "assetId": "ceramic-low-cabinet-01",
103
+ "metadataRef": "assets/ceramic-low-cabinet-01/asset.json"
104
+ },
105
+ {
106
+ "assetId": "ceramic-floor-lamp-01",
107
+ "metadataRef": "assets/ceramic-floor-lamp-01/asset.json"
108
+ }
109
+ ]
110
+ }
@@ -0,0 +1,72 @@
1
+ // 家具模型注册(E4b-1):modelRef → 固定字面量动态 import 的白名单工厂。
2
+ // 安全边界:modelRef 与元数据里的任何字符串都不会被拼成导入地址——每款模型的导入
3
+ // 地址是本文件写死的字面量;未知、null、非字符串引用在导入前拒绝(constructor/
4
+ // toString/__proto__ 等保留键也不是已登记模型),也不执行 JSON 中的代码。
5
+ // 顶层不加载 Three.js、不构造模型;调用 create 才导入对应模型并每次创建独立实例。
6
+ // 模块加载或工厂抛错按真实原因经 Promise 拒绝传递,不回退占位模型、不返回半个句柄。
7
+ // 注册层不缓存模型实例;场景侧用完应调用句柄上的 dispose(接线归 E4b-2 的页面接入)。
8
+
9
+ const MODEL_LOADERS = new Map([
10
+ ['fairy-bookshelf-01', async () => {
11
+ const model = await import('./assets/fairy-bookshelf-01/model.js');
12
+ return model.createFairyBookshelf();
13
+ }],
14
+ ['fairy-desk-01', async () => {
15
+ const model = await import('./assets/fairy-desk-01/model.js');
16
+ return model.createFairyDesk();
17
+ }],
18
+ ['fairy-table-lamp-01', async () => {
19
+ const model = await import('./assets/fairy-table-lamp-01/model.js');
20
+ return model.createFairyTableLamp();
21
+ }],
22
+ ['fairy-notice-board-01', async () => {
23
+ const model = await import('./assets/fairy-notice-board-01/model.js');
24
+ return model.createFairyNoticeBoard();
25
+ }],
26
+ ['ceramic-display-stand-01', async () => {
27
+ const model = await import('./assets/ceramic-display-stand-01/model.js');
28
+ return model.createCeramicDisplayStand();
29
+ }],
30
+ ['ceramic-vase-01', async () => {
31
+ const model = await import('./assets/ceramic-vase-01/model.js');
32
+ return model.createCeramicVase();
33
+ }],
34
+ ['fairy-potted-plant-01', async () => {
35
+ const model = await import('./assets/fairy-potted-plant-01/model.js');
36
+ return model.createFairyPottedPlant();
37
+ }],
38
+ ['fairy-book-stack-01', async () => {
39
+ const model = await import('./assets/fairy-book-stack-01/model.js');
40
+ return model.createFairyBookStack();
41
+ }],
42
+ ['fairy-bed-01', async () => {
43
+ const model = await import('./assets/fairy-bed-01/model.js');
44
+ return model.createFairyBed();
45
+ }],
46
+ ['fairy-reading-chair-01', async () => {
47
+ const model = await import('./assets/fairy-reading-chair-01/model.js');
48
+ return model.createFairyReadingChair();
49
+ }],
50
+ ['ceramic-low-cabinet-01', async () => {
51
+ const model = await import('./assets/ceramic-low-cabinet-01/model.js');
52
+ return model.createCeramicLowCabinet();
53
+ }],
54
+ ['ceramic-floor-lamp-01', async () => {
55
+ const model = await import('./assets/ceramic-floor-lamp-01/model.js');
56
+ return model.createCeramicFloorLamp();
57
+ }],
58
+ ]);
59
+
60
+ /** modelRef 是否已登记为可创建的家具模型;保留键与未知值一律 false,且不触发任何导入。 */
61
+ export function hasFurnitureModel(modelRef) {
62
+ return typeof modelRef === 'string' && MODEL_LOADERS.has(modelRef);
63
+ }
64
+
65
+ /** 按登记创建一件独立模型,返回 { group, dispose };未登记的引用直接拒绝,不执行任何导入。 */
66
+ export function createFurnitureModel(modelRef) {
67
+ if (!hasFurnitureModel(modelRef)) {
68
+ const shown = (() => { try { const text = JSON.stringify(modelRef); return text === undefined ? String(modelRef) : text; } catch { return String(modelRef); } })();
69
+ return Promise.reject(new Error(`未登记的家具模型 modelRef:${shown}——请先在 web/assets/furniture/registry.js 与 index.json 登记`));
70
+ }
71
+ return MODEL_LOADERS.get(modelRef)();
72
+ }
@@ -0,0 +1,68 @@
1
+ /* 家具图鉴页(E9b)专属样式:只作用于 .furniture-page,颜色全部走 style.css 的
2
+ 主题变量(深/浅主题自动跟随);不重排业务页、建筑页或房间页。布局以图片辨识和
3
+ 挑选为先:桌面多列卡片、窄屏单列,长名称与说明换行,不横向撑破页面。 */
4
+
5
+ .furniture-page .picker-bar {
6
+ display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
7
+ margin: 0 0 10px; padding: 10px 14px; border-radius: 10px;
8
+ background: var(--surface-soft); border: 1px solid var(--border);
9
+ }
10
+ .furniture-page .picker-bar p { margin: 0; color: var(--secondary); font-weight: 600; }
11
+ .furniture-page .picker-bar button {
12
+ font: inherit; cursor: pointer; border-radius: 8px; padding: 5px 14px;
13
+ border: 1px solid var(--accent-border); background: var(--accent-bg); color: var(--accent);
14
+ }
15
+ .furniture-page .picker-bar button:hover:not(:disabled) { background: var(--accent-hover); }
16
+ .furniture-page .picker-bar button:disabled { cursor: not-allowed; opacity: .55; }
17
+
18
+ .furniture-page .pick-list-box { margin: 0 0 20px; }
19
+ .furniture-page .pick-list-label { display: block; margin: 0 0 6px; color: var(--muted); font-size: 13px; }
20
+ .furniture-page .pick-list-box textarea {
21
+ width: 100%; resize: vertical; font: inherit; color: var(--text);
22
+ background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px;
23
+ }
24
+ .furniture-page .pick-list-box textarea:focus-visible { outline: 2px solid var(--accent-border); }
25
+ .furniture-page .copy-note { margin: 6px 0 0; font-size: 13px; color: var(--secondary); }
26
+ .furniture-page .copy-note[data-kind="ok"] { color: var(--success); }
27
+ .furniture-page .copy-note[data-kind="bad"] { color: var(--danger); }
28
+
29
+ /* 分类标题:跟 index.categories 顺序;没有分类信息时由脚本退回单一列表。 */
30
+ .furniture-page .furniture-group { margin: 26px 0 10px; }
31
+ .furniture-page .furniture-group h2 { margin: 0 0 4px; font-size: 15px; }
32
+ .furniture-page .furniture-group .group-note { margin: 0 0 10px; font-size: 13px; color: var(--muted); }
33
+
34
+ .furniture-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
35
+ .furniture-card {
36
+ display: flex; flex-direction: column; gap: 8px;
37
+ background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 12px;
38
+ }
39
+ .furniture-card:has(.furniture-pick input:checked) { border-color: var(--accent-border); background: var(--accent-bg); }
40
+
41
+ .furniture-page .furniture-pick { display: flex; align-items: baseline; gap: 8px; cursor: pointer; }
42
+ .furniture-page .furniture-pick input { width: 16px; height: 16px; flex: none; align-self: center; cursor: pointer; }
43
+ .furniture-page .furniture-name { font-weight: 600; color: var(--text); overflow-wrap: anywhere; }
44
+
45
+ /* 缩略图:完整轮廓优先(object-fit: contain),不裁切床/落地灯等比例差异大的资产。 */
46
+ .furniture-page .furniture-thumb {
47
+ display: flex; align-items: center; justify-content: center;
48
+ height: 150px; border-radius: 8px; overflow: hidden;
49
+ background: var(--canvas); border: 1px solid var(--border);
50
+ }
51
+ .furniture-page .furniture-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
52
+ .furniture-page .no-preview { margin: 0; color: var(--muted); font-size: 13px; }
53
+
54
+ .furniture-page .furniture-size { margin: 0; color: var(--secondary); font-size: 13px; }
55
+ .furniture-page .furniture-meta { margin: 0; color: var(--muted); font-size: 13px; }
56
+ .furniture-page .asset-id { margin: 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
57
+ .furniture-page .furniture-card details { font-size: 13px; color: var(--secondary); }
58
+ .furniture-page .furniture-card details summary { cursor: pointer; color: var(--muted); }
59
+ .furniture-page .furniture-card details p { margin: 6px 0 0; overflow-wrap: anywhere; }
60
+
61
+ .furniture-page .furniture-empty {
62
+ padding: 26px; border-radius: 10px; border: 1px dashed var(--border-strong);
63
+ color: var(--muted); text-align: center;
64
+ }
65
+
66
+ @media (max-width: 560px) {
67
+ .furniture-cards { grid-template-columns: 1fr; }
68
+ }