@scenoco-three/editor 0.4.0 → 0.5.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -17
  3. package/app/cameraRig.ts +73 -12
  4. package/app/commands.ts +5 -1
  5. package/app/desktopControls.ts +13 -10
  6. package/app/document.ts +474 -427
  7. package/app/editLog.ts +127 -0
  8. package/app/editor.css +51 -0
  9. package/app/gizmo.ts +100 -6
  10. package/app/index.html +6 -122
  11. package/app/main.ts +488 -304
  12. package/app/meta.ts +24 -0
  13. package/app/picking.ts +47 -0
  14. package/app/session.ts +476 -311
  15. package/app/touchControls.ts +85 -62
  16. package/app/ui-react/AddMemberPopup.tsx +114 -0
  17. package/app/ui-react/BottomLauncher.tsx +15 -0
  18. package/app/ui-react/EditorUI.tsx +22 -0
  19. package/app/ui-react/FilePicker.tsx +80 -0
  20. package/app/ui-react/HeadBtn.tsx +21 -0
  21. package/app/ui-react/InspectorSheet.tsx +475 -0
  22. package/app/ui-react/NodeListSheet.tsx +56 -0
  23. package/app/ui-react/TopBar.tsx +137 -0
  24. package/app/ui-react/Tree.tsx +106 -0
  25. package/app/ui-react/cx.ts +4 -0
  26. package/app/ui-react/fields.tsx +407 -0
  27. package/app/ui-react/format.ts +30 -0
  28. package/app/ui-react/mount.tsx +31 -0
  29. package/app/ui-react/store.ts +80 -0
  30. package/app/ui-react/types.ts +88 -0
  31. package/app/ui-react/ui.module.css +250 -0
  32. package/app/ui-react/useSize.ts +20 -0
  33. package/app/values.ts +137 -0
  34. package/dist/cli/index.js +93 -70
  35. package/dist/index.d.ts +10 -3
  36. package/dist/index.js +9 -5
  37. package/dist/mcp.d.ts +28 -12
  38. package/dist/mcp.js +172 -131
  39. package/dist/protocol.d.ts +74 -65
  40. package/dist/protocol.js +17 -4
  41. package/dist/server.d.ts +106 -39
  42. package/dist/server.js +386 -361
  43. package/package.json +25 -10
  44. package/app/filePicker.ts +0 -64
  45. package/app/panels.ts +0 -553
  46. package/app/toolbar.ts +0 -131
  47. package/app/wsClient.ts +0 -92
  48. package/dist/args.d.ts +0 -8
  49. package/dist/args.js +0 -28
@@ -2,34 +2,48 @@ import { Vector2 } from 'three';
2
2
  import type { CameraRig } from './cameraRig.js';
3
3
 
4
4
  /**
5
- * Touch navigation for mobile 3D editing (HypeHype-style):
6
- * • one finger drag → pan the camera up/down/left/right (like Q/E/A/D)
7
- * • two finger drag → rotate (look) with the two-finger movement
8
- * • two finger pinch → dolly forward / back
5
+ * Touch navigation, for editing a scene on a phone or tablet:
9
6
  *
10
- * Event-driven (touchmove drives the rig directly); coexists with the desktop
11
- * controls because both only ever call `rig` verbs.
7
+ * - **one finger** drags the scene — the camera moves opposite the finger, so whatever is under
8
+ * it stays under it
9
+ * - **two fingers** turn the view by their average movement, and pinch to dolly in and out
10
+ *
11
+ * One finger pans rather than orbits because the other half of the editor's one-finger vocabulary
12
+ * is already spoken for: a tap selects, and a drag on a gizmo handle moves the thing you selected.
13
+ * Turning is the gesture you reach for least often while placing something, so it gets the
14
+ * two-finger slot.
15
+ *
16
+ * Every motion is scaled by `focusDistance`, which is Unity's trick: a drag covers the same
17
+ * *screen* distance whether you're an arm's length from a crate or across the level, so the
18
+ * controls feel the same at every zoom.
19
+ *
20
+ * It shares the camera with the desktop controls by construction — both only ever call `rig`
21
+ * verbs, and the rig is what writes the transform.
12
22
  */
13
23
  export class TouchControls {
14
24
  private readonly points = new Map<number, Vector2>();
15
- private prevPinch = 0;
16
- // Per-pixel factors, multiplied by the focus distance (Unity-like): drag/dolly
17
- // feel the same on screen at any zoom — fine up close, fast when far away.
25
+ private previousPinch = 0;
26
+
27
+ /** Per-pixel rates, multiplied by the focus distance (except look, which is angular). */
18
28
  private readonly pan = 0.0016;
19
29
  private readonly dolly = 0.005;
20
30
  private readonly look = 0.005; // radians per pixel
31
+
21
32
  private detach: Array<() => void> = [];
22
33
 
23
- constructor(
24
- private readonly rig: CameraRig,
25
- canvas: HTMLElement,
26
- /** When true, a gizmo handle owns the gesture — leave the camera alone. */
27
- private readonly isBlocked: () => boolean = () => false,
28
- ) {
29
- const on = (type: string, fn: (e: TouchEvent) => void) => {
30
- const h = fn as EventListener;
31
- canvas.addEventListener(type, h, { passive: false });
32
- this.detach.push(() => canvas.removeEventListener(type, h));
34
+ private readonly rig: CameraRig;
35
+ /** True while a gizmo handle owns the gesture — the camera stays out of it. */
36
+ private readonly isBlocked: () => boolean;
37
+
38
+ constructor(rig: CameraRig, canvas: HTMLElement, isBlocked: () => boolean = () => false) {
39
+ this.rig = rig;
40
+ this.isBlocked = isBlocked;
41
+ const on = (type: string, handler: (event: TouchEvent) => void): void => {
42
+ const listener = handler as EventListener;
43
+ // Not passive: these gestures have to beat the browser's own pan/zoom, and it only
44
+ // honours `preventDefault` from a listener that said up front it might call it.
45
+ canvas.addEventListener(type, listener, { passive: false });
46
+ this.detach.push(() => canvas.removeEventListener(type, listener));
33
47
  };
34
48
  on('touchstart', this.onStart);
35
49
  on('touchmove', this.onMove);
@@ -37,59 +51,68 @@ export class TouchControls {
37
51
  on('touchcancel', this.onEnd);
38
52
  }
39
53
 
40
- private onStart = (e: TouchEvent): void => {
41
- e.preventDefault();
42
- for (const t of Array.from(e.changedTouches)) this.points.set(t.identifier, new Vector2(t.clientX, t.clientY));
43
- this.prevPinch = this.pinchDistance();
54
+ private onStart = (event: TouchEvent): void => {
55
+ for (const touch of Array.from(event.changedTouches)) {
56
+ this.points.set(touch.identifier, new Vector2(touch.clientX, touch.clientY));
57
+ }
58
+ this.previousPinch = this.pinchDistance();
44
59
  };
45
60
 
46
- private onMove = (e: TouchEvent): void => {
47
- // A single finger dragging a gizmo handle also emits touch events — yield it.
61
+ private onMove = (event: TouchEvent): void => {
62
+ // A finger dragging a gizmo handle emits touch events too — that gesture belongs to the
63
+ // gizmo, and the camera must hold still under it or the drag fights the view.
48
64
  if (this.points.size === 1 && this.isBlocked()) return;
49
- e.preventDefault();
50
- const k = this.rig.focusDistance;
65
+ event.preventDefault();
66
+ const scale = this.rig.focusDistance;
67
+
51
68
  if (this.points.size === 1) {
52
- // One finger → drag the scene: the camera moves *opposite* the finger so the
53
- // grabbed point follows it. Scaled by focus distance.
54
- for (const t of Array.from(e.touches)) {
55
- const prev = this.points.get(t.identifier);
56
- if (!prev) continue;
57
- this.rig.move(-(t.clientX - prev.x) * this.pan * k, (t.clientY - prev.y) * this.pan * k, 0);
58
- prev.set(t.clientX, t.clientY);
59
- }
60
- } else if (this.points.size >= 2) {
61
- // Two fingers → rotate by the average movement, dolly by the pinch change.
62
- let dx = 0;
63
- let dy = 0;
64
- let n = 0;
65
- for (const t of Array.from(e.touches)) {
66
- const prev = this.points.get(t.identifier);
67
- if (!prev) continue;
68
- dx += t.clientX - prev.x;
69
- dy += t.clientY - prev.y;
70
- prev.set(t.clientX, t.clientY);
71
- n++;
69
+ for (const touch of Array.from(event.touches)) {
70
+ const previous = this.points.get(touch.identifier);
71
+ if (!previous) continue;
72
+ this.rig.move(
73
+ -(touch.clientX - previous.x) * this.pan * scale,
74
+ (touch.clientY - previous.y) * this.pan * scale,
75
+ 0,
76
+ );
77
+ previous.set(touch.clientX, touch.clientY);
72
78
  }
73
- if (n > 0) this.rig.rotate((-dx / n) * this.look, (-dy / n) * this.look);
79
+ return;
80
+ }
74
81
 
75
- const pinch = this.pinchDistance();
76
- if (this.prevPinch > 0) {
77
- const forward = (pinch - this.prevPinch) * this.dolly * k;
78
- this.rig.move(0, 0, forward);
79
- this.rig.focusDistance = Math.max(0.1, this.rig.focusDistance - forward); // dollied closer → finer
80
- }
81
- this.prevPinch = pinch;
82
+ let dx = 0;
83
+ let dy = 0;
84
+ let moved = 0;
85
+ for (const touch of Array.from(event.touches)) {
86
+ const previous = this.points.get(touch.identifier);
87
+ if (!previous) continue;
88
+ dx += touch.clientX - previous.x;
89
+ dy += touch.clientY - previous.y;
90
+ previous.set(touch.clientX, touch.clientY);
91
+ moved++;
92
+ }
93
+ // The *average* of the two fingers, so a pinch — where they travel opposite ways — cancels
94
+ // out and dollies without also swinging the view around.
95
+ if (moved > 0) this.rig.rotate((-dx / moved) * this.look, (-dy / moved) * this.look);
96
+
97
+ const pinch = this.pinchDistance();
98
+ if (this.previousPinch > 0) {
99
+ const forward = (pinch - this.previousPinch) * this.dolly * scale;
100
+ this.rig.move(0, 0, forward);
101
+ // Closer in means finer control, the same way the wheel tunes fly speed on desktop.
102
+ this.rig.focusDistance = Math.max(0.1, this.rig.focusDistance - forward);
82
103
  }
104
+ this.previousPinch = pinch;
83
105
  };
84
106
 
85
- private onEnd = (e: TouchEvent): void => {
86
- for (const t of Array.from(e.changedTouches)) this.points.delete(t.identifier);
87
- this.prevPinch = this.pinchDistance();
107
+ private onEnd = (event: TouchEvent): void => {
108
+ for (const touch of Array.from(event.changedTouches)) this.points.delete(touch.identifier);
109
+ // Lifting one of two fingers must not read as a huge pinch on the next move.
110
+ this.previousPinch = this.pinchDistance();
88
111
  };
89
112
 
90
113
  private pinchDistance(): number {
91
- const pts = [...this.points.values()];
92
- return pts.length >= 2 ? pts[0]!.distanceTo(pts[1]!) : 0;
114
+ const [a, b] = [...this.points.values()];
115
+ return a && b ? a.distanceTo(b) : 0;
93
116
  }
94
117
 
95
118
  dispose(): void {
@@ -98,7 +121,7 @@ export class TouchControls {
98
121
  }
99
122
  }
100
123
 
101
- /** True on a touch-capable device. */
124
+ /** True on a device that can be touched — which is what decides fat gizmo handles. */
102
125
  export function isTouchDevice(): boolean {
103
- return typeof window !== 'undefined' && ('ontouchstart' in window || (navigator.maxTouchPoints ?? 0) > 0);
126
+ return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
104
127
  }
@@ -0,0 +1,114 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { X } from 'lucide-react';
3
+ import { getDeps, useUI } from './store.js';
4
+ import { cx } from './cx.js';
5
+ import styles from './ui.module.css';
6
+
7
+ /** One offerable tag with the registry description shown beside it. */
8
+ interface Candidate {
9
+ tag: string;
10
+ description: string;
11
+ }
12
+
13
+ /**
14
+ * The "add component" picker: a centred modal with a case-insensitive filter over name and
15
+ * description.
16
+ *
17
+ * What it offers is every component class the project declares, read from `ProjectModel` — which
18
+ * is read from the source. v1 asked a runtime registry that decorators populated, so a component
19
+ * only appeared here if it had been registered; a class is the registration now.
20
+ *
21
+ * There is no attachment half any more: a geometry and a material are assets a mesh points at,
22
+ * so they are attributes rather than members to add.
23
+ */
24
+ export function AddMemberPopup(): React.JSX.Element | null {
25
+ const adding = useUI((s) => s.adding);
26
+ const closeAdd = useUI((s) => s.closeAdd);
27
+ useUI((s) => s.rev);
28
+ const [query, setQuery] = useState('');
29
+ const input = useRef<HTMLInputElement>(null);
30
+
31
+ // Reopening for a different target starts from an empty filter.
32
+ useEffect(() => {
33
+ if (adding) {
34
+ setQuery('');
35
+ input.current?.focus();
36
+ }
37
+ }, [adding]);
38
+
39
+ const candidates = useMemo(() => (adding ? offerings() : []), [adding]);
40
+ if (!adding) return null;
41
+
42
+ const needle = query.trim().toLowerCase();
43
+ const matches = needle
44
+ ? candidates.filter((c) => c.tag.toLowerCase().includes(needle) || c.description.toLowerCase().includes(needle))
45
+ : candidates;
46
+
47
+ const { getDoc } = getDeps();
48
+ const add = (tag: string): void => {
49
+ getDoc()?.addComponent(adding, tag);
50
+ closeAdd();
51
+ useUI.getState().bump(); // re-read the inspector against the new member
52
+ };
53
+
54
+ const label = 'Add component';
55
+ return (
56
+ // Clicking the backdrop dismisses; clicks inside must not bubble to it.
57
+ <div className={styles.modalBackdrop} onClick={closeAdd}>
58
+ <div className={styles.modal} onClick={(e) => e.stopPropagation()}>
59
+ <div className={styles.modalHead}>
60
+ <span className={styles.title}>{label}</span>
61
+ <button type="button" className={styles.clearBtn} title="Close" aria-label="Close" onClick={closeAdd}>
62
+ <X />
63
+ </button>
64
+ </div>
65
+ <input
66
+ ref={input}
67
+ type="text"
68
+ className={cx(styles.input, styles.modalFilter)}
69
+ placeholder="Filter…"
70
+ value={query}
71
+ autoFocus
72
+ onChange={(e) => setQuery(e.target.value)}
73
+ onKeyDown={(e) => {
74
+ if (e.key === 'Escape') closeAdd();
75
+ // Enter commits the only remaining match — the fast path once you've typed.
76
+ if (e.key === 'Enter' && matches.length === 1) add(matches[0]!.tag);
77
+ }}
78
+ />
79
+ <div className={styles.modalList}>
80
+ {matches.length === 0 ? (
81
+ <div className={styles.empty}>
82
+ {candidates.length === 0
83
+ ? 'This project declares no components yet.'
84
+ : 'Nothing matches that filter.'}
85
+ </div>
86
+ ) : (
87
+ matches.map((c) => (
88
+ <button key={c.tag} type="button" className={styles.modalItem} onClick={() => add(c.tag)}>
89
+ <span className={styles.modalItemTag}>{c.tag}</span>
90
+ <span className={styles.modalItemDesc}>{c.description}</span>
91
+ </button>
92
+ ))
93
+ )}
94
+ </div>
95
+ </div>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ /** What this node may still receive, straight from the registry. */
101
+ /**
102
+ * Every component the project declares.
103
+ *
104
+ * A component with a `requiresNode` — `class Flicker extends Component<PointLight>` — is only
105
+ * offered on a node it can actually work on. Almost none declare one: like a MonoBehaviour on a
106
+ * GameObject, a component goes on anything, and the constraint exists for the rare one that
107
+ * reads a host-specific member.
108
+ */
109
+ function offerings(): Candidate[] {
110
+ const model = getDeps().getModel();
111
+ return (model?.components ?? [])
112
+ .map((c) => ({ tag: c.name, description: c.description ?? '' }))
113
+ .sort((a, b) => a.tag.localeCompare(b.tag));
114
+ }
@@ -0,0 +1,15 @@
1
+ import { ListTree } from 'lucide-react';
2
+ import { useUI } from './store.js';
3
+ import styles from './ui.module.css';
4
+
5
+ /** The small bottom-right button that opens the node list. Hidden while it's open. */
6
+ export function BottomLauncher(): React.JSX.Element | null {
7
+ const nodesOpen = useUI((s) => s.nodesOpen);
8
+ const openNodes = useUI((s) => s.openNodes);
9
+ if (nodesOpen) return null;
10
+ return (
11
+ <button className={styles.launcher} title="Node list" aria-label="Node list" onClick={openNodes}>
12
+ <ListTree />
13
+ </button>
14
+ );
15
+ }
@@ -0,0 +1,22 @@
1
+ import { TopBar } from './TopBar.js';
2
+ import { BottomLauncher } from './BottomLauncher.js';
3
+ import { NodeListSheet } from './NodeListSheet.js';
4
+ import { InspectorSheet } from './InspectorSheet.js';
5
+ import { AddMemberPopup } from './AddMemberPopup.js';
6
+ import { FilePicker } from './FilePicker.js';
7
+
8
+ /** The whole editor chrome: a responsive top bar, a bottom launcher, the two bottom sheets
9
+ * (node list + inspector), and the open dialog that stands in for all of it until a document
10
+ * is open. */
11
+ export function EditorUI(): React.JSX.Element {
12
+ return (
13
+ <>
14
+ <TopBar />
15
+ <BottomLauncher />
16
+ <NodeListSheet />
17
+ <InspectorSheet />
18
+ <AddMemberPopup />
19
+ <FilePicker />
20
+ </>
21
+ );
22
+ }
@@ -0,0 +1,80 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { getDeps, useUI } from './store.js';
3
+ import styles from './ui.module.css';
4
+
5
+ /** A document the picker offers — the scenes and prefabs the server found in the project. */
6
+ interface Pickable {
7
+ kind: string;
8
+ /** Project-relative path, which is what `open` takes. */
9
+ path: string;
10
+ /** File name without its kind suffix — `main` from `main.scene.xml`. */
11
+ stem: string;
12
+ }
13
+
14
+ /** Only these two are a thing to *open*: the rest are referenced by path from inside one. */
15
+ const OPENABLE = new Set(['scene', 'prefab']);
16
+
17
+ /**
18
+ * The start-up open dialog, shown whenever no document is open — which is what a bare
19
+ * `scenoco-editor` means.
20
+ *
21
+ * Opening the first scene it happened to find instead would be a guess, and a wrong one in any
22
+ * project with more than one scene: the editor would come up on something the person has to
23
+ * navigate away from, with no way to see what else there is.
24
+ */
25
+ export function FilePicker(): React.JSX.Element | null {
26
+ useUI((s) => s.rev); // an open (or a failed one) changes whether this shows at all
27
+ const { getDoc, listDocuments, openDocument } = getDeps();
28
+ const [files, setFiles] = useState<Pickable[] | null>(null);
29
+ const [failed, setFailed] = useState<string | null>(null);
30
+ const open = getDoc() !== undefined;
31
+
32
+ useEffect(() => {
33
+ if (open) return;
34
+ let live = true;
35
+ void listDocuments().then(
36
+ (all) => live && setFiles(all.filter((f) => OPENABLE.has(f.kind))),
37
+ () => live && setFiles([]),
38
+ );
39
+ return () => {
40
+ live = false;
41
+ };
42
+ }, [open, listDocuments]);
43
+
44
+ if (open) return null;
45
+
46
+ const pick = (file: Pickable): void => {
47
+ setFailed(null);
48
+ void openDocument(file.path).catch((error: unknown) =>
49
+ setFailed(error instanceof Error ? error.message : String(error)),
50
+ );
51
+ };
52
+
53
+ return (
54
+ <div className={styles.modalBackdrop}>
55
+ <div className={styles.modal}>
56
+ <div className={styles.modalHead}>
57
+ <span className={styles.title}>Open a document</span>
58
+ </div>
59
+ <div className={styles.modalList}>
60
+ {files === null ? (
61
+ <div className={styles.empty}>Scanning the project…</div>
62
+ ) : files.length === 0 ? (
63
+ <div className={styles.empty}>No .scene.xml or .prefab.xml documents in this project.</div>
64
+ ) : (
65
+ files.map((file) => (
66
+ <button key={file.path} type="button" className={styles.modalItem} onClick={() => pick(file)}>
67
+ <span className={styles.modalItemTag}>
68
+ {file.stem}
69
+ <span className={styles.pickKind}>{file.kind}</span>
70
+ </span>
71
+ <span className={styles.modalItemDesc}>{file.path}</span>
72
+ </button>
73
+ ))
74
+ )}
75
+ {failed !== null && <div className={styles.empty}>{failed}</div>}
76
+ </div>
77
+ </div>
78
+ </div>
79
+ );
80
+ }
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from 'react';
2
+ import styles from './ui.module.css';
3
+
4
+ /** An icon button in a sheet header (actions row / close). */
5
+ export function HeadBtn({
6
+ label,
7
+ onClick,
8
+ disabled,
9
+ children,
10
+ }: {
11
+ label: string;
12
+ onClick: () => void;
13
+ disabled?: boolean;
14
+ children: ReactNode;
15
+ }): React.JSX.Element {
16
+ return (
17
+ <button className={styles.headBtn} title={label} aria-label={label} onClick={onClick} disabled={disabled}>
18
+ {children}
19
+ </button>
20
+ );
21
+ }