@volter/editor-blender 0.1.151 → 0.1.153

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.
@@ -1,16 +1,16 @@
1
1
  import type { ModelDocumentPreview } from '../src/model-document-preview';
2
2
  import { modelOpeningErrorMessage } from '../src/model-opening-error';
3
- import { Button, StateSurface, fontMono, fontSizeVar, spaceVar, themeVars } from '@volter/sdk/widgets';
3
+ import { Button, EditorBanner, StateSurface, themeVars } from '@volter/sdk/widgets';
4
4
  import type { AuthoringAdapter, EditorNode } from '@volter/project/adapter';
5
5
  import { AssetEditorSubject } from '@volter/sdk/components/AssetEditorShell';
6
- import { useEffect, useMemo, useState } from 'react';
6
+ import { useMemo } from 'react';
7
7
 
8
- /** How long an open may run before the surface says it may have stalled and offers a retry. A
9
- * limited view's first open downloads the engine and the project, so this is generous. */
10
- const STALL_AFTER_MS = 180_000;
11
-
12
- /** A loading/error surface has no authoring stage and publishes no model. */
13
- export function BlenderModelOpening({ documentId, path, error, preview, retry, returnToPreview, preparingView = false, note = null }: {
8
+ /**
9
+ * A model that is not open yet: its empty frame while it opens (the previous model's last view,
10
+ * when there is one), and what failed once it has failed. Its wait is the document's
11
+ * (`blender-runtime.document.tsx`). It has no authoring stage and publishes no model.
12
+ */
13
+ export function BlenderModelOpening({ documentId, path, error, preview, retry, returnToPreview, preparingView = false }: {
14
14
  readonly documentId: string;
15
15
  readonly path: string;
16
16
  readonly error: string | null;
@@ -18,63 +18,37 @@ export function BlenderModelOpening({ documentId, path, error, preview, retry, r
18
18
  readonly retry: () => void;
19
19
  readonly returnToPreview: () => void;
20
20
  readonly preparingView?: boolean;
21
- /** What a slow open is waiting on — progress beside the spinner, not an error. */
22
- readonly note?: string | null;
23
21
  }) {
24
- const title = error ? 'Could not open model' : preparingView ? 'Preparing model view' : 'Opening model';
25
- // A stalled open offers the same retry as an error.
26
- const [startedAt] = useState(() => Date.now());
27
- const [now, setNow] = useState(() => Date.now());
28
- useEffect(() => {
29
- if (error) return;
30
- const timer = setInterval(() => setNow(Date.now()), 5_000);
31
- return () => clearInterval(timer);
32
- }, [error]);
33
- const waited = now - startedAt;
34
- const stalled = !error && waited >= STALL_AFTER_MS;
35
22
  // This document has not published an authoring stage yet. Its panels still
36
23
  // belong to it, rather than the empty project's "Declare a root" fallback.
37
24
  // A boundary row is informational; no old model objects remain editable.
25
+ const label = error ? 'Could not open model' : path;
38
26
  const adapter = useMemo<AuthoringAdapter>(() => {
39
- const node: EditorNode = { id: `opening:${documentId}`, label: title, secondaryLabel: path,
27
+ const node: EditorNode = { id: `opening:${documentId}`, label, ...(error ? { secondaryLabel: path } : {}),
40
28
  role: 'boundary', kind: 'object', parentId: null, childIds: [] };
41
29
  return { capabilities: { transform: false, inspectorFields: false, persist: false },
42
30
  hierarchy: { roots: () => [node], node: id => id === node.id ? node : null },
43
31
  subscribe: () => () => {} };
44
- }, [documentId, path, title]);
32
+ }, [documentId, error, label, path]);
45
33
  return (
46
34
  <div data-testid="blender-model-opening" aria-busy={!error}
47
- style={{ position: 'absolute', inset: 0, overflow: 'auto', background: themeVars.surface.panel, color: themeVars.content.primary }}>
48
- {!preparingView && <AssetEditorSubject key={title} documentId={documentId} type="Model" title={path}
49
- status={error ? modelOpeningErrorMessage(error) : title}
35
+ style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', background: themeVars.surface.panel }}>
36
+ {!preparingView && <AssetEditorSubject documentId={documentId} type="Model" title={path}
37
+ status={error ? modelOpeningErrorMessage(error) : undefined}
50
38
  selection={() => ({ adapter, nodeId: null })} />}
51
- {preview && <img src={preview.image} alt={`Last view of ${preview.path}`} draggable={false}
52
- style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'contain', pointerEvents: 'none' }} />}
53
- <div style={{ position: 'relative', minHeight: '100%', display: 'flex', flexDirection: 'column', justifyContent: preview ? 'flex-end' : 'center', alignItems: preview ? 'flex-start' : 'center', boxSizing: 'border-box', padding: spaceVar[8], gap: spaceVar[4] }}>
54
- {preview && <div style={{ padding: `${spaceVar[2]} ${spaceVar[4]}`, background: themeVars.surface.chrome, border: `1px solid ${themeVars.boundary.default}`, borderRadius: themeVars.shape.small, maxWidth: '100%', overflowWrap: 'anywhere' }}>
55
- Previous model: {preview.path} · Preview only
56
- </div>}
57
- <StateSurface tone={error ? 'error' : 'loading'}
58
- style={{ width: 'min(100%, 32rem)', background: themeVars.surface.chrome, border: `1px solid ${themeVars.boundary.default}`, borderRadius: themeVars.shape.small, overflowWrap: 'anywhere' }}
59
- title={<span style={{ fontSize: fontSizeVar.lg }}>{title}</span>}
60
- description={<>
61
- <div style={{ color: themeVars.content.primary, marginBottom: spaceVar[3] }}>{path}</div>
62
- {error ? modelOpeningErrorMessage(error) : preparingView ? 'The file is open. Preparing its viewport.' : preview ? 'The previous model stays visible while this file opens.' : 'Preparing Blender and opening this file. The model will appear here when it is ready.'}
63
- {/* Show the exact cause as selectable text where the person is looking. */}
64
- {stalled && <div data-testid="blender-model-opening-stalled" style={{ marginTop: spaceVar[3], color: themeVars.content.primary }}>
65
- Still opening after {Math.floor(waited / 60_000)} minutes. It may have stalled: retry opening, or reload the page.
66
- </div>}
67
- {!error && note && <div data-testid="blender-model-opening-note" style={{ marginTop: spaceVar[3], color: themeVars.content.muted, userSelect: 'text' }}>{note}</div>}
68
- {error && <div data-testid="blender-model-opening-detail"
69
- style={{ marginTop: spaceVar[3], fontFamily: fontMono, fontSize: fontSizeVar.sm, color: themeVars.content.muted, whiteSpace: 'pre-wrap', userSelect: 'text', textAlign: 'left' }}>
70
- {error}
71
- </div>}
72
- </>}
73
- action={error || stalled ? <div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: spaceVar[3] }}>
39
+ {preview && <EditorBanner tone="info">Previous model: {preview.path} · Preview only</EditorBanner>}
40
+ <div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
41
+ {preview && <img src={preview.image} alt={`Last view of ${preview.path}`} draggable={false}
42
+ style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'contain', pointerEvents: 'none' }} />}
43
+ {error && <StateSurface tone="error" fill data-testid="blender-model-opening-error"
44
+ title="Could not open model"
45
+ description={<>{path}<br />{modelOpeningErrorMessage(error)}</>}
46
+ detail={error}
47
+ action={<>
74
48
  {preview && <Button onClick={returnToPreview}>Return to previous model</Button>}
75
49
  <Button variant="secondary" onClick={retry}>Retry opening</Button>
76
- </div> : <progress aria-label={`Opening ${path}`} style={{ width: 'min(100%, 18rem)', accentColor: themeVars.accent.default }} />}
77
- />
50
+ </>}
51
+ />}
78
52
  </div>
