@scenoco-three/editor 0.5.0 → 0.6.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/README.md +31 -93
- package/dist/cli/index.js +49 -115
- package/dist-app/assets/EffectComposer-lekj9UUq.js +1 -0
- package/dist-app/assets/GLTFLoader-C2lUhzim.js +1 -0
- package/dist-app/assets/OutputPass-DZtd342R.js +72 -0
- package/dist-app/assets/Pass-BP3b3hlf.js +1 -0
- package/dist-app/assets/RenderPass-BjVchlYg.js +1 -0
- package/dist-app/assets/ShaderPass-DMXLcV9X.js +24 -0
- package/dist-app/assets/SkeletonUtils-Cxy_owx9.js +1 -0
- package/dist-app/assets/index-AxOpglZ-.css +1 -0
- package/dist-app/assets/index-D-Iq7tix.js +4135 -0
- package/dist-app/assets/inter-CaiW00F8.js +1 -0
- package/dist-app/assets/registry-BOM35tSr.js +1433 -0
- package/dist-app/assets/worker-_Lh8Vt-i.js +1270 -0
- package/dist-app/examples/breakout/assets/geometry/ball.geometry.xml +1 -0
- package/dist-app/examples/breakout/assets/geometry/brick.geometry.xml +1 -0
- package/dist-app/examples/breakout/assets/geometry/floor-sensor.geometry.xml +1 -0
- package/dist-app/examples/breakout/assets/geometry/paddle.geometry.xml +1 -0
- package/dist-app/examples/breakout/assets/geometry/wall-side.geometry.xml +1 -0
- package/dist-app/examples/breakout/assets/geometry/wall-top.geometry.xml +1 -0
- package/dist-app/examples/breakout/assets/materials/ball.material.xml +1 -0
- package/dist-app/examples/breakout/assets/materials/brick-firm.material.xml +1 -0
- package/dist-app/examples/breakout/assets/materials/brick-hard.material.xml +1 -0
- package/dist-app/examples/breakout/assets/materials/brick-soft.material.xml +1 -0
- package/dist-app/examples/breakout/assets/materials/paddle.material.xml +10 -0
- package/dist-app/examples/breakout/assets/materials/wall.material.xml +1 -0
- package/dist-app/examples/breakout/assets/render/neon.render.xml +14 -0
- package/dist-app/examples/breakout/assets/scenes/arena.prefab.xml +68 -0
- package/dist-app/examples/breakout/assets/scenes/brick.prefab.xml +15 -0
- package/dist-app/examples/breakout/assets/scenes/hud.scene.xml +56 -0
- package/dist-app/examples/breakout/assets/scenes/level-1.scene.xml +36 -0
- package/dist-app/examples/breakout/assets/scenes/level-2.scene.xml +36 -0
- package/dist-app/examples/breakout/assets/scenes/level-3.scene.xml +36 -0
- package/dist-app/examples/castle/assets/geometry/capsule.geometry.xml +3 -0
- package/dist-app/examples/castle/assets/geometry/cone.geometry.xml +2 -0
- package/dist-app/examples/castle/assets/geometry/cube.geometry.xml +3 -0
- package/dist-app/examples/castle/assets/geometry/cylinder.geometry.xml +3 -0
- package/dist-app/examples/castle/assets/geometry/plane.geometry.xml +4 -0
- package/dist-app/examples/castle/assets/geometry/quad.geometry.xml +3 -0
- package/dist-app/examples/castle/assets/geometry/sphere.geometry.xml +2 -0
- package/dist-app/examples/castle/assets/materials/grass.material.xml +1 -0
- package/dist-app/examples/castle/assets/materials/roof.material.xml +1 -0
- package/dist-app/examples/castle/assets/materials/stone.material.xml +1 -0
- package/dist-app/examples/castle/assets/materials/wood.material.xml +1 -0
- package/dist-app/examples/castle/assets/scenes/main.scene.xml +25 -0
- package/dist-app/examples/collector/assets/geometry/capsule.geometry.xml +3 -0
- package/dist-app/examples/collector/assets/geometry/cone.geometry.xml +2 -0
- package/dist-app/examples/collector/assets/geometry/cube.geometry.xml +3 -0
- package/dist-app/examples/collector/assets/geometry/cylinder.geometry.xml +3 -0
- package/dist-app/examples/collector/assets/geometry/plane.geometry.xml +4 -0
- package/dist-app/examples/collector/assets/geometry/quad.geometry.xml +3 -0
- package/dist-app/examples/collector/assets/geometry/sphere.geometry.xml +2 -0
- package/dist-app/examples/collector/assets/materials/crate.material.xml +1 -0
- package/dist-app/examples/collector/assets/materials/depot.material.xml +1 -0
- package/dist-app/examples/collector/assets/materials/ground.material.xml +1 -0
- package/dist-app/examples/collector/assets/materials/player.material.xml +1 -0
- package/dist-app/examples/collector/assets/scenes/crate.prefab.xml +9 -0
- package/dist-app/examples/collector/assets/scenes/main.scene.xml +83 -0
- package/dist-app/examples/playable/assets/geometry/capsule.geometry.xml +3 -0
- package/dist-app/examples/playable/assets/geometry/cone.geometry.xml +2 -0
- package/dist-app/examples/playable/assets/geometry/cube.geometry.xml +3 -0
- package/dist-app/examples/playable/assets/geometry/cylinder.geometry.xml +3 -0
- package/dist-app/examples/playable/assets/geometry/plane.geometry.xml +4 -0
- package/dist-app/examples/playable/assets/geometry/quad.geometry.xml +3 -0
- package/dist-app/examples/playable/assets/geometry/sphere.geometry.xml +2 -0
- package/dist-app/examples/playable/assets/materials/ground.material.xml +1 -0
- package/dist-app/examples/playable/assets/materials/target.material.xml +2 -0
- package/dist-app/examples/playable/assets/scenes/main.scene.xml +53 -0
- package/dist-app/examples/playable/assets/scenes/target.prefab.xml +9 -0
- package/dist-app/examples.json +68 -0
- package/{app → dist-app}/index.html +2 -2
- package/dist-app/model.json +1 -0
- package/package.json +26 -30
- package/app/cameraRig.ts +0 -118
- package/app/commands.ts +0 -78
- package/app/desktopControls.ts +0 -47
- package/app/document.ts +0 -547
- package/app/editLog.ts +0 -127
- package/app/editor.css +0 -51
- package/app/gizmo.ts +0 -169
- package/app/main.ts +0 -543
- package/app/meta.ts +0 -24
- package/app/picking.ts +0 -47
- package/app/session.ts +0 -572
- package/app/touchControls.ts +0 -127
- package/app/ui-react/AddMemberPopup.tsx +0 -114
- package/app/ui-react/BottomLauncher.tsx +0 -15
- package/app/ui-react/EditorUI.tsx +0 -22
- package/app/ui-react/FilePicker.tsx +0 -80
- package/app/ui-react/HeadBtn.tsx +0 -21
- package/app/ui-react/InspectorSheet.tsx +0 -475
- package/app/ui-react/NodeListSheet.tsx +0 -56
- package/app/ui-react/TopBar.tsx +0 -137
- package/app/ui-react/Tree.tsx +0 -106
- package/app/ui-react/cx.ts +0 -4
- package/app/ui-react/fields.tsx +0 -407
- package/app/ui-react/format.ts +0 -30
- package/app/ui-react/mount.tsx +0 -31
- package/app/ui-react/store.ts +0 -80
- package/app/ui-react/types.ts +0 -88
- package/app/ui-react/ui.module.css +0 -250
- package/app/ui-react/useSize.ts +0 -20
- package/app/values.ts +0 -137
- package/app/vite-env.d.ts +0 -7
- package/dist/index.d.ts +0 -10
- package/dist/index.js +0 -10
- package/dist/mcp.d.ts +0 -32
- package/dist/mcp.js +0 -191
- package/dist/protocol.d.ts +0 -170
- package/dist/protocol.js +0 -22
- package/dist/server.d.ts +0 -124
- package/dist/server.js +0 -437
package/app/ui-react/Tree.tsx
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { useMemo } from 'react';
|
|
2
|
-
import { Tree as Arborist } from 'react-arborist';
|
|
3
|
-
import type { NodeRendererProps } from 'react-arborist';
|
|
4
|
-
import type { Object3D } from 'three';
|
|
5
|
-
import { ChevronDown, ChevronRight } from 'lucide-react';
|
|
6
|
-
import { getDeps, useUI } from './store.js';
|
|
7
|
-
import { isEditorOnly } from '../meta.js';
|
|
8
|
-
import { useSize } from './useSize.js';
|
|
9
|
-
import { cx } from './cx.js';
|
|
10
|
-
import styles from './ui.module.css';
|
|
11
|
-
|
|
12
|
-
/** One scene node projected into the tree (react-arborist reads `id`/`children`). */
|
|
13
|
-
interface TNode {
|
|
14
|
-
id: string;
|
|
15
|
-
name: string;
|
|
16
|
-
type: string;
|
|
17
|
-
obj: Object3D;
|
|
18
|
-
children: TNode[];
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* The element name the document wrote, not three.js's class.
|
|
23
|
-
*
|
|
24
|
-
* Every `@scenoco-three/ui` element is a `Mesh` subclass whose `.type` reads "Mesh", so a whole
|
|
25
|
-
* HUD would otherwise be an indistinguishable stack of them.
|
|
26
|
-
*/
|
|
27
|
-
function typeName(node: Object3D): string {
|
|
28
|
-
return getDeps().getDoc()?.tagNameOf(node) ?? node.type ?? node.constructor.name;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function build(obj: Object3D): TNode {
|
|
32
|
-
const children = obj.children.filter((c) => !isEditorOnly(c)).map(build);
|
|
33
|
-
const id = getDeps().getDoc()?.nodeAttr(obj, 'id');
|
|
34
|
-
return { id: obj.uuid, name: id ? `#${id}` : `(${typeName(obj)})`, type: typeName(obj), obj, children };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* The scene hierarchy as a react-arborist tree — touch-friendly rows, virtualized,
|
|
39
|
-
* with drag scaffolding ready for reparenting (wired to `onMove`, a no-op until the
|
|
40
|
-
* document gains structural edits). Selecting a row selects the node everywhere.
|
|
41
|
-
*/
|
|
42
|
-
export function Tree(): React.JSX.Element {
|
|
43
|
-
const rev = useUI((s) => s.rev);
|
|
44
|
-
const selected = useUI((s) => s.selected);
|
|
45
|
-
const select = useUI((s) => s.select);
|
|
46
|
-
const { getScene, reparent } = getDeps();
|
|
47
|
-
// Re-derive whenever the scene changes (rev bumps on open/edit/reload).
|
|
48
|
-
const data = useMemo(() => {
|
|
49
|
-
const scene = getScene();
|
|
50
|
-
return scene ? [build(scene)] : [];
|
|
51
|
-
}, [rev, getScene]);
|
|
52
|
-
const [ref, { width, height }] = useSize<HTMLDivElement>();
|
|
53
|
-
|
|
54
|
-
return (
|
|
55
|
-
<div ref={ref} className={styles.tree} style={{ height: '100%' }}>
|
|
56
|
-
<Arborist<TNode>
|
|
57
|
-
data={data}
|
|
58
|
-
openByDefault
|
|
59
|
-
width={width}
|
|
60
|
-
height={height}
|
|
61
|
-
indent={16}
|
|
62
|
-
rowHeight={30}
|
|
63
|
-
selection={selected?.uuid}
|
|
64
|
-
onSelect={(nodes) => {
|
|
65
|
-
const n = nodes[0];
|
|
66
|
-
if (n) select(n.data.obj);
|
|
67
|
-
}}
|
|
68
|
-
onMove={({ dragNodes, parentNode, index }) => {
|
|
69
|
-
// Reparent under the drop target (the scene root when dropped at top level),
|
|
70
|
-
// recorded as an undoable move + persisted comment-preservingly on save.
|
|
71
|
-
const obj = dragNodes[0]?.data.obj;
|
|
72
|
-
const root = parentNode?.data.obj ?? getScene();
|
|
73
|
-
if (obj && root) reparent(obj, root, index);
|
|
74
|
-
}}
|
|
75
|
-
>
|
|
76
|
-
{Node}
|
|
77
|
-
</Arborist>
|
|
78
|
-
</div>
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function Node({ node, style, dragHandle }: NodeRendererProps<TNode>): React.JSX.Element {
|
|
83
|
-
const selectedUuid = useUI((s) => s.selected)?.uuid;
|
|
84
|
-
const d = node.data;
|
|
85
|
-
const hasKids = d.children.length > 0;
|
|
86
|
-
return (
|
|
87
|
-
<div
|
|
88
|
-
style={style}
|
|
89
|
-
ref={dragHandle}
|
|
90
|
-
className={cx(styles.node, selectedUuid === node.id && styles.sel, node.willReceiveDrop && styles.drop)}
|
|
91
|
-
onClick={() => node.select()}
|
|
92
|
-
>
|
|
93
|
-
<span
|
|
94
|
-
className={styles.caret}
|
|
95
|
-
onClick={(e) => {
|
|
96
|
-
e.stopPropagation();
|
|
97
|
-
node.toggle();
|
|
98
|
-
}}
|
|
99
|
-
>
|
|
100
|
-
{hasKids ? node.isOpen ? <ChevronDown /> : <ChevronRight /> : null}
|
|
101
|
-
</span>
|
|
102
|
-
<span className={styles.nodeName}>{d.name}</span>
|
|
103
|
-
{d.name.startsWith('#') ? <span className={styles.nodeType}>{d.type}</span> : null}
|
|
104
|
-
</div>
|
|
105
|
-
);
|
|
106
|
-
}
|
package/app/ui-react/cx.ts
DELETED
package/app/ui-react/fields.tsx
DELETED
|
@@ -1,407 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import type { PointerEvent as ReactPointerEvent } from 'react';
|
|
3
|
-
import { Lock, X } from 'lucide-react';
|
|
4
|
-
import { fmtNum } from './format.js';
|
|
5
|
-
import { cx } from './cx.js';
|
|
6
|
-
import styles from './ui.module.css';
|
|
7
|
-
|
|
8
|
-
// React inspector field widgets (one per value shape). Each owns local editing
|
|
9
|
-
// state and commits through the caller's `commit` closure (which routes to the
|
|
10
|
-
// live SceneDocument). Ported from the old vanilla fields.ts, scrub logic intact.
|
|
11
|
-
|
|
12
|
-
type Commit = (value: number, opts?: { coalesce?: boolean }) => void;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Every row accepts the registry `description` of the thing it edits and shows it
|
|
16
|
-
* as the row's tooltip. Descriptions are mandatory on `@property`/`@node`/
|
|
17
|
-
* `@attachment`, so this surfaces documentation the codebase already guarantees.
|
|
18
|
-
*/
|
|
19
|
-
type Described<T> = T & {
|
|
20
|
-
description?: string;
|
|
21
|
-
/** The value is not written in the source — render it as a placeholder, not a value. */
|
|
22
|
-
unset?: boolean;
|
|
23
|
-
/** Present only for a clearable (source-written) attribute; renders the × button. */
|
|
24
|
-
onClear?: () => void;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/** Removes an attribute from the source. Only rendered when the value is actually written. */
|
|
28
|
-
function ClearBtn({ onClear }: { onClear: () => void }): React.JSX.Element {
|
|
29
|
-
return (
|
|
30
|
-
<button
|
|
31
|
-
type="button"
|
|
32
|
-
className={styles.clearBtn}
|
|
33
|
-
title="Clear — remove this attribute from the file"
|
|
34
|
-
aria-label="Clear value"
|
|
35
|
-
onClick={onClear}
|
|
36
|
-
>
|
|
37
|
-
<X />
|
|
38
|
-
</button>
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** A read-only label/value row (purely informational — no "disabled" affordance). */
|
|
43
|
-
export function ReadRow({ label, value, description, unset }: Described<{ label: string; value: string }>): React.JSX.Element {
|
|
44
|
-
return (
|
|
45
|
-
<div className={cx(styles.row, unset && styles.unsetRow)} title={description}>
|
|
46
|
-
<span className={styles.k}>{label}</span>
|
|
47
|
-
<span className={styles.v}>{value}</span>
|
|
48
|
-
</div>
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* A read-only row for a value that *would* be editable but is disabled — decorated
|
|
54
|
-
* with a lock and a `reason` tooltip so it reads as intentionally locked (prefab
|
|
55
|
-
* content, a `src`-loaded asset, or a value type the inspector can't edit yet)
|
|
56
|
-
* rather than a plain fact.
|
|
57
|
-
*/
|
|
58
|
-
export function LockRow({ label, value, reason, description, unset }: Described<{ label: string; value: string; reason: string }>): React.JSX.Element {
|
|
59
|
-
return (
|
|
60
|
-
<div className={cx(styles.row, styles.locked)} title={description ? `${description}\n\n${reason}` : reason}>
|
|
61
|
-
<span className={styles.k}>{label}</span>
|
|
62
|
-
<span className={styles.v}>
|
|
63
|
-
<Lock className={styles.lockIcon} aria-label={reason} />
|
|
64
|
-
<span className={styles.lockVal}>{value}</span>
|
|
65
|
-
</span>
|
|
66
|
-
</div>
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** A standalone scalar (no scrub handle). */
|
|
71
|
-
export function NumberRow({
|
|
72
|
-
label,
|
|
73
|
-
value,
|
|
74
|
-
commit,
|
|
75
|
-
int,
|
|
76
|
-
description,
|
|
77
|
-
unset,
|
|
78
|
-
onClear,
|
|
79
|
-
}: Described<{
|
|
80
|
-
label: string;
|
|
81
|
-
value: number;
|
|
82
|
-
/** `coalesce` folds a drag's many steps into one undo, as the vector rows do. */
|
|
83
|
-
commit: (v: number, opts?: { coalesce?: boolean }) => void;
|
|
84
|
-
int?: boolean;
|
|
85
|
-
}>): React.JSX.Element {
|
|
86
|
-
const [text, setText] = useState(() => fmtNum(value));
|
|
87
|
-
const focused = useRef(false);
|
|
88
|
-
useEffect(() => {
|
|
89
|
-
if (!focused.current) setText(fmtNum(value));
|
|
90
|
-
}, [value]);
|
|
91
|
-
const doCommit = (): void => {
|
|
92
|
-
if (text === fmtNum(value)) return; // untouched — never write on a bare focus/blur
|
|
93
|
-
const v = parseFloat(text);
|
|
94
|
-
if (Number.isFinite(v)) commit(int ? Math.round(v) : v);
|
|
95
|
-
else setText(fmtNum(value));
|
|
96
|
-
};
|
|
97
|
-
return (
|
|
98
|
-
<div className={cx(styles.row, unset && styles.unsetRow)} title={description}>
|
|
99
|
-
<span className={styles.k}>{label}</span>
|
|
100
|
-
<div className={styles.v}>
|
|
101
|
-
<input
|
|
102
|
-
type="number"
|
|
103
|
-
inputMode={int ? 'numeric' : 'decimal'}
|
|
104
|
-
className={cx(styles.input, styles.num)}
|
|
105
|
-
value={text}
|
|
106
|
-
onChange={(e) => setText(e.target.value)}
|
|
107
|
-
onFocus={() => (focused.current = true)}
|
|
108
|
-
onBlur={() => {
|
|
109
|
-
focused.current = false;
|
|
110
|
-
doCommit();
|
|
111
|
-
}}
|
|
112
|
-
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
|
|
113
|
-
/>
|
|
114
|
-
{onClear ? <ClearBtn onClear={onClear} /> : null}
|
|
115
|
-
</div>
|
|
116
|
-
</div>
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function TextRow({
|
|
121
|
-
label,
|
|
122
|
-
value,
|
|
123
|
-
commit,
|
|
124
|
-
description,
|
|
125
|
-
unset,
|
|
126
|
-
onClear,
|
|
127
|
-
}: Described<{
|
|
128
|
-
label: string;
|
|
129
|
-
value: string;
|
|
130
|
-
commit: (v: string) => void;
|
|
131
|
-
}>): React.JSX.Element {
|
|
132
|
-
const [text, setText] = useState(() => value);
|
|
133
|
-
const focused = useRef(false);
|
|
134
|
-
useEffect(() => {
|
|
135
|
-
if (!focused.current) setText(value);
|
|
136
|
-
}, [value]);
|
|
137
|
-
return (
|
|
138
|
-
<div className={cx(styles.row, unset && styles.unsetRow)} title={description}>
|
|
139
|
-
<span className={styles.k}>{label}</span>
|
|
140
|
-
<div className={styles.v}>
|
|
141
|
-
<input
|
|
142
|
-
type="text"
|
|
143
|
-
className={styles.input}
|
|
144
|
-
value={text}
|
|
145
|
-
onChange={(e) => setText(e.target.value)}
|
|
146
|
-
onFocus={() => (focused.current = true)}
|
|
147
|
-
onBlur={() => {
|
|
148
|
-
focused.current = false;
|
|
149
|
-
if (text !== value) commit(text); // untouched → no write
|
|
150
|
-
}}
|
|
151
|
-
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
|
|
152
|
-
/>
|
|
153
|
-
{onClear ? <ClearBtn onClear={onClear} /> : null}
|
|
154
|
-
</div>
|
|
155
|
-
</div>
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export function BoolRow({
|
|
160
|
-
label,
|
|
161
|
-
value,
|
|
162
|
-
commit,
|
|
163
|
-
description,
|
|
164
|
-
unset,
|
|
165
|
-
onClear,
|
|
166
|
-
}: Described<{
|
|
167
|
-
label: string;
|
|
168
|
-
value: boolean;
|
|
169
|
-
commit: (v: boolean) => void;
|
|
170
|
-
}>): React.JSX.Element {
|
|
171
|
-
return (
|
|
172
|
-
<div className={cx(styles.row, unset && styles.unsetRow)} title={description}>
|
|
173
|
-
<span className={styles.k}>{label}</span>
|
|
174
|
-
<div className={styles.v}>
|
|
175
|
-
<input type="checkbox" className={styles.toggle} checked={value} onChange={(e) => commit(e.target.checked)} />
|
|
176
|
-
{onClear ? <ClearBtn onClear={onClear} /> : null}
|
|
177
|
-
</div>
|
|
178
|
-
</div>
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** A dropdown of enum member names; maps the live native value ↔ its name. */
|
|
183
|
-
export function EnumRow({
|
|
184
|
-
label,
|
|
185
|
-
options,
|
|
186
|
-
value,
|
|
187
|
-
commit,
|
|
188
|
-
description,
|
|
189
|
-
unset,
|
|
190
|
-
onClear,
|
|
191
|
-
}: Described<{
|
|
192
|
-
label: string;
|
|
193
|
-
/** The member names, which are also the values a document writes. */
|
|
194
|
-
options: readonly string[];
|
|
195
|
-
value: string;
|
|
196
|
-
commit: (v: string) => void;
|
|
197
|
-
}>): React.JSX.Element {
|
|
198
|
-
const current = value;
|
|
199
|
-
return (
|
|
200
|
-
<div className={cx(styles.row, unset && styles.unsetRow)} title={description}>
|
|
201
|
-
<span className={styles.k}>{label}</span>
|
|
202
|
-
<div className={styles.v}>
|
|
203
|
-
<select
|
|
204
|
-
className={cx(styles.input, styles.select)}
|
|
205
|
-
value={current}
|
|
206
|
-
onChange={(e) => commit(e.target.value)}
|
|
207
|
-
>
|
|
208
|
-
{/* A blank first option is how a value gets cleared back to the default. */}
|
|
209
|
-
<option value="">—</option>
|
|
210
|
-
{options.map((n) => (
|
|
211
|
-
<option key={n} value={n}>
|
|
212
|
-
{n}
|
|
213
|
-
</option>
|
|
214
|
-
))}
|
|
215
|
-
</select>
|
|
216
|
-
{onClear ? <ClearBtn onClear={onClear} /> : null}
|
|
217
|
-
</div>
|
|
218
|
-
</div>
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
export function ColorRow({
|
|
223
|
-
label,
|
|
224
|
-
value,
|
|
225
|
-
commit,
|
|
226
|
-
description,
|
|
227
|
-
unset,
|
|
228
|
-
onClear,
|
|
229
|
-
}: Described<{
|
|
230
|
-
label: string;
|
|
231
|
-
value: number;
|
|
232
|
-
commit: (hex: number) => void;
|
|
233
|
-
}>): React.JSX.Element {
|
|
234
|
-
// Defensive: the inspector has no error boundary, so a bad value here would
|
|
235
|
-
// blank the entire panel rather than one row.
|
|
236
|
-
const hex24 = Number.isFinite(value) ? (value as number) & 0xffffff : 0;
|
|
237
|
-
const css = '#' + hex24.toString(16).padStart(6, '0');
|
|
238
|
-
const [hex, setHex] = useState(css);
|
|
239
|
-
const focused = useRef(false);
|
|
240
|
-
useEffect(() => {
|
|
241
|
-
if (!focused.current) setHex(css);
|
|
242
|
-
}, [css]);
|
|
243
|
-
return (
|
|
244
|
-
<div className={cx(styles.row, unset && styles.unsetRow)} title={description}>
|
|
245
|
-
<span className={styles.k}>{label}</span>
|
|
246
|
-
<div className={styles.v}>
|
|
247
|
-
<div className={styles.color}>
|
|
248
|
-
<input
|
|
249
|
-
type="color"
|
|
250
|
-
className={styles.swatch}
|
|
251
|
-
value={css}
|
|
252
|
-
onChange={(e) => {
|
|
253
|
-
setHex(e.target.value);
|
|
254
|
-
commit(parseInt(e.target.value.slice(1), 16));
|
|
255
|
-
}}
|
|
256
|
-
/>
|
|
257
|
-
<input
|
|
258
|
-
type="text"
|
|
259
|
-
className={cx(styles.input, styles.hex)}
|
|
260
|
-
value={hex}
|
|
261
|
-
onChange={(e) => setHex(e.target.value)}
|
|
262
|
-
onFocus={() => (focused.current = true)}
|
|
263
|
-
onBlur={() => {
|
|
264
|
-
focused.current = false;
|
|
265
|
-
if (hex === css) return; // untouched → no write
|
|
266
|
-
const v = parseInt(hex.replace(/^#/, ''), 16);
|
|
267
|
-
if (Number.isFinite(v)) commit(v);
|
|
268
|
-
else setHex(css);
|
|
269
|
-
}}
|
|
270
|
-
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
|
|
271
|
-
/>
|
|
272
|
-
</div>
|
|
273
|
-
{onClear ? <ClearBtn onClear={onClear} /> : null}
|
|
274
|
-
</div>
|
|
275
|
-
</div>
|
|
276
|
-
);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/** A multi-axis vector editor (x/y/z), each axis a scrub handle + number input. */
|
|
280
|
-
export function VecRow({
|
|
281
|
-
label,
|
|
282
|
-
value,
|
|
283
|
-
commit,
|
|
284
|
-
labels = ['x', 'y', 'z'],
|
|
285
|
-
description,
|
|
286
|
-
unset,
|
|
287
|
-
onClear,
|
|
288
|
-
}: Described<{
|
|
289
|
-
label: string;
|
|
290
|
-
value: number[];
|
|
291
|
-
commit: (v: number[], opts?: { coalesce?: boolean }) => void;
|
|
292
|
-
labels?: string[];
|
|
293
|
-
}>): React.JSX.Element {
|
|
294
|
-
return (
|
|
295
|
-
<div className={cx(styles.row, unset && styles.unsetRow)} title={description}>
|
|
296
|
-
<span className={styles.k}>{label}</span>
|
|
297
|
-
<div className={cx(styles.v, styles.axes)}>
|
|
298
|
-
{labels.map((axis, i) => (
|
|
299
|
-
<AxisField
|
|
300
|
-
key={axis}
|
|
301
|
-
label={axis}
|
|
302
|
-
value={value[i] ?? 0}
|
|
303
|
-
commit={(v, opts) => {
|
|
304
|
-
const next = value.slice();
|
|
305
|
-
next[i] = v;
|
|
306
|
-
commit(next, opts);
|
|
307
|
-
}}
|
|
308
|
-
/>
|
|
309
|
-
))}
|
|
310
|
-
{onClear ? <ClearBtn onClear={onClear} /> : null}
|
|
311
|
-
</div>
|
|
312
|
-
</div>
|
|
313
|
-
);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Euler editor — **degrees in, degrees out**, matching the authored DSL and the
|
|
318
|
-
* compiled model (`transform.eulerAngles`, `ValueType.Euler` props). Callers
|
|
319
|
-
* holding a live `THREE.Euler` (radians) convert at their own boundary, e.g. via
|
|
320
|
-
* `node.eulerAngles.toArray()` or `eulerDeg(...)`; this row never converts, so
|
|
321
|
-
* there is exactly one place per call site where units change.
|
|
322
|
-
*/
|
|
323
|
-
export function EulerRow({
|
|
324
|
-
label,
|
|
325
|
-
value,
|
|
326
|
-
commit,
|
|
327
|
-
description,
|
|
328
|
-
unset,
|
|
329
|
-
onClear,
|
|
330
|
-
}: Described<{
|
|
331
|
-
label: string;
|
|
332
|
-
value: number[]; // degrees
|
|
333
|
-
commit: (degrees: number[], opts?: { coalesce?: boolean }) => void;
|
|
334
|
-
}>): React.JSX.Element {
|
|
335
|
-
return <VecRow label={label} value={value} commit={commit} description={description} />;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/** One axis: a draggable letter (scrub) + a number input. */
|
|
339
|
-
function AxisField({ label, value, commit }: { label: string; value: number; commit: Commit }): React.JSX.Element {
|
|
340
|
-
const [text, setText] = useState(() => fmtNum(value));
|
|
341
|
-
const focused = useRef(false);
|
|
342
|
-
useEffect(() => {
|
|
343
|
-
if (!focused.current) setText(fmtNum(value));
|
|
344
|
-
}, [value]);
|
|
345
|
-
const doCommit = (): void => {
|
|
346
|
-
if (text === fmtNum(value)) return; // untouched → no write
|
|
347
|
-
const v = parseFloat(text);
|
|
348
|
-
if (Number.isFinite(v)) commit(v);
|
|
349
|
-
else setText(fmtNum(value));
|
|
350
|
-
};
|
|
351
|
-
const scrub = useScrub(
|
|
352
|
-
() => parseFloat(text) || 0,
|
|
353
|
-
(v) => {
|
|
354
|
-
setText(fmtNum(v));
|
|
355
|
-
commit(v, { coalesce: true });
|
|
356
|
-
},
|
|
357
|
-
);
|
|
358
|
-
return (
|
|
359
|
-
<div className={styles.axis}>
|
|
360
|
-
<span className={cx(styles.axisK, styles.scrub)} {...scrub}>
|
|
361
|
-
{label}
|
|
362
|
-
</span>
|
|
363
|
-
<input
|
|
364
|
-
type="number"
|
|
365
|
-
inputMode="decimal"
|
|
366
|
-
className={cx(styles.input, styles.num)}
|
|
367
|
-
value={text}
|
|
368
|
-
onChange={(e) => setText(e.target.value)}
|
|
369
|
-
onFocus={() => (focused.current = true)}
|
|
370
|
-
onBlur={() => {
|
|
371
|
-
focused.current = false;
|
|
372
|
-
doCommit();
|
|
373
|
-
}}
|
|
374
|
-
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
|
|
375
|
-
/>
|
|
376
|
-
</div>
|
|
377
|
-
);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/** Drag horizontally on an element to scrub a numeric value (pointer + touch). */
|
|
381
|
-
function useScrub(getValue: () => number, onScrub: (v: number) => void, step = 0.05) {
|
|
382
|
-
const start = useRef({ x: 0, v: 0 });
|
|
383
|
-
const active = useRef(false);
|
|
384
|
-
const end = (e: ReactPointerEvent): void => {
|
|
385
|
-
if (!active.current) return;
|
|
386
|
-
active.current = false;
|
|
387
|
-
try {
|
|
388
|
-
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
|
389
|
-
} catch {
|
|
390
|
-
/* capture already gone */
|
|
391
|
-
}
|
|
392
|
-
};
|
|
393
|
-
return {
|
|
394
|
-
onPointerDown: (e: ReactPointerEvent) => {
|
|
395
|
-
active.current = true;
|
|
396
|
-
start.current = { x: e.clientX, v: getValue() };
|
|
397
|
-
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
398
|
-
e.preventDefault();
|
|
399
|
-
},
|
|
400
|
-
onPointerMove: (e: ReactPointerEvent) => {
|
|
401
|
-
if (!active.current) return;
|
|
402
|
-
onScrub(start.current.v + (e.clientX - start.current.x) * step);
|
|
403
|
-
},
|
|
404
|
-
onPointerUp: end,
|
|
405
|
-
onPointerCancel: end,
|
|
406
|
-
};
|
|
407
|
-
}
|
package/app/ui-react/format.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import type { Color, Euler, Object3D, Vector2, Vector3 } from 'three';
|
|
2
|
-
import { round, toDeg } from '../meta.js';
|
|
3
|
-
|
|
4
|
-
// Pure value formatters for the inspector's read-only rows (ported unchanged from
|
|
5
|
-
// the old fields.ts). No DOM — just value → display string.
|
|
6
|
-
|
|
7
|
-
export function fmt(v: unknown): string {
|
|
8
|
-
if (v == null) return '—';
|
|
9
|
-
if (typeof v === 'number') return fmtNum(v);
|
|
10
|
-
if (typeof v === 'boolean' || typeof v === 'string') return String(v);
|
|
11
|
-
if (Array.isArray(v)) return `[${v.map(fmt).join(', ')}]`;
|
|
12
|
-
const o = v as Record<string, unknown> & {
|
|
13
|
-
isVector3?: boolean; isVector2?: boolean; isColor?: boolean; isEuler?: boolean; isObject3D?: boolean;
|
|
14
|
-
};
|
|
15
|
-
if (o.isVector3 || o.isVector2) return vec(o as unknown as Vector3);
|
|
16
|
-
if (o.isEuler) return euler(o as unknown as Euler);
|
|
17
|
-
if (o.isColor) return `#${(o as unknown as Color).getHexString()}`;
|
|
18
|
-
// A reference to another node or component: its class is the useful thing to show, since the
|
|
19
|
-
// document refers to it by an id the inspector already renders on its own row.
|
|
20
|
-
if (o.isObject3D) return `(${(v as Object3D).type})`;
|
|
21
|
-
if ('object3D' in o) return `(${(v as object).constructor.name})`;
|
|
22
|
-
return String(v);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export const vec = (v: Vector3 | Vector2): string =>
|
|
26
|
-
'z' in v ? `${round(v.x)} ${round(v.y)} ${round((v as Vector3).z)}` : `${round(v.x)} ${round(v.y)}`;
|
|
27
|
-
export const euler = (e: Euler): string => `${toDeg(e.x)} ${toDeg(e.y)} ${toDeg(e.z)}`;
|
|
28
|
-
/** A live `Euler` (radians) as the degrees triple the DSL and inspector use. */
|
|
29
|
-
export const eulerDeg = (e: Euler): number[] => [toDeg(e.x), toDeg(e.y), toDeg(e.z)];
|
|
30
|
-
export const fmtNum = (n: number): string => String(round(n));
|
package/app/ui-react/mount.tsx
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { createRoot } from 'react-dom/client';
|
|
2
|
-
import { EditorUI } from './EditorUI.js';
|
|
3
|
-
import { initStore, useUI } from './store.js';
|
|
4
|
-
import type { EditorDeps, EditorUIHandle } from './types.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Mount the React editor chrome into `#sc-ui` and return the tiny imperative
|
|
8
|
-
* surface `main.ts` drives — `refresh()` (re-read live engine/document state) and
|
|
9
|
-
* `select(node)` (a viewport pick). Mirrors the old `buildPanels`/`buildToolbar`
|
|
10
|
-
* handles so the host wiring stays minimal.
|
|
11
|
-
*/
|
|
12
|
-
export function mountEditorUI(deps: EditorDeps): EditorUIHandle {
|
|
13
|
-
initStore(deps);
|
|
14
|
-
|
|
15
|
-
const host = document.getElementById('sc-ui');
|
|
16
|
-
if (!host) throw new Error('#sc-ui root missing');
|
|
17
|
-
// No StrictMode: its dev double-mount makes react-arborist (react-dnd HTML5
|
|
18
|
-
// backend) throw "Cannot have two HTML5 backends at the same time".
|
|
19
|
-
createRoot(host).render(<EditorUI />);
|
|
20
|
-
|
|
21
|
-
// Track orientation so the store can enforce one-sheet-at-a-time on phones.
|
|
22
|
-
const mq = window.matchMedia('(orientation: portrait)');
|
|
23
|
-
const syncOrientation = (): void => useUI.getState().setPortrait(mq.matches);
|
|
24
|
-
syncOrientation();
|
|
25
|
-
mq.addEventListener('change', syncOrientation);
|
|
26
|
-
|
|
27
|
-
return {
|
|
28
|
-
refresh: () => useUI.getState().bump(),
|
|
29
|
-
select: (node) => useUI.getState().select(node),
|
|
30
|
-
};
|
|
31
|
-
}
|
package/app/ui-react/store.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { create } from 'zustand';
|
|
2
|
-
import type { Object3D } from 'three';
|
|
3
|
-
import type { EditorDeps } from './types.js';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The editor chrome's UI state. There's exactly one editor per page, so the store
|
|
7
|
-
* is a module singleton — `initStore` injects the host deps once (before the React
|
|
8
|
-
* tree renders) and the actions relay to them.
|
|
9
|
-
*
|
|
10
|
-
* `rev` is a monotonic counter bumped on every `refresh()`: components that read
|
|
11
|
-
* live engine/document values subscribe to it so an external change, an edit, or a
|
|
12
|
-
* playback transition re-renders them without threading the data through props.
|
|
13
|
-
*/
|
|
14
|
-
interface UIState {
|
|
15
|
-
selected: Object3D | null;
|
|
16
|
-
nodesOpen: boolean;
|
|
17
|
-
inspectorOpen: boolean;
|
|
18
|
-
/** Portrait forces the two sheets to be mutually exclusive (phones); landscape allows both. */
|
|
19
|
-
portrait: boolean;
|
|
20
|
-
/** The node the "add component" picker is open for, if any. */
|
|
21
|
-
adding: Object3D | null;
|
|
22
|
-
rev: number;
|
|
23
|
-
select: (node: Object3D | null) => void;
|
|
24
|
-
openNodes: () => void;
|
|
25
|
-
closeNodes: () => void;
|
|
26
|
-
openInspector: () => void;
|
|
27
|
-
closeInspector: () => void;
|
|
28
|
-
setPortrait: (portrait: boolean) => void;
|
|
29
|
-
openAdd: (node: Object3D) => void;
|
|
30
|
-
closeAdd: () => void;
|
|
31
|
-
bump: () => void;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
let deps: EditorDeps | undefined;
|
|
35
|
-
|
|
36
|
-
/** Inject the host deps. Called by `mountEditorUI` before the first render. */
|
|
37
|
-
export function initStore(d: EditorDeps): void {
|
|
38
|
-
deps = d;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** The host deps, once injected (actions run only after mount, so this is set). */
|
|
42
|
-
export function getDeps(): EditorDeps {
|
|
43
|
-
if (!deps) throw new Error('editor UI store used before initStore()');
|
|
44
|
-
return deps;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export const useUI = create<UIState>((set, get) => ({
|
|
48
|
-
selected: null,
|
|
49
|
-
nodesOpen: false,
|
|
50
|
-
inspectorOpen: false,
|
|
51
|
-
portrait: false,
|
|
52
|
-
adding: null,
|
|
53
|
-
rev: 0,
|
|
54
|
-
|
|
55
|
-
// `rev` moves with the selection because the toolbar's state is derived from it: the transform
|
|
56
|
-
// modes are only meaningful with something selected, and `handlers.state()` is read at render
|
|
57
|
-
// time rather than subscribed to. Without the bump, TopBar (which subscribes to `rev` alone)
|
|
58
|
-
// keeps the enablement it was last drawn with, and Move/Rotate/Scale stay grey over a node
|
|
59
|
-
// the gizmo is visibly attached to.
|
|
60
|
-
select: (node) => {
|
|
61
|
-
set((s) => ({ selected: node, rev: s.rev + 1 }));
|
|
62
|
-
getDeps().onSelect(node); // host highlights + frames it in the viewport
|
|
63
|
-
},
|
|
64
|
-
|
|
65
|
-
// Opening one sheet closes the other only in portrait (a phone can't show both);
|
|
66
|
-
// in landscape they coexist.
|
|
67
|
-
openNodes: () => set((s) => ({ nodesOpen: true, inspectorOpen: s.portrait ? false : s.inspectorOpen })),
|
|
68
|
-
closeNodes: () => set({ nodesOpen: false }),
|
|
69
|
-
openInspector: () => set((s) => ({ inspectorOpen: true, nodesOpen: s.portrait ? false : s.nodesOpen })),
|
|
70
|
-
closeInspector: () => set({ inspectorOpen: false }),
|
|
71
|
-
|
|
72
|
-
openAdd: (node) => set({ adding: node }),
|
|
73
|
-
closeAdd: () => set({ adding: null }),
|
|
74
|
-
|
|
75
|
-
// On rotating to portrait with both open, drop the node list (keep the inspector focused).
|
|
76
|
-
setPortrait: (portrait) =>
|
|
77
|
-
set((s) => ({ portrait, nodesOpen: portrait && s.inspectorOpen ? false : s.nodesOpen })),
|
|
78
|
-
|
|
79
|
-
bump: () => set({ rev: get().rev + 1 }),
|
|
80
|
-
}));
|