@deepblend/dsh-blender-tool 0.13.0 → 0.14.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.
@@ -63,7 +63,7 @@ export function compareAssemblyOrigins(before, after) {
63
63
  })
64
64
  return { schemaVersion: 'deepblend.assembly-origin-comparison/v1', referenceSource: before.source, currentSource: after.source,
65
65
  pairs, comparedPairs: pairs.filter(pair => pair.worldOriginOffsetChangeMm !== undefined).length,
66
- limitations: ['Changes are current minus reference; offset preservation does not establish surface contact or attachment correctness.', ...limitations] }
66
+ limitations: ['Object-local changes compare each object own scaled evaluated mesh axes. Changed local geometry orientation can change these spans. They are not world distances or semantic body dimensions.', 'Changes are current minus reference; offset preservation does not establish surface contact or attachment correctness.', ...limitations] }
67
67
  }
68
68
 
69
69
  /** Inputs have already passed source/selection/frame and native report verification. */
@@ -72,10 +72,13 @@ export function compareGeometryReports(reference, current) {
72
72
  const objects = current.objects.map((after, index) => {
73
73
  const before = reference.objects[index]
74
74
  if (before.entityId !== after.entityId) refuse('comparison entity identities differ.')
75
- const values = object => object.available ? { status: 'measured', worldAxisExtentMm: object.worldAxisExtentMm, worldOriginMeters: origin(object) } :
75
+ const values = object => object.available ? { status: 'measured', worldAxisExtentMm: object.worldAxisExtentMm, worldOriginMeters: origin(object),
76
+ ...(object.objectAxisStatus ? { objectAxisStatus: object.objectAxisStatus } : {}),
77
+ ...(object.objectAxisExtentMm ? { objectAxisExtentMm: object.objectAxisExtentMm } : {}) } :
76
78
  { status: 'unavailable', reason: object.unavailableReason }
77
79
  const row = { entityId: after.entityId, before: values(before), after: values(after) }
78
80
  if (before.available && after.available) {
81
+ if (before.objectAxisExtentMm && after.objectAxisExtentMm) row.objectAxisExtentChangeMm = subtract(after.objectAxisExtentMm, before.objectAxisExtentMm)
79
82
  row.worldAxisExtentChangeMm = subtract(after.worldAxisExtentMm, before.worldAxisExtentMm)
80
83
  row.worldOriginChangeMm = subtractMm(row.after.worldOriginMeters, row.before.worldOriginMeters)
81
84
  }
@@ -83,5 +86,5 @@ export function compareGeometryReports(reference, current) {
83
86
  })
84
87
  return { schemaVersion: 'deepblend.geometry-comparison/v1', referenceInspection: reference, currentSource: identity(current), objects,
85
88
  summary: { requestedEntities: objects.length, comparedEntities: objects.filter(object => object.worldAxisExtentChangeMm !== undefined).length },
86
- limitations: ['Changes are current minus reference for the same selected native parts and frame; their cause is not established.', ...limitations] }
89
+ limitations: ['Object-local changes compare each object own scaled evaluated mesh axes. Changed local geometry orientation can change these spans. They are not world distances or semantic body dimensions.', 'Changes are current minus reference for the same selected native parts and frame; their cause is not established.', ...limitations] }
87
90
  }
@@ -0,0 +1,59 @@
1
+ import { BlenderError, BlenderErrorCode, sceneSpecDigest, sha256Canonical } from '@deepblend/dsh-blender-contracts'
2
+
3
+ const validId = value => typeof value === 'string' && /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/.test(value)
4
+ const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value)
5
+
6
+ /** Expand bounded saved-data references into the ordinary atomic patch protocol. */
7
+ export async function expandEntityCopies(studio, args) {
8
+ const invalid = message => { throw new BlenderError(BlenderErrorCode.SCENE_PATCH_INVALID, message) }
9
+ const offset = args.entityCopyOffset
10
+ if (offset !== undefined && (args.entityCopies === undefined || !Array.isArray(offset) || offset.length !== 3 ||
11
+ !offset.every(value => typeof value === 'number' && Number.isFinite(value)))) {
12
+ invalid('entityCopyOffset requires entityCopies and three finite world-metre translation components.')
13
+ }
14
+ if (args.entityCopies === undefined) return { operations: args.operations, copies: [] }
15
+ if (!Array.isArray(args.entityCopies) || args.entityCopies.length < 1 || args.entityCopies.length > 16) {
16
+ invalid('entityCopies must contain 1–16 explicit saved entity copies.')
17
+ }
18
+ if (args.operations.length + args.entityCopies.length > 500) invalid('Expanded patch exceeds the 500-operation limit.')
19
+ const ids = new Set()
20
+ for (const copy of args.entityCopies) {
21
+ if (!plain(copy) || Object.keys(copy).some(k => !['sourceEntityId', 'newEntityId', 'transform'].includes(k)) ||
22
+ !validId(copy.sourceEntityId) || !validId(copy.newEntityId) || ids.has(copy.newEntityId)) {
23
+ invalid('Each copy requires a saved sourceEntityId and distinct newEntityId; only transform may also be supplied.')
24
+ }
25
+ ids.add(copy.newEntityId)
26
+ if (offset !== undefined && copy.transform?.location !== undefined) invalid('entityCopyOffset cannot be combined with a copy absolute location override.')
27
+ if (copy.transform !== undefined && (!plain(copy.transform) || Object.keys(copy.transform).some(k =>
28
+ !['location', 'rotationEuler', 'scale'].includes(k) || !Array.isArray(copy.transform[k]) ||
29
+ copy.transform[k].length !== 3 || !copy.transform[k].every(v => typeof v === 'number' && Number.isFinite(v))))) {
30
+ invalid('Copy transform must contain only finite three-number location, rotationEuler or scale components.')
31
+ }
32
+ }
33
+ const data = await studio.getScene(args.projectId, { revision: args.baseRevision, full: true })
34
+ if (!data?.spec || !Array.isArray(data.spec.entities) || data.projectId !== args.projectId ||
35
+ data.revision !== args.baseRevision || data.digest !== sceneSpecDigest(data.spec)) {
36
+ throw new BlenderError(BlenderErrorCode.RUNTIME_UNAVAILABLE, 'Host did not return the matching saved source revision for entity copies.')
37
+ }
38
+ const sources = new Map(data.spec.entities.map(entity => [entity.id, entity]))
39
+ const additions = [], copies = []
40
+ for (const copy of args.entityCopies) {
41
+ const source = sources.get(copy.sourceEntityId)
42
+ if (!source || sources.has(copy.newEntityId)) {
43
+ throw new BlenderError(BlenderErrorCode.SCENE_PATCH_REJECTED, 'Copy source must exist in baseRevision and newEntityId must not already exist.')
44
+ }
45
+ const entity = structuredClone(source)
46
+ entity.id = copy.newEntityId
47
+ if (copy.transform !== undefined) entity.transform = { ...entity.transform, ...structuredClone(copy.transform) }
48
+ if (offset !== undefined) {
49
+ const location = (source.transform?.location ?? [0, 0, 0]).map((value, index) => value + offset[index])
50
+ if (!location.every(Number.isFinite)) invalid('entityCopyOffset produced a non-finite position.')
51
+ entity.transform = { ...entity.transform, location }
52
+ }
53
+ additions.push({ op: 'entity.add', entity })
54
+ copies.push({ sourceRevision: data.revision, sourceDigest: data.digest, sourceEntityId: source.id,
55
+ sourceEntitySha256: sha256Canonical(source), newEntityId: entity.id })
56
+ }
57
+ // New entities are available to ordinary operations in this same atomic patch.
58
+ return { operations: [...additions, ...args.operations], copies }
59
+ }
@@ -0,0 +1,36 @@
1
+ import { BlenderError, BlenderErrorCode, sceneSpecDigest, sha256, sha256Canonical } from '@deepblend/dsh-blender-contracts'
2
+
3
+ /** A bounded projection of saved entity data, never a filesystem reader. */
4
+ export async function readEntitySource(studio, args) {
5
+ const invalid = message => { throw new BlenderError(BlenderErrorCode.SCRIPT_ERROR, message) }
6
+ if (typeof args.sourceEntityId !== 'string' || !/^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/.test(args.sourceEntityId)) {
7
+ invalid('sourceEntityId must be one saved entity ID (1–64 characters).')
8
+ }
9
+ const offset = args.sourceOffset === undefined ? 0 : args.sourceOffset
10
+ const limit = args.sourceLimit === undefined ? 2048 : args.sourceLimit
11
+ if (!Number.isSafeInteger(offset) || offset < 0) invalid('sourceOffset must be a non-negative safe integer.')
12
+ if (!Number.isInteger(limit) || limit < 1 || limit > 2048) invalid('sourceLimit must be an integer from 1 to 2048.')
13
+ if (offset > 0 && args.revision === undefined) invalid('Subsequent source chunks require the revision returned by the first chunk.')
14
+ if (args.full === true || Object.keys(args).some(key => key.startsWith('geometry') && args[key] !== undefined)) {
15
+ invalid('Entity source chunks cannot be combined with full:true or geometry options.')
16
+ }
17
+ const data = await studio.getScene(args.projectId, { ...(args.revision === undefined ? {} : { revision: args.revision }), full: true })
18
+ if (!data?.spec || !Array.isArray(data.spec.entities) || !/^r\d{4,}$/.test(data.revision ?? '') ||
19
+ data.projectId !== args.projectId || (args.revision !== undefined && data.revision !== args.revision) ||
20
+ data.digest !== sceneSpecDigest(data.spec)) {
21
+ throw new BlenderError(BlenderErrorCode.RUNTIME_UNAVAILABLE, 'Host did not return a matching saved SceneSpec for bounded entity source reading.')
22
+ }
23
+ const matches = data.spec.entities.filter(entity => entity.id === args.sourceEntityId)
24
+ if (matches.length !== 1) invalid('sourceEntityId must identify exactly one entity in the selected saved revision.')
25
+ const source = JSON.stringify(matches[0])
26
+ if (offset > source.length) invalid('sourceOffset exceeds the entity source length.')
27
+ let end = Math.min(source.length, offset + limit)
28
+ // Escaped controls can cost six output characters each. Keep the rendered
29
+ // response safely below the preset's 8192-character spill threshold.
30
+ while (JSON.stringify(source.slice(offset, end)).length > 4096) end--
31
+ return { projectId: args.projectId, revision: data.revision, digest: data.digest, isCurrent: data.isCurrent,
32
+ documentSha256: sha256Canonical(data.spec), entityId: args.sourceEntityId, entityJsonSha256: sha256(source),
33
+ serialization: 'compact JSON; offsets are UTF-16 code units', totalChars: source.length,
34
+ sourceOffset: offset, sourceLength: end - offset, nextOffset: end < source.length ? end : null,
35
+ sourceText: source.slice(offset, end) }
36
+ }
package/lib/tools.js CHANGED
@@ -54,6 +54,8 @@ import {
54
54
  resolveStudio,
55
55
  } from './shared.js'
