@volter/blender-engine 0.1.139 → 0.1.141
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/browser/session.py +81 -6
- package/browser/three/blender-runtime-view.ts +6 -2
- package/browser/worker.ts +10 -1
- package/package.json +1 -1
package/browser/session.py
CHANGED
|
@@ -207,6 +207,7 @@ def _load_post(_arg):
|
|
|
207
207
|
# The DOOR resets its own revision table on LOAD_POST (it holds ID pointers
|
|
208
208
|
# into the Main that just went away); this is the session's half.
|
|
209
209
|
SESSION.forget()
|
|
210
|
+
SESSION.loaded()
|
|
210
211
|
# Factory reset reloads add-ons. Preserve native Cycles and re-register
|
|
211
212
|
# only the headless GPU presenters we own.
|
|
212
213
|
ENGINES[:], UNAVAILABLE_ENGINES[:] = _register_engine()
|
|
@@ -1589,6 +1590,13 @@ class Session:
|
|
|
1589
1590
|
# The OUTSIDE-THE-PROJECT file Blender was last found holding, once said
|
|
1590
1591
|
# (`follow_open_file`): the refusal is told once per file, not per call.
|
|
1591
1592
|
self._outside_file = None
|
|
1593
|
+
# BLENDER HOLDS ANOTHER FILE'S MODEL (`loaded`): a load that was not the session's own
|
|
1594
|
+
# open of its document -- `read_homefile`, `read_factory_settings`, `open_mainfile` of
|
|
1595
|
+
# some other file. Until Blender holds the document again, or a project file the session
|
|
1596
|
+
# follows, the model in memory is not the document's and is never saved over it.
|
|
1597
|
+
self._foreign = False
|
|
1598
|
+
self._foreign_said = False
|
|
1599
|
+
self._binding = False
|
|
1592
1600
|
# WHAT THE PRESENTER LAST REPORTED HOLDING, as an instrument: the
|
|
1593
1601
|
# `(session, revision)` it held BEFORE the last frame, None when it
|
|
1594
1602
|
# held nothing, and the string "unreported" for a presenter that does
|
|
@@ -1725,6 +1733,16 @@ class Session:
|
|
|
1725
1733
|
"metallic": float(material.metallic),
|
|
1726
1734
|
}
|
|
1727
1735
|
graph = depsgraph if depsgraph is not None else bpy.context.evaluated_depsgraph_get()
|
|
1736
|
+
# ONE NAME, TWO OBJECTS: Blender tells a linked object from a local one (or from another
|
|
1737
|
+
# file's) by its library as well as its name; the presenter knows them by name alone and
|
|
1738
|
+
# refuses the frame. Say which object and which files, so the clash can be renamed.
|
|
1739
|
+
names = [row["name"] for row in frame["objects"]]
|
|
1740
|
+
if len(set(names)) != len(names):
|
|
1741
|
+
for name in sorted({n for n in names if names.count(n) > 1}):
|
|
1742
|
+
files = [o.library.filepath if o.library is not None else "this file"
|
|
1743
|
+
for o in bpy.data.objects if o.name == name]
|
|
1744
|
+
warn("%r is the name of objects from %s; the editor needs one object per name, "
|
|
1745
|
+
"so rename one (in its own file when it is linked)" % (name, ", ".join(files)))
|
|
1728
1746
|
frame["instances"] = _depsgraph_placements(frame, graph)
|
|
1729
1747
|
frame["world"] = draw_world(scene)
|
|
1730
1748
|
frame["cameras"] = {
|
|
@@ -1982,7 +2000,7 @@ class Session:
|
|
|
1982
2000
|
`18-riverside-bridge`, `19-tram-stop` and `20-rigged-courier` each got
|
|
1983
2001
|
ZERO calls, refused inside `blender-start` -- `bind_document` opened
|
|
1984
2002
|
the document the previous model wrote, whose textures live OUTSIDE the
|
|
1985
|
-
project (
|
|
2003
|
+
project (another folder of the machine that wrote it) and
|
|
1986
2004
|
are in no fresh worker's filesystem.
|
|
1987
2005
|
|
|
1988
2006
|
THE PRESENTER'S REFUSAL IS RIGHT AND STAYS. It answers "the record says
|
|
@@ -2127,6 +2145,8 @@ class Session:
|
|
|
2127
2145
|
"""
|
|
2128
2146
|
self.document_relative = relative_path
|
|
2129
2147
|
self.document = os.path.join(self.project, relative_path)
|
|
2148
|
+
self._foreign = False
|
|
2149
|
+
self._foreign_said = False
|
|
2130
2150
|
if not os.path.exists(self.document):
|
|
2131
2151
|
return {"document": relative_path, "opened": False}
|
|
2132
2152
|
_asked({"checkpoint": "document-open"})
|
|
@@ -2134,9 +2154,15 @@ class Session:
|
|
|
2134
2154
|
checkpointed_read = hasattr(_blender_web, "set_read_checkpoint")
|
|
2135
2155
|
if checkpointed_read:
|
|
2136
2156
|
_blender_web.set_read_checkpoint(lambda: _asked({"checkpoint": "file-read"}))
|
|
2157
|
+
self._binding = True
|
|
2137
2158
|
try:
|
|
2138
2159
|
bpy.ops.wm.open_mainfile(filepath=self.document)
|
|
2160
|
+
except BaseException:
|
|
2161
|
+
# The document was not read, so what Blender holds is not its model.
|
|
2162
|
+
self._foreign = True
|
|
2163
|
+
raise
|
|
2139
2164
|
finally:
|
|
2165
|
+
self._binding = False
|
|
2140
2166
|
if checkpointed_read:
|
|
2141
2167
|
_blender_web.set_read_checkpoint(None)
|
|
2142
2168
|
_mark("open:after")
|
|
@@ -2156,6 +2182,18 @@ class Session:
|
|
|
2156
2182
|
"objects": len(bpy.data.objects),
|
|
2157
2183
|
"size": size}
|
|
2158
2184
|
|
|
2185
|
+
def loaded(self):
|
|
2186
|
+
"""A FILE WAS LOADED (`_load_post`). The session's own open of its document is the one
|
|
2187
|
+
load that leaves the document's model in memory; any other replaces it with another
|
|
2188
|
+
file's (`read_homefile` is Blender's File > New), and the session used to save that over
|
|
2189
|
+
the document: a script that ran `read_homefile` on its own, meaning to Save As a new
|
|
2190
|
+
piece next, emptied the open level on disk. MEASURED 2026-10-08 in the editor's Blender:
|
|
2191
|
+
a document with four objects was saved with none."""
|
|
2192
|
+
if self._binding:
|
|
2193
|
+
return
|
|
2194
|
+
self._foreign = True
|
|
2195
|
+
self._foreign_said = False
|
|
2196
|
+
|
|
2159
2197
|
def mark_changed(self):
|
|
2160
2198
|
"""A request that can change the model ran: the next present carries
|
|
2161
2199
|
`saveDue`, and the tab saves after an idle second."""
|
|
@@ -2190,11 +2228,14 @@ class Session:
|
|
|
2190
2228
|
holds a file outside the project: the editor has no document there, so
|
|
2191
2229
|
the session keeps its own and keeps saving to it.
|
|
2192
2230
|
"""
|
|
2193
|
-
if self.document is None
|
|
2231
|
+
if self.document is None:
|
|
2194
2232
|
return None
|
|
2233
|
+
if not bpy.data.filepath:
|
|
2234
|
+
return self._not_saving("a new, untitled file")
|
|
2195
2235
|
current = os.path.normpath(os.path.abspath(bpy.data.filepath))
|
|
2196
2236
|
if current == os.path.normpath(self.document):
|
|
2197
2237
|
self._outside_file = None
|
|
2238
|
+
self._foreign = False
|
|
2198
2239
|
return None
|
|
2199
2240
|
root = os.path.normpath(self.project)
|
|
2200
2241
|
relative = current[len(root) + 1:] if current.startswith(root + "/") else None
|
|
@@ -2205,15 +2246,30 @@ class Session:
|
|
|
2205
2246
|
self.document = current
|
|
2206
2247
|
self.document_relative = relative
|
|
2207
2248
|
self._outside_file = None
|
|
2249
|
+
self._foreign = False
|
|
2208
2250
|
# The file Blender wrote sits only in the engine's filesystem until the
|
|
2209
2251
|
# session's own save carries it to the project.
|
|
2210
2252
|
self.save_due = True
|
|
2211
2253
|
return {"moved": True, "from": previous, "document": relative}
|
|
2254
|
+
if self._foreign:
|
|
2255
|
+
return self._not_saving(current)
|
|
2212
2256
|
if current == self._outside_file:
|
|
2213
2257
|
return None
|
|
2214
2258
|
self._outside_file = current
|
|
2215
2259
|
return {"moved": False, "from": self.document_relative, "outside": current}
|
|
2216
2260
|
|
|
2261
|
+
def _not_saving(self, holds):
|
|
2262
|
+
"""The answer, once per load, that the document is no longer being saved."""
|
|
2263
|
+
if not self._foreign or self._foreign_said:
|
|
2264
|
+
return None
|
|
2265
|
+
self._foreign_said = True
|
|
2266
|
+
return {"moved": False, "from": self.document_relative, "notSaving": True,
|
|
2267
|
+
"note": "Blender now holds %s, not %s's model, so %s is no longer saved: "
|
|
2268
|
+
"save_as_mainfile(filepath=<project>/src/models/<name>.blend) makes this a "
|
|
2269
|
+
"document of its own, and opening %s again in the editor returns to it"
|
|
2270
|
+
% (holds, self.document_relative, self.document_relative,
|
|
2271
|
+
self.document_relative)}
|
|
2272
|
+
|
|
2217
2273
|
def save_document(self):
|
|
2218
2274
|
"""Write the document -- Blender's own format, by Blender's own operator.
|
|
2219
2275
|
|
|
@@ -2236,11 +2292,11 @@ class Session:
|
|
|
2236
2292
|
`relative_remap` is described as remapping RELATIVE paths so they stay
|
|
2237
2293
|
valid from a new location; on this build it also rewrites ABSOLUTE
|
|
2238
2294
|
ones. MEASURED 2026-09-18 in the tab, four cells, one image loaded from
|
|
2239
|
-
`/
|
|
2295
|
+
an absolute path outside the project, `/outside/wsh-outside/textures/wsh_abs.png`:
|
|
2240
2296
|
|
|
2241
2297
|
relative_remap use_relative_paths img.filepath after the save
|
|
2242
|
-
False True /
|
|
2243
|
-
False False /
|
|
2298
|
+
False True /outside/.../wsh_abs.png
|
|
2299
|
+
False False /outside/.../wsh_abs.png
|
|
2244
2300
|
True False //../../../wsh-outside/.../wsh_abs.png
|
|
2245
2301
|
True True //../../../wsh-outside/.../wsh_abs.png
|
|
2246
2302
|
|
|
@@ -2287,10 +2343,29 @@ class Session:
|
|
|
2287
2343
|
`BLENDER`). Blender reads either back, so nothing was broken; what was
|
|
2288
2344
|
wrong is that the BYTES of a file the project commits depended on a
|
|
2289
2345
|
per-machine preference. A document states its own format.
|
|
2346
|
+
|
|
2347
|
+
THE FORMAT IT STATES IS COMPRESSED (Zstandard, Blender's own). Measured
|
|
2348
|
+
2026-10-08 on a character with a 43-clip library (1.3 million keys): the
|
|
2349
|
+
uncompressed write was 101.9 MB, compressed 12.8 MB, and the save itself
|
|
2350
|
+
took the same time (0.07-0.17 s against 0.10-0.14 s). A game scene with
|
|
2351
|
+
that character was 120 MB uncompressed, past GitHub's 100 MB file limit,
|
|
2352
|
+
and 17 MB compressed. Blender reads both.
|
|
2353
|
+
|
|
2354
|
+
THE SAVE'S CHUNKED TRANSPORT READS THE BYTES (`document-chunks.mts`): it
|
|
2355
|
+
sends the server only the chunks it lacks, and that reuse was measured
|
|
2356
|
+
on uncompressed files. Measured again with compression, same character,
|
|
2357
|
+
one small edit per save after the first: uncompressed sent 4.19 MB (one
|
|
2358
|
+
chunk) in about 1.4 s end to end; compressed sent 2.31 MB in about
|
|
2359
|
+
0.26 s. Zstandard compresses in independent blocks, so an edit still
|
|
2360
|
+
changes part of the file, not all of it.
|
|
2290
2361
|
"""
|
|
2291
2362
|
self.save_due = False
|
|
2292
2363
|
if self.document is None:
|
|
2293
2364
|
return {"saved": False, "reason": "no-document"}
|
|
2365
|
+
if self._foreign:
|
|
2366
|
+
# Not the document's model (`loaded`): writing it would replace the file with another.
|
|
2367
|
+
return {"saved": False, "reason": "not-the-document", "document": self.document_relative,
|
|
2368
|
+
"holds": bpy.data.filepath or "a new, untitled file"}
|
|
2294
2369
|
# `bpy.data.is_dirty` IS NOT A PREDICATE HERE, and this is the measurement
|
|
2295
2370
|
# rather than a preference. Before native undo initialization it stayed
|
|
2296
2371
|
# False even after a script built fourteen objects; with explicit undo
|
|
@@ -2317,7 +2392,7 @@ class Session:
|
|
|
2317
2392
|
except OSError:
|
|
2318
2393
|
pass
|
|
2319
2394
|
bpy.ops.wm.save_as_mainfile(
|
|
2320
|
-
filepath=self.document, compress=
|
|
2395
|
+
filepath=self.document, compress=True, relative_remap=False, copy=True)
|
|
2321
2396
|
return {"saved": True, "path": self.document,
|
|
2322
2397
|
"document": self.document_relative,
|
|
2323
2398
|
"size": os.path.getsize(self.document),
|
|
@@ -749,7 +749,7 @@ export class BlenderRuntimeView {
|
|
|
749
749
|
* visibility belongs to the viewport's Helpers menu rather than to this
|
|
750
750
|
* presenter, and the door for that is the host's own — a document hands
|
|
751
751
|
* each group to its stage's `setHelper(kind, object)`
|
|
752
|
-
* (`@volter/
|
|
752
|
+
* (`@volter/sdk/host`, `EditorHostStage`), which marks it
|
|
753
753
|
* `editorHelper`, keeps it out of the hierarchy and the raycast, and turns
|
|
754
754
|
* it on and off with the kind's checkbox. `blender-runtime.document.tsx`
|
|
755
755
|
* is where they are handed over.
|
|
@@ -1804,7 +1804,11 @@ export class BlenderRuntimeView {
|
|
|
1804
1804
|
if (this.frame?.session === next.session && next.revision < this.frame.revision)
|
|
1805
1805
|
throw new Error('The runtime frame is older than the displayed model');
|
|
1806
1806
|
const ids = new Set(next.objects.map((obj) => obj.id));
|
|
1807
|
-
if (ids.size !== next.objects.length)
|
|
1807
|
+
if (ids.size !== next.objects.length) {
|
|
1808
|
+
const seen = new Set<string>();
|
|
1809
|
+
const twice = [...new Set(next.objects.map((obj) => obj.id).filter((id) => seen.has(id) || !seen.add(id)))];
|
|
1810
|
+
throw new Error(`Runtime object IDs must be unique; more than one object is named ${twice.map((id) => JSON.stringify(id)).join(', ')}`);
|
|
1811
|
+
}
|
|
1808
1812
|
const byId = new Map(next.objects.map((obj) => [obj.id, obj]));
|
|
1809
1813
|
for (const obj of next.objects) {
|
|
1810
1814
|
if (obj.mesh !== null && !next.meshes[obj.mesh])
|
package/browser/worker.ts
CHANGED
|
@@ -197,7 +197,7 @@ async function saveDocument(): Promise<void> {
|
|
|
197
197
|
ms[phase] = Math.round(now - mark);
|
|
198
198
|
mark = now;
|
|
199
199
|
};
|
|
200
|
-
let answer: { saved?: boolean; path?: string; size?: number };
|
|
200
|
+
let answer: { saved?: boolean; path?: string; size?: number; reason?: string; holds?: string };
|
|
201
201
|
try {
|
|
202
202
|
answer = (await engine.request({ op: 'save-document' })) as typeof answer;
|
|
203
203
|
lap('save');
|
|
@@ -206,6 +206,15 @@ async function saveDocument(): Promise<void> {
|
|
|
206
206
|
// is a named condition in the editor's console, not a debug line.
|
|
207
207
|
throw new Error(`The Blender document ${relative} could not be saved: ${describeThrown(error)}`);
|
|
208
208
|
}
|
|
209
|
+
// BLENDER HOLDS ANOTHER FILE'S MODEL (`session.py::loaded`), so the session wrote nothing over
|
|
210
|
+
// the document. Said each time an edit goes unsaved, where the person looks: the document on
|
|
211
|
+
// disk is the last one saved, and what they see now is not being kept.
|
|
212
|
+
if (answer?.saved === false && answer.reason === 'not-the-document') {
|
|
213
|
+
setDocumentDirty(false);
|
|
214
|
+
log('warn', `@@VOLTER-WARN ${relative} is not being saved: Blender holds ${answer.holds ?? 'another file'}, ` +
|
|
215
|
+
`not its model. Save As a .blend under src/models to keep this as a document of its own, or open ${relative} again to return to it.`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
209
218
|
if (!answer?.saved || typeof answer.path !== 'string')
|
|
210
219
|
throw new Error(`Blender did not save the document ${relative}`);
|
|
211
220
|
let bytes: Uint8Array;
|