@deepblend/dsh-blender-ui 0.2.19 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +3044 -114
- package/lib/index.js +126 -5
- 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': '
|
|
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.
|
|
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': '
|
|
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
|
|
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.
|
|
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': '
|
|
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
|
-
|
|
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[
|
|
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 =
|
|
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
|
|
426
|
-
{ id: 'scene', label
|
|
427
|
-
{ id: 'preview', label
|
|
428
|
-
{ id: 'jobs', label
|
|
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
|
|
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: '
|
|
2187
|
+
compareMode: 'result',
|
|
826
2188
|
diff: null,
|
|
827
2189
|
diffError: null,
|
|
828
2190
|
previewBusy: false,
|
|
829
|
-
|
|
830
|
-
|
|
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) =>
|
|
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) =>
|
|
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(
|
|
3025
|
+
const outcome = await postJson(fetchImpl, projectRoute(projectId, '/patch'), { patch })
|
|
999
3026
|
setIn('busy', 'patch', false)
|
|
1000
3027
|
if (outcome.ok) {
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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
|
-
|
|
1291
|
-
|
|
1292
|
-
const
|
|
1293
|
-
|
|
1294
|
-
const
|
|
1295
|
-
const
|
|
1296
|
-
|
|
1297
|
-
|
|
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
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
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
|
-
|
|
1316
|
-
const
|
|
1317
|
-
|
|
1318
|
-
|
|
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
|
|
1323
|
-
|
|
1324
|
-
|
|
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
|
-
|
|
1331
|
-
|
|
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':
|
|
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
|
|
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
|
-
|
|
1475
|
-
?
|
|
1476
|
-
|
|
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.
|
|
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'
|
|
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
|
|
1684
|
-
? Badge({ tone: 'warn', name: 'api', children: `hostApiVersion ${state.hostApiVersion}
|
|
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
|
|
1832
|
-
*
|
|
1833
|
-
*
|
|
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(
|
|
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,
|