56
56
  import { compareSurfaceReports } from './geometry-comparison.js'
57
+ import { readEntitySource } from './scene-source.js'
58
+ import { expandEntityCopies } from './entity-copies.js'
57
59
  import { validateAssemblyPairs, readAssemblyOrigins, compareAssemblyOrigins, compareGeometryReports } from './assembly-origins.js'
58
60
 
59
61
  /** A short parameter description of a SceneSpec engine key. */
@@ -75,7 +77,12 @@ const SCENE_SPEC_PARAMETER = {
75
77
  'shots[], animationTracks[], renderProfiles{preview,final}. project.reviewSubjectId optionally selects an existing non-empty main review entity without moving cameras. ' +
76
78
  'A generator entity is `{id,type:"generator",generator:{shape,size|radius|depth|majorRadius|minorRadius,bevel?},' +
77
79
  'materialId,transform:{location,rotationEuler,scale}}` where shape is one of ' +
78
- 'cube, rounded_box, uv_sphere, cylinder, cone, plane, torus, lathe, curve, handled_cup. handled_cup builds one closed cup/handle mesh with flared shared-boundary roots. ' +
80
+ 'cube, rounded_box, uv_sphere, cylinder, cone, plane, torus, lathe, curve, handled_cup, mesh. ' +
81
+ 'Mesh builds authored control geometry from vertices:[[x,y,z],...] in object-local space and faces:[[vertexIndex,...],...] with 3-32 distinct in-range indices per face; winding is preserved. ' +
82
+ 'Limits: 32768 vertices, 32768 faces, 131072 total face corners, coordinates within +/-1000000. ' +
83
+ 'Optional faceUvs supplies one [u,v] pair per face corner; omitted uses planar UVs. smooth:true interpolates normals without changing geometry. ' +
84
+ 'Use mesh for asymmetric surfaces; it accepts data only, no formulas/scripts. Validate and inspect actual geometry, normals and renders; valid indices do not certify appearance or manifold topology. ' +
85
+ 'handled_cup builds one closed cup/handle mesh with flared shared-boundary roots. ' +
79
86
  'Defaults are radius 0.04, height 0.105, wallThickness 0.003, baseThickness 0.005; optional handleRadius, handleLower, handleUpper, rootRadius, rootLength and mesh segments obey coupled constraints. Inspect root highlights; mesh checks do not certify aesthetic quality. Curve sweeps a round section ' +
80
87
  'of radius (default 0.01) along path:[[x,y,z],…]; pathInterpolation is poly or bezier, pathClosed closes ' +
81
88
  'the loop, and capEnds seals open endpoints. Lathe uses profile:[[radius,height],…] ' +
@@ -83,7 +90,15 @@ const SCENE_SPEC_PARAMETER = {
83
90
  'sections. Use enough profile points to describe smooth shoulders and rounded lips; points form straight segments. ' +
84
91
  'Entity modifiers[] apply in order: {type:"solidify",thickness,offset?}, {type:"mirror",axis:"x"|"y"|"z",merge?}, ' +
85
92
  '{type:"array",count,offset:[x,y,z]}, {type:"boolean",operation:"union"|"difference"|"intersect",targetEntityId}, ' +
86
- 'or {type:"bevel",width,segments?,angle?,miterInner?:"arc"|"sharp"}. Place bevel after boolean to round its new edges; segments default 4, angle defaults 30 degrees, miterInner defaults arc for compatibility; sharp can avoid inner-corner artifacts around curved bores. ' +
93
+ '{type:"subdivision",levels:1|2|3,scheme?:"catmull-clark"|"simple",boundaryMode?:"all"|"preserve-corners"}, ' +
94
+ 'or {type:"bevel",width,segments?,angle?,miterInner?:"arc"|"sharp"}. Subdivision defaults to catmull-clark/all; simple refines without rounding. ' +
95
+ 'Static closed-surface placement: {type:"surface_scatter",targetEntityIds:[ids],count:1..512,anchorVertexIndices:[indices],seed?,embedDepthMm?,scaleRange?:[min,max],upwardBias?}. ' +
96
+ 'This must be last. Prototype and 1–8 targets must be static generated single meshes without rigs/physics/transform animation. Targets must be closed solids. ' +
97
+ 'Anchor indices select 1–8 actual prototype vertices AFTER preceding modifiers, not just generator control indices. Local +Z points out of the attachment. Every selected vertex of every instance must embed inside a target; insufficient placements fail atomically. ' +
98
+ 'Defaults seed=0, embedDepthMm=5 (0..100 exclusive zero), scaleRange=[1,1] (0.05..20 ordered), upwardBias=0 (0..1). Placement is deterministic and recomputed on rebuild, not live binding; it does not certify whole-mesh clearance or appearance. ' +
99
+ 'All modifiers are bounded to 500000 output polygons per entity, with subdivision refused before allocating an excessive result. ' +
100
+ 'Array offsets are object-local and are scaled/rotated with the object; a world displacement must use the full inverse transform, not just divide by scale. ' +
101
+ 'Place bevel after boolean to round its new edges; segments default 4, angle defaults 30 degrees, miterInner defaults arc for compatibility; sharp can avoid inner-corner artifacts around curved bores. ' +
87
102
  'Boolean operands must be separate single-mesh entities; visible:false hides an operand while preserving its geometry for the operation. ' +
88
103
  'Material parameters use Blender 5 socket ' +
89
104
  'names: baseColor [r,g,b,a], metallic, roughness, ior, alpha, emissionColor, emissionStrength, ' +
@@ -392,13 +407,15 @@ function sceneGet(ctx) {
392
407
  'location and visibility, camera parameters, animation tracks and the render profiles. That is ' +
393
408
  'usually everything you need to decide what to change. Pass full:true to also get the complete ' +
394
409
  'SceneSpec document, which you should do only when you actually need exact field values. ' +
410
+ 'For exact control vertices or other entity fields in a large scene, use sourceEntityId instead: bounded compact JSON chunks avoid output spill. ' +
411
+ 'Continue with the returned revision and nextOffset, check entityJsonSha256 stays identical, concatenate sourceText in order, then parse JSON. The preset does not provide a generic read tool. ' +
395
412
  'To measure saved native parts, pass geometryEntityIds (1–8 IDs from this scene) and an explicit geometryFrame. ' +
396
413
  'This returns source-verified world-axis spans in millimeters and transforms without rendering or creating a revision. ' +
397
414
  'Spans include rotation, handles and protrusions; they are not body diameter, wall thickness, clearance or quality approval. ' +
398
415
  'Do not subtract shell/liner spans or radii to claim a gap, or use unchanged height/scale to certify wall thickness. ' +
399
416
  'Optional geometrySurfaceSamples measures 1–8 explicit local surface chords and pair separations on the same saved source. ' +
400
417
  'The chord runs from source entry to exit; separation runs from source EXIT to target entry and excludes that chord. ' +
401
- 'Use geometryOnly:true for a compact verified measurement receipt without the scene catalogue. ' +
418
+ 'Use geometryObjectAxes:true for measured scaled object-axis extents. Do not invert a world AABB into local dimensions. Use geometryOnly:true for a compact verified measurement receipt without the scene catalogue. ' +
402
419
  'Add geometryAssemblyPairs to measure explicit ordered object-origin offsets in world XYZ, not surface clearances. When comparing revisions, geometryCompareRevision computes current-minus-reference native spans and origin moves; if surface samples are supplied, their existing local comparison is used. ' +
403
420
  'Choose outside world seeds and directions from the actual scene/bounds; missed or unsupported samples remain unavailable. ' +
404
421
  'Local samples do not prove full-surface minimum wall thickness, whole-assembly clearance, no collision or quality approval. ' +
@@ -406,10 +423,14 @@ function sceneGet(ctx) {
406
423
  parameters: {
407
424
  projectId: { type: 'string', required: true, description: 'The project id.' },
408
425
  revision: { type: 'string', description: 'Read a specific revision instead of the current one.' },
426
+ sourceEntityId: { type: 'string', description: 'Read exact saved JSON for one entity (1–64 character ID), without the scene catalogue. Cannot combine with full:true or geometry options.' },
427
+ sourceOffset: { type: 'integer', description: 'UTF-16 character offset in compact entity JSON, default 0. Requires sourceEntityId; offsets above zero require the revision returned by the first chunk.' },
428
+ sourceLimit: { type: 'integer', description: 'Maximum source characters in this chunk, 1–2048 (default 2048). Escaped text may return fewer. Requires sourceEntityId.' },
409
429
  geometryEntityIds: { type: 'array', items: { type: 'string' },
410
430
  description: 'Optional: measure 1–8 distinct saved native entity IDs. Requires geometryFrame. Hidden viewport objects and non-mesh roots are explicitly unavailable.' },
411
431
  geometryFrame: { type: 'integer',
412
432
  description: 'Explicit integer frame from 0 to 1000000 for native geometry measurement. Use the frame of the preview being checked. Requires geometryEntityIds.' },
433
+ geometryObjectAxes: { type: 'boolean', description: 'Optional true: also measure actual evaluated vertices in physical orthogonal axes of each selected object, including native scale. Requires geometryEntityIds and geometryFrame. Returns objectAxisBoundsMeters/objectAxisExtentMm/objectAxisWorldMatrix and an explicit basis, separate from world-axis spans. Sheared/degenerate bases are unavailable. Never invert a world AABB to recover local extents.' },
413
434
  geometryOnly: { type: 'boolean', description: 'Return only source identity and verified geometry evidence, without the scene catalogue. Requires geometryEntityIds and geometryFrame; cannot be combined with full:true. Ordinary reads keep their existing shape.' },
414
435
  geometryCompareRevision: { type: 'string', description: 'Optional explicit reference revision (e.g. r0004). Requires geometryEntityIds and geometryFrame. Re-measures identical selected parts/frame and computes current-minus-reference native world-axis spans and origin moves. If geometrySurfaceSamples is supplied, compares those same local samples instead. Requested geometryAssemblyPairs also return origin-offset changes. Unavailable evidence stays unavailable; no clearance, global minima or change cause is established.' },
415
436
  geometryAssemblyPairs: { type: 'array', description: 'Optional 1–8 ordered native object-origin pairs. Requires geometryEntityIds containing both ends and geometryFrame. Offsets use world XYZ in millimeters; they are not surface protrusion, contact, clearance or whole-assembly certification.',
@@ -438,6 +459,12 @@ function sceneGet(ctx) {
438
459
  const resolved = resolveStudio(ctx)
439
460
  if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
440
461
  try {
462
+ if (args.sourceEntityId !== undefined || args.sourceOffset !== undefined || args.sourceLimit !== undefined) {
463
+ const receipt = await readEntitySource(resolved.studio, args)
464
+ return { ok: true, text: renderSuccess('Saved entity source chunk. Continue at nextOffset using this revision; verify entityJsonSha256 before concatenating.', receipt, { compact: true }), data: receipt }
465
+ }
466
+ if (args.geometryObjectAxes !== undefined && typeof args.geometryObjectAxes !== 'boolean') throw new BlenderError(BlenderErrorCode.SCRIPT_ERROR, 'geometryObjectAxes must be boolean.')
467
+ if (args.geometryObjectAxes === true && args.geometryEntityIds === undefined) throw new BlenderError(BlenderErrorCode.SCRIPT_ERROR, 'Object-local bounds require geometryEntityIds and geometryFrame.')
441
468
  if (args.geometryOnly !== undefined && typeof args.geometryOnly !== 'boolean') {
442
469
  throw new BlenderError(BlenderErrorCode.SCRIPT_ERROR, 'geometryOnly must be boolean.')
443
470
  }
@@ -456,19 +483,19 @@ function sceneGet(ctx) {
456
483
  }
457
484
  if (args.geometryAssemblyPairs !== undefined) validateAssemblyPairs(args.geometryAssemblyPairs, args.geometryEntityIds)
458
485
  if (args.geometryEntityIds !== undefined) validateGeometrySelection({ entityIds: args.geometryEntityIds,
459
- frame: args.geometryFrame, checkpointSha256: '0'.repeat(64), ...(args.geometrySurfaceSamples === undefined ? {} : { surfaceSamples: args.geometrySurfaceSamples }) })
486
+ frame: args.geometryFrame, checkpointSha256: '0'.repeat(64), ...(args.geometryObjectAxes === true ? { objectAxes: true } : {}), ...(args.geometrySurfaceSamples === undefined ? {} : { surfaceSamples: args.geometrySurfaceSamples }) })
460
487
  const { data, canonicalWarnings } = await canonicalCall(resolved.studio.getScene(args.projectId, {
461
488
  ...definedFields({ revision: args.revision }),
462
489
  full: args.full === true,
463
490
  ...(args.geometryEntityIds === undefined ? {} : { geometry: { entityIds: args.geometryEntityIds, frame: args.geometryFrame,
464
- ...(args.geometrySurfaceSamples === undefined ? {} : { surfaceSamples: args.geometrySurfaceSamples }) }, signal: exec?.signal }),
491
+ ...(args.geometryObjectAxes === true ? { objectAxes: true } : {}), ...(args.geometrySurfaceSamples === undefined ? {} : { surfaceSamples: args.geometrySurfaceSamples }) }, signal: exec?.signal }),
465
492
  }), warning)
466
- const hasSamples = args.geometrySurfaceSamples !== undefined
493
+ const hasSamples = args.geometrySurfaceSamples !== undefined, hasObjectAxes = args.geometryObjectAxes === true
467
494
  const verifyScene = (data, expectedRevision) => {
468
495
  if (expectedRevision !== undefined && data.revision !== expectedRevision) {
469
496
  throw new BlenderError(BlenderErrorCode.RUNTIME_UNAVAILABLE, 'The Host returned a different saved revision than requested.')
470
497
  }
471
- if (args.geometryEntityIds !== undefined && (!data.geometryInspection || data.geometryInspection.schemaVersion !== (hasSamples ? 'deepblend.scene-geometry/v2' : 'deepblend.scene-geometry/v1') ||
498
+ if (args.geometryEntityIds !== undefined && (!data.geometryInspection || data.geometryInspection.schemaVersion !== (hasObjectAxes ? 'deepblend.scene-geometry/v3' : hasSamples ? 'deepblend.scene-geometry/v2' : 'deepblend.scene-geometry/v1') ||
472
499
  data.geometryInspection.projectId !== args.projectId || data.geometryInspection.sourceRevision !== data.revision ||
473
500
  data.geometryInspection.sourceDigest !== data.digest || data.geometryInspection.sourceUnchanged !== true || data.geometryInspection.frame !== args.geometryFrame ||
474
501
  !Array.isArray(data.geometryInspection.limitations) ||
@@ -476,16 +503,17 @@ function sceneGet(ctx) {
476
503
  throw new BlenderError(BlenderErrorCode.RUNTIME_UNAVAILABLE, 'The Host did not return measurements for the requested source and frame. Upgrade DeepBlend before measuring; ordinary scene reads remain available.')
477
504
  }
478
505
  if (data.geometryInspection) verifyGeometryReport({ frame: data.geometryInspection.frame, blenderVersion: data.geometryInspection.blenderVersion, objects: data.geometryInspection.objects,
479
- schemaVersion: hasSamples ? 'deepblend.geometry-inspection/v2' : 'deepblend.geometry-inspection/v1',
506
+ schemaVersion: hasObjectAxes ? 'deepblend.geometry-inspection/v3' : hasSamples ? 'deepblend.geometry-inspection/v2' : 'deepblend.geometry-inspection/v1',
507
+ ...(hasObjectAxes ? { objectAxisBasis: data.geometryInspection.objectAxisBasis } : {}),
480
508
  ...(hasSamples ? { surfaceSamples: data.geometryInspection.surfaceSamples } : {}), checkpointSha256: data.geometryInspection.sourceCheckpointSha256,
481
509
  checkpointUnchanged: data.geometryInspection.sourceUnchanged }, { entityIds: args.geometryEntityIds, frame: args.geometryFrame,
482
- checkpointSha256: data.geometryInspection.sourceCheckpointSha256, ...(hasSamples ? { surfaceSamples: args.geometrySurfaceSamples } : {}) })
510
+ checkpointSha256: data.geometryInspection.sourceCheckpointSha256, ...(hasObjectAxes ? { objectAxes: true } : {}), ...(hasSamples ? { surfaceSamples: args.geometrySurfaceSamples } : {}) })
483
511
  }
484
512
  verifyScene(data, args.revision)
485
513
  if (args.geometryAssemblyPairs !== undefined) data.geometryAssemblyOrigins = readAssemblyOrigins(data.geometryInspection, args.geometryAssemblyPairs)
486
514
  if (args.geometryCompareRevision !== undefined) {
487
515
  const reference = await canonicalCall(resolved.studio.getScene(args.projectId, { revision: args.geometryCompareRevision,
488
- full: false, geometry: { entityIds: args.geometryEntityIds, frame: args.geometryFrame, ...(hasSamples ? { surfaceSamples: args.geometrySurfaceSamples } : {}) }, signal: exec?.signal }), warning)
516
+ full: false, geometry: { entityIds: args.geometryEntityIds, frame: args.geometryFrame, ...(hasObjectAxes ? { objectAxes: true } : {}), ...(hasSamples ? { surfaceSamples: args.geometrySurfaceSamples } : {}) }, signal: exec?.signal }), warning)
489
517
  verifyScene(reference.data, args.geometryCompareRevision)
490
518
  canonicalWarnings.push(...reference.canonicalWarnings)
491
519
  data.geometryComparison = hasSamples ? compareSurfaceReports(reference.data.geometryInspection, data.geometryInspection) : compareGeometryReports(reference.data.geometryInspection, data.geometryInspection)
@@ -562,7 +590,7 @@ function sceneGet(ctx) {
562
590
  },
563
591
  presentCall: args => ({
564
592
  card: 'generic',
565
- title: args?.full === true ? `Read full scene of "${args?.projectId ?? ''}"` : `Read scene of "${args?.projectId ?? ''}"`,
593
+ title: args?.sourceEntityId !== undefined ? `Read saved entity "${args.sourceEntityId}"` : args?.full === true ? `Read full scene of "${args?.projectId ?? ''}"` : `Read scene of "${args?.projectId ?? ''}"`,
566
594
  kind: 'read',
567
595
  }),
568
596
  })
@@ -617,6 +645,12 @@ function scenePatch(ctx) {
617
645
  'fails, no revision is created and the current revision is untouched, so a failed patch is always safe ' +
618
646
  'to correct and retry. Retrying an identical patch is also safe — it returns the original outcome ' +
619
647
  'instead of committing twice.\n\n' +
648
+ 'Optional entityCopies creates 1–16 explicit copies of single saved entities from baseRevision, before operations. ' +
649
+ 'Use {sourceEntityId,newEntityId,transform?} to reuse exact geometry and manual details without repeating vertices; operations may then be []. ' +
650
+ 'Transform components are absolute overrides. All other entity fields and external references are preserved; animation tracks, child assemblies and reference remapping are NOT copied. ' +
651
+ 'New IDs must be absent from the saved scene; sources must exist in baseRevision (not earlier copies in this request). Inspect real placement/contact and renders.\n\n' +
652
+ 'entityCopyOffset:[dx,dy,dz] optionally translates all selected copies by the same world-metre offset, preserving their relative positions. ' +
653
+ 'Do not combine it with any copy transform.location; rotation/scale overrides remain absolute per entity. References and unselected dependencies do not move automatically.\n\n' +
620
654
  'saveCheckpoint (default true) compiles this revision now. If false, the first preview compiles ' +
621
655
  'the revision lazily before rendering.\n\n' +
622
656
  'Operations (each object needs an "op" key):\n ' + OPERATION_SUMMARY,
@@ -637,8 +671,19 @@ function scenePatch(ctx) {
637
671
  description:
638
672
  'Ordered list of operations. Each needs an "op" key naming the operation (see the tool description). ' +
639
673
  'Later operations see the result of earlier ones: remove or retarget every shot that uses a camera ' +
640
- 'before removing that camera.',
674
+ 'before removing that camera. May be [] only when entityCopies supplies additions first.',
641
675
  },
676
+ entityCopies: { type: 'array', description: 'Optional 1–16 copies from baseRevision, expanded to entity.add before operations. Existing IDs cannot be overwritten; all non-transform entity details and references are preserved. No automatic assembly/animation copying or placement.',
677
+ items: { type: 'object', additionalProperties: false, properties: {
678
+ sourceEntityId: { type: 'string', required: true, description: 'Existing single entity in baseRevision.' },
679
+ newEntityId: { type: 'string', required: true, description: 'Distinct new entity ID, 1–64 characters.' },
680
+ transform: { type: 'object', additionalProperties: false, description: 'Optional absolute overrides; omitted components retain source values.', properties: {
681
+ location: { type: 'array', items: { type: 'number' } },
682
+ rotationEuler: { type: 'array', items: { type: 'number' } },
683
+ scale: { type: 'array', items: { type: 'number' } },
684
+ } },
685
+ } } },
686
+ entityCopyOffset: { type: 'array', items: { type: 'number' }, description: 'Optional common world translation [dx,dy,dz] in metres for entityCopies. Requires three finite numbers and entityCopies. Preserves selected source relative positions; cannot combine with any copy absolute transform.location. Does not move referenced/unselected objects or copy animations.' },
642
687
  note: {
643
688
  type: 'string',
644
689
  description:
@@ -672,7 +717,7 @@ function scenePatch(ctx) {
672
717
  async execute(args, exec) {
673
718
  const resolved = resolveStudio(ctx)
674
719
  if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
675
- if (!Array.isArray(args.operations) || args.operations.length === 0) {
720
+ if (!Array.isArray(args.operations) || (args.operations.length === 0 && args.entityCopies === undefined)) {
676
721
  return {
677
722
  ok: false,
678
723
  text:
@@ -683,6 +728,7 @@ function scenePatch(ctx) {
683
728
  }
684
729
  }
685
730
  try {
731
+ const expanded = await expandEntityCopies(resolved.studio, args)
686
732
  // `definedFields` is load-bearing here, not tidiness: forwarding
687
733
  // `note: undefined` puts an own `note` key on the patch document, and the
688
734
  // host's schema validation rejects that as a wrong TYPE. A correct call
@@ -691,7 +737,7 @@ function scenePatch(ctx) {
691
737
  ...definedFields({
692
738
  projectId: args.projectId,
693
739
  baseRevision: args.baseRevision,
694
- operations: args.operations,
740
+ operations: expanded.operations,
695
741
  note: args.note,
696
742
  actor: args.actor,
697
743
  stage: args.stage,
@@ -701,6 +747,8 @@ function scenePatch(ctx) {
701
747
  renderPreview: args.renderPreview === true,
702
748
  signal: exec.signal,
703
749
  }), warning)
750
+ if (expanded.copies.length) data.entityCopies = expanded.copies
751
+ if (args.entityCopyOffset !== undefined) data.entityCopyOffset = [...args.entityCopyOffset]
704
752
  const notes = [
705
753
  `Revision: ${describeRevision(data)}`,
706
754
  `Digest: ${data.revision ? data.revision : data.digest}`,
@@ -742,7 +790,7 @@ function scenePatch(ctx) {
742
790
  },
743
791
  presentCall: args => ({
744
792
  card: 'generic',
745
- title: `Patch scene of "${args?.projectId ?? ''}" (${Array.isArray(args?.operations) ? args.operations.length : 0} ops)`,
793
+ title: `Patch scene of "${args?.projectId ?? ''}" (${(Array.isArray(args?.operations) ? args.operations.length : 0) + (Array.isArray(args?.entityCopies) ? args.entityCopies.length : 0)} ops)`,
746
794
  kind: 'edit',
747
795
  }),
748
796
  })
@@ -43,7 +43,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
43
43
  import { attachVerifiedRenderedImage } from './committed-preview.js'
44
44
  import { imageForCallingModel } from './image-delivery.js'
45
45
 
46
- import { BlenderWarningCode, warning } from '@deepblend/dsh-blender-contracts'
46
+ import { BlenderError, BlenderErrorCode, BlenderWarningCode, warning } from '@deepblend/dsh-blender-contracts'
47
47
 
48
48
  import {
49
49
  TOOL_OUTPUT,
@@ -64,6 +64,10 @@ const MEASUREMENT_GLOSSARY =
64
64
  'the way), its visible pixel count, the visible fraction (occlusion), its frame coverage, its bounding ' +
65
65
  'box and its centroid. These come from the rendered pixels, not from the SceneSpec.'
66
66
 
67
+ const PREVIEW_FRAMING_GLOSSARY =
68
+ 'frameBoundaryContacts names tracked silhouettes touching an image edge; that is a framing warning, ' +
69
+ 'not proof of how much geometry lies outside. No contacts proves nothing about untracked parts.'
70
+
67
71
  /**
68
72
  * Register the M2 visual tools for the calling agent scope.
69
73
  *
@@ -91,10 +95,12 @@ function previewViews(ctx) {
91
95
  'the saved images, a verified contact sheet attached for the calling model, and per-view measurements. Rendering does NOT create a ' +
92
96
  'revision — a preview observes a scene rather than changing it — so this is always safe to call and ' +
93
97
  'never needs a baseRevision. Use it to look at a scene; use blender_visual_review when you also want a ' +
94
- 'score and a judgment.\n\n' + MEASUREMENT_GLOSSARY,
98
+ 'score and a judgment.\n\n' + MEASUREMENT_GLOSSARY + '\n\n' + PREVIEW_FRAMING_GLOSSARY,
95
99
  parameters: {
96
100
  projectId: { type: 'string', required: true, description: 'The project id.' },
97
101
  revision: { type: 'string', description: 'Revision to render. Defaults to the current one.' },
102
+ subjectId: { type: 'string', description: 'Optional explicit entity to measure as the subject of this call (for example the cabinet shell instead of an automatically selected cable). Does not edit saved tags or the project subject.' },
103
+ track: { type: 'array', items: { type: 'string' }, description: 'Optional 1–8 distinct entity IDs to measure by pixel isolation; the selected subject is always included. These selected silhouettes are not whole-assembly framing certification.' },
98
104
  roles: {
99
105
  type: 'array',
100
106
  items: { type: 'string' },
@@ -120,10 +126,19 @@ function previewViews(ctx) {
120
126
  const resolved = resolveStudio(ctx)
121
127
  if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
122
128
  try {
129
+ if (args.subjectId !== undefined && (typeof args.subjectId !== 'string' || !args.subjectId.trim())) {
130
+ throw new BlenderError(BlenderErrorCode.SCRIPT_ERROR, 'subjectId must be a non-empty entity ID.')
131
+ }
132
+ if (args.track !== undefined && (!Array.isArray(args.track) || args.track.length < 1 || args.track.length > 8 ||
133
+ new Set(args.track).size !== args.track.length || args.track.some(id => typeof id !== 'string' || !id.trim()))) {
134
+ throw new BlenderError(BlenderErrorCode.SCRIPT_ERROR, 'track must contain 1–8 distinct non-empty entity IDs.')
135
+ }
123
136
  const { data, canonicalWarnings } = await canonicalCall(Promise.resolve(resolved.studio.renderViews({
124
137
  ...definedFields({
125
138
  projectId: args.projectId,
126
139
  revision: args.revision,
140
+ subjectId: args.subjectId,
141
+ track: args.track,
127
142
  roles: args.roles,
128
143
  frame: args.frame,
129
144
  width: args.width,
@@ -133,6 +148,20 @@ function previewViews(ctx) {
133
148
  signal: exec.signal,
134
149
  })).then(({ pngs, ...metadata }) => metadata), warning)
135
150
  const scored = resolved.studio.scoreVisualViews({ views: data.views, subjectId: data.subjectId })
151
+ // Isolation masks are clipped to the image. Edge contact cannot establish how
152
+ // much is missing, but must not disappear behind the primary subject's score.
153
+ const frameBoundaryContacts = []
154
+ for (const view of data.views ?? []) for (const object of view.metrics?.objects ?? []) {
155
+ const box = object.bbox
156
+ if (object.inFrame !== false || !(object.silhouettePixels > 0) || !Array.isArray(box) || box.length !== 4 ||
157
+ !box.every(n => Number.isFinite(n) && n >= 0 && n <= 1) || box[0] > box[2] || box[1] > box[3]) continue
158
+ const edges = [['left', box[0] === 0], ['top', box[1] === 0], ['right', box[2] === 1], ['bottom', box[3] === 1]]
159
+ .filter(([, touches]) => touches).map(([edge]) => edge)
160
+ if (edges.length) frameBoundaryContacts.push(definedFields({ viewId: view.viewId, cameraId: view.cameraId,
161
+ frame: view.frame, entityId: object.id, bbox: box, edges }))
162
+ }
163
+ const framingScope = 'Tracked isolated silhouettes at each returned camera/frame/resolution only. Edge contact means complete framing is unverified; no contacts cannot certify untracked parts, depth clipping, occlusion or aesthetic quality.'
164
+
136
165
  const notes = [
137
166
  `Revision: ${data.revision}`,
138
167
  `Subject: ${data.subjectId ?? '(none tagged)'}`,
@@ -147,6 +176,11 @@ function previewViews(ctx) {
147
176
  `${view.width}x${view.height}) — ${view.purpose ?? ''}`,
148
177
  )
149
178
  }
179
+ notes.push('', 'Frame boundary contacts (including tracked objects other than the selected subject):')
180
+ for (const contact of frameBoundaryContacts) notes.push(
181
+ ` ${contact.viewId}: ${contact.entityId} touches ${contact.edges.join(', ')} — complete framing is unverified.`)
182
+ if (!frameBoundaryContacts.length) notes.push(' No reported edge contacts; this is not a complete assembly framing certificate.')
183
+ notes.push(framingScope)
150
184
  const sheet = (data.revisionPreviews ?? data.contactSheets ?? [])
151
185
  notes.push('')
152
186
  notes.push('The paths are relative to the project directory. The measured score is what the automated')
@@ -154,8 +188,8 @@ function previewViews(ctx) {
154
188
  notes.push('when you need a separate vision reviewer judgement, not just to obtain these images.')
155
189
  return await attachVerifiedRenderedImage(ctx, exec, resolved, {
156
190
  ok: true,
157
- text: renderSuccess(`Rendered ${data.views.length} view(s) of ${data.revision}.`, { ...data, score: scored.score, issues: scored.issues }, { notes, warnings: [...(data.warnings ?? []), ...canonicalWarnings] }),
158
- data: { ...data, score: scored.score, issues: scored.issues, sheet },
191
+ text: renderSuccess(`Rendered ${data.views.length} view(s) of ${data.revision}.`, { ...data, score: scored.score, issues: scored.issues, frameBoundaryContacts, framingScope }, { notes, warnings: [...(data.warnings ?? []), ...canonicalWarnings] }),
192
+ data: { ...data, score: scored.score, issues: scored.issues, frameBoundaryContacts, framingScope, sheet },
159
193
  }, { projectId: args.projectId, revision: data.revision, digest: data.digest,
160
194
  artifact: data.previewSheets?.current, kind: 'contact-sheet' })
161
195
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-tool",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "description": "Agent-preset tool consumer: registers the model-visible DeepBlend Blender tools. Publishes no service.",
6
6
  "main": "lib/index.js",
@@ -11,7 +11,7 @@
11
11
  "./package.json": "./package.json"
12
12
  },
13
13
  "dependencies": {
14
- "@deepblend/dsh-blender-contracts": "0.13.0"
14
+ "@deepblend/dsh-blender-contracts": "0.14.0"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {