@deepblend/dsh-blender-ui 0.4.0 → 0.6.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
@@ -355,6 +355,14 @@ window.__ModuleLoader__.load({
355
355
  'editor.unknownSettings': '这张预览缺少实测渲染设置,无法比较编辑差异。',
356
356
  'editor.missingBaseline': '没有同相机、同帧和同渲染设置的编辑前预览,暂时无法比较差异。',
357
357
  'editor.choose': '选择一个对象,调整后应用并查看真实预览。',
358
+ 'assembly.title': '调整整套模型',
359
+ 'assembly.help': '尺寸会联动配方中的部件,保留独立修改的材质、倒角和摄影设置。保存后生成新版本和预览。',
360
+ 'assembly.save': '保存整套尺寸并预览',
361
+ 'assembly.retry': '重试同一次保存',
362
+ 'assembly.conflict': '部分受控几何已被单独修改,请先恢复对应部件;整套尺寸尚未保存。',
363
+ 'assembly.stale': '当前作品已有新版本。草稿已保留,请重新载入后再修改。',
364
+ 'assembly.otherDraft': '请先保存或放弃其他未提交的编辑。',
365
+ 'assembly.photoKept': '已保留自定义摄影,请确认新尺寸下的构图。',
358
366
  'editor.position': '位置(mm)',
359
367
  'editor.rotation': '旋转(°)',
360
368
  'editor.geometry': '形状与轮廓',
@@ -371,6 +379,9 @@ window.__ModuleLoader__.load({
371
379
  'editor.animated': '动画控制的通道已禁用。',
372
380
  'editor.invalid': '{field} 的值无效。',
373
381
  'editor.size': '尺寸(mm)',
382
+ 'editor.boxWidth': '宽(mm)',
383
+ 'editor.boxDepth': '深(mm)',
384
+ 'editor.boxHeight': '高(mm)',
374
385
  'editor.radius': '半径(mm)',
375
386
  'editor.handledCup': '带把手杯体',
376
387
  'editor.wallThickness': '杯壁厚度(mm)',
@@ -943,6 +954,14 @@ window.__ModuleLoader__.load({
943
954
  'editor.unknownSettings': 'Measured render settings are missing. This preview cannot establish the edit difference.',
944
955
  'editor.missingBaseline': 'No before preview has the same camera, frame and measured render settings. The edit difference is unavailable.',
945
956
  'editor.choose': 'Select an object, adjust its settings, then apply and view a real preview.',
957
+ 'assembly.title': 'Resize the assembly',
958
+ 'assembly.help': 'Recipe parts follow the dimensions. Independent materials, bevels and photography are retained. Saving creates a new revision and preview.',
959
+ 'assembly.save': 'Save assembly dimensions and preview',
960
+ 'assembly.retry': 'Retry the same save',
961
+ 'assembly.conflict': 'Controlled geometry was edited separately. Restore those parts before resizing; nothing has been saved.',
962
+ 'assembly.stale': 'The work has a newer revision. Your draft is retained; reload it before editing.',
963
+ 'assembly.otherDraft': 'Save or discard other unsaved edits first.',
964
+ 'assembly.photoKept': 'Custom photography was preserved. Check framing at the new dimensions.',
946
965
  'editor.position': 'Position (mm)',
947
966
  'editor.rotation': 'Rotation (°)',
948
967
  'editor.geometry': 'Shape and profile',
@@ -959,6 +978,9 @@ window.__ModuleLoader__.load({
959
978
  'editor.animated': 'Channels driven by animation are disabled.',
960
979
  'editor.invalid': 'Invalid value for {field}.',
961
980
  'editor.size': 'Size (mm)',
981
+ 'editor.boxWidth': 'Width (mm)',
982
+ 'editor.boxDepth': 'Depth (mm)',
983
+ 'editor.boxHeight': 'Height (mm)',
962
984
  'editor.radius': 'Radius (mm)',
963
985
  'editor.handledCup': 'Handled cup',
964
986
  'editor.wallThickness': 'Wall thickness (mm)',
@@ -1386,7 +1408,7 @@ window.__ModuleLoader__.load({
1386
1408
 
1387
1409
  /** Every DeepBlend wire tool name this package draws a card for. */
1388
1410
  const TOOL_CARD_KEYS = [
1389
- 'blender_capabilities', 'blender_recipe_list', 'blender_project_create', 'blender_project_get',
1411
+ 'blender_capabilities', 'blender_recipe_list', 'blender_recipe_parameters', 'blender_project_create', 'blender_project_get',
1390
1412
  'blender_scene_get', 'blender_scene_patch', 'blender_preview_render',
1391
1413
  'blender_scene_validate', 'blender_revision_restore', 'blender_asset_ingest',
1392
1414
  'blender_preview_views', 'blender_visual_review',
@@ -2054,7 +2076,13 @@ window.__ModuleLoader__.load({
2054
2076
  }
2055
2077
  const generator = draft.entity.generator
2056
2078
  if (generator) {
2079
+ if (generator.dimensions !== undefined) {
2080
+ if (!['cube', 'rounded_box'].includes(generator.shape) || generator.size !== undefined
2081
+ || !Array.isArray(generator.dimensions) || generator.dimensions.length !== 3) bad('dimensions')
2082
+ else generator.dimensions.forEach((value, axis) => scalar(value, `dimensions.${EDITOR_AXES[axis]}`, Number.MIN_VALUE))
2083
+ }
2057
2084
  for (const key of GENERATOR_FIELDS[generator.shape] || []) {
2085
+ if (key === 'size' && generator.dimensions !== undefined) continue
2058
2086
  const bounds = generatorIntegerBounds(generator, key)
2059
2087
  if (bounds?.[2] && generator[key] % bounds[2]) bad(key)
2060
2088
  scalar(generator[key], key, key === 'rootTension' ? 1 : bounds ? bounds[0] : Number.MIN_VALUE,
@@ -2478,6 +2506,19 @@ window.__ModuleLoader__.load({
2478
2506
  || photographyDirty(photographyDraftFor(state))
2479
2507
  || Boolean(state.assetDrafts?.[state.activeProjectId])
2480
2508
  || state.forms.patch !== null
2509
+ || assemblyDirty(assemblyDraftFor(state))
2510
+ }
2511
+ const assemblyKey = state => JSON.stringify([state.projectsRoot, state.activeProjectId])
2512
+ const assemblyDraftFor = state => state.assemblyDrafts?.[assemblyKey(state)] || null
2513
+ const assemblyDirty = draft => Boolean(draft && Object.entries(draft.values).some(([id, value]) => value === '' || Number(value) !== draft.original[id]))
2514
+ const assemblyInvalid = draft => !draft || draft.parameters.some(parameter => {
2515
+ const raw = draft.values[parameter.id], value = Number(raw)
2516
+ return raw === '' || !Number.isFinite(value) || value < parameter.minimum || value > parameter.maximum
2517
+ })
2518
+ function createAssemblyDraft(source, projectsRoot) {
2519
+ const values = Object.fromEntries(source.parameters.map(parameter => [parameter.id, parameter.value]))
2520
+ return { projectId: source.projectId, projectsRoot, baseRevision: source.revision,
2521
+ parameters: editorClone(source.parameters), values, original: { ...values }, idempotencyKey: newCreationKey(), attempt: null }
2481
2522
  }
2482
2523
  function briefValid(draft) {
2483
2524
  return Boolean(draft && typeof draft.goal === 'string' && draft.goal.length <= 2000 && draft.referenceImages.length <= 4
@@ -2724,10 +2765,11 @@ window.__ModuleLoader__.load({
2724
2765
  editorDrafts: {},
2725
2766
  editorLastEdits: {},
2726
2767
  editorComparison: null,
2768
+ assemblyDrafts: {},
2727
2769
  patchDrafts: {},
2728
2770
  recipeChoices: [],
2729
2771
  forms: { title: '', goal: '', recipe: null, recipeParameters: {}, patch: null, frameStart: '', frameEnd: '', profile: 'preview' },
2730
- busy: { create: false, patch: false, editor: false, render: false, restore: false },
2772
+ busy: { create: false, patch: false, editor: false, assembly: false, render: false, restore: false },
2731
2773
  notices: { projects: null, scene: null, preview: null, jobs: null, revisions: null },
2732
2774
  }
2733
2775
  }
@@ -2989,6 +3031,13 @@ window.__ModuleLoader__.load({
2989
3031
  if (sequence !== loadSequence || projectId !== target()) return
2990
3032
  if (active !== data.activeProjectId) patch.forms = { ...data.forms, patch: data.patchDrafts[active] ?? null }
2991
3033
  if (patch.selected?.scene && active) {
3034
+ const source = patch.selected.recipeParameters
3035
+ if (source?.available === true && source.projectId === active && source.revision === patch.selected.scene.revision) {
3036
+ const key = JSON.stringify([patch.projectsRoot, active]), draft = data.assemblyDrafts[key]
3037
+ if ((!draft || !assemblyDirty(draft)) && !data.busy.assembly) {
3038
+ patch.assemblyDrafts = { ...data.assemblyDrafts, [key]: createAssemblyDraft(source, patch.projectsRoot) }
3039
+ }
3040
+ }
2992
3041
  const photography = data.photographyDrafts[active]
2993
3042
  if ((!photography || !photographyDirty(photography)) && !data.photographyWork[active]?.saving) {
2994
3043
  patch.photographyDrafts = { ...data.photographyDrafts, [active]: createPhotographyDraft(patch.selected.scene, active, photography, patch.projectsRoot) }
@@ -3159,6 +3208,48 @@ window.__ModuleLoader__.load({
3159
3208
  }
3160
3209
  }
3161
3210
  const actions = {
3211
+ setAssemblyValue: (id, value) => {
3212
+ const draft = assemblyDraftFor(data)
3213
+ if (!draft || data.busy.assembly || draft.attempt || !draft.parameters.some(parameter => parameter.id === id)) return
3214
+ set({ assemblyDrafts: { ...data.assemblyDrafts, [assemblyKey(data)]: { ...draft, values: { ...draft.values, [id]: value } } } })
3215
+ },
3216
+ resetAssembly: () => {
3217
+ const source = data.selected?.recipeParameters
3218
+ if (!source?.available || data.busy.assembly) return
3219
+ set({ assemblyDrafts: { ...data.assemblyDrafts, [assemblyKey(data)]: createAssemblyDraft(source, data.projectsRoot) } })
3220
+ },
3221
+ saveAssembly: async () => {
3222
+ const draft = assemblyDraftFor(data), source = data.selected?.recipeParameters
3223
+ if (!draft || data.busy.assembly || assemblyInvalid(draft) || !assemblyDirty(draft)) return
3224
+ if (!draft.attempt && (draft.baseRevision !== source?.revision || source?.conflicts?.length
3225
+ || projectHasUnsavedDrafts({ ...data, assemblyDrafts: {} }))) return
3226
+ const key = assemblyKey(data), body = draft.attempt || { baseRevision: draft.baseRevision,
3227
+ projectsRoot: draft.projectsRoot, idempotencyKey: draft.idempotencyKey,
3228
+ parameters: Object.fromEntries(Object.entries(draft.values).map(([id, value]) => [id, Number(value)])) }
3229
+ set({ assemblyDrafts: { ...data.assemblyDrafts, [key]: { ...draft, attempt: body } } })
3230
+ setIn('busy', 'assembly', true)
3231
+ const outcome = await postJson(fetchImpl, projectRoute(draft.projectId, '/recipe-parameters'), body)
3232
+ setIn('busy', 'assembly', false)
3233
+ if (outcome.ok) {
3234
+ const drafts = { ...data.assemblyDrafts }; delete drafts[key]
3235
+ set({ assemblyDrafts: drafts })
3236
+ if (target() === draft.projectId && data.projectsRoot === draft.projectsRoot) {
3237
+ const revision = outcome.payload.revision.revision
3238
+ const notice = { ok: true, kind: 'recipe-parameters', message: t('scene.committed', { revision,
3239
+ digest: shortDigest(outcome.payload.revision.digest) }) + (outcome.payload.revision.preservedCameraIds?.length ? ` ${t('assembly.photoKept')}` : '') }
3240
+ set({ view: 'preview', compareMode: 'revisions', compareLeft: draft.baseRevision, compareRight: revision,
3241
+ notices: { ...data.notices, scene: notice, preview: notice } })
3242
+ }
3243
+ reload()
3244
+ } else {
3245
+ const uncertain = outcome.error.code === 'UI_FETCH_FAILED' || /^HTTP_5/.test(outcome.error.code)
3246
+ set({ assemblyDrafts: { ...data.assemblyDrafts, [key]: { ...draft, attempt: uncertain ? body : null } } })
3247
+ if (target() === draft.projectId && data.projectsRoot === draft.projectsRoot) {
3248
+ setIn('notices', 'scene', { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
3249
+ if (outcome.error.code === 'REVISION_CONFLICT') reload()
3250
+ }
3251
+ }
3252
+ },
3162
3253
  acceptsFileChoice,
3163
3254
  setAppearance: value => {
3164
3255
  if (!APPEARANCES.includes(value)) return
@@ -4358,6 +4449,32 @@ window.__ModuleLoader__.load({
4358
4449
  }, result.message)
4359
4450
  }
4360
4451
 
4452
+ function AssemblyEditor(ctx) {
4453
+ const { state, actions } = ctx, source = state.selected?.recipeParameters, draft = assemblyDraftFor(state)
4454
+ if (!source) return null
4455
+ if (source.error) return el('div', { className: 'db-card', 'data-assembly-editor': true },
4456
+ el('h4', null, t('assembly.title')), ErrorBox({ error: source.error }))
4457
+ if (!source.available || !draft) return null
4458
+ const stale = draft.baseRevision !== state.selected.scene.revision
4459
+ const otherDirty = projectHasUnsavedDrafts({ ...state, assemblyDrafts: {} })
4460
+ const blocked = state.busy.assembly || !draft.attempt && (stale || source.conflicts?.length || otherDirty)
4461
+ return el('div', { className: 'db-card', 'data-assembly-editor': true,
4462
+ 'data-assembly-base-revision': draft.baseRevision, 'data-assembly-dirty': String(assemblyDirty(draft)) },
4463
+ el('h4', null, t('assembly.title')), el('p', { className: 'db-muted' }, t('assembly.help')),
4464
+ source.conflicts?.length ? el('div', { className: 'db-error' }, t('assembly.conflict'),
4465
+ el('div', { className: 'db-inline' }, [...new Set(source.conflicts.map(conflict => conflict.entityId))].map(entityId =>
4466
+ Button({ key: entityId, action: `assembly-conflict:${entityId}`, onClick: () => actions.selectEditorEntity(entityId), children: entityId })))) : null,
4467
+ stale ? el('div', { className: 'db-error' }, t('assembly.stale')) : null,
4468
+ otherDirty ? el('div', { className: 'db-muted' }, t('assembly.otherDraft')) : null,
4469
+ el('div', { className: 'db-grid' }, draft.parameters.map(parameter => el('label', { key: parameter.id },
4470
+ parameter.title, el('input', { type: 'number', className: 'db-input', 'data-field': `assembly-${parameter.id}`,
4471
+ min: parameter.minimum, max: parameter.maximum, step: 'any', value: draft.values[parameter.id],
4472
+ disabled: state.busy.assembly || Boolean(draft.attempt), onInput: event => actions.setAssemblyValue(parameter.id, event.currentTarget.value) })))),
4473
+ el('div', { className: 'db-inline' }, Button({ action: 'assembly-save', disabled: Boolean(blocked) || assemblyInvalid(draft) || !assemblyDirty(draft),
4474
+ onClick: actions.saveAssembly, children: draft.attempt ? t('assembly.retry') : t('assembly.save') }),
4475
+ Button({ action: 'assembly-reset', disabled: state.busy.assembly, onClick: actions.resetAssembly, children: t('editor.reset') })))
4476
+ }
4477
+
4361
4478
  function SceneEditor(ctx) {
4362
4479
  const { state, actions } = ctx, draft = editorDraftFor(state)
4363
4480
  const lastEdit = state.editorLastEdits?.[state.activeProjectId]
@@ -4455,7 +4572,11 @@ window.__ModuleLoader__.load({
4455
4572
  el('p', { className: 'db-muted', key: 'local-size' }, t('editor.localDimensions', { scale: (draft.entity.transform.scale || [1, 1, 1]).join(' × ') })),
4456
4573
  geometry.shape === 'rounded_box' ? el('p', { className: 'db-muted', key: 'rounded' }, t('editor.roundedRequired')) : null,
4457
4574
  geometry.shape === 'handled_cup' ? el('p', { className: 'db-muted', key: 'cup-help' }, t('editor.cupHelp')) : null,
4458
- ...(GENERATOR_FIELDS[geometry.shape] || []).filter(key => geometry.shape !== 'handled_cup' || !CUP_ADVANCED_FIELDS.includes(key)).map(geometryField),
4575
+ ...(GENERATOR_FIELDS[geometry.shape] || []).filter(key => !(key === 'size' && geometry.dimensions !== undefined)
4576
+ && (geometry.shape !== 'handled_cup' || !CUP_ADVANCED_FIELDS.includes(key))).map(geometryField),
4577
+ ...(geometry.dimensions !== undefined ? [t('editor.boxWidth'), t('editor.boxDepth'), t('editor.boxHeight')].map((label, index) => numeric(
4578
+ label, `editor-generator-dimensions-${EDITOR_AXES[index]}`, geometry.dimensions[index],
4579
+ value => actions.updateEditor('generator', ['dimensions', index], value), { factor: 1000, min: Number.MIN_VALUE })) : []),
4459
4580
  geometry.shape === 'handled_cup' ? el('details', { key: 'cup-advanced' },
4460
4581
  el('summary', null, t('editor.cupAdvanced')), ...CUP_ADVANCED_FIELDS.map(geometryField)) : null,
4461
4582
  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'),
@@ -4839,7 +4960,7 @@ window.__ModuleLoader__.load({
4839
4960
  el('span', { className: 'db-muted db-mono' }, String(asset.path || '')))),
4840
4961
  )),
4841
4962
  el('section', { className: 'db-scene-inspector', 'aria-label': t('scene.inspector') },
4842
- SceneEditor(ctx),
4963
+ AssemblyEditor(ctx), SceneEditor(ctx),
4843
4964
  PhotographyEditor(ctx),
4844
4965
  AssetsView(ctx),
4845
4966
  el('details', { key: advancedDisclosure, 'data-disclosure': advancedDisclosure, open: state.disclosures?.[advancedDisclosure] ?? false,
package/lib/index.js CHANGED
@@ -426,6 +426,7 @@ export function statusForError(error) {
426
426
  if (referenceStatuses[error.code]) return referenceStatuses[error.code]
427
427
  if (error.code === 'RECIPE_NOT_FOUND') return 404
428
428
  if (error.code === 'RECIPE_CHANGED' || error.code === 'RECIPE_ID_CONFLICT') return 409
429
+ if (error.code === 'RECIPE_PARAMETER_CONFLICT') return 409
429
430
  if (error.code?.startsWith('RECIPE_')) return 400
430
431
  switch (error.code) {
431
432
  case BlenderErrorCode.PROJECT_NOT_FOUND:
@@ -439,6 +440,7 @@ export function statusForError(error) {
439
440
  return 400
440
441
  case BlenderErrorCode.RENDER_JOB_CONFLICT:
441
442
  case BlenderErrorCode.REVISION_CONFLICT:
443
+ case BlenderErrorCode.SCENE_FEATURE_UNSUPPORTED:
442
444
  return 409
443
445
  default:
444
446
  return 500
@@ -827,6 +829,16 @@ export function createHandlers(ctx) {
827
829
  return { revision: result }
828
830
  },
829
831
 
832
+ 'project.recipeParameters': async ({ params, query }) => {
833
+ if (studio().recipeParameterEditingSupported !== true) throw new BlenderError('UI_HOST_API_STALE', 'Update the Host to edit assembly dimensions')
834
+ return studio().getRecipeParameters(params.projectId, { revision: query.revision })
835
+ },
836
+ 'project.recipeParameters.update': async ({ params, body }) => {
837
+ if (studio().recipeParameterEditingSupported !== true) throw new BlenderError('UI_HOST_API_STALE', 'Update the Host to edit assembly dimensions')
838
+ return { revision: await studio().updateRecipeParameters({ ...body, projectId: params.projectId,
839
+ saveCheckpoint: true, renderPreview: true }) }
840
+ },
841
+
830
842
  'project.restore': async ({ params, body }) => {
831
843
  const result = await studio().restoreRevision({
832
844
  projectId: params.projectId,
@@ -917,7 +929,14 @@ async function buildProjectState(studio, projectId, revision, threshold) {
917
929
  const scene = await studio.getScene(projectId, { revision: requested, full: true })
918
930
  const jobs = await studio.listJobs({ projectId })
919
931
  const qa = await studio.getQaRecord({ projectId, revision: scene.revision })
932
+ let recipeParameters = null
933
+ if (studio.recipeParameterEditingSupported === true) {
934
+ try { recipeParameters = await studio.getRecipeParameters(projectId, { revision: scene.revision }) }
935
+ catch (error) { recipeParameters = { projectId, revision: scene.revision, available: false,
936
+ error: { code: error.code ?? 'RECIPE_LOCK_INVALID', message: error.message } } }
937
+ }
920
938
  return {
939
+ recipeParameters,
921
940
  project: buildProjectView({
922
941
  projectId,
923
942
  title: overview.title,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-ui",
3
- "version": "0.4.0",
3
+ "version": "0.6.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.4.0"
22
+ "@deepblend/dsh-blender-contracts": "0.6.0"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {