@deepblend/dsh-blender-tool 0.13.0 → 0.15.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/assembly-origins.js +6 -3
- package/lib/entity-copies.js +59 -0
- package/lib/scene-source.js +36 -0
- package/lib/tools.js +63 -15
- package/lib/visual-tools.js +38 -4
- package/package.json +3 -3
package/lib/assembly-origins.js
CHANGED
|
@@ -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
|
|
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
|
-
'
|
|
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:
|
|
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
|
})
|
package/lib/visual-tools.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.15.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.
|
|
14
|
+
"@deepblend/dsh-blender-contracts": "0.15.0"
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"repository": {
|
|
@@ -19,6 +19,6 @@
|
|
|
19
19
|
"url": "git+https://github.com/pearjelly/deep-blend.git"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
|
-
"@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0"
|
|
22
|
+
"@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.2.1-rc.1 <0.2.2-0"
|
|
23
23
|
}
|
|
24
24
|
}
|