@deepblend/dsh-blender-contracts 0.1.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/asset-content.js +134 -0
- package/lib/bitmap-font.js +149 -0
- package/lib/canonical.js +98 -0
- package/lib/config-surface.js +88 -0
- package/lib/contact-sheet.js +233 -0
- package/lib/deployment-paths.js +153 -0
- package/lib/index.js +829 -0
- package/lib/json-schema.js +337 -0
- package/lib/png.js +452 -0
- package/lib/projections.js +209 -0
- package/lib/redact.js +53 -0
- package/lib/render-job.js +459 -0
- package/lib/scene-patch.js +947 -0
- package/lib/scene-spec.js +901 -0
- package/lib/schemas/job-result.schema.json +295 -0
- package/lib/schemas/scene-patch.schema.json +1307 -0
- package/lib/schemas/scene-spec.schema.json +791 -0
- package/lib/ui-api.js +695 -0
- package/lib/visual-composition.js +574 -0
- package/lib/visual-issue.js +738 -0
- package/lib/visual-loop.js +401 -0
- package/package.json +19 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contact-sheet composition: many preview views into one image, with labels.
|
|
3
|
+
*
|
|
4
|
+
* WHY A SHEET AND NOT N IMAGES
|
|
5
|
+
* ----------------------------
|
|
6
|
+
* Measured in the M2 probe (`runtime-audit.md` §7.2.3): one 640x360 view costs 177
|
|
7
|
+
* vision tokens and a 2x2 sheet costs 369. Token cost is therefore NOT the reason
|
|
8
|
+
* to prefer a sheet — the reason is that the model's per-request pixel budget
|
|
9
|
+
* (640 000 px) downscales whatever is sent, and a sheet is the only way to answer a
|
|
10
|
+
* *relational* question ("is the subject off-centre, is anything in front of it")
|
|
11
|
+
* from one look. The numbers that decide detail questions are the measurements,
|
|
12
|
+
* which travel as text.
|
|
13
|
+
*
|
|
14
|
+
* The layout is chosen so the sheet survives that downscale with each tile at or
|
|
15
|
+
* above the resolution of the views that went into it: a 2x2 sheet of 640x360 views
|
|
16
|
+
* is composed at 2x and lands at roughly 1:1 after the model's own resize. A 3x3
|
|
17
|
+
* sheet would land below 1:1 and would make a partial occlusion look like no
|
|
18
|
+
* occlusion at all — which is worse than not showing it.
|
|
19
|
+
*
|
|
20
|
+
* Owner: DeepBlend Studio — M2
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { blendInto, createImage, decodePng, encodePng, fillRect } from './png.js'
|
|
24
|
+
import { drawText } from './bitmap-font.js'
|
|
25
|
+
|
|
26
|
+
/** Sheet background: a neutral mid-dark grey, so neither a black nor a white render
|
|
27
|
+
* appears to bleed into its neighbour. */
|
|
28
|
+
const BACKGROUND = [24, 24, 28, 255]
|
|
29
|
+
/** Cell caption band. Exported so a reader can identify a band without guessing. */
|
|
30
|
+
export const CAPTION_BAND_COLOR = [12, 12, 16, 255]
|
|
31
|
+
const CAPTION_BAND = CAPTION_BAND_COLOR
|
|
32
|
+
/** Cell caption text. */
|
|
33
|
+
const CAPTION_TEXT = [232, 232, 236, 255]
|
|
34
|
+
/** The border that separates two adjacent renders. */
|
|
35
|
+
const BORDER = [96, 96, 104, 255]
|
|
36
|
+
|
|
37
|
+
/** Layout constants, in output pixels. */
|
|
38
|
+
const MARGIN = 20
|
|
39
|
+
const GAP = 16
|
|
40
|
+
const CAPTION_HEIGHT = 30
|
|
41
|
+
const CAPTION_SCALE = 2
|
|
42
|
+
const BORDER_WIDTH = 2
|
|
43
|
+
/** Target sheet width before the layout is fitted to the grid. */
|
|
44
|
+
const TARGET_WIDTH = 1600
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {object} SheetView
|
|
48
|
+
* @property {string} viewId - the id the tool and the measurements use.
|
|
49
|
+
* @property {string} [label] - caption text; defaults to the view id.
|
|
50
|
+
* @property {Buffer} png - the rendered PNG bytes.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @typedef {object} SheetPlacement
|
|
55
|
+
* @property {string} viewId
|
|
56
|
+
* @property {string} label - the caption as DRAWN (the font has one case).
|
|
57
|
+
* @property {number} column - 0-based column in the grid.
|
|
58
|
+
* @property {number} row - 0-based row in the grid.
|
|
59
|
+
* @property {[number, number, number, number]} box - normalized [left, top, right, bottom]
|
|
60
|
+
* of the IMAGE TILE, so a caller can talk about "the tile at ..." without pixels.
|
|
61
|
+
* @property {[number, number, number, number]} captionBox - normalized box of the
|
|
62
|
+
* caption band above the tile. Reported separately rather than left to be derived:
|
|
63
|
+
* a caller that recomputes the layout is a second implementation of it, and the one
|
|
64
|
+
* that drifts is always the copy nobody draws.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compose a contact sheet.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} input
|
|
71
|
+
* @param {SheetView[]} input.views - in reading order (left to right, then down).
|
|
72
|
+
* @param {number} [input.columns] - grid columns; defaults to a near-square grid.
|
|
73
|
+
* @param {number} [input.scale] - tile upscale factor; defaults to the largest
|
|
74
|
+
* power of two that keeps the sheet near {@link TARGET_WIDTH}.
|
|
75
|
+
* @param {string} [input.title] - drawn top-left above the grid when present.
|
|
76
|
+
* @returns {{ png: Buffer, width: number, height: number, columns: number, rows: number, placements: SheetPlacement[] }}
|
|
77
|
+
*/
|
|
78
|
+
export function composeContactSheet(input) {
|
|
79
|
+
const views = input?.views ?? []
|
|
80
|
+
if (views.length === 0) throw new Error('a contact sheet needs at least one view')
|
|
81
|
+
|
|
82
|
+
const decoded = views.map(view => {
|
|
83
|
+
if (!Buffer.isBuffer(view.png)) throw new Error(`view "${view.viewId}" has no PNG bytes`)
|
|
84
|
+
return { view, image: decodePng(view.png) }
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// The first view defines the tile size. Views in one sheet come from one render
|
|
88
|
+
// plan, so they share a resolution; taking the first and scaling the rest to fit
|
|
89
|
+
// keeps a stray view from reflowing the whole grid.
|
|
90
|
+
const tileWidth = decoded[0].image.width
|
|
91
|
+
const tileHeight = decoded[0].image.height
|
|
92
|
+
if (tileWidth <= 0 || tileHeight <= 0) throw new Error('the first view decoded to an empty image')
|
|
93
|
+
|
|
94
|
+
const columns = clampInteger(input.columns ?? Math.ceil(Math.sqrt(views.length)), 1, views.length)
|
|
95
|
+
const rows = Math.ceil(views.length / columns)
|
|
96
|
+
const scale = clampInteger(input.scale ?? defaultScale(columns, tileWidth), 1, 8)
|
|
97
|
+
|
|
98
|
+
const cellWidth = tileWidth * scale
|
|
99
|
+
const cellHeight = tileHeight * scale
|
|
100
|
+
const titleHeight = typeof input.title === 'string' && input.title.length > 0
|
|
101
|
+
? CAPTION_HEIGHT
|
|
102
|
+
: 0
|
|
103
|
+
|
|
104
|
+
const width = MARGIN * 2 + columns * cellWidth + (columns - 1) * GAP
|
|
105
|
+
const height = MARGIN * 2 + titleHeight + rows * (CAPTION_HEIGHT + cellHeight) + (rows - 1) * GAP
|
|
106
|
+
|
|
107
|
+
const sheet = createImage(width, height, BACKGROUND)
|
|
108
|
+
if (titleHeight > 0) {
|
|
109
|
+
drawText(sheet, input.title, MARGIN, MARGIN - 4, CAPTION_TEXT, CAPTION_SCALE)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @type {SheetPlacement[]} */
|
|
113
|
+
const placements = []
|
|
114
|
+
decoded.forEach((entry, index) => {
|
|
115
|
+
const column = index % columns
|
|
116
|
+
const row = Math.floor(index / columns)
|
|
117
|
+
const left = MARGIN + column * (cellWidth + GAP)
|
|
118
|
+
const top = MARGIN + titleHeight + row * (CAPTION_HEIGHT + cellHeight + GAP)
|
|
119
|
+
const label = entry.view.label ?? entry.view.viewId
|
|
120
|
+
|
|
121
|
+
fillRect(sheet, { x: left, y: top, width: cellWidth, height: CAPTION_HEIGHT }, CAPTION_BAND)
|
|
122
|
+
drawText(sheet, label, left + 6, top + 8, CAPTION_TEXT, CAPTION_SCALE)
|
|
123
|
+
|
|
124
|
+
const imageTop = top + CAPTION_HEIGHT
|
|
125
|
+
// A border, not a gap: two renders of the same scene differ along their shared
|
|
126
|
+
// edge most of all, and an unseparated pair reads as one wide image.
|
|
127
|
+
fillRect(sheet, { x: left, y: imageTop, width: cellWidth, height: cellHeight }, BORDER)
|
|
128
|
+
blendInto(entry.image, sheet, {
|
|
129
|
+
x: left + BORDER_WIDTH,
|
|
130
|
+
y: imageTop + BORDER_WIDTH,
|
|
131
|
+
width: cellWidth - BORDER_WIDTH * 2,
|
|
132
|
+
height: cellHeight - BORDER_WIDTH * 2,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
placements.push({
|
|
136
|
+
viewId: entry.view.viewId,
|
|
137
|
+
label: label.toUpperCase(),
|
|
138
|
+
column,
|
|
139
|
+
row,
|
|
140
|
+
captionBox: [
|
|
141
|
+
round(left / width),
|
|
142
|
+
round(top / height),
|
|
143
|
+
round((left + cellWidth) / width),
|
|
144
|
+
round((top + CAPTION_HEIGHT) / height),
|
|
145
|
+
],
|
|
146
|
+
box: [
|
|
147
|
+
round(left / width),
|
|
148
|
+
round(imageTop / height),
|
|
149
|
+
round((left + cellWidth) / width),
|
|
150
|
+
round((imageTop + cellHeight) / height),
|
|
151
|
+
],
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
png: encodePng(sheet),
|
|
157
|
+
width,
|
|
158
|
+
height,
|
|
159
|
+
columns,
|
|
160
|
+
rows,
|
|
161
|
+
placements,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The largest power of two that keeps a sheet near the target width.
|
|
167
|
+
*
|
|
168
|
+
* Measured against the REQUEST pixel budget, not against aesthetics: a sheet wider
|
|
169
|
+
* than the budget is downscaled by the model pipeline, so upscaling past the point
|
|
170
|
+
* where the downscale lands at 1:1 adds bytes and no detail.
|
|
171
|
+
*
|
|
172
|
+
* @param {number} columns
|
|
173
|
+
* @param {number} tileWidth
|
|
174
|
+
* @returns {number}
|
|
175
|
+
*/
|
|
176
|
+
function defaultScale(columns, tileWidth) {
|
|
177
|
+
const perTile = TARGET_WIDTH / columns
|
|
178
|
+
let scale = 1
|
|
179
|
+
while (scale * 2 * tileWidth <= perTile && scale < 8) scale *= 2
|
|
180
|
+
return scale
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Clamp an integer option into a range, tolerating absent or fractional input.
|
|
185
|
+
* @param {unknown} value
|
|
186
|
+
* @param {number} minimum
|
|
187
|
+
* @param {number} maximum
|
|
188
|
+
* @returns {number}
|
|
189
|
+
*/
|
|
190
|
+
function clampInteger(value, minimum, maximum) {
|
|
191
|
+
const number = Number(value)
|
|
192
|
+
if (!Number.isFinite(number)) return minimum
|
|
193
|
+
return Math.min(maximum, Math.max(minimum, Math.trunc(number)))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Round to six decimals: enough to locate a tile, small enough to read. */
|
|
197
|
+
function round(value) {
|
|
198
|
+
return Math.round(value * 1e6) / 1e6
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Role names shortened to what the 26-character caption band can hold. */
|
|
202
|
+
const CAPTION_ROLE_ALIASES = Object.freeze({
|
|
203
|
+
'active-camera': 'ACTIVE',
|
|
204
|
+
'three-quarter': '3Q',
|
|
205
|
+
top: 'TOP',
|
|
206
|
+
detail: 'DETAIL',
|
|
207
|
+
front: 'FRONT',
|
|
208
|
+
side: 'SIDE',
|
|
209
|
+
back: 'BACK',
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A short caption for one view that fits the band.
|
|
214
|
+
*
|
|
215
|
+
* The long role names are shortened rather than truncated, because a truncated
|
|
216
|
+
* caption is the one thing a label must not be: `THREE-QUARTER:CAMERA-T~` tells a
|
|
217
|
+
* reviewer less than `3Q:CAMERA-TOP` while looking like it told them something.
|
|
218
|
+
*
|
|
219
|
+
* @param {object} view - a view entry from a render plan.
|
|
220
|
+
* @returns {string}
|
|
221
|
+
*/
|
|
222
|
+
export function viewCaption(view) {
|
|
223
|
+
const role = typeof view.role === 'string' && view.role.length > 0 ? view.role : null
|
|
224
|
+
const camera = String(view.cameraId ?? view.viewId ?? 'view')
|
|
225
|
+
const frame = view.frame ?? null
|
|
226
|
+
const label = role === null ? camera : `${CAPTION_ROLE_ALIASES[role] ?? role}:${camera}`
|
|
227
|
+
const caption = frame === null ? label : `${label} F${frame}`
|
|
228
|
+
// Uppercased here rather than at draw time so the string a caller sees IS the string
|
|
229
|
+
// on the sheet. A caption that reads "3Q:camera-top" to a human and "3Q:CAMERA-TOP"
|
|
230
|
+
// to the font is two representations of one label, which is how a reviewer ends up
|
|
231
|
+
// quoting a view id that nothing else uses.
|
|
232
|
+
return caption.toUpperCase()
|
|
233
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a DeepBlend deployment keeps its state, and where it finds the Blender
|
|
3
|
+
* that the installer put there.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS SHARED AND NOT WRITTEN TWICE
|
|
6
|
+
* ----------------------------------------
|
|
7
|
+
* Before this module, "where is the store" was answered in four unrelated places,
|
|
8
|
+
* and they agreed only because a human kept them in sync:
|
|
9
|
+
*
|
|
10
|
+
* 1. `bundle/cordis.patch.yml` — three literal absolute paths;
|
|
11
|
+
* 2. `tools/create-demo-project.mjs` — `<repo>/.deepblend`, with a comment
|
|
12
|
+
* saying it is "configured with exactly the values the installed bundle
|
|
13
|
+
* patch composes, so the project this produces is the one a restarted
|
|
14
|
+
* profile finds";
|
|
15
|
+
* 3. `tools/m3-delivery-acceptance.mjs`, `apply-brief-content.mjs`,
|
|
16
|
+
* `visual-review-live-probe.mjs` — the same literal, again;
|
|
17
|
+
* 4. SPEC §17, which asks for `~/.dsh/deepblend/projects` — a location NONE of
|
|
18
|
+
* the three above used.
|
|
19
|
+
*
|
|
20
|
+
* That is the D38 shape for the sixth time: several copies of one fact, compared
|
|
21
|
+
* by nothing. It is also why the bundle could not be installed on any machine but
|
|
22
|
+
* the one it was written on — the literal paths were not configuration, they were
|
|
23
|
+
* this developer's home directory, and no amount of documentation makes another
|
|
24
|
+
* machine grow that directory.
|
|
25
|
+
*
|
|
26
|
+
* So the answer lives here, once, as pure functions of `{ env, home }` that a
|
|
27
|
+
* test can call with any environment it likes. The plugin schemas resolve their
|
|
28
|
+
* defaults through it, and the repository's own tools resolve theirs through it,
|
|
29
|
+
* which is what makes "the project this produces is the one a restarted profile
|
|
30
|
+
* finds" true by construction instead of by comment.
|
|
31
|
+
*
|
|
32
|
+
* Owner: DeepBlend Studio — M5 (portability)
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { homedir } from 'node:os'
|
|
36
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
37
|
+
|
|
38
|
+
/** The directory DeepBlend owns under the DSH home. */
|
|
39
|
+
export const DEEPBLEND_STATE_DIRECTORY = 'deepblend'
|
|
40
|
+
|
|
41
|
+
/** Directory the managed Blender is installed into, relative to an install root. */
|
|
42
|
+
export const MANAGED_TOOLS_DIRECTORY = '.tools'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The pinned Blender, relative to the managed tools directory.
|
|
46
|
+
*
|
|
47
|
+
* macOS ships an app bundle; a Linux tarball puts a `blender` binary at the root.
|
|
48
|
+
* Both are listed because the *deployment* may be either, and a candidate that
|
|
49
|
+
* does not exist costs one `existsSync`.
|
|
50
|
+
*/
|
|
51
|
+
export const MANAGED_BLENDER_RELATIVE_PATHS = [
|
|
52
|
+
'Blender.app/Contents/MacOS/Blender',
|
|
53
|
+
'blender',
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Expand a leading `~` the way SPEC §17 writes paths (`~/.dsh/deepblend/projects`).
|
|
58
|
+
*
|
|
59
|
+
* A `${HOME}`-style interpolation is deliberately NOT supported: the spec example
|
|
60
|
+
* uses `~`, and a second syntax would be a second thing to get wrong.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} value
|
|
63
|
+
* @param {string} [home]
|
|
64
|
+
* @returns {string} an absolute path
|
|
65
|
+
*/
|
|
66
|
+
export function expandHome(value, home = homedir()) {
|
|
67
|
+
if (value === '~') return home
|
|
68
|
+
if (value.startsWith('~/')) return join(home, value.slice(2))
|
|
69
|
+
return isAbsolute(value) ? value : resolve(value)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The DSH home this process belongs to.
|
|
74
|
+
*
|
|
75
|
+
* `DSH_HOME` first — that is the harness's own override and the one a test or a
|
|
76
|
+
* second deployment sets — then `~/.dsh`, which is what the harness itself falls
|
|
77
|
+
* back to.
|
|
78
|
+
*
|
|
79
|
+
* @param {{ env?: Record<string, string|undefined>, home?: string }} [options]
|
|
80
|
+
* @returns {string} absolute path
|
|
81
|
+
*/
|
|
82
|
+
export function resolveDshHome(options = {}) {
|
|
83
|
+
const env = options.env ?? process.env
|
|
84
|
+
const home = options.home ?? homedir()
|
|
85
|
+
const configured = env.DSH_HOME
|
|
86
|
+
if (typeof configured === 'string' && configured.length > 0) return expandHome(configured, home)
|
|
87
|
+
return join(home, '.dsh')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The default workspace root: everything DeepBlend writes lives under it.
|
|
92
|
+
*
|
|
93
|
+
* SPEC §17 asks for `~/.dsh/deepblend/projects`; this is the `workspaceRoot` that
|
|
94
|
+
* makes that path mean something, and `<workspaceRoot>/projects` is the store.
|
|
95
|
+
*
|
|
96
|
+
* @param {{ env?: Record<string, string|undefined>, home?: string }} [options]
|
|
97
|
+
* @returns {string} absolute path
|
|
98
|
+
*/
|
|
99
|
+
export function resolveDefaultWorkspaceRoot(options = {}) {
|
|
100
|
+
return join(resolveDshHome(options), DEEPBLEND_STATE_DIRECTORY)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The `workspaceRoot` a composition row asked for, or the deployment default.
|
|
105
|
+
*
|
|
106
|
+
* One function rather than an `??` at each call site, because the pair
|
|
107
|
+
* (`workspaceRoot`, `projectsRoot`) must agree: the host hands the provider a
|
|
108
|
+
* staging path inside the former, and the provider refuses anything outside its
|
|
109
|
+
* own copy of it (SPEC §15.2). Two `??` expressions in two packages is exactly
|
|
110
|
+
* how those two drift apart.
|
|
111
|
+
*
|
|
112
|
+
* @param {string|undefined} configured - the row's value, if it set one
|
|
113
|
+
* @param {{ env?: Record<string, string|undefined>, home?: string }} [options]
|
|
114
|
+
* @returns {string} absolute path
|
|
115
|
+
*/
|
|
116
|
+
export function resolveWorkspaceRoot(configured, options = {}) {
|
|
117
|
+
if (typeof configured === 'string' && configured.trim().length > 0) {
|
|
118
|
+
return expandHome(configured.trim(), options.home ?? homedir())
|
|
119
|
+
}
|
|
120
|
+
return resolveDefaultWorkspaceRoot(options)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The `projectsRoot` a composition row asked for, or `<workspaceRoot>/projects`.
|
|
125
|
+
*
|
|
126
|
+
* @param {string|undefined} configured - the row's value, if it set one
|
|
127
|
+
* @param {string} workspaceRoot - the already-resolved workspace root
|
|
128
|
+
* @param {{ home?: string }} [options]
|
|
129
|
+
* @returns {string} absolute path
|
|
130
|
+
*/
|
|
131
|
+
export function resolveProjectsRoot(configured, workspaceRoot, options = {}) {
|
|
132
|
+
if (typeof configured === 'string' && configured.trim().length > 0) {
|
|
133
|
+
return expandHome(configured.trim(), options.home ?? homedir())
|
|
134
|
+
}
|
|
135
|
+
return join(workspaceRoot, 'projects')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Candidate absolute paths for a Blender installed by `tools/install-blender.mjs`
|
|
140
|
+
* under any of `roots`, in the order they should be tried.
|
|
141
|
+
*
|
|
142
|
+
* @param {string[]} roots - directories that might contain a `.tools` directory
|
|
143
|
+
* @returns {string[]} absolute paths, possibly empty, possibly non-existent
|
|
144
|
+
*/
|
|
145
|
+
export function managedBlenderCandidates(roots) {
|
|
146
|
+
const candidates = []
|
|
147
|
+
for (const root of roots) {
|
|
148
|
+
for (const relative of MANAGED_BLENDER_RELATIVE_PATHS) {
|
|
149
|
+
candidates.push(join(root, MANAGED_TOOLS_DIRECTORY, relative))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return candidates
|
|
153
|
+
}
|