@deepblend/dsh-blender-host 0.8.0 → 0.9.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.
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { mkdirSync, copyFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { BlenderError, BlenderErrorCode, sceneSpecDigest, specHash, validateGeometrySelection,
|
|
5
|
+
verifyGeometryReport, GEOMETRY_INSPECTION_LIMITS } from '@deepblend/dsh-blender-contracts'
|
|
6
|
+
import { fileSha256, fileSize, isFile, resolveInside, removeTree } from './paths.js'
|
|
7
|
+
import { verifyAssetBundle, verifyUnbundledGltfAsset, verifyUnbundledObjAsset } from './asset-bundle.js'
|
|
8
|
+
|
|
9
|
+
function refuse(code, message) { throw new BlenderError(code, `Native geometry inspection: ${message}`) }
|
|
10
|
+
function cancelled(signal) {
|
|
11
|
+
if (signal?.aborted) refuse(BlenderErrorCode.ABORTED, 'cancelled; the saved source was not changed.')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Native values are bound to an immutable revision, never inferred from generator declarations. */
|
|
15
|
+
export async function inspectRevisionGeometry(host, projectId, revision, selection, signal) {
|
|
16
|
+
const store = host.store, spec = store.readRevisionSpec(projectId, revision)
|
|
17
|
+
const manifest = store.readRevisionManifest(projectId, revision), digest = sceneSpecDigest(spec)
|
|
18
|
+
const project = store.projectDirectory(projectId), directory = store.revisionDirectory(projectId, revision)
|
|
19
|
+
const checkpoint = resolveInside(project, `revisions/${revision}/scene.blend`, 'inspection checkpoint')
|
|
20
|
+
cancelled(signal)
|
|
21
|
+
// Validate selection even if the historical checkpoint has no hash evidence.
|
|
22
|
+
validateGeometrySelection({ ...selection, checkpointSha256: '0'.repeat(64) })
|
|
23
|
+
if (selection.entityIds.some(id => !(spec.entities ?? []).some(entity => entity.id === id))) refuse(BlenderErrorCode.SCENE_SPEC_INVALID, 'one or more selected entities do not exist in this revision.')
|
|
24
|
+
if (manifest?.projectId !== projectId || manifest.revision !== revision || manifest.digest !== digest || manifest.specHashAfter !== specHash(spec)) refuse(BlenderErrorCode.SCENE_VALIDATION_FAILED, 'the revision manifest no longer matches its saved scene.')
|
|
25
|
+
if (!isFile(checkpoint)) refuse(BlenderErrorCode.REVISION_CHECKPOINT_MISSING, 'this revision has no native checkpoint; save a checkpoint before measuring.')
|
|
26
|
+
if (fileSize(checkpoint) > GEOMETRY_INSPECTION_LIMITS.checkpointBytes) refuse(BlenderErrorCode.SCENE_TOO_HEAVY, 'the native checkpoint exceeds the inspection budget.')
|
|
27
|
+
const binding = manifest.checkpointBinding
|
|
28
|
+
let expected
|
|
29
|
+
if (binding) {
|
|
30
|
+
if (binding.sourceDigest !== digest || binding.specHash !== manifest.specHashAfter || binding.bytes !== fileSize(checkpoint)) refuse(BlenderErrorCode.SCENE_VALIDATION_FAILED, 'checkpoint binding differs from the selected revision.')
|
|
31
|
+
expected = binding.sha256
|
|
32
|
+
} else {
|
|
33
|
+
// Older immutable revisions retain the producer's exact artifact hash in their compile job.
|
|
34
|
+
const job = typeof manifest.jobId === 'string' ? store.readJobSafe(projectId, manifest.jobId) : null
|
|
35
|
+
if (job?.projectId === projectId && job.revision === revision && job.status === 'succeeded' &&
|
|
36
|
+
['compile_scene', 'apply_scene_patch'].includes(job.action)) {
|
|
37
|
+
const matches = (job.artifacts ?? []).filter(a => a.kind === 'blend' && a.path === `revisions/${revision}/scene.blend` && a.bytes === fileSize(checkpoint))
|
|
38
|
+
if (matches.length === 1) expected = matches[0].sha256
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!/^[a-f0-9]{64}$/.test(expected ?? '')) refuse(BlenderErrorCode.UNSUPPORTED_ACTION, 'this historical checkpoint has no verified producer hash; save a new checkpoint before measuring.')
|
|
42
|
+
if (typeof host.runtime?.inspectGeometry !== 'function') refuse(BlenderErrorCode.RUNTIME_UNAVAILABLE, 'the configured runtime does not support native geometry inspection.')
|
|
43
|
+
const paths = [join(directory, 'scene-spec.json'), join(directory, 'revision-manifest.json')]
|
|
44
|
+
if (!binding) paths.push(store.jobPath(projectId, manifest.jobId))
|
|
45
|
+
const before = new Map(paths.map(path => [path, fileSha256(path)]))
|
|
46
|
+
const verifySource = () => {
|
|
47
|
+
cancelled(signal)
|
|
48
|
+
resolveInside(project, `revisions/${revision}/scene.blend`, 'inspection checkpoint')
|
|
49
|
+
if (!isFile(checkpoint) || fileSha256(checkpoint) !== expected || [...before].some(([path, hash]) => !isFile(path) || fileSha256(path) !== hash)) refuse(BlenderErrorCode.SCENE_VALIDATION_FAILED, 'the saved source changed; no measurement is accepted.')
|
|
50
|
+
for (const asset of spec.assets ?? []) {
|
|
51
|
+
if (!/^[a-f0-9]{64}$/.test(asset.sha256 ?? '') || typeof asset.path !== 'string') refuse(BlenderErrorCode.ASSET_HASH_MISMATCH, `asset ${asset.id} has no pinned source.`)
|
|
52
|
+
const file = resolveInside(project, asset.path, 'inspection asset')
|
|
53
|
+
if (!isFile(file) || fileSha256(file) !== asset.sha256) refuse(BlenderErrorCode.ASSET_HASH_MISMATCH, `asset ${asset.id} no longer matches its declared source.`)
|
|
54
|
+
verifyAssetBundle(project, asset); verifyUnbundledGltfAsset(project, asset); verifyUnbundledObjAsset(project, asset)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
verifySource()
|
|
58
|
+
const scratch = resolveInside(host.workspaceRoot, join('tmp', `geometry-${randomUUID()}`), 'geometry workspace')
|
|
59
|
+
mkdirSync(scratch, { recursive: true })
|
|
60
|
+
try {
|
|
61
|
+
const snapshot = join(scratch, 'source.blend'); copyFileSync(checkpoint, snapshot)
|
|
62
|
+
const request = { ...selection, checkpointPath: snapshot, checkpointSha256: expected, signal }
|
|
63
|
+
if (fileSha256(snapshot) !== expected) refuse(BlenderErrorCode.SCENE_VALIDATION_FAILED, 'the staged native input differs from the saved source.')
|
|
64
|
+
const result = await host.runtime.inspectGeometry(request)
|
|
65
|
+
cancelled(signal)
|
|
66
|
+
const native = verifyGeometryReport(result?.report, request)
|
|
67
|
+
verifySource()
|
|
68
|
+
return { schemaVersion: 'deepblend.scene-geometry/v1', projectId, sourceRevision: revision,
|
|
69
|
+
sourceDigest: digest, sourceSpecHash: manifest.specHashAfter, sourceCheckpointSha256: expected,
|
|
70
|
+
sourceUnchanged: true, frame: native.frame, objects: native.objects, blenderVersion: native.blenderVersion,
|
|
71
|
+
excludedMeasurements: ['wallThickness', 'shellToLinerClearance', 'minimumClearance', 'collision'],
|
|
72
|
+
execution: { transport: 'batch' }, limitations: [
|
|
73
|
+
'World-axis spans include rotations, handles and protrusions; they are not intrinsic body dimensions.',
|
|
74
|
+
'This evaluates saved native mesh geometry, not shader displacement or rendered pixels.',
|
|
75
|
+
'No wall thickness, clearance, collision, topology or artistic approval is established.',
|
|
76
|
+
'Do not subtract shell and liner extents or radii to report a gap: both bounds describe outer surfaces, and their difference includes wall material and depends on curvature and orientation.',
|
|
77
|
+
'Unchanged height, position or scale does not prove that wall thickness or clearance was preserved. Report those conditions as unmeasured unless a separate surface measurement verifies them.',
|
|
78
|
+
] }
|
|
79
|
+
} finally { removeTree(scratch) }
|
|
80
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -105,6 +105,7 @@ import { prepareModelBundle, assetPreviewVersion, stageAssetBundle, verifyUnbund
|
|
|
105
105
|
import { ASSET_LIBRARY_LIMITS, uploadAssetType, checkAssetUpload, hashAssetFile, previewRasterAsset } from './asset-library.js'
|
|
106
106
|
import { ASSET_PREVIEW_TEMPLATE, renderAssetPreview } from './asset-preview.js'
|
|
107
107
|
import { listDiagnostics, renderDiagnostic } from './diagnostic-preview.js'
|
|
108
|
+
import { inspectRevisionGeometry } from './geometry-inspection.js'
|
|
108
109
|
import { inspectReferenceImage, REFERENCE_IMAGE_MAX_BYTES } from './reference-image.js'
|
|
109
110
|
import { createReadStream, linkSync } from 'node:fs'
|
|
110
111
|
import { createHash, randomUUID } from 'node:crypto'
|
|
@@ -709,7 +710,7 @@ export default class BlenderStudio extends Service {
|
|
|
709
710
|
* whole spec on every turn spends its context on fields it is not changing.
|
|
710
711
|
*
|
|
711
712
|
* @param {string} projectId
|
|
712
|
-
* @param {{ revision?: string, full?: boolean }} [options]
|
|
713
|
+
* @param {{ revision?: string, full?: boolean, geometry?: { entityIds: string[], frame: number }, signal?: AbortSignal }} [options]
|
|
713
714
|
* @returns {Promise<Record<string, unknown>>}
|
|
714
715
|
*/
|
|
715
716
|
async getScene(projectId, options = {}) {
|
|
@@ -723,10 +724,12 @@ export default class BlenderStudio extends Service {
|
|
|
723
724
|
digest,
|
|
724
725
|
})
|
|
725
726
|
const manifest = this.store.readRevisionManifest(projectId, revision)
|
|
727
|
+
const geometry = options.geometry === undefined ? undefined : await inspectRevisionGeometry(this, projectId, revision, options.geometry, options.signal)
|
|
726
728
|
return {
|
|
727
729
|
...summary,
|
|
728
730
|
projectId,
|
|
729
|
-
isCurrent: revision === record.currentRevision,
|
|
731
|
+
isCurrent: revision === (geometry ? this.store.readRecord(projectId).currentRevision : record.currentRevision),
|
|
732
|
+
...(geometry ? { geometryInspection: geometry } : {}),
|
|
730
733
|
assetParts: (manifest?.assetParts ?? []).map(part => {
|
|
731
734
|
const entity = spec.entities.find(entry => entry.id === part.entityId)
|
|
732
735
|
const asset = spec.assets.find(entry => entry.id === entity?.assetId)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Refuse to rebuild away native edits not represented by the saved SceneSpec. */
|
|
2
|
+
import { BlenderError, GEOMETRY_INSPECTION_LIMITS } from '@deepblend/dsh-blender-contracts'
|
|
3
|
+
import { fileSha256, fileSize, isFile, resolveInside } from './paths.js'
|
|
4
|
+
|
|
5
|
+
export function protectRecipeNativeSource(store, projectId, revision) {
|
|
6
|
+
const project = store.projectDirectory(projectId)
|
|
7
|
+
const path = resolveInside(project, `revisions/${revision}/scene.blend`, 'recipe checkpoint')
|
|
8
|
+
const manifest = store.readRevisionManifest(projectId, revision)
|
|
9
|
+
if (!isFile(path) && !manifest.checkpoint && !manifest.checkpointBinding) return () => {}
|
|
10
|
+
const conflict = () => { throw new BlenderError('RECIPE_PARAMETER_CONFLICT',
|
|
11
|
+
'The saved native file was independently changed or has no verified producer hash. Keep that file and reconcile its edits before resizing; no new revision was published.',
|
|
12
|
+
{ detail: { conflicts: [{ entityId: null, property: 'native-checkpoint', revision }] } }) }
|
|
13
|
+
let expected = manifest.checkpointBinding?.sha256, bytes = manifest.checkpointBinding?.bytes
|
|
14
|
+
if (!expected) {
|
|
15
|
+
const job = typeof manifest.jobId === 'string' ? store.readJobSafe(projectId, manifest.jobId) : null
|
|
16
|
+
if (job?.projectId === projectId && job.revision === revision && job.status === 'succeeded'
|
|
17
|
+
&& ['compile_scene', 'apply_scene_patch'].includes(job.action)) {
|
|
18
|
+
const matches = (job.artifacts ?? []).filter(a => a.kind === 'blend' && a.path === `revisions/${revision}/scene.blend`)
|
|
19
|
+
if (matches.length === 1) { expected = matches[0].sha256; bytes = matches[0].bytes }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const verify = () => {
|
|
23
|
+
resolveInside(project, `revisions/${revision}/scene.blend`, 'recipe checkpoint')
|
|
24
|
+
if (!/^[a-f0-9]{64}$/.test(expected ?? '') || !isFile(path)
|
|
25
|
+
|| fileSize(path) !== bytes || bytes > GEOMETRY_INSPECTION_LIMITS.checkpointBytes || fileSha256(path) !== expected) conflict()
|
|
26
|
+
}
|
|
27
|
+
verify()
|
|
28
|
+
return verify
|
|
29
|
+
}
|
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
import { verifyAssetBundle, verifyUnbundledGltfAsset, verifyUnbundledObjAsset } from './asset-bundle.js'
|
|
72
72
|
import { GENESIS_REVISION, parseRevisionId } from './project-store.js'
|
|
73
73
|
import { readRecipeContext } from './recipe-state.js'
|
|
74
|
+
import { protectRecipeNativeSource } from './recipe-native-source.js'
|
|
74
75
|
import {
|
|
75
76
|
fileSha256,
|
|
76
77
|
fileSize,
|
|
@@ -314,6 +315,7 @@ export class RevisionTransaction {
|
|
|
314
315
|
revision: this.store.readRevisionManifest(request.projectId, request.baseRevision),
|
|
315
316
|
parameterState: context.state, preservedCameraIds: planned.preservedCameraIds }
|
|
316
317
|
}
|
|
318
|
+
const verifyNativeSource = protectRecipeNativeSource(this.store, request.projectId, request.baseRevision)
|
|
317
319
|
const patch = { projectId: request.projectId, baseRevision: request.baseRevision, operations: planned.operations,
|
|
318
320
|
saveCheckpoint: request.saveCheckpoint !== false, renderPreview: request.renderPreview === true,
|
|
319
321
|
idempotencyKey: request.idempotencyKey ?? `recipe-${sha256Canonical({ projectId: request.projectId,
|
|
@@ -323,7 +325,7 @@ export class RevisionTransaction {
|
|
|
323
325
|
const checked = validateScenePatch(patch)
|
|
324
326
|
if (!checked.ok) throw new BlenderError(BlenderErrorCode.SCENE_PATCH_INVALID, checked.summary)
|
|
325
327
|
const state = { ...planned.parameterState, originRevision: context.originRevision }
|
|
326
|
-
const outcome = await this._applyScenePatch(patch, { ...options, recipeParameters: state })
|
|
328
|
+
const outcome = await this._applyScenePatch(patch, { ...options, recipeParameters: state, verifyNativeSource })
|
|
327
329
|
const committed = readRecipeContext(this.store, request.projectId, outcome.revision.revision)
|
|
328
330
|
if (!committed?.state) throw new BlenderError('RECIPE_LOCK_INVALID', 'The recorded outcome has no recipe parameter state')
|
|
329
331
|
return { ...outcome, parameterState: committed.state, preservedCameraIds: planned.preservedCameraIds }
|
|
@@ -478,6 +480,7 @@ export class RevisionTransaction {
|
|
|
478
480
|
spec: nextSpec,
|
|
479
481
|
kind: 'scene_patch',
|
|
480
482
|
recipeParameters: options.recipeParameters ?? null,
|
|
483
|
+
verifyNativeSource: options.verifyNativeSource,
|
|
481
484
|
summary,
|
|
482
485
|
actor: patch.actor ?? null,
|
|
483
486
|
stage: patch.stage ?? null,
|
|
@@ -815,6 +818,12 @@ export class RevisionTransaction {
|
|
|
815
818
|
sourceMaterialSlots: sourceMaterialSlots ?? [], materialSlots: materialSlots ?? [],
|
|
816
819
|
})),
|
|
817
820
|
checkpoint: isFile(join(staging, 'scene.blend')) ? `revisions/${revision}/scene.blend` : null,
|
|
821
|
+
...(isFile(join(staging, 'scene.blend')) ? { checkpointBinding: {
|
|
822
|
+
sha256: fileSha256(join(staging, 'scene.blend')),
|
|
823
|
+
bytes: fileSize(join(staging, 'scene.blend')),
|
|
824
|
+
sourceDigest: digest,
|
|
825
|
+
specHash: plan.specHashAfter ?? specHash(plan.spec),
|
|
826
|
+
} } : {}),
|
|
818
827
|
previews: previews.map(entry => entry.artifact),
|
|
819
828
|
jobId,
|
|
820
829
|
runtime: {
|
|
@@ -886,6 +895,7 @@ export class RevisionTransaction {
|
|
|
886
895
|
// the whole record so a restore away and back cannot hide behind the same id.
|
|
887
896
|
try {
|
|
888
897
|
this.store.assertProjectWrite(projectId)
|
|
898
|
+
plan.verifyNativeSource?.()
|
|
889
899
|
const current = this.store.readRecord(projectId)
|
|
890
900
|
if (JSON.stringify(current) !== JSON.stringify(record)) {
|
|
891
901
|
throw new BlenderError(BlenderErrorCode.REVISION_CONFLICT,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepblend/dsh-blender-host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DeepBlend Host service: the blenderStudio business facade over the BlenderRuntime seam.",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"./package.json": "./package.json"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@deepblend/dsh-blender-contracts": "0.
|
|
23
|
+
"@deepblend/dsh-blender-contracts": "0.9.0",
|
|
24
24
|
"sharp": "0.35.5"
|
|
25
25
|
},
|
|
26
26
|
"license": "MIT",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "deepblend.recipe/v1",
|
|
3
3
|
"id": "deepblend.metal-lamp",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"title": "香槟金属桌灯",
|
|
6
6
|
"description": "连续旋压灯罩、沿曲面延伸的金属细纹、珐琅内衬、曲线弯臂与贴地线缆。分别调整旋压件和拉丝件的粗糙度,同步调整金属颜色与摄影曝光;灯罩外径联动金属壁与内衬,颈口保持,提供独立内衬检查相机。",
|
|
7
7
|
"author": {
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
"material.procedural",
|
|
27
27
|
"material.procedural.uv",
|
|
28
28
|
"recipe.geometry-bindings",
|
|
29
|
-
"recipe.lathe-shell-bindings"
|
|
29
|
+
"recipe.lathe-shell-bindings",
|
|
30
|
+
"recipe.curve-point-bindings",
|
|
31
|
+
"recipe.threshold-bindings"
|
|
30
32
|
]
|
|
31
33
|
},
|
|
32
34
|
"input": {
|
|
@@ -43,7 +45,7 @@
|
|
|
43
45
|
{
|
|
44
46
|
"id": "shade-diameter-mm",
|
|
45
47
|
"title": "灯罩外径(毫米)",
|
|
46
|
-
"description": "
|
|
48
|
+
"description": "调整实际灯罩外径,联动金属壁和内衬;超过230毫米时后移立柱与关节并调整弯臂起段,保持颈口和灯泡。",
|
|
47
49
|
"type": "number",
|
|
48
50
|
"default": 209.8,
|
|
49
51
|
"minimum": 180,
|
|
@@ -59,6 +61,94 @@
|
|
|
59
61
|
"rimHeight": 0.008,
|
|
60
62
|
"neckAnchorHeight": 0.118,
|
|
61
63
|
"multiplier": 0.0005
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"kind": "entity",
|
|
67
|
+
"entityId": "mast-socket",
|
|
68
|
+
"property": "location",
|
|
69
|
+
"axis": "x",
|
|
70
|
+
"multiplier": -0.00055,
|
|
71
|
+
"inputFloor": 230
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"kind": "entity",
|
|
75
|
+
"entityId": "mast-socket-collar",
|
|
76
|
+
"property": "location",
|
|
77
|
+
"axis": "x",
|
|
78
|
+
"multiplier": -0.00055,
|
|
79
|
+
"inputFloor": 230
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"kind": "entity",
|
|
83
|
+
"entityId": "upright-mast",
|
|
84
|
+
"property": "location",
|
|
85
|
+
"axis": "x",
|
|
86
|
+
"multiplier": -0.00055,
|
|
87
|
+
"inputFloor": 230
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"kind": "entity",
|
|
91
|
+
"entityId": "hinge-body",
|
|
92
|
+
"property": "location",
|
|
93
|
+
"axis": "x",
|
|
94
|
+
"multiplier": -0.00055,
|
|
95
|
+
"inputFloor": 230
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"kind": "entity",
|
|
99
|
+
"entityId": "hinge-front-cap",
|
|
100
|
+
"property": "location",
|
|
101
|
+
"axis": "x",
|
|
102
|
+
"multiplier": -0.00055,
|
|
103
|
+
"inputFloor": 230
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"kind": "entity",
|
|
107
|
+
"entityId": "hinge-rear-cap",
|
|
108
|
+
"property": "location",
|
|
109
|
+
"axis": "x",
|
|
110
|
+
"multiplier": -0.00055,
|
|
111
|
+
"inputFloor": 230
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"kind": "entity",
|
|
115
|
+
"entityId": "hinge-fine-ring",
|
|
116
|
+
"property": "location",
|
|
117
|
+
"axis": "x",
|
|
118
|
+
"multiplier": -0.00055,
|
|
119
|
+
"inputFloor": 230
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"kind": "entity",
|
|
123
|
+
"entityId": "hinge-centre-pin",
|
|
124
|
+
"property": "location",
|
|
125
|
+
"axis": "x",
|
|
126
|
+
"multiplier": -0.00055,
|
|
127
|
+
"inputFloor": 230
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"kind": "curve-point",
|
|
131
|
+
"entityId": "swept-upper-arm",
|
|
132
|
+
"pointIndex": 0,
|
|
133
|
+
"axis": "x",
|
|
134
|
+
"multiplier": -0.00055,
|
|
135
|
+
"inputFloor": 230
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"kind": "curve-point",
|
|
139
|
+
"entityId": "swept-upper-arm",
|
|
140
|
+
"pointIndex": 1,
|
|
141
|
+
"axis": "x",
|
|
142
|
+
"multiplier": -0.00055,
|
|
143
|
+
"inputFloor": 230
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"kind": "curve-point",
|
|
147
|
+
"entityId": "swept-upper-arm",
|
|
148
|
+
"pointIndex": 2,
|
|
149
|
+
"axis": "x",
|
|
150
|
+
"multiplier": -0.000275,
|
|
151
|
+
"inputFloor": 230
|
|
62
152
|
}
|
|
63
153
|
]
|
|
64
154
|
},
|