@volter/blender-engine 0.1.138 → 0.1.140

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.
@@ -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 (`/Volumes/PeakSSD/volter-work/blender-scene-battery/...`) and
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 or not bpy.data.filepath:
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
- `/Volumes/PeakSSD/volter-work/wsh-outside/textures/wsh_abs.png`:
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 /Volumes/.../wsh_abs.png
2243
- False False /Volumes/.../wsh_abs.png
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=False, relative_remap=False, copy=True)
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/editor-sdk/host`, `EditorHostStage`), which marks it
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) throw new Error('Runtime object IDs must be unique');
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;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/blender-engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "GPL-3.0-or-later",
5
- "version": "0.1.138",
5
+ "version": "0.1.140",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },