@deepblend/dsh-blender-ui 0.2.20 → 0.3.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 (3) hide show
  1. package/lib/client.js +3044 -114
  2. package/lib/index.js +126 -5
  3. package/package.json +2 -2
package/lib/client.js CHANGED
@@ -108,6 +108,332 @@ window.__ModuleLoader__.load({
108
108
  // `contract/workbench-copy.test.mjs`, so a missing key cannot leave a hole in either direction.
109
109
  const STRINGS = {
110
110
  zh: {
111
+ 'inspection.mismatch': '检查回执与请求的版本、相机或帧不一致,未标记为成功。',
112
+ 'inspection.cameraUnavailable': '{camera}(当前版本不可用)',
113
+ "inspection.title": "固定视角检查",
114
+ "inspection.help": "选择已有相机和帧号。检查图从此版本场景描述独立重建,单独保存;它不是原检查点截图,不替换成品预览,也不修改场景。",
115
+ "inspection.camera": "检查相机",
116
+ "inspection.frame": "检查帧",
117
+ "inspection.mode": "显示方式",
118
+ "inspection.samples": "采样数",
119
+ "inspection.beauty": "材质检查图",
120
+ "inspection.clay": "中性灰模",
121
+ "inspection.render": "生成检查图",
122
+ "inspection.rendering": "正在生成检查图…",
123
+ "inspection.cancel": "取消检查渲染",
124
+ "inspection.cancelled": "检查渲染已取消,已保存场景保持不变。",
125
+ "inspection.invalid": "请选择存在的相机、范围内的整数帧和有效采样数。",
126
+ "inspection.pending": "有未保存的修改。先保存或放弃草稿,再检查已保存场景。",
127
+ "inspection.saved": "检查图已保存:{revision} · {camera} · 帧 {frame}。",
128
+ "inspection.source": "本次来源:{revision};帧范围 {start}–{end}。采样数受 Host 预算限制,以产物实测记录为准。",
129
+ "inspection.gallery": "独立检查图",
130
+ "inspection.revision": "查看版本",
131
+ "inspection.none": "这个版本尚无独立检查图。",
132
+ "inspection.limit": "灰模便于查看轮廓、比例和接缝;它不证明壁厚、封闭性或美术质量。灰模去掉透明、发光、纹理及表面凹凸,导入材质的位移也可能改变渲染外形,不能用它判断原材质效果。",
133
+ "inspection.identity": "{revision} · {camera} · 帧 {frame}",
134
+ "inspection.actual": "实测:{engine} · {width}×{height} · {samples} samples",
135
+ "inspection.open": "打开原图",
136
+ "inspection.history": "当前显示历史版本 {revision} 的检查图,当前项目为 {current}。",
137
+ "guide.title": "创作引导",
138
+ "guide.help": "按需要跳转。每一步打开对应操作;不会自动提交、渲染或判定质量通过。",
139
+ "guide.goal": "目标与参考",
140
+ "guide.route": "选择起点",
141
+ "guide.parts": "部件与结构",
142
+ "guide.form": "灰模与比例",
143
+ "guide.detail": "边缘与细节",
144
+ "guide.appearance": "材质与灯光",
145
+ "guide.delivery": "检查与交付",
146
+ "guide.goalHint": "说明用途、尺寸、材质和交付视角;保存参考图并核对主要评审对象。",
147
+ "guide.routeHint": "配方适合改已有设计;素材库可放入自己的模型。基础项目仍需要补齐结构与细节。",
148
+ "guide.partsHint": "逐个选择实际对象,检查装配与接触。导入部件和槽位以已编译库存为准。",
149
+ "guide.formHint": "生成灰模检查轮廓与比例;发现问题后回到场景树修改对象。",
150
+ "guide.detailHint": "选择已有特写相机和固定帧,检查倒角、接缝与重复细节。没有特写机位时,需先通过 Agent 或场景补丁添加。",
151
+ "guide.appearanceHint": "编辑局部材质或绑定图片、环境图;再查看反射、透明度与接触阴影。灯光编辑可通过 Agent 或高级补丁完成。",
152
+ "guide.deliveryHint": "先在预览与 QA 核对目标和运动覆盖,再选择已保存版本交付。检查图存在不代表作品质量通过。",
153
+ "guide.evidence": "当前 {revision}:{parts} 个对象,{references} 张参考,{images} 张独立检查图。",
154
+ "guide.noProject": "先创建项目。配方示例来自真实渲染;调整后仍需检查你自己的结果。",
155
+
156
+ "assets.bundleTitle": "导入模型资源包",
157
+ "assets.bundleFiles": "选择同目录文件",
158
+ "assets.bundleDirectory": "选择完整目录",
159
+ "assets.bundleHelp": "多选文件只保留文件名,适合同目录依赖。跨目录引用请选包含模型与全部资源的共同父目录;目录内相对路径保持不变。",
160
+ "assets.bundleLimits": "最多 {files} 个文件,合计 {bytes};完成的依赖包连同锁文件也需在此上限内。",
161
+ "assets.bundleEntry": "模型入口文件",
162
+ "assets.bundleChooseEntry": "请选择一个 glTF、GLB 或 OBJ",
163
+ "assets.bundleSelection": "已选择 {files} 个文件,合计 {bytes}。",
164
+ "assets.bundleStart": "上传并保存到素材库",
165
+ "assets.bundleInvalid": "所选路径、数量或总大小无效;请按限制重新选择完整资源。",
166
+ "assets.bundleDirectoryMissing": "浏览器未提供目录相对路径。请使用支持目录选择的浏览器,或多选同目录文件。",
167
+ "assets.bundleProgress": "Host 已接收 {received} / {total};当前文件 {path}。",
168
+ "assets.bundleCompleting": "正在核验依赖并保存到素材库…",
169
+ "assets.bundleSaved": "已保存到素材库,场景版本没有改变。请检查并预览后再应用。",
170
+ "assets.bundleUnused": "{count} 个未被模型引用的文件没有入库。",
171
+ "assets.bundleDependency": "无法解析依赖时,请选择包含模型和全部资源的共同父目录。文件不会被改名或自动改写引用。",
172
+ "assets.bundleCleanup": "无法确认清理结果;请恢复连接后重试取消。运行中的 Host 会在会话到期后清理未完成文件。",
173
+ "assets.bundleUnavailable": "当前服务已无法继续这次上传。可以重新选择文件上传;若提示另一个上传仍在运行,请先处理该上传。",
174
+ "assets.bundleSummary": "资源包:{files} 个文件,共 {bytes}",
175
+ "assets.title": "素材库",
176
+ "assets.open": "打开素材库",
177
+ "assets.refresh": "刷新素材库",
178
+ "assets.help": "上传只保存素材。检查预览后,选择用途并保存新版本。",
179
+ "assets.limits": "文件上限 {bytes};图片 {pixels} 像素、单边 {edge} 像素;预览 {width} × {height}。",
180
+ "assets.upload": "上传本地素材",
181
+ "assets.license": "来源许可(可选,按原文填写)",
182
+ "assets.noLicense": "未提供许可信息",
183
+ "assets.staged": "已暂存,未入本版本",
184
+ "assets.declared": "本版本已声明",
185
+ "assets.inspect": "检查并预览",
186
+ "assets.choose": "用于当前场景",
187
+ "assets.cancel": "取消上传 / 检查",
188
+ "assets.working": "正在{action}…",
189
+ "assets.uploading": "上传素材",
190
+ "assets.inspecting": "检查素材",
191
+ "assets.cancelled": "已取消,场景版本没有改变。",
192
+ "assets.invalidFile": "请选择 Host 文件上限内的 GLB、PNG、JPEG、HDR 或 EXR。",
193
+ "assets.needLibrary": "请先打开素材库以读取实际上传限制。",
194
+ "assets.needPreview": "请先检查并预览此素材。",
195
+ "assets.invalid": "素材操作无效:{field}",
196
+ "assets.conflict": "场景已有更新。素材草稿已保留,请放弃草稿后重新选择用途。",
197
+ "assets.apply": "保存到场景并预览",
198
+ "assets.reset": "放弃素材草稿",
199
+ "assets.saved": "素材已应用到 {revision}。",
200
+ "assets.model": "放入场景(保留原材质)",
201
+ "assets.image": "绑定图片材质",
202
+ "assets.environment": "用作环境照明",
203
+ "assets.dimensions": "素材尺寸(米)",
204
+ "assets.toneMapped": "这是环境照明效果示例,经过色调映射;应用使用原始 HDR / EXR 数据。",
205
+ "assets.modelHelp": "位置为素材原点,缩放相对原素材。保留原材质、层级与源对象,不改相机。",
206
+ "assets.entityId": "新对象标识",
207
+ "assets.scale": "统一缩放倍数",
208
+ "assets.target": "应用对象",
209
+ "assets.channel": "图片通道",
210
+ "assets.channel.baseColor": "颜色",
211
+ "assets.channel.roughness": "粗糙度",
212
+ "assets.channel.metallic": "金属度",
213
+ "assets.channel.normal": "法线(OpenGL)",
214
+ "assets.channel.alpha": "透明度",
215
+ "assets.channel.emissionColor": "发光颜色",
216
+ "assets.uv": "UV 名称(空值使用活动 UV)",
217
+ "assets.tile": "UV 平铺",
218
+ "assets.offset": "UV 偏移",
219
+ "assets.scalarChannel": "读取图片通道",
220
+ "assets.normalStrength": "法线强度",
221
+ "assets.newMaterial": "创建新的基础材质并覆盖所选范围",
222
+ "assets.nativeHelp": "原生节点没有可编辑的公共定义;需明确创建新材质覆盖所选范围。",
223
+ "assets.replaceTexture": "用图片替换此材质的程序化纹理",
224
+ "assets.localAnimated": "来源材质含动画,局部复制会丢失动画。请选择共享修改或明确创建新材质。",
225
+ "assets.animatedChannel": "该通道由动画驱动,不能在此覆盖。",
226
+ "assets.imageHelp": "保留其他通道;缺少 UV 时保存会明确失败。共享修改影响该材质所有使用者。",
227
+ "assets.environmentHelp": "环境图改变照明、反射与背景;保留其他世界设置和相机。",
228
+ "assets.strength": "环境强度",
229
+ "assets.rotation": "环境旋转(度)",
230
+ "assets.inspectionWarnings": "素材检查提示",
231
+ "assets.material": "当前公共材质",
232
+ "assets.partDefault": "部件默认材质(已有槽覆盖优先)",
233
+ "assets.busyDraft": "请先完成当前素材操作或放弃草稿。",
234
+ "assets.inspected": "已检查",
235
+ "assets.pending": "尚未检查",
236
+ "assets.emissionHelp": "发光图使用现有强度;强度为零时不会发光。",
237
+ 'brief.title': "制作目标与参考图片",
238
+ 'brief.goal': "本版本制作目标",
239
+ 'brief.upload': "上传 PNG / JPEG",
240
+ 'brief.uploadHelp': "最多 4 张,每张不超过 8 MiB。上传只保存素材;点击保存目标才写入新版本。",
241
+ 'brief.save': "保存目标与参考",
242
+ 'brief.reset': "放弃草稿,重新载入",
243
+ 'brief.saved': "已保存到 {revision};没有启动渲染。",
244
+ 'brief.saving': "正在保存版本…",
245
+ 'brief.uploading': "正在上传图片…",
246
+ 'brief.conflict': "场景已有更新。目标草稿已保留,请重新载入后再保存。",
247
+ 'brief.pending': "有未保存的编辑;评审仅使用已保存版本。",
248
+ 'brief.clean': "目标、主要评审对象与参考已保存。",
249
+ 'brief.subject': "主要评审对象",
250
+ 'brief.subjectAuto': "自动选择",
251
+ 'brief.subjectEnvironment': "{id}(环境对象)",
252
+ 'brief.subjectUnavailableOption': "{id}(当前不可用)",
253
+ 'brief.subjectHelp': "选择影响构图、曝光和遮挡测量;不会改变相机。点击保存后生效。",
254
+ 'brief.subjectSaved': "已保存版本的解析对象",
255
+ 'brief.subjectUnknown': "尚无可用对象",
256
+ 'brief.subjectUnavailable': "当前选择不可用于评审:{reason}",
257
+ 'brief.subjectShortcut': "设为主要评审对象",
258
+ 'brief.subjectShortcutHint': "已加入本项目目标草稿;请在项目页保存。",
259
+ 'brief.subjectMode': "选择方式",
260
+ 'brief.subjectExplicit': "明确指定",
261
+ 'brief.subjectFixed': "本次固定对象",
262
+ 'brief.subjectReason': "选择依据",
263
+ 'brief.subjectLegacy': "旧记录只保存对象 ID,选择来源未知。",
264
+ 'brief.label': "图片说明",
265
+ 'brief.notes': "观察重点(可选)",
266
+ 'brief.purposes': "用于判断",
267
+ 'brief.remove': "移除参考",
268
+ 'brief.invalid': "目标或参考无效:说明不能为空,每张至少选择一个用途。",
269
+ 'brief.invalidFile': "请选择不超过 8 MiB 的 PNG 或 JPEG 图片。",
270
+ 'brief.limit': "每个版本最多保存 4 张参考图片。",
271
+ 'brief.duplicate': "这张图片已在当前参考列表中。",
272
+ 'brief.review': "评审已保存版本",
273
+ 'brief.autofix': "按参考自动修正",
274
+ 'brief.reviewCost': "点击后会渲染 640 × 480 / 16 采样预览并调用视觉模型;自动修正可能创建或回退版本。",
275
+ 'brief.iterations': "最多修正轮数",
276
+ 'brief.reviewing': "正在渲染并评审…",
277
+ 'brief.fixing': "正在进行有限轮次修正…",
278
+ 'brief.reviewDone': "评审完成:{revision}。美术判断请查看 QA。",
279
+ 'brief.fixDone': "修正结束:{revision} · {reason}。这不代表已通过参考验收。",
280
+ 'brief.actualReferences': "本次评审核验的参考 ID",
281
+ 'brief.referenceUnknown': "没有已记录的参考使用证据。",
282
+ 'brief.referenceStatus': "参考证据状态",
283
+ "photo.title": "摄影:相机与灯光",
284
+ "photo.help": "修改作为本项目当前修订的草稿保存。点击“保存并预览”先保存整个修订,再渲染选定相机和帧的材质检查图。",
285
+ "photo.camera": "编辑与检查相机",
286
+ "photo.light": "编辑灯光",
287
+ "photo.lens": "焦距(mm)",
288
+ "photo.position": "位置(m)",
289
+ "photo.rotation": "旋转(°)",
290
+ "photo.aim": "朝向方式",
291
+ "photo.free": "自由旋转",
292
+ "photo.entity": "对象原点",
293
+ "photo.point": "指定点",
294
+ "photo.target": "瞄准对象原点",
295
+ "photo.targetPoint": "指定点(m)",
296
+ "photo.aimHelp": "瞄准对象的根原点或指定坐标;此设置在场景构造时定向。动画驱动的通道保持锁定。",
297
+ "photo.aimLocked": "此相机的旋转由动画驱动,瞄准设置已锁定。",
298
+ "photo.rotationLocked": "朝向由目标计算,旋转输入已锁定。",
299
+ "photo.power": "功率(W)",
300
+ "photo.sunEnergy": "太阳强度(W/m²)",
301
+ "photo.color": "颜色 RGB(0–1)",
302
+ "photo.areaSize": "方形灯边长(m)",
303
+ "photo.softSize": "柔光半径(m)",
304
+ "photo.addLight": "新增面积补光灯",
305
+ "photo.newLight": "新面积灯(待保存)",
306
+ "photo.save": "保存并预览",
307
+ "photo.saving": "正在保存修订…",
308
+ "photo.previewing": "修订已保存,正在渲染检查图…",
309
+ "photo.saved": "已保存 {revision}。",
310
+ "photo.failed": "修订 {revision} 已保存;检查图片未完成。{message}",
311
+ "photo.ready": "检查图:{revision} · {camera} · 第 {frame} 帧",
312
+ "photo.retry": "重试已保存修订的预览",
313
+ "photo.restore": "恢复摄影编辑前的场景",
314
+ "photo.restoreHelp": "恢复整个场景;仅当当前修订仍是 {revision} 时可用。",
315
+ "photo.reset": "放弃草稿,读取当前修订",
316
+ "photo.conflict": "草稿基于 {before},当前为 {current}。草稿已保留,请放弃草稿后重新编辑。",
317
+ "photo.invalid": "输入无效:{fields}",
318
+ "photo.base": "草稿来源:{revision}",
319
+ "photo.changes": "此修订修改了 {fields}。",
320
+ "photo.cameraChanges": "相机",
321
+ "photo.lightChanges": "灯光",
322
+ "photo.restoreFailed": "恢复失败,已保留摄影记录:{message}",
323
+ 'editor.title': '编辑对象',
324
+ 'editor.unknownSettings': '这张预览缺少实测渲染设置,无法比较编辑差异。',
325
+ 'editor.missingBaseline': '没有同相机、同帧和同渲染设置的编辑前预览,暂时无法比较差异。',
326
+ 'editor.choose': '选择一个对象,调整后应用并查看真实预览。',
327
+ 'editor.position': '位置(mm)',
328
+ 'editor.rotation': '旋转(°)',
329
+ 'editor.geometry': '形状与轮廓',
330
+ 'editor.localDimensions': '这里是对象局部尺寸;已有缩放 {scale} 保留,最终尺寸还会受缩放影响。',
331
+ 'editor.roundedRequired': '圆角盒保留固有倒角,可调整宽度与分段。',
332
+ 'editor.operations': '有序几何操作',
333
+ 'editor.material': '表面材质',
334
+ 'editor.apply': '应用并预览',
335
+ 'editor.reset': '放弃草稿,重新载入',
336
+ 'editor.restore': '恢复编辑前整个场景',
337
+ 'editor.conflict': '场景已更新。草稿保留,请重新载入后编辑。',
338
+ 'editor.pending': '尚未应用;输入不会自动启动渲染。',
339
+ 'editor.clean': '尚无修改',
340
+ 'editor.animated': '动画控制的通道已禁用。',
341
+ 'editor.invalid': '{field} 的值无效。',
342
+ 'editor.size': '尺寸(mm)',
343
+ 'editor.radius': '半径(mm)',
344
+ 'editor.handledCup': '带把手杯体',
345
+ 'editor.wallThickness': '杯壁厚度(mm)',
346
+ 'editor.baseThickness': '杯底厚度(mm)',
347
+ 'editor.footRound': '底部圆角(mm)',
348
+ 'editor.handleRadius': '把手截面半径(mm)',
349
+ 'editor.handleLower': '下连接高度(mm)',
350
+ 'editor.handleUpper': '上连接高度(mm)',
351
+ 'editor.rootRadius': '连接根部半径(mm)',
352
+ 'editor.rootLength': '连接过渡长度(mm)',
353
+ 'editor.rootTension': '连接曲面展开程度(1–2.5)',
354
+ 'editor.sectionSegments': '杯口与把手截面分段',
355
+ 'editor.handleSegments': '把手弧线分段',
356
+ 'editor.rootSegments': '连接过渡分段',
357
+ 'editor.wallRows': '杯壁纵向分段',
358
+ 'editor.cupAdvanced': '连接细节与网格分段',
359
+ 'editor.cupHelp': '杯壁、把手和连接处共同决定可用尺寸;应用时会检查实际网格。高级参数中的连接曲面展开程度:1 保留原形,较大值展开过渡,最大 2.5。尺寸超出支持范围时会保留当前版本。',
360
+ 'editor.depth': '高度(mm)',
361
+ 'editor.majorRadius': '主半径(mm)',
362
+ 'editor.minorRadius': '截面半径(mm)',
363
+ 'editor.segments': '周向分段',
364
+ 'editor.ringCount': '环分段',
365
+ 'editor.curveResolution': '曲线分段',
366
+ 'editor.bevelResolution': '截面分段',
367
+ 'editor.bevel': '倒角',
368
+ 'editor.width': '倒角宽度(mm)',
369
+ 'editor.bevelSegments': '倒角分段',
370
+ 'editor.miterInner': '内角处理',
371
+ 'editor.miterArc': '圆弧',
372
+ 'editor.miterSharp': '尖角',
373
+ 'editor.miterHelp': '尖角常适合曲面上的开孔边缘。它控制倒角内角的交会方式,不是通用的光滑融合。',
374
+ 'editor.profile': '截面点:半径 / 高度(mm)',
375
+ 'editor.path': '路径点:X / Y / Z(mm)',
376
+ 'editor.closed': '连接首尾',
377
+ 'editor.cap': '封闭端面',
378
+ 'editor.interpolation': '路径连接',
379
+ 'editor.poly': '直线',
380
+ 'editor.bezier': '平滑曲线',
381
+ 'editor.addPoint': '添加点',
382
+ 'editor.remove': '删除',
383
+ 'editor.up': '上移',
384
+ 'editor.down': '下移',
385
+ 'editor.add': '添加 {name}',
386
+ 'editor.solidify': '加厚',
387
+ 'editor.array': '阵列',
388
+ 'editor.mirror': '镜像',
389
+ 'editor.boolean': '布尔运算',
390
+ 'editor.thickness': '厚度(mm)',
391
+ 'editor.offset': '厚度偏移(−1 到 1)',
392
+ 'editor.spacing': '每份偏移(mm)',
393
+ 'editor.count': '数量',
394
+ 'editor.axis': '镜像轴',
395
+ 'editor.merge': '合并镜像接缝',
396
+ 'editor.angle': '倒角角度阈值(°)',
397
+ 'editor.operand': '运算对象',
398
+ 'editor.union': '合并',
399
+ 'editor.difference': '挖空',
400
+ 'editor.intersect': '保留交集',
401
+ 'editor.local': '仅当前目标(复制材质)',
402
+ 'editor.shared': '同步修改共享材质',
403
+ 'editor.affected': '共享修改影响:{targets}',
404
+ 'editor.color': '基础色',
405
+ 'editor.roughness': '粗糙度',
406
+ 'editor.texture.surface': "表面纹理",
407
+ 'editor.texture.pattern': "纹理图案",
408
+ 'editor.texture.noTexture': "无纹理",
409
+ 'editor.texture.noise': "细颗粒",
410
+ 'editor.texture.wave': "条纹",
411
+ 'editor.texture.voronoi': "蜂窝颗粒",
412
+ 'editor.texture.coordinates': "表面方向",
413
+ 'editor.texture.objectCoordinates': "沿物体空间",
414
+ 'editor.texture.uvCoordinates': "沿曲面 UV 布局",
415
+ 'editor.texture.uvMap': "UV 层名称(留空用渲染层)",
416
+ 'editor.texture.density': "纹理密度",
417
+ 'editor.texture.stretch': "方向密度倍率",
418
+ 'editor.texture.detail': "细节层次",
419
+ 'editor.texture.distortion': "纹理扰动",
420
+ 'editor.texture.bump': "凹凸强度",
421
+ 'editor.texture.roughnessVariation': "粗糙度变化",
422
+ 'editor.texture.colorVariation': "颜色变化",
423
+ 'editor.texture.textureHelp': "密度越大,纹理越细。方向倍率决定延伸方向;UV 取决于模型已有的展开布局。先用小幅凹凸,再应用并检查高光。",
424
+ 'editor.texture.textureLocked': "此材质由图片控制、使用不支持的着色器,或包含无法局部复制的动画。图片替换请使用素材面板。",
425
+ 'editor.keepMaterial': '保留当前材质',
426
+ 'editor.mapDriven': '图片或动画控制的通道已禁用。动画材质无法局部复制;选择共享修改可编辑未被动画驱动的通道。',
427
+ 'editor.assetWhole': '整个导入资产',
428
+ 'editor.assetPart': '部件默认材质(已有槽覆盖优先)',
429
+ 'editor.assetSlot': '指定部件的原始槽',
430
+ 'editor.assetNotice': '整体覆盖会替换来源材质;已有部件和槽覆盖仍优先。部件选择来自已编译资产清单。',
431
+ 'editor.noParts': '没有已编译部件清单,暂时只能设置整体材质。',
432
+ 'editor.part': '来源部件',
433
+ 'editor.slot': '原始材质槽',
434
+ 'editor.stackNotice': '操作按顺序重建。几何变化可能影响壁厚、接触和装配,请查看真实预览。',
435
+ 'editor.advanced': '高级:编辑场景补丁 JSON',
436
+ 'editor.unavailable': '此对象的完整几何定义尚不可用。',
111
437
  'tab.projects': '项目',
112
438
  'tab.scene': '场景树',
113
439
  'tab.preview': '预览对比',
@@ -121,9 +447,23 @@ window.__ModuleLoader__.load({
121
447
  'preview.isArtifact': '。预览是产物:替换同一路径上的旧图,不产生新的 revision。',
122
448
  'jobs.cancelRequested': '取消已请求,但进程仍在',
123
449
  'projects.empty': '这个工作区还没有项目。',
124
- 'projects.create': '新建项目(写操作经 Host)',
450
+ 'projects.create': '新建项目',
125
451
  'projects.titlePlaceholder': '标题,例如 watch-commercial',
126
452
  'projects.goal': '目标(可选)',
453
+ 'recipes.heading': '从作品配方开始',
454
+ 'recipes.blank': '空白项目',
455
+ 'recipes.select': '使用这个配方',
456
+ 'recipes.selected': '已选择',
457
+ 'recipes.parameters': '调整配方',
458
+ 'recipes.previewNote': '示例图展示默认参数。创建后会渲染你的设置,耗时取决于设备。',
459
+ 'recipes.createPreview': '创建并生成预览',
460
+ 'recipes.creatingPreview': '正在创建并渲染…',
461
+ 'recipes.stale': '选中的配方已更新或不可用,请重新选择。',
462
+ 'recipes.range': '范围:{min}–{max}',
463
+ 'recipes.invalid': '请输入范围内的有效数值。',
464
+ 'recipes.reset': '恢复配方默认值',
465
+ 'recipes.license': '作者与许可',
466
+ 'recipes.unavailable': '部分配方无法载入;可继续使用下方可用的配方。',
127
467
  'projects.creating': '创建中…',
128
468
  'projects.createButton': '创建',
129
469
  'scene.currentRevision': '当前 revision',
@@ -155,13 +495,22 @@ window.__ModuleLoader__.load({
155
495
  'preview.notRenderedHere': '这个 revision 还没有由面板渲过预览(上方的「渲染预览」会生成第一张)。',
156
496
  'preview.rendering': '渲染中…',
157
497
  'preview.render': '渲染预览',
158
- 'preview.compare': '比较:',
498
+ 'preview.latest': '最新预览',
499
+ 'preview.compare': '查看:',
159
500
  'preview.lastVsThis': '上一次 vs 本次渲染',
160
501
  'preview.twoRevisions': '两个 revision',
161
502
  'preview.structuralDiff': '看结构差异',
162
503
  'preview.identical': '结构完全相同',
163
504
  'jobs.empty': '还没有渲染任务。',
164
505
  'jobs.deliveryVerified': '交付已校验',
506
+ 'jobs.provenanceUnknown': '帧来源未知',
507
+ 'jobs.provenanceUnchecked': '{count} 帧未在本次状态读取中复核;交付时的记录见交付清单',
508
+ 'jobs.provenanceMixed': '混合配置:{details}',
509
+ 'jobs.provenanceConfig': '帧配置:{details}',
510
+ 'jobs.provenanceGroup': '{engine} · {resolution} · samples {samples} · 帧 {frames}(共 {count} 帧)',
511
+ 'jobs.provenanceMore': '另有 {count} 组配置,详见交付清单',
512
+ 'jobs.provenanceUnknownCount': '{count} 帧来源未知',
513
+ 'jobs.provenanceMissing': '{count} 帧尚未完成',
165
514
  'jobs.cancel': '取消',
166
515
  'jobs.resume': '继续渲染',
167
516
  'jobs.startDelivery': '启动一次交付渲染(写操作经 Host)',
@@ -178,7 +527,15 @@ window.__ModuleLoader__.load({
178
527
  'qa.noErrors': '没有技术错误。',
179
528
  'qa.notices': '编译器 notices',
180
529
  'visual.title': '视觉评审(测量 + 模型 finding,两个来源不合并)',
181
- 'visual.score': '分数',
530
+ 'visual.score': '技术分',
531
+ 'visual.artistic': '美术评审',
532
+ 'visual.artistic.pass': '通过',
533
+ 'visual.artistic.needs_work': '需要改善',
534
+ 'visual.artistic.unassessable': '尚无法判断',
535
+ 'visual.geometry': '几何与比例',
536
+ 'visual.materials': '材质',
537
+ 'visual.lighting': '灯光',
538
+ 'visual.goalFit': '目标吻合度',
182
539
  'common.yes': '通过',
183
540
  'common.yesShort': '是',
184
541
  'common.noShort': '否',
@@ -187,6 +544,7 @@ window.__ModuleLoader__.load({
187
544
  'visual.views': '视角数',
188
545
  'visual.reviewer': '审查器',
189
546
  'visual.called': '已调用',
547
+ 'visual.incomplete': '未完成',
190
548
  'visual.notCalled': '未调用',
191
549
  'visual.noFindings': '测量没有发现问题。',
192
550
  'visual.reviewerSilent': '审查器没有报告 finding。',
@@ -209,7 +567,7 @@ window.__ModuleLoader__.load({
209
567
  'jobs.started': '任务 {jobId} 已启动({frames} 帧)',
210
568
  'projects.updated': '当前 {revision} · 更新于 {when}',
211
569
  'qa.errorCount': '技术错误 {count}',
212
- 'visual.scoreIs': '视觉评分 {score}',
570
+ 'visual.scoreIs': '技术评分 {score}',
213
571
  'visual.notRun': '未跑视觉评审',
214
572
  'host.observedResponse': '观察到的响应:{detail}。',
215
573
  'host.stale': '本进程里的 blenderUi 比磁盘上的包旧:/deepblend/{route} 没有被这一版的宿主回答',
@@ -227,6 +585,23 @@ window.__ModuleLoader__.load({
227
585
  'scene.material': '材质 {id}',
228
586
  'scene.track': '{target} · {property} · {keys} 关键帧',
229
587
  'preview.renderedAt': ' · 渲染于 {when}',
588
+ 'preview.actualPixels': '实际渲染 {width}×{height} · 采样 {samples} · {engine}',
589
+ 'preview.actualCamera': '相机 {camera} · 帧 {frame} · 焦距 {lens} mm',
590
+ 'preview.actualColor': '{transform} · 曝光 {exposure}',
591
+ 'preview.sheetPixels': '拼图图片 {width}×{height};以下为各视图实际设置',
592
+ 'preview.settingsMissing': '部分实际设置未记录,重新渲染可补齐。',
593
+ 'preview.conditionsDifferent': '已记录的比较条件有差异:{fields}。请结合实际设置判断画面变化。',
594
+ 'preview.conditionsUnknown': '部分实际设置未记录,无法确认比较条件一致。',
595
+ 'preview.conditionsMatching': '已记录的渲染与摄影条件一致。',
596
+ 'preview.condition.layout': '图片布局或视图组成',
597
+ 'preview.condition.resolution': '分辨率',
598
+ 'preview.condition.samples': '采样',
599
+ 'preview.condition.engine': '渲染引擎',
600
+ 'preview.condition.camera': '相机参数',
601
+ 'preview.condition.frame': '帧',
602
+ 'preview.condition.color': '色彩或曝光',
603
+ 'preview.condition.transparency': '背景透明',
604
+ 'preview.condition.timing': '动画时间范围',
230
605
  'preview.lastRenderOf': '上一次渲染 · {revision}',
231
606
  'preview.thisRenderOf': '本次渲染 · {revision}',
232
607
  'preview.changeCount': '{count} 处结构变化',
@@ -246,6 +621,332 @@ window.__ModuleLoader__.load({
246
621
  'revisions.title': '版本({count})',
247
622
  },
248
623
  en: {
624
+ 'inspection.mismatch': 'The inspection receipt does not match the requested revision, camera or frame; success was not recorded.',
625
+ 'inspection.cameraUnavailable': '{camera} (unavailable in this revision)',
626
+ "inspection.title": "Inspect a fixed view",
627
+ "inspection.help": "Choose a saved camera and frame. Inspection images are rebuilt from this revision’s scene description and stored separately. They are not screenshots of the original checkpoint, and do not replace product previews or change the scene.",
628
+ "inspection.camera": "Camera",
629
+ "inspection.frame": "Frame",
630
+ "inspection.mode": "Appearance",
631
+ "inspection.samples": "Samples",
632
+ "inspection.beauty": "Material inspection",
633
+ "inspection.clay": "Neutral clay",
634
+ "inspection.render": "Render inspection",
635
+ "inspection.rendering": "Rendering inspection…",
636
+ "inspection.cancel": "Cancel inspection",
637
+ "inspection.cancelled": "Inspection rendering cancelled. The saved scene is unchanged.",
638
+ "inspection.invalid": "Choose an existing camera, an integer frame within the scene range and a valid sample count.",
639
+ "inspection.pending": "There are unsaved changes. Save or discard them before inspecting the saved scene.",
640
+ "inspection.saved": "Inspection saved: {revision} · {camera} · frame {frame}.",
641
+ "inspection.source": "Source: {revision}; frames {start}–{end}. Host budgets may reduce samples; artifact measurements are authoritative.",
642
+ "inspection.gallery": "Inspection images",
643
+ "inspection.revision": "Evidence revision",
644
+ "inspection.none": "This revision has no inspection images.",
645
+ "inspection.limit": "Clay helps inspect silhouette, proportions and joins. It does not prove thickness, watertightness or artistic quality. Clay removes transparency, emission, textures and surface bumps; displacement from imported materials may also change rendered geometry. Use material images to inspect the original appearance.",
646
+ "inspection.identity": "{revision} · {camera} · frame {frame}",
647
+ "inspection.actual": "Measured: {engine} · {width}×{height} · {samples} samples",
648
+ "inspection.open": "Open image",
649
+ "inspection.history": "Showing inspection images from historical revision {revision}; the project is at {current}.",
650
+ "guide.title": "Creation guide",
651
+ "guide.help": "Jump to the work you need. These links open the relevant controls; they do not submit, render or judge quality automatically.",
652
+ "guide.goal": "Goal and references",
653
+ "guide.route": "Choose a starting point",
654
+ "guide.parts": "Parts and structure",
655
+ "guide.form": "Clay and proportions",
656
+ "guide.detail": "Edges and details",
657
+ "guide.appearance": "Materials and lighting",
658
+ "guide.delivery": "Review and delivery",
659
+ "guide.goalHint": "Describe use, dimensions, materials and delivery views. Save references and check the review subject.",
660
+ "guide.routeHint": "Recipes adapt an existing design; the asset library brings in your model. A basic project still needs structure and detail.",
661
+ "guide.partsHint": "Select real objects and inspect assembly and contact. Imported parts and slots come from compiled inventory.",
662
+ "guide.formHint": "Render clay to inspect silhouette and proportions; return to the scene tree to edit the object.",
663
+ "guide.detailHint": "Choose an existing close-up camera and a fixed frame to inspect bevels, joins and repeated details. Add missing close-up cameras through the agent or a scene patch.",
664
+ "guide.appearanceHint": "Edit a local material or bind images and an environment, then inspect reflections, transparency and contact shadows. Use the agent or an advanced patch to edit lights.",
665
+ "guide.deliveryHint": "Check the goal and motion coverage in Preview and QA before delivering a saved revision. Having inspection images does not establish artistic quality.",
666
+ "guide.evidence": "Current {revision}: {parts} objects, {references} references and {images} inspection images.",
667
+ "guide.noProject": "Create a project first. Recipe examples are real renders; inspect your own result after changing them.",
668
+
669
+ "assets.bundleTitle": "Import a model resource bundle",
670
+ "assets.bundleFiles": "Choose files in one folder",
671
+ "assets.bundleDirectory": "Choose a complete folder",
672
+ "assets.bundleHelp": "Multiple file selection preserves file names only, for dependencies in one folder. For cross-folder references, select the common parent folder containing the model and all resources; relative paths are preserved.",
673
+ "assets.bundleLimits": "Up to {files} files and {bytes} in total; the completed dependency bundle including its lock must fit the same limit.",
674
+ "assets.bundleEntry": "Model entrypoint",
675
+ "assets.bundleChooseEntry": "Choose a glTF, GLB or OBJ",
676
+ "assets.bundleSelection": "Selected {files} files, {bytes} in total.",
677
+ "assets.bundleStart": "Upload and save to library",
678
+ "assets.bundleInvalid": "Invalid paths, count or total size. Select the complete resources within the limits.",
679
+ "assets.bundleDirectoryMissing": "The browser did not provide folder-relative paths. Use a browser that supports folder selection, or choose files in one folder.",
680
+ "assets.bundleProgress": "Host received {received} / {total}; current file: {path}.",
681
+ "assets.bundleCompleting": "Checking dependencies and saving to the library\u2026",
682
+ "assets.bundleSaved": "Saved to the library; the scene revision is unchanged. Inspect a preview before applying.",
683
+ "assets.bundleUnused": "{count} files not referenced by the model were left out of the library.",
684
+ "assets.bundleDependency": "If dependencies cannot be resolved, select their common parent folder. Files are not renamed and references are not rewritten.",
685
+ "assets.bundleCleanup": "Cleanup could not be confirmed. Reconnect and retry cancellation. A running Host removes unfinished files when the session expires.",
686
+ "assets.bundleUnavailable": "This upload session is unavailable on the current Host. You can select files and upload again; if another upload is still running, resolve it first.",
687
+ "assets.bundleSummary": "Bundle: {files} files, {bytes}",
688
+ "assets.title": "Asset library",
689
+ "assets.open": "Open asset library",
690
+ "assets.refresh": "Refresh assets",
691
+ "assets.help": "Uploads only store assets. Inspect a preview, choose its use, then save a revision.",
692
+ "assets.limits": "File limit {bytes}; images {pixels} pixels, {edge} pixels per edge; preview {width} × {height}.",
693
+ "assets.upload": "Upload a local asset",
694
+ "assets.license": "Source license (optional, as provided)",
695
+ "assets.noLicense": "License information not provided",
696
+ "assets.staged": "Staged; not in this revision",
697
+ "assets.declared": "Declared in this revision",
698
+ "assets.inspect": "Inspect and preview",
699
+ "assets.choose": "Use in this scene",
700
+ "assets.cancel": "Cancel upload / inspection",
701
+ "assets.working": "{action}…",
702
+ "assets.uploading": "Uploading asset",
703
+ "assets.inspecting": "Inspecting asset",
704
+ "assets.cancelled": "Cancelled; the scene revision is unchanged.",
705
+ "assets.invalidFile": "Choose a GLB, PNG, JPEG, HDR or EXR within the Host file limit.",
706
+ "assets.needLibrary": "Open the asset library first to read the actual limits.",
707
+ "assets.needPreview": "Inspect and preview this asset first.",
708
+ "assets.invalid": "Invalid asset action: {field}",
709
+ "assets.conflict": "The scene changed. Your asset draft is retained; discard it and choose its use again.",
710
+ "assets.apply": "Save to scene and preview",
711
+ "assets.reset": "Discard asset draft",
712
+ "assets.saved": "Asset applied in {revision}.",
713
+ "assets.model": "Place in scene (preserve materials)",
714
+ "assets.image": "Bind image material",
715
+ "assets.environment": "Use for environment lighting",
716
+ "assets.dimensions": "Asset dimensions (metres)",
717
+ "assets.toneMapped": "This tone-mapped preview shows environment lighting; applying uses original HDR / EXR data.",
718
+ "assets.modelHelp": "Position refers to the source origin and scale is relative to the source. Preserve its materials, hierarchy and objects; keep the camera.",
719
+ "assets.entityId": "New object ID",
720
+ "assets.scale": "Uniform scale",
721
+ "assets.target": "Target object",
722
+ "assets.channel": "Image map",
723
+ "assets.channel.baseColor": "Base color",
724
+ "assets.channel.roughness": "Roughness",
725
+ "assets.channel.metallic": "Metallic",
726
+ "assets.channel.normal": "Normal (OpenGL)",
727
+ "assets.channel.alpha": "Alpha",
728
+ "assets.channel.emissionColor": "Emission color",
729
+ "assets.uv": "UV name (empty for active UV)",
730
+ "assets.tile": "UV tiling",
731
+ "assets.offset": "UV offset",
732
+ "assets.scalarChannel": "Read image channel",
733
+ "assets.normalStrength": "Normal strength",
734
+ "assets.newMaterial": "Create a new basic material and override this target",
735
+ "assets.nativeHelp": "Native nodes have no editable public definition; explicitly create a replacement material for this target.",
736
+ "assets.replaceTexture": "Replace this material’s procedural texture with image maps",
737
+ "assets.localAnimated": "A local copy would lose source material animation. Choose shared editing or explicitly create a new material.",
738
+ "assets.animatedChannel": "This channel is animated and cannot be overridden here.",
739
+ "assets.imageHelp": "Other maps are preserved. Missing UVs cause an explicit save failure. Shared editing affects all material users.",
740
+ "assets.environmentHelp": "Environment images change lighting, reflections and background; other world settings and the camera are preserved.",
741
+ "assets.strength": "Environment strength",
742
+ "assets.rotation": "Environment rotation (degrees)",
743
+ "assets.inspectionWarnings": "Inspection notes",
744
+ "assets.material": "Current public material",
745
+ "assets.partDefault": "Part default material (existing slot overrides take priority)",
746
+ "assets.busyDraft": "Finish this asset action or discard its draft first.",
747
+ "assets.inspected": "Inspected",
748
+ "assets.pending": "Not inspected",
749
+ "assets.emissionHelp": "The map uses existing emission strength; zero strength produces no emission.",
750
+ 'brief.title': "Design goal and reference images",
751
+ 'brief.goal': "Goal for this revision",
752
+ 'brief.upload': "Upload PNG / JPEG",
753
+ 'brief.uploadHelp': "Up to 4 images, 8 MiB each. Uploading stores an asset; Save brief attaches it to a new revision.",
754
+ 'brief.save': "Save brief and references",
755
+ 'brief.reset': "Discard draft and reload",
756
+ 'brief.saved': "Saved to {revision}; no render was started.",
757
+ 'brief.saving': "Saving revision…",
758
+ 'brief.uploading': "Uploading images…",
759
+ 'brief.conflict': "The scene changed. Your brief draft is preserved; reload it before saving.",
760
+ 'brief.pending': "There are unsaved edits; reviews use the saved revision only.",
761
+ 'brief.clean': "The goal, main review subject and references are saved.",
762
+ 'brief.subject': "Main review subject",
763
+ 'brief.subjectAuto': "Choose automatically",
764
+ 'brief.subjectEnvironment': "{id} (environment object)",
765
+ 'brief.subjectUnavailableOption': "{id} (currently unavailable)",
766
+ 'brief.subjectHelp': "This selects the object measured for composition, exposure and occlusion. It does not move the camera. Save to apply.",
767
+ 'brief.subjectSaved': "Resolved subject of the saved revision",
768
+ 'brief.subjectUnknown': "No available subject",
769
+ 'brief.subjectUnavailable': "The selected subject cannot be reviewed: {reason}",
770
+ 'brief.subjectShortcut': "Set as main review subject",
771
+ 'brief.subjectShortcutHint': "Added to this project's brief draft; save it on the Projects page.",
772
+ 'brief.subjectMode': "Selection mode",
773
+ 'brief.subjectExplicit': "Explicit selection",
774
+ 'brief.subjectFixed': "Fixed for this review",
775
+ 'brief.subjectReason': "Selection reason",
776
+ 'brief.subjectLegacy': "This older record contains only the object ID; the selection source is unknown.",
777
+ 'brief.label': "Image label",
778
+ 'brief.notes': "What to observe (optional)",
779
+ 'brief.purposes': "Use as reference for",
780
+ 'brief.remove': "Remove reference",
781
+ 'brief.invalid': "Invalid brief or reference: labels are required, and each image needs at least one purpose.",
782
+ 'brief.invalidFile': "Choose a PNG or JPEG image no larger than 8 MiB.",
783
+ 'brief.limit': "A revision can have at most 4 reference images.",
784
+ 'brief.duplicate': "This image is already in the reference list.",
785
+ 'brief.review': "Review saved revision",
786
+ 'brief.autofix': "Correct using references",
787
+ 'brief.reviewCost': "Clicking renders 640 × 480 previews at 16 samples and calls the vision model. Corrections may create or restore revisions.",
788
+ 'brief.iterations': "Maximum correction rounds",
789
+ 'brief.reviewing': "Rendering and reviewing…",
790
+ 'brief.fixing': "Running bounded corrections…",
791
+ 'brief.reviewDone': "Review completed for {revision}. See QA for the artistic assessment.",
792
+ 'brief.fixDone': "Correction ended at {revision}: {reason}. This does not establish a reference match.",
793
+ 'brief.actualReferences': "Verified reference IDs for this review",
794
+ 'brief.referenceUnknown': "No reference-use evidence was recorded.",
795
+ 'brief.referenceStatus': "Reference evidence status",
796
+ "photo.title": "Photography: cameras and lights",
797
+ "photo.help": "Edits are drafts for this project and revision. Save and preview first saves the revision, then renders the selected camera and frame in beauty mode.",
798
+ "photo.camera": "Camera to edit and inspect",
799
+ "photo.light": "Light to edit",
800
+ "photo.lens": "Lens (mm)",
801
+ "photo.position": "Position (m)",
802
+ "photo.rotation": "Rotation (°)",
803
+ "photo.aim": "Aim mode",
804
+ "photo.free": "Free rotation",
805
+ "photo.entity": "Object origin",
806
+ "photo.point": "Specified point",
807
+ "photo.target": "Aim at object origin",
808
+ "photo.targetPoint": "Specified point (m)",
809
+ "photo.aimHelp": "Aim at the object root origin or explicit coordinates when the scene is built. Animated channels stay locked.",
810
+ "photo.aimLocked": "Camera rotation is animated; aim controls are locked.",
811
+ "photo.rotationLocked": "The target determines rotation; rotation controls are locked.",
812
+ "photo.power": "Power (W)",
813
+ "photo.sunEnergy": "Sun strength (W/m²)",
814
+ "photo.color": "RGB color (0–1)",
815
+ "photo.areaSize": "Square side length (m)",
816
+ "photo.softSize": "Soft radius (m)",
817
+ "photo.addLight": "Add area fill light",
818
+ "photo.newLight": "New area light (unsaved)",
819
+ "photo.save": "Save and preview",
820
+ "photo.saving": "Saving revision…",
821
+ "photo.previewing": "Revision saved. Rendering inspection…",
822
+ "photo.saved": "Saved {revision}.",
823
+ "photo.failed": "Revision {revision} is saved; the inspection image did not finish. {message}",
824
+ "photo.ready": "Inspection: {revision} · {camera} · frame {frame}",
825
+ "photo.retry": "Retry preview of saved revision",
826
+ "photo.restore": "Restore scene before photography edits",
827
+ "photo.restoreHelp": "Restores the whole scene only while {revision} is still current.",
828
+ "photo.reset": "Discard draft and read current revision",
829
+ "photo.conflict": "Draft is based on {before}; current revision is {current}. Your draft is retained. Discard it before editing the current revision.",
830
+ "photo.invalid": "Invalid input: {fields}",
831
+ "photo.base": "Draft source: {revision}",
832
+ "photo.changes": "This revision changed {fields}.",
833
+ "photo.cameraChanges": "cameras",
834
+ "photo.lightChanges": "lighting",
835
+ "photo.restoreFailed": "Restore failed; the photography record is retained: {message}",
836
+ 'editor.title': 'Edit object',
837
+ 'editor.unknownSettings': 'Measured render settings are missing. This preview cannot establish the edit difference.',
838
+ 'editor.missingBaseline': 'No before preview has the same camera, frame and measured render settings. The edit difference is unavailable.',
839
+ 'editor.choose': 'Select an object, adjust its settings, then apply and view a real preview.',
840
+ 'editor.position': 'Position (mm)',
841
+ 'editor.rotation': 'Rotation (°)',
842
+ 'editor.geometry': 'Shape and profile',
843
+ 'editor.localDimensions': 'These are local dimensions. Existing scale {scale} is preserved and affects the final size.',
844
+ 'editor.roundedRequired': 'Rounded boxes retain their bevel. Adjust its width and segments below.',
845
+ 'editor.operations': 'Ordered geometry operations',
846
+ 'editor.material': 'Surface material',
847
+ 'editor.apply': 'Apply and preview',
848
+ 'editor.reset': 'Discard draft and reload',
849
+ 'editor.restore': 'Restore the entire scene before this edit',
850
+ 'editor.conflict': 'The scene changed. Your draft is kept; reload before editing again.',
851
+ 'editor.pending': 'Not applied yet. Typing does not start a render.',
852
+ 'editor.clean': 'No changes yet',
853
+ 'editor.animated': 'Channels driven by animation are disabled.',
854
+ 'editor.invalid': 'Invalid value for {field}.',
855
+ 'editor.size': 'Size (mm)',
856
+ 'editor.radius': 'Radius (mm)',
857
+ 'editor.handledCup': 'Handled cup',
858
+ 'editor.wallThickness': 'Wall thickness (mm)',
859
+ 'editor.baseThickness': 'Base thickness (mm)',
860
+ 'editor.footRound': 'Foot rounding (mm)',
861
+ 'editor.handleRadius': 'Handle section radius (mm)',
862
+ 'editor.handleLower': 'Lower attachment height (mm)',
863
+ 'editor.handleUpper': 'Upper attachment height (mm)',
864
+ 'editor.rootRadius': 'Attachment root radius (mm)',
865
+ 'editor.rootLength': 'Attachment transition length (mm)',
866
+ 'editor.rootTension': 'Attachment transition spread (1–2.5)',
867
+ 'editor.sectionSegments': 'Lip and handle section segments',
868
+ 'editor.handleSegments': 'Handle arc segments',
869
+ 'editor.rootSegments': 'Attachment transition segments',
870
+ 'editor.wallRows': 'Vertical wall segments',
871
+ 'editor.cupAdvanced': 'Attachment details and mesh segments',
872
+ 'editor.cupHelp': 'Wall, handle and attachment dimensions constrain one another. Applying an edit checks the actual mesh. Advanced transition spread: 1 keeps the original shape; larger values up to 2.5 spread the transition. Unsupported dimensions keep the current revision.',
873
+ 'editor.depth': 'Height (mm)',
874
+ 'editor.majorRadius': 'Major radius (mm)',
875
+ 'editor.minorRadius': 'Section radius (mm)',
876
+ 'editor.segments': 'Radial segments',
877
+ 'editor.ringCount': 'Ring segments',
878
+ 'editor.curveResolution': 'Curve resolution',
879
+ 'editor.bevelResolution': 'Section resolution',
880
+ 'editor.bevel': 'Bevel',
881
+ 'editor.width': 'Bevel width (mm)',
882
+ 'editor.bevelSegments': 'Bevel segments',
883
+ 'editor.miterInner': 'Inner corner treatment',
884
+ 'editor.miterArc': 'Arc',
885
+ 'editor.miterSharp': 'Sharp',
886
+ 'editor.miterHelp': 'Sharp often suits openings on curved surfaces. It controls how inner bevel corners meet; it is not a general smooth blend.',
887
+ 'editor.profile': 'Profile points: radius / height (mm)',
888
+ 'editor.path': 'Path points: X / Y / Z (mm)',
889
+ 'editor.closed': 'Connect first and last',
890
+ 'editor.cap': 'Close ends',
891
+ 'editor.interpolation': 'Path connection',
892
+ 'editor.poly': 'Straight',
893
+ 'editor.bezier': 'Smooth curve',
894
+ 'editor.addPoint': 'Add point',
895
+ 'editor.remove': 'Remove',
896
+ 'editor.up': 'Move up',
897
+ 'editor.down': 'Move down',
898
+ 'editor.add': 'Add {name}',
899
+ 'editor.solidify': 'Solidify',
900
+ 'editor.array': 'Array',
901
+ 'editor.mirror': 'Mirror',
902
+ 'editor.boolean': 'Boolean',
903
+ 'editor.thickness': 'Thickness (mm)',
904
+ 'editor.offset': 'Thickness offset (−1 to 1)',
905
+ 'editor.spacing': 'Offset per copy (mm)',
906
+ 'editor.count': 'Count',
907
+ 'editor.axis': 'Mirror axis',
908
+ 'editor.merge': 'Merge mirror seam',
909
+ 'editor.angle': 'Bevel angle threshold (°)',
910
+ 'editor.operand': 'Operand object',
911
+ 'editor.union': 'Union',
912
+ 'editor.difference': 'Difference',
913
+ 'editor.intersect': 'Intersection',
914
+ 'editor.local': 'Only this target (copy material)',
915
+ 'editor.shared': 'Update shared material',
916
+ 'editor.affected': 'Shared changes affect: {targets}',
917
+ 'editor.color': 'Base color',
918
+ 'editor.roughness': 'Roughness',
919
+ 'editor.texture.surface': "Surface texture",
920
+ 'editor.texture.pattern': "Pattern",
921
+ 'editor.texture.noTexture': "No texture",
922
+ 'editor.texture.noise': "Fine grain",
923
+ 'editor.texture.wave': "Bands",
924
+ 'editor.texture.voronoi': "Cellular grain",
925
+ 'editor.texture.coordinates': "Surface direction",
926
+ 'editor.texture.objectCoordinates': "Object space",
927
+ 'editor.texture.uvCoordinates': "Surface UV layout",
928
+ 'editor.texture.uvMap': "UV map name (blank uses render map)",
929
+ 'editor.texture.density': "Pattern density",
930
+ 'editor.texture.stretch': "Directional density multipliers",
931
+ 'editor.texture.detail': "Detail",
932
+ 'editor.texture.distortion': "Distortion",
933
+ 'editor.texture.bump': "Bump strength",
934
+ 'editor.texture.roughnessVariation': "Roughness variation",
935
+ 'editor.texture.colorVariation': "Color variation",
936
+ 'editor.texture.textureHelp': "Higher density makes finer grain. Direction multipliers control its orientation; UV follows the model’s existing layout. Start with subtle bump, then apply and inspect the highlights.",
937
+ 'editor.texture.textureLocked': "This material uses image maps, an unsupported shader, or animation that cannot be copied locally. Use the asset panel to replace image maps.",
938
+ 'editor.keepMaterial': 'Keep current material',
939
+ 'editor.mapDriven': 'Image- or animation-driven channels are disabled. Animated materials cannot be copied locally; shared edits can change their undriven channels.',
940
+ 'editor.assetWhole': 'Entire imported asset',
941
+ 'editor.assetPart': 'Part default (existing slot overrides take priority)',
942
+ 'editor.assetSlot': 'One original slot of one part',
943
+ 'editor.assetNotice': 'A whole-asset override replaces source materials. Existing part and slot overrides still take priority. Parts come from the compiled asset inventory.',
944
+ 'editor.noParts': 'No compiled part inventory is available; only whole-asset material assignment is offered.',
945
+ 'editor.part': 'Source part',
946
+ 'editor.slot': 'Original material slot',
947
+ 'editor.stackNotice': 'Operations rebuild in order. Geometry changes can affect wall thickness, contact and assembly; inspect the real preview.',
948
+ 'editor.advanced': 'Advanced: edit ScenePatch JSON',
949
+ 'editor.unavailable': 'The full geometry definition is not available for this object.',
249
950
  'tab.projects': 'Projects',
250
951
  'tab.scene': 'Scene',
251
952
  'tab.preview': 'Preview',
@@ -259,9 +960,23 @@ window.__ModuleLoader__.load({
259
960
  'preview.isArtifact': '. A preview is an artifact: it replaces the old image at the same path and creates no revision.',
260
961
  'jobs.cancelRequested': 'cancellation requested, but the process is still there',
261
962
  'projects.empty': 'This workspace has no projects yet.',
262
- 'projects.create': 'New project (the write goes through the Host)',
963
+ 'projects.create': 'New project',
263
964
  'projects.titlePlaceholder': 'title, e.g. watch-commercial',
264
965
  'projects.goal': 'Goal (optional)',
966
+ 'recipes.heading': 'Start from a recipe',
967
+ 'recipes.blank': 'Blank project',
968
+ 'recipes.select': 'Use this recipe',
969
+ 'recipes.selected': 'Selected',
970
+ 'recipes.parameters': 'Customize recipe',
971
+ 'recipes.previewNote': 'Images show default parameters. Creating a project renders your settings; time depends on your device.',
972
+ 'recipes.createPreview': 'Create and preview',
973
+ 'recipes.creatingPreview': 'Creating and rendering…',
974
+ 'recipes.stale': 'The selected recipe changed or is unavailable. Please select it again.',
975
+ 'recipes.range': 'Range: {min}–{max}',
976
+ 'recipes.invalid': 'Enter a valid number within the range.',
977
+ 'recipes.reset': 'Restore recipe defaults',
978
+ 'recipes.license': 'Author and license',
979
+ 'recipes.unavailable': 'Some recipes could not be loaded. Available recipes are shown below.',
265
980
  'projects.creating': 'Creating…',
266
981
  'projects.createButton': 'Create',
267
982
  'scene.currentRevision': 'current revision',
@@ -293,13 +1008,22 @@ window.__ModuleLoader__.load({
293
1008
  'preview.notRenderedHere': 'This revision has no preview rendered from this panel yet (the “Render preview” button above makes the first one).',
294
1009
  'preview.rendering': 'Rendering…',
295
1010
  'preview.render': 'Render preview',
296
- 'preview.compare': 'Compare:',
1011
+ 'preview.latest': 'Latest preview',
1012
+ 'preview.compare': 'View:',
297
1013
  'preview.lastVsThis': 'last vs this render',
298
1014
  'preview.twoRevisions': 'two revisions',
299
1015
  'preview.structuralDiff': 'structural diff',
300
1016
  'preview.identical': 'structurally identical',
301
1017
  'jobs.empty': 'No render jobs yet.',
302
1018
  'jobs.deliveryVerified': 'delivery verified',
1019
+ 'jobs.provenanceUnknown': 'Frame provenance unknown',
1020
+ 'jobs.provenanceUnchecked': '{count} frames not rechecked in this status read; see the delivery manifest for publication-time evidence',
1021
+ 'jobs.provenanceMixed': 'Mixed configurations: {details}',
1022
+ 'jobs.provenanceConfig': 'Frame configurations: {details}',
1023
+ 'jobs.provenanceGroup': '{engine} · {resolution} · samples {samples} · frames {frames} ({count} total)',
1024
+ 'jobs.provenanceMore': '{count} more configurations; see the delivery manifest',
1025
+ 'jobs.provenanceUnknownCount': '{count} frames with unknown provenance',
1026
+ 'jobs.provenanceMissing': '{count} frames not completed',
303
1027
  'jobs.cancel': 'Cancel',
304
1028
  'jobs.resume': 'Resume render',
305
1029
  'jobs.startDelivery': 'Start a delivery render (the write goes through the Host)',
@@ -316,7 +1040,15 @@ window.__ModuleLoader__.load({
316
1040
  'qa.noErrors': 'No technical errors.',
317
1041
  'qa.notices': 'compiler notices',
318
1042
  'visual.title': 'Visual review (measurements + model findings; the two sources are not merged)',
319
- 'visual.score': 'score',
1043
+ 'visual.score': 'technical score',
1044
+ 'visual.artistic': 'Artistic review',
1045
+ 'visual.artistic.pass': 'Passed',
1046
+ 'visual.artistic.needs_work': 'Needs work',
1047
+ 'visual.artistic.unassessable': 'Unassessable',
1048
+ 'visual.geometry': 'Geometry and proportions',
1049
+ 'visual.materials': 'Materials',
1050
+ 'visual.lighting': 'Lighting',
1051
+ 'visual.goalFit': 'Goal fit',
320
1052
  'common.yes': 'yes',
321
1053
  'common.yesShort': 'yes',
322
1054
  'common.noShort': 'no',
@@ -325,6 +1057,7 @@ window.__ModuleLoader__.load({
325
1057
  'visual.views': 'views',
326
1058
  'visual.reviewer': 'reviewer',
327
1059
  'visual.called': 'called',
1060
+ 'visual.incomplete': 'review did not complete',
328
1061
  'visual.notCalled': 'not called',
329
1062
  'visual.noFindings': 'The measurements found nothing.',
330
1063
  'visual.reviewerSilent': 'The reviewer reported no findings.',
@@ -347,7 +1080,7 @@ window.__ModuleLoader__.load({
347
1080
  'jobs.started': 'Job {jobId} started ({frames} frames)',
348
1081
  'projects.updated': 'current {revision} · updated {when}',
349
1082
  'qa.errorCount': '{count} technical errors',
350
- 'visual.scoreIs': 'visual score {score}',
1083
+ 'visual.scoreIs': 'technical score {score}',
351
1084
  'visual.notRun': 'no visual review yet',
352
1085
  'host.observedResponse': 'the response observed: {detail}.',
353
1086
  'host.stale': 'the blenderUi in this process is older than the package on disk: /deepblend/{route} was not answered by this host',
@@ -365,6 +1098,23 @@ window.__ModuleLoader__.load({
365
1098
  'scene.material': 'material {id}',
366
1099
  'scene.track': '{target} · {property} · {keys} keyframes',
367
1100
  'preview.renderedAt': ' · rendered {when}',
1101
+ 'preview.actualPixels': 'Rendered {width}×{height} · samples {samples} · {engine}',
1102
+ 'preview.actualCamera': 'Camera {camera} · frame {frame} · lens {lens} mm',
1103
+ 'preview.actualColor': '{transform} · exposure {exposure}',
1104
+ 'preview.sheetPixels': 'Sheet image {width}×{height}; constituent render settings below',
1105
+ 'preview.settingsMissing': 'Some measured settings are missing; render again to record them.',
1106
+ 'preview.conditionsDifferent': 'Recorded comparison conditions differ: {fields}. Consider these settings when judging image changes.',
1107
+ 'preview.conditionsUnknown': 'Some measured settings are missing; matching comparison conditions cannot be confirmed.',
1108
+ 'preview.conditionsMatching': 'Recorded render and camera conditions match.',
1109
+ 'preview.condition.layout': 'layout or view composition',
1110
+ 'preview.condition.resolution': 'resolution',
1111
+ 'preview.condition.samples': 'samples',
1112
+ 'preview.condition.engine': 'engine',
1113
+ 'preview.condition.camera': 'camera',
1114
+ 'preview.condition.frame': 'frame',
1115
+ 'preview.condition.color': 'color or exposure',
1116
+ 'preview.condition.transparency': 'transparency',
1117
+ 'preview.condition.timing': 'animation timing',
368
1118
  'preview.lastRenderOf': 'last render · {revision}',
369
1119
  'preview.thisRenderOf': 'this render · {revision}',
370
1120
  'preview.changeCount': '{count} structural change(s)',
@@ -386,11 +1136,11 @@ window.__ModuleLoader__.load({
386
1136
  }
387
1137
 
388
1138
  /** The active locale id, lowercased and stripped of any region, or `en` when it is not ours. */
389
- const LOCALE = (() => {
1139
+ function readLocale() {
390
1140
  const named = typeof document === 'undefined' ? '' : String(document.documentElement?.lang ?? '')
391
1141
  const key = named.toLowerCase().split('-')[0]
392
1142
  return Object.prototype.hasOwnProperty.call(STRINGS, key) ? key : 'en'
393
- })()
1143
+ }
394
1144
 
395
1145
  /**
396
1146
  * One string, in the active locale, with `{name}` placeholders filled from `params`.
@@ -400,13 +1150,66 @@ window.__ModuleLoader__.load({
400
1150
  * rather than a silent blank.
401
1151
  */
402
1152
  const t = (key, params) => {
403
- const table = STRINGS[LOCALE] ?? STRINGS.en
1153
+ const table = STRINGS[readLocale()] ?? STRINGS.en
404
1154
  const raw = table[key] ?? STRINGS.en[key] ?? key
405
1155
  if (params === undefined) return raw
406
1156
  return raw.replace(/\{(\w+)\}/g, (whole, name) => (name in params ? String(params[name]) : whole))
407
1157
  }
408
1158
  // #endregion strings
409
1159
 
1160
+ function jobProvenanceText(job) {
1161
+ const display = job.provenanceDisplay
1162
+ if (!display) return t('jobs.provenanceUnknown')
1163
+ if (display.uncheckedCount) return t('jobs.provenanceUnchecked', { count: display.uncheckedCount })
1164
+ const parts = display.groups.map(group => t('jobs.provenanceGroup', {
1165
+ ...group, engine: group.engine ?? '?', resolution: group.resolution ?? '?',
1166
+ samples: group.samples ?? '?', count: group.frameCount,
1167
+ }))
1168
+ if (display.moreGroups) parts.push(t('jobs.provenanceMore', { count: display.moreGroups }))
1169
+ if (display.unknownCount) parts.push(t('jobs.provenanceUnknownCount', { count: display.unknownCount }))
1170
+ if (display.missingCount) parts.push(t('jobs.provenanceMissing', { count: display.missingCount }))
1171
+ if (!parts.length) return t('jobs.provenanceUnknown')
1172
+ const details = parts.join('; ')
1173
+ return display.mixed ? t('jobs.provenanceMixed', { details }) : t('jobs.provenanceConfig', { details })
1174
+ }
1175
+
1176
+ /** Observe the platform's document language only while a UI surface is mounted. */
1177
+ const localeListeners = new Set()
1178
+ let localeObserver = null
1179
+ let observedLocale = null
1180
+ function subscribeLocale(listener) {
1181
+ localeListeners.add(listener)
1182
+ if (localeObserver === null && typeof document !== 'undefined' && document.documentElement) {
1183
+ const Observer = document.defaultView?.MutationObserver
1184
+ ?? (typeof MutationObserver === 'undefined' ? null : MutationObserver)
1185
+ if (Observer) {
1186
+ observedLocale = readLocale()
1187
+ localeObserver = new Observer(() => {
1188
+ const current = readLocale()
1189
+ if (current === observedLocale) return
1190
+ observedLocale = current
1191
+ for (const notify of [...localeListeners]) notify()
1192
+ })
1193
+ localeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
1194
+ }
1195
+ }
1196
+ return () => {
1197
+ localeListeners.delete(listener)
1198
+ if (localeListeners.size === 0) {
1199
+ localeObserver?.disconnect()
1200
+ localeObserver = null
1201
+ observedLocale = null
1202
+ }
1203
+ }
1204
+ }
1205
+
1206
+ const artisticStatusLabel = status => status === 'pass' ? t('visual.artistic.pass')
1207
+ : status === 'needs_work' ? t('visual.artistic.needs_work') : t('visual.artistic.unassessable')
1208
+ const artisticDimensionLabel = dimension => ({
1209
+ geometry: t('visual.geometry'), materials: t('visual.materials'),
1210
+ lighting: t('visual.lighting'), goalFit: t('visual.goalFit'),
1211
+ })[dimension] ?? dimension
1212
+
410
1213
  // §B Vocabulary
411
1214
  // =========================================================================
412
1215
 
@@ -415,24 +1218,24 @@ window.__ModuleLoader__.load({
415
1218
  /** Label shown by the sidebar entry, the settings nav and the panel title. */
416
1219
  const PANEL_LABEL = 'Blender'
417
1220
  /** The host API this half was written against; a mismatch is a deployment state. */
418
- const EXPECTED_HOST_API = 4
1221
+ const EXPECTED_HOST_API = 6
419
1222
  /** Polling cadence while something is live; M3 writes progress once a second. */
420
1223
  const POLL_LIVE_MS = 1500
421
1224
  /** Polling cadence when nothing is running. */
422
1225
  const POLL_IDLE_MS = 8000
423
1226
 
424
1227
  const VIEWS = [
425
- { id: 'projects', label: t('tab.projects') },
426
- { id: 'scene', label: t('tab.scene') },
427
- { id: 'preview', label: t('tab.preview') },
428
- { id: 'jobs', label: t('tab.jobs') },
1228
+ { id: 'projects', get label() { return t('tab.projects') } },
1229
+ { id: 'scene', get label() { return t('tab.scene') } },
1230
+ { id: 'preview', get label() { return t('tab.preview') } },
1231
+ { id: 'jobs', get label() { return t('tab.jobs') } },
429
1232
  { id: 'qa', label: 'QA' },
430
- { id: 'revisions', label: t('tab.revisions') },
1233
+ { id: 'revisions', get label() { return t('tab.revisions') } },
431
1234
  ]
432
1235
 
433
1236
  /** Every DeepBlend wire tool name this package draws a card for. */
434
1237
  const TOOL_CARD_KEYS = [
435
- 'blender_capabilities', 'blender_project_create', 'blender_project_get',
1238
+ 'blender_capabilities', 'blender_recipe_list', 'blender_project_create', 'blender_project_get',
436
1239
  'blender_scene_get', 'blender_scene_patch', 'blender_preview_render',
437
1240
  'blender_scene_validate', 'blender_revision_restore', 'blender_asset_ingest',
438
1241
  'blender_preview_views', 'blender_visual_review',
@@ -690,6 +1493,534 @@ window.__ModuleLoader__.load({
690
1493
  return Number.isFinite(parsed) ? parsed : undefined
691
1494
  }
692
1495
 
1496
+ // Editable drafts contain complete public definitions. UI controls change
1497
+ // only named fields; whole generator/stack replacements retain everything else.
1498
+ const editorClone = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value))
1499
+ // Clearing a number stores null; only omitted fields receive the display/validation default.
1500
+ const editorNumberDefault = (value, fallback) => value === undefined ? fallback : value
1501
+ const editorEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b)
1502
+ const editorKey = (projectId, entityId) => JSON.stringify([projectId, entityId])
1503
+ const EDITOR_AXES = ['x', 'y', 'z']
1504
+ const GENERATOR_FIELDS = {
1505
+ cube: ['size'], rounded_box: ['size'], plane: ['size'],
1506
+ uv_sphere: ['radius', 'segments', 'ringCount'], cylinder: ['radius', 'depth', 'segments'],
1507
+ cone: ['radius', 'depth', 'segments'], torus: ['majorRadius', 'minorRadius', 'segments', 'ringCount'],
1508
+ handled_cup: ['radius', 'height', 'wallThickness', 'baseThickness', 'handleRadius', 'handleLower', 'handleUpper',
1509
+ 'footRound', 'rootRadius', 'rootLength', 'rootTension', 'segments', 'sectionSegments', 'handleSegments', 'rootSegments', 'wallRows'],
1510
+ lathe: ['segments'], curve: ['radius', 'curveResolution', 'bevelResolution'],
1511
+ }
1512
+ const EDITOR_INTEGERS = { segments: [3, 512], ringCount: [3, 512], curveResolution: [1, 64], bevelResolution: [0, 16] }
1513
+ const CUP_ADVANCED_FIELDS = ['footRound', 'rootRadius', 'rootLength', 'rootTension', 'segments', 'sectionSegments', 'handleSegments', 'rootSegments', 'wallRows']
1514
+ const CUP_INTEGERS = { segments: [64, 256, 4], sectionSegments: [32, 96, 4], handleSegments: [24, 128, 4], rootSegments: [8, 48, 1], wallRows: [16, 64, 1] }
1515
+ const generatorIntegerBounds = (generator, key) => generator.shape === 'handled_cup' ? CUP_INTEGERS[key] : EDITOR_INTEGERS[key]
1516
+ let editorSequence = 0
1517
+
1518
+ function editorTrackLocked(draft, kind, id, property) {
1519
+ return draft.tracks.some(track => track.targetKind === kind && track.targetId === id
1520
+ && (track.property === property || (property === 'baseColor' && track.property?.startsWith('baseColor.'))))
1521
+ }
1522
+
1523
+ function createEditorDraft(scene, projectId, entityId) {
1524
+ const entity = scene.nodes.entities.find(item => item.id === entityId)
1525
+ if (!entity) return null
1526
+ const original = editorClone(entity)
1527
+ original.transform ||= { location: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1] }
1528
+ original.modifiers ||= []
1529
+ original.materialBindings ||= []
1530
+ const material = scene.nodes.materials.find(item => item.id === entity.materialId)
1531
+ let cloneId
1532
+ do { cloneId = `ui-material-${Date.now().toString(36)}-${++editorSequence}` }
1533
+ while (scene.nodes.materials.some(item => item.id === cloneId))
1534
+ return { projectId, entityId, baseRevision: scene.revision, original, entity: editorClone(original),
1535
+ materials: editorClone(scene.nodes.materials), entities: editorClone(scene.nodes.entities),
1536
+ tracks: editorClone(scene.nodes.animationTracks || []), cloneId,
1537
+ material: { id: material?.id || '', definition: editorClone(material?.definition || null),
1538
+ scope: 'local', target: 'entity', partId: '', slotIndex: '' } }
1539
+ }
1540
+
1541
+ function editorMaterialOriginal(draft) {
1542
+ return draft.materials.find(material => material.id === draft.material.id)?.definition || null
1543
+ }
1544
+
1545
+ function editorMaterialLocked(draft, property) {
1546
+ const material = draft.material.definition
1547
+ return !material || !['principled', 'glass'].includes(material.shader)
1548
+ || Boolean(material.images?.[property]) || editorTrackLocked(draft, 'material', draft.material.id, property)
1549
+ || (draft.material.scope === 'local' && draft.tracks.some(track => track.targetKind === 'material' && track.targetId === draft.material.id))
1550
+ }
1551
+
1552
+ function editorEffectiveMaterialId(draft) {
1553
+ const target = draft.material
1554
+ if (target.target === 'entity') return draft.original.materialId || ''
1555
+ if (!target.partId || (target.target === 'slot' && target.slotIndex === '')) return ''
1556
+ const bindings = draft.original.materialBindings || []
1557
+ const exact = target.target === 'slot' ? bindings.find(item => item.partId === target.partId && item.slotIndex === Number(target.slotIndex)) : null
1558
+ const part = bindings.find(item => item.partId === target.partId && item.slotIndex === undefined)
1559
+ const inventory = (draft.original.assetParts || []).find(item => item.partId === target.partId)
1560
+ const slots = inventory?.materialSlots || []
1561
+ const slotIds = new Set(slots.map(item => item.materialId))
1562
+ return exact?.materialId || part?.materialId || draft.original.materialId
1563
+ || (target.target === 'slot' ? slots.find(item => item.index === Number(target.slotIndex))?.materialId
1564
+ : slots.length > 0 && slotIds.size === 1 && slots[0].materialId ? slots[0].materialId : '') || ''
1565
+ }
1566
+
1567
+ function editorSelectMaterial(draft, id) {
1568
+ draft.material.id = id
1569
+ draft.material.definition = editorClone(draft.materials.find(item => item.id === id)?.definition || null)
1570
+ }
1571
+
1572
+ function editorTextureLocked(draft) {
1573
+ const material = draft.material.definition
1574
+ return !material || !['principled', 'glass'].includes(material.shader)
1575
+ || Object.keys(material.images || {}).length > 0
1576
+ || (draft.material.scope === 'local' && draft.tracks.some(track => track.targetKind === 'material' && track.targetId === draft.material.id))
1577
+ }
1578
+
1579
+ function editorErrors(draft) {
1580
+ if (!draft) return []
1581
+ const errors = [], bad = field => errors.push(t('editor.invalid', { field }))
1582
+ const scalar = (value, field, min = -Infinity, max = Infinity, integer = false) => {
1583
+ if (!Number.isFinite(value) || value < min || value > max || (integer && !Number.isInteger(value))) bad(field)
1584
+ }
1585
+ for (const key of ['location', 'rotationEuler']) for (let axis = 0; axis < 3; axis++) {
1586
+ scalar(draft.entity.transform[key]?.[axis], `${key}.${EDITOR_AXES[axis]}`)
1587
+ if (!editorEqual(draft.entity.transform[key]?.[axis], draft.original.transform[key]?.[axis])
1588
+ && editorTrackLocked(draft, 'entity', draft.entityId, `${key}.${EDITOR_AXES[axis]}`)) bad(`${key}.${EDITOR_AXES[axis]}`)
1589
+ }
1590
+ const generator = draft.entity.generator
1591
+ if (generator) {
1592
+ for (const key of GENERATOR_FIELDS[generator.shape] || []) {
1593
+ const bounds = generatorIntegerBounds(generator, key)
1594
+ if (bounds?.[2] && generator[key] % bounds[2]) bad(key)
1595
+ scalar(generator[key], key, key === 'rootTension' ? 1 : bounds ? bounds[0] : Number.MIN_VALUE,
1596
+ key === 'rootTension' ? 2.5 : bounds ? bounds[1] : Infinity, Boolean(bounds))
1597
+ }
1598
+ if (generator.bevel) {
1599
+ if (generator.bevel.width !== undefined) scalar(generator.bevel.width, 'bevel.width', Number.MIN_VALUE)
1600
+ if (generator.bevel.segments !== undefined) scalar(generator.bevel.segments, 'bevel.segments', 1, 16, true)
1601
+ }
1602
+ const key = generator.shape === 'lathe' ? 'profile' : generator.shape === 'curve' ? 'path' : null
1603
+ if (key) {
1604
+ const points = generator[key] || [], size = key === 'profile' ? 2 : 3
1605
+ const minimum = generator.closedProfile || generator.pathClosed ? 3 : 2
1606
+ if (points.length < minimum || points.length > 128) bad(key)
1607
+ points.forEach((point, index) => {
1608
+ if (point.length !== size) bad(`${key}.${index}`)
1609
+ point.forEach((value, axis) => scalar(value, `${key}.${index}.${axis}`, key === 'profile' && axis === 0 ? 0 : -Infinity))
1610
+ })
1611
+ }
1612
+ }
1613
+ if (draft.entity.modifiers.length > 8) bad('modifiers')
1614
+ if (!editorEqual(draft.entity.modifiers, draft.original.modifiers)
1615
+ && draft.entity.kind !== 'generator' && !(draft.entity.kind === 'asset-instance' && draft.original.assetParts?.length === 1)) bad('modifiers')
1616
+ draft.entity.modifiers.forEach((modifier, index) => {
1617
+ const at = `modifiers.${index}`
1618
+ if (modifier.type === 'bevel') {
1619
+ if (modifier.miterInner !== undefined && !['arc', 'sharp'].includes(modifier.miterInner)) bad(`${at}.miterInner`)
1620
+ scalar(modifier.width, `${at}.width`, Number.MIN_VALUE)
1621
+ if (modifier.segments !== undefined) scalar(modifier.segments, `${at}.segments`, 1, 16, true)
1622
+ if (modifier.angle !== undefined) scalar(modifier.angle, `${at}.angle`, 0, 180)
1623
+ } else if (modifier.type === 'solidify') {
1624
+ scalar(modifier.thickness, `${at}.thickness`)
1625
+ if (modifier.thickness === 0) bad(`${at}.thickness`)
1626
+ if (modifier.offset !== undefined) scalar(modifier.offset, `${at}.offset`, -1, 1)
1627
+ } else if (modifier.type === 'array') {
1628
+ scalar(modifier.count, `${at}.count`, 2, 64, true)
1629
+ modifier.offset?.forEach((value, axis) => scalar(value, `${at}.offset.${axis}`))
1630
+ if (modifier.offset?.length !== 3 || modifier.offset.every(value => value === 0)) bad(`${at}.offset`)
1631
+ } else if (modifier.type === 'mirror') {
1632
+ if (!EDITOR_AXES.includes(modifier.axis)) bad(`${at}.axis`)
1633
+ } else if (modifier.type === 'boolean') {
1634
+ if (!['union', 'difference', 'intersect'].includes(modifier.operation)
1635
+ || !draft.entities.some(entity => entity.id === modifier.targetEntityId && entity.id !== draft.entityId && entity.kind !== 'empty')) bad(`${at}.targetEntityId`)
1636
+ } else bad(at)
1637
+ })
1638
+ const material = draft.material.definition, original = editorMaterialOriginal(draft)
1639
+ if (material && original) for (const property of ['baseColor', 'roughness']) {
1640
+ if (editorEqual(material.parameters?.[property], original.parameters?.[property])) continue
1641
+ if (editorMaterialLocked(draft, property)) bad(property)
1642
+ if (property === 'baseColor') {
1643
+ const color = material.parameters?.baseColor
1644
+ if (!Array.isArray(color) || color.length !== 4) bad(property)
1645
+ else color.forEach(value => scalar(value, property, 0, 1))
1646
+ } else scalar(material.parameters?.roughness, property, 0, 1)
1647
+ }
1648
+ if (material && original && !editorEqual(material.texture, original.texture)) {
1649
+ if (editorTextureLocked(draft)) bad('texture')
1650
+ const texture = material.texture
1651
+ if (texture !== undefined) {
1652
+ if (!texture || typeof texture !== 'object' || Array.isArray(texture)) bad('texture')
1653
+ else {
1654
+ const keys = ['type', 'scale', 'detail', 'distortion', 'stretch', 'bump', 'roughnessVariation', 'colorVariation', 'coordinates', 'uvMap']
1655
+ if (Object.keys(texture).some(key => !keys.includes(key))) bad('texture')
1656
+ if (!['noise', 'wave', 'voronoi'].includes(texture.type)) bad('texture.type')
1657
+ scalar(texture.scale, 'texture.scale', Number.MIN_VALUE)
1658
+ if (texture.coordinates !== undefined && !['object', 'uv'].includes(texture.coordinates)) bad('texture.coordinates')
1659
+ if (texture.uvMap !== undefined && (texture.coordinates !== 'uv' || typeof texture.uvMap !== 'string' || !texture.uvMap.trim())) bad('texture.uvMap')
1660
+ for (const [key, min, max] of [['detail', 0, 16], ['distortion', 0, Infinity], ['bump', 0, 1], ['roughnessVariation', 0, 1], ['colorVariation', 0, 1]]) {
1661
+ if (texture[key] !== undefined) scalar(texture[key], `texture.${key}`, min, max)
1662
+ }
1663
+ if (texture.stretch !== undefined) {
1664
+ if (!Array.isArray(texture.stretch) || texture.stretch.length !== 3) bad('texture.stretch')
1665
+ else texture.stretch.forEach((value, axis) => scalar(value, `texture.stretch.${axis}`, Number.MIN_VALUE))
1666
+ }
1667
+ }
1668
+ }
1669
+ }
1670
+ if (draft.material.target !== 'entity') {
1671
+ const part = draft.original.assetParts?.find(item => item.partId === draft.material.partId)
1672
+ if (!part) bad('partId')
1673
+ if (draft.material.target === 'slot' && (draft.material.slotIndex === '' || !part?.sourceMaterialSlots?.some(slot => slot.index === Number(draft.material.slotIndex)))) bad('slotIndex')
1674
+ }
1675
+ return errors
1676
+ }
1677
+
1678
+ function buildEditorPatch(draft) {
1679
+ const errors = editorErrors(draft)
1680
+ if (errors.length) throw new Error(errors.join(' '))
1681
+ const operations = [], original = draft.original, entity = draft.entity
1682
+ const transform = { op: 'entity.transform.update', entityId: draft.entityId }
1683
+ for (const key of ['location', 'rotationEuler']) if (!editorEqual(entity.transform[key], original.transform[key])) transform[key] = editorClone(entity.transform[key])
1684
+ if (Object.keys(transform).length > 2) operations.push(transform)
1685
+ if (!editorEqual(entity.generator, original.generator)) operations.push({ op: 'entity.generator.set', entityId: draft.entityId, generator: editorClone(entity.generator) })
1686
+ if (!editorEqual(entity.modifiers, original.modifiers)) operations.push({ op: 'entity.modifiers.set', entityId: draft.entityId, modifiers: editorClone(entity.modifiers) })
1687
+ const material = draft.material.definition, baseMaterial = editorMaterialOriginal(draft)
1688
+ const changedProperties = material && baseMaterial ? ['baseColor', 'roughness'].filter(key => !editorEqual(material.parameters?.[key], baseMaterial.parameters?.[key])) : []
1689
+ const textureChanged = material && baseMaterial && !editorEqual(material.texture, baseMaterial.texture)
1690
+ const materialChanged = changedProperties.length > 0 || textureChanged
1691
+ let materialId = draft.material.id
1692
+ if (materialChanged) {
1693
+ if (draft.material.scope === 'local') {
1694
+ materialId = draft.cloneId
1695
+ operations.push({ op: 'material.add', material: { ...editorClone(material), id: materialId } })
1696
+ } else {
1697
+ for (const parameter of changedProperties) operations.push({ op: 'material.parameter.update', materialId, parameter, value: editorClone(material.parameters[parameter]) })
1698
+ if (textureChanged) operations.push({ op: 'material.texture.set', materialId, texture: material.texture === undefined ? null : editorClone(material.texture) })
1699
+ }
1700
+ }
1701
+ if (entity.kind !== 'empty' && materialId && materialId !== editorEffectiveMaterialId(draft)) {
1702
+ if (draft.material.target === 'entity') operations.push({ op: 'entity.material.set', entityId: draft.entityId, materialId })
1703
+ else {
1704
+ const slotIndex = draft.material.target === 'slot' ? Number(draft.material.slotIndex) : undefined
1705
+ const bindings = editorClone(original.materialBindings).filter(binding => !(binding.partId === draft.material.partId && binding.slotIndex === slotIndex))
1706
+ bindings.push({ partId: draft.material.partId, ...(slotIndex === undefined ? {} : { slotIndex }), materialId })
1707
+ operations.push({ op: 'entity.materialBindings.set', entityId: draft.entityId, materialBindings: bindings })
1708
+ }
1709
+ }
1710
+ return { projectId: draft.projectId, baseRevision: draft.baseRevision, saveCheckpoint: true, renderPreview: true, actor: 'ui', operations }
1711
+ }
1712
+
1713
+ function editorDirty(draft) {
1714
+ if (!draft) return false
1715
+ try { return buildEditorPatch(draft).operations.length > 0 } catch { return true }
1716
+ }
1717
+
1718
+ function editorDraftFor(state) {
1719
+ return state.editorDrafts?.[editorKey(state.activeProjectId, state.editorEntityId)] || null
1720
+ }
1721
+
1722
+ function createPhotographyDraft(scene, projectId, previous = {}) {
1723
+ const cameras = (scene.nodes.cameras || []).map(item => editorClone(item.definition)).filter(Boolean)
1724
+ const lights = (scene.nodes.lights || []).map(item => editorClone(item.definition)).filter(Boolean)
1725
+ return { projectId, baseRevision: scene.revision, cameras, lights, original: editorClone({ cameras, lights }),
1726
+ entities: editorClone(scene.nodes.entities), tracks: editorClone(scene.nodes.animationTracks || []),
1727
+ cameraId: cameras.some(item => item.id === previous.cameraId) ? previous.cameraId : scene.project.activeCamera || cameras[0]?.id || '',
1728
+ lightId: lights.some(item => item.id === previous.lightId) ? previous.lightId : lights[0]?.id || '',
1729
+ frame: previous.frame ?? scene.project.frameStart, samples: previous.samples ?? 16,
1730
+ frameStart: scene.project.frameStart, frameEnd: scene.project.frameEnd }
1731
+ }
1732
+ const photographyDraftFor = state => state.photographyDrafts?.[state.activeProjectId] || null
1733
+ const photographyBusy = work => Boolean(work?.saving || work?.previewing || work?.restoring)
1734
+ const photographyAim = camera => camera?.targetEntityId !== undefined ? 'entity' : camera?.targetPoint !== undefined ? 'point' : 'free'
1735
+ const photographyAimLocked = (draft, camera) => EDITOR_AXES.some(axis => editorTrackLocked(draft, 'camera', camera.id, `rotationEuler.${axis}`))
1736
+ function photographyErrors(draft) {
1737
+ const errors = [], bad = key => errors.push(key), number = (value, min, inclusive = true) => Number.isFinite(value) && (inclusive ? value >= min : value > min)
1738
+ const vector = value => Array.isArray(value) && value.length === 3 && value.every(Number.isFinite)
1739
+ for (const [kind, items] of [['camera', draft.cameras], ['light', draft.lights]]) for (const item of items) {
1740
+ const original = draft.original[`${kind}s`].find(old => old.id === item.id)
1741
+ for (const key of ['location', 'rotationEuler']) {
1742
+ if (!editorEqual(item.transform?.[key], original?.transform?.[key])) {
1743
+ if (!vector(item.transform?.[key])) bad(`${item.id}.${key}`)
1744
+ else for (let axis = 0; axis < 3; axis++) if (item.transform[key][axis] !== (original?.transform?.[key]?.[axis] ?? 0)
1745
+ && editorTrackLocked(draft, kind, item.id, `${key}.${EDITOR_AXES[axis]}`)) bad(`${item.id}.${key}.${EDITOR_AXES[axis]}`)
1746
+ if (kind === 'camera' && key === 'rotationEuler' && photographyAim(item) !== 'free') bad(`${item.id}.rotationEuler`)
1747
+ }
1748
+ }
1749
+ if (kind === 'camera') {
1750
+ if (!number(editorNumberDefault(item.lens, 50), 0, false)) bad(`${item.id}.lens`)
1751
+ if (item.targetEntityId !== undefined && !draft.entities.some(entity => entity.id === item.targetEntityId)) bad(`${item.id}.targetEntityId`)
1752
+ if (item.targetPoint !== undefined && !vector(item.targetPoint)) bad(`${item.id}.targetPoint`)
1753
+ if (item.targetEntityId !== undefined && item.targetPoint !== undefined) bad(`${item.id}.target`)
1754
+ const targetChanged = !editorEqual([item.targetEntityId, item.targetPoint], [original?.targetEntityId, original?.targetPoint])
1755
+ if (targetChanged && (photographyAimLocked(draft, item) || photographyAim(item) === 'free')) bad(`${item.id}.target`)
1756
+ } else {
1757
+ if (!number(editorNumberDefault(item.energy, 100), 0)) bad(`${item.id}.energy`)
1758
+ if (!Array.isArray(item.color ?? [1, 1, 1]) || ![3, 4].includes((item.color ?? [1, 1, 1]).length)
1759
+ || !(item.color ?? [1, 1, 1]).every(value => number(value, 0) && value <= 1)) bad(`${item.id}.color`)
1760
+ if (item.type !== 'sun' && !number(editorNumberDefault(item.size, item.type === 'area' ? 1 : .25), 0, false)) bad(`${item.id}.size`)
1761
+ if (!original && item.type !== 'area') bad(`${item.id}.type`)
1762
+ if (original && item.type !== original.type) bad(`${item.id}.type`)
1763
+ }
1764
+ }
1765
+ if (!draft.cameras.some(camera => camera.id === draft.cameraId)) bad('camera')
1766
+ if (!Number.isInteger(draft.frame) || draft.frame < draft.frameStart || draft.frame > draft.frameEnd) bad('frame')
1767
+ if (!Number.isInteger(draft.samples) || draft.samples < 1 || draft.samples > 512) bad('samples')
1768
+ return errors
1769
+ }
1770
+ function buildPhotographyPatch(draft) {
1771
+ const errors = photographyErrors(draft)
1772
+ if (errors.length) throw new Error(t('photo.invalid', { fields: errors.join(', ') }))
1773
+ const operations = []
1774
+ for (const [kind, items, fields] of [['camera', draft.cameras, ['lens', 'targetEntityId', 'targetPoint']], ['light', draft.lights, ['energy', 'color', 'size']]]) {
1775
+ for (const item of items) {
1776
+ const original = draft.original[`${kind}s`].find(old => old.id === item.id)
1777
+ if (!original) { operations.push({ op: 'light.add', light: editorClone(item) }); continue }
1778
+ const changes = {}
1779
+ for (const key of fields) if (item[key] !== undefined && !editorEqual(item[key], original[key])) changes[key] = editorClone(item[key])
1780
+ const transform = {}
1781
+ for (const key of ['location', 'rotationEuler']) if (!editorEqual(item.transform?.[key], original.transform?.[key])) transform[key] = editorClone(item.transform[key])
1782
+ if (Object.keys(transform).length) changes.transform = transform
1783
+ if (Object.keys(changes).length) operations.push({ op: `${kind}.update`, [`${kind}Id`]: item.id, ...changes })
1784
+ }
1785
+ }
1786
+ return { projectId: draft.projectId, baseRevision: draft.baseRevision, actor: 'ui', saveCheckpoint: true, renderPreview: false, operations }
1787
+ }
1788
+ function photographyDirty(draft) {
1789
+ return Boolean(draft && !editorEqual({ cameras: draft.cameras, lights: draft.lights }, draft.original))
1790
+ }
1791
+ function inspectionReceiptValid(receipt, request, digest) {
1792
+ const artifacts = receipt?.artifacts, view = request.views[0]
1793
+ return Boolean(receipt?.revision === request.revision && receipt?.sourceRevision === request.revision && receipt?.mode === request.mode
1794
+ && (!digest || receipt.sourceDigest === digest) && Array.isArray(artifacts) && artifacts.length === 1
1795
+ && artifacts.every(artifact => artifact.kind === 'diagnostic' && artifact.mode === request.mode && artifact.sourceRevision === request.revision
1796
+ && artifact.cameraId === view.cameraId && artifact.frame === view.frame && artifact.mime === 'image/png'
1797
+ && typeof artifact.path === 'string' && artifact.path && /^[a-f0-9]{64}$/.test(artifact.sha256 || '')))
1798
+ }
1799
+
1800
+ const ASSET_TYPES = ['glb', 'png', 'jpg', 'jpeg', 'hdr', 'exr']
1801
+ const ASSET_CHANNELS = ['baseColor', 'roughness', 'metallic', 'normal', 'alpha', 'emissionColor']
1802
+ const assetKey = asset => JSON.stringify([asset.id, asset.sha256, asset.path])
1803
+ const assetKind = asset => ['gltf', 'glb', 'obj'].includes(asset.type) ? 'model' : ['hdr', 'exr'].includes(asset.type) ? 'environment' : 'image'
1804
+ const assetBytes = value => {
1805
+ if (!Number.isFinite(value)) return '—'
1806
+ const unit = value >= 1048576 ? ['MiB', 1048576] : value >= 1024 ? ['KiB', 1024] : ['B', 1]
1807
+ return `${(value / unit[1]).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${unit[0]}`
1808
+ }
1809
+ // File handles stay outside snapshots. This metadata never invents paths that
1810
+ // ordinary multi-selection cannot provide; the Host repeats all validation.
1811
+ function assetBundleSelection(files, mode, limits) {
1812
+ const selected = Array.from(files || []), fail = (message = t('assets.bundleInvalid')) => { throw new Error(message) }
1813
+ if (!limits?.bundleUpload || !Number.isSafeInteger(limits.maxBytes) || limits.maxBytes <= 0) fail(t('assets.needLibrary'))
1814
+ if (!['files', 'directory'].includes(mode) || !selected.length || selected.length > limits.bundleUpload.maxFiles) fail()
1815
+ let root = null, totalBytes = 0
1816
+ const paths = new Set(), directories = new Map()
1817
+ const members = selected.map(file => {
1818
+ let path = file.name
1819
+ if (mode === 'directory') {
1820
+ const relative = file.webkitRelativePath
1821
+ if (typeof relative !== 'string' || relative.indexOf('/') <= 0) fail(t('assets.bundleDirectoryMissing'))
1822
+ const folder = relative.slice(0, relative.indexOf('/'))
1823
+ if (root !== null && root !== folder) fail(t('assets.bundleDirectoryMissing'))
1824
+ root = folder; path = relative.slice(relative.indexOf('/') + 1)
1825
+ }
1826
+ if (typeof path !== 'string' || path.length > 1024 || /[\\:\x00-\x1f\x7f]/.test(path)
1827
+ || (mode === 'files' && path.includes('/')) || !Number.isSafeInteger(file.size) || file.size < 0) fail()
1828
+ const segments = path.split('/'), key = path.normalize('NFC').toLowerCase()
1829
+ if (segments.some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part)
1830
+ || /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(part)
1831
+ || part.toLowerCase() === '.deepblend-lock.json') || paths.has(key)) fail()
1832
+ paths.add(key)
1833
+ for (let i = 1; i < segments.length; i++) {
1834
+ const directory = segments.slice(0, i).join('/'), normalized = directory.normalize('NFC').toLowerCase()
1835
+ if (directories.has(normalized) && directories.get(normalized) !== directory) fail()
1836
+ directories.set(normalized, directory)
1837
+ }
1838
+ totalBytes += file.size
1839
+ if (!Number.isSafeInteger(totalBytes) || totalBytes > limits.maxBytes) fail()
1840
+ return { path, bytes: file.size }
1841
+ })
1842
+ if ([...paths].some(path => directories.has(path))) fail()
1843
+ const entrypoints = members.filter(file => file.bytes > 0 && /\.(gltf|glb|obj)$/i.test(file.path)).map(file => file.path)
1844
+ if (!entrypoints.length) fail()
1845
+ return { mode, files: members, totalBytes, entrypoints, entrypoint: '' }
1846
+ }
1847
+ function assetEditor(draft) {
1848
+ const original = draft.scene.nodes.entities.find(entity => entity.id === draft.entityId)
1849
+ if (!original) return null
1850
+ const editor = { original, materials: draft.scene.nodes.materials, tracks: draft.scene.nodes.animationTracks || [],
1851
+ material: { target: draft.target, partId: draft.partId, slotIndex: draft.slotIndex } }
1852
+ editorSelectMaterial(editor, editorEffectiveMaterialId(editor))
1853
+ return editor
1854
+ }
1855
+ function resetAssetBinding(draft) {
1856
+ const source = draft.newMaterial ? null : assetEditor(draft)?.material.definition
1857
+ draft.binding = editorClone(source?.images?.[draft.channel] || {})
1858
+ draft.replaceTexture = false
1859
+ }
1860
+ function createAssetDraft(scene, projectId, entry, inspection, entityId) {
1861
+ const id = `ui-asset-${Date.now().toString(36)}-${++editorSequence}`
1862
+ const draft = { projectId, baseRevision: scene.revision, scene: editorClone(scene), entry: editorClone(entry),
1863
+ inspection: editorClone(inspection), kind: assetKind(entry.asset), newEntityId: id, newMaterialId: `${id}-material`,
1864
+ location: [0, 0, 0], rotationEuler: [0, 0, 0], scale: 1,
1865
+ entityId: entityId || scene.nodes.entities.find(item => item.kind !== 'empty')?.id || '',
1866
+ target: 'entity', partId: '', slotIndex: '', scope: 'local', newMaterial: false,
1867
+ channel: 'baseColor', binding: {}, replaceTexture: false,
1868
+ world: editorClone(scene.world || {}), strength: scene.world?.strength ?? 1,
1869
+ rotation: scene.world?.environment?.rotation ?? 0 }
1870
+ resetAssetBinding(draft)
1871
+ return draft
1872
+ }
1873
+ function buildAssetPatch(draft) {
1874
+ if (!draft) throw new Error(t('assets.invalid', { field: 'draft' }))
1875
+ const fail = field => { throw new Error(t('assets.invalid', { field })) }
1876
+ let asset = draft.entry.asset
1877
+ const operations = []
1878
+ if (!asset || ![...ASSET_TYPES, 'gltf', 'obj'].includes(asset.type) || !/^[a-f0-9]{64}$/.test(asset.sha256 || '')) fail('asset')
1879
+ if (draft.inspection?.kind !== assetKind(asset) || draft.kind !== assetKind(asset)) fail('inspection')
1880
+ let declared = draft.scene.nodes.assets.find(item => item.id === asset.id)
1881
+ if (declared && (declared.sha256 !== asset.sha256 || declared.path !== asset.path || declared.type !== asset.type)) {
1882
+ const alias = `${draft.newEntityId}-source`
1883
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(alias) || draft.scene.nodes.assets.some(item => item.id === alias)) fail('asset ID')
1884
+ asset = { ...asset, id: alias }
1885
+ declared = null
1886
+ }
1887
+ if (!declared) operations.push({ op: 'asset.add', asset: { ...editorClone(asset),
1888
+ ...(asset.license === undefined && draft.entry.license ? { license: { source: draft.entry.license } } : {}) } })
1889
+ if (draft.kind === 'model') {
1890
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(draft.newEntityId) || draft.scene.nodes.entities.some(item => item.id === draft.newEntityId)) fail('object ID')
1891
+ if (![draft.location, draft.rotationEuler].every(values => Array.isArray(values) && values.length === 3 && values.every(Number.isFinite))) fail('transform')
1892
+ if (!Number.isFinite(draft.scale) || draft.scale <= 0) fail('scale')
1893
+ operations.push({ op: 'entity.add', entity: { id: draft.newEntityId, type: 'asset-instance', assetId: asset.id,
1894
+ transform: { location: [...draft.location], rotationEuler: [...draft.rotationEuler], scale: [draft.scale, draft.scale, draft.scale] } } })
1895
+ } else if (draft.kind === 'environment') {
1896
+ if (!Number.isFinite(draft.strength) || draft.strength < 0 || draft.strength > 1000 || !Number.isFinite(draft.rotation)) fail('environment')
1897
+ operations.push({ op: 'world.set', world: { ...editorClone(draft.world), strength: draft.strength,
1898
+ environment: { ...editorClone(draft.world.environment || {}), assetId: asset.id, rotation: draft.rotation } } })
1899
+ } else {
1900
+ const editor = assetEditor(draft), target = editor?.original
1901
+ if (!editor || target.kind === 'empty' || target.locked) fail('target')
1902
+ if (!['entity', 'part', 'slot'].includes(draft.target) || !['local', 'shared'].includes(draft.scope)) fail('target scope')
1903
+ if (draft.target !== 'entity') {
1904
+ const part = target.assetParts?.find(item => item.partId === draft.partId)
1905
+ const sourceAsset = draft.scene.nodes.assets.find(item => item.id === target.assetId)
1906
+ if (target.kind !== 'asset-instance' || !part || part.selectorVersion !== 1 || !sourceAsset?.sha256 || part.assetSha256 !== sourceAsset.sha256) fail('source part inventory')
1907
+ if (draft.target === 'slot' && (draft.slotIndex === '' || !part.sourceMaterialSlots.some(slot => slot.index === Number(draft.slotIndex)))) fail('source slot')
1908
+ }
1909
+ const original = editor.material.definition
1910
+ if (!draft.newMaterial && (!original || !['principled', 'glass'].includes(original.shader))) throw new Error(t('assets.nativeHelp'))
1911
+ const animated = editor.tracks.filter(track => track.targetKind === 'material' && track.targetId === original?.id)
1912
+ if (!draft.newMaterial && draft.scope === 'local' && animated.length) throw new Error(t('assets.localAnimated'))
1913
+ if (!draft.newMaterial && animated.some(track => track.property === draft.channel || track.property?.startsWith(`${draft.channel}.`))) throw new Error(t('assets.animatedChannel'))
1914
+ if (!ASSET_CHANNELS.includes(draft.channel)) fail('image channel')
1915
+ const binding = { ...editorClone(draft.binding), assetId: asset.id }
1916
+ if (binding.uvMap !== undefined && (typeof binding.uvMap !== 'string' || !binding.uvMap.trim())) fail('UV name')
1917
+ for (const key of ['scale', 'offset']) if (binding[key] !== undefined && (!Array.isArray(binding[key]) || binding[key].length !== 3 || !binding[key].every(Number.isFinite))) fail(key)
1918
+ if (binding.channel !== undefined && (!['roughness', 'metallic', 'alpha'].includes(draft.channel) || !['r', 'g', 'b', 'a'].includes(binding.channel))) fail('image channel')
1919
+ if (binding.strength !== undefined && (draft.channel !== 'normal' || !Number.isFinite(binding.strength) || binding.strength < 0 || binding.strength > 10)) fail('normal strength')
1920
+ const material = draft.newMaterial ? { id: draft.newMaterialId, shader: 'principled', parameters: { baseColor: [0.8, 0.8, 0.8, 1], roughness: 0.5, metallic: 0 } } : editorClone(original)
1921
+ if (material.texture && !draft.replaceTexture) fail('procedural texture must be explicitly replaced')
1922
+ const images = { ...editorClone(material.images || {}), [draft.channel]: binding }
1923
+ let materialId = material.id
1924
+ if (draft.newMaterial || draft.scope === 'local') {
1925
+ materialId = draft.newMaterialId; material.id = materialId; material.images = images; delete material.texture
1926
+ operations.push({ op: 'material.add', material })
1927
+ if (draft.target === 'entity') operations.push({ op: 'entity.material.set', entityId: draft.entityId, materialId })
1928
+ else {
1929
+ const slotIndex = draft.target === 'slot' ? Number(draft.slotIndex) : undefined
1930
+ const bindings = editorClone(target.materialBindings || []).filter(binding => !(binding.partId === draft.partId && binding.slotIndex === slotIndex))
1931
+ bindings.push({ partId: draft.partId, ...(slotIndex === undefined ? {} : { slotIndex }), materialId })
1932
+ operations.push({ op: 'entity.materialBindings.set', entityId: draft.entityId, materialBindings: bindings })
1933
+ }
1934
+ } else {
1935
+ if (material.texture) operations.push({ op: 'material.texture.set', materialId, texture: null })
1936
+ operations.push({ op: 'material.images.set', materialId, images })
1937
+ }
1938
+ }
1939
+ return { projectId: draft.projectId, baseRevision: draft.baseRevision, saveCheckpoint: true, renderPreview: true, actor: 'ui', operations }
1940
+ }
1941
+ function assetDraftError(draft) { try { buildAssetPatch(draft); return null } catch (error) { return error.message } }
1942
+
1943
+ function inspectionForm(state) {
1944
+ const scene = state.selected?.scene
1945
+ if (!scene) return null
1946
+ return state.inspectionForms?.[state.activeProjectId] || {
1947
+ cameraId: scene.project.activeCamera || scene.nodes.cameras[0]?.id || '',
1948
+ frame: scene.project.frameStart ?? 1, mode: 'beauty', samples: 16,
1949
+ }
1950
+ }
1951
+ function inspectionFormValid(form, scene) {
1952
+ return Boolean(form && scene?.revision && scene.nodes.cameras.some(camera => camera.id === form.cameraId)
1953
+ && ['beauty', 'clay'].includes(form.mode) && Number.isInteger(form.frame)
1954
+ && form.frame >= scene.project.frameStart && form.frame <= scene.project.frameEnd
1955
+ && Number.isInteger(form.samples) && form.samples >= 1 && form.samples <= 512)
1956
+ }
1957
+
1958
+ const REFERENCE_PURPOSES = ['geometry', 'materials', 'lighting', 'goalFit']
1959
+ const REFERENCE_MAX_BYTES = 8 * 1024 * 1024
1960
+ function createBriefDraft(scene, projectId) {
1961
+ const goal = scene.project.goal ?? '', referenceImages = editorClone(scene.project.referenceImages || [])
1962
+ const reviewSubjectId = scene.project.reviewSubjectId ?? null
1963
+ return { projectId, baseRevision: scene.revision, goal, referenceImages, reviewSubjectId,
1964
+ reviewSubject: editorClone(scene.reviewSubject || null), entities: editorClone(scene.nodes.entities || []),
1965
+ original: { goal, referenceImages: editorClone(referenceImages), reviewSubjectId }, assets: editorClone(scene.nodes.assets || []), pendingAssets: {} }
1966
+ }
1967
+ function briefContentDirty(draft) { return !editorEqual(draft.goal, draft.original.goal) || !editorEqual(draft.referenceImages, draft.original.referenceImages) }
1968
+ function briefDirty(draft) {
1969
+ return Boolean(draft && (briefContentDirty(draft) || draft.reviewSubjectId !== draft.original.reviewSubjectId))
1970
+ }
1971
+ function projectHasUnsavedDrafts(state) {
1972
+ return briefDirty(state.briefDrafts[state.activeProjectId])
1973
+ || Object.values(state.editorDrafts || {}).some(draft => draft.projectId === state.activeProjectId && editorDirty(draft))
1974
+ || photographyDirty(photographyDraftFor(state))
1975
+ || Boolean(state.assetDrafts?.[state.activeProjectId])
1976
+ || state.forms.patch !== null
1977
+ }
1978
+ function briefValid(draft) {
1979
+ return Boolean(draft && typeof draft.goal === 'string' && draft.goal.length <= 2000 && draft.referenceImages.length <= 4
1980
+ && (draft.reviewSubjectId === null || typeof draft.reviewSubjectId === 'string' && draft.reviewSubjectId.length > 0)
1981
+ && draft.referenceImages.every(reference => typeof reference.label === 'string' && reference.label.trim().length > 0 && reference.label.length <= 160
1982
+ && (reference.notes === undefined || typeof reference.notes === 'string' && reference.notes.length <= 1000)
1983
+ && Array.isArray(reference.purposes) && reference.purposes.length > 0 && reference.purposes.length <= 4
1984
+ && new Set(reference.purposes).size === reference.purposes.length && reference.purposes.every(purpose => REFERENCE_PURPOSES.includes(purpose))))
1985
+ }
1986
+ function buildBriefPatch(draft) {
1987
+ if (!briefValid(draft)) throw new Error(t('brief.invalid'))
1988
+ const used = new Set(draft.referenceImages.map(reference => reference.assetId))
1989
+ const operations = Object.values(draft.pendingAssets).filter(asset => used.has(asset.id) && !draft.assets.some(existing => existing.id === asset.id))
1990
+ .map(asset => ({ op: 'asset.add', asset: editorClone(asset) }))
1991
+ if (briefContentDirty(draft)) operations.push({ op: 'project.brief.set', goal: draft.goal, referenceImages: editorClone(draft.referenceImages) })
1992
+ if (draft.reviewSubjectId !== draft.original.reviewSubjectId) operations.push({ op: 'project.reviewSubject.set', entityId: draft.reviewSubjectId })
1993
+ return { projectId: draft.projectId, baseRevision: draft.baseRevision, actor: 'ui', saveCheckpoint: true, renderPreview: false, operations }
1994
+ }
1995
+
1996
+ const EDITOR_RENDER_FIELDS = ['engine', 'resolution', 'resolutionPercentage', 'samples', 'filmTransparent', 'viewTransform', 'look', 'exposure', 'fps', 'frameStart', 'frameEnd']
1997
+ function editorPreviewPair(comparison) {
1998
+ const previews = (list, revision) => (list || []).filter(item => item.kind === 'preview' && item.path && item.sha256)
1999
+ .map(item => previewSource({ revision }, item)).filter(item => item.sourceRevision === revision)
2000
+ const after = previews(comparison.afterPreviews, comparison.after).at(-1) || null
2001
+ const positiveInteger = value => Number.isInteger(value) && value > 0
2002
+ const known = item => {
2003
+ const renderSettings = item?.renderConfig
2004
+ return Boolean(item && typeof item.cameraId === 'string' && item.cameraId.length > 0 && Number.isInteger(item.frame)
2005
+ && positiveInteger(item.width) && positiveInteger(item.height) && positiveInteger(item.samples)
2006
+ && typeof item.engine === 'string' && item.engine.length > 0 && renderSettings
2007
+ && EDITOR_RENDER_FIELDS.every(key => Object.hasOwn(renderSettings, key))
2008
+ && renderSettings.engine === item.engine && renderSettings.samples === item.samples
2009
+ && Array.isArray(renderSettings.resolution) && renderSettings.resolution.length === 2 && renderSettings.resolution.every(positiveInteger)
2010
+ && positiveInteger(renderSettings.resolutionPercentage) && renderSettings.resolutionPercentage <= 100
2011
+ && Math.floor(renderSettings.resolution[0] * renderSettings.resolutionPercentage / 100) === item.width
2012
+ && Math.floor(renderSettings.resolution[1] * renderSettings.resolutionPercentage / 100) === item.height
2013
+ && typeof renderSettings.filmTransparent === 'boolean' && typeof renderSettings.viewTransform === 'string' && renderSettings.viewTransform.length > 0
2014
+ && typeof renderSettings.look === 'string' && Number.isFinite(renderSettings.exposure) && positiveInteger(renderSettings.fps)
2015
+ && Number.isInteger(renderSettings.frameStart) && Number.isInteger(renderSettings.frameEnd) && renderSettings.frameEnd >= renderSettings.frameStart)
2016
+ }
2017
+ const identity = item => JSON.stringify([item.cameraId, item.frame, item.width, item.height, ...EDITOR_RENDER_FIELDS.map(key => item.renderConfig[key])])
2018
+ const before = known(after) ? previews(comparison.beforePreviews, comparison.before).findLast(item => known(item) && identity(item) === identity(after)) || null : null
2019
+ return { beforeArtifact: before ? editorClone(before) : null,
2020
+ afterArtifact: after ? editorClone(after) : null,
2021
+ reason: !after ? 'no-preview' : !known(after) ? 'unknown-settings' : before ? null : 'no-matching-baseline' }
2022
+ }
2023
+
693
2024
  // =========================================================================
694
2025
  // §F Talking to the Host
695
2026
  // =========================================================================
@@ -765,6 +2096,9 @@ window.__ModuleLoader__.load({
765
2096
  }),
766
2097
  }
767
2098
  }
2099
+ if (typeof payload?.hostApiVersion === 'number' && payload.hostApiVersion < EXPECTED_HOST_API) {
2100
+ return { status: 'stale', payload: null, error: staleHostError(expectedRoute, { payload, status: response.status }) }
2101
+ }
768
2102
  if (payload && payload.ok) return { status: 'ok', payload, error: null }
769
2103
  return {
770
2104
  status: 'error',
@@ -803,14 +2137,42 @@ window.__ModuleLoader__.load({
803
2137
  // the half-typed patch) lives here too, exactly as SPEC §14.3 draws the line:
804
2138
  // it decides what is DISPLAYED, never what is TRUE.
805
2139
 
2140
+ const FILE_FIELDS = ['brief-upload', 'asset-upload', 'asset-bundle-files', 'asset-bundle-directory']
2141
+ function fileChoiceAllowed(state, field) {
2142
+ const projectId = state.activeProjectId
2143
+ if (state.status !== 'ok' || !projectId || !state.selected?.scene) return false
2144
+ if (field === 'brief-upload') {
2145
+ const draft = state.briefDrafts[projectId], work = state.briefWork[projectId] || {}
2146
+ return state.view === 'projects' && Boolean(draft) && draft.baseRevision === state.selected.scene.revision
2147
+ && !work.saving && !work.uploading && !state.visualRuns[projectId]?.busy && draft.referenceImages.length < 4
2148
+ }
2149
+ const library = state.assetLibraries[projectId]
2150
+ return state.view === 'scene' && !state.assetWork[projectId]?.busy && Boolean(library)
2151
+ && (field === 'asset-upload' ? Number.isFinite(library.limits?.maxBytes)
2152
+ : ['asset-bundle-files', 'asset-bundle-directory'].includes(field) && Boolean(library.limits?.bundleUpload))
2153
+ }
2154
+ const fileChoiceKey = (state, field) => JSON.stringify([state.fileChoiceGeneration, state.activeProjectId, state.selected?.scene?.revision, state.view, field])
2155
+ function fileChoiceProps(state, actions, field, consume) {
2156
+ const source = fileChoiceKey(state, field)
2157
+ return { key: source, 'data-field': field, 'data-file-context': source, disabled: !fileChoiceAllowed(state, field),
2158
+ onChange: event => {
2159
+ // Check before even accessing event.target.files: the old native node
2160
+ // can finish after its view/project has been replaced.
2161
+ if (!actions.acceptsFileChoice(source, field)) return
2162
+ return consume(event, source)
2163
+ } }
2164
+ }
2165
+
806
2166
  /** A blank snapshot, so `getState()` always answers the same shape. */
807
2167
  function emptySnapshot() {
808
2168
  return {
809
2169
  status: 'loading',
2170
+ fileChoiceGeneration: 0,
810
2171
  error: null,
811
2172
  hostApiVersion: null,
812
2173
  projects: [],
813
2174
  projectsRoot: null,
2175
+ recipeCatalog: { recipes: [], errors: [] },
814
2176
  selected: null,
815
2177
  currentRevision: null,
816
2178
  jobs: [],
@@ -822,12 +2184,35 @@ window.__ModuleLoader__.load({
822
2184
  activeProjectId: null,
823
2185
  compareLeft: null,
824
2186
  compareRight: null,
825
- compareMode: 'renders',
2187
+ compareMode: 'result',
826
2188
  diff: null,
827
2189
  diffError: null,
828
2190
  previewBusy: false,
829
- forms: { title: '', goal: '', patch: null, frameStart: '', frameEnd: '', profile: 'preview' },
830
- busy: { create: false, patch: false, render: false, restore: false },
2191
+ inspectionForms: {},
2192
+ inspectionWork: {},
2193
+ inspectionRevisions: {},
2194
+ guideFocus: {},
2195
+ disclosures: {},
2196
+ assetLibraries: {},
2197
+ assetWork: {},
2198
+ assetDrafts: {},
2199
+ assetPreviews: {},
2200
+ assetLicenses: {},
2201
+ assetBundleSelections: {},
2202
+ briefDrafts: {},
2203
+ briefWork: {},
2204
+ visualRuns: {},
2205
+ visualIterations: {},
2206
+ photographyDrafts: {},
2207
+ photographyWork: {},
2208
+ photographyEdits: {},
2209
+ editorEntityId: null,
2210
+ editorDrafts: {},
2211
+ editorLastEdits: {},
2212
+ editorComparison: null,
2213
+ patchDrafts: {},
2214
+ forms: { title: '', goal: '', recipe: null, recipeParameters: {}, patch: null, frameStart: '', frameEnd: '', profile: 'preview' },
2215
+ busy: { create: false, patch: false, editor: false, render: false, restore: false },
831
2216
  notices: { projects: null, scene: null, preview: null, jobs: null, revisions: null },
832
2217
  }
833
2218
  }
@@ -849,8 +2234,51 @@ window.__ModuleLoader__.load({
849
2234
  let timer = null
850
2235
  let tick = 0
851
2236
  let live = false
2237
+ let loadSequence = 0
2238
+ let fileChoiceSignature = null, fileChoicesStopped = false
2239
+ const photographyControllers = new Map()
2240
+ const photographyWork = (projectId, changes) => set({ photographyWork: { ...data.photographyWork, [projectId]: { ...data.photographyWork[projectId], ...changes } } })
2241
+ const inspectionControllers = new Map()
2242
+ const assetControllers = new Map()
2243
+ const assetListSequences = new Map()
2244
+ const bundleFiles = new Map(), bundleOperations = new Map()
2245
+ // Control requests are bounded and remain alive after a local cancel so a
2246
+ // late create response can still identify and remove its Host session.
2247
+ const bundleRequest = async (projectId, suffix, body, signal) => {
2248
+ const timeout = signal ? null : new AbortController()
2249
+ const deadline = timeout ? setTimeout(() => timeout.abort(), 30000) : null
2250
+ try {
2251
+ const response = await fetchImpl(projectRoute(projectId, `/asset-uploads${suffix}`), {
2252
+ method: body === undefined ? 'GET' : 'POST', headers: { accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
2253
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: signal || timeout.signal,
2254
+ })
2255
+ const result = await response.json()
2256
+ if (!response.ok || !result.ok) throw new Error(`${result.error?.code || 'UI_UPLOAD_FAILED'}: ${result.error?.message || response.status}`)
2257
+ if (result.projectId !== projectId || (suffix && result.uploadId !== suffix.split('/')[1])) throw new Error(t('assets.bundleInvalid'))
2258
+ return result
2259
+ } finally { if (deadline !== null) clearTimeout(deadline) }
2260
+ }
2261
+ const cancelBundle = async operation => {
2262
+ operation.cancelled = true; operation.controller.abort()
2263
+ if (!operation.uploadId) return null
2264
+ if (!operation.cancellation) operation.cancellation = bundleRequest(operation.projectId, `/${operation.uploadId}/cancel`, {}).then(result => {
2265
+ if (!['completed', 'cancelled', 'expired', 'failed', 'unavailable'].includes(result.status)
2266
+ || (result.status === 'unavailable' && result.reason !== 'unknown-session')
2267
+ || (result.status === 'completed' && (result.receipt?.projectId !== operation.projectId
2268
+ || result.receipt?.uploadId !== operation.uploadId || !result.receipt?.asset?.path))) throw new Error(t('assets.bundleInvalid'))
2269
+ return result
2270
+ }).catch(error => {
2271
+ operation.cancellation = null
2272
+ throw error
2273
+ })
2274
+ return operation.cancellation
2275
+ }
2276
+ const inspectionWork = (projectId, changes) => set({ inspectionWork: { ...data.inspectionWork, [projectId]: { ...data.inspectionWork[projectId], ...changes } } })
2277
+ const assetWork = (projectId, changes) => set({ assetWork: { ...data.assetWork, [projectId]: { ...data.assetWork[projectId], ...changes } } })
852
2278
 
853
2279
  const notify = () => {
2280
+ const signature = JSON.stringify([data.activeProjectId, data.selected?.scene?.revision, data.view, ...FILE_FIELDS.map(field => fileChoiceAllowed(data, field))])
2281
+ if (signature !== fileChoiceSignature) { data.fileChoiceGeneration += 1; fileChoiceSignature = signature }
854
2282
  snapshot = { ...data, forms: { ...data.forms }, busy: { ...data.busy }, notices: { ...data.notices } }
855
2283
  for (const listener of [...listeners]) listener(snapshot)
856
2284
  }
@@ -864,10 +2292,12 @@ window.__ModuleLoader__.load({
864
2292
 
865
2293
  /** Read everything the panel shows, in one pass. */
866
2294
  const load = async () => {
2295
+ const sequence = ++loadSequence
867
2296
  const projectId = target()
868
2297
  const statePath = projectId === null ? ROUTES.state : `${ROUTES.state}?projectId=${encodeURIComponent(projectId)}`
869
2298
  const stateResult = await readRoute(fetchImpl, `${statePath}${statePath.includes('?') ? '&' : '?'}t=${tick}`, 'state')
870
2299
  if (stateResult.status !== 'ok') {
2300
+ if (sequence !== loadSequence || projectId !== target()) return
871
2301
  set({ status: stateResult.status, error: stateResult.error, hostApiVersion: stateResult.payload ? stateResult.payload.hostApiVersion : null })
872
2302
  return
873
2303
  }
@@ -878,6 +2308,7 @@ window.__ModuleLoader__.load({
878
2308
  error: null,
879
2309
  hostApiVersion: payload.hostApiVersion ?? null,
880
2310
  projects: payload.projects || [],
2311
+ recipeCatalog: payload.recipeCatalog ?? { recipes: [], errors: [] },
881
2312
  projectsRoot: payload.projectsRoot ?? null,
882
2313
  selected: payload.selected ?? null,
883
2314
  activeProjectId: active,
@@ -909,6 +2340,25 @@ window.__ModuleLoader__.load({
909
2340
  patch.previews = null
910
2341
  patch.artifactBase = ''
911
2342
  }
2343
+ if (sequence !== loadSequence || projectId !== target()) return
2344
+ if (active !== data.activeProjectId) patch.forms = { ...data.forms, patch: data.patchDrafts[active] ?? null }
2345
+ if (patch.selected?.scene && active) {
2346
+ const photography = data.photographyDrafts[active]
2347
+ if ((!photography || !photographyDirty(photography)) && !data.photographyWork[active]?.saving) {
2348
+ patch.photographyDrafts = { ...data.photographyDrafts, [active]: createPhotographyDraft(patch.selected.scene, active, photography) }
2349
+ }
2350
+ const draft = data.briefDrafts[active], work = data.briefWork[active]
2351
+ if (!draft || !briefDirty(draft) && !work?.uploading && !work?.saving) {
2352
+ patch.briefDrafts = { ...data.briefDrafts, [active]: createBriefDraft(patch.selected.scene, active) }
2353
+ }
2354
+ }
2355
+ const comparison = data.editorComparison
2356
+ if (comparison?.projectId === active && !comparison.resolved) {
2357
+ const afterPreviews = comparison.afterPreviews.length ? comparison.afterPreviews
2358
+ : patch.previews?.revisions?.find(item => item.revision === comparison.after)?.previews || []
2359
+ const next = { ...comparison, afterPreviews: editorClone(afterPreviews) }
2360
+ patch.editorComparison = { ...next, ...editorPreviewPair(next), resolved: afterPreviews.length > 0 }
2361
+ }
912
2362
  set(patch)
913
2363
  arm()
914
2364
  }
@@ -933,7 +2383,28 @@ window.__ModuleLoader__.load({
933
2383
  return outcome
934
2384
  }
935
2385
 
2386
+ const previewPhotography = async projectId => {
2387
+ const edit = data.photographyEdits[projectId]
2388
+ if (!edit || photographyBusy(data.photographyWork[projectId])) return
2389
+ const request = { revision: edit.after, mode: 'beauty', views: [{ id: 'selected', cameraId: edit.cameraId, frame: edit.frame }], samples: edit.samples }
2390
+ const controller = new AbortController(); photographyControllers.set(projectId, controller)
2391
+ photographyWork(projectId, { previewing: true, error: null, artifact: null })
2392
+ try {
2393
+ const response = await fetchImpl(projectRoute(projectId, '/preview'), { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(request), signal: controller.signal })
2394
+ const payload = await response.json()
2395
+ if (controller.signal.aborted) throw new Error(t('inspection.cancelled'))
2396
+ if (!response.ok || !payload.ok) throw new Error(`${payload.error?.code || 'UI_PREVIEW_FAILED'}: ${payload.error?.message || response.status}`)
2397
+ if (!inspectionReceiptValid(payload.preview, request, edit.digest)) throw new Error(t('inspection.mismatch'))
2398
+ photographyWork(projectId, { artifact: editorClone(payload.preview.artifacts[0]) })
2399
+ } catch (error) {
2400
+ photographyWork(projectId, { error: t('photo.failed', { revision: edit.after, message: controller.signal.aborted ? t('inspection.cancelled') : error.message || String(error) }) })
2401
+ } finally { photographyControllers.delete(projectId); photographyWork(projectId, { previewing: false }); reload() }
2402
+ }
2403
+
2404
+ const acceptsFileChoice = (source, field) => !fileChoicesStopped && typeof source === 'string'
2405
+ && FILE_FIELDS.includes(field) && source === fileChoiceKey(data, field) && fileChoiceAllowed(data, field)
936
2406
  const actions = {
2407
+ acceptsFileChoice,
937
2408
  /**
938
2409
  * Switch tabs, and drop the notice the tab being ENTERED was carrying.
939
2410
  *
@@ -947,9 +2418,260 @@ window.__ModuleLoader__.load({
947
2418
  * it clears nothing.
948
2419
  */
949
2420
  setView: (view) => (view === data.view ? undefined : set({ view, notices: { ...data.notices, [view]: null } })),
950
- selectProject: (projectId) => set({ projectId, diff: null, diffError: null, compareLeft: null, compareRight: null }),
2421
+ selectProject: (projectId) => {
2422
+ if (projectId === data.activeProjectId) { reload(); return }
2423
+ const operation = bundleOperations.get(data.activeProjectId)
2424
+ if (operation && projectId !== data.activeProjectId) void cancelBundle(operation).catch(() => {})
2425
+ set({ projectId, selected: null, activeProjectId: null, editorEntityId: null, editorComparison: null, diff: null, diffError: null, compareLeft: null, compareRight: null })
2426
+ reload()
2427
+ },
951
2428
  reload,
952
- setForm: (field, value) => setIn('forms', field, value),
2429
+ setForm: (field, value) => field === 'patch'
2430
+ ? set({ forms: { ...data.forms, patch: value }, patchDrafts: { ...data.patchDrafts, [data.activeProjectId]: value } })
2431
+ : setIn('forms', field, value),
2432
+ setDisclosure: (key, open) => {
2433
+ if (data.disclosures[key] !== open) set({ disclosures: { ...data.disclosures, [key]: open } })
2434
+ },
2435
+ loadAssets: async (requestedProjectId, requestedRevision) => {
2436
+ const projectId = requestedProjectId || data.activeProjectId
2437
+ const revision = requestedRevision || (data.activeProjectId === projectId ? data.selected?.scene?.revision : null)
2438
+ if (!projectId || !revision) return
2439
+ const sequence = (assetListSequences.get(projectId) || 0) + 1
2440
+ assetListSequences.set(projectId, sequence); assetWork(projectId, { loading: true, error: null })
2441
+ const result = await readRoute(fetchImpl, `${projectRoute(projectId, '/assets')}?revision=${encodeURIComponent(revision)}`, 'project.assets.list')
2442
+ if (sequence !== assetListSequences.get(projectId)) return
2443
+ if (result.status === 'ok') {
2444
+ const previews = { ...data.assetPreviews[projectId] }
2445
+ for (const entry of result.payload.assets || []) {
2446
+ if (entry.preview?.mime === 'image/png' && entry.inspection?.kind === assetKind(entry.asset)) previews[assetKey(entry.asset)] = {
2447
+ projectId, assetId: entry.asset.id, sha256: entry.asset.sha256, assetPath: entry.asset.path,
2448
+ inspection: editorClone(entry.inspection), preview: editorClone(entry.preview),
2449
+ }
2450
+ }
2451
+ set({ assetLibraries: { ...data.assetLibraries, [projectId]: editorClone(result.payload) }, assetPreviews: { ...data.assetPreviews, [projectId]: previews } })
2452
+ }
2453
+ assetWork(projectId, { loading: false, error: result.status === 'ok' ? null : `${result.error.code}: ${result.error.message}` })
2454
+ },
2455
+ setAssetLicense: value => set({ assetLicenses: { ...data.assetLicenses, [data.activeProjectId]: value } }),
2456
+ cancelAsset: async () => {
2457
+ const projectId = data.activeProjectId, current = assetControllers.get(projectId), operation = bundleOperations.get(projectId)
2458
+ // A failed old bundle cleanup may coexist with a newer single-file
2459
+ // upload or preview. Cancel belongs to the task currently running.
2460
+ if (current) { current.abort(); return }
2461
+ if (!operation || operation.committed) return
2462
+ try {
2463
+ const result = await cancelBundle(operation)
2464
+ if (bundleOperations.get(projectId) !== operation || operation.committed) return
2465
+ if (!data.assetWork[projectId]?.busy && result) {
2466
+ bundleOperations.delete(projectId)
2467
+ assetWork(projectId, { error: null, bundleCleanup: false, message: t(result.status === 'completed' ? 'assets.bundleSaved' : result.status === 'unavailable' ? 'assets.bundleUnavailable' : 'assets.cancelled') })
2468
+ if (result.status === 'completed') {
2469
+ if (bundleFiles.get(projectId) === operation.files) {
2470
+ bundleFiles.delete(projectId)
2471
+ set({ assetBundleSelections: { ...data.assetBundleSelections, [projectId]: null } })
2472
+ }
2473
+ await actions.loadAssets(projectId)
2474
+ }
2475
+ }
2476
+ } catch {
2477
+ if (bundleOperations.get(projectId) === operation && !operation.committed) assetWork(projectId, { error: t('assets.bundleCleanup'), bundleCleanup: true })
2478
+ }
2479
+ },
2480
+ selectAssetBundle: (files, mode, source) => {
2481
+ if (!acceptsFileChoice(source, mode === 'directory' ? 'asset-bundle-directory' : 'asset-bundle-files')) return
2482
+ const projectId = data.activeProjectId
2483
+ if (!projectId || data.assetWork[projectId]?.busy) return
2484
+ try {
2485
+ const selected = Array.from(files || []), selection = assetBundleSelection(selected, mode, data.assetLibraries[projectId]?.limits)
2486
+ bundleFiles.set(projectId, selected)
2487
+ set({ assetBundleSelections: { ...data.assetBundleSelections, [projectId]: selection } })
2488
+ assetWork(projectId, { error: null, message: null, bundleProgress: null })
2489
+ } catch (error) {
2490
+ bundleFiles.delete(projectId)
2491
+ set({ assetBundleSelections: { ...data.assetBundleSelections, [projectId]: null } })
2492
+ assetWork(projectId, { error: error.message })
2493
+ }
2494
+ },
2495
+ setAssetBundleEntry: entrypoint => {
2496
+ const projectId = data.activeProjectId, selection = data.assetBundleSelections[projectId]
2497
+ if (!selection || data.assetWork[projectId]?.busy || !selection.entrypoints.includes(entrypoint)) return
2498
+ set({ assetBundleSelections: { ...data.assetBundleSelections, [projectId]: { ...selection, entrypoint } } })
2499
+ },
2500
+ uploadAssetBundle: async () => {
2501
+ const projectId = data.activeProjectId, revision = data.selected?.scene?.revision, selection = data.assetBundleSelections[projectId], files = bundleFiles.get(projectId)
2502
+ if (!projectId || !revision || data.assetWork[projectId]?.busy || bundleOperations.has(projectId)) return
2503
+ if (!selection?.entrypoints.includes(selection.entrypoint) || !files) { assetWork(projectId, { error: t('assets.bundleInvalid') }); return }
2504
+ const license = (data.assetLicenses[projectId] || '').trim()
2505
+ if (license.length > 200) { assetWork(projectId, { error: t('assets.invalid', { field: 'license' }) }); return }
2506
+ const operation = { projectId, files, controller: new AbortController(), uploadId: null, cancelled: false, committed: false, cancellation: null }
2507
+ bundleOperations.set(projectId, operation)
2508
+ assetWork(projectId, { busy: 'uploading', error: null, message: null, bundleProgress: { totalBytes: selection.totalBytes, receivedBytes: 0, path: selection.entrypoint } })
2509
+ let poll = null, polling = false, committed = false
2510
+ const acceptReceipt = result => {
2511
+ if (result?.status !== 'completed' || result.receipt?.projectId !== projectId || result.receipt?.uploadId !== operation.uploadId || !result.receipt?.asset?.path) return false
2512
+ committed = true; operation.committed = true
2513
+ assetWork(projectId, { error: null, bundleCleanup: false, message: `${t('assets.bundleSaved')}${result.receipt.unusedFiles?.length ? ` ${t('assets.bundleUnused', { count: result.receipt.unusedFiles.length })}` : ''}` })
2514
+ return true
2515
+ }
2516
+ try {
2517
+ const created = await bundleRequest(projectId, '', { entrypoint: selection.entrypoint, files: selection.files, ...(license ? { license } : {}) })
2518
+ if (!/^upload-[a-f0-9-]{36}$/.test(created.uploadId || '')) throw new Error(t('assets.bundleInvalid'))
2519
+ operation.uploadId = created.uploadId
2520
+ // Use the same cleanup path as cancellation during file transfer so
2521
+ // an unavailable session is not presented as confirmed cancellation.
2522
+ if (operation.cancelled) throw new Error(t('assets.cancelled'))
2523
+ if (created.files?.length !== files.length || created.files.some((file, i) => file.path !== selection.files[i].path || file.bytes !== selection.files[i].bytes || file.id !== `file-${i}`)) throw new Error(t('assets.bundleInvalid'))
2524
+ poll = setInterval(async () => {
2525
+ if (polling || operation.cancelled || committed) return
2526
+ polling = true
2527
+ try {
2528
+ const status = await bundleRequest(projectId, `/${operation.uploadId}`, undefined, operation.controller.signal)
2529
+ if (bundleOperations.get(projectId) === operation && !operation.controller.signal.aborted && !operation.cancelled && !committed && status.status === 'receiving') assetWork(projectId, { bundleProgress: {
2530
+ ...data.assetWork[projectId]?.bundleProgress, receivedBytes: (status.receivedBytes || 0) + (status.inFlightBytes || 0),
2531
+ } })
2532
+ } catch {} finally { polling = false }
2533
+ }, settings.bundlePollMs ?? 500)
2534
+ for (let i = 0; i < files.length; i++) {
2535
+ if (operation.cancelled) throw new Error(t('assets.cancelled'))
2536
+ assetWork(projectId, { bundleProgress: { ...data.assetWork[projectId]?.bundleProgress, path: selection.files[i].path } })
2537
+ const response = await fetchImpl(projectRoute(projectId, `/asset-uploads/${operation.uploadId}/files/${created.files[i].id}`), {
2538
+ method: 'POST', headers: { 'content-type': 'application/octet-stream', accept: 'application/json' }, body: files[i], signal: operation.controller.signal,
2539
+ })
2540
+ const result = await response.json()
2541
+ if (!response.ok || !result.ok) throw new Error(`${result.error?.code || 'UI_UPLOAD_FAILED'}: ${result.error?.message || response.status}`)
2542
+ if (result.projectId !== projectId || result.uploadId !== operation.uploadId) throw new Error(t('assets.bundleInvalid'))
2543
+ assetWork(projectId, { bundleProgress: { ...data.assetWork[projectId]?.bundleProgress, receivedBytes: result.receivedBytes } })
2544
+ }
2545
+ if (operation.cancelled) throw new Error(t('assets.cancelled'))
2546
+ clearInterval(poll); poll = null
2547
+ assetWork(projectId, { bundleProgress: { ...data.assetWork[projectId]?.bundleProgress, completing: true } })
2548
+ const completed = await bundleRequest(projectId, `/${operation.uploadId}/complete`, {}, operation.controller.signal)
2549
+ if (!acceptReceipt(completed)) throw new Error(t('assets.bundleInvalid'))
2550
+ } catch (error) {
2551
+ // Cancellation also recovers a published receipt if the complete
2552
+ // response was lost. A failed transfer never changes the scene.
2553
+ const userCancelled = operation.cancelled
2554
+ let resolved = !operation.uploadId, unavailable = false
2555
+ if (operation.uploadId) {
2556
+ try { const result = await cancelBundle(operation); resolved = true; unavailable = result.status === 'unavailable'; acceptReceipt(result) } catch {}
2557
+ }
2558
+ if (!committed) assetWork(projectId, { bundleCleanup: !resolved, error: !resolved ? t('assets.bundleCleanup') : unavailable ? t('assets.bundleUnavailable') : userCancelled ? t('assets.cancelled') : `${error.message || String(error)} ${t('assets.bundleDependency')}` })
2559
+ } finally {
2560
+ if (poll !== null) clearInterval(poll)
2561
+ operation.controller.abort()
2562
+ if (committed) {
2563
+ bundleFiles.delete(projectId)
2564
+ set({ assetBundleSelections: { ...data.assetBundleSelections, [projectId]: null } })
2565
+ assetWork(projectId, { busy: 'refreshing', bundleProgress: null })
2566
+ await actions.loadAssets(projectId, data.activeProjectId === projectId ? data.selected?.scene?.revision : revision)
2567
+ } else if (operation.cancelled && !data.assetWork[projectId]?.error) assetWork(projectId, { message: t('assets.cancelled') })
2568
+ // Keep a failed cleanup reachable through Cancel until it succeeds.
2569
+ if (!operation.uploadId || operation.cancellation || committed) bundleOperations.delete(projectId)
2570
+ assetWork(projectId, { busy: null, bundleProgress: null })
2571
+ }
2572
+ },
2573
+ uploadAsset: async (files, source) => {
2574
+ if (!acceptsFileChoice(source, 'asset-upload')) return
2575
+ const projectId = data.activeProjectId, revision = data.selected?.scene?.revision
2576
+ if (!projectId || !revision || data.assetWork[projectId]?.busy) return
2577
+ const limit = data.assetLibraries[projectId]?.limits?.maxBytes
2578
+ if (!Number.isFinite(limit) || limit <= 0) { assetWork(projectId, { error: t('assets.needLibrary') }); return }
2579
+ const selected = Array.from(files || [])
2580
+ if (selected.length !== 1 || !ASSET_TYPES.includes(selected[0].name?.split('.').at(-1)?.toLowerCase())
2581
+ || !Number.isInteger(selected[0].size) || selected[0].size <= 0 || selected[0].size > limit) {
2582
+ assetWork(projectId, { error: t('assets.invalidFile') }); return
2583
+ }
2584
+ const file = selected[0], license = (data.assetLicenses[projectId] || '').trim()
2585
+ if (license.length > 200) { assetWork(projectId, { error: t('assets.invalid', { field: 'license' }) }); return }
2586
+ const controller = new AbortController(); assetControllers.set(projectId, controller)
2587
+ assetWork(projectId, { busy: 'uploading', error: null, message: null })
2588
+ try {
2589
+ const response = await fetchImpl(`${projectRoute(projectId, '/assets')}?name=${encodeURIComponent(file.name)}${license ? `&license=${encodeURIComponent(license)}` : ''}`, {
2590
+ method: 'POST', headers: { 'content-type': file.type || 'application/octet-stream', accept: 'application/json' }, body: file, signal: controller.signal,
2591
+ })
2592
+ const result = await response.json()
2593
+ if (controller.signal.aborted) throw new Error(t('assets.cancelled'))
2594
+ if (!response.ok || !result.ok) throw new Error(`${result.error?.code || 'UI_UPLOAD_FAILED'}: ${result.error?.message || response.status}`)
2595
+ await actions.loadAssets(projectId, data.activeProjectId === projectId ? data.selected?.scene?.revision : revision)
2596
+ } catch (error) { assetWork(projectId, { error: controller.signal.aborted ? t('assets.cancelled') : error.message || String(error) }) }
2597
+ finally { assetControllers.delete(projectId); assetWork(projectId, { busy: null }) }
2598
+ },
2599
+ previewAsset: async key => {
2600
+ const projectId = data.activeProjectId, entry = data.assetLibraries[projectId]?.assets.find(item => assetKey(item.asset) === key)
2601
+ if (!entry || data.assetWork[projectId]?.busy) return
2602
+ const controller = new AbortController(); assetControllers.set(projectId, controller)
2603
+ const previews = { ...data.assetPreviews[projectId] }; delete previews[key]
2604
+ set({ assetPreviews: { ...data.assetPreviews, [projectId]: previews } })
2605
+ assetWork(projectId, { busy: 'inspecting', error: null, message: null })
2606
+ try {
2607
+ const response = await fetchImpl(projectRoute(projectId, `/assets/${encodeURIComponent(entry.asset.id)}/preview`), {
2608
+ method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
2609
+ body: JSON.stringify({ sha256: entry.asset.sha256, assetPath: entry.asset.path }), signal: controller.signal,
2610
+ })
2611
+ const result = await response.json()
2612
+ if (controller.signal.aborted) throw new Error(t('assets.cancelled'))
2613
+ if (!response.ok || !result.ok) throw new Error(`${result.error?.code || 'UI_PREVIEW_FAILED'}: ${result.error?.message || response.status}`)
2614
+ if (result.projectId !== projectId || result.assetId !== entry.asset.id || result.sha256 !== entry.asset.sha256
2615
+ || (result.assetPath !== entry.asset.path && !(result.assetPath === undefined
2616
+ && entry.asset.path === `assets/raw/${entry.asset.sha256}.${entry.asset.type}`))
2617
+ || result.inspection?.kind !== assetKind(entry.asset) || result.preview?.mime !== 'image/png') throw new Error(t('assets.needPreview'))
2618
+ set({ assetPreviews: { ...data.assetPreviews, [projectId]: { ...data.assetPreviews[projectId], [key]: editorClone(result) } } })
2619
+ } catch (error) { assetWork(projectId, { error: controller.signal.aborted ? t('assets.cancelled') : error.message || String(error) }) }
2620
+ finally { assetControllers.delete(projectId); assetWork(projectId, { busy: null }) }
2621
+ },
2622
+ chooseAsset: key => {
2623
+ const projectId = data.activeProjectId, scene = data.selected?.scene
2624
+ if (!scene || !projectId || data.assetWork[projectId]?.busy) return
2625
+ if (data.assetDrafts[projectId]) { assetWork(projectId, { error: t('assets.busyDraft') }); return }
2626
+ const entry = data.assetLibraries[projectId]?.assets.find(item => assetKey(item.asset) === key)
2627
+ const preview = data.assetPreviews[projectId]?.[key]
2628
+ if (!entry || !preview) { assetWork(projectId, { error: t('assets.needPreview') }); return }
2629
+ set({ assetDrafts: { ...data.assetDrafts, [projectId]: createAssetDraft(scene, projectId, entry, preview.inspection, data.editorEntityId) } })
2630
+ assetWork(projectId, { error: null, message: null })
2631
+ },
2632
+ updateAsset: (field, value) => {
2633
+ const projectId = data.activeProjectId, current = data.assetDrafts[projectId]
2634
+ if (!current || data.assetWork[projectId]?.busy) return
2635
+ const allowed = ['newEntityId', 'location', 'rotationEuler', 'scale', 'entityId', 'target', 'partId', 'slotIndex', 'scope', 'newMaterial', 'channel', 'binding', 'replaceTexture', 'strength', 'rotation']
2636
+ if (!allowed.includes(field)) return
2637
+ const draft = editorClone(current); draft[field] = editorClone(value)
2638
+ if (['entityId', 'target'].includes(field)) { draft.partId = ''; draft.slotIndex = '' }
2639
+ if (field === 'partId') draft.slotIndex = ''
2640
+ if (['entityId', 'target', 'partId', 'slotIndex', 'channel', 'newMaterial'].includes(field)) resetAssetBinding(draft)
2641
+ set({ assetDrafts: { ...data.assetDrafts, [projectId]: draft } })
2642
+ },
2643
+ discardAsset: () => {
2644
+ const projectId = data.activeProjectId
2645
+ if (!projectId || data.assetWork[projectId]?.busy) return
2646
+ const drafts = { ...data.assetDrafts }; delete drafts[projectId]
2647
+ set({ assetDrafts: drafts }); assetWork(projectId, { error: null, message: null })
2648
+ },
2649
+ applyAsset: async () => {
2650
+ const projectId = data.activeProjectId, draft = data.assetDrafts[projectId]
2651
+ if (!draft || data.assetWork[projectId]?.busy || data.busy.editor || data.busy.patch || data.busy.restore
2652
+ || data.visualRuns[projectId]?.busy || draft.baseRevision !== data.selected?.scene?.revision) return
2653
+ let patch
2654
+ try { patch = buildAssetPatch(draft) } catch (error) { assetWork(projectId, { error: error.message }); return }
2655
+ const beforePreviews = editorClone(data.previews?.revisions?.find(item => item.revision === draft.baseRevision)?.previews || [])
2656
+ assetWork(projectId, { busy: 'saving', error: null, message: null }); setIn('busy', 'editor', true)
2657
+ const outcome = await postJson(fetchImpl, projectRoute(projectId, '/patch'), { patch })
2658
+ setIn('busy', 'editor', false); assetWork(projectId, { busy: null })
2659
+ if (outcome.ok) {
2660
+ const revision = outcome.payload.revision.revision, drafts = { ...data.assetDrafts }; delete drafts[projectId]
2661
+ const lastEdit = { before: draft.baseRevision, after: revision }
2662
+ set({ assetDrafts: drafts, editorLastEdits: { ...data.editorLastEdits, [projectId]: lastEdit } })
2663
+ assetWork(projectId, { message: t('assets.saved', { revision }) })
2664
+ if (target() === projectId) set({ view: 'preview', compareMode: 'revisions', compareLeft: draft.baseRevision, compareRight: revision,
2665
+ editorComparison: { projectId, ...lastEdit, beforePreviews, afterPreviews: editorClone(outcome.payload.revision.previews || []) } })
2666
+ await actions.loadAssets(projectId, revision)
2667
+ } else assetWork(projectId, { error: `${outcome.error.code}: ${outcome.error.message}` })
2668
+ reload()
2669
+ },
2670
+ selectRecipe: recipe => set({ forms: { ...data.forms, recipe,
2671
+ recipeParameters: recipe ? Object.fromEntries(recipe.parameters.map(parameter => [parameter.id, parameter.default])) : {},
2672
+ title: !data.forms.title || data.forms.title === data.forms.recipe?.title ? recipe?.title || '' : data.forms.title,
2673
+ } }),
2674
+ setRecipeParameter: (id, value) => setIn('forms', 'recipeParameters', { ...data.forms.recipeParameters, [id]: value }),
953
2675
  setCompareMode: (compareMode) => set({ compareMode }),
954
2676
  pickCompare: (side, revision) => set(side === 'left'
955
2677
  ? { compareLeft: revision, diff: null, diffError: null }
@@ -957,6 +2679,303 @@ window.__ModuleLoader__.load({
957
2679
  /** Hand off from 版本 to 预览对比 with that revision on the left. */
958
2680
  compareFrom: (revision) => set({ compareLeft: revision, view: 'preview' }),
959
2681
 
2682
+ updateBrief: (field, value, referenceId) => {
2683
+ const projectId = data.activeProjectId, current = data.briefDrafts[projectId]
2684
+ if (!current || data.briefWork[projectId]?.saving) return
2685
+ const draft = editorClone(current)
2686
+ if (field === 'goal') draft.goal = value
2687
+ else if (field === 'reviewSubjectId') {
2688
+ if (value !== null && !draft.entities.some(entity => entity.id === value && entity.visible !== false && entity.kind !== 'empty')) return
2689
+ draft.reviewSubjectId = value
2690
+ }
2691
+ else if (['label', 'notes', 'purposes'].includes(field)) {
2692
+ const reference = draft.referenceImages.find(item => item.id === referenceId)
2693
+ if (!reference) return
2694
+ reference[field] = editorClone(value)
2695
+ } else return
2696
+ set({ briefDrafts: { ...data.briefDrafts, [projectId]: draft } })
2697
+ },
2698
+ setReviewSubject: entityId => {
2699
+ const projectId = data.activeProjectId, draft = data.briefDrafts[projectId]
2700
+ if (!draft || data.briefWork[projectId]?.saving || data.visualRuns[projectId]?.busy
2701
+ || !draft.entities.some(entity => entity.id === entityId && entity.visible !== false && entity.kind !== 'empty')) return
2702
+ actions.updateBrief('reviewSubjectId', entityId)
2703
+ set({ view: 'projects', briefWork: { ...data.briefWork, [projectId]: { ...data.briefWork[projectId], message: t('brief.subjectShortcutHint') } } })
2704
+ },
2705
+ removeReference: referenceId => {
2706
+ const projectId = data.activeProjectId, current = data.briefDrafts[projectId]
2707
+ if (!current || data.briefWork[projectId]?.saving || data.briefWork[projectId]?.uploading) return
2708
+ set({ briefDrafts: { ...data.briefDrafts, [projectId]: { ...current, referenceImages: current.referenceImages.filter(item => item.id !== referenceId) } } })
2709
+ },
2710
+ resetBrief: () => {
2711
+ const projectId = data.activeProjectId
2712
+ if (!data.selected?.scene || data.briefWork[projectId]?.saving || data.briefWork[projectId]?.uploading) return
2713
+ set({ briefDrafts: { ...data.briefDrafts, [projectId]: createBriefDraft(data.selected.scene, projectId) },
2714
+ briefWork: { ...data.briefWork, [projectId]: {} } })
2715
+ },
2716
+ uploadReferences: async (files, source) => {
2717
+ if (!acceptsFileChoice(source, 'brief-upload')) return
2718
+ const projectId = data.activeProjectId, initial = data.briefDrafts[projectId]
2719
+ if (!initial || data.briefWork[projectId]?.uploading || data.briefWork[projectId]?.saving) return
2720
+ const selected = Array.from(files || [])
2721
+ const work = update => set({ briefWork: { ...data.briefWork, [projectId]: { ...data.briefWork[projectId], ...update } } })
2722
+ if (!selected.length) return
2723
+ if (selected.length + initial.referenceImages.length > 4) { work({ error: t('brief.limit') }); return }
2724
+ if (selected.some(file => !['image/png', 'image/jpeg'].includes(file.type) || !Number.isInteger(file.size) || file.size <= 0 || file.size > REFERENCE_MAX_BYTES)) {
2725
+ work({ error: t('brief.invalidFile') }); return
2726
+ }
2727
+ work({ uploading: true, error: null, message: null })
2728
+ try {
2729
+ for (const file of selected) {
2730
+ const response = await fetchImpl(`${projectRoute(projectId, '/reference-images')}?name=${encodeURIComponent(file.name)}`, {
2731
+ method: 'POST', headers: { 'content-type': file.type, accept: 'application/json' }, body: file,
2732
+ })
2733
+ const result = await response.json()
2734
+ if (!response.ok || !result.ok) throw new Error(`${result.error?.code || 'UI_UPLOAD_FAILED'}: ${result.error?.message || response.status}`)
2735
+ const draft = editorClone(data.briefDrafts[projectId])
2736
+ if (draft.referenceImages.some(reference => reference.sha256 === result.asset.sha256)) { work({ error: t('brief.duplicate') }); continue }
2737
+ draft.pendingAssets[result.asset.id] = editorClone(result.asset)
2738
+ draft.referenceImages.push({ id: `reference-${Date.now().toString(36)}-${++editorSequence}`, assetId: result.asset.id, sha256: result.asset.sha256,
2739
+ label: file.name.slice(0, 160), purposes: ['goalFit'] })
2740
+ set({ briefDrafts: { ...data.briefDrafts, [projectId]: draft } })
2741
+ }
2742
+ } catch (error) { work({ error: error.message || String(error) }) }
2743
+ finally { work({ uploading: false }) }
2744
+ },
2745
+ saveBrief: async () => {
2746
+ const projectId = data.activeProjectId, draft = data.briefDrafts[projectId]
2747
+ if (!draft || data.briefWork[projectId]?.saving || data.briefWork[projectId]?.uploading || data.visualRuns[projectId]?.busy
2748
+ || draft.baseRevision !== data.selected?.scene?.revision || !briefDirty(draft)) return
2749
+ let patch
2750
+ try { patch = buildBriefPatch(draft) } catch (error) { set({ briefWork: { ...data.briefWork, [projectId]: { error: error.message } } }); return }
2751
+ set({ briefWork: { ...data.briefWork, [projectId]: { saving: true, error: null } } })
2752
+ const outcome = await postJson(fetchImpl, projectRoute(projectId, '/patch'), { patch })
2753
+ if (outcome.ok) {
2754
+ const drafts = { ...data.briefDrafts }; delete drafts[projectId]
2755
+ set({ briefDrafts: drafts, briefWork: { ...data.briefWork, [projectId]: { saving: false, message: t('brief.saved', { revision: outcome.payload.revision.revision }) } } })
2756
+ } else set({ briefWork: { ...data.briefWork, [projectId]: { saving: false, error: `${outcome.error.code}: ${outcome.error.message}` } } })
2757
+ reload()
2758
+ },
2759
+ setVisualIterations: value => {
2760
+ if (Number.isInteger(value) && value >= 1 && value <= 3) set({ visualIterations: { ...data.visualIterations, [data.activeProjectId]: value } })
2761
+ },
2762
+ runVisual: async mode => {
2763
+ const projectId = data.activeProjectId, revision = data.selected?.scene?.revision
2764
+ if (!projectId || !revision || !data.briefDrafts[projectId] || !['review', 'autofix'].includes(mode) || data.visualRuns[projectId]?.busy
2765
+ || data.briefWork[projectId]?.saving || data.briefWork[projectId]?.uploading || projectHasUnsavedDrafts(data)
2766
+ || data.busy.editor || data.busy.patch || data.busy.restore) return
2767
+ set({ visualRuns: { ...data.visualRuns, [projectId]: { busy: true, mode, revision } } })
2768
+ const outcome = await postJson(fetchImpl, projectRoute(projectId, `/${mode}`), { revision,
2769
+ ...(mode === 'autofix' ? { maxIterations: data.visualIterations[projectId] || 1 } : {}) })
2770
+ const reviewerError = outcome.payload?.review?.reviewerError || outcome.payload?.review?.referenceInputError?.message
2771
+ const result = outcome.ok ? outcome.payload : null
2772
+ set({ visualRuns: { ...data.visualRuns, [projectId]: { busy: false, mode, revision, result,
2773
+ error: !outcome.ok ? `${outcome.error.code}: ${outcome.error.message}` : reviewerError || null } } })
2774
+ if (target() === projectId) set({ view: 'qa' })
2775
+ reload()
2776
+ },
2777
+
2778
+ selectPhotography: (kind, id) => {
2779
+ const current = photographyDraftFor(data)
2780
+ if (!current || photographyBusy(data.photographyWork[current.projectId]) || !['camera', 'light'].includes(kind) || !current[`${kind}s`].some(item => item.id === id)) return
2781
+ set({ photographyDrafts: { ...data.photographyDrafts, [current.projectId]: { ...current, [`${kind}Id`]: id } } })
2782
+ },
2783
+ resetPhotography: () => {
2784
+ const projectId = data.activeProjectId, scene = data.selected?.scene
2785
+ if (!scene || photographyBusy(data.photographyWork[projectId])) return
2786
+ set({ photographyDrafts: { ...data.photographyDrafts, [projectId]: createPhotographyDraft(scene, projectId, photographyDraftFor(data)) } })
2787
+ },
2788
+ updatePhotography: (kind, path, value) => {
2789
+ const current = photographyDraftFor(data)
2790
+ if (!current || photographyBusy(data.photographyWork[current.projectId]) || current.baseRevision !== data.selected?.scene?.revision) return
2791
+ const draft = editorClone(current)
2792
+ if (kind === 'inspection' && ['frame', 'samples'].includes(path)) draft[path] = value
2793
+ else {
2794
+ if (!['camera', 'light'].includes(kind) || !Array.isArray(path)) return
2795
+ const item = draft[`${kind}s`].find(item => item.id === draft[`${kind}Id`])
2796
+ if (!item) return
2797
+ if (path[0] === 'transform' && path.length === 3 && ['location', 'rotationEuler'].includes(path[1]) && [0, 1, 2].includes(path[2])) {
2798
+ if (editorTrackLocked(draft, kind, item.id, `${path[1]}.${EDITOR_AXES[path[2]]}`) || kind === 'camera' && path[1] === 'rotationEuler' && photographyAim(item) !== 'free') return
2799
+ item.transform ||= {}; item.transform[path[1]] ||= [0, 0, 0]; item.transform[path[1]][path[2]] = value
2800
+ } else if (kind === 'camera' && path.length === 1 && path[0] === 'aim') {
2801
+ if (!['free', 'entity', 'point'].includes(value) || photographyAimLocked(draft, item)) return
2802
+ const original = draft.original.cameras.find(camera => camera.id === item.id)
2803
+ if (value === 'free' && photographyAim(original) !== 'free') return
2804
+ delete item.targetEntityId; delete item.targetPoint
2805
+ if (value === 'entity') item.targetEntityId = draft.entities.find(entity => entity.id === data.editorEntityId)?.id || data.selected?.scene?.project?.reviewSubjectId || draft.entities[0]?.id || ''
2806
+ if (value === 'point') item.targetPoint = [0, 0, 0]
2807
+ if (value !== 'free' && !editorEqual(item.transform?.rotationEuler, original?.transform?.rotationEuler)) {
2808
+ if (original?.transform?.rotationEuler) item.transform.rotationEuler = editorClone(original.transform.rotationEuler)
2809
+ else delete item.transform.rotationEuler
2810
+ }
2811
+ } else if (kind === 'camera' && path.length === 1 && ['lens', 'targetEntityId'].includes(path[0])) {
2812
+ if (path[0] === 'targetEntityId' && (photographyAim(item) !== 'entity' || photographyAimLocked(draft, item))) return
2813
+ item[path[0]] = value
2814
+ } else if (kind === 'camera' && path[0] === 'targetPoint' && path.length === 2 && [0, 1, 2].includes(path[1]) && photographyAim(item) === 'point' && !photographyAimLocked(draft, item)) item.targetPoint[path[1]] = value
2815
+ else if (kind === 'light' && path.length === 1 && ['energy', 'size'].includes(path[0]) && !(item.type === 'sun' && path[0] === 'size')) item[path[0]] = value
2816
+ else if (kind === 'light' && path[0] === 'color' && path.length === 2 && [0, 1, 2].includes(path[1])) { item.color ||= [1, 1, 1]; item.color[path[1]] = value }
2817
+ else return
2818
+ }
2819
+ set({ photographyDrafts: { ...data.photographyDrafts, [current.projectId]: draft } })
2820
+ },
2821
+ addPhotographyLight: () => {
2822
+ const current = photographyDraftFor(data)
2823
+ if (!current || photographyBusy(data.photographyWork[current.projectId]) || current.baseRevision !== data.selected?.scene?.revision) return
2824
+ const draft = editorClone(current)
2825
+ let id
2826
+ do { id = `ui-fill-${Date.now().toString(36)}-${++editorSequence}` } while (draft.lights.some(light => light.id === id))
2827
+ draft.lights.push({ id, type: 'area', energy: 100, color: [1, 1, 1], size: 1, transform: { location: [1, -1, 2], rotationEuler: [0, 0, 0] } })
2828
+ draft.lightId = id
2829
+ set({ photographyDrafts: { ...data.photographyDrafts, [current.projectId]: draft } })
2830
+ },
2831
+ savePhotography: async () => {
2832
+ const draft = photographyDraftFor(data), projectId = draft?.projectId, work = data.photographyWork[projectId]
2833
+ if (!draft || work?.saving || work?.previewing || work?.restoring || data.busy.editor || data.busy.patch || data.busy.restore
2834
+ || draft.baseRevision !== data.selected?.scene?.revision || !photographyDirty(draft)) return
2835
+ let patch
2836
+ try { patch = buildPhotographyPatch(draft) } catch (error) { photographyWork(projectId, { error: error.message }); return }
2837
+ if (!patch.operations.length) return
2838
+ photographyWork(projectId, { saving: true, error: null }); setIn('busy', 'editor', true)
2839
+ const outcome = await postJson(fetchImpl, projectRoute(projectId, '/patch'), { patch })
2840
+ setIn('busy', 'editor', false)
2841
+ if (outcome.ok) {
2842
+ const saved = outcome.payload.revision, drafts = { ...data.photographyDrafts, [projectId]: { ...editorClone(draft), baseRevision: outcome.payload.revision.revision, original: editorClone({ cameras: draft.cameras, lights: draft.lights }) } }
2843
+ set({ photographyDrafts: drafts, photographyEdits: { ...data.photographyEdits, [projectId]: {
2844
+ before: draft.baseRevision, after: saved.revision, digest: saved.digest, cameraId: draft.cameraId, frame: draft.frame, samples: draft.samples,
2845
+ changes: [...new Set(patch.operations.map(operation => operation.op.split('.')[0]))] } } })
2846
+ photographyWork(projectId, { saving: false, error: null, artifact: null })
2847
+ reload()
2848
+ await previewPhotography(projectId)
2849
+ } else {
2850
+ photographyWork(projectId, { saving: false, error: `${outcome.error.code}: ${outcome.error.message}` }); reload()
2851
+ }
2852
+ },
2853
+ retryPhotography: () => previewPhotography(data.activeProjectId),
2854
+ cancelPhotography: () => photographyControllers.get(data.activeProjectId)?.abort(),
2855
+ restorePhotography: async () => {
2856
+ const projectId = data.activeProjectId, edit = data.photographyEdits[projectId], work = data.photographyWork[projectId]
2857
+ if (!edit || work?.saving || work?.previewing || work?.restoring || data.busy.editor || data.busy.patch || data.busy.restore || data.currentRevision !== edit.after) return
2858
+ photographyWork(projectId, { restoring: true, error: null }); setIn('busy', 'restore', true)
2859
+ const outcome = await postJson(fetchImpl, projectRoute(projectId, '/restore'), { revision: edit.before, expectedCurrentRevision: edit.after })
2860
+ setIn('busy', 'restore', false)
2861
+ if (outcome.ok) {
2862
+ const edits = { ...data.photographyEdits }; delete edits[projectId]
2863
+ // Unsaved drafts are retained. The next state read marks their base revision as stale.
2864
+ set({ photographyEdits: edits }); photographyWork(projectId, { restoring: false, artifact: null, error: null })
2865
+ } else photographyWork(projectId, { restoring: false, error: t('photo.restoreFailed', { message: `${outcome.error.code}: ${outcome.error.message}` }) })
2866
+ reload()
2867
+ },
2868
+
2869
+ selectEditorEntity: entityId => {
2870
+ const scene = data.selected?.scene, projectId = data.activeProjectId
2871
+ if (!scene || !projectId) return
2872
+ const key = editorKey(projectId, entityId)
2873
+ const draft = data.editorDrafts[key] || createEditorDraft(scene, projectId, entityId)
2874
+ if (draft) set({ editorEntityId: entityId, editorDrafts: { ...data.editorDrafts, [key]: draft } })
2875
+ },
2876
+ resetEditor: () => {
2877
+ const current = editorDraftFor(data), scene = data.selected?.scene
2878
+ if (!current || !scene || data.busy.editor) return
2879
+ const draft = createEditorDraft(scene, current.projectId, current.entityId)
2880
+ set({ editorDrafts: { ...data.editorDrafts, [editorKey(current.projectId, current.entityId)]: draft }, notices: { ...data.notices, scene: null } })
2881
+ },
2882
+ updateEditor: (section, path, value) => {
2883
+ const current = editorDraftFor(data)
2884
+ if (!current || data.busy.editor || !['transform', 'generator', 'modifiers', 'material'].includes(section)) return
2885
+ if (!Array.isArray(path) || path.some(key => ['__proto__', 'constructor', 'prototype'].includes(String(key)))) return
2886
+ if (section === 'generator' && path[0] === 'bevel' && path.length === 1 && value === undefined && current.entity.generator?.shape === 'rounded_box') return
2887
+ if (section === 'transform' && editorTrackLocked(current, 'entity', current.entityId, `${path[0]}.${EDITOR_AXES[path[1]]}`)) return
2888
+ if (section === 'material' && path[0] === 'definition' && path[1] === 'parameters' && editorMaterialLocked(current, path[2])) return
2889
+ if (section === 'material' && path[0] === 'definition' && path[1] === 'texture' && editorTextureLocked(current)) return
2890
+ const draft = editorClone(current)
2891
+ if (section === 'material' && path.length === 1 && path[0] === 'id') editorSelectMaterial(draft, value)
2892
+ else {
2893
+ const parent = section === 'material' ? draft : draft.entity
2894
+ if (path.length === 0) parent[section] = editorClone(value)
2895
+ else {
2896
+ let object = parent[section]
2897
+ for (const key of path.slice(0, -1)) { object[key] ??= {}; object = object[key] }
2898
+ if (value === undefined) delete object[path[path.length - 1]]
2899
+ else object[path[path.length - 1]] = editorClone(value)
2900
+ }
2901
+ if (section === 'material' && ['target', 'partId', 'slotIndex'].includes(path[0])) {
2902
+ if (path[0] === 'partId') draft.material.slotIndex = ''
2903
+ editorSelectMaterial(draft, editorEffectiveMaterialId(draft))
2904
+ }
2905
+ }
2906
+ set({ editorDrafts: { ...data.editorDrafts, [editorKey(draft.projectId, draft.entityId)]: draft } })
2907
+ },
2908
+ editEditorList: (kind, index, action, type) => {
2909
+ const current = editorDraftFor(data)
2910
+ if (!current || data.busy.editor) return
2911
+ const draft = editorClone(current)
2912
+ const list = kind === 'modifiers' ? draft.entity.modifiers : draft.entity.generator?.[kind]
2913
+ if (!Array.isArray(list)) return
2914
+ if (action === 'add') {
2915
+ if (list.length >= (kind === 'modifiers' ? 8 : 128)) return
2916
+ if (kind === 'modifiers') {
2917
+ const defaults = { bevel: { type, width: 0.001, segments: 3, angle: 30 }, solidify: { type, thickness: 0.002, offset: -1 },
2918
+ array: { type, count: 2, offset: [0.02, 0, 0] }, mirror: { type, axis: 'x', merge: true },
2919
+ boolean: { type, operation: 'difference', targetEntityId: draft.entities.find(item => item.id !== draft.entityId && item.kind !== 'empty')?.id || '' } }
2920
+ if (!defaults[type]) return
2921
+ list.push(defaults[type])
2922
+ } else {
2923
+ const point = [...(list.at(-1) || (kind === 'profile' ? [0.01, 0] : [0, 0, 0]))]
2924
+ point[point.length - 1] += 0.001
2925
+ list.push(point)
2926
+ }
2927
+ } else if (action === 'remove') list.splice(index, 1)
2928
+ else {
2929
+ const next = index + (action === 'up' ? -1 : 1)
2930
+ if (next < 0 || next >= list.length) return
2931
+ ;[list[index], list[next]] = [list[next], list[index]]
2932
+ }
2933
+ set({ editorDrafts: { ...data.editorDrafts, [editorKey(draft.projectId, draft.entityId)]: draft } })
2934
+ },
2935
+ applyEditor: async () => {
2936
+ const draft = editorDraftFor(data)
2937
+ if (!draft || data.busy.editor || draft.baseRevision !== data.selected?.scene?.revision) return
2938
+ let patch
2939
+ try { patch = buildEditorPatch(draft) } catch (error) {
2940
+ setIn('notices', 'scene', { ok: false, message: error.message }); return
2941
+ }
2942
+ if (!patch.operations.length) return
2943
+ const beforePreviews = editorClone(data.previews?.revisions?.find(item => item.revision === draft.baseRevision)?.previews || [])
2944
+ setIn('busy', 'editor', true)
2945
+ const outcome = await postJson(fetchImpl, projectRoute(draft.projectId, '/patch'), { patch })
2946
+ setIn('busy', 'editor', false)
2947
+ if (outcome.ok) {
2948
+ const revision = outcome.payload.revision.revision
2949
+ const drafts = { ...data.editorDrafts }; delete drafts[editorKey(draft.projectId, draft.entityId)]
2950
+ const lastEdit = { before: draft.baseRevision, after: revision }
2951
+ set({ editorDrafts: drafts, editorLastEdits: { ...data.editorLastEdits, [draft.projectId]: lastEdit } })
2952
+ if (target() === draft.projectId) set({ view: 'preview', compareMode: 'revisions', compareLeft: draft.baseRevision, compareRight: revision,
2953
+ editorComparison: { projectId: draft.projectId, ...lastEdit, beforePreviews,
2954
+ afterPreviews: editorClone(outcome.payload.revision.previews || []) },
2955
+ notices: { ...data.notices, scene: { ok: true, message: t('scene.committed', { revision, digest: shortDigest(outcome.payload.revision.digest) }) } } })
2956
+ reload()
2957
+ } else if (target() === draft.projectId) {
2958
+ setIn('notices', 'scene', { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
2959
+ if (outcome.error.code === 'REVISION_CONFLICT') reload()
2960
+ }
2961
+ },
2962
+ restoreEditor: async () => {
2963
+ const projectId = data.activeProjectId, lastEdit = data.editorLastEdits[projectId]
2964
+ if (!lastEdit || data.busy.restore || data.currentRevision !== lastEdit.after) return
2965
+ setIn('busy', 'restore', true)
2966
+ const outcome = await postJson(fetchImpl, projectRoute(projectId, '/restore'), { revision: lastEdit.before, expectedCurrentRevision: lastEdit.after })
2967
+ setIn('busy', 'restore', false)
2968
+ if (outcome.ok) {
2969
+ const edits = { ...data.editorLastEdits }; delete edits[projectId]
2970
+ const drafts = Object.fromEntries(Object.entries(data.editorDrafts).filter(([, item]) => item?.projectId !== projectId))
2971
+ set({ editorDrafts: drafts, editorLastEdits: edits, ...(target() === projectId ? { editorEntityId: null, editorComparison: null } : {}) })
2972
+ }
2973
+ if (target() === projectId) setIn('notices', 'scene', outcome.ok
2974
+ ? { ok: true, message: t('revisions.restored', { from: lastEdit.after, to: lastEdit.before }) }
2975
+ : { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
2976
+ reload()
2977
+ },
2978
+
960
2979
  diff: async (from, to) => {
961
2980
  set({ diffError: null })
962
2981
  try {
@@ -970,22 +2989,30 @@ window.__ModuleLoader__.load({
970
2989
  },
971
2990
 
972
2991
  createProject: async () => {
2992
+ if (data.busy.create) return
2993
+ if (!recipeSelectionCurrent(data) || !recipeValuesValid(data.forms.recipe, data.forms.recipeParameters)) return
2994
+ const withRecipe = Boolean(data.forms.recipe)
973
2995
  setIn('busy', 'create', true)
974
2996
  const outcome = await postJson(fetchImpl, ROUTES.projects, {
975
2997
  title: data.forms.title,
976
2998
  goal: data.forms.goal.length > 0 ? data.forms.goal : undefined,
2999
+ renderPreview: withRecipe,
3000
+ recipe: data.forms.recipe ? { id: data.forms.recipe.id, version: data.forms.recipe.version, digest: data.forms.recipe.digest, parameters: data.forms.recipeParameters } : undefined,
977
3001
  })
978
3002
  setIn('busy', 'create', false)
979
3003
  if (outcome.ok) {
980
- set({ forms: { ...data.forms, title: '', goal: '' }, projectId: outcome.payload.project.projectId })
3004
+ set({ forms: { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} }, projectId: outcome.payload.project.projectId, ...(withRecipe ? { view: 'preview', compareMode: 'result' } : {}) })
981
3005
  setIn('notices', 'projects', { ok: true, message: t('projects.created', { id: outcome.payload.project.projectId }) })
982
3006
  reload()
983
3007
  } else {
984
3008
  setIn('notices', 'projects', { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
3009
+ if (outcome.error.code === 'RECIPE_CHANGED' || outcome.error.code === 'RECIPE_NOT_FOUND') reload()
985
3010
  }
986
3011
  },
987
3012
 
988
3013
  applyPatch: async () => {
3014
+ const projectId = data.activeProjectId
3015
+ if (!projectId || data.busy.patch) return
989
3016
  const text = data.forms.patch
990
3017
  let patch
991
3018
  try {
@@ -995,21 +3022,61 @@ window.__ModuleLoader__.load({
995
3022
  return
996
3023
  }
997
3024
  setIn('busy', 'patch', true)
998
- const outcome = await postJson(fetchImpl, projectRoute(target(), '/patch'), { patch })
3025
+ const outcome = await postJson(fetchImpl, projectRoute(projectId, '/patch'), { patch })
999
3026
  setIn('busy', 'patch', false)
1000
3027
  if (outcome.ok) {
1001
- setIn('notices', 'scene', { ok: true, message: t('scene.committed', { revision: outcome.payload.revision.revision, digest: shortDigest(outcome.payload.revision.digest) }) })
1002
- setIn('forms', 'patch', null)
3028
+ set({ patchDrafts: { ...data.patchDrafts, [projectId]: null } })
3029
+ if (data.activeProjectId === projectId) {
3030
+ setIn('notices', 'scene', { ok: true, message: t('scene.committed', { revision: outcome.payload.revision.revision, digest: shortDigest(outcome.payload.revision.digest) }) })
3031
+ setIn('forms', 'patch', null)
3032
+ }
1003
3033
  reload()
1004
- } else {
3034
+ } else if (data.activeProjectId === projectId) {
1005
3035
  setIn('notices', 'scene', { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
1006
3036
  }
1007
3037
  },
1008
3038
 
3039
+ setInspection: (field, value) => {
3040
+ const projectId = data.activeProjectId, current = inspectionForm(data)
3041
+ if (!projectId || !current || data.inspectionWork[projectId]?.busy || !['cameraId', 'frame', 'mode', 'samples'].includes(field)) return
3042
+ set({ inspectionForms: { ...data.inspectionForms, [projectId]: { ...current, [field]: value } } })
3043
+ },
3044
+ selectInspectionRevision: revision => {
3045
+ const projectId = data.activeProjectId
3046
+ if (!projectId || !data.previews?.revisions?.some(item => item.revision === revision)) return
3047
+ set({ inspectionRevisions: { ...data.inspectionRevisions, [projectId]: revision } })
3048
+ },
3049
+ openCreationStep: step => {
3050
+ const projectId = data.activeProjectId, destination = { goal: 'projects', route: 'projects', parts: 'scene', form: 'preview', detail: 'preview', appearance: 'scene', delivery: 'jobs' }[step]
3051
+ if (!destination) return
3052
+ if (projectId && ['form', 'detail'].includes(step)) actions.setInspection('mode', step === 'form' ? 'clay' : 'beauty')
3053
+ set({ view: destination, guideFocus: { ...data.guideFocus, [projectId || 'new']: step } })
3054
+ },
3055
+ cancelInspection: () => inspectionControllers.get(data.activeProjectId)?.abort(),
3056
+ renderInspection: async () => {
3057
+ const projectId = data.activeProjectId, scene = data.selected?.scene, form = inspectionForm(data)
3058
+ if (!projectId || !scene || data.inspectionWork[projectId]?.busy || data.busy.editor || data.busy.patch || data.busy.restore || data.previewBusy || data.assetWork[projectId]?.busy || data.visualRuns[projectId]?.busy) return
3059
+ if (projectHasUnsavedDrafts(data)) { inspectionWork(projectId, { error: t('inspection.pending') }); return }
3060
+ if (!inspectionFormValid(form, scene)) { inspectionWork(projectId, { error: t('inspection.invalid') }); return }
3061
+ const request = { revision: scene.revision, mode: form.mode, views: [{ id: 'selected', cameraId: form.cameraId, frame: form.frame }], samples: form.samples }
3062
+ const controller = new AbortController(); inspectionControllers.set(projectId, controller)
3063
+ inspectionWork(projectId, { busy: true, error: null, result: null, request: editorClone(request) })
3064
+ try {
3065
+ const response = await fetchImpl(projectRoute(projectId, '/preview'), { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(request), signal: controller.signal })
3066
+ const payload = await response.json()
3067
+ if (controller.signal.aborted) throw new Error(t('inspection.cancelled'))
3068
+ if (!response.ok || !payload.ok) throw new Error(`${payload.error?.code || 'UI_PREVIEW_FAILED'}: ${payload.error?.message || response.status}`)
3069
+ if (!inspectionReceiptValid(payload.preview, request, scene.digest)) throw new Error(t('inspection.mismatch'))
3070
+ inspectionWork(projectId, { result: { revision: request.revision, mode: request.mode, message: t('inspection.saved', { revision: request.revision, camera: form.cameraId, frame: form.frame }) } })
3071
+ set({ inspectionRevisions: { ...data.inspectionRevisions, [projectId]: request.revision } })
3072
+ } catch (error) { inspectionWork(projectId, { error: controller.signal.aborted ? t('inspection.cancelled') : error.message || String(error) }) }
3073
+ finally { inspectionControllers.delete(projectId); inspectionWork(projectId, { busy: false }); reload() }
3074
+ },
3075
+
1009
3076
  renderPreview: async () => {
1010
3077
  set({ previewBusy: true, previewResult: null })
1011
- const outcome = await postJson(fetchImpl, projectRoute(target(), '/preview'), {})
1012
- set({ previewBusy: false })
3078
+ const outcome = await postJson(fetchImpl, projectRoute(target(), '/preview'), { revision: data.selected?.scene?.revision })
3079
+ set({ previewBusy: false, ...(outcome.ok ? { compareMode: 'renders' } : {}) })
1013
3080
  set({
1014
3081
  previewResult: outcome.ok
1015
3082
  ? {
@@ -1026,15 +3093,20 @@ window.__ModuleLoader__.load({
1026
3093
  },
1027
3094
 
1028
3095
  startRender: async (resumeJobId) => {
1029
- setIn('busy', 'render', true)
1030
- const outcome = await postJson(fetchImpl, projectRoute(target(), '/render'), {
1031
- resumeJobId,
3096
+ const projectId = data.activeProjectId, scene = data.selected?.scene
3097
+ if (!projectId || !scene?.revision || data.busy.render) return
3098
+ // Snapshot before notifying subscribers: a project switch or a concurrent
3099
+ // save must not change the scene or settings this click asked to render.
3100
+ const request = resumeJobId === undefined ? {
3101
+ revision: scene.revision,
1032
3102
  frameStart: data.forms.frameStart === '' ? undefined : Number(data.forms.frameStart),
1033
3103
  frameEnd: data.forms.frameEnd === '' ? undefined : Number(data.forms.frameEnd),
1034
- profile: resumeJobId === undefined ? data.forms.profile : undefined,
1035
- })
3104
+ profile: data.forms.profile,
3105
+ } : { resumeJobId }
3106
+ setIn('busy', 'render', true)
3107
+ const outcome = await postJson(fetchImpl, projectRoute(projectId, '/render'), request)
1036
3108
  setIn('busy', 'render', false)
1037
- setIn('notices', 'jobs', outcome.ok
3109
+ if (target() === projectId) setIn('notices', 'jobs', outcome.ok
1038
3110
  ? { kind: 'render', ok: true, message: t('jobs.started', { jobId: outcome.payload.job.jobId, frames: outcome.payload.job.frames || '?' }) }
1039
3111
  : { kind: 'render', ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
1040
3112
  reload()
@@ -1057,11 +3129,13 @@ window.__ModuleLoader__.load({
1057
3129
  },
1058
3130
 
1059
3131
  restoreRevision: async (revision) => {
3132
+ const projectId = data.activeProjectId, currentRevision = data.currentRevision
3133
+ if (!projectId || !currentRevision || data.busy.restore || data.busy.editor) return
1060
3134
  setIn('busy', 'restore', true)
1061
- const outcome = await postJson(fetchImpl, projectRoute(target(), '/restore'), { revision })
3135
+ const outcome = await postJson(fetchImpl, projectRoute(projectId, '/restore'), { revision, expectedCurrentRevision: currentRevision })
1062
3136
  setIn('busy', 'restore', false)
1063
- setIn('notices', 'revisions', outcome.ok
1064
- ? { ok: true, message: t('revisions.restored', { from: revision, to: outcome.payload.revision.revision }) }
3137
+ if (target() === projectId) setIn('notices', 'revisions', outcome.ok
3138
+ ? { ok: true, message: t('revisions.restored', { from: outcome.payload.revision.from || currentRevision, to: outcome.payload.revision.revision }) }
1065
3139
  : { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
1066
3140
  reload()
1067
3141
  },
@@ -1077,11 +3151,22 @@ window.__ModuleLoader__.load({
1077
3151
  start() {
1078
3152
  if (running) return
1079
3153
  running = true
3154
+ fileChoicesStopped = false
1080
3155
  void load()
1081
3156
  },
1082
3157
  stop() {
3158
+ fileChoicesStopped = true
3159
+ data = { ...data, fileChoiceGeneration: data.fileChoiceGeneration + 1 }
1083
3160
  running = false
1084
3161
  if (timer !== null) { clearInterval(timer); timer = null }
3162
+ for (const controller of photographyControllers.values()) controller.abort()
3163
+ photographyControllers.clear()
3164
+ for (const controller of inspectionControllers.values()) controller.abort()
3165
+ inspectionControllers.clear()
3166
+ for (const controller of assetControllers.values()) controller.abort()
3167
+ assetControllers.clear()
3168
+ for (const operation of bundleOperations.values()) void cancelBundle(operation).catch(() => {})
3169
+ bundleFiles.clear()
1085
3170
  },
1086
3171
  actions,
1087
3172
  /** The console's write helper, kept reachable for the tool cards. */
@@ -1127,10 +3212,12 @@ window.__ModuleLoader__.load({
1127
3212
 
1128
3213
  el('div', { className: 'db-card' },
1129
3214
  el('h4', null, t('projects.create')),
3215
+ RecipesView(ctx),
1130
3216
  el('div', { className: 'db-inline' },
1131
3217
  el('input', {
1132
3218
  className: 'db-input',
1133
3219
  'data-field': 'project-title',
3220
+ 'aria-label': t('projects.titlePlaceholder'),
1134
3221
  placeholder: t('projects.titlePlaceholder'),
1135
3222
  value: state.forms.title,
1136
3223
  onChange: event => actions.setForm('title', event.target.value),
@@ -1138,6 +3225,7 @@ window.__ModuleLoader__.load({
1138
3225
  el('input', {
1139
3226
  className: 'db-input',
1140
3227
  'data-field': 'project-goal',
3228
+ 'aria-label': t('projects.goal'),
1141
3229
  placeholder: t('projects.goal'),
1142
3230
  value: state.forms.goal,
1143
3231
  onChange: event => actions.setForm('goal', event.target.value),
@@ -1145,14 +3233,15 @@ window.__ModuleLoader__.load({
1145
3233
  Button({
1146
3234
  tone: 'primary',
1147
3235
  action: 'create-project',
1148
- disabled: state.busy.create || state.forms.title.trim().length === 0,
3236
+ disabled: state.busy.create || state.forms.title.trim().length === 0 || !recipeSelectionCurrent(state) || !recipeValuesValid(state.forms.recipe, state.forms.recipeParameters),
1149
3237
  onClick: actions.createProject,
1150
- children: state.busy.create ? t('projects.creating') : t('projects.createButton'),
3238
+ children: state.busy.create ? (state.forms.recipe ? t('recipes.creatingPreview') : t('projects.creating')) : (state.forms.recipe ? t('recipes.createPreview') : t('projects.createButton')),
1151
3239
  }),
1152
3240
  ),
1153
3241
  state.notices.projects ? Notice(state.notices.projects, { marginTop: '8px' }) : null,
1154
3242
  ),
1155
3243
 
3244
+ BriefEditor({ state, actions }),
1156
3245
  state.selected ? el('div', { className: 'db-card' },
1157
3246
  el('h4', null, t('projects.currentProject', { title: state.selected.project.title })),
1158
3247
  KeyValues({ entries: [
@@ -1172,6 +3261,144 @@ window.__ModuleLoader__.load({
1172
3261
  )
1173
3262
  }
1174
3263
 
3264
+ // HTML color controls use sRGB; SceneSpec stores scene-linear channels.
3265
+ function recipeColorHex(value) {
3266
+ return '#' + value.map(channel => {
3267
+ const linear = Math.max(0, Math.min(1, channel))
3268
+ const srgb = linear <= 0.0031308 ? 12.92 * linear : 1.055 * Math.pow(linear, 1 / 2.4) - 0.055
3269
+ return Math.round(srgb * 255).toString(16).padStart(2, '0')
3270
+ }).join('')
3271
+ }
3272
+ function recipeColorLinear(hex) {
3273
+ return [1, 3, 5].map(index => {
3274
+ const srgb = parseInt(hex.slice(index, index + 2), 16) / 255
3275
+ return srgb <= 0.04045 ? srgb / 12.92 : Math.pow((srgb + 0.055) / 1.055, 2.4)
3276
+ })
3277
+ }
3278
+ function recipeSelectionCurrent(state) {
3279
+ const recipe = state.forms.recipe
3280
+ return !recipe || state.recipeCatalog.recipes.some(item => item.id === recipe.id && item.version === recipe.version && item.digest === recipe.digest)
3281
+ }
3282
+ function recipeValuesValid(recipe, values) {
3283
+ return !recipe || recipe.parameters.every(parameter => {
3284
+ const value = values[parameter.id]
3285
+ return parameter.type === 'color' ? Array.isArray(value) && value.length === 3 && value.every(v => Number.isFinite(v) && v >= 0 && v <= 1)
3286
+ : Number.isFinite(value) && value >= parameter.minimum && value <= parameter.maximum
3287
+ })
3288
+ }
3289
+ function RecipesView({ state, actions }) {
3290
+ const catalog = state.recipeCatalog || { recipes: [], errors: [] }
3291
+ const selected = state.forms.recipe
3292
+ return el('section', { 'aria-label': t('recipes.heading'), style: { marginBottom: '16px' } },
3293
+ el('h5', null, t('recipes.heading')),
3294
+ el('p', { className: 'db-muted' }, t('recipes.previewNote')),
3295
+ !recipeSelectionCurrent(state) ? el('p', { role: 'alert', className: 'db-error' }, t('recipes.stale')) : null,
3296
+ catalog.errors.length ? el('p', { className: 'db-error' }, t('recipes.unavailable')) : null,
3297
+ Button({ action: 'recipe-blank', tone: !selected ? 'primary' : undefined,
3298
+ onClick: () => actions.selectRecipe(null), children: t('recipes.blank') }),
3299
+ el('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '12px', marginTop: '10px' } },
3300
+ catalog.recipes.map(recipe => {
3301
+ const chosen = selected?.id === recipe.id && selected?.version === recipe.version && selected?.digest === recipe.digest
3302
+ return el('article', { key: `${recipe.id}@${recipe.version}`, 'data-recipe': recipe.id,
3303
+ style: { border: chosen ? '2px solid var(--db-accent, #4f8cff)' : '1px solid var(--db-border, #555)', borderRadius: '8px', padding: '10px' } },
3304
+ el('img', { src: recipe.previewUrl, alt: recipe.title, loading: 'lazy', style: { width: '100%', aspectRatio: '4 / 3', objectFit: 'contain', borderRadius: '4px' } }),
3305
+ el('h5', null, recipe.title), el('p', { className: 'db-muted' }, recipe.description),
3306
+ el('p', { className: 'db-muted' }, `${t('recipes.license')}: ${recipe.author.name} · ${recipe.license} · v${recipe.version}`),
3307
+ Button({ action: `select-recipe:${recipe.id}@${recipe.version}`, tone: chosen ? 'primary' : undefined,
3308
+ onClick: () => actions.selectRecipe(recipe), children: chosen ? t('recipes.selected') : t('recipes.select') }),
3309
+ )
3310
+ })),
3311
+ selected ? el('fieldset', { style: { marginTop: '12px', borderRadius: '6px' } },
3312
+ el('legend', null, t('recipes.parameters')),
3313
+ Button({ action: 'reset-recipe', onClick: () => actions.selectRecipe(selected), children: t('recipes.reset') }),
3314
+ el('div', { className: 'db-inline', style: { flexWrap: 'wrap' } }, selected.parameters.map(parameter =>
3315
+ el('label', { key: parameter.id, style: { display: 'flex', flexDirection: 'column', gap: '5px' } },
3316
+ parameter.title,
3317
+ parameter.description ? el('span', { className: 'db-muted', style: { maxWidth: '260px' } }, parameter.description) : null,
3318
+ parameter.type !== 'color' ? el('span', { className: 'db-muted' }, t('recipes.range', { min: parameter.minimum, max: parameter.maximum })) : null,
3319
+ !recipeValuesValid({ parameters: [parameter] }, state.forms.recipeParameters) ? el('span', { className: 'db-error', role: 'alert' }, t('recipes.invalid')) : null,
3320
+ parameter.type === 'color' ? el('input', { type: 'color', 'data-field': `recipe-${parameter.id}`, 'aria-label': parameter.title,
3321
+ value: recipeColorHex(state.forms.recipeParameters[parameter.id]), onChange: event => actions.setRecipeParameter(parameter.id, recipeColorLinear(event.target.value)) })
3322
+ : el('input', { className: 'db-input', type: 'number', step: 'any', min: parameter.minimum, max: parameter.maximum,
3323
+ 'data-field': `recipe-${parameter.id}`, 'aria-label': parameter.title, value: state.forms.recipeParameters[parameter.id],
3324
+ onChange: event => actions.setRecipeParameter(parameter.id, event.target.value === '' ? '' : Number(event.target.value)) }),
3325
+ ))),
3326
+ ) : null,
3327
+ )
3328
+ }
3329
+
3330
+ function VisualActions({ state, actions }) {
3331
+ const projectId = state.activeProjectId, run = state.visualRuns[projectId], work = state.briefWork[projectId]
3332
+ const dirty = projectHasUnsavedDrafts(state), disabled = !projectId || !state.briefDrafts[projectId] || run?.busy || work?.saving || work?.uploading || dirty
3333
+ || state.busy.editor || state.busy.patch || state.busy.restore
3334
+ return el('div', { className: 'db-card', 'data-visual-actions': true, 'data-review-busy': String(Boolean(run?.busy)) },
3335
+ el('p', { className: 'db-muted' }, t('brief.reviewCost')),
3336
+ dirty ? el('p', { className: 'db-muted' }, t('brief.pending')) : null,
3337
+ el('div', { className: 'db-inline' },
3338
+ Button({ action: 'project-review', disabled, onClick: () => actions.runVisual('review'), children: t('brief.review') }),
3339
+ el('label', null, t('brief.iterations'), el('select', { className: 'db-input', 'data-field': 'review-iterations', value: state.visualIterations[projectId] || 1,
3340
+ disabled, onChange: event => actions.setVisualIterations(Number(event.target.value)) }, [1, 2, 3].map(value => el('option', { key: value, value }, String(value))))),
3341
+ Button({ action: 'project-autofix', disabled, onClick: () => actions.runVisual('autofix'), children: t('brief.autofix') })),
3342
+ run?.busy ? el('p', { role: 'status' }, run.mode === 'review' ? t('brief.reviewing') : t('brief.fixing')) : null,
3343
+ run?.error ? el('p', { className: 'db-error', role: 'alert', 'data-review-error': true }, run.error) : null,
3344
+ run?.result && !run.error ? el('p', { 'data-review-result': run.mode, className: 'db-muted' }, run.mode === 'review'
3345
+ ? t('brief.reviewDone', { revision: run.result.revision })
3346
+ : t('brief.fixDone', { revision: run.result.run?.finalRevision || run.revision, reason: run.result.run?.stopReason || '—' })) : null)
3347
+ }
3348
+
3349
+ function BriefEditor(ctx) {
3350
+ const { state, actions } = ctx, projectId = state.activeProjectId, draft = state.briefDrafts[projectId]
3351
+ if (!draft) return null
3352
+ const work = state.briefWork[projectId] || {}, conflict = draft.baseRevision !== state.selected?.scene?.revision
3353
+ const disabled = Boolean(work.saving || state.visualRuns[projectId]?.busy), dirty = briefDirty(draft)
3354
+ const assetOf = reference => draft.pendingAssets[reference.assetId] || draft.assets.find(asset => asset.id === reference.assetId)
3355
+ const subjects = draft.entities.filter(entity => entity.visible !== false && entity.kind !== 'empty')
3356
+ const selectedUnavailable = draft.reviewSubjectId !== null && !subjects.some(entity => entity.id === draft.reviewSubjectId)
3357
+ const resolved = draft.reviewSubject
3358
+ return el('section', { className: 'db-card', 'data-brief-project': projectId, 'data-brief-base-revision': draft.baseRevision,
3359
+ 'data-brief-dirty': String(dirty), 'data-brief-conflict': String(conflict) },
3360
+ el('h4', null, t('brief.title')),
3361
+ el('label', null, t('brief.goal'), el('textarea', { className: 'db-area', 'data-field': 'brief-goal', maxLength: 2000,
3362
+ value: draft.goal, disabled, onChange: event => actions.updateBrief('goal', event.target.value) })),
3363
+ el('label', { className: 'db-row' }, t('brief.subject'), el('select', { className: 'db-input', 'data-field': 'brief-review-subject', value: draft.reviewSubjectId ?? '', disabled,
3364
+ onChange: event => actions.updateBrief('reviewSubjectId', event.target.value || null) },
3365
+ el('option', { value: '' }, t('brief.subjectAuto')),
3366
+ selectedUnavailable ? el('option', { value: draft.reviewSubjectId, disabled: true }, t('brief.subjectUnavailableOption', { id: draft.reviewSubjectId })) : null,
3367
+ subjects.map(entity => el('option', { key: entity.id, value: entity.id }, (entity.tags || []).includes('environment') ? t('brief.subjectEnvironment', { id: entity.id }) : entity.id)))),
3368
+ el('p', { className: 'db-muted' }, t('brief.subjectHelp')),
3369
+ el('div', { 'data-review-subject-resolution': true, 'data-review-subject-id': resolved?.id || '',
3370
+ 'data-review-subject-mode': resolved?.mode || '', 'data-review-subject-available': String(resolved?.available === true) },
3371
+ el('p', { className: 'db-muted' }, `${t('brief.subjectSaved')}: ${resolved?.id || t('brief.subjectUnknown')}`),
3372
+ resolved?.source ? el('p', { className: 'db-muted' }, `${t('brief.subjectReason')}: ${resolved.source}`) : null,
3373
+ resolved?.available === false ? el('p', { className: 'db-error', role: 'status' }, t('brief.subjectUnavailable', { reason: resolved.reason || t('brief.subjectUnknown') })) : null),
3374
+ el('p', { className: conflict ? 'db-error' : 'db-muted' }, conflict ? t('brief.conflict') : dirty ? t('brief.pending') : t('brief.clean')),
3375
+ el('p', { className: 'db-muted' }, t('brief.uploadHelp')),
3376
+ el('label', null, t('brief.upload'), el('input', { type: 'file', accept: 'image/png,image/jpeg', multiple: true,
3377
+ ...fileChoiceProps(state, actions, 'brief-upload', (event, source) => actions.uploadReferences(event.target.files, source)) })),
3378
+ work.uploading ? el('p', { role: 'status' }, t('brief.uploading')) : null,
3379
+ el('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px', marginTop: '12px' } }, draft.referenceImages.map(reference => {
3380
+ const asset = assetOf(reference)
3381
+ return el('article', { key: reference.id, className: 'db-card', 'data-reference-id': reference.id, 'data-reference-sha256': reference.sha256 },
3382
+ asset?.path ? el('img', { alt: reference.label, src: artifactUrl(`/deepblend/artifacts/${encodeURIComponent(projectId)}/`, { path: asset.path, sha256: reference.sha256 }),
3383
+ style: { width: '100%', height: '150px', objectFit: 'contain' } }) : null,
3384
+ el('label', null, t('brief.label'), el('input', { className: 'db-input', 'data-field': `reference-${reference.id}-label`, value: reference.label, maxLength: 160, disabled,
3385
+ onChange: event => actions.updateBrief('label', event.target.value, reference.id) })),
3386
+ el('fieldset', { disabled }, el('legend', null, t('brief.purposes')), REFERENCE_PURPOSES.map(purpose =>
3387
+ el('label', { key: purpose, className: 'db-row' }, artisticDimensionLabel(purpose), el('input', { type: 'checkbox', 'data-field': `reference-${reference.id}-${purpose}`, checked: reference.purposes.includes(purpose),
3388
+ onChange: event => actions.updateBrief('purposes', event.target.checked ? [...reference.purposes, purpose] : reference.purposes.filter(item => item !== purpose), reference.id) })))),
3389
+ el('label', null, t('brief.notes'), el('textarea', { className: 'db-area', style: { minHeight: '64px' }, 'data-field': `reference-${reference.id}-notes`, value: reference.notes || '', maxLength: 1000, disabled,
3390
+ onChange: event => actions.updateBrief('notes', event.target.value, reference.id) })),
3391
+ Button({ action: `reference-remove:${reference.id}`, disabled: disabled || work.uploading, onClick: () => actions.removeReference(reference.id), children: t('brief.remove') }))
3392
+ })),
3393
+ dirty && !briefValid(draft) ? el('p', { className: 'db-error', role: 'alert' }, t('brief.invalid')) : null,
3394
+ work.error ? el('p', { className: 'db-error', role: 'alert', 'data-brief-error': true }, work.error) : null,
3395
+ work.message ? el('p', { role: 'status', 'data-brief-result': true }, work.message) : null,
3396
+ el('div', { className: 'db-inline' },
3397
+ Button({ action: 'brief-save', disabled: disabled || work.uploading || conflict || !dirty || !briefValid(draft), onClick: actions.saveBrief, children: work.saving ? t('brief.saving') : t('brief.save') }),
3398
+ Button({ action: 'brief-reset', disabled: disabled || work.uploading, onClick: actions.resetBrief, children: t('brief.reset') })),
3399
+ VisualActions(ctx))
3400
+ }
3401
+
1175
3402
  /** A result line, in the two shapes the panel already used. */
1176
3403
  function Notice(result, style) {
1177
3404
  if (result === null || result === undefined) return null
@@ -1183,7 +3410,390 @@ window.__ModuleLoader__.load({
1183
3410
  }, result.message)
1184
3411
  }
1185
3412
 
3413
+ function SceneEditor(ctx) {
3414
+ const { state, actions } = ctx, draft = editorDraftFor(state)
3415
+ const lastEdit = state.editorLastEdits?.[state.activeProjectId]
3416
+ const stackDisclosure = JSON.stringify([state.activeProjectId, draft?.entityId, 'modifiers'])
3417
+ const restore = lastEdit ? Button({ action: 'editor-restore', children: t('editor.restore'),
3418
+ disabled: state.busy.restore || state.busy.editor || state.currentRevision !== lastEdit.after, onClick: actions.restoreEditor }) : null
3419
+ if (!draft) return el('div', { className: 'db-card', 'data-scene-editor': true }, el('h4', null, t('editor.title')), t('editor.choose'), restore)
3420
+ const conflict = draft.baseRevision !== state.selected?.scene?.revision
3421
+ const disabled = state.busy.editor || conflict
3422
+ const dirty = editorDirty(draft), errors = editorErrors(draft)
3423
+ const labels = {
3424
+ wallThickness: t('editor.wallThickness'),
3425
+ baseThickness: t('editor.baseThickness'),
3426
+ footRound: t('editor.footRound'),
3427
+ handleRadius: t('editor.handleRadius'),
3428
+ handleLower: t('editor.handleLower'),
3429
+ handleUpper: t('editor.handleUpper'),
3430
+ rootRadius: t('editor.rootRadius'),
3431
+ rootLength: t('editor.rootLength'),
3432
+ rootTension: t('editor.rootTension'),
3433
+ sectionSegments: t('editor.sectionSegments'),
3434
+ handleSegments: t('editor.handleSegments'),
3435
+ rootSegments: t('editor.rootSegments'),
3436
+ wallRows: t('editor.wallRows'),
3437
+ height: t('editor.depth'), size: t('editor.size'), radius: t('editor.radius'), depth: t('editor.depth'), majorRadius: t('editor.majorRadius'), minorRadius: t('editor.minorRadius'),
3438
+ segments: t('editor.segments'), ringCount: t('editor.ringCount'), curveResolution: t('editor.curveResolution'), bevelResolution: t('editor.bevelResolution'),
3439
+ }
3440
+ const modifierNames = { bevel: t('editor.bevel'), solidify: t('editor.solidify'), array: t('editor.array'), mirror: t('editor.mirror'), boolean: t('editor.boolean') }
3441
+ const numeric = (label, field, value, update, options = {}) => {
3442
+ const factor = options.factor ?? 1
3443
+ const displayed = Number.isFinite(value) ? Math.round(value * factor * 1e9) / 1e9 : ''
3444
+ return el('label', { className: 'db-row', key: field }, el('span', null, label), el('input', {
3445
+ className: 'db-input', type: 'number', 'data-field': field, value: displayed, step: options.step ?? (options.integer ? 1 : 'any'),
3446
+ min: options.min, max: options.max, disabled: disabled || options.disabled === true,
3447
+ style: { width: '125px' }, onChange: event => update(event.target.value === '' ? null : Number(event.target.value) / factor),
3448
+ }))
3449
+ }
3450
+ const choose = (label, field, value, items, update, off = false) => el('label', { className: 'db-row', key: field }, el('span', null, label), el('select', {
3451
+ className: 'db-input', 'data-field': field, value, disabled: disabled || off, style: { maxWidth: '65%' }, onChange: event => update(event.target.value),
3452
+ }, items.map(([id, title]) => el('option', { key: id, value: id }, title))))
3453
+ const toggle = (label, field, value, update, off = false) => el('label', { className: 'db-row', key: field }, el('span', null, label), el('input', {
3454
+ type: 'checkbox', 'data-field': field, checked: value, disabled: disabled || off, onChange: event => update(event.target.checked),
3455
+ }))
3456
+ const vector = (title, prefix, value, update, factor = 1000, locks = []) => el('div', null, el('strong', null, title),
3457
+ EDITOR_AXES.map((axis, index) => numeric(axis.toUpperCase(), `${prefix}-${axis}`, value?.[index], next => update(index, next), { factor, disabled: locks[index] })))
3458
+ const geometry = draft.entity.generator
3459
+ const geometryField = key => {
3460
+ const bounds = generatorIntegerBounds(geometry, key)
3461
+ if (key === 'rootTension') return numeric(labels[key], `editor-generator-${key}`, geometry[key],
3462
+ value => actions.updateEditor('generator', [key], value), { factor: 1, min: 1, max: 2.5, step: .05 })
3463
+ return numeric(labels[key], `editor-generator-${key}`, geometry[key], value => actions.updateEditor('generator', [key], value), {
3464
+ factor: bounds ? 1 : 1000, integer: Boolean(bounds), min: bounds?.[0], max: bounds?.[1], step: bounds?.[2],
3465
+ })
3466
+ }
3467
+ const points = (key, title) => {
3468
+ const rows = geometry[key].map((point, index) => {
3469
+ const cells = point.map((value, axis) => el('td', { key: axis }, numeric(
3470
+ key === 'profile' ? ['R', 'Z'][axis] : EDITOR_AXES[axis].toUpperCase(),
3471
+ `editor-generator-${key}-${index}-${axis}`, value,
3472
+ next => actions.updateEditor('generator', [key, index, axis], next), { factor: 1000 })))
3473
+ const controls = el('td', null,
3474
+ Button({ action: `editor-${key}-${index}-up`, disabled: disabled || index === 0, children: t('editor.up'), onClick: () => actions.editEditorList(key, index, 'up') }),
3475
+ Button({ action: `editor-${key}-${index}-down`, disabled: disabled || index === geometry[key].length - 1, children: t('editor.down'), onClick: () => actions.editEditorList(key, index, 'down') }),
3476
+ Button({ action: `editor-${key}-${index}-remove`, disabled: disabled || geometry[key].length <= (geometry.pathClosed || geometry.closedProfile ? 3 : 2), children: t('editor.remove'), onClick: () => actions.editEditorList(key, index, 'remove') }))
3477
+ return el('tr', { key: index }, el('td', null, String(index + 1)), cells, controls)
3478
+ })
3479
+ return el('div', { style: { overflowX: 'auto', maxHeight: '320px', overflowY: 'auto', marginTop: '10px' } },
3480
+ el('strong', null, title), el('table', { className: 'db-table', 'data-editor-points': key }, el('tbody', null, rows)),
3481
+ Button({ action: `editor-${key}-add`, disabled: disabled || geometry[key].length >= 128, children: t('editor.addPoint'), onClick: () => actions.editEditorList(key, 0, 'add') }))
3482
+ }
3483
+ const material = draft.material.definition, parameters = material?.parameters || {}
3484
+ const parts = draft.original.assetParts || [], part = parts.find(item => item.partId === draft.material.partId)
3485
+ const affected = draft.entities.filter(entity => entity.materialId === draft.material.id
3486
+ || entity.materialBindings?.some(binding => binding.materialId === draft.material.id)
3487
+ || entity.assetParts?.some(item => item.materialSlots?.some(slot => slot.materialId === draft.material.id))).map(entity => entity.id)
3488
+ const canModify = draft.entity.kind === 'generator' || (draft.entity.kind === 'asset-instance' && parts.length === 1)
3489
+ return el('div', { className: 'db-card', 'data-scene-editor': true, 'data-editor-entity': draft.entityId, 'data-editor-project': draft.projectId,
3490
+ 'data-editor-base-revision': draft.baseRevision, 'data-editor-dirty': String(dirty), 'data-editor-conflict': String(conflict) },
3491
+ el('h4', null, `${t('editor.title')} · ${draft.entityId}`),
3492
+ el('div', { className: conflict ? 'db-error' : 'db-muted' }, conflict ? t('editor.conflict') : dirty ? t('editor.pending') : t('editor.clean')),
3493
+ el('div', { className: 'db-grid', style: { marginTop: '12px' } },
3494
+ el('div', { className: 'db-card' },
3495
+ vector(t('editor.position'), 'editor-location', draft.entity.transform.location, (axis, value) => actions.updateEditor('transform', ['location', axis], value), 1000,
3496
+ EDITOR_AXES.map(axis => editorTrackLocked(draft, 'entity', draft.entityId, `location.${axis}`))),
3497
+ vector(t('editor.rotation'), 'editor-rotation', draft.entity.transform.rotationEuler, (axis, value) => actions.updateEditor('transform', ['rotationEuler', axis], value), 180 / Math.PI,
3498
+ EDITOR_AXES.map(axis => editorTrackLocked(draft, 'entity', draft.entityId, `rotationEuler.${axis}`))),
3499
+ draft.tracks.some(track => track.targetKind === 'entity' && track.targetId === draft.entityId) ? el('p', { className: 'db-muted' }, t('editor.animated')) : null),
3500
+ draft.entity.kind === 'generator' ? el('div', { className: 'db-card' }, el('h4', null, t('editor.geometry')),
3501
+ geometry ? [
3502
+ el('strong', { key: 'shape' }, geometry.shape === 'handled_cup' ? t('editor.handledCup') : geometry.shape),
3503
+ el('p', { className: 'db-muted', key: 'local-size' }, t('editor.localDimensions', { scale: (draft.entity.transform.scale || [1, 1, 1]).join(' × ') })),
3504
+ geometry.shape === 'rounded_box' ? el('p', { className: 'db-muted', key: 'rounded' }, t('editor.roundedRequired')) : null,
3505
+ geometry.shape === 'handled_cup' ? el('p', { className: 'db-muted', key: 'cup-help' }, t('editor.cupHelp')) : null,
3506
+ ...(GENERATOR_FIELDS[geometry.shape] || []).filter(key => geometry.shape !== 'handled_cup' || !CUP_ADVANCED_FIELDS.includes(key)).map(geometryField),
3507
+ geometry.shape === 'handled_cup' ? el('details', { key: 'cup-advanced' },
3508
+ el('summary', null, t('editor.cupAdvanced')), ...CUP_ADVANCED_FIELDS.map(geometryField)) : null,
3509
+ toggle(t('editor.bevel'), 'editor-generator-bevel', Boolean(geometry.bevel), value => actions.updateEditor('generator', ['bevel'], value ? { width: 0.001, segments: 3 } : undefined), geometry.shape === 'rounded_box'),
3510
+ geometry.bevel ? numeric(t('editor.width'), 'editor-generator-bevel-width', editorNumberDefault(geometry.bevel.width, 0.01), value => actions.updateEditor('generator', ['bevel', 'width'], value), { factor: 1000, min: 0 }) : null,
3511
+ geometry.bevel ? numeric(t('editor.bevelSegments'), 'editor-generator-bevel-segments', editorNumberDefault(geometry.bevel.segments, 3), value => actions.updateEditor('generator', ['bevel', 'segments'], value), { integer: true, min: 1, max: 16 }) : null,
3512
+ geometry.shape === 'lathe' ? toggle(t('editor.closed'), 'editor-generator-closedProfile', geometry.closedProfile === true, value => actions.updateEditor('generator', ['closedProfile'], value)) : null,
3513
+ geometry.shape === 'curve' ? toggle(t('editor.closed'), 'editor-generator-pathClosed', geometry.pathClosed === true, value => actions.updateEditor('generator', ['pathClosed'], value)) : null,
3514
+ ['lathe', 'curve'].includes(geometry.shape) ? toggle(t('editor.cap'), 'editor-generator-capEnds', geometry.capEnds !== false, value => actions.updateEditor('generator', ['capEnds'], value)) : null,
3515
+ geometry.shape === 'curve' ? choose(t('editor.interpolation'), 'editor-generator-pathInterpolation', geometry.pathInterpolation || 'poly', [['poly', t('editor.poly')], ['bezier', t('editor.bezier')]], value => actions.updateEditor('generator', ['pathInterpolation'], value)) : null,
3516
+ geometry.profile ? points('profile', t('editor.profile')) : null,
3517
+ geometry.path ? points('path', t('editor.path')) : null,
3518
+ ] : t('editor.unavailable')) : null,
3519
+ draft.entity.kind !== 'empty' ? el('div', { className: 'db-card' }, el('h4', null, t('editor.material')),
3520
+ draft.entity.kind === 'asset-instance' ? [
3521
+ el('p', { className: 'db-muted', key: 'asset-notice' }, t('editor.assetNotice')),
3522
+ choose(t('editor.material'), 'editor-material-target', draft.material.target, [['entity', t('editor.assetWhole')], ...(parts.length ? [['part', t('editor.assetPart')], ['slot', t('editor.assetSlot')]] : [])], value => actions.updateEditor('material', ['target'], value)),
3523
+ parts.length === 0 ? el('p', { className: 'db-muted', key: 'missing-parts' }, t('editor.noParts')) : null,
3524
+ draft.material.target !== 'entity' ? choose(t('editor.part'), 'editor-material-part', draft.material.partId, [['', '—'], ...parts.map(item => [item.partId, item.partId])], value => actions.updateEditor('material', ['partId'], value)) : null,
3525
+ draft.material.target === 'slot' ? choose(t('editor.slot'), 'editor-material-slot', draft.material.slotIndex, [['', '—'], ...(part?.sourceMaterialSlots || []).map(slot => [String(slot.index), `${slot.index} · ${slot.materialName || '—'}`])], value => actions.updateEditor('material', ['slotIndex'], value)) : null,
3526
+ ] : null,
3527
+ choose(t('editor.material'), 'editor-material-id', draft.material.id, [['', t('editor.keepMaterial')], ...draft.materials.map(item => [item.id, item.id])], value => actions.updateEditor('material', ['id'], value)),
3528
+ material ? [
3529
+ choose(t('editor.material'), 'editor-material-scope', draft.material.scope, [['local', t('editor.local')], ['shared', t('editor.shared')]], value => actions.updateEditor('material', ['scope'], value)),
3530
+ draft.material.scope === 'shared' ? el('p', { className: 'db-muted', 'data-editor-affected': true, key: 'affected' }, t('editor.affected', { targets: affected.join(', ') || '—' })) : null,
3531
+ el('label', { className: 'db-row', key: 'color' }, t('editor.color'), el('input', { className: 'db-input', type: 'color', 'data-field': 'editor-material-color',
3532
+ disabled: disabled || editorMaterialLocked(draft, 'baseColor'), value: recipeColorHex((parameters.baseColor || [0.8, 0.8, 0.8]).slice(0, 3)),
3533
+ onChange: event => actions.updateEditor('material', ['definition', 'parameters', 'baseColor'], [...recipeColorLinear(event.target.value), parameters.baseColor?.[3] ?? 1]) })),
3534
+ numeric(t('editor.roughness'), 'editor-material-roughness', editorNumberDefault(parameters.roughness, material.shader === 'glass' ? 0.05 : 0.5), value => actions.updateEditor('material', ['definition', 'parameters', 'roughness'], value), { min: 0, max: 1, disabled: editorMaterialLocked(draft, 'roughness') }),
3535
+ el('fieldset', { className: 'db-card', 'data-texture-editor': true, disabled: disabled || editorTextureLocked(draft) },
3536
+ el('legend', null, t('editor.texture.surface')),
3537
+ choose(t('editor.texture.pattern'), 'editor-texture-type', material.texture?.type || '',
3538
+ [['', t('editor.texture.noTexture')], ['noise', t('editor.texture.noise')], ['wave', t('editor.texture.wave')], ['voronoi', t('editor.texture.voronoi')]],
3539
+ value => actions.updateEditor('material', ['definition', 'texture'], value ? { ...(material.texture || { scale: 20, bump: 0.01 }), type: value } : undefined)),
3540
+ material.texture ? [
3541
+ choose(t('editor.texture.coordinates'), 'editor-texture-coordinates', material.texture.coordinates || 'object',
3542
+ [['object', t('editor.texture.objectCoordinates')], ['uv', t('editor.texture.uvCoordinates')]], value => {
3543
+ const texture = editorClone(material.texture); texture.coordinates = value
3544
+ if (value !== 'uv') delete texture.uvMap
3545
+ actions.updateEditor('material', ['definition', 'texture'], texture)
3546
+ }),
3547
+ material.texture.coordinates === 'uv' ? el('label', { className: 'db-row' }, el('span', null, t('editor.texture.uvMap')), el('input', {
3548
+ className: 'db-input', type: 'text', style: { maxWidth: '65%' }, 'data-field': 'editor-texture-uvMap', value: material.texture.uvMap ?? '',
3549
+ onChange: event => actions.updateEditor('material', ['definition', 'texture', 'uvMap'], event.target.value === '' ? undefined : event.target.value),
3550
+ })) : null,
3551
+ numeric(t('editor.texture.density'), 'editor-texture-scale', material.texture.scale,
3552
+ value => actions.updateEditor('material', ['definition', 'texture', 'scale'], value), { min: Number.MIN_VALUE }),
3553
+ el('div', null, el('strong', null, t('editor.texture.stretch')), EDITOR_AXES.map((axis, index) =>
3554
+ numeric((material.texture.coordinates === 'uv' ? ['U', 'V', 'W'][index] : axis.toUpperCase()), 'editor-texture-stretch-' + axis, editorNumberDefault(material.texture.stretch?.[index], 1),
3555
+ value => { const stretch = [...(material.texture.stretch || [1, 1, 1])]; stretch[index] = value; actions.updateEditor('material', ['definition', 'texture', 'stretch'], stretch) }, { min: Number.MIN_VALUE }))),
3556
+ ...[['detail', t('editor.texture.detail'), 0, 16], ['distortion', t('editor.texture.distortion'), 0, undefined], ['bump', t('editor.texture.bump'), 0, 1], ['roughnessVariation', t('editor.texture.roughnessVariation'), 0, 1], ['colorVariation', t('editor.texture.colorVariation'), 0, 1]].map(([key, label, min, max]) =>
3557
+ numeric(label, 'editor-texture-' + key, material.texture[key],
3558
+ value => actions.updateEditor('material', ['definition', 'texture', key], value === null ? undefined : value), { min, max })),
3559
+ el('p', { className: 'db-muted' }, t('editor.texture.textureHelp')),
3560
+ ] : null),
3561
+ editorTextureLocked(draft) ? el('p', { className: 'db-muted', 'data-texture-locked': true }, t('editor.texture.textureLocked')) : null,
3562
+ editorMaterialLocked(draft, 'baseColor') || editorMaterialLocked(draft, 'roughness') ? el('p', { className: 'db-muted', key: 'maps' }, t('editor.mapDriven')) : null,
3563
+ ] : null) : null),
3564
+ draft.entity.kind !== 'empty' ? el('details', { key: stackDisclosure, 'data-disclosure': stackDisclosure, open: state.disclosures?.[stackDisclosure] ?? true,
3565
+ onToggle: event => actions.setDisclosure(stackDisclosure, event.currentTarget.open), className: 'db-card' }, el('summary', null, t('editor.operations')),
3566
+ el('p', { className: 'db-muted' }, t('editor.stackNotice')),
3567
+ !canModify ? el('p', { className: 'db-muted' }, t('editor.unavailable')) : null,
3568
+ draft.entity.modifiers.map((modifier, index) => {
3569
+ const prefix = `editor-modifier-${index}`, update = (key, value) => actions.updateEditor('modifiers', [index, ...key], value)
3570
+ return el('fieldset', { key: index, disabled: disabled || !canModify, className: 'db-card', 'data-modifier-index': index },
3571
+ el('legend', null, `${index + 1}. ${modifierNames[modifier.type] || modifier.type}`),
3572
+ modifier.type === 'bevel' ? [numeric(t('editor.width'), `${prefix}-width`, modifier.width, value => update(['width'], value), { factor: 1000 }),
3573
+ numeric(t('editor.bevelSegments'), `${prefix}-segments`, editorNumberDefault(modifier.segments, 4), value => update(['segments'], value), { integer: true, min: 1, max: 16 }),
3574
+ numeric(t('editor.angle'), `${prefix}-angle`, editorNumberDefault(modifier.angle, 30), value => update(['angle'], value), { min: 0, max: 180 }),
3575
+ choose(t('editor.miterInner'), `${prefix}-miterInner`, modifier.miterInner ?? 'arc', [['arc', t('editor.miterArc')], ['sharp', t('editor.miterSharp')]], value => update(['miterInner'], value)),
3576
+ el('p', { className: 'db-muted', 'data-miter-help': true }, t('editor.miterHelp'))] : null,
3577
+ modifier.type === 'solidify' ? [numeric(t('editor.thickness'), `${prefix}-thickness`, modifier.thickness, value => update(['thickness'], value), { factor: 1000 }),
3578
+ numeric(t('editor.offset'), `${prefix}-offset`, editorNumberDefault(modifier.offset, -1), value => update(['offset'], value), { min: -1, max: 1 })] : null,
3579
+ modifier.type === 'array' ? [numeric(t('editor.count'), `${prefix}-count`, modifier.count, value => update(['count'], value), { integer: true, min: 2, max: 64 }),
3580
+ vector(t('editor.spacing'), `${prefix}-offset`, modifier.offset, (axis, value) => update(['offset', axis], value))] : null,
3581
+ modifier.type === 'mirror' ? [choose(t('editor.axis'), `${prefix}-axis`, modifier.axis, EDITOR_AXES.map(axis => [axis, axis.toUpperCase()]), value => update(['axis'], value)),
3582
+ toggle(t('editor.merge'), `${prefix}-merge`, modifier.merge !== false, value => update(['merge'], value))] : null,
3583
+ modifier.type === 'boolean' ? [choose(t('editor.boolean'), `${prefix}-operation`, modifier.operation, [['union', t('editor.union')], ['difference', t('editor.difference')], ['intersect', t('editor.intersect')]], value => update(['operation'], value)),
3584
+ choose(t('editor.operand'), `${prefix}-targetEntityId`, modifier.targetEntityId, [['', '—'], ...draft.entities.filter(item => item.id !== draft.entityId && item.kind !== 'empty').map(item => [item.id, item.id])], value => update(['targetEntityId'], value))] : null,
3585
+ el('div', { className: 'db-inline' },
3586
+ Button({ action: `${prefix}-up`, disabled: disabled || !canModify || index === 0, children: t('editor.up'), onClick: () => actions.editEditorList('modifiers', index, 'up') }),
3587
+ Button({ action: `${prefix}-down`, disabled: disabled || !canModify || index === draft.entity.modifiers.length - 1, children: t('editor.down'), onClick: () => actions.editEditorList('modifiers', index, 'down') }),
3588
+ Button({ action: `${prefix}-remove`, disabled: disabled || !canModify, children: t('editor.remove'), onClick: () => actions.editEditorList('modifiers', index, 'remove') })))
3589
+ }),
3590
+ el('div', { className: 'db-inline' }, Object.entries(modifierNames).map(([type, name]) => Button({ action: `editor-modifier-add-${type}`, disabled: disabled || !canModify || draft.entity.modifiers.length >= 8,
3591
+ children: t('editor.add', { name }), onClick: () => actions.editEditorList('modifiers', 0, 'add', type) })))) : null,
3592
+ errors.length ? el('div', { className: 'db-error', 'data-editor-errors': true }, errors.join(' ')) : null,
3593
+ el('div', { className: 'db-inline' }, Button({ action: 'editor-apply', tone: 'primary', disabled: disabled || !dirty || errors.length > 0, onClick: actions.applyEditor, children: t('editor.apply') }),
3594
+ Button({ action: 'editor-reset', disabled: state.busy.editor, onClick: actions.resetEditor, children: t('editor.reset') }),
3595
+ Button({ action: 'editor-review-subject', disabled: disabled || draft.entity.visible === false || draft.entity.kind === 'empty'
3596
+ || state.briefWork[state.activeProjectId]?.saving || state.visualRuns[state.activeProjectId]?.busy,
3597
+ onClick: () => actions.setReviewSubject(draft.entityId), children: t('brief.subjectShortcut') }), restore),
3598
+ Notice(state.notices.scene))
3599
+ }
3600
+
1186
3601
  /** 场景树: the Scene Tree of the revision in view. */
3602
+ function AssetsView({ state, actions }) {
3603
+ const projectId = state.activeProjectId, library = state.assetLibraries?.[projectId], work = state.assetWork?.[projectId] || {}
3604
+ const draft = state.assetDrafts?.[projectId], busy = Boolean(work.busy), limits = library?.limits, bundle = state.assetBundleSelections?.[projectId]
3605
+ const conflict = Boolean(draft && draft.baseRevision !== state.selected?.scene?.revision)
3606
+ const update = (field, value) => actions.updateAsset(field, value)
3607
+ const text = (label, field, value, change, extra = {}) => el('label', { className: 'db-row' }, label,
3608
+ el('input', { className: 'db-input', 'aria-label': label, 'data-field': field, value, disabled: busy, onChange: event => change(event.target.value), ...extra }))
3609
+ const number = (label, field, value, change, extra = {}) => text(label, field, value === null ? '' : value, value => change(value === '' ? null : Number(value)), { type: 'number', step: 'any', ...extra })
3610
+ const choose = (label, field, value, entries, change) => el('label', { className: 'db-row' }, label,
3611
+ el('select', { className: 'db-input', 'aria-label': label, 'data-field': field, value, disabled: busy, onChange: event => change(event.target.value) },
3612
+ entries.map(([id, name]) => el('option', { value: id, key: id }, name))))
3613
+ const check = (label, field, value, change) => el('label', { className: 'db-row' },
3614
+ el('input', { type: 'checkbox', 'data-field': field, checked: value, disabled: busy, onChange: event => change(event.target.checked) }), label)
3615
+ const vector = (name, field, values, factor) => el('div', { className: 'db-inline' }, EDITOR_AXES.map((axis, index) => number(`${name} ${axis}`, `asset-${field}-${axis}`,
3616
+ values[index] === null ? '' : values[index] * factor, value => { const next = [...values]; next[index] = value === null ? null : value / factor; update(field, next) })))
3617
+ let editor, material, part, error, affected = []
3618
+ if (draft) {
3619
+ error = assetDraftError(draft)
3620
+ if (draft.kind === 'image') {
3621
+ editor = assetEditor(draft); material = editor?.material.definition
3622
+ part = editor?.original.assetParts?.find(item => item.partId === draft.partId)
3623
+ affected = draft.scene.nodes.entities.filter(entity => entity.materialId === material?.id
3624
+ || entity.materialBindings?.some(binding => binding.materialId === material?.id)
3625
+ || entity.assetParts?.some(item => item.materialSlots?.some(slot => slot.materialId === material?.id))).map(entity => entity.id)
3626
+ }
3627
+ }
3628
+ const mapSetting = (key, value) => { const binding = editorClone(draft.binding); if (value === undefined) delete binding[key]; else binding[key] = value; update('binding', binding) }
3629
+ return el('section', { className: 'db-card', 'data-assets-library': projectId, 'data-asset-base-revision': draft?.baseRevision || '', 'data-asset-conflict': String(conflict) },
3630
+ el('h4', null, t('assets.title')), el('p', { className: 'db-muted' }, t('assets.help')),
3631
+ Button({ action: 'assets-open', disabled: busy || work.loading, onClick: () => actions.loadAssets(), children: library ? t('assets.refresh') : t('assets.open') }),
3632
+ work.error ? el('p', { role: 'status', className: 'db-error' }, work.error) : null,
3633
+ work.message ? el('p', { role: 'status' }, work.message) : null,
3634
+ library ? el('div', null,
3635
+ el('p', { className: 'db-muted', 'data-asset-limits': true }, t('assets.limits', { bytes: assetBytes(limits?.maxBytes), pixels: limits?.maxImagePixels ?? '—', edge: limits?.maxImageEdge ?? '—', width: limits?.previewWidth ?? '—', height: limits?.previewHeight ?? '—' })),
3636
+ text(t('assets.license'), 'asset-license', state.assetLicenses?.[projectId] || '', actions.setAssetLicense, { maxLength: 200 }),
3637
+ el('label', null, t('assets.upload'), el('input', { type: 'file', accept: '.glb,.png,.jpg,.jpeg,.hdr,.exr',
3638
+ ...fileChoiceProps(state, actions, 'asset-upload', (event, source) => actions.uploadAsset(event.target.files, source)) })),
3639
+ limits?.bundleUpload ? el('div', { className: 'db-card', 'data-asset-bundle-picker': true },
3640
+ el('h5', null, t('assets.bundleTitle')), el('p', { className: 'db-muted' }, t('assets.bundleHelp')),
3641
+ el('p', { className: 'db-muted' }, t('assets.bundleLimits', { files: limits.bundleUpload.maxFiles, bytes: assetBytes(limits.maxBytes) })),
3642
+ el('label', { className: 'db-row' }, t('assets.bundleFiles'), el('input', { type: 'file', multiple: true,
3643
+ ...fileChoiceProps(state, actions, 'asset-bundle-files', (event, source) => { actions.selectAssetBundle(event.target.files, 'files', source); event.target.value = '' }) })),
3644
+ el('label', { className: 'db-row' }, t('assets.bundleDirectory'), el('input', { type: 'file', multiple: true, webkitdirectory: '',
3645
+ ...fileChoiceProps(state, actions, 'asset-bundle-directory', (event, source) => { actions.selectAssetBundle(event.target.files, 'directory', source); event.target.value = '' }) })),
3646
+ bundle ? el('div', null,
3647
+ el('p', null, t('assets.bundleSelection', { files: bundle.files.length, bytes: assetBytes(bundle.totalBytes) })),
3648
+ choose(t('assets.bundleEntry'), 'asset-bundle-entrypoint', bundle.entrypoint, [['', t('assets.bundleChooseEntry')], ...bundle.entrypoints.map(path => [path, path])], actions.setAssetBundleEntry),
3649
+ el('details', null, el('summary', null, t('assets.bundleFiles')), el('ul', { className: 'db-list' }, bundle.files.map(file => el('li', { key: file.path, style: { overflowWrap: 'anywhere' } }, `${file.path} · ${assetBytes(file.bytes)}`)))),
3650
+ Button({ action: 'asset-bundle-upload', disabled: busy || work.bundleCleanup || !bundle.entrypoint, onClick: actions.uploadAssetBundle, children: t('assets.bundleStart') })) : null,
3651
+ work.bundleProgress ? el('p', { role: 'status', 'data-asset-bundle-progress': true, style: { overflowWrap: 'anywhere' } }, work.bundleProgress.completing ? t('assets.bundleCompleting') : t('assets.bundleProgress', {
3652
+ received: assetBytes(work.bundleProgress.receivedBytes), total: assetBytes(work.bundleProgress.totalBytes), path: work.bundleProgress.path,
3653
+ })) : null) : null,
3654
+ (busy && !['saving', 'refreshing'].includes(work.busy)) || work.bundleCleanup ? el('div', { className: 'db-inline' }, busy ? el('span', { role: 'status' }, t('assets.working', { action: work.busy === 'uploading' ? t('assets.uploading') : t('assets.inspecting') })) : null,
3655
+ Button({ action: 'asset-cancel', onClick: actions.cancelAsset, children: t('assets.cancel') })) : null,
3656
+ el('div', { className: 'db-grid', style: { marginTop: '12px' } }, (library.assets || []).map(entry => {
3657
+ const key = assetKey(entry.asset), result = state.assetPreviews?.[projectId]?.[key]
3658
+ // Reserve the recorded PNG ratio before decoding, so controls below
3659
+ // thumbnails stay in place as the standalone view is rebuilt.
3660
+ const dimensions = Number.isSafeInteger(result?.preview?.width) && result.preview.width > 0
3661
+ && Number.isSafeInteger(result?.preview?.height) && result.preview.height > 0
3662
+ ? { width: result.preview.width, height: result.preview.height } : {}
3663
+ const declared = state.selected?.scene?.nodes.assets.some(asset => asset.id === entry.asset.id && asset.sha256 === entry.asset.sha256 && asset.path === entry.asset.path)
3664
+ return el('article', { className: 'db-card', key, 'data-asset-id': entry.asset.id, 'data-asset-sha256': entry.asset.sha256, 'data-asset-path': entry.asset.path },
3665
+ el('h4', null, entry.originalName || entry.asset.id), el('p', null, `${entry.asset.type.toUpperCase()} · ${assetBytes(entry.bytes)}`),
3666
+ entry.bundle ? el('p', null, t('assets.bundleSummary', { files: entry.bundle.files.length, bytes: assetBytes(entry.bundle.totalBytes) })) : null,
3667
+ el('p', { className: 'db-muted' }, declared ? t('assets.declared') : t('assets.staged')),
3668
+ el('p', { className: 'db-muted' }, entry.license || t('assets.noLicense')),
3669
+ el('p', { className: 'db-muted' }, result ? t('assets.inspected') : t('assets.pending')),
3670
+ result ? el('div', null, el('img', { 'data-asset-preview': entry.asset.id, alt: entry.originalName || entry.asset.id,
3671
+ ...dimensions, src: artifactUrl(`/deepblend/artifacts/${encodeURIComponent(projectId)}/`, result.preview), style: { width: '100%', height: 'auto', maxHeight: '240px', objectFit: 'contain' } }),
3672
+ result.inspection.kind === 'environment' && result.preview.toneMapped ? el('p', { className: 'db-muted' }, t('assets.toneMapped')) : null,
3673
+ result.inspection.dimensions ? el('p', null, `${t('assets.dimensions')}: ${result.inspection.dimensions.map(value => Number.isFinite(value) ? Number(value.toFixed(4)) : '—').join(' × ')}`) : null,
3674
+ result.inspection.image ? el('p', null, `${result.inspection.image.width} × ${result.inspection.image.height}`) : null,
3675
+ result.inspection.warnings?.length ? el('h5', null, t('assets.inspectionWarnings')) : null,
3676
+ (result.inspection.warnings || []).map((warning, index) => el('p', { className: 'db-muted', key: index }, typeof warning === 'string' ? warning : warning.message || warning.code || '')),
3677
+ result.inspection.parts?.length ? el('ul', { className: 'db-list' }, result.inspection.parts.map(item => el('li', { key: item.partId },
3678
+ item.partId, ' · ', (item.sourceMaterialSlots || []).map(slot => `${slot.index}: ${slot.materialName || '—'}`).join(', '),
3679
+ ' · UV: ', (item.uvMaps || []).map(uv => typeof uv === 'string' ? uv : uv.name).join(', ') || '—'))) : null) : null,
3680
+ el('div', { className: 'db-inline' },
3681
+ Button({ action: `asset-preview:${entry.asset.id}`, disabled: busy, onClick: () => actions.previewAsset(key), children: t('assets.inspect') }),
3682
+ Button({ action: `asset-use:${entry.asset.id}`, disabled: busy || !result || Boolean(draft), onClick: () => actions.chooseAsset(key), children: t('assets.choose') })))
3683
+ }))) : null,
3684
+ draft ? el('div', { className: 'db-card', 'data-asset-draft': draft.kind },
3685
+ el('h4', null, `${{ model: t('assets.model'), image: t('assets.image'), environment: t('assets.environment') }[draft.kind]} · ${draft.entry.originalName || draft.entry.asset.id}`),
3686
+ conflict ? el('p', { className: 'db-error' }, t('assets.conflict')) : null,
3687
+ draft.kind === 'model' ? el('div', null,
3688
+ el('p', { className: 'db-muted' }, t('assets.modelHelp')),
3689
+ text(t('assets.entityId'), 'asset-entity-id', draft.newEntityId, value => update('newEntityId', value)),
3690
+ vector(t('editor.position'), 'location', draft.location, 1000), vector(t('editor.rotation'), 'rotationEuler', draft.rotationEuler, 180 / Math.PI),
3691
+ number(t('assets.scale'), 'asset-scale', draft.scale ?? '', value => update('scale', value), { min: 0.000001 })) : null,
3692
+ draft.kind === 'environment' ? el('div', null,
3693
+ el('p', { className: 'db-muted' }, t('assets.environmentHelp')),
3694
+ number(t('assets.strength'), 'asset-world-strength', draft.strength ?? '', value => update('strength', value), { min: 0, max: 1000 }),
3695
+ number(t('assets.rotation'), 'asset-world-rotation', draft.rotation === null ? '' : draft.rotation * 180 / Math.PI, value => update('rotation', value === null ? null : value * Math.PI / 180))) : null,
3696
+ draft.kind === 'image' ? el('div', null,
3697
+ el('p', { className: 'db-muted' }, t('assets.imageHelp')),
3698
+ choose(t('assets.target'), 'asset-target-entity', draft.entityId, [['', '—'], ...draft.scene.nodes.entities.filter(entity => entity.kind !== 'empty').map(entity => [entity.id, entity.id])], value => update('entityId', value)),
3699
+ editor?.original.kind === 'asset-instance' ? el('div', null,
3700
+ choose(t('editor.material'), 'asset-material-target', draft.target, [['entity', t('editor.assetWhole')], ['part', t('assets.partDefault')], ['slot', t('editor.slot')]], value => update('target', value)),
3701
+ el('p', { className: 'db-muted' }, t('editor.assetNotice')),
3702
+ draft.target !== 'entity' ? choose(t('editor.part'), 'asset-material-part', draft.partId, [['', '—'], ...(editor.original.assetParts || []).map(item => [item.partId, item.partId])], value => update('partId', value)) : null,
3703
+ draft.target === 'slot' ? choose(t('editor.slot'), 'asset-material-slot', draft.slotIndex, [['', '—'], ...(part?.sourceMaterialSlots || []).map(slot => [String(slot.index), `${slot.index}: ${slot.materialName || '—'}`])], value => update('slotIndex', value)) : null) : null,
3704
+ el('p', null, `${t('assets.material')}: ${material?.id || '—'}`),
3705
+ !material || !['principled', 'glass'].includes(material.shader) ? el('p', { className: 'db-muted' }, t('assets.nativeHelp')) : null,
3706
+ check(t('assets.newMaterial'), 'asset-new-material', draft.newMaterial, value => update('newMaterial', value)),
3707
+ !draft.newMaterial && material ? choose(t('editor.material'), 'asset-material-scope', draft.scope, [['local', t('editor.local')], ['shared', t('editor.shared')]], value => update('scope', value)) : null,
3708
+ !draft.newMaterial && draft.scope === 'shared' ? el('p', { className: 'db-muted' }, t('editor.affected', { targets: affected.join(', ') || '—' })) : null,
3709
+ choose(t('assets.channel'), 'asset-map-channel', draft.channel, ASSET_CHANNELS.map(channel => [channel, { baseColor: t('assets.channel.baseColor'), roughness: t('assets.channel.roughness'), metallic: t('assets.channel.metallic'), normal: t('assets.channel.normal'), alpha: t('assets.channel.alpha'), emissionColor: t('assets.channel.emissionColor') }[channel]]), value => update('channel', value)),
3710
+ text(t('assets.uv'), 'asset-uv-map', draft.binding.uvMap || '', value => mapSetting('uvMap', value.trim() || undefined)),
3711
+ ...['scale', 'offset'].map(key => el('div', { className: 'db-inline', key }, ['u', 'v'].map((axis, index) => number(`${key === 'scale' ? t('assets.tile') : t('assets.offset')} ${axis}`, `asset-map-${key}-${axis}`, (draft.binding[key] || (key === 'scale' ? [1, 1, 1] : [0, 0, 0]))[index], value => {
3712
+ const values = [...(draft.binding[key] || (key === 'scale' ? [1, 1, 1] : [0, 0, 0]))]; values[index] = value; mapSetting(key, values)
3713
+ })))),
3714
+ ['roughness', 'metallic', 'alpha'].includes(draft.channel) ? choose(t('assets.scalarChannel'), 'asset-scalar-channel', draft.binding.channel || 'r', ['r', 'g', 'b', 'a'].map(value => [value, value]), value => mapSetting('channel', value)) : null,
3715
+ draft.channel === 'normal' ? number(t('assets.normalStrength'), 'asset-normal-strength', editorNumberDefault(draft.binding.strength, 1), value => mapSetting('strength', value), { min: 0, max: 10 }) : null,
3716
+ draft.channel === 'emissionColor' ? el('p', { className: 'db-muted' }, t('assets.emissionHelp')) : null,
3717
+ !draft.newMaterial && material?.texture ? check(t('assets.replaceTexture'), 'asset-replace-texture', draft.replaceTexture, value => update('replaceTexture', value)) : null) : null,
3718
+ error ? el('p', { className: 'db-error' }, error) : null,
3719
+ el('div', { className: 'db-inline' }, Button({ action: 'asset-apply', tone: 'primary', disabled: busy || conflict || Boolean(error) || state.busy.editor || state.busy.patch || state.busy.restore || state.visualRuns[projectId]?.busy,
3720
+ onClick: actions.applyAsset, children: t('assets.apply') }), Button({ action: 'asset-reset', disabled: busy, onClick: actions.discardAsset, children: t('assets.reset') }))) : null)
3721
+ }
3722
+
3723
+ function PhotographyEditor({ state, actions }) {
3724
+ const draft = photographyDraftFor(state), projectId = state.activeProjectId, work = state.photographyWork?.[projectId] || {}, edit = state.photographyEdits?.[projectId]
3725
+ const busy = photographyBusy(work)
3726
+ const conflict = draft && draft.baseRevision !== state.selected?.scene?.revision
3727
+ const disabled = busy || conflict, errors = draft ? photographyErrors(draft) : []
3728
+ const camera = draft?.cameras.find(item => item.id === draft.cameraId), light = draft?.lights.find(item => item.id === draft.lightId)
3729
+ const update = (kind, path) => value => actions.updatePhotography(kind, path, value)
3730
+ const numeric = (label, field, value, change, options = {}) => el('label', { className: 'db-row' }, label, el('input', {
3731
+ className: 'db-input', type: 'number', 'data-field': `photo-${field}`, value: Number.isFinite(value) ? Math.round(value * (options.factor || 1) * 1e9) / 1e9 : '',
3732
+ min: options.min, max: options.max, step: options.step || 'any', disabled: disabled || options.locked, style: { width: '125px' },
3733
+ onChange: event => change(event.target.value === '' ? null : Number(event.target.value) / (options.factor || 1)),
3734
+ }))
3735
+ const choose = (label, field, value, items, change, locked = false) => el('label', { className: 'db-row' }, label, el('select', {
3736
+ className: 'db-input', 'data-field': `photo-${field}`, value, disabled: disabled || locked,
3737
+ onChange: event => change(event.target.value),
3738
+ }, items.map(([id, title]) => el('option', { key: id, value: id }, title))))
3739
+ const vector = (kind, field, value, title, factor = 1, locked = false) => el('div', null, el('strong', null, title), EDITOR_AXES.map((axis, index) =>
3740
+ numeric(axis.toUpperCase(), `${kind}-${field}-${axis}`, editorNumberDefault(value?.[index], 0), update(kind, field === 'targetPoint' ? [field, index] : ['transform', field, index]),
3741
+ { factor, locked: locked || editorTrackLocked(draft, kind, kind === 'camera' ? camera.id : light.id, `${field}.${axis}`) })))
3742
+ const aim = photographyAim(camera), originalCamera = draft?.original.cameras.find(item => item.id === camera?.id)
3743
+ const aimLocked = camera && photographyAimLocked(draft, camera)
3744
+ return el('section', { className: 'db-card', 'data-photography-editor': projectId },
3745
+ el('h4', null, t('photo.title')), el('p', { className: 'db-muted' }, t('photo.help')),
3746
+ draft ? el('div', null,
3747
+ el('p', { className: 'db-muted', 'data-photography-base': draft.baseRevision }, t('photo.base', { revision: draft.baseRevision })),
3748
+ conflict ? el('p', { className: 'db-error', 'data-photography-conflict': true }, t('photo.conflict', { before: draft.baseRevision, current: state.selected?.scene?.revision })) : null,
3749
+ el('div', { className: 'db-grid' },
3750
+ el('div', null,
3751
+ choose(t('photo.camera'), 'camera', draft.cameraId, draft.cameras.map(item => [item.id, item.id]), id => actions.selectPhotography('camera', id)),
3752
+ camera ? el('div', null,
3753
+ numeric(t('photo.lens'), 'camera-lens', editorNumberDefault(camera.lens, 50), update('camera', ['lens']), { min: .001 }),
3754
+ vector('camera', 'location', camera.transform?.location, t('photo.position')),
3755
+ choose(t('photo.aim'), 'camera-aim', aim, [...(photographyAim(originalCamera) === 'free' ? [['free', t('photo.free')]] : []), ['entity', t('photo.entity')], ['point', t('photo.point')]], update('camera', ['aim']), aimLocked),
3756
+ aim === 'entity' ? choose(t('photo.target'), 'camera-target', camera.targetEntityId, draft.entities.map(item => [item.id, item.id]), update('camera', ['targetEntityId']), aimLocked) : null,
3757
+ aim === 'point' ? vector('camera', 'targetPoint', camera.targetPoint, t('photo.targetPoint'), 1, aimLocked) : null,
3758
+ vector('camera', 'rotationEuler', camera.transform?.rotationEuler, t('photo.rotation'), 180 / Math.PI, aim !== 'free'),
3759
+ aim !== 'free' ? el('p', { className: 'db-muted' }, t('photo.rotationLocked')) : null,
3760
+ aimLocked ? el('p', { className: 'db-muted' }, t('photo.aimLocked')) : null,
3761
+ el('p', { className: 'db-muted' }, t('photo.aimHelp'))) : null),
3762
+ el('div', null,
3763
+ choose(t('photo.light'), 'light', draft.lightId, draft.lights.map(item => [item.id, item.id]), id => actions.selectPhotography('light', id)),
3764
+ light ? el('div', null,
3765
+ el('p', null, light.type, !draft.original.lights.some(item => item.id === light.id) ? ` · ${t('photo.newLight')}` : ''),
3766
+ vector('light', 'location', light.transform?.location, t('photo.position')),
3767
+ vector('light', 'rotationEuler', light.transform?.rotationEuler, t('photo.rotation'), 180 / Math.PI),
3768
+ numeric(light.type === 'sun' ? t('photo.sunEnergy') : t('photo.power'), 'light-energy', editorNumberDefault(light.energy, 100), update('light', ['energy']), { min: 0 }),
3769
+ el('strong', null, t('photo.color')), ['R', 'G', 'B'].map((label, index) => numeric(label, `light-color-${label.toLowerCase()}`, editorNumberDefault(light.color?.[index], 1), update('light', ['color', index]), { min: 0, max: 1 })),
3770
+ light.type !== 'sun' ? numeric(light.type === 'area' ? t('photo.areaSize') : t('photo.softSize'), 'light-size', editorNumberDefault(light.size, light.type === 'area' ? 1 : .25), update('light', ['size']), { min: .000001 }) : null) : null,
3771
+ Button({ action: 'photo-add-light', disabled, onClick: actions.addPhotographyLight, children: t('photo.addLight') }))),
3772
+ el('div', { className: 'db-inline', style: { flexWrap: 'wrap' } },
3773
+ numeric(t('inspection.frame'), 'frame', draft.frame, update('inspection', 'frame'), { min: draft.frameStart, max: draft.frameEnd, step: 1 }),
3774
+ numeric(t('inspection.samples'), 'samples', draft.samples, update('inspection', 'samples'), { min: 1, max: 512, step: 1 })),
3775
+ errors.length ? el('p', { className: 'db-error' }, t('photo.invalid', { fields: errors.join(', ') })) : null,
3776
+ el('div', { className: 'db-inline' },
3777
+ Button({ action: 'photo-save', disabled: disabled || !photographyDirty(draft) || errors.length > 0 || state.busy.editor || state.busy.patch || state.busy.restore,
3778
+ onClick: actions.savePhotography, children: t('photo.save') }),
3779
+ Button({ action: 'photo-reset', disabled: busy, onClick: actions.resetPhotography, children: t('photo.reset') }))) : null,
3780
+ work.saving ? el('p', { role: 'status' }, t('photo.saving')) : null,
3781
+ work.previewing ? el('div', null, el('p', { role: 'status' }, t('photo.previewing')), Button({ action: 'photo-cancel', onClick: actions.cancelPhotography, children: t('inspection.cancel') })) : null,
3782
+ edit ? el('div', { 'data-photography-saved': edit.after },
3783
+ el('p', { role: 'status' }, t('photo.saved', { revision: edit.after })),
3784
+ el('p', { className: 'db-muted' }, t('photo.changes', { fields: edit.changes.map(kind => kind === 'camera' ? t('photo.cameraChanges') : t('photo.lightChanges')).join(' / ') })),
3785
+ el('div', { className: 'db-inline' },
3786
+ Button({ action: 'photo-retry', disabled: busy, onClick: actions.retryPhotography, children: t('photo.retry') }),
3787
+ Button({ action: 'photo-restore', disabled: busy || state.busy.editor || state.busy.patch || state.busy.restore || state.currentRevision !== edit.after,
3788
+ onClick: actions.restorePhotography, children: t('photo.restore') })),
3789
+ el('p', { className: 'db-muted' }, t('photo.restoreHelp', { revision: edit.after })),
3790
+ work.artifact ? el('figure', { 'data-photography-artifact': work.artifact.path },
3791
+ el('figcaption', null, t('photo.ready', { revision: edit.after, camera: edit.cameraId, frame: edit.frame })),
3792
+ el('img', { src: artifactUrl(state.artifactBase, work.artifact), alt: t('inspection.beauty'), style: { display: 'block', width: 'auto', maxWidth: '100%', height: 'auto', maxHeight: '640px', margin: '0 auto', objectFit: 'contain' } }),
3793
+ PreviewSettings(work.artifact)) : null) : null,
3794
+ work.error ? el('p', { className: 'db-error', role: 'status', 'data-photography-error': true }, work.error) : null)
3795
+ }
3796
+
1187
3797
  function SceneView(ctx) {
1188
3798
  const state = ctx.state
1189
3799
  const actions = ctx.actions
@@ -1199,6 +3809,7 @@ window.__ModuleLoader__.load({
1199
3809
  }],
1200
3810
  }, null, 2)
1201
3811
  const text = state.forms.patch === null ? template : state.forms.patch
3812
+ const advancedDisclosure = JSON.stringify([state.activeProjectId, 'advanced-patch'])
1202
3813
 
1203
3814
  const section = (title, items, render) => el('div', { className: 'db-card', key: title },
1204
3815
  el('h4', null, `${title}(${items.length})`),
@@ -1218,6 +3829,8 @@ window.__ModuleLoader__.load({
1218
3829
  ),
1219
3830
  el('div', { className: 'db-grid' },
1220
3831
  section(t('scene.entities'), scene.nodes.entities, entity => el('div', null,
3832
+ el('button', { type: 'button', className: 'db-btn', 'data-action': `select-entity:${entity.id}`, 'data-entity-id': entity.id,
3833
+ 'aria-pressed': state.editorEntityId === entity.id, onClick: () => actions.selectEditorEntity(entity.id) }, entity.id), ' ',
1221
3834
  el('span', { className: 'db-mono', 'data-node': `entity:${entity.id}` }, entity.id), ' ',
1222
3835
  el('span', { className: 'db-kind' }, entity.shape || entity.kind),
1223
3836
  entity.materialId ? el('span', { className: 'db-muted' }, t('scene.material', { id: entity.materialId })) : null,
@@ -1229,10 +3842,12 @@ window.__ModuleLoader__.load({
1229
3842
  el('span', { className: 'db-kind' }, material.shader),
1230
3843
  material.parameters ? el('span', { className: 'db-muted db-mono' }, Object.entries(material.parameters).slice(0, 4).map(([key, value]) => `${key}=${Array.isArray(value) ? `[${value.join(',')}]` : value}`).join(' ')) : null)),
1231
3844
  section(t('scene.lights'), scene.nodes.lights, light => el('div', null,
3845
+ Button({ action: `select-light:${light.id}`, onClick: () => actions.selectPhotography('light', light.id), children: light.id }), ' ',
1232
3846
  el('span', { className: 'db-mono', 'data-node': `light:${light.id}` }, light.id), ' ',
1233
3847
  el('span', { className: 'db-kind' }, light.type),
1234
3848
  el('span', { className: 'db-muted' }, `energy ${light.energy}`))),
1235
3849
  section(t('scene.cameras'), scene.nodes.cameras, camera => el('div', null,
3850
+ Button({ action: `select-camera:${camera.id}`, onClick: () => actions.selectPhotography('camera', camera.id), children: camera.id }), ' ',
1236
3851
  el('span', { className: 'db-mono', 'data-node': `camera:${camera.id}` }, camera.id), ' ',
1237
3852
  camera.isActive ? Badge({ tone: 'ok', children: 'active' }) : null, ' ',
1238
3853
  el('span', { className: 'db-kind' }, camera.role || 'no role'),
@@ -1249,8 +3864,11 @@ window.__ModuleLoader__.load({
1249
3864
  el('span', { className: 'db-kind' }, asset.type),
1250
3865
  el('span', { className: 'db-muted db-mono' }, String(asset.path || '')))),
1251
3866
  ),
1252
-
1253
- el('div', { className: 'db-card' },
3867
+ PhotographyEditor(ctx),
3868
+ AssetsView(ctx),
3869
+ SceneEditor(ctx),
3870
+ el('details', { key: advancedDisclosure, 'data-disclosure': advancedDisclosure, open: state.disclosures?.[advancedDisclosure] ?? false,
3871
+ onToggle: event => actions.setDisclosure(advancedDisclosure, event.currentTarget.open), className: 'db-card' }, el('summary', null, t('editor.advanced')),
1254
3872
  el('h4', null, t('scene.patch')),
1255
3873
  el('textarea', {
1256
3874
  className: 'db-area',
@@ -1274,62 +3892,213 @@ window.__ModuleLoader__.load({
1274
3892
  )
1275
3893
  }
1276
3894
 
1277
- /**
1278
- * The newest image to show for a revision.
1279
- *
1280
- * Order of preference: the sheet THIS panel's render just composed, then any
1281
- * contact sheet (a review's), then the newest single view. The artifact is
1282
- * passed through WHOLE with a display label added: an earlier version built a
1283
- * fresh `{ path, kind }` here and threw away the digest and the timestamp —
1284
- * which is how the panel ended up keyed on the path alone and showing a stale
1285
- * render (§13.5B).
1286
- */
1287
- function sheetOf(entry) {
1288
- if (entry === null) return null
3895
+ /** Keep declared sources; derive a legacy source only from its own path. */
3896
+ function previewSource(entry, artifact) {
3897
+ const ownPath = typeof artifact.path === 'string'
3898
+ && [`revisions/${entry.revision}/previews/`, `revisions/${entry.revision}/contact-sheets/`].some(prefix => artifact.path.startsWith(prefix))
3899
+ && !artifact.path.split('/').some(part => part === '..' || part === '.')
3900
+ const sourceRevision = Object.hasOwn(artifact, 'sourceRevision') ? artifact.sourceRevision : ownPath ? entry.revision : null
3901
+ return { ...artifact, sourceRevision,
3902
+ sourceDigest: Object.hasOwn(artifact, 'sourceDigest') ? artifact.sourceDigest
3903
+ : sourceRevision === entry.revision ? entry.digest || null : null }
3904
+ }
3905
+
3906
+ /** Single renders and the retained sheet generations, newest first. */
3907
+ function renderHistoryOf(entry) {
3908
+ if (!entry) return []
3909
+ const singles = (entry.previews || []).filter(item => item.path && item.kind === 'preview').slice().reverse()
1289
3910
  const sheets = entry.contactSheets || []
1290
- const current = sheets.find(sheet => sheet.slot === 'preview-current')
1291
- if (current && current.path) return { ...current, label: t('preview.thisRender') }
1292
- const sheet = sheets[sheets.length - 1]
1293
- if (sheet && sheet.path) return { ...sheet, label: 'contact sheet' }
1294
- const list = entry.previews || []
1295
- const preview = list[list.length - 1]
1296
- if (preview && preview.path) return { ...preview, label: preview.kind || 'preview' }
1297
- return null
3911
+ // Undated legacy data retains the former sheet preference. An unknown
3912
+ // timestamp remains unknown; it is never replaced with revision creation.
3913
+ const candidates = [sheets.find(item => item.slot === 'preview-current'), ...singles,
3914
+ sheets.find(item => item.slot === 'preview-previous')].filter(item => item?.path)
3915
+ const time = item => { const value = Date.parse(item.at); return Number.isFinite(value) ? value : -Infinity }
3916
+ const seen = new Set()
3917
+ return candidates.filter(item => { if (seen.has(item.path)) return false; seen.add(item.path); return true })
3918
+ .map(item => previewSource(entry, item)).sort((left, right) => {
3919
+ const a = time(left), b = time(right)
3920
+ return a === b ? 0 : a > b ? -1 : 1
3921
+ })
1298
3922
  }
1299
3923
 
1300
- /**
1301
- * The pair a render leaves behind: what it just composed, and what came before.
1302
- *
1303
- * "Before" is the previous generation of the SAME revision when there is one.
1304
- * When there is not — which is the case on the first render after a scene
1305
- * change, and therefore the case the comparison exists for — it falls back to
1306
- * the newest render of an older revision. Without that fallback the axis is
1307
- * empty exactly when a person wants it: they changed something, rendered once,
1308
- * and the left pane would say "nothing to compare yet" while the picture they
1309
- * want to compare against is sitting one revision back.
1310
- *
1311
- * The pane labels say which revision each side came from, so a cross-revision
1312
- * pair cannot be mistaken for two renders of one scene.
1313
- */
3924
+ /** Latest finished preview, with a review sheet as a display fallback. */
3925
+ function sheetOf(entry) {
3926
+ if (!entry) return null
3927
+ const latest = renderHistoryOf(entry)[0]
3928
+ if (latest) return { ...latest, label: latest.slot === 'preview-current' ? t('preview.thisRender') : latest.kind || 'preview' }
3929
+ const review = (entry.contactSheets || []).filter(item => item.path).at(-1)
3930
+ return review ? { ...previewSource(entry, review), label: 'contact sheet' } : null
3931
+ }
3932
+
3933
+ /** Compare render generations, falling back to an older scene revision. */
1314
3934
  function renderPairOf(entry, allRevisions) {
1315
- if (entry === null) return { current: null, previous: null }
1316
- const sheets = entry.contactSheets || []
1317
- const current = sheets.find(sheet => sheet.slot === 'preview-current') ?? null
1318
- let previous = sheets.find(sheet => sheet.slot === 'preview-previous') ?? null
1319
- let previousRevision = entry.revision
1320
- if (previous === null) {
3935
+ const history = renderHistoryOf(entry)
3936
+ const current = history[0] || null
3937
+ let previous = history[1] || null
3938
+ if (entry && !previous) {
1321
3939
  const index = allRevisions.findIndex(candidate => candidate.revision === entry.revision)
1322
- const older = (index > 0 ? allRevisions.slice(0, index) : []).reverse()
1323
- .find(candidate => (candidate.contactSheets || []).some(sheet => sheet.slot === 'preview-current'))
1324
- if (older !== undefined) {
1325
- previous = (older.contactSheets || []).find(sheet => sheet.slot === 'preview-current')
1326
- previousRevision = older.revision
3940
+ for (const older of (index > 0 ? allRevisions.slice(0, index) : []).slice().reverse()) {
3941
+ previous = renderHistoryOf(older)[0] || null
3942
+ if (previous) break
1327
3943
  }
1328
3944
  }
1329
- return {
1330
- current: current === null ? null : { ...current, label: t('preview.thisRender'), sourceRevision: entry.revision },
1331
- previous: previous === null ? null : { ...previous, label: t('preview.lastRender'), sourceRevision: previousRevision },
3945
+ return { current, previous }
3946
+ }
3947
+
3948
+ // Compare only recorded measurements; never fill a gap from today's profile.
3949
+ function previewViews(artifact) {
3950
+ if (!artifact) return []
3951
+ return artifact.kind === 'contact-sheet' ? (Array.isArray(artifact.viewSettings) ? artifact.viewSettings : []) : [artifact]
3952
+ }
3953
+ const previewPositive = value => Number.isInteger(value) && value > 0
3954
+ const previewText = value => typeof value === 'string' && value.length > 0
3955
+ const previewMatrix = value => Array.isArray(value) && value.length === 4 && value.every(row => Array.isArray(row) && row.length === 4 && row.every(Number.isFinite))
3956
+ const PREVIEW_CAMERA_FIELDS = ['matrixWorld', 'type', 'lens', 'orthoScale', 'sensorWidth', 'sensorHeight', 'sensorFit', 'shift', 'clip', 'dof']
3957
+ function previewCameraKnown(view) {
3958
+ const camera = view?.cameraFacts, dof = camera?.dof
3959
+ return Boolean(camera && camera.frame === view.frame && PREVIEW_CAMERA_FIELDS.every(key => Object.hasOwn(camera, key))
3960
+ && previewMatrix(camera.matrixWorld) && previewText(camera.type) && previewText(camera.sensorFit)
3961
+ && ['lens', 'orthoScale', 'sensorWidth', 'sensorHeight'].every(key => Number.isFinite(camera[key]) && camera[key] > 0)
3962
+ && Array.isArray(camera.shift) && camera.shift.length === 2 && camera.shift.every(Number.isFinite)
3963
+ && Array.isArray(camera.clip) && camera.clip.length === 2 && camera.clip.every(Number.isFinite) && camera.clip[0] > 0 && camera.clip[1] > camera.clip[0]
3964
+ && dof && typeof dof.enabled === 'boolean' && Number.isFinite(dof.focusDistance) && dof.focusDistance >= 0
3965
+ && Object.hasOwn(dof, 'focusObject') && (dof.focusObject === null || previewMatrix(dof.focusObject?.matrixWorld))
3966
+ && Object.hasOwn(dof, 'focusSubtarget') && ['apertureFstop', 'apertureRatio'].every(key => Number.isFinite(dof[key]) && dof[key] > 0)
3967
+ && Number.isInteger(dof.apertureBlades) && dof.apertureBlades >= 0 && Number.isFinite(dof.apertureRotation))
3968
+ }
3969
+ function previewConfigKnown(view) {
3970
+ const measured = view?.renderConfig
3971
+ return Boolean(view && previewText(view.cameraId) && Number.isInteger(view.frame) && previewPositive(view.width) && previewPositive(view.height)
3972
+ && measured && EDITOR_RENDER_FIELDS.every(key => Object.hasOwn(measured, key))
3973
+ && previewText(view.engine) && measured.engine === view.engine && previewPositive(measured.samples) && measured.samples === view.samples
3974
+ && Array.isArray(measured.resolution) && measured.resolution.length === 2 && measured.resolution.every(previewPositive)
3975
+ && previewPositive(measured.resolutionPercentage) && measured.resolutionPercentage <= 100
3976
+ && Math.floor(measured.resolution[0] * measured.resolutionPercentage / 100) === view.width
3977
+ && Math.floor(measured.resolution[1] * measured.resolutionPercentage / 100) === view.height
3978
+ && typeof measured.filmTransparent === 'boolean' && previewText(measured.viewTransform) && typeof measured.look === 'string'
3979
+ && Number.isFinite(measured.exposure) && previewPositive(measured.fps) && Number.isInteger(measured.frameStart) && Number.isInteger(measured.frameEnd) && measured.frameEnd >= measured.frameStart)
3980
+ }
3981
+ function previewCanonical(value) {
3982
+ if (Array.isArray(value)) return value.map(previewCanonical)
3983
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, previewCanonical(value[key])]))
3984
+ return value
3985
+ }
3986
+ function previewConditions(before, after) {
3987
+ if (!before || !after) return null
3988
+ const left = previewViews(before), right = previewViews(after), differences = new Set()
3989
+ let unknown = left.length === 0 || right.length === 0 || [...left, ...right].some(view => !previewConfigKnown(view) || !previewCameraKnown(view))
3990
+ if (before.kind === 'contact-sheet' && after.kind === 'contact-sheet') {
3991
+ if ([before.columns, before.rows, after.columns, after.rows].every(previewPositive)) {
3992
+ if (before.columns !== after.columns || before.rows !== after.rows) differences.add('layout')
3993
+ } else unknown = true
3994
+ }
3995
+ if ((before.kind === 'contact-sheet') !== (after.kind === 'contact-sheet') || left.length !== right.length
3996
+ || before.kind === 'contact-sheet' && after.kind === 'contact-sheet' && !editorEqual(left.map(view => view.viewId), right.map(view => view.viewId))) differences.add('layout')
3997
+ const compare = (a, b, key, valid) => { if (valid(a) && valid(b)) { if (!editorEqual(previewCanonical(a), previewCanonical(b))) differences.add(key) } else unknown = true }
3998
+ for (let index = 0; index < Math.min(left.length, right.length); index++) {
3999
+ const a = left[index], b = right[index]
4000
+ compare([a.width, a.height], [b.width, b.height], 'resolution', value => value.every(previewPositive))
4001
+ compare(a.cameraId, b.cameraId, 'camera', previewText)
4002
+ compare(a.frame, b.frame, 'frame', Number.isInteger)
4003
+ compare(a.renderConfig?.samples, b.renderConfig?.samples, 'samples', previewPositive)
4004
+ compare(a.engine, b.engine, 'engine', previewText)
4005
+ if (previewConfigKnown(a) && previewConfigKnown(b)) {
4006
+ compare([a.renderConfig.viewTransform, a.renderConfig.look, a.renderConfig.exposure], [b.renderConfig.viewTransform, b.renderConfig.look, b.renderConfig.exposure], 'color', () => true)
4007
+ compare(a.renderConfig.filmTransparent, b.renderConfig.filmTransparent, 'transparency', () => true)
4008
+ compare([a.renderConfig.fps, a.renderConfig.frameStart, a.renderConfig.frameEnd], [b.renderConfig.fps, b.renderConfig.frameStart, b.renderConfig.frameEnd], 'timing', () => true)
4009
+ } else unknown = true
4010
+ if (previewCameraKnown(a) && previewCameraKnown(b)) compare(Object.fromEntries(PREVIEW_CAMERA_FIELDS.map(key => [key, a.cameraFacts[key]])), Object.fromEntries(PREVIEW_CAMERA_FIELDS.map(key => [key, b.cameraFacts[key]])), 'camera', () => true)
4011
+ else unknown = true
1332
4012
  }
4013
+ return { status: differences.size ? 'different' : unknown ? 'unknown' : 'matching', differences: [...differences], unknown }
4014
+ }
4015
+ function PreviewConditions(before, after) {
4016
+ const conditions = previewConditions(before, after)
4017
+ if (!conditions) return null
4018
+ return el('p', { className: 'db-muted', role: 'status', 'data-preview-conditions': conditions.status, 'data-preview-conditions-incomplete': String(conditions.unknown), style: { margin: '8px 0' } },
4019
+ conditions.status === 'different' ? t('preview.conditionsDifferent', { fields: conditions.differences.map(key => ({ layout: t('preview.condition.layout'), resolution: t('preview.condition.resolution'), samples: t('preview.condition.samples'), engine: t('preview.condition.engine'), camera: t('preview.condition.camera'), frame: t('preview.condition.frame'), color: t('preview.condition.color'), transparency: t('preview.condition.transparency'), timing: t('preview.condition.timing') })[key]).join(' · ') })
4020
+ : conditions.status === 'matching' ? t('preview.conditionsMatching') : t('preview.conditionsUnknown'),
4021
+ conditions.status === 'different' && conditions.unknown ? ` ${t('preview.conditionsUnknown')}` : null)
4022
+ }
4023
+ function PreviewSettings(artifact) {
4024
+ if (!artifact) return null
4025
+ const views = previewViews(artifact), value = number => Number.isFinite(number) ? number : '—'
4026
+ return el('div', { 'data-preview-settings': artifact.path, style: { marginBottom: '6px', overflowWrap: 'anywhere' } },
4027
+ artifact.kind === 'contact-sheet' ? el('div', { className: 'db-muted' }, t('preview.sheetPixels', { width: value(artifact.width), height: value(artifact.height) })) : null,
4028
+ views.length === 0 ? el('div', { className: 'db-muted', 'data-render-settings-missing': true }, t('preview.settingsMissing')) : views.map((view, index) =>
4029
+ el('div', { 'data-render-view-id': view.viewId || 'single', key: view.viewId || index,
4030
+ 'data-render-settings-known': String(previewConfigKnown(view) && previewCameraKnown(view)) },
4031
+ el('div', { className: 'db-muted' },
4032
+ view.viewId ? `${view.viewId} · ` : '',
4033
+ t('preview.actualPixels', { width: previewPositive(view.width) ? view.width : '—', height: previewPositive(view.height) ? view.height : '—',
4034
+ samples: previewPositive(view.renderConfig?.samples) ? view.renderConfig.samples : '—', engine: view.engine || '—' })),
4035
+ el('div', { className: 'db-muted' }, t('preview.actualCamera', { camera: view.cameraId || '—', frame: Number.isInteger(view.frame) ? view.frame : '—',
4036
+ lens: Number.isFinite(view.cameraFacts?.lens) ? Math.round(view.cameraFacts.lens * 1000) / 1000 : '—' }),
4037
+ view.renderConfig ? ` · ${t('preview.actualColor', { transform: view.renderConfig.viewTransform || '—', exposure: value(view.renderConfig.exposure) })}` : ''),
4038
+ !previewConfigKnown(view) || !previewCameraKnown(view) ? el('small', { className: 'db-muted' }, t('preview.settingsMissing')) : null)))
4039
+ }
4040
+
4041
+ function CreationGuide({ state, actions }) {
4042
+ const projectId = state.activeProjectId, scene = state.selected?.scene, focus = state.guideFocus?.[projectId || 'new'] || 'goal'
4043
+ const evidence = state.previews?.revisions?.find(item => item.revision === scene?.revision)
4044
+ const steps = [
4045
+ { id: 'goal', label: t('guide.goal'), hint: t('guide.goalHint') },
4046
+ { id: 'route', label: t('guide.route'), hint: t('guide.routeHint') },
4047
+ { id: 'parts', label: t('guide.parts'), hint: t('guide.partsHint') },
4048
+ { id: 'form', label: t('guide.form'), hint: t('guide.formHint') },
4049
+ { id: 'detail', label: t('guide.detail'), hint: t('guide.detailHint') },
4050
+ { id: 'appearance', label: t('guide.appearance'), hint: t('guide.appearanceHint') },
4051
+ { id: 'delivery', label: t('guide.delivery'), hint: t('guide.deliveryHint') },
4052
+ ]
4053
+ return el('section', { className: 'db-card', 'data-creation-guide': projectId || 'new' },
4054
+ el('strong', null, t('guide.title')),
4055
+ el('div', { className: 'db-inline', style: { flexWrap: 'wrap', marginTop: '8px' } }, steps.map(step =>
4056
+ Button({ action: `guide-${step.id}`, disabled: !projectId && !['goal', 'route'].includes(step.id), onClick: () => actions.openCreationStep(step.id), children: step.label }))),
4057
+ el('p', { className: 'db-muted', 'data-guide-hint': focus }, steps.find(step => step.id === focus)?.hint || steps[0].hint),
4058
+ el('p', { className: 'db-muted' }, scene ? t('guide.evidence', { revision: scene.revision,
4059
+ parts: scene.nodes.entities.filter(entity => entity.kind !== 'empty' && !entity.tags?.includes('environment')).length,
4060
+ references: scene.project.referenceImages?.length || 0, images: evidence?.diagnostics?.length || 0 }) : t('guide.noProject')),
4061
+ el('small', { className: 'db-muted' }, t('guide.help')))
4062
+ }
4063
+ function InspectionPanel({ state, actions }) {
4064
+ const projectId = state.activeProjectId, scene = state.selected?.scene, form = inspectionForm(state), work = state.inspectionWork?.[projectId] || {}
4065
+ if (!scene || !form) return null
4066
+ const dirty = projectHasUnsavedDrafts(state), valid = inspectionFormValid(form, scene), busy = Boolean(work.busy)
4067
+ const revisions = state.previews?.revisions || [], selectedRevision = state.inspectionRevisions?.[projectId] || scene.revision
4068
+ const entry = revisions.find(item => item.revision === selectedRevision)
4069
+ const artifacts = (entry?.diagnostics || []).filter(item => ['beauty', 'clay'].includes(item.mode) && item.path && item.sha256).sort((left, right) => String(right.at || '').localeCompare(String(left.at || '')))
4070
+ const choose = (label, key, options) => el('label', { className: 'db-row' }, label, el('select', { className: 'db-input', 'data-field': `inspection-${key}`, value: form[key], disabled: busy,
4071
+ onChange: event => actions.setInspection(key, event.target.value) }, options.map(([value, text]) => el('option', { value, key: value }, text))))
4072
+ return el('section', { className: 'db-card', 'data-inspection-panel': projectId, 'data-inspection-source-revision': scene.revision },
4073
+ el('h4', null, t('inspection.title')), el('p', { className: 'db-muted' }, t('inspection.help')),
4074
+ el('div', { className: 'db-inline', style: { flexWrap: 'wrap' } },
4075
+ choose(t('inspection.camera'), 'cameraId', [['', '—'], ...(!scene.nodes.cameras.some(camera => camera.id === form.cameraId) && form.cameraId ? [[form.cameraId, t('inspection.cameraUnavailable', { camera: form.cameraId })]] : []), ...scene.nodes.cameras.map(camera => [camera.id, camera.id])]),
4076
+ el('label', null, t('inspection.frame'), el('input', { className: 'db-input', type: 'number', step: 1, min: scene.project.frameStart, max: scene.project.frameEnd, 'data-field': 'inspection-frame', value: form.frame ?? '', disabled: busy,
4077
+ onChange: event => actions.setInspection('frame', event.target.value === '' ? null : Number(event.target.value)) })),
4078
+ choose(t('inspection.mode'), 'mode', [['beauty', t('inspection.beauty')], ['clay', t('inspection.clay')]]),
4079
+ el('label', null, t('inspection.samples'), el('input', { className: 'db-input', type: 'number', step: 1, min: 1, max: 512, 'data-field': 'inspection-samples', value: form.samples ?? '', disabled: busy,
4080
+ onChange: event => actions.setInspection('samples', event.target.value === '' ? null : Number(event.target.value)) }))),
4081
+ el('p', { className: 'db-muted' }, t('inspection.source', { revision: scene.revision, start: scene.project.frameStart, end: scene.project.frameEnd })),
4082
+ dirty ? el('p', { className: 'db-muted' }, t('inspection.pending')) : null,
4083
+ !valid ? el('p', { className: 'db-error' }, t('inspection.invalid')) : null,
4084
+ el('div', { className: 'db-inline' }, Button({ action: 'inspection-render', disabled: busy || dirty || !valid || state.previewBusy || state.busy.editor || state.busy.patch || state.busy.restore || state.assetWork?.[projectId]?.busy || state.visualRuns?.[projectId]?.busy,
4085
+ onClick: actions.renderInspection, children: busy ? t('inspection.rendering') : t('inspection.render') }),
4086
+ busy ? Button({ action: 'inspection-cancel', onClick: actions.cancelInspection, children: t('inspection.cancel') }) : null),
4087
+ work.error ? el('p', { className: 'db-error', 'data-inspection-error': true, role: 'status' }, work.error) : null,
4088
+ work.result && !work.error ? el('p', { 'data-inspection-result': work.result.revision }, work.result.message) : null,
4089
+ el('h4', null, t('inspection.gallery')),
4090
+ el('p', { className: 'db-muted' }, t('inspection.limit')),
4091
+ el('label', { className: 'db-row' }, t('inspection.revision'), el('select', { className: 'db-input', 'data-field': 'inspection-revision', value: selectedRevision,
4092
+ onChange: event => actions.selectInspectionRevision(event.target.value) }, revisions.map(item => el('option', { value: item.revision, key: item.revision }, item.revision)))),
4093
+ selectedRevision !== scene.revision ? el('p', { className: 'db-muted' }, t('inspection.history', { revision: selectedRevision, current: scene.revision })) : null,
4094
+ artifacts.length === 0 ? el('p', { className: 'db-muted' }, t('inspection.none')) : el('div', { className: 'db-grid' }, artifacts.map(artifact => el('article', { className: 'db-card', key: artifact.path,
4095
+ 'data-inspection-artifact': artifact.path, 'data-inspection-mode': artifact.mode, 'data-inspection-revision': artifact.sourceRevision || selectedRevision },
4096
+ el('h5', null, t(`inspection.${artifact.mode}`)),
4097
+ el('p', null, t('inspection.identity', { revision: artifact.sourceRevision || selectedRevision, camera: artifact.cameraId || '—', frame: artifact.frame ?? '—' })),
4098
+ el('img', { src: artifactUrl(state.artifactBase, artifact), alt: t(`inspection.${artifact.mode}`), 'data-inspection-image': artifact.path, style: { width: '100%', objectFit: 'contain' } }),
4099
+ el('p', { className: 'db-muted' }, t('inspection.actual', { engine: artifact.engine || '—', width: artifact.width ?? '—', height: artifact.height ?? '—', samples: artifact.samples ?? '—' })),
4100
+ el('p', { className: 'db-muted db-mono' }, `${shortDigest(artifact.sha256)} · ${artifact.at || ''}`),
4101
+ el('a', { className: 'db-btn', href: artifactUrl(state.artifactBase, artifact), target: '_blank', rel: 'noopener noreferrer' }, t('inspection.open'))))))
1333
4102
  }
1334
4103
 
1335
4104
  /** 预览对比: two revisions' contact sheets side by side. */
@@ -1347,6 +4116,11 @@ window.__ModuleLoader__.load({
1347
4116
  /** One image (or the reason there is none), with its digest and its time. */
1348
4117
  const imagePane = (side, title, artifact, missing) => {
1349
4118
  const when = artifactTime(artifact)
4119
+ // Keep controls below the current preview in place while its PNG loads.
4120
+ // Only recorded image dimensions can reserve its size and aspect ratio.
4121
+ const dimensions = side === 'current' && Number.isSafeInteger(artifact?.width) && artifact.width > 0
4122
+ && Number.isSafeInteger(artifact?.height) && artifact.height > 0
4123
+ ? { width: artifact.width, height: artifact.height } : {}
1350
4124
  return el('div', { className: 'db-shot', 'data-compare': side, 'data-compare-kind': artifact === null ? 'empty' : 'image' },
1351
4125
  el('h5', null, title),
1352
4126
  artifact === null
@@ -1356,12 +4130,20 @@ window.__ModuleLoader__.load({
1356
4130
  key: 'img',
1357
4131
  'data-artifact': artifact.path,
1358
4132
  'data-artifact-digest': artifact.sha256 || '',
4133
+ 'data-artifact-source-digest': artifact.sourceDigest || '',
1359
4134
  'data-artifact-slot': artifact.slot || '',
1360
4135
  'data-artifact-revision': artifact.sourceRevision || '',
1361
4136
  'data-artifact-at': artifact.at || '',
4137
+ ...dimensions,
4138
+ style: side === 'current' ? {
4139
+ width: dimensions.width ? `min(${dimensions.width}px, 100%, calc((100vh - 300px) * ${dimensions.width / dimensions.height}))` : 'auto',
4140
+ aspectRatio: dimensions.width ? `${dimensions.width} / ${dimensions.height}` : undefined,
4141
+ maxWidth: '100%', height: 'auto', maxHeight: 'calc(100vh - 300px)', margin: '0 auto',
4142
+ } : undefined,
1362
4143
  alt: `${title} ${artifact.path}`,
1363
4144
  src: artifactUrl(state.artifactBase, artifact),
1364
4145
  }),
4146
+ PreviewSettings(artifact),
1365
4147
  el('div', { className: 'db-muted db-mono', key: 'meta' },
1366
4148
  `${artifact.sourceRevision ? `${artifact.sourceRevision} ` : ''}${artifact.slot ? artifact.slot : (artifact.kind || 'artifact')} · ${artifact.sha256 ? String(artifact.sha256).slice(0, 10) : '—'}${when === null ? '' : t('preview.renderedAt', { when })}`),
1367
4149
  ].filter(Boolean))
@@ -1385,17 +4167,19 @@ window.__ModuleLoader__.load({
1385
4167
  'data-artifact': sheet.path,
1386
4168
  'data-artifact-digest': sheet.sha256 || '',
1387
4169
  'data-artifact-slot': sheet.slot || '',
1388
- 'data-artifact-revision': entry.revision,
4170
+ 'data-artifact-revision': sheet.sourceRevision || '',
4171
+ 'data-artifact-source-digest': sheet.sourceDigest || '',
1389
4172
  'data-artifact-at': sheet.at || '',
1390
4173
  alt: `${entry.revision} ${sheet.label}`,
1391
4174
  src: artifactUrl(state.artifactBase, sheet),
1392
4175
  })
1393
4176
  })(),
4177
+ PreviewSettings(sheetOf(entry)),
1394
4178
  (() => {
1395
4179
  const sheet = sheetOf(entry)
1396
4180
  const when = artifactTime(sheet)
1397
4181
  return sheet === null ? null : el('div', { className: 'db-muted db-mono', key: 'sheet' },
1398
- `${sheet.sha256 ? String(sheet.sha256).slice(0, 10) : '—'}${when === null ? '' : t('preview.renderedAt', { when })}`)
4182
+ `${sheet.sourceRevision ? `${sheet.sourceRevision} ` : ''}${sheet.sha256 ? String(sheet.sha256).slice(0, 10) : '—'}${when === null ? '' : t('preview.renderedAt', { when })}`)
1399
4183
  })(),
1400
4184
  el('div', { className: 'db-muted db-mono', key: 'counts' }, `previews ${(entry.previews || []).length} · sheets ${(entry.contactSheets || []).length} · reviews ${(entry.reviews || []).length}`),
1401
4185
  (entry.reviews || []).length > 0
@@ -1417,11 +4201,20 @@ window.__ModuleLoader__.load({
1417
4201
  // M4 brief describe, kept because it is the one that survives a
1418
4202
  // scene change.
1419
4203
  const pair = renderPairOf(entryOf(right), revisions)
1420
- const rendersMode = state.compareMode !== 'revisions'
4204
+ const resultMode = state.compareMode === 'result'
4205
+ const rendersMode = !resultMode && state.compareMode !== 'revisions'
4206
+ const editorComparison = state.editorComparison?.projectId === state.activeProjectId
4207
+ && state.editorComparison.before === left && state.editorComparison.after === right ? state.editorComparison : null
4208
+ const editPair = editorComparison ? (editorComparison.resolved ? editorComparison : editorPreviewPair(editorComparison)) : null
4209
+ const editMissing = editPair?.reason === 'unknown-settings' ? t('editor.unknownSettings') : t('editor.missingBaseline')
4210
+ const editorPanes = editPair ? [
4211
+ imagePane('left', left, editPair.beforeArtifact, editMissing),
4212
+ imagePane('right', right, editPair.afterArtifact, t('preview.noneForRevision')),
4213
+ ] : null
1421
4214
  const renderPairPanes = [
1422
- imagePane('left', pair.previous === null ? t('preview.lastRender') : t('preview.lastRenderOf', { revision: pair.previous.sourceRevision }), pair.previous,
4215
+ imagePane('left', pair.previous === null ? t('preview.lastRender') : t('preview.lastRenderOf', { revision: pair.previous.sourceRevision || '—' }), pair.previous,
1423
4216
  t('preview.noPrevious')),
1424
- imagePane('right', t('preview.thisRenderOf', { revision: right }), pair.current,
4217
+ imagePane('right', t('preview.thisRenderOf', { revision: pair.current ? pair.current.sourceRevision || '—' : right }), pair.current,
1425
4218
  t('preview.notRenderedHere')),
1426
4219
  ]
1427
4220
 
@@ -1439,16 +4232,19 @@ window.__ModuleLoader__.load({
1439
4232
  ),
1440
4233
  el('div', { className: 'db-tabs' },
1441
4234
  el('span', { className: 'db-muted' }, t('preview.compare')),
4235
+ el('button', { type: 'button', className: 'db-btn', 'data-compare-mode': 'result', 'data-active': String(resultMode),
4236
+ onClick: () => actions.setCompareMode('result'),
4237
+ }, t('preview.latest')),
1442
4238
  el('button', {
1443
4239
  type: 'button', className: 'db-btn', 'data-compare-mode': 'renders', 'data-active': String(rendersMode),
1444
4240
  onClick: () => actions.setCompareMode('renders'),
1445
4241
  }, t('preview.lastVsThis')),
1446
4242
  el('button', {
1447
- type: 'button', className: 'db-btn', 'data-compare-mode': 'revisions', 'data-active': String(!rendersMode),
4243
+ type: 'button', className: 'db-btn', 'data-compare-mode': 'revisions', 'data-active': String(!rendersMode && !resultMode),
1448
4244
  onClick: () => actions.setCompareMode('revisions'),
1449
4245
  }, t('preview.twoRevisions')),
1450
4246
  el('span', { style: { flex: 1 } }),
1451
- rendersMode
4247
+ resultMode ? null : rendersMode
1452
4248
  ? el('span', { className: 'db-inline' },
1453
4249
  el('span', { className: 'db-muted' }, t('tab.revisions')),
1454
4250
  el('select', {
@@ -1471,9 +4267,11 @@ window.__ModuleLoader__.load({
1471
4267
  state.activeProjectId ? Button({ action: 'diff', onClick: () => actions.diff(left, right), children: t('preview.structuralDiff') }) : null),
1472
4268
  ),
1473
4269
  el('div', { style: { paddingTop: '2px' } },
1474
- el('div', { className: 'db-grid' }, rendersMode
1475
- ? renderPairPanes
1476
- : [revisionPane(entryOf(left), 'left'), revisionPane(entryOf(right), 'right')]),
4270
+ resultMode ? null : rendersMode ? PreviewConditions(pair.previous, pair.current)
4271
+ : editPair ? PreviewConditions(editPair.beforeArtifact, editPair.afterArtifact) : PreviewConditions(sheetOf(entryOf(left)), sheetOf(entryOf(right))),
4272
+ resultMode
4273
+ ? el('div', { style: { maxWidth: '960px', margin: '0 auto' } }, imagePane('current', t('preview.latest'), sheetOf(entryOf(state.currentRevision)), t('preview.noneForRevision')))
4274
+ : el('div', { className: 'db-grid' }, rendersMode ? renderPairPanes : editorPanes || [revisionPane(entryOf(left), 'left'), revisionPane(entryOf(right), 'right')]),
1477
4275
  state.diff ? el('div', { className: 'db-card', 'data-diff': state.diff.identical ? 'identical' : 'changed' },
1478
4276
  el('h4', null, `${state.diff.fromRevision} → ${state.diff.toRevision}:${state.diff.identical ? t('preview.identical') : t('preview.changeCount', { count: state.diff.totalChanges })}`),
1479
4277
  state.diff.identical ? null : el('div', null,
@@ -1491,6 +4289,7 @@ window.__ModuleLoader__.load({
1491
4289
  ) : null,
1492
4290
  state.diffError ? ErrorBox({ error: state.diffError }) : null,
1493
4291
  ),
4292
+ InspectionPanel(ctx),
1494
4293
  )
1495
4294
  }
1496
4295
 
@@ -1520,6 +4319,7 @@ window.__ModuleLoader__.load({
1520
4319
  ),
1521
4320
  ProgressBar({ percent: job.progress.percent }),
1522
4321
  el('div', { className: 'db-muted', 'data-job-detail': job.jobId }, `${job.detail} · ${job.progress.percent}% · 缺失 ${job.progress.missing} · 损坏 ${job.progress.corrupt}`),
4322
+ el('div', { className: 'db-muted', 'data-job-provenance': job.jobId }, jobProvenanceText(job)),
1523
4323
  job.errorCode ? el('div', { className: 'db-error', style: { marginTop: '4px' } }, `${job.errorCode}: ${job.message || ''}`) : null,
1524
4324
  job.delivery ? el('div', { className: 'db-muted db-mono' }, `video ${job.delivery.videoPath || ''}`) : null,
1525
4325
  ))),
@@ -1546,6 +4346,31 @@ window.__ModuleLoader__.load({
1546
4346
  )
1547
4347
  }
1548
4348
 
4349
+ function ReferenceEvidence(visual) {
4350
+ const references = visual.referenceImages || []
4351
+ return el('div', { 'data-reference-evidence': true, 'data-review-inputs-digest': visual.reviewInputsDigest || '' },
4352
+ el('strong', null, t('brief.actualReferences')),
4353
+ visual.referenceInputError ? el('p', { className: 'db-error', role: 'alert' },
4354
+ `${t('brief.referenceStatus')}: ${visual.referenceInputError.code || ''} ${visual.referenceInputError.message || ''}`) : null,
4355
+ references.length ? el('ul', { className: 'db-list' }, references.map(reference => el('li', { key: reference.id, 'data-reviewed-reference-id': reference.id },
4356
+ el('strong', null, reference.label || reference.id), ` · ${reference.id} · ${(reference.purposes || []).map(artisticDimensionLabel).join(', ')}`)))
4357
+ : el('p', { className: 'db-muted' }, t('brief.referenceUnknown')))
4358
+ }
4359
+
4360
+ function ReviewSubjectEvidence(visual) {
4361
+ const subject = visual.subject, id = subject?.id ?? visual.subjectId
4362
+ return el('div', { 'data-reviewed-subject': id || '', 'data-reviewed-subject-mode': subject?.mode || 'legacy',
4363
+ 'data-reviewed-subject-available': subject ? String(subject.available === true) : 'unknown' },
4364
+ KeyValues({ entries: [
4365
+ { label: t('visual.subjects'), value: id },
4366
+ ...(subject ? [{ label: t('brief.subjectMode'), value: subject.mode === 'explicit' ? t('brief.subjectExplicit')
4367
+ : subject.mode === 'fixed' ? t('brief.subjectFixed') : t('brief.subjectAuto') },
4368
+ { label: t('brief.subjectReason'), value: subject.source }] : []),
4369
+ ] }),
4370
+ !subject ? el('p', { className: 'db-muted' }, t('brief.subjectLegacy')) : null,
4371
+ subject?.available === false ? el('p', { className: 'db-error', role: 'status' }, t('brief.subjectUnavailable', { reason: subject.reason || t('brief.subjectUnknown') })) : null)
4372
+ }
4373
+
1549
4374
  /** QA: technical validation and the visual review, never merged. */
1550
4375
  function QaView(ctx) {
1551
4376
  const state = ctx.state
@@ -1562,6 +4387,7 @@ window.__ModuleLoader__.load({
1562
4387
 
1563
4388
  return el('div', { 'data-view': 'qa' },
1564
4389
  ErrorBox({ error: state.error }),
4390
+ VisualActions(ctx),
1565
4391
  el('div', { className: 'db-card', 'data-qa-revision': qa.revision },
1566
4392
  el('div', { className: 'db-inline' },
1567
4393
  el('strong', null, `QA · ${qa.revision}`),
@@ -1597,15 +4423,21 @@ window.__ModuleLoader__.load({
1597
4423
  ? el('div', null,
1598
4424
  KeyValues({ entries: [
1599
4425
  { label: t('visual.score'), value: qa.visual.score },
4426
+ { label: t('visual.artistic'), value: artisticStatusLabel(qa.visual.artistic?.status) },
1600
4427
  { label: t('common.yes'), value: qa.visual.pass ? t('common.yesShort') : t('common.noShort') },
1601
4428
  { label: t('visual.rounds'), value: qa.visual.iteration },
1602
- { label: t('visual.subjects'), value: qa.visual.subjectId },
1603
4429
  { label: t('visual.views'), value: qa.visual.viewCount },
1604
- { label: t('visual.reviewer'), value: qa.visual.reviewerAvailable ? (qa.visual.reviewerModel || t('visual.called')) : (qa.visual.reviewerError || t('visual.notCalled')) },
4430
+ { label: t('visual.reviewer'), value: qa.visual.reviewerError || qa.visual.referenceInputError ? t('visual.incomplete') : qa.visual.reviewerAvailable ? (qa.visual.reviewerModel || t('visual.called')) : t('visual.notCalled') },
1605
4431
  ] }),
4432
+ ReviewSubjectEvidence(qa.visual),
4433
+ ReferenceEvidence(qa.visual),
4434
+ qa.visual.reviewerError ? el('p', { className: 'db-error', role: 'alert' }, qa.visual.reviewerError) : null,
4435
+ Object.entries(qa.visual.artistic?.dimensions ?? {}).map(([dimension, assessment]) =>
4436
+ el('div', { className: 'db-muted' },
4437
+ `${artisticDimensionLabel(dimension)}: ${artisticStatusLabel(assessment.status)} — ${assessment.evidence ?? t('visual.artistic.unassessable')}`)),
1606
4438
  qa.visual.measuredIssues.length === 0 ? el('div', { className: 'db-muted' }, t('visual.noFindings')) : issueList(qa.visual.measuredIssues, 'm'),
1607
4439
  qa.visual.findings.length === 0
1608
- ? el('div', { className: 'db-muted' }, qa.visual.reviewerAvailable ? t('visual.reviewerSilent') : t('visual.noSecondOpinion'))
4440
+ ? el('div', { className: 'db-muted' }, qa.visual.reviewerAvailable && !qa.visual.reviewerError && !qa.visual.referenceInputError ? t('visual.reviewerSilent') : t('visual.noSecondOpinion'))
1609
4441
  : issueList(qa.visual.findings, 'f'))
1610
4442
  : el('div', { className: 'db-muted' }, t('visual.none')),
1611
4443
  ),
@@ -1670,7 +4502,9 @@ window.__ModuleLoader__.load({
1670
4502
  ? el('div', { className: 'db-body' }, ErrorBox({ error: state.error }))
1671
4503
  : state.status === 'loading'
1672
4504
  ? el('div', { className: 'db-body db-muted' }, t('host.reading'))
1673
- : el('div', { className: 'db-body' }, renderView(ctx))
4505
+ : el('div', { className: 'db-body', 'data-scroll-key': JSON.stringify([state.activeProjectId, state.view]) },
4506
+ state.view === 'preview' ? renderView(ctx) : CreationGuide(ctx),
4507
+ state.view === 'preview' ? CreationGuide(ctx) : renderView(ctx))
1674
4508
 
1675
4509
  return el('div', { className: 'db-root', 'data-deepblend-panel': PANEL_ID },
1676
4510
  el('div', { className: 'db-head' },
@@ -1680,8 +4514,8 @@ window.__ModuleLoader__.load({
1680
4514
  state.unfinishedJobs.length > 0
1681
4515
  ? Badge({ tone: 'live', name: 'unfinished', children: t('jobs.unfinished', { count: state.unfinishedJobs.length }) })
1682
4516
  : null,
1683
- state.hostApiVersion !== null && state.hostApiVersion !== EXPECTED_HOST_API
1684
- ? Badge({ tone: 'warn', name: 'api', children: `hostApiVersion ${state.hostApiVersion} ≠ ${EXPECTED_HOST_API}` })
4517
+ typeof state.hostApiVersion === 'number' && state.hostApiVersion < EXPECTED_HOST_API
4518
+ ? Badge({ tone: 'warn', name: 'api', children: `hostApiVersion ${state.hostApiVersion} < ${EXPECTED_HOST_API}` })
1685
4519
  : null,
1686
4520
  el('span', { style: { flex: 1 } }),
1687
4521
  // AN ANCHOR, NOT A BUTTON. The export is a download, and a download is what an anchor with
@@ -1740,7 +4574,7 @@ window.__ModuleLoader__.load({
1740
4574
  /** Props that are listeners, spelled the way this vocabulary spells them. */
1741
4575
  const EVENT_NAMES = {
1742
4576
  onClick: 'click', onChange: 'input', onInput: 'input', onKeyDown: 'keydown',
1743
- onSubmit: 'submit', onBlur: 'blur', onFocus: 'focus',
4577
+ onSubmit: 'submit', onBlur: 'blur', onFocus: 'focus', onToggle: 'toggle',
1744
4578
  }
1745
4579
 
1746
4580
  /**
@@ -1786,11 +4620,12 @@ window.__ModuleLoader__.load({
1786
4620
  }
1787
4621
  assertNode(node)
1788
4622
  const element = doc.createElement(node.tag)
1789
- applyProps(element, node.props)
1790
4623
  for (const child of node.children) {
1791
4624
  const built = toDom(child, doc)
1792
4625
  if (built !== null) element.appendChild(built)
1793
4626
  }
4627
+ // A select can only resolve its value after its option children exist.
4628
+ applyProps(element, node.props)
1794
4629
  return element
1795
4630
  }
1796
4631
 
@@ -1822,41 +4657,113 @@ window.__ModuleLoader__.load({
1822
4657
  root.classList.add('db-standalone-root')
1823
4658
 
1824
4659
  const store = createWorkbenchStore(options)
4660
+ const pressed = new Set()
4661
+ let pendingDraw = false, releaseTimer = null, disposed = false, picker = null
4662
+ const samePickerIn = (node, choice) => {
4663
+ if (!node || typeof node !== 'object') return false
4664
+ if (Array.isArray(node)) return node.some(child => samePickerIn(child, choice))
4665
+ return node.tag === 'input' && node.props?.type === 'file' && !node.props.disabled
4666
+ && node.props['data-field'] === choice.field && node.props['data-file-context'] === choice.context
4667
+ || (node.children || []).some(child => samePickerIn(child, choice))
4668
+ }
1825
4669
 
1826
4670
  /**
1827
4671
  * Redraw from the snapshot.
1828
4672
  *
1829
4673
  * The tree is rebuilt wholesale, so a text field would lose its caret on
1830
4674
  * every keystroke. The focus and the selection are therefore carried across
1831
- * the redraw by the field's own `data-field` marker — the one piece of DOM
1832
- * bookkeeping this binding needs, and the reason it can rebuild rather than
1833
- * reconcile.
4675
+ * the redraw by the field's own `data-field` marker. Generic scroll keys
4676
+ * preserve position on the same surface; active presses postpone drawing
4677
+ * so a native click can complete before its target is replaced.
1834
4678
  */
1835
4679
  const draw = () => {
4680
+ if (disposed) return
4681
+ // Replacing a pressed control prevents its native click from firing.
4682
+ // Keep it connected through pointerup and the ensuing click event.
4683
+ if (pressed.size > 0) { pendingDraw = true; return }
4684
+ const tree = buildWorkbenchView(store.getState(), store.actions)
4685
+ // Keep a native picker connected while its own context is still usable.
4686
+ // A disabled control or a different context must become visible at once.
4687
+ if (picker && samePickerIn(tree, picker)) { pendingDraw = true; return }
4688
+ picker = null
4689
+ pendingDraw = false
1836
4690
  const active = doc.activeElement
1837
4691
  const focused = active !== null && active !== doc.body && active.dataset ? active.dataset.field ?? null : null
1838
4692
  const caret = focused === null ? null : active.selectionStart
4693
+ const scrolls = [...(root.querySelectorAll?.('[data-scroll-key]') || [])].map(element => ({
4694
+ key: element.dataset.scrollKey, top: element.scrollTop, left: element.scrollLeft,
4695
+ }))
1839
4696
 
1840
- const next = toDom(buildWorkbenchView(store.getState(), store.actions), doc)
4697
+ const next = toDom(tree, doc)
1841
4698
  root.replaceChildren(...(next === null ? [] : [next]))
4699
+ for (const scroll of scrolls) {
4700
+ const restored = [...(root.querySelectorAll?.('[data-scroll-key]') || [])].find(element => element.dataset.scrollKey === scroll.key)
4701
+ if (restored) { restored.scrollTop = scroll.top; restored.scrollLeft = scroll.left }
4702
+ }
1842
4703
 
1843
4704
  if (focused !== null) {
1844
4705
  const restored = root.querySelector(`[data-field="${focused}"]`)
1845
4706
  if (restored !== null) {
1846
- restored.focus()
4707
+ restored.focus({ preventScroll: true })
1847
4708
  if (caret !== null && typeof restored.setSelectionRange === 'function') restored.setSelectionRange(caret, caret)
1848
4709
  }
1849
4710
  }
1850
4711
  }
1851
4712
 
4713
+ const beginPress = event => { pressed.add(event.pointerId) }
4714
+ const finishPress = event => {
4715
+ pressed.delete(event.pointerId)
4716
+ if (pressed.size === 0 && pendingDraw && releaseTimer === null) {
4717
+ releaseTimer = setTimeout(() => { releaseTimer = null; draw() }, 0)
4718
+ }
4719
+ }
4720
+ const cancelPresses = () => {
4721
+ pressed.clear()
4722
+ finishPress({})
4723
+ }
4724
+ const beginPicker = event => {
4725
+ const node = event.target, context = node?.getAttribute?.('data-file-context')
4726
+ if (event.isTrusted !== true || node?.type !== 'file' || node.disabled || !context || !root.contains?.(node)) return
4727
+ picker = { node, context, field: node.getAttribute('data-field') }
4728
+ }
4729
+ const finishPicker = event => {
4730
+ const completing = picker
4731
+ if (!completing || event.target !== completing.node) return
4732
+ // Native input precedes the target handler and change. Keep the node
4733
+ // through both; a later terminal from another node cannot release it.
4734
+ Promise.resolve().then(() => {
4735
+ if (disposed || picker !== completing) return
4736
+ picker = null
4737
+ if (pendingDraw) draw()
4738
+ })
4739
+ }
4740
+ root.addEventListener?.('click', beginPicker, true)
4741
+ for (const type of ['input', 'change', 'cancel']) doc.addEventListener?.(type, finishPicker, true)
4742
+ root.addEventListener?.('pointerdown', beginPress, true)
4743
+ doc.addEventListener?.('pointerup', finishPress, true)
4744
+ doc.addEventListener?.('pointercancel', finishPress, true)
4745
+ doc.defaultView?.addEventListener('blur', cancelPresses)
4746
+
1852
4747
  const unsubscribe = store.subscribe(draw)
4748
+ const unsubscribeLocale = subscribeLocale(draw)
1853
4749
  store.start()
1854
4750
  draw()
1855
4751
 
1856
4752
  return {
1857
4753
  store,
1858
4754
  dispose() {
4755
+ disposed = true
4756
+ picker = null
4757
+ root.removeEventListener?.('click', beginPicker, true)
4758
+ for (const type of ['input', 'change', 'cancel']) doc.removeEventListener?.(type, finishPicker, true)
4759
+ if (releaseTimer !== null) clearTimeout(releaseTimer)
4760
+ root.removeEventListener?.('pointerdown', beginPress, true)
4761
+ doc.removeEventListener?.('pointerup', finishPress, true)
4762
+ doc.removeEventListener?.('pointercancel', finishPress, true)
4763
+ doc.defaultView?.removeEventListener('blur', cancelPresses)
4764
+ pressed.clear()
1859
4765
  unsubscribe()
4766
+ unsubscribeLocale()
1860
4767
  store.stop()
1861
4768
  root.replaceChildren()
1862
4769
  },
@@ -1867,6 +4774,17 @@ window.__ModuleLoader__.load({
1867
4774
  // §K The console face — React, and only React
1868
4775
  // =========================================================================
1869
4776
 
4777
+ /** Subscribe at mount, and close the render-to-effect language change window. */
4778
+ function useLocale() {
4779
+ const [, setLocale] = react().useState(readLocale)
4780
+ react().useEffect(() => {
4781
+ const refresh = () => setLocale(readLocale())
4782
+ const unsubscribe = subscribeLocale(refresh)
4783
+ refresh()
4784
+ return unsubscribe
4785
+ }, [])
4786
+ }
4787
+
1870
4788
  /** One store per panel, started with the mount and stopped with it. */
1871
4789
  function useWorkbenchStore() {
1872
4790
  const ref = react().useRef(null)
@@ -1901,6 +4819,7 @@ window.__ModuleLoader__.load({
1901
4819
  * read from the Host on every pass, so the mirror cannot outlive the truth.
1902
4820
  */
1903
4821
  function WorkbenchPanel() {
4822
+ useLocale()
1904
4823
  const store = useWorkbenchStore()
1905
4824
  const snapshot = useSnapshot(store)
1906
4825
  return toReact(buildWorkbenchView(snapshot, store.actions), h)
@@ -1908,6 +4827,7 @@ window.__ModuleLoader__.load({
1908
4827
 
1909
4828
  /** The settings page: the M0 card, now with a seat. */
1910
4829
  function BlenderSettingsPage() {
4830
+ useLocale()
1911
4831
  const [state, setState] = react().useState({ status: 'loading', payload: null, error: null })
1912
4832
  react().useEffect(() => {
1913
4833
  let live = true
@@ -1994,6 +4914,7 @@ window.__ModuleLoader__.load({
1994
4914
  * still running.
1995
4915
  */
1996
4916
  function BlenderToolCard(props) {
4917
+ useLocale()
1997
4918
  const toolName = props.toolName
1998
4919
  const args = parseArgs(callArgs(props.block))
1999
4920
  const projectId = typeof args.projectId === 'string' ? args.projectId : null
@@ -2062,12 +4983,15 @@ window.__ModuleLoader__.load({
2062
4983
  ),
2063
4984
  h(RProgressBar, { percent: job.progress.percent }),
2064
4985
  h('div', { className: 'db-muted' }, job.detail),
4986
+ h('div', { className: 'db-muted', 'data-job-provenance': job.jobId }, jobProvenanceText(job)),
2065
4987
  job.delivery ? h('div', { className: 'db-muted db-mono' }, String(job.delivery.videoPath || '')) : null,
2066
4988
  ) : null,
2067
4989
 
2068
4990
  reviewShaped && qa !== null ? h('div', { 'data-tool-qa': qa.revision },
2069
4991
  h('div', { className: 'db-inline' },
2070
4992
  qa.visual.available ? h(RBadge, { tone: qa.visual.pass ? 'ok' : 'warn' }, t('visual.scoreIs', { score: qa.visual.score })) : h(RBadge, null, t('visual.notRun')),
4993
+ h(RBadge, { tone: qa.visual.artistic?.status === 'pass' ? 'ok' : 'warn' },
4994
+ `${t('visual.artistic')}: ${artisticStatusLabel(qa.visual.artistic?.status)}`),
2071
4995
  h(RBadge, { tone: qa.technical.ok ? 'ok' : 'bad' }, qa.technical.available ? (qa.technical.ok ? t('qa.passed') : t('qa.errorCount', { count: qa.technical.errorCount })) : t('common.noRecord')),
2072
4996
  h('span', { className: 'db-muted' }, t('visual.counts', { measured: qa.visual.measuredIssueCount, findings: qa.visual.findingCount })),
2073
4997
  ),
@@ -2092,6 +5016,7 @@ window.__ModuleLoader__.load({
2092
5016
 
2093
5017
  /** The session-header chip: how many renders are live, right now. */
2094
5018
  function SessionJobsChip() {
5019
+ useLocale()
2095
5020
  const state = useCardRoute(ROUTES.projects, 'projects.list', POLL_LIVE_MS)
2096
5021
  if (state.payload === null) return null
2097
5022
  const projects = state.payload.projects || []
@@ -2204,6 +5129,11 @@ window.__ModuleLoader__.load({
2204
5129
  toDom,
2205
5130
  buildWorkbenchView,
2206
5131
  createWorkbenchStore,
5132
+ inspection: { form: inspectionForm, valid: inspectionFormValid },
5133
+ assetLibrary: { selection: assetBundleSelection, createDraft: createAssetDraft, buildPatch: buildAssetPatch, error: assetDraftError, key: assetKey },
5134
+ referenceBrief: { createDraft: createBriefDraft, buildPatch: buildBriefPatch, dirty: briefDirty, valid: briefValid },
5135
+ photographyEditor: { createDraft: createPhotographyDraft, buildPatch: buildPhotographyPatch, errors: photographyErrors, dirty: photographyDirty, draftFor: photographyDraftFor },
5136
+ sceneEditor: { createDraft: createEditorDraft, buildPatch: buildEditorPatch, errors: editorErrors, dirty: editorDirty, previewPair: editorPreviewPair, draftFor: editorDraftFor },
2207
5137
  renderView,
2208
5138
  mountStandalone,
2209
5139
  injectStyles,