79
53
  </div>
80
54
  );
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { BlenderNode, BlenderNodeTree } from '@volter/blender-engine/browser/rna';
4
4
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
5
+ import { StateSurface } from '@volter/sdk/widgets';
5
6
  import {
6
7
  blenderNodeTree,
7
8
  blenderRnaVersion,
@@ -1181,7 +1182,7 @@ function NodePanel({
1181
1182
  >
1182
1183
  <div style={{ opacity: 0.6, marginBottom: 'var(--volter-space-2)' }}>Node</div>
1183
1184
  {!node ? (
1184
- <div style={{ opacity: 0.5 }}>No node looked at.</div>
1185
+ <StateSurface inline title="No node looked at." />
1185
1186
  ) : (
1186
1187
  <>
1187
1188
  <Row label="Name" value={node.name} onRefuse={onRefuse} />
@@ -1,6 +1,6 @@
1
- /** The bottom area shows animation editors or the model Play tool’s Panel. PlayModeSwitch stays in the header of either view, and exists only with a Play tool and model. Each switch applies the mode’s area ratio; the person’s sash resize holds between switches. */
1
+ /** The bottom area shows the Play tool’s Panel, or below a model its animation editors. PlayModeSwitch stays in the header of either view, and exists only with a Play tool and a model in front. Each switch applies the mode’s area ratio; the person’s sash resize holds between switches. */
2
2
  import { type MouseEvent, type ReactNode, useSyncExternalStore } from 'react';
3
- import { modelPlayExtension, subscribeModelPlayExtension } from '../src/model-play-extension';
3
+ import { playTool, subscribePlayTool } from '../src/play-tool';
4
4
  import { blenderSkin } from './blender-runtime-skin';
5
5
  import { type ModelPlayMode, modelPlayMode, playModeVersion, servedModelDocument, setChosenPlayMode, subscribePlayMode } from '../src/play-mode';
6
6
  import { TIMELINE_CHROME, TIMELINE_THEME } from './blender-timeline-geometry';
@@ -8,7 +8,6 @@ import { TIMELINE_CHROME, TIMELINE_THEME } from './blender-timeline-geometry';
8
8
  /** Reserve 20% of the column for the Game panel; the person can resize its sash. */
9
9
  export const PLAY_PANEL_RATIO = 0.2;
10
10
 
11
- const extension = () => modelPlayExtension();
12
11
 
13
12
  /**
14
13
  * FLIP A DOCUMENT BETWEEN GAME AND ANIMATION. A running game keeps running when Animation is
@@ -76,7 +75,7 @@ export function PlayAreaButton({ label, testId, disabled, pressed, wide, onClick
76
75
  */
77
76
  export function PlayModeSwitch({ title }: { readonly title: string }) {
78
77
  useSyncExternalStore(subscribePlayMode, playModeVersion, playModeVersion);
79
- const found = useSyncExternalStore(subscribeModelPlayExtension, extension, () => null);
78
+ const found = useSyncExternalStore(subscribePlayTool, playTool, () => null);
80
79
  const documentId = servedModelDocument();
81
80
  if (found === null || documentId === null) return <span style={{ color: TIMELINE_CHROME.text }}>{title}</span>;
82
81
  const mode = modelPlayMode(documentId);
@@ -8,9 +8,12 @@ import {
8
8
  blenderRna,
9
9
  blenderRnaContext,
10
10
  blenderRnaSet,
11
+ blenderModelChanging,
12
+ blenderModelConflict,
11
13
  blenderSessionStarted,
12
14
  noteBlenderRnaChanged,
13
15
  subscribeBlenderRna,
16
+ subscribeBlenderStarting,
14
17
  } from '../host/blender-runtime-host';
15
18
  import { blenderOutlinerState } from './blender-outliner-model';
16
19
 
@@ -143,6 +146,8 @@ let contextRequest = 0;
143
146
  /** Views read at the current generation. A held view outside this set is
144
147
  * STALE: still shown, re-read on the next ask. */
145
148
  const fresh = new Set<string>();
149
+ /** Paths Blender answered with nothing at the current generation: it holds no model. */
150
+ const answeredNothing = new Set<string>();
146
151
 
147
152
  function publish(next: Partial<BlenderPropertiesState>, tabsMayHaveChanged = false): void {
148
153
  state = { ...state, ...next };
@@ -179,6 +184,7 @@ function onEngineMoved(): void {
179
184
  readGeneration += 1;
180
185
  inFlight.clear();
181
186
  fresh.clear();
187
+ answeredNothing.clear();
182
188
  if (subject !== null) scheduleContextRead(subject, true);
183
189
  else publish({});
184
190
  }
@@ -229,6 +235,7 @@ async function readContext(key: string, refresh = false): Promise<void> {
229
235
  readGeneration += 1;
230
236
  inFlight.clear();
231
237
  fresh.clear();
238
+ answeredNothing.clear();
232
239
  }
233
240
  const generation = readGeneration;
234
241
  const request = ++contextRequest;
@@ -247,6 +254,18 @@ async function readContext(key: string, refresh = false): Promise<void> {
247
254
  publish({ context, loading: false, error: null }, true);
248
255
  } catch (error) {
249
256
  if (generation !== readGeneration || request !== contextRequest) return;
257
+ if (blenderModelChanging()) {
258
+ // Refused while Blender changes its model (a switch): still reading, and read again once the
259
+ // change is over, when the tabs ask for their subject again.
260
+ readFor = null;
261
+ publish({ loading: true, error: null });
262
+ const stop = subscribeBlenderStarting(() => {
263
+ if (blenderModelChanging()) return;
264
+ stop();
265
+ publish({}, true);
266
+ });
267
+ return;
268
+ }
250
269
  publish({ context: null, loading: false, error: describe(error) }, true);
251
270
  }
252
271
  }
@@ -279,7 +298,11 @@ export function blenderRnaViewFor(path: string): BlenderRnaView | undefined {
279
298
  if (generation !== readGeneration) return;
280
299
  inFlight.delete(path);
281
300
  fresh.add(path);
282
- if (view === null) return;
301
+ if (view === null) {
302
+ answeredNothing.add(path);
303
+ publish({});
304
+ return;
305
+ }
283
306
  const views = new Map(state.views);
284
307
  views.set(path, view);
285
308
  publish({ views, error: null });
@@ -287,11 +310,30 @@ export function blenderRnaViewFor(path: string): BlenderRnaView | undefined {
287
310
  .catch((error: unknown) => {
288
311
  if (generation !== readGeneration) return;
289
312
  inFlight.delete(path);
313
+ // Refused while Blender changes its model (a switch): the read waits for it and is asked again
314
+ // once the change is over (`subscribeBlenderRnaAvailability`), rather than failing.
315
+ if (blenderModelChanging()) return;
290
316
  publish({ error: describe(error) });
291
317
  });
292
318
  return held;
293
319
  }
294
320
 
321
+ /** Why `path` cannot be read, in a person's words, or null while its read may still come: Blender is on another
322
+ * document than the editor shows, or holds no model, or answered nothing for it. While Blender starts or changes its
323
+ * model (a switch) the read waits. Its section says the reason rather than waiting on a read that will not come. */
324
+ export function blenderRnaUnavailable(path: string): string | null {
325
+ if (state.views.get(path) !== undefined || blenderModelChanging()) return null;
326
+ const conflict = blenderModelConflict();
327
+ if (conflict !== null) return conflict;
328
+ if (!blenderSessionStarted() || answeredNothing.has(path)) return `Blender holds no model, so ${path} cannot be read.`;
329
+ return null;
330
+ }
331
+
332
+ /** Hear when what {@link blenderRnaUnavailable} answers may have changed. */
333
+ export function subscribeBlenderRnaAvailability(listener: () => void): () => void {
334
+ return subscribeBlenderStarting(listener);
335
+ }
336
+
295
337
  /**
296
338
  * WRITE ONE PROPERTY. The engine refuses a read-only one by name and this
297
339
  * surfaces that refusal verbatim; a successful write presents, and the frame
@@ -309,6 +351,7 @@ export async function writeBlenderRnaProperty(
309
351
  // The write presented; a re-read of this one datablock closes the loop
310
352
  // even if the frame listener has not been taken yet.
311
353
  fresh.delete(path);
354
+ answeredNothing.delete(path);
312
355
  publish({ error: null });
313
356
  return true;
314
357
  } catch (error) {
@@ -8,12 +8,15 @@ import type {
8
8
  BlenderRnaView,
9
9
  } from '@volter/blender-engine/browser/rna';
10
10
  import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
11
+ import { Progress } from '@volter/sdk/progress';
11
12
  import { faLock, faLockOpen } from '@fortawesome/free-solid-svg-icons';
12
- import { ColorPicker, EditorIcon, hexToRgb, parseAlpha, toHex } from '@volter/sdk/widgets';
13
+ import { ColorPicker, EditorIcon, hexToRgb, parseAlpha, StateSurface, toHex } from '@volter/sdk/widgets';
13
14
  import {
14
15
  type BlenderSubject,
15
16
  blenderPropertiesState,
16
17
  blenderPropertiesVersion,
18
+ blenderRnaUnavailable,
19
+ subscribeBlenderRnaAvailability,
17
20
  blenderRnaViewFor,
18
21
  showBlenderSubject,
19
22
  subscribeBlenderProperties,
@@ -38,9 +41,6 @@ const SUB_PANEL_BACK = 'rgba(0,0,0,0.12)';
38
41
  const WIDGET_INNER = '#545454';
39
42
  const WIDGET_OUTLINE = '#3d3d3d';
40
43
  const WIDGET_ITEM = '#4772b3';
41
- /** `wcol_state.error` (`userdef_default_theme.c:234`) — the ink Blender puts
42
- * on a widget whose state is an error. The door's refusals are drawn in it. */
43
- const ERROR_INK = '#991616';
44
44
  /** The collapse triangle's optical size and the gap after it. Blender draws
45
45
  * the triangle as an icon inside `panel_label_offset`'s UI_UNIT_X lead-in
46
46
  * (`interface_panel.cc:1049-1060`) rather than as text; 8 is that mark's
@@ -66,6 +66,14 @@ export function useBlenderProperties() {
66
66
 
67
67
  // ---- Blender's panel -------------------------------------------------------
68
68
 
69
+ /** A datablock still being read: the Properties view's wait, or, when Blender holds no model to
70
+ * read it from, that failure said in its place. */
71
+ function Reading({ path }: { readonly path: string }) {
72
+ const unavailable = useSyncExternalStore(subscribeBlenderRnaAvailability, () => blenderRnaUnavailable(path), () => null);
73
+ if (unavailable !== null) return <StateSurface inline tone="error" title={unavailable} />;
74
+ return <Progress view="properties" title={"Reading " + path} />;
75
+ }
76
+
69
77
  function Panel({
70
78
  title,
71
79
  sub = false,
@@ -845,21 +853,19 @@ function CuratedCollectionPanel({
845
853
  if (view === undefined)
846
854
  return (
847
855
  <Panel title={panel.title} closed={panel.closed === true}>
848
- <div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT }}>Reading {path}…</div>
856
+ <Reading path={path} />
849
857
  </Panel>
850
858
  );
851
859
  if (view.kind !== 'collection')
852
860
  return (
853
861
  <Panel title={panel.title} closed={panel.closed === true} note={path}>
854
- <div style={{ color: ERROR_INK, fontSize: FONT }}>
855
- {path} is not a collection — this panel is declared `collection: true`.
856
- </div>
862
+ <StateSurface inline tone="error" title={<>{path} is not a collection — this panel is declared <code>collection: true</code>.</>} />
857
863
  </Panel>
858
864
  );
859
865
  return (
860
866
  <Panel title={panel.title} closed={panel.closed === true} note={path} count={view.count}>
861
867
  {view.items.length === 0 ? (
862
- <div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT }}>None</div>
868
+ <StateSurface inline title="None" />
863
869
  ) : null}
864
870
  {view.items.map((item, index) => (
865
871
  <Panel
@@ -870,9 +876,7 @@ function CuratedCollectionPanel({
870
876
  sub
871
877
  >
872
878
  {item.path === null ? (
873
- <div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT }}>
874
- {item.type} — RNA gives this member no address.
875
- </div>
879
+ <StateSurface inline title={<>{item.type} — RNA gives this member no address.</>} />
876
880
  ) : (
877
881
  <CuratedRows
878
882
  path={item.path}
@@ -903,9 +907,7 @@ function CuratedRows({
903
907
  useBlenderProperties();
904
908
  const view = blenderRnaViewFor(path);
905
909
  if (view === undefined)
906
- return (
907
- <div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT }}>Reading {path}…</div>
908
- );
910
+ return <Reading path={path} />;
909
911
  const rows = view.kind === 'struct' ? view.groups.flatMap((group) => group.rows) : [];
910
912
  const byIdentifier = new Map(rows.map((row) => [row.identifier, row]));
911
913
  // A collection member is addressed by its own path, so a qualified entry is
@@ -1024,9 +1026,7 @@ function CuratedPanel({
1024
1026
  if (view === undefined)
1025
1027
  return (
1026
1028
  <Panel title={panel.title} closed={panel.closed === true}>
1027
- <div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT }}>
1028
- Reading {entry.path}…
1029
- </div>
1029
+ <Reading path={entry.path} />
1030
1030
  </Panel>
1031
1031
  );
1032
1032
  if (found.length === 0 && sub.length === 0) return null;
@@ -1072,25 +1072,17 @@ function DatablockView({
1072
1072
  useBlenderProperties();
1073
1073
  const view: BlenderRnaView | undefined = blenderRnaViewFor(path);
1074
1074
  if (view === undefined)
1075
- return (
1076
- <div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT, padding: PANEL_PAD }}>
1077
- Reading {path}…
1078
- </div>
1079
- );
1075
+ return <Reading path={path} />;
1080
1076
  if (view.kind === 'collection')
1081
1077
  return (
1082
1078
  <>
1083
1079
  {view.items.length === 0 ? (
1084
- <div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT }}>
1085
- {view.count === 0 ? 'None' : `${view.count} items`}
1086
- </div>
1080
+ <StateSurface inline title={view.count === 0 ? 'None' : <>{view.count} items</>} />
1087
1081
  ) : null}
1088
1082
  {view.items.map((item) => (
1089
1083
  <Panel key={item.path ?? item.name ?? item.type} title={item.name ?? item.type} sub>
1090
1084
  {item.path === null ? (
1091
- <div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT }}>
1092
- {item.type} — RNA gives this member no address.
1093
- </div>
1085
+ <StateSurface inline title={<>{item.type} — RNA gives this member no address.</>} />
1094
1086
  ) : (
1095
1087
  <DatablockView path={item.path} onOpen={onOpen} />
1096
1088
  )}
@@ -1144,13 +1136,11 @@ export function BlenderPropertiesSection({
1144
1136
 
1145
1137
  if (state.error !== null)
1146
1138
  return (
1147
- <div style={{ color: ERROR_INK, fontSize: FONT, padding: PANEL_PAD }}>{state.error}</div>
1139
+ <StateSurface inline tone="error" title="Blender did not answer for these properties" detail={state.error} />
1148
1140
  );
1149
1141
  if (tab === null)
1150
- return (
1151
- <div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT, padding: PANEL_PAD }}>
1152
- {state.loading ? 'Reading the engine…' : 'Blender shows no properties here.'}
1153
- </div>
1142
+ return state.loading ? <Progress view="properties" title="Reading the engine" /> : (
1143
+ <StateSurface inline title="Blender shows no properties here." />
1154
1144
  );
1155
1145
 
1156
1146
  const open = (path: string) => setDrill((held) => [...held, path]);