@deepblend/dsh-blender-ui 0.3.9 → 0.5.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 CHANGED
@@ -333,6 +333,24 @@ window.__ModuleLoader__.load({
333
333
  "photo.cameraChanges": "相机",
334
334
  "photo.lightChanges": "灯光",
335
335
  "photo.restoreFailed": "恢复失败,已保留摄影记录:{message}",
336
+ "photo.reuseUnavailable": "无法读取完整摄影设置,请重新选择来源。",
337
+ "photo.reuseTitle": "复用已保存作品的摄影设置",
338
+ "photo.reuseHelp": "原坐标以米复用,不自动缩放或取景。替换本作品全部灯光和所选相机的可编辑设置;模型、材质、世界、动画、其他相机与交付设置保留。套用后检查构图,再明确保存。",
339
+ "photo.reuseProject": "来源作品",
340
+ "photo.reuseRevision": "来源保存版本",
341
+ "photo.reuseCamera": "来源相机",
342
+ "photo.reuseTarget": "瞄准目标作品中的对象",
343
+ "photo.reuseReading": "正在读取已保存摄影设置…",
344
+ "photo.reuseSource": "来源:{project} · {revision} · {digest} · {count} 盏灯",
345
+ "photo.reuseReplace": "替换当前未提交的摄影草稿(其他编辑保留)",
346
+ "photo.reuseStale": "目标草稿来源已变化,请重新读取摄影设置。",
347
+ "photo.reuseApplied": "已套用到草稿,尚未保存。请检查构图与灯光。",
348
+ "photo.reuseSame": "当前摄影设置已与来源一致,无需再次保存。",
349
+ "photo.reuseReload": "重新读取来源",
350
+ "photo.reuseApply": "套用到摄影草稿",
351
+ "photo.reuseUpgrade": "更新插件并重启 DSH 后即可复用摄影设置。",
352
+ "photo.reuseNote": "摄影设置从 {project} · {revision} · {digest} · 相机 {camera} 开始复用;目标对象 {target}。保存前可继续调整草稿。",
353
+ "photo.reuseTargetRequired": "请选择目标作品中的瞄准对象。",
336
354
  'editor.title': '编辑对象',
337
355
  'editor.unknownSettings': '这张预览缺少实测渲染设置,无法比较编辑差异。',
338
356
  'editor.missingBaseline': '没有同相机、同帧和同渲染设置的编辑前预览,暂时无法比较差异。',
@@ -353,6 +371,9 @@ window.__ModuleLoader__.load({
353
371
  'editor.animated': '动画控制的通道已禁用。',
354
372
  'editor.invalid': '{field} 的值无效。',
355
373
  'editor.size': '尺寸(mm)',
374
+ 'editor.boxWidth': '宽(mm)',
375
+ 'editor.boxDepth': '深(mm)',
376
+ 'editor.boxHeight': '高(mm)',
356
377
  'editor.radius': '半径(mm)',
357
378
  'editor.handledCup': '带把手杯体',
358
379
  'editor.wallThickness': '杯壁厚度(mm)',
@@ -903,6 +924,24 @@ window.__ModuleLoader__.load({
903
924
  "photo.cameraChanges": "cameras",
904
925
  "photo.lightChanges": "lighting",
905
926
  "photo.restoreFailed": "Restore failed; the photography record is retained: {message}",
927
+ "photo.reuseUnavailable": "Complete photography settings are unavailable. Select the source again.",
928
+ "photo.reuseTitle": "Reuse photography from saved work",
929
+ "photo.reuseHelp": "Reuse absolute coordinates in metres, without automatic scaling or framing. Replace all lights and the selected camera’s editable settings. Keep models, materials, world, animation, other cameras and delivery settings. Check the framing before explicitly saving.",
930
+ "photo.reuseProject": "Source work",
931
+ "photo.reuseRevision": "Saved source version",
932
+ "photo.reuseCamera": "Source camera",
933
+ "photo.reuseTarget": "Aim at an object in the destination work",
934
+ "photo.reuseReading": "Reading saved photography settings…",
935
+ "photo.reuseSource": "Source: {project} · {revision} · {digest} · {count} lights",
936
+ "photo.reuseReplace": "Replace the unsaved photography draft (keep other edits)",
937
+ "photo.reuseStale": "The destination draft source changed. Read the photography settings again.",
938
+ "photo.reuseApplied": "Applied to the draft; not saved yet. Check the framing and lighting.",
939
+ "photo.reuseSame": "The current photography matches the source; no new save is needed.",
940
+ "photo.reuseReload": "Read source again",
941
+ "photo.reuseApply": "Apply to photography draft",
942
+ "photo.reuseUpgrade": "Update the plugin and restart DSH to reuse photography settings.",
943
+ "photo.reuseNote": "Photography started from {project} · {revision} · {digest} · camera {camera}; destination aim object {target}. The draft may be adjusted before saving.",
944
+ "photo.reuseTargetRequired": "Choose an aim target in the destination work.",
906
945
  'editor.title': 'Edit object',
907
946
  'editor.unknownSettings': 'Measured render settings are missing. This preview cannot establish the edit difference.',
908
947
  'editor.missingBaseline': 'No before preview has the same camera, frame and measured render settings. The edit difference is unavailable.',
@@ -923,6 +962,9 @@ window.__ModuleLoader__.load({
923
962
  'editor.animated': 'Channels driven by animation are disabled.',
924
963
  'editor.invalid': 'Invalid value for {field}.',
925
964
  'editor.size': 'Size (mm)',
965
+ 'editor.boxWidth': 'Width (mm)',
966
+ 'editor.boxDepth': 'Depth (mm)',
967
+ 'editor.boxHeight': 'Height (mm)',
926
968
  'editor.radius': 'Radius (mm)',
927
969
  'editor.handledCup': 'Handled cup',
928
970
  'editor.wallThickness': 'Wall thickness (mm)',
@@ -2018,7 +2060,13 @@ window.__ModuleLoader__.load({
2018
2060
  }
2019
2061
  const generator = draft.entity.generator
2020
2062
  if (generator) {
2063
+ if (generator.dimensions !== undefined) {
2064
+ if (!['cube', 'rounded_box'].includes(generator.shape) || generator.size !== undefined
2065
+ || !Array.isArray(generator.dimensions) || generator.dimensions.length !== 3) bad('dimensions')
2066
+ else generator.dimensions.forEach((value, axis) => scalar(value, `dimensions.${EDITOR_AXES[axis]}`, Number.MIN_VALUE))
2067
+ }
2021
2068
  for (const key of GENERATOR_FIELDS[generator.shape] || []) {
2069
+ if (key === 'size' && generator.dimensions !== undefined) continue
2022
2070
  const bounds = generatorIntegerBounds(generator, key)
2023
2071
  if (bounds?.[2] && generator[key] % bounds[2]) bad(key)
2024
2072
  scalar(generator[key], key, key === 'rootTension' ? 1 : bounds ? bounds[0] : Number.MIN_VALUE,
@@ -2148,10 +2196,10 @@ window.__ModuleLoader__.load({
2148
2196
  return state.editorDrafts?.[editorKey(state.activeProjectId, state.editorEntityId)] || null
2149
2197
  }
2150
2198
 
2151
- function createPhotographyDraft(scene, projectId, previous = {}) {
2199
+ function createPhotographyDraft(scene, projectId, previous = {}, projectsRoot = null) {
2152
2200
  const cameras = (scene.nodes.cameras || []).map(item => editorClone(item.definition)).filter(Boolean)
2153
2201
  const lights = (scene.nodes.lights || []).map(item => editorClone(item.definition)).filter(Boolean)
2154
- return { projectId, baseRevision: scene.revision, cameras, lights, original: editorClone({ cameras, lights }),
2202
+ return { projectId, projectsRoot, baseRevision: scene.revision, cameras, lights, original: editorClone({ cameras, lights }),
2155
2203
  entities: editorClone(scene.nodes.entities), tracks: editorClone(scene.nodes.animationTracks || []),
2156
2204
  cameraId: cameras.some(item => item.id === previous.cameraId) ? previous.cameraId : scene.project.activeCamera || cameras[0]?.id || '',
2157
2205
  lightId: lights.some(item => item.id === previous.lightId) ? previous.lightId : lights[0]?.id || '',
@@ -2161,6 +2209,8 @@ window.__ModuleLoader__.load({
2161
2209
  const photographyDraftFor = state => state.photographyDrafts?.[state.activeProjectId] || null
2162
2210
  const photographyBusy = work => Boolean(work?.saving || work?.previewing || work?.restoring)
2163
2211
  const photographyAim = camera => camera?.targetEntityId !== undefined ? 'entity' : camera?.targetPoint !== undefined ? 'point' : 'free'
2212
+ const photographySameLights = (a, b) => creationSignature(Object.fromEntries(a.map(item => [item.id, item])))
2213
+ === creationSignature(Object.fromEntries(b.map(item => [item.id, item])))
2164
2214
  const photographyAimLocked = (draft, camera) => EDITOR_AXES.some(axis => editorTrackLocked(draft, 'camera', camera.id, `rotationEuler.${axis}`))
2165
2215
  function photographyErrors(draft) {
2166
2216
  const errors = [], bad = key => errors.push(key), number = (value, min, inclusive = true) => Number.isFinite(value) && (inclusive ? value >= min : value > min)
@@ -2181,14 +2231,14 @@ window.__ModuleLoader__.load({
2181
2231
  if (item.targetPoint !== undefined && !vector(item.targetPoint)) bad(`${item.id}.targetPoint`)
2182
2232
  if (item.targetEntityId !== undefined && item.targetPoint !== undefined) bad(`${item.id}.target`)
2183
2233
  const targetChanged = !editorEqual([item.targetEntityId, item.targetPoint], [original?.targetEntityId, original?.targetPoint])
2184
- if (targetChanged && (photographyAimLocked(draft, item) || photographyAim(item) === 'free')) bad(`${item.id}.target`)
2234
+ if (targetChanged && photographyAimLocked(draft, item)) bad(`${item.id}.target`)
2185
2235
  } else {
2186
2236
  if (!number(editorNumberDefault(item.energy, 100), 0)) bad(`${item.id}.energy`)
2187
2237
  if (!Array.isArray(item.color ?? [1, 1, 1]) || ![3, 4].includes((item.color ?? [1, 1, 1]).length)
2188
2238
  || !(item.color ?? [1, 1, 1]).every(value => number(value, 0) && value <= 1)) bad(`${item.id}.color`)
2189
2239
  if (item.type !== 'sun' && !number(editorNumberDefault(item.size, item.type === 'area' ? 1 : .25), 0, false)) bad(`${item.id}.size`)
2190
- if (!original && item.type !== 'area') bad(`${item.id}.type`)
2191
- if (original && item.type !== original.type) bad(`${item.id}.type`)
2240
+ if (!draft.reusedLights && ((!original && item.type !== 'area') || (original && item.type !== original.type))) bad(`${item.id}.type`)
2241
+ if (!['area', 'point', 'spot', 'sun'].includes(item.type)) bad(`${item.id}.type`)
2192
2242
  }
2193
2243
  }
2194
2244
  if (!draft.cameras.some(camera => camera.id === draft.cameraId)) bad('camera')
@@ -2200,11 +2250,18 @@ window.__ModuleLoader__.load({
2200
2250
  const errors = photographyErrors(draft)
2201
2251
  if (errors.length) throw new Error(t('photo.invalid', { fields: errors.join(', ') }))
2202
2252
  const operations = []
2253
+ const replaceLights = draft.reusedLights && !photographySameLights(draft.lights, draft.original.lights)
2203
2254
  for (const [kind, items, fields] of [['camera', draft.cameras, ['lens', 'targetEntityId', 'targetPoint']], ['light', draft.lights, ['energy', 'color', 'size']]]) {
2255
+ if (kind === 'light' && replaceLights) {
2256
+ for (const light of draft.original.lights) operations.push({ op: 'light.remove', lightId: light.id })
2257
+ for (const light of items) operations.push({ op: 'light.add', light: editorClone(light) })
2258
+ continue
2259
+ }
2204
2260
  for (const item of items) {
2205
2261
  const original = draft.original[`${kind}s`].find(old => old.id === item.id)
2206
2262
  if (!original) { operations.push({ op: 'light.add', light: editorClone(item) }); continue }
2207
2263
  const changes = {}
2264
+ if (kind === 'camera' && photographyAim(item) === 'free' && photographyAim(original) !== 'free') changes.clearTarget = true
2208
2265
  for (const key of fields) if (item[key] !== undefined && !editorEqual(item[key], original[key])) changes[key] = editorClone(item[key])
2209
2266
  const transform = {}
2210
2267
  for (const key of ['location', 'rotationEuler']) if (!editorEqual(item.transform?.[key], original.transform?.[key])) transform[key] = editorClone(item.transform[key])
@@ -2212,10 +2269,40 @@ window.__ModuleLoader__.load({
2212
2269
  if (Object.keys(changes).length) operations.push({ op: `${kind}.update`, [`${kind}Id`]: item.id, ...changes })
2213
2270
  }
2214
2271
  }
2215
- return { projectId: draft.projectId, baseRevision: draft.baseRevision, actor: 'ui', saveCheckpoint: true, renderPreview: false, operations }
2272
+ const note = draft.reuseSource ? t('photo.reuseNote', draft.reuseSource) : null
2273
+ return { projectId: draft.projectId, baseRevision: draft.baseRevision, actor: 'ui', saveCheckpoint: true, renderPreview: false, operations, ...(note ? { note } : {}) }
2216
2274
  }
2217
2275
  function photographyDirty(draft) {
2218
- return Boolean(draft && !editorEqual({ cameras: draft.cameras, lights: draft.lights }, draft.original))
2276
+ return Boolean(draft && (creationSignature(draft.cameras) !== creationSignature(draft.original.cameras)
2277
+ || !photographySameLights(draft.lights, draft.original.lights)))
2278
+ }
2279
+ function reusePhotography(draft, source, cameraId, targetEntityId) {
2280
+ const camera = source?.nodes?.cameras?.find(item => item.id === cameraId)?.definition
2281
+ const target = draft?.cameras?.find(item => item.id === draft.cameraId)
2282
+ if (!camera || !target || !Array.isArray(source.nodes.lights)) throw new Error(t('photo.reuseUnavailable'))
2283
+ const next = editorClone(draft), selected = next.cameras.find(item => item.id === next.cameraId)
2284
+ selected.lens = editorNumberDefault(camera.lens, 50)
2285
+ selected.transform ||= {}
2286
+ selected.transform.location = editorClone(camera.transform?.location || [0, 0, 0])
2287
+ delete selected.targetEntityId; delete selected.targetPoint
2288
+ if (photographyAim(camera) === 'entity') {
2289
+ if (!next.entities.some(item => item.id === targetEntityId)) throw new Error(t('photo.reuseTargetRequired'))
2290
+ selected.targetEntityId = targetEntityId
2291
+ } else if (photographyAim(camera) === 'point') selected.targetPoint = editorClone(camera.targetPoint)
2292
+ if (photographyAim(camera) === 'free') selected.transform.rotationEuler = editorClone(camera.transform?.rotationEuler || [0, 0, 0])
2293
+ else {
2294
+ const original = next.original.cameras.find(item => item.id === selected.id)
2295
+ if (original?.transform?.rotationEuler) selected.transform.rotationEuler = editorClone(original.transform.rotationEuler)
2296
+ else delete selected.transform.rotationEuler
2297
+ }
2298
+ next.lights = source.nodes.lights.map(item => editorClone(item.definition))
2299
+ if (next.lights.some(item => !item)) throw new Error(t('photo.reuseUnavailable'))
2300
+ next.lightId = next.lights.some(item => item.id === next.lightId) ? next.lightId : next.lights[0]?.id || ''
2301
+ next.reusedLights = true
2302
+ next.reuseSource = { project: source.project.id, revision: source.revision, digest: source.digest, camera: cameraId, target: targetEntityId || '—' }
2303
+ const errors = photographyErrors(next)
2304
+ if (errors.length) throw new Error(t('photo.invalid', { fields: errors.join(', ') }))
2305
+ return next
2219
2306
  }
2220
2307
  function inspectionReceiptValid(receipt, request, digest) {
2221
2308
  const artifacts = receipt?.artifacts, view = request.views[0]
@@ -2622,6 +2709,7 @@ window.__ModuleLoader__.load({
2622
2709
  imageDownloads: {},
2623
2710
  creationWork: null,
2624
2711
  creationRequestProtocol: null,
2712
+ cameraTargetResetSupported: false,
2625
2713
  creationDrafts: [],
2626
2714
  creationDraftStorageStatus: null,
2627
2715
  creationDraftMissingRecipe: null,
@@ -2643,6 +2731,7 @@ window.__ModuleLoader__.load({
2643
2731
  photographyDrafts: {},
2644
2732
  photographyWork: {},
2645
2733
  photographyEdits: {},
2734
+ photographyReuse: {},
2646
2735
  editorEntityId: null,
2647
2736
  editorDrafts: {},
2648
2737
  editorLastEdits: {},
@@ -2711,6 +2800,8 @@ window.__ModuleLoader__.load({
2711
2800
  const draftLifecycle = settings.creationDraftLifecycle ?? globalThis.window
2712
2801
  let fileChoiceSignature = null, fileChoicesStopped = false
2713
2802
  const photographyControllers = new Map()
2803
+ const photographySourceControllers = new Map()
2804
+ let photographySourceSequence = 0
2714
2805
  const photographyWork = (projectId, changes) => set({ photographyWork: { ...data.photographyWork, [projectId]: { ...data.photographyWork[projectId], ...changes } } })
2715
2806
  const inspectionControllers = new Map()
2716
2807
  const imageDownloadControllers = new Map()
@@ -2765,7 +2856,9 @@ window.__ModuleLoader__.load({
2765
2856
  // Automatic edit images are source snapshots too. They cannot override
2766
2857
  // fresh rows from another store, even when its project/revision IDs match.
2767
2858
  if (Object.hasOwn(patch, 'projectsRoot') && patch.projectsRoot !== data.projectsRoot) {
2768
- patch = { ...patch, editorComparison: null }
2859
+ for (const controller of photographySourceControllers.values()) controller.abort()
2860
+ photographySourceControllers.clear()
2861
+ patch = { ...patch, editorComparison: null, photographyReuse: {} }
2769
2862
  }
2770
2863
  // Retire the old source before selection/creation navigation can expose
2771
2864
  // it with a new project. A load supplies the complete new source atomically.
@@ -2862,6 +2955,7 @@ window.__ModuleLoader__.load({
2862
2955
  recipeCatalog: payload.recipeCatalog ?? { recipes: [], errors: [] },
2863
2956
  projectsRoot: payload.projectsRoot ?? null,
2864
2957
  creationRequestProtocol: payload.creationRequestProtocol ?? null,
2958
+ cameraTargetResetSupported: payload.cameraTargetResetSupported === true,
2865
2959
  selected: payload.selected ?? null,
2866
2960
  activeProjectId: active,
2867
2961
  currentRevision: payload.selected ? payload.selected.currentRevision : null,
@@ -2909,7 +3003,7 @@ window.__ModuleLoader__.load({
2909
3003
  if (patch.selected?.scene && active) {
2910
3004
  const photography = data.photographyDrafts[active]
2911
3005
  if ((!photography || !photographyDirty(photography)) && !data.photographyWork[active]?.saving) {
2912
- patch.photographyDrafts = { ...data.photographyDrafts, [active]: createPhotographyDraft(patch.selected.scene, active, photography) }
3006
+ patch.photographyDrafts = { ...data.photographyDrafts, [active]: createPhotographyDraft(patch.selected.scene, active, photography, patch.projectsRoot) }
2913
3007
  }
2914
3008
  const draft = data.briefDrafts[active], work = data.briefWork[active]
2915
3009
  if (!draft || !briefDirty(draft) && !work?.uploading && !work?.saving) {
@@ -3036,6 +3130,46 @@ window.__ModuleLoader__.load({
3036
3130
 
3037
3131
  const acceptsFileChoice = (source, field) => !fileChoicesStopped && typeof source === 'string'
3038
3132
  && FILE_FIELDS.includes(field) && source === fileChoiceKey(data, field) && fileChoiceAllowed(data, field)
3133
+ const loadPhotographySource = async requestedRevision => {
3134
+ const draft = photographyDraftFor(data), projectId = data.activeProjectId, selection = data.photographyReuse[projectId]
3135
+ if (!data.cameraTargetResetSupported || !draft || !selection?.sourceProjectId || photographyBusy(data.photographyWork[projectId])
3136
+ || draft.baseRevision !== data.selected?.scene?.revision || draft.projectsRoot !== data.projectsRoot) return
3137
+ photographySourceControllers.get(projectId)?.abort()
3138
+ const controller = new AbortController(), sequence = ++photographySourceSequence
3139
+ photographySourceControllers.set(projectId, controller)
3140
+ const root = data.projectsRoot, baseRevision = draft.baseRevision, sourceProjectId = selection.sourceProjectId
3141
+ const alive = () => running && !controller.signal.aborted && photographySourceControllers.get(projectId) === controller && data.projectsRoot === root
3142
+ const update = changes => set({ photographyReuse: { ...data.photographyReuse, [projectId]: { ...data.photographyReuse[projectId], ...changes } } })
3143
+ update({ source: null, loading: true, error: null, applied: false, unchanged: false, replaceDraft: false, projectsRoot: root, baseRevision, sequence })
3144
+ const deadline = setTimeout(() => controller.abort(), 30000)
3145
+ const read = (path, route) => readRoute((url, options) => fetchImpl(url, { ...options, signal: controller.signal }), path, route)
3146
+ try {
3147
+ const listed = await read(projectRoute(sourceProjectId, '/revisions'), 'project.revisions')
3148
+ if (!alive()) return
3149
+ const list = listed.payload
3150
+ if (listed.status !== 'ok' || list?.projectId !== sourceProjectId || !Array.isArray(list.revisions)
3151
+ || list.revisions.some(item => !/^r[0-9]{4,}$/.test(item.revision))) throw new Error(listed.error?.message || t('photo.reuseUnavailable'))
3152
+ const revision = requestedRevision || list.currentRevision, saved = list.revisions.find(item => item.revision === revision)
3153
+ if (!saved) throw new Error(t('photo.reuseUnavailable'))
3154
+ update({ revisions: editorClone(list.revisions), revision })
3155
+ const result = await read(`${projectRoute(sourceProjectId, '/scene')}?revision=${encodeURIComponent(revision)}`, 'project.scene')
3156
+ if (!alive()) return
3157
+ const payload = result.payload, scene = payload?.scene
3158
+ if (result.status !== 'ok' || payload?.projectId !== sourceProjectId || payload.revision !== revision
3159
+ || scene?.revision !== revision || scene?.project?.id !== sourceProjectId || scene.digest !== payload.digest
3160
+ || !/^[a-f0-9]{64}$/.test(payload.digest || '') || (saved.digest && saved.digest !== payload.digest)
3161
+ || !Array.isArray(scene.nodes?.cameras) || !Array.isArray(scene.nodes?.lights)) throw new Error(result.error?.message || t('photo.reuseUnavailable'))
3162
+ update({ source: editorClone(scene), cameraId: scene.nodes.cameras.find(item => item.id === scene.project.activeCamera)?.id || scene.nodes.cameras[0]?.id || '', targetEntityId: '', error: null })
3163
+ } catch (error) {
3164
+ if (alive()) update({ source: null, error: error.message || String(error) })
3165
+ } finally {
3166
+ clearTimeout(deadline)
3167
+ if (photographySourceControllers.get(projectId) === controller) {
3168
+ photographySourceControllers.delete(projectId)
3169
+ if (running && data.projectsRoot === root) update({ loading: false, ...(controller.signal.aborted ? { source: null, error: t('photo.reuseUnavailable') } : {}) })
3170
+ }
3171
+ }
3172
+ }
3039
3173
  const actions = {
3040
3174
  acceptsFileChoice,
3041
3175
  setAppearance: value => {
@@ -3439,7 +3573,43 @@ window.__ModuleLoader__.load({
3439
3573
  resetPhotography: () => {
3440
3574
  const projectId = data.activeProjectId, scene = data.selected?.scene
3441
3575
  if (!scene || photographyBusy(data.photographyWork[projectId])) return
3442
- set({ photographyDrafts: { ...data.photographyDrafts, [projectId]: createPhotographyDraft(scene, projectId, photographyDraftFor(data)) } })
3576
+ set({ photographyDrafts: { ...data.photographyDrafts, [projectId]: createPhotographyDraft(scene, projectId, photographyDraftFor(data), data.projectsRoot) },
3577
+ photographyReuse: { ...data.photographyReuse, ...(data.photographyReuse[projectId] ? { [projectId]: { ...data.photographyReuse[projectId], applied: false, unchanged: false, replaceDraft: false } } : {}) } })
3578
+ },
3579
+ setPhotographySource: (field, value) => {
3580
+ const projectId = data.activeProjectId, selection = data.photographyReuse[projectId] || {}
3581
+ if (!data.cameraTargetResetSupported || !photographyDraftFor(data) || photographyBusy(data.photographyWork[projectId])) return
3582
+ if (field === 'project') {
3583
+ if (value && !data.projects.some(item => item.projectId === value && !item.creationPending)) return
3584
+ photographySourceControllers.get(projectId)?.abort(); photographySourceControllers.delete(projectId)
3585
+ set({ photographyReuse: { ...data.photographyReuse, [projectId]: { sourceProjectId: value, source: null, revisions: [], revision: '', cameraId: '', targetEntityId: '', replaceDraft: false } } })
3586
+ if (value) return loadPhotographySource()
3587
+ } else if (field === 'revision') {
3588
+ if (!selection.revisions?.some(item => item.revision === value)) return
3589
+ return loadPhotographySource(value)
3590
+ } else if (field === 'camera' && selection.source?.nodes.cameras.some(item => item.id === value)) {
3591
+ set({ photographyReuse: { ...data.photographyReuse, [projectId]: { ...selection, cameraId: value, targetEntityId: '', applied: false, unchanged: false } } })
3592
+ } else if (field === 'target' && (!value || photographyDraftFor(data).entities.some(item => item.id === value))) {
3593
+ set({ photographyReuse: { ...data.photographyReuse, [projectId]: { ...selection, targetEntityId: value, applied: false, unchanged: false } } })
3594
+ } else if (field === 'replace' && typeof value === 'boolean') {
3595
+ set({ photographyReuse: { ...data.photographyReuse, [projectId]: { ...selection, replaceDraft: value } } })
3596
+ }
3597
+ },
3598
+ reloadPhotographySource: () => loadPhotographySource(data.photographyReuse[data.activeProjectId]?.revision),
3599
+ reusePhotography: () => {
3600
+ const projectId = data.activeProjectId, selection = data.photographyReuse[projectId], draft = photographyDraftFor(data)
3601
+ if (!data.cameraTargetResetSupported || !draft || !selection?.source || selection.loading || photographyBusy(data.photographyWork[projectId])
3602
+ || selection.projectsRoot !== data.projectsRoot || draft.projectsRoot !== data.projectsRoot
3603
+ || selection.baseRevision !== draft.baseRevision || draft.baseRevision !== data.selected?.scene?.revision
3604
+ || (photographyDirty(draft) && !selection.replaceDraft)) return
3605
+ try {
3606
+ const next = reusePhotography(draft, selection.source, selection.cameraId, selection.targetEntityId)
3607
+ set({ photographyDrafts: { ...data.photographyDrafts, [projectId]: next }, photographyReuse: {
3608
+ ...data.photographyReuse, [projectId]: { ...selection, replaceDraft: false, error: null, applied: photographyDirty(next), unchanged: !photographyDirty(next) },
3609
+ } })
3610
+ } catch (error) {
3611
+ set({ photographyReuse: { ...data.photographyReuse, [projectId]: { ...selection, applied: false, error: error.message || String(error) } } })
3612
+ }
3443
3613
  },
3444
3614
  updatePhotography: (kind, path, value) => {
3445
3615
  const current = photographyDraftFor(data)
@@ -3456,7 +3626,7 @@ window.__ModuleLoader__.load({
3456
3626
  } else if (kind === 'camera' && path.length === 1 && path[0] === 'aim') {
3457
3627
  if (!['free', 'entity', 'point'].includes(value) || photographyAimLocked(draft, item)) return
3458
3628
  const original = draft.original.cameras.find(camera => camera.id === item.id)
3459
- if (value === 'free' && photographyAim(original) !== 'free') return
3629
+ if (value === 'free' && photographyAim(original) !== 'free' && !data.cameraTargetResetSupported) return
3460
3630
  delete item.targetEntityId; delete item.targetPoint
3461
3631
  if (value === 'entity') item.targetEntityId = draft.entities.find(entity => entity.id === data.editorEntityId)?.id || data.selected?.scene?.project?.reviewSubjectId || draft.entities[0]?.id || ''
3462
3632
  if (value === 'point') item.targetPoint = [0, 0, 0]
@@ -3472,7 +3642,8 @@ window.__ModuleLoader__.load({
3472
3642
  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 }
3473
3643
  else return
3474
3644
  }
3475
- set({ photographyDrafts: { ...data.photographyDrafts, [current.projectId]: draft } })
3645
+ set({ photographyDrafts: { ...data.photographyDrafts, [current.projectId]: draft },
3646
+ photographyReuse: { ...data.photographyReuse, ...(data.photographyReuse[current.projectId] ? { [current.projectId]: { ...data.photographyReuse[current.projectId], unchanged: false } } : {}) } })
3476
3647
  },
3477
3648
  addPhotographyLight: () => {
3478
3649
  const current = photographyDraftFor(data)
@@ -3496,7 +3667,9 @@ window.__ModuleLoader__.load({
3496
3667
  setIn('busy', 'editor', false)
3497
3668
  if (outcome.ok) {
3498
3669
  const saved = outcome.payload.revision, drafts = { ...data.photographyDrafts, [projectId]: { ...editorClone(draft), baseRevision: outcome.payload.revision.revision, original: editorClone({ cameras: draft.cameras, lights: draft.lights }) } }
3499
- set({ photographyDrafts: drafts, photographyEdits: { ...data.photographyEdits, [projectId]: {
3670
+ set({ photographyDrafts: drafts,
3671
+ photographyReuse: { ...data.photographyReuse, ...(data.photographyReuse[projectId] ? { [projectId]: { ...data.photographyReuse[projectId], baseRevision: saved.revision, applied: false, unchanged: false, replaceDraft: false } } : {}) },
3672
+ photographyEdits: { ...data.photographyEdits, [projectId]: {
3500
3673
  before: draft.baseRevision, after: saved.revision, digest: saved.digest, cameraId: draft.cameraId, frame: draft.frame, samples: draft.samples,
3501
3674
  changes: [...new Set(patch.operations.map(operation => operation.op.split('.')[0]))] } } })
3502
3675
  photographyWork(projectId, { saving: false, error: null, artifact: null })
@@ -3896,6 +4069,8 @@ window.__ModuleLoader__.load({
3896
4069
  if (timer !== null) { clearInterval(timer); timer = null }
3897
4070
  for (const controller of photographyControllers.values()) controller.abort()
3898
4071
  photographyControllers.clear()
4072
+ for (const controller of photographySourceControllers.values()) controller.abort()
4073
+ photographySourceControllers.clear()
3899
4074
  for (const controller of inspectionControllers.values()) controller.abort()
3900
4075
  inspectionControllers.clear()
3901
4076
  for (const controller of imageDownloadControllers.values()) controller.abort('stopped')
@@ -4292,7 +4467,11 @@ window.__ModuleLoader__.load({
4292
4467
  el('p', { className: 'db-muted', key: 'local-size' }, t('editor.localDimensions', { scale: (draft.entity.transform.scale || [1, 1, 1]).join(' × ') })),
4293
4468
  geometry.shape === 'rounded_box' ? el('p', { className: 'db-muted', key: 'rounded' }, t('editor.roundedRequired')) : null,
4294
4469
  geometry.shape === 'handled_cup' ? el('p', { className: 'db-muted', key: 'cup-help' }, t('editor.cupHelp')) : null,
4295
- ...(GENERATOR_FIELDS[geometry.shape] || []).filter(key => geometry.shape !== 'handled_cup' || !CUP_ADVANCED_FIELDS.includes(key)).map(geometryField),
4470
+ ...(GENERATOR_FIELDS[geometry.shape] || []).filter(key => !(key === 'size' && geometry.dimensions !== undefined)
4471
+ && (geometry.shape !== 'handled_cup' || !CUP_ADVANCED_FIELDS.includes(key))).map(geometryField),
4472
+ ...(geometry.dimensions !== undefined ? [t('editor.boxWidth'), t('editor.boxDepth'), t('editor.boxHeight')].map((label, index) => numeric(
4473
+ label, `editor-generator-dimensions-${EDITOR_AXES[index]}`, geometry.dimensions[index],
4474
+ value => actions.updateEditor('generator', ['dimensions', index], value), { factor: 1000, min: Number.MIN_VALUE })) : []),
4296
4475
  geometry.shape === 'handled_cup' ? el('details', { key: 'cup-advanced' },
4297
4476
  el('summary', null, t('editor.cupAdvanced')), ...CUP_ADVANCED_FIELDS.map(geometryField)) : null,
4298
4477
  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'),
@@ -4530,18 +4709,38 @@ window.__ModuleLoader__.load({
4530
4709
  { factor, locked: locked || editorTrackLocked(draft, kind, kind === 'camera' ? camera.id : light.id, `${field}.${axis}`) })))
4531
4710
  const aim = photographyAim(camera), originalCamera = draft?.original.cameras.find(item => item.id === camera?.id)
4532
4711
  const aimLocked = camera && photographyAimLocked(draft, camera)
4712
+ const reuse = state.photographyReuse?.[projectId] || {}, sourceCamera = reuse.source?.nodes.cameras.find(item => item.id === reuse.cameraId)?.definition
4713
+ const reuseStale = reuse.source && (reuse.projectsRoot !== state.projectsRoot || reuse.baseRevision !== draft?.baseRevision || draft?.projectsRoot !== state.projectsRoot)
4533
4714
  return el('section', { className: 'db-card', 'data-photography-editor': projectId },
4534
4715
  el('h4', null, t('photo.title')), el('p', { className: 'db-muted' }, t('photo.help')),
4535
4716
  draft ? el('div', null,
4536
4717
  el('p', { className: 'db-muted', 'data-photography-base': draft.baseRevision }, t('photo.base', { revision: draft.baseRevision })),
4537
4718
  conflict ? el('p', { className: 'db-error', 'data-photography-conflict': true }, t('photo.conflict', { before: draft.baseRevision, current: state.selected?.scene?.revision })) : null,
4719
+ state.cameraTargetResetSupported ? el('div', { 'data-photography-reuse': true },
4720
+ el('h4', null, t('photo.reuseTitle')), el('p', { className: 'db-muted' }, t('photo.reuseHelp')),
4721
+ choose(t('photo.reuseProject'), 'source-project', reuse.sourceProjectId || '', [['', '—'], ...state.projects.filter(item => !item.creationPending).map(item => [item.projectId, item.title || item.projectId])], value => actions.setPhotographySource('project', value)),
4722
+ choose(t('photo.reuseRevision'), 'source-revision', reuse.revision || '', [['', '—'], ...(reuse.revisions || []).map(item => [item.revision, item.revision])], value => actions.setPhotographySource('revision', value), reuse.loading || !reuse.revisions?.length),
4723
+ reuse.loading ? el('p', { role: 'status' }, t('photo.reuseReading')) : null,
4724
+ reuse.source ? el('div', { 'data-photography-source': `${reuse.sourceProjectId}:${reuse.revision}` },
4725
+ el('p', { className: 'db-muted' }, t('photo.reuseSource', { project: reuse.sourceProjectId, revision: reuse.revision, digest: shortDigest(reuse.source.digest), count: reuse.source.nodes.lights.length })),
4726
+ choose(t('photo.reuseCamera'), 'source-camera', reuse.cameraId, reuse.source.nodes.cameras.map(item => [item.id, item.id]), value => actions.setPhotographySource('camera', value)),
4727
+ photographyAim(sourceCamera) === 'entity' ? choose(t('photo.reuseTarget'), 'source-target', reuse.targetEntityId || '', [['', '—'], ...draft.entities.map(item => [item.id, item.id])], value => actions.setPhotographySource('target', value)) : null,
4728
+ photographyDirty(draft) ? el('label', { className: 'db-row' }, el('input', { type: 'checkbox', 'data-field': 'photo-source-replace', checked: Boolean(reuse.replaceDraft), disabled: disabled || reuse.loading,
4729
+ onChange: event => actions.setPhotographySource('replace', Boolean(event.target.checked)) }), t('photo.reuseReplace')) : null,
4730
+ reuseStale ? el('p', { className: 'db-error' }, t('photo.reuseStale')) : null,
4731
+ reuse.applied ? el('p', { role: 'status', 'data-photography-reused': true }, t('photo.reuseApplied')) : null,
4732
+ reuse.unchanged ? el('p', { role: 'status', 'data-photography-reuse-unchanged': true }, t('photo.reuseSame')) : null) : null,
4733
+ reuse.error ? el('p', { className: 'db-error', role: 'status', 'data-photography-source-error': true }, reuse.error) : null,
4734
+ el('div', { className: 'db-inline' },
4735
+ Button({ action: 'photo-source-reload', disabled: disabled || !reuse.sourceProjectId || reuse.loading, onClick: actions.reloadPhotographySource, children: t('photo.reuseReload') }),
4736
+ Button({ action: 'photo-reuse', disabled: disabled || reuse.loading || !reuse.source || reuseStale || (photographyDirty(draft) && !reuse.replaceDraft) || (photographyAim(sourceCamera) === 'entity' && !reuse.targetEntityId), onClick: actions.reusePhotography, children: t('photo.reuseApply') }))) : el('p', { className: 'db-muted' }, t('photo.reuseUpgrade')),
4538
4737
  el('div', { className: 'db-grid' },
4539
4738
  el('div', null,
4540
4739
  choose(t('photo.camera'), 'camera', draft.cameraId, draft.cameras.map(item => [item.id, item.id]), id => actions.selectPhotography('camera', id)),
4541
4740
  camera ? el('div', null,
4542
4741
  numeric(t('photo.lens'), 'camera-lens', editorNumberDefault(camera.lens, 50), update('camera', ['lens']), { min: .001 }),
4543
4742
  vector('camera', 'location', camera.transform?.location, t('photo.position')),
4544
- 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),
4743
+ choose(t('photo.aim'), 'camera-aim', aim, [...(state.cameraTargetResetSupported || photographyAim(originalCamera) === 'free' ? [['free', t('photo.free')]] : []), ['entity', t('photo.entity')], ['point', t('photo.point')]], update('camera', ['aim']), aimLocked),
4545
4744
  aim === 'entity' ? choose(t('photo.target'), 'camera-target', camera.targetEntityId, draft.entities.map(item => [item.id, item.id]), update('camera', ['targetEntityId']), aimLocked) : null,
4546
4745
  aim === 'point' ? vector('camera', 'targetPoint', camera.targetPoint, t('photo.targetPoint'), 1, aimLocked) : null,
4547
4746
  vector('camera', 'rotationEuler', camera.transform?.rotationEuler, t('photo.rotation'), 180 / Math.PI, aim !== 'free'),
@@ -5955,7 +6154,7 @@ window.__ModuleLoader__.load({
5955
6154
  inspection: { form: inspectionForm, valid: inspectionFormValid },
5956
6155
  assetLibrary: { selection: assetBundleSelection, createDraft: createAssetDraft, buildPatch: buildAssetPatch, error: assetDraftError, key: assetKey },
5957
6156
  referenceBrief: { createDraft: createBriefDraft, buildPatch: buildBriefPatch, dirty: briefDirty, valid: briefValid },
5958
- photographyEditor: { createDraft: createPhotographyDraft, buildPatch: buildPhotographyPatch, errors: photographyErrors, dirty: photographyDirty, draftFor: photographyDraftFor },
6157
+ photographyEditor: { createDraft: createPhotographyDraft, buildPatch: buildPhotographyPatch, errors: photographyErrors, dirty: photographyDirty, draftFor: photographyDraftFor, reuse: reusePhotography },
5959
6158
  sceneEditor: { createDraft: createEditorDraft, buildPatch: buildEditorPatch, errors: editorErrors, dirty: editorDirty, previewPair: editorPreviewPair, draftFor: editorDraftFor },
5960
6159
  renderView,
5961
6160
  mountStandalone,
package/lib/index.js CHANGED
@@ -439,6 +439,7 @@ export function statusForError(error) {
439
439
  return 400
440
440
  case BlenderErrorCode.RENDER_JOB_CONFLICT:
441
441
  case BlenderErrorCode.REVISION_CONFLICT:
442
+ case BlenderErrorCode.SCENE_FEATURE_UNSUPPORTED:
442
443
  return 409
443
444
  default:
444
445
  return 500
@@ -633,6 +634,7 @@ export function createHandlers(ctx) {
633
634
  projects: listed.projects.map(record => buildProjectView(record)),
634
635
  projectsRoot: listed.projectsRoot,
635
636
  creationRequestProtocol: studio().creationRequestProtocol ?? null,
637
+ cameraTargetResetSupported: studio().cameraTargetResetSupported === true,
636
638
  selected: projectId === null || creationPending
637
639
  ? null
638
640
  : await buildProjectState(studio(), projectId, query.revision ?? undefined, approvalThreshold()),
@@ -669,6 +671,7 @@ export function createHandlers(ctx) {
669
671
  'project.scene': async ({ params, query }) => {
670
672
  const detail = await studio().getScene(params.projectId, { revision: query.revision, full: true })
671
673
  return {
674
+ projectId: params.projectId,
672
675
  revision: detail.revision,
673
676
  digest: detail.digest,
674
677
  scene: buildSceneTree(detail.spec, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-ui",
3
- "version": "0.3.9",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "DeepBlend workbench UI: Host half (typed HTTP API) and Web Client half (sidebar, panels, tool cards).",
6
6
  "main": "lib/index.js",
@@ -19,7 +19,7 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@deepblend/dsh-blender-contracts": "0.3.9"
22
+ "@deepblend/dsh-blender-contracts": "0.5.0"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {