@deepblend/dsh-blender-ui 0.3.9 → 0.4.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': '没有同相机、同帧和同渲染设置的编辑前预览,暂时无法比较差异。',
@@ -903,6 +921,24 @@ window.__ModuleLoader__.load({
903
921
  "photo.cameraChanges": "cameras",
904
922
  "photo.lightChanges": "lighting",
905
923
  "photo.restoreFailed": "Restore failed; the photography record is retained: {message}",
924
+ "photo.reuseUnavailable": "Complete photography settings are unavailable. Select the source again.",
925
+ "photo.reuseTitle": "Reuse photography from saved work",
926
+ "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.",
927
+ "photo.reuseProject": "Source work",
928
+ "photo.reuseRevision": "Saved source version",
929
+ "photo.reuseCamera": "Source camera",
930
+ "photo.reuseTarget": "Aim at an object in the destination work",
931
+ "photo.reuseReading": "Reading saved photography settings…",
932
+ "photo.reuseSource": "Source: {project} · {revision} · {digest} · {count} lights",
933
+ "photo.reuseReplace": "Replace the unsaved photography draft (keep other edits)",
934
+ "photo.reuseStale": "The destination draft source changed. Read the photography settings again.",
935
+ "photo.reuseApplied": "Applied to the draft; not saved yet. Check the framing and lighting.",
936
+ "photo.reuseSame": "The current photography matches the source; no new save is needed.",
937
+ "photo.reuseReload": "Read source again",
938
+ "photo.reuseApply": "Apply to photography draft",
939
+ "photo.reuseUpgrade": "Update the plugin and restart DSH to reuse photography settings.",
940
+ "photo.reuseNote": "Photography started from {project} · {revision} · {digest} · camera {camera}; destination aim object {target}. The draft may be adjusted before saving.",
941
+ "photo.reuseTargetRequired": "Choose an aim target in the destination work.",
906
942
  'editor.title': 'Edit object',
907
943
  'editor.unknownSettings': 'Measured render settings are missing. This preview cannot establish the edit difference.',
908
944
  'editor.missingBaseline': 'No before preview has the same camera, frame and measured render settings. The edit difference is unavailable.',
@@ -2148,10 +2184,10 @@ window.__ModuleLoader__.load({
2148
2184
  return state.editorDrafts?.[editorKey(state.activeProjectId, state.editorEntityId)] || null
2149
2185
  }
2150
2186
 
2151
- function createPhotographyDraft(scene, projectId, previous = {}) {
2187
+ function createPhotographyDraft(scene, projectId, previous = {}, projectsRoot = null) {
2152
2188
  const cameras = (scene.nodes.cameras || []).map(item => editorClone(item.definition)).filter(Boolean)
2153
2189
  const lights = (scene.nodes.lights || []).map(item => editorClone(item.definition)).filter(Boolean)
2154
- return { projectId, baseRevision: scene.revision, cameras, lights, original: editorClone({ cameras, lights }),
2190
+ return { projectId, projectsRoot, baseRevision: scene.revision, cameras, lights, original: editorClone({ cameras, lights }),
2155
2191
  entities: editorClone(scene.nodes.entities), tracks: editorClone(scene.nodes.animationTracks || []),
2156
2192
  cameraId: cameras.some(item => item.id === previous.cameraId) ? previous.cameraId : scene.project.activeCamera || cameras[0]?.id || '',
2157
2193
  lightId: lights.some(item => item.id === previous.lightId) ? previous.lightId : lights[0]?.id || '',
@@ -2161,6 +2197,8 @@ window.__ModuleLoader__.load({
2161
2197
  const photographyDraftFor = state => state.photographyDrafts?.[state.activeProjectId] || null
2162
2198
  const photographyBusy = work => Boolean(work?.saving || work?.previewing || work?.restoring)
2163
2199
  const photographyAim = camera => camera?.targetEntityId !== undefined ? 'entity' : camera?.targetPoint !== undefined ? 'point' : 'free'
2200
+ const photographySameLights = (a, b) => creationSignature(Object.fromEntries(a.map(item => [item.id, item])))
2201
+ === creationSignature(Object.fromEntries(b.map(item => [item.id, item])))
2164
2202
  const photographyAimLocked = (draft, camera) => EDITOR_AXES.some(axis => editorTrackLocked(draft, 'camera', camera.id, `rotationEuler.${axis}`))
2165
2203
  function photographyErrors(draft) {
2166
2204
  const errors = [], bad = key => errors.push(key), number = (value, min, inclusive = true) => Number.isFinite(value) && (inclusive ? value >= min : value > min)
@@ -2181,14 +2219,14 @@ window.__ModuleLoader__.load({
2181
2219
  if (item.targetPoint !== undefined && !vector(item.targetPoint)) bad(`${item.id}.targetPoint`)
2182
2220
  if (item.targetEntityId !== undefined && item.targetPoint !== undefined) bad(`${item.id}.target`)
2183
2221
  const targetChanged = !editorEqual([item.targetEntityId, item.targetPoint], [original?.targetEntityId, original?.targetPoint])
2184
- if (targetChanged && (photographyAimLocked(draft, item) || photographyAim(item) === 'free')) bad(`${item.id}.target`)
2222
+ if (targetChanged && photographyAimLocked(draft, item)) bad(`${item.id}.target`)
2185
2223
  } else {
2186
2224
  if (!number(editorNumberDefault(item.energy, 100), 0)) bad(`${item.id}.energy`)
2187
2225
  if (!Array.isArray(item.color ?? [1, 1, 1]) || ![3, 4].includes((item.color ?? [1, 1, 1]).length)
2188
2226
  || !(item.color ?? [1, 1, 1]).every(value => number(value, 0) && value <= 1)) bad(`${item.id}.color`)
2189
2227
  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`)
2228
+ if (!draft.reusedLights && ((!original && item.type !== 'area') || (original && item.type !== original.type))) bad(`${item.id}.type`)
2229
+ if (!['area', 'point', 'spot', 'sun'].includes(item.type)) bad(`${item.id}.type`)
2192
2230
  }
2193
2231
  }
2194
2232
  if (!draft.cameras.some(camera => camera.id === draft.cameraId)) bad('camera')
@@ -2200,11 +2238,18 @@ window.__ModuleLoader__.load({
2200
2238
  const errors = photographyErrors(draft)
2201
2239
  if (errors.length) throw new Error(t('photo.invalid', { fields: errors.join(', ') }))
2202
2240
  const operations = []
2241
+ const replaceLights = draft.reusedLights && !photographySameLights(draft.lights, draft.original.lights)
2203
2242
  for (const [kind, items, fields] of [['camera', draft.cameras, ['lens', 'targetEntityId', 'targetPoint']], ['light', draft.lights, ['energy', 'color', 'size']]]) {
2243
+ if (kind === 'light' && replaceLights) {
2244
+ for (const light of draft.original.lights) operations.push({ op: 'light.remove', lightId: light.id })
2245
+ for (const light of items) operations.push({ op: 'light.add', light: editorClone(light) })
2246
+ continue
2247
+ }
2204
2248
  for (const item of items) {
2205
2249
  const original = draft.original[`${kind}s`].find(old => old.id === item.id)
2206
2250
  if (!original) { operations.push({ op: 'light.add', light: editorClone(item) }); continue }
2207
2251
  const changes = {}
2252
+ if (kind === 'camera' && photographyAim(item) === 'free' && photographyAim(original) !== 'free') changes.clearTarget = true
2208
2253
  for (const key of fields) if (item[key] !== undefined && !editorEqual(item[key], original[key])) changes[key] = editorClone(item[key])
2209
2254
  const transform = {}
2210
2255
  for (const key of ['location', 'rotationEuler']) if (!editorEqual(item.transform?.[key], original.transform?.[key])) transform[key] = editorClone(item.transform[key])
@@ -2212,10 +2257,40 @@ window.__ModuleLoader__.load({
2212
2257
  if (Object.keys(changes).length) operations.push({ op: `${kind}.update`, [`${kind}Id`]: item.id, ...changes })
2213
2258
  }
2214
2259
  }
2215
- return { projectId: draft.projectId, baseRevision: draft.baseRevision, actor: 'ui', saveCheckpoint: true, renderPreview: false, operations }
2260
+ const note = draft.reuseSource ? t('photo.reuseNote', draft.reuseSource) : null
2261
+ return { projectId: draft.projectId, baseRevision: draft.baseRevision, actor: 'ui', saveCheckpoint: true, renderPreview: false, operations, ...(note ? { note } : {}) }
2216
2262
  }
2217
2263
  function photographyDirty(draft) {
2218
- return Boolean(draft && !editorEqual({ cameras: draft.cameras, lights: draft.lights }, draft.original))
2264
+ return Boolean(draft && (creationSignature(draft.cameras) !== creationSignature(draft.original.cameras)
2265
+ || !photographySameLights(draft.lights, draft.original.lights)))
2266
+ }
2267
+ function reusePhotography(draft, source, cameraId, targetEntityId) {
2268
+ const camera = source?.nodes?.cameras?.find(item => item.id === cameraId)?.definition
2269
+ const target = draft?.cameras?.find(item => item.id === draft.cameraId)
2270
+ if (!camera || !target || !Array.isArray(source.nodes.lights)) throw new Error(t('photo.reuseUnavailable'))
2271
+ const next = editorClone(draft), selected = next.cameras.find(item => item.id === next.cameraId)
2272
+ selected.lens = editorNumberDefault(camera.lens, 50)
2273
+ selected.transform ||= {}
2274
+ selected.transform.location = editorClone(camera.transform?.location || [0, 0, 0])
2275
+ delete selected.targetEntityId; delete selected.targetPoint
2276
+ if (photographyAim(camera) === 'entity') {
2277
+ if (!next.entities.some(item => item.id === targetEntityId)) throw new Error(t('photo.reuseTargetRequired'))
2278
+ selected.targetEntityId = targetEntityId
2279
+ } else if (photographyAim(camera) === 'point') selected.targetPoint = editorClone(camera.targetPoint)
2280
+ if (photographyAim(camera) === 'free') selected.transform.rotationEuler = editorClone(camera.transform?.rotationEuler || [0, 0, 0])
2281
+ else {
2282
+ const original = next.original.cameras.find(item => item.id === selected.id)
2283
+ if (original?.transform?.rotationEuler) selected.transform.rotationEuler = editorClone(original.transform.rotationEuler)
2284
+ else delete selected.transform.rotationEuler
2285
+ }
2286
+ next.lights = source.nodes.lights.map(item => editorClone(item.definition))
2287
+ if (next.lights.some(item => !item)) throw new Error(t('photo.reuseUnavailable'))
2288
+ next.lightId = next.lights.some(item => item.id === next.lightId) ? next.lightId : next.lights[0]?.id || ''
2289
+ next.reusedLights = true
2290
+ next.reuseSource = { project: source.project.id, revision: source.revision, digest: source.digest, camera: cameraId, target: targetEntityId || '—' }
2291
+ const errors = photographyErrors(next)
2292
+ if (errors.length) throw new Error(t('photo.invalid', { fields: errors.join(', ') }))
2293
+ return next
2219
2294
  }
2220
2295
  function inspectionReceiptValid(receipt, request, digest) {
2221
2296
  const artifacts = receipt?.artifacts, view = request.views[0]
@@ -2622,6 +2697,7 @@ window.__ModuleLoader__.load({
2622
2697
  imageDownloads: {},
2623
2698
  creationWork: null,
2624
2699
  creationRequestProtocol: null,
2700
+ cameraTargetResetSupported: false,
2625
2701
  creationDrafts: [],
2626
2702
  creationDraftStorageStatus: null,
2627
2703
  creationDraftMissingRecipe: null,
@@ -2643,6 +2719,7 @@ window.__ModuleLoader__.load({
2643
2719
  photographyDrafts: {},
2644
2720
  photographyWork: {},
2645
2721
  photographyEdits: {},
2722
+ photographyReuse: {},
2646
2723
  editorEntityId: null,
2647
2724
  editorDrafts: {},
2648
2725
  editorLastEdits: {},
@@ -2711,6 +2788,8 @@ window.__ModuleLoader__.load({
2711
2788
  const draftLifecycle = settings.creationDraftLifecycle ?? globalThis.window
2712
2789
  let fileChoiceSignature = null, fileChoicesStopped = false
2713
2790
  const photographyControllers = new Map()
2791
+ const photographySourceControllers = new Map()
2792
+ let photographySourceSequence = 0
2714
2793
  const photographyWork = (projectId, changes) => set({ photographyWork: { ...data.photographyWork, [projectId]: { ...data.photographyWork[projectId], ...changes } } })
2715
2794
  const inspectionControllers = new Map()
2716
2795
  const imageDownloadControllers = new Map()
@@ -2765,7 +2844,9 @@ window.__ModuleLoader__.load({
2765
2844
  // Automatic edit images are source snapshots too. They cannot override
2766
2845
  // fresh rows from another store, even when its project/revision IDs match.
2767
2846
  if (Object.hasOwn(patch, 'projectsRoot') && patch.projectsRoot !== data.projectsRoot) {
2768
- patch = { ...patch, editorComparison: null }
2847
+ for (const controller of photographySourceControllers.values()) controller.abort()
2848
+ photographySourceControllers.clear()
2849
+ patch = { ...patch, editorComparison: null, photographyReuse: {} }
2769
2850
  }
2770
2851
  // Retire the old source before selection/creation navigation can expose
2771
2852
  // it with a new project. A load supplies the complete new source atomically.
@@ -2862,6 +2943,7 @@ window.__ModuleLoader__.load({
2862
2943
  recipeCatalog: payload.recipeCatalog ?? { recipes: [], errors: [] },
2863
2944
  projectsRoot: payload.projectsRoot ?? null,
2864
2945
  creationRequestProtocol: payload.creationRequestProtocol ?? null,
2946
+ cameraTargetResetSupported: payload.cameraTargetResetSupported === true,
2865
2947
  selected: payload.selected ?? null,
2866
2948
  activeProjectId: active,
2867
2949
  currentRevision: payload.selected ? payload.selected.currentRevision : null,
@@ -2909,7 +2991,7 @@ window.__ModuleLoader__.load({
2909
2991
  if (patch.selected?.scene && active) {
2910
2992
  const photography = data.photographyDrafts[active]
2911
2993
  if ((!photography || !photographyDirty(photography)) && !data.photographyWork[active]?.saving) {
2912
- patch.photographyDrafts = { ...data.photographyDrafts, [active]: createPhotographyDraft(patch.selected.scene, active, photography) }
2994
+ patch.photographyDrafts = { ...data.photographyDrafts, [active]: createPhotographyDraft(patch.selected.scene, active, photography, patch.projectsRoot) }
2913
2995
  }
2914
2996
  const draft = data.briefDrafts[active], work = data.briefWork[active]
2915
2997
  if (!draft || !briefDirty(draft) && !work?.uploading && !work?.saving) {
@@ -3036,6 +3118,46 @@ window.__ModuleLoader__.load({
3036
3118
 
3037
3119
  const acceptsFileChoice = (source, field) => !fileChoicesStopped && typeof source === 'string'
3038
3120
  && FILE_FIELDS.includes(field) && source === fileChoiceKey(data, field) && fileChoiceAllowed(data, field)
3121
+ const loadPhotographySource = async requestedRevision => {
3122
+ const draft = photographyDraftFor(data), projectId = data.activeProjectId, selection = data.photographyReuse[projectId]
3123
+ if (!data.cameraTargetResetSupported || !draft || !selection?.sourceProjectId || photographyBusy(data.photographyWork[projectId])
3124
+ || draft.baseRevision !== data.selected?.scene?.revision || draft.projectsRoot !== data.projectsRoot) return
3125
+ photographySourceControllers.get(projectId)?.abort()
3126
+ const controller = new AbortController(), sequence = ++photographySourceSequence
3127
+ photographySourceControllers.set(projectId, controller)
3128
+ const root = data.projectsRoot, baseRevision = draft.baseRevision, sourceProjectId = selection.sourceProjectId
3129
+ const alive = () => running && !controller.signal.aborted && photographySourceControllers.get(projectId) === controller && data.projectsRoot === root
3130
+ const update = changes => set({ photographyReuse: { ...data.photographyReuse, [projectId]: { ...data.photographyReuse[projectId], ...changes } } })
3131
+ update({ source: null, loading: true, error: null, applied: false, unchanged: false, replaceDraft: false, projectsRoot: root, baseRevision, sequence })
3132
+ const deadline = setTimeout(() => controller.abort(), 30000)
3133
+ const read = (path, route) => readRoute((url, options) => fetchImpl(url, { ...options, signal: controller.signal }), path, route)
3134
+ try {
3135
+ const listed = await read(projectRoute(sourceProjectId, '/revisions'), 'project.revisions')
3136
+ if (!alive()) return
3137
+ const list = listed.payload
3138
+ if (listed.status !== 'ok' || list?.projectId !== sourceProjectId || !Array.isArray(list.revisions)
3139
+ || list.revisions.some(item => !/^r[0-9]{4,}$/.test(item.revision))) throw new Error(listed.error?.message || t('photo.reuseUnavailable'))
3140
+ const revision = requestedRevision || list.currentRevision, saved = list.revisions.find(item => item.revision === revision)
3141
+ if (!saved) throw new Error(t('photo.reuseUnavailable'))
3142
+ update({ revisions: editorClone(list.revisions), revision })
3143
+ const result = await read(`${projectRoute(sourceProjectId, '/scene')}?revision=${encodeURIComponent(revision)}`, 'project.scene')
3144
+ if (!alive()) return
3145
+ const payload = result.payload, scene = payload?.scene
3146
+ if (result.status !== 'ok' || payload?.projectId !== sourceProjectId || payload.revision !== revision
3147
+ || scene?.revision !== revision || scene?.project?.id !== sourceProjectId || scene.digest !== payload.digest
3148
+ || !/^[a-f0-9]{64}$/.test(payload.digest || '') || (saved.digest && saved.digest !== payload.digest)
3149
+ || !Array.isArray(scene.nodes?.cameras) || !Array.isArray(scene.nodes?.lights)) throw new Error(result.error?.message || t('photo.reuseUnavailable'))
3150
+ update({ source: editorClone(scene), cameraId: scene.nodes.cameras.find(item => item.id === scene.project.activeCamera)?.id || scene.nodes.cameras[0]?.id || '', targetEntityId: '', error: null })
3151
+ } catch (error) {
3152
+ if (alive()) update({ source: null, error: error.message || String(error) })
3153
+ } finally {
3154
+ clearTimeout(deadline)
3155
+ if (photographySourceControllers.get(projectId) === controller) {
3156
+ photographySourceControllers.delete(projectId)
3157
+ if (running && data.projectsRoot === root) update({ loading: false, ...(controller.signal.aborted ? { source: null, error: t('photo.reuseUnavailable') } : {}) })
3158
+ }
3159
+ }
3160
+ }
3039
3161
  const actions = {
3040
3162
  acceptsFileChoice,
3041
3163
  setAppearance: value => {
@@ -3439,7 +3561,43 @@ window.__ModuleLoader__.load({
3439
3561
  resetPhotography: () => {
3440
3562
  const projectId = data.activeProjectId, scene = data.selected?.scene
3441
3563
  if (!scene || photographyBusy(data.photographyWork[projectId])) return
3442
- set({ photographyDrafts: { ...data.photographyDrafts, [projectId]: createPhotographyDraft(scene, projectId, photographyDraftFor(data)) } })
3564
+ set({ photographyDrafts: { ...data.photographyDrafts, [projectId]: createPhotographyDraft(scene, projectId, photographyDraftFor(data), data.projectsRoot) },
3565
+ photographyReuse: { ...data.photographyReuse, ...(data.photographyReuse[projectId] ? { [projectId]: { ...data.photographyReuse[projectId], applied: false, unchanged: false, replaceDraft: false } } : {}) } })
3566
+ },
3567
+ setPhotographySource: (field, value) => {
3568
+ const projectId = data.activeProjectId, selection = data.photographyReuse[projectId] || {}
3569
+ if (!data.cameraTargetResetSupported || !photographyDraftFor(data) || photographyBusy(data.photographyWork[projectId])) return
3570
+ if (field === 'project') {
3571
+ if (value && !data.projects.some(item => item.projectId === value && !item.creationPending)) return
3572
+ photographySourceControllers.get(projectId)?.abort(); photographySourceControllers.delete(projectId)
3573
+ set({ photographyReuse: { ...data.photographyReuse, [projectId]: { sourceProjectId: value, source: null, revisions: [], revision: '', cameraId: '', targetEntityId: '', replaceDraft: false } } })
3574
+ if (value) return loadPhotographySource()
3575
+ } else if (field === 'revision') {
3576
+ if (!selection.revisions?.some(item => item.revision === value)) return
3577
+ return loadPhotographySource(value)
3578
+ } else if (field === 'camera' && selection.source?.nodes.cameras.some(item => item.id === value)) {
3579
+ set({ photographyReuse: { ...data.photographyReuse, [projectId]: { ...selection, cameraId: value, targetEntityId: '', applied: false, unchanged: false } } })
3580
+ } else if (field === 'target' && (!value || photographyDraftFor(data).entities.some(item => item.id === value))) {
3581
+ set({ photographyReuse: { ...data.photographyReuse, [projectId]: { ...selection, targetEntityId: value, applied: false, unchanged: false } } })
3582
+ } else if (field === 'replace' && typeof value === 'boolean') {
3583
+ set({ photographyReuse: { ...data.photographyReuse, [projectId]: { ...selection, replaceDraft: value } } })
3584
+ }
3585
+ },
3586
+ reloadPhotographySource: () => loadPhotographySource(data.photographyReuse[data.activeProjectId]?.revision),
3587
+ reusePhotography: () => {
3588
+ const projectId = data.activeProjectId, selection = data.photographyReuse[projectId], draft = photographyDraftFor(data)
3589
+ if (!data.cameraTargetResetSupported || !draft || !selection?.source || selection.loading || photographyBusy(data.photographyWork[projectId])
3590
+ || selection.projectsRoot !== data.projectsRoot || draft.projectsRoot !== data.projectsRoot
3591
+ || selection.baseRevision !== draft.baseRevision || draft.baseRevision !== data.selected?.scene?.revision
3592
+ || (photographyDirty(draft) && !selection.replaceDraft)) return
3593
+ try {
3594
+ const next = reusePhotography(draft, selection.source, selection.cameraId, selection.targetEntityId)
3595
+ set({ photographyDrafts: { ...data.photographyDrafts, [projectId]: next }, photographyReuse: {
3596
+ ...data.photographyReuse, [projectId]: { ...selection, replaceDraft: false, error: null, applied: photographyDirty(next), unchanged: !photographyDirty(next) },
3597
+ } })
3598
+ } catch (error) {
3599
+ set({ photographyReuse: { ...data.photographyReuse, [projectId]: { ...selection, applied: false, error: error.message || String(error) } } })
3600
+ }
3443
3601
  },
3444
3602
  updatePhotography: (kind, path, value) => {
3445
3603
  const current = photographyDraftFor(data)
@@ -3456,7 +3614,7 @@ window.__ModuleLoader__.load({
3456
3614
  } else if (kind === 'camera' && path.length === 1 && path[0] === 'aim') {
3457
3615
  if (!['free', 'entity', 'point'].includes(value) || photographyAimLocked(draft, item)) return
3458
3616
  const original = draft.original.cameras.find(camera => camera.id === item.id)
3459
- if (value === 'free' && photographyAim(original) !== 'free') return
3617
+ if (value === 'free' && photographyAim(original) !== 'free' && !data.cameraTargetResetSupported) return
3460
3618
  delete item.targetEntityId; delete item.targetPoint
3461
3619
  if (value === 'entity') item.targetEntityId = draft.entities.find(entity => entity.id === data.editorEntityId)?.id || data.selected?.scene?.project?.reviewSubjectId || draft.entities[0]?.id || ''
3462
3620
  if (value === 'point') item.targetPoint = [0, 0, 0]
@@ -3472,7 +3630,8 @@ window.__ModuleLoader__.load({
3472
3630
  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
3631
  else return
3474
3632
  }
3475
- set({ photographyDrafts: { ...data.photographyDrafts, [current.projectId]: draft } })
3633
+ set({ photographyDrafts: { ...data.photographyDrafts, [current.projectId]: draft },
3634
+ photographyReuse: { ...data.photographyReuse, ...(data.photographyReuse[current.projectId] ? { [current.projectId]: { ...data.photographyReuse[current.projectId], unchanged: false } } : {}) } })
3476
3635
  },
3477
3636
  addPhotographyLight: () => {
3478
3637
  const current = photographyDraftFor(data)
@@ -3496,7 +3655,9 @@ window.__ModuleLoader__.load({
3496
3655
  setIn('busy', 'editor', false)
3497
3656
  if (outcome.ok) {
3498
3657
  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]: {
3658
+ set({ photographyDrafts: drafts,
3659
+ photographyReuse: { ...data.photographyReuse, ...(data.photographyReuse[projectId] ? { [projectId]: { ...data.photographyReuse[projectId], baseRevision: saved.revision, applied: false, unchanged: false, replaceDraft: false } } : {}) },
3660
+ photographyEdits: { ...data.photographyEdits, [projectId]: {
3500
3661
  before: draft.baseRevision, after: saved.revision, digest: saved.digest, cameraId: draft.cameraId, frame: draft.frame, samples: draft.samples,
3501
3662
  changes: [...new Set(patch.operations.map(operation => operation.op.split('.')[0]))] } } })
3502
3663
  photographyWork(projectId, { saving: false, error: null, artifact: null })
@@ -3896,6 +4057,8 @@ window.__ModuleLoader__.load({
3896
4057
  if (timer !== null) { clearInterval(timer); timer = null }
3897
4058
  for (const controller of photographyControllers.values()) controller.abort()
3898
4059
  photographyControllers.clear()
4060
+ for (const controller of photographySourceControllers.values()) controller.abort()
4061
+ photographySourceControllers.clear()
3899
4062
  for (const controller of inspectionControllers.values()) controller.abort()
3900
4063
  inspectionControllers.clear()
3901
4064
  for (const controller of imageDownloadControllers.values()) controller.abort('stopped')
@@ -4530,18 +4693,38 @@ window.__ModuleLoader__.load({
4530
4693
  { factor, locked: locked || editorTrackLocked(draft, kind, kind === 'camera' ? camera.id : light.id, `${field}.${axis}`) })))
4531
4694
  const aim = photographyAim(camera), originalCamera = draft?.original.cameras.find(item => item.id === camera?.id)
4532
4695
  const aimLocked = camera && photographyAimLocked(draft, camera)
4696
+ const reuse = state.photographyReuse?.[projectId] || {}, sourceCamera = reuse.source?.nodes.cameras.find(item => item.id === reuse.cameraId)?.definition
4697
+ const reuseStale = reuse.source && (reuse.projectsRoot !== state.projectsRoot || reuse.baseRevision !== draft?.baseRevision || draft?.projectsRoot !== state.projectsRoot)
4533
4698
  return el('section', { className: 'db-card', 'data-photography-editor': projectId },
4534
4699
  el('h4', null, t('photo.title')), el('p', { className: 'db-muted' }, t('photo.help')),
4535
4700
  draft ? el('div', null,
4536
4701
  el('p', { className: 'db-muted', 'data-photography-base': draft.baseRevision }, t('photo.base', { revision: draft.baseRevision })),
4537
4702
  conflict ? el('p', { className: 'db-error', 'data-photography-conflict': true }, t('photo.conflict', { before: draft.baseRevision, current: state.selected?.scene?.revision })) : null,
4703
+ state.cameraTargetResetSupported ? el('div', { 'data-photography-reuse': true },
4704
+ el('h4', null, t('photo.reuseTitle')), el('p', { className: 'db-muted' }, t('photo.reuseHelp')),
4705
+ 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)),
4706
+ 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),
4707
+ reuse.loading ? el('p', { role: 'status' }, t('photo.reuseReading')) : null,
4708
+ reuse.source ? el('div', { 'data-photography-source': `${reuse.sourceProjectId}:${reuse.revision}` },
4709
+ 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 })),
4710
+ choose(t('photo.reuseCamera'), 'source-camera', reuse.cameraId, reuse.source.nodes.cameras.map(item => [item.id, item.id]), value => actions.setPhotographySource('camera', value)),
4711
+ 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,
4712
+ photographyDirty(draft) ? el('label', { className: 'db-row' }, el('input', { type: 'checkbox', 'data-field': 'photo-source-replace', checked: Boolean(reuse.replaceDraft), disabled: disabled || reuse.loading,
4713
+ onChange: event => actions.setPhotographySource('replace', Boolean(event.target.checked)) }), t('photo.reuseReplace')) : null,
4714
+ reuseStale ? el('p', { className: 'db-error' }, t('photo.reuseStale')) : null,
4715
+ reuse.applied ? el('p', { role: 'status', 'data-photography-reused': true }, t('photo.reuseApplied')) : null,
4716
+ reuse.unchanged ? el('p', { role: 'status', 'data-photography-reuse-unchanged': true }, t('photo.reuseSame')) : null) : null,
4717
+ reuse.error ? el('p', { className: 'db-error', role: 'status', 'data-photography-source-error': true }, reuse.error) : null,
4718
+ el('div', { className: 'db-inline' },
4719
+ Button({ action: 'photo-source-reload', disabled: disabled || !reuse.sourceProjectId || reuse.loading, onClick: actions.reloadPhotographySource, children: t('photo.reuseReload') }),
4720
+ 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
4721
  el('div', { className: 'db-grid' },
4539
4722
  el('div', null,
4540
4723
  choose(t('photo.camera'), 'camera', draft.cameraId, draft.cameras.map(item => [item.id, item.id]), id => actions.selectPhotography('camera', id)),
4541
4724
  camera ? el('div', null,
4542
4725
  numeric(t('photo.lens'), 'camera-lens', editorNumberDefault(camera.lens, 50), update('camera', ['lens']), { min: .001 }),
4543
4726
  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),
4727
+ 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
4728
  aim === 'entity' ? choose(t('photo.target'), 'camera-target', camera.targetEntityId, draft.entities.map(item => [item.id, item.id]), update('camera', ['targetEntityId']), aimLocked) : null,
4546
4729
  aim === 'point' ? vector('camera', 'targetPoint', camera.targetPoint, t('photo.targetPoint'), 1, aimLocked) : null,
4547
4730
  vector('camera', 'rotationEuler', camera.transform?.rotationEuler, t('photo.rotation'), 180 / Math.PI, aim !== 'free'),
@@ -5955,7 +6138,7 @@ window.__ModuleLoader__.load({
5955
6138
  inspection: { form: inspectionForm, valid: inspectionFormValid },
5956
6139
  assetLibrary: { selection: assetBundleSelection, createDraft: createAssetDraft, buildPatch: buildAssetPatch, error: assetDraftError, key: assetKey },
5957
6140
  referenceBrief: { createDraft: createBriefDraft, buildPatch: buildBriefPatch, dirty: briefDirty, valid: briefValid },
5958
- photographyEditor: { createDraft: createPhotographyDraft, buildPatch: buildPhotographyPatch, errors: photographyErrors, dirty: photographyDirty, draftFor: photographyDraftFor },
6141
+ photographyEditor: { createDraft: createPhotographyDraft, buildPatch: buildPhotographyPatch, errors: photographyErrors, dirty: photographyDirty, draftFor: photographyDraftFor, reuse: reusePhotography },
5959
6142
  sceneEditor: { createDraft: createEditorDraft, buildPatch: buildEditorPatch, errors: editorErrors, dirty: editorDirty, previewPair: editorPreviewPair, draftFor: editorDraftFor },
5960
6143
  renderView,
5961
6144
  mountStandalone,
package/lib/index.js CHANGED
@@ -633,6 +633,7 @@ export function createHandlers(ctx) {
633
633
  projects: listed.projects.map(record => buildProjectView(record)),
634
634
  projectsRoot: listed.projectsRoot,
635
635
  creationRequestProtocol: studio().creationRequestProtocol ?? null,
636
+ cameraTargetResetSupported: studio().cameraTargetResetSupported === true,
636
637
  selected: projectId === null || creationPending
637
638
  ? null
638
639
  : await buildProjectState(studio(), projectId, query.revision ?? undefined, approvalThreshold()),
@@ -669,6 +670,7 @@ export function createHandlers(ctx) {
669
670
  'project.scene': async ({ params, query }) => {
670
671
  const detail = await studio().getScene(params.projectId, { revision: query.revision, full: true })
671
672
  return {
673
+ projectId: params.projectId,
672
674
  revision: detail.revision,
673
675
  digest: detail.digest,
674
676
  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.4.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.4.0"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {