@opsydyn/effect-three-foldkit 0.3.0 → 0.6.4

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 (52) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +4 -2
  3. package/dist/assetRegistry.d.ts +49 -0
  4. package/dist/assetRegistry.d.ts.map +1 -0
  5. package/dist/assets.d.ts +18 -5
  6. package/dist/assets.d.ts.map +1 -1
  7. package/dist/boxSelect.d.ts +150 -0
  8. package/dist/boxSelect.d.ts.map +1 -0
  9. package/dist/dom/canvas.d.ts +7 -0
  10. package/dist/dom/canvas.d.ts.map +1 -1
  11. package/dist/dom/pointer.d.ts.map +1 -1
  12. package/dist/dom/webgl.d.ts.map +1 -1
  13. package/dist/dom/webgpu.d.ts.map +1 -1
  14. package/dist/element.d.ts +60 -0
  15. package/dist/element.d.ts.map +1 -1
  16. package/dist/environment.d.ts +6 -0
  17. package/dist/environment.d.ts.map +1 -1
  18. package/dist/index.d.ts +7 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +745 -60
  21. package/dist/index.js.map +19 -12
  22. package/dist/inspector.d.ts +24 -0
  23. package/dist/inspector.d.ts.map +1 -0
  24. package/dist/measuringTape.d.ts +76 -0
  25. package/dist/measuringTape.d.ts.map +1 -0
  26. package/dist/mount.d.ts.map +1 -1
  27. package/dist/pivotControls.d.ts +106 -0
  28. package/dist/pivotControls.d.ts.map +1 -0
  29. package/dist/transformGizmo.d.ts +158 -0
  30. package/dist/transformGizmo.d.ts.map +1 -0
  31. package/dist/view.d.ts +23 -0
  32. package/dist/view.d.ts.map +1 -1
  33. package/dist/viewportGizmo.d.ts +146 -0
  34. package/dist/viewportGizmo.d.ts.map +1 -0
  35. package/package.json +8 -8
  36. package/src/assetRegistry.ts +218 -0
  37. package/src/assets.ts +96 -39
  38. package/src/boxSelect.ts +158 -0
  39. package/src/dom/canvas.ts +12 -0
  40. package/src/dom/pointer.ts +14 -1
  41. package/src/dom/webgl.ts +15 -0
  42. package/src/dom/webgpu.ts +15 -0
  43. package/src/element.ts +6 -0
  44. package/src/environment.ts +6 -1
  45. package/src/index.ts +10 -0
  46. package/src/inspector.ts +112 -0
  47. package/src/measuringTape.ts +124 -0
  48. package/src/mount.ts +24 -1
  49. package/src/pivotControls.ts +118 -0
  50. package/src/transformGizmo.ts +131 -0
  51. package/src/view.ts +28 -0
  52. package/src/viewportGizmo.ts +119 -0
package/src/dom/webgpu.ts CHANGED
@@ -177,6 +177,21 @@ export const webgpuRendererFactory = (
177
177
  scissored = true;
178
178
  renderer.render(scene, camera);
179
179
  },
180
+ renderOver: (scene, camera) => {
181
+ if (renderer.xr.isPresenting) return;
182
+ if (scissored) {
183
+ const { width, height } = renderer.getSize(drawingSize);
184
+ renderer.setScissorTest(false);
185
+ renderer.setViewport(0, 0, width, height);
186
+ scissored = false;
187
+ }
188
+ // As Drei's Hud: keep what is drawn, clear depth so the HUD is never hidden by it.
189
+ const autoClear = renderer.autoClear;
190
+ renderer.autoClear = false;
191
+ renderer.clearDepth();
192
+ renderer.render(scene, camera);
193
+ renderer.autoClear = autoClear;
194
+ },
180
195
  createTarget: (width, height) => createTarget(targets, width, height),
181
196
  createEnvironment: () => createRoom(renderer),
182
197
  configure: (settings) => {
package/src/element.ts CHANGED
@@ -59,9 +59,11 @@ export const sceneElement = CustomElement.define({
59
59
  'scene-drag': SceneSchema.DragDetail,
60
60
  'scene-drag-end': SceneSchema.DragDetail,
61
61
  'scene-gesture': SceneSchema.GestureDetail,
62
+ 'scene-gesture-end': SceneSchema.GestureEndDetail,
62
63
  'scene-auto-rotated': SceneSchema.AutoRotatedDetail,
63
64
  'scene-collision': SceneSchema.CollisionDetail,
64
65
  'scene-contact-force': SceneSchema.ContactForceDetail,
66
+ 'scene-physics-reached': SceneSchema.PhysicsReachedDetail,
65
67
  'scene-measured': SceneSchema.MeasuredDetail,
66
68
  'scene-label-measured': SceneSchema.LabelMeasuredDetail,
67
69
  'scene-xr': XrSessionDetail,
@@ -73,6 +75,8 @@ export const sceneElement = CustomElement.define({
73
75
  'scene-xr-meshes': SceneSchema.XrMeshesDetail,
74
76
  'scene-performance': SceneSchema.PerformanceDetail,
75
77
  'scene-error': Schema.Struct({ error: SceneError }),
78
+ 'scene-asset-loaded': SceneSchema.AssetLoadedDetail,
79
+ 'scene-asset-failed': SceneSchema.AssetFailedDetail,
76
80
  'scene-reconciled': ReconciledDetail,
77
81
  },
78
82
  });
@@ -92,9 +96,11 @@ export const domEventName = {
92
96
  Drag: 'scene-drag',
93
97
  DragEnd: 'scene-drag-end',
94
98
  Gesture: 'scene-gesture',
99
+ GestureEnd: 'scene-gesture-end',
95
100
  AutoRotated: 'scene-auto-rotated',
96
101
  Collision: 'scene-collision',
97
102
  ContactForce: 'scene-contact-force',
103
+ PhysicsReached: 'scene-physics-reached',
98
104
  Measured: 'scene-measured',
99
105
  LabelMeasured: 'scene-label-measured',
100
106
  XrStick: 'scene-xr-stick',
@@ -13,7 +13,8 @@ import {
13
13
  type ResourceStore,
14
14
  } from '@opsydyn/effect-three';
15
15
 
16
- import { makeAssets } from './assets.ts';
16
+ import { makeAssetRegistry } from './assetRegistry.ts';
17
+ import { assetLoader, makeAssets } from './assets.ts';
17
18
  import { animationFrameScheduler, type AnimationFrameHost } from './dom/animationFrames.ts';
18
19
  import { defaultPixelRatio, type PixelRatio } from './dom/viewport.ts';
19
20
  import { webglRendererFactory } from './dom/webgl.ts';
@@ -49,6 +50,7 @@ export type EnvironmentOptions = {
49
50
  export const makeEnvironment = (host: EnvironmentHost, options: EnvironmentOptions = {}) => {
50
51
  const store: ResourceStore = createResourceStore();
51
52
  const assets = makeAssets(store);
53
+ const assetRegistry = makeAssetRegistry(assetLoader(store));
52
54
  const xrHub = createXrHub(() => host.navigator?.xr);
53
55
  return {
54
56
  host,
@@ -63,12 +65,15 @@ export const makeEnvironment = (host: EnvironmentHost, options: EnvironmentOptio
63
65
  labels: options.labels,
64
66
  /** Loads textures and glTF models by URL and lends them to the store. */
65
67
  assets,
68
+ /** The assets scenes ask for in their descriptions: loaded, shared, cancelled, freed. */
69
+ assetRegistry,
66
70
  /** Enters and leaves immersive (WebXR) sessions, from Commands. */
67
71
  xr: makeXr(xrHub),
68
72
  /** The page's presentable scenes and its session; scene mounts register here. */
69
73
  xrHub,
70
74
  /** Disposes every loaded asset and every GPU resource still held. */
71
75
  dispose: () => {
76
+ assetRegistry.dispose();
72
77
  assets.dispose();
73
78
  store.dispose();
74
79
  },
package/src/index.ts CHANGED
@@ -44,21 +44,31 @@ export {
44
44
  type EnvironmentOptions,
45
45
  makeEnvironment,
46
46
  } from './environment.ts';
47
+ export { inspectorView, type OnEdit } from './inspector.ts';
47
48
  export { makeSceneMount, type SceneMount } from './mount.ts';
49
+ export * as BoxSelect from './boxSelect.ts';
50
+ export * as MeasuringTape from './measuringTape.ts';
51
+ export * as PivotControls from './pivotControls.ts';
52
+ export * as TransformGizmo from './transformGizmo.ts';
53
+ export * as ViewportGizmo from './viewportGizmo.ts';
48
54
  export { type OverlayView } from './overlayView.ts';
49
55
  export {
56
+ type AssetFailedDetail,
57
+ type AssetLoadedDetail,
50
58
  type AutoRotatedDetail,
51
59
  type ButtonDetail,
52
60
  type CollisionDetail,
53
61
  type ContactForceDetail,
54
62
  type DragDetail,
55
63
  type Gesture,
64
+ type GestureEndDetail,
56
65
  type HitDetail,
57
66
  type KeyDetail,
58
67
  type LabelMeasuredDetail,
59
68
  type MeasuredDetail,
60
69
  type MissDetail,
61
70
  type PerformanceDetail,
71
+ type PhysicsReachedDetail,
62
72
  view,
63
73
  type ViewConfig,
64
74
  type WheelDetail,
@@ -0,0 +1,112 @@
1
+ /**
2
+ * An inspector panel for Foldkit views (roadmap, "Beyond parity" 4): the fields the
3
+ * core's `Inspector.fields` reads from a schema, drawn as a form. Each control reports
4
+ * an edit as the app's own Message, with the field's path and the new value; the app
5
+ * applies it with `Inspector.edit`, which the schema checks, and keeps the result (or
6
+ * shows why it was refused).
7
+ *
8
+ * Numbers, text and choices report when they change (on Enter or leaving the field),
9
+ * colours as they are picked, switches when clicked, and each of a vector's three
10
+ * numbers on its own. Kinds, absent parts and lists are shown, not edited.
11
+ *
12
+ * @module
13
+ */
14
+ import type { FieldPath, FieldValue, InspectedField } from '@opsydyn/effect-three';
15
+ import type { Html, HtmlBuilder } from 'foldkit/html';
16
+
17
+ /** Turns an edit (where, and the new value) into the app's Message. */
18
+ export type OnEdit<Message> = (path: FieldPath, value: FieldValue) => Message;
19
+
20
+ const axisNames = ['x', 'y', 'z'] as const;
21
+
22
+ /** The field's name for people: its property names, without list indices. */
23
+ const nameOf = (field: InspectedField): string =>
24
+ field.path.filter((part) => typeof part === 'string').join(' ') || field.label;
25
+
26
+ const hex = (colour: number): string => `#${colour.toString(16).padStart(6, '0')}`;
27
+
28
+ const numberInput = <Message>(
29
+ h: HtmlBuilder<Message>,
30
+ label: string,
31
+ value: number,
32
+ report: (raw: string) => Message,
33
+ ): Html =>
34
+ h.input([
35
+ h.Type('number'),
36
+ h.Step('any'),
37
+ h.Value(String(value)),
38
+ h.AriaLabel(label),
39
+ h.OnChange(report),
40
+ ]);
41
+
42
+ const controlOf = <Message>(
43
+ h: HtmlBuilder<Message>,
44
+ field: InspectedField,
45
+ onEdit: OnEdit<Message>,
46
+ ): Html => {
47
+ const name = nameOf(field);
48
+ switch (field._tag) {
49
+ case 'Number':
50
+ return numberInput(h, name, field.value, (raw) => onEdit(field.path, Number(raw)));
51
+ case 'Text':
52
+ return h.input([
53
+ h.Type('text'),
54
+ h.Value(field.value),
55
+ h.AriaLabel(name),
56
+ h.OnChange((raw) => onEdit(field.path, raw)),
57
+ ]);
58
+ case 'Toggle':
59
+ return h.input([
60
+ h.Type('checkbox'),
61
+ h.Checked(field.value),
62
+ h.AriaLabel(name),
63
+ h.OnClick(onEdit(field.path, !field.value)),
64
+ ]);
65
+ case 'Choice':
66
+ return h.select(
67
+ [h.AriaLabel(name), h.OnChange((raw) => onEdit(field.path, raw))],
68
+ field.options.map((option) =>
69
+ h.option([h.Value(option), h.Selected(option === field.value)], [option]),
70
+ ),
71
+ );
72
+ case 'Colour':
73
+ return h.input([
74
+ h.Type('color'),
75
+ h.Value(hex(field.value)),
76
+ h.AriaLabel(name),
77
+ h.OnInput((raw) => onEdit(field.path, Number.parseInt(raw.slice(1), 16))),
78
+ ]);
79
+ case 'Vector':
80
+ return h.span(
81
+ [h.Class('e3-inspector-vector')],
82
+ field.value.map((component, index) =>
83
+ numberInput(h, `${name} ${axisNames[index] ?? index}`, component, (raw) =>
84
+ onEdit([...field.path, index], Number(raw)),
85
+ ),
86
+ ),
87
+ );
88
+ case 'Fixed':
89
+ return h.span([h.Class('e3-inspector-fixed')], [field.text]);
90
+ }
91
+ };
92
+
93
+ /**
94
+ * The fields as a form: a row each, its name and its control, keyed by path. Style it
95
+ * with the classes `e3-inspector`, `e3-inspector-row`, `e3-inspector-name`,
96
+ * `e3-inspector-vector` and `e3-inspector-fixed`.
97
+ */
98
+ export const inspectorView = <Message>(
99
+ h: HtmlBuilder<Message>,
100
+ fields: ReadonlyArray<InspectedField>,
101
+ onEdit: OnEdit<Message>,
102
+ ): Html =>
103
+ h.div(
104
+ [h.Class('e3-inspector')],
105
+ fields.map((field) =>
106
+ h.keyed('label')(
107
+ field.path.join('.'),
108
+ [h.Class('e3-inspector-row')],
109
+ [h.span([h.Class('e3-inspector-name')], [nameOf(field)]), controlOf(h, field, onEdit)],
110
+ ),
111
+ ),
112
+ );
@@ -0,0 +1,124 @@
1
+ /**
2
+ * A measuring tape as a Foldkit submodel (roadmap, "Beyond parity" 5): the core's pure
3
+ * `tape` with Messages for clicks and pointer moves on the scene, an OutMessage when a
4
+ * length is measured, and its length as HTML at the tape's middle. The parent decides
5
+ * when the tape is in use and which clicks are on the scene:
6
+ *
7
+ * - `Placed` (a click on the scene, with the point it hit) places an end, or starts a
8
+ * new tape; placing the second reports `Measured { from, to, length }`;
9
+ * - `Pointed` (the pointer moving over the scene, or `null` off it) runs the tape to
10
+ * the pointer while the second end is placed;
11
+ * - `Cleared` empties it; `ChoseSnap` rounds new ends to a step, or not.
12
+ *
13
+ * `nodes(model)` draws it for the scene and `label(model, h)` is the length for the
14
+ * view's `overlays`.
15
+ *
16
+ * @module
17
+ */
18
+ import {
19
+ clearTape,
20
+ overlay,
21
+ placeTapeEnd,
22
+ pointTape,
23
+ SceneSchema,
24
+ type SceneNode,
25
+ tape,
26
+ tapeLength,
27
+ type TapeLook,
28
+ tapeMiddle,
29
+ tapeNodes,
30
+ } from '@opsydyn/effect-three';
31
+ import { Option, Schema } from 'effect';
32
+ import type { HtmlBuilder } from 'foldkit/html';
33
+ import { defineMessageUnion } from 'foldkit/message';
34
+ import type { ReturnWithOutMessage } from 'foldkit/update';
35
+
36
+ import type { OverlayView } from './overlayView.ts';
37
+
38
+ export const Model = Schema.Struct({
39
+ /** The key its nodes and label are drawn under. */
40
+ key: Schema.String,
41
+ tape: SceneSchema.Tape,
42
+ });
43
+ export type Model = typeof Model.Type;
44
+
45
+ export const Message = defineMessageUnion({
46
+ Placed: { point: SceneSchema.Vec3 },
47
+ Pointed: { point: Schema.NullOr(SceneSchema.Vec3) },
48
+ Cleared: {},
49
+ ChoseSnap: { snap: SceneSchema.Tape.fields.snap },
50
+ });
51
+ export type Message = typeof Message.Type;
52
+
53
+ export const OutMessage = defineMessageUnion({
54
+ /** Both ends are placed: where, and how far apart. */
55
+ Measured: { from: SceneSchema.Vec3, to: SceneSchema.Vec3, length: Schema.Finite },
56
+ });
57
+ export type OutMessage = typeof OutMessage.Type;
58
+
59
+ /** An empty tape drawn under `key`, snapping to `snap` when given. */
60
+ export const init = (key: string, options: { readonly snap?: number | null } = {}): Model => ({
61
+ key,
62
+ tape: tape(options),
63
+ });
64
+
65
+ type Return = ReturnWithOutMessage<Model, Message, OutMessage>;
66
+
67
+ /** The OutMessage for a tape that has just been measured, if it has. */
68
+ const measured = (next: Model): Return =>
69
+ Option.match(
70
+ Option.all([Option.fromNullishOr(next.tape.from), Option.fromNullishOr(next.tape.to)]),
71
+ {
72
+ onNone: () => ({ model: next }),
73
+ onSome: ([from, to]) => ({
74
+ model: next,
75
+ outMessage: OutMessage.Measured({ from, to, length: tapeLength(next.tape) ?? 0 }),
76
+ }),
77
+ },
78
+ );
79
+
80
+ export const update = (model: Model, message: Message): Return =>
81
+ Message.match<Return>(message, {
82
+ Placed: ({ point }) => measured({ ...model, tape: placeTapeEnd(model.tape, point) }),
83
+ Pointed: ({ point }) => ({ model: { ...model, tape: pointTape(model.tape, point) } }),
84
+ Cleared: () => ({ model: { ...model, tape: clearTape(model.tape) } }),
85
+ ChoseSnap: ({ snap }) => ({ model: { ...model, tape: { ...model.tape, snap } } }),
86
+ });
87
+
88
+ /** The tape's length, while it has one (to the pointer while the second end is placed). */
89
+ export const length = (model: Model): Option.Option<number> =>
90
+ Option.fromNullishOr(tapeLength(model.tape));
91
+
92
+ /** The tape, for the parent's scene. */
93
+ export const nodes = (model: Model, look: Partial<TapeLook> = {}): SceneNode =>
94
+ tapeNodes(model.key, model.tape, look);
95
+
96
+ /** The label's class, when one is given. */
97
+ const classOf = <Message>(h: HtmlBuilder<Message>, className: string | undefined) =>
98
+ Option.match(Option.fromNullishOr(className), {
99
+ onNone: () => [],
100
+ onSome: (name) => [h.Class(name)],
101
+ });
102
+
103
+ /**
104
+ * The length at the tape's middle, for the view's `overlays`: two decimals and `unit`
105
+ * (`m` unless given), in an element with the class `className` (unstyled unless given).
106
+ * Pointer events pass through it to the scene.
107
+ */
108
+ export const label = <Message>(
109
+ model: Model,
110
+ h: HtmlBuilder<Message>,
111
+ options: { readonly unit?: string; readonly className?: string } = {},
112
+ ): ReadonlyArray<OverlayView> =>
113
+ Option.match(Option.all([length(model), Option.fromNullishOr(tapeMiddle(model.tape))]), {
114
+ onNone: () => [],
115
+ onSome: ([measuredLength, middle]) => [
116
+ {
117
+ overlay: overlay({ key: `${model.key}:label`, position: middle, center: true }),
118
+ content: h.span(classOf(h, options.className), [
119
+ `${measuredLength.toFixed(2)} ${options.unit ?? 'm'}`,
120
+ ]),
121
+ pointerEvents: 'none',
122
+ },
123
+ ],
124
+ });
package/src/mount.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  removal,
28
28
  sceneErrorDispatch,
29
29
  sceneEventDispatch,
30
+ dispatchAssetNotice,
30
31
  } from './dom/canvas.ts';
31
32
  import { applying, createLatch, type Latch, routedWhileOpen } from './dom/latch.ts';
32
33
  import { containOverlays, createOverlayWriter, type OverlayWriter } from './dom/overlays.ts';
@@ -140,6 +141,27 @@ const connectObserver =
140
141
  const observed = <A>(observe: (emit: (value: A) => void) => () => void) =>
141
142
  Stream.callback<A>(connectObserver(observe));
142
143
 
144
+ /** What the scene asks for now, given to the environment's asset registry. */
145
+ const asking = (mounted: Mounted, scene: SceneDescription) => () =>
146
+ mounted.environment.assetRegistry.want(mounted.element, scene.assets, (notice) =>
147
+ dispatchAssetNotice(mounted.element, notice),
148
+ );
149
+
150
+ const lettingGo = (mounted: Mounted) => () =>
151
+ mounted.environment.assetRegistry.release(mounted.element);
152
+
153
+ /** The scene's assets, asked for while it is mounted: going, it asks for none. */
154
+ const acquireAssets = (mounted: Mounted, initial: SceneDescription) => {
155
+ const ask = Effect.sync(asking(mounted, initial));
156
+ return Effect.acquireRelease(ask, () => Effect.sync(lettingGo(mounted)));
157
+ };
158
+
159
+ /** An update applied, then what it asks for given to the registry (a refused one asks nothing). */
160
+ const updated = (mounted: Mounted) => (scene: SceneDescription) => {
161
+ const ask = Effect.sync(asking(mounted, scene));
162
+ return Effect.tap(mounted.handle.update(scene), () => ask);
163
+ };
164
+
143
165
  const applyUpdate = (mounted: Mounted) => (value: unknown) => {
144
166
  const reportError = sceneErrorDispatch(mounted.element);
145
167
  const reportReconciled = afterUpdate(
@@ -148,7 +170,7 @@ const applyUpdate = (mounted: Mounted) => (value: unknown) => {
148
170
  );
149
171
  const decoded = decodeScene(value);
150
172
  const applied = decoded.pipe(
151
- Effect.flatMap(mounted.handle.update),
173
+ Effect.flatMap(updated(mounted)),
152
174
  Effect.flatMap((ops) => Effect.sync(reportReconciled(ops))),
153
175
  );
154
176
  return applied.pipe(
@@ -258,6 +280,7 @@ function* runScene(
258
280
  gate: createFrameGate(frames),
259
281
  live: createLatch(true),
260
282
  };
283
+ yield* acquireAssets(mounted, initial);
261
284
  yield* acquirePointer(canvas, mounted);
262
285
  yield* acquireXrTarget(mounted);
263
286
  yield* Effect.sync(touchActionSync(canvas, handle.graph));
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Pivot controls as a Foldkit submodel (roadmap, "Beyond parity" 5): the core's pure
3
+ * `pivotNodes`, `pressPivot` and `dragPivot` (after Drei's `PivotControls`) with
4
+ * Messages for the scene's pointer events and OutMessages for what the parent does with
5
+ * them, as `TransformGizmo` is for three's controls. The parent owns where its objects
6
+ * are; the submodel owns only the handles:
7
+ *
8
+ * - `Pressed` (a pointer down on one of its handles, with the point it hit and the
9
+ * object's placement) takes hold;
10
+ * - `Dragged` reports `Moved { placement }` for the parent to apply (`snap` rounds turns
11
+ * to ten degrees and scales to tenths, as Drei's shift does);
12
+ * - `Released` lets go and reports `Finished { placement }`, a step for history;
13
+ * - `Hovered` highlights a handle.
14
+ *
15
+ * `handleOf(model, key)` says whether a hit is on a handle, and `nodes(model, placement)`
16
+ * draws them.
17
+ *
18
+ * @module
19
+ */
20
+ import {
21
+ dragPivot,
22
+ hoverPivot,
23
+ type Pivot,
24
+ pivot,
25
+ type PivotHandle,
26
+ pivotHandleOf,
27
+ pivotNodes,
28
+ type Placement,
29
+ pressPivot,
30
+ releasePivot,
31
+ SceneSchema,
32
+ type SceneNode,
33
+ type Vec3,
34
+ } from '@opsydyn/effect-three';
35
+ import { Option, Schema } from 'effect';
36
+ import { defineMessageUnion } from 'foldkit/message';
37
+ import type { ReturnWithOutMessage } from 'foldkit/update';
38
+
39
+ export const Model = Schema.Struct({
40
+ /** The key its handles are drawn under: `${key}:arrow-x`, `${key}:slider-z` and so on. */
41
+ key: Schema.String,
42
+ pivot: SceneSchema.Pivot,
43
+ /** The placement the drag last reported, so `Released` can report where it ended. */
44
+ last: Schema.Option(SceneSchema.Placement),
45
+ });
46
+ export type Model = typeof Model.Type;
47
+
48
+ const Handle = SceneSchema.PivotHandle;
49
+
50
+ export const Message = defineMessageUnion({
51
+ Pressed: { handle: Handle, point: SceneSchema.Vec3, placement: SceneSchema.Placement },
52
+ Dragged: { origin: SceneSchema.Vec3, direction: SceneSchema.Vec3, snap: Schema.Boolean },
53
+ Released: {},
54
+ Hovered: { handle: Schema.NullOr(Handle) },
55
+ });
56
+ export type Message = typeof Message.Type;
57
+
58
+ export const OutMessage = defineMessageUnion({
59
+ /** The object's placement now, during a drag. */
60
+ Moved: { placement: SceneSchema.Placement },
61
+ /** The drag is over, the object here: a step for history. */
62
+ Finished: { placement: SceneSchema.Placement },
63
+ });
64
+ export type OutMessage = typeof OutMessage.Type;
65
+
66
+ /** Pivot controls drawn under `key`, with Drei's defaults or the options given. */
67
+ export const init = (key: string, options: Parameters<typeof pivot>[0] = {}): Model => ({
68
+ key,
69
+ pivot: pivot(options),
70
+ last: Option.none(),
71
+ });
72
+
73
+ type Return = ReturnWithOutMessage<Model, Message, OutMessage>;
74
+
75
+ const withPivot = (model: Model, next: Pivot): Model => ({ ...model, pivot: next });
76
+
77
+ const finished = (model: Model): Return => ({
78
+ model: { ...withPivot(model, releasePivot(model.pivot)), last: Option.none() },
79
+ ...Option.match(model.last, {
80
+ onNone: () => ({}),
81
+ onSome: (placement) => ({ outMessage: OutMessage.Finished({ placement }) }),
82
+ }),
83
+ });
84
+
85
+ const dragged = (model: Model, origin: Vec3, direction: Vec3, snap: boolean): Return => {
86
+ const placement = Option.fromNullishOr(dragPivot(model.pivot, { origin, direction }, { snap }));
87
+ return Option.match(placement, {
88
+ onNone: () => ({ model }),
89
+ onSome: (placement) => ({
90
+ model: { ...model, last: Option.some(placement) },
91
+ outMessage: OutMessage.Moved({ placement }),
92
+ }),
93
+ });
94
+ };
95
+
96
+ export const update = (model: Model, message: Message): Return =>
97
+ Message.match<Return>(message, {
98
+ Pressed: ({ handle, point, placement }) => ({
99
+ model: {
100
+ ...withPivot(model, pressPivot(model.pivot, placement, handle, point)),
101
+ last: Option.none(),
102
+ },
103
+ }),
104
+ Dragged: ({ origin, direction, snap }) => dragged(model, origin, direction, snap),
105
+ Released: () => finished(model),
106
+ Hovered: ({ handle }) => ({ model: withPivot(model, hoverPivot(model.pivot, handle)) }),
107
+ });
108
+
109
+ /** Whether a hit `key` is one of these controls' handles, and which. */
110
+ export const handleOf = (model: Model, key: string): Option.Option<PivotHandle> =>
111
+ Option.fromNullishOr(pivotHandleOf(model.key, key));
112
+
113
+ /** Whether a handle is held: the parent leaves the camera alone while it is. */
114
+ export const held = (model: Model): boolean => model.pivot.grab !== null;
115
+
116
+ /** The handles for `placement`, for the parent's scene. */
117
+ export const nodes = (model: Model, placement: Placement): SceneNode =>
118
+ pivotNodes(model.key, model.pivot, placement);
@@ -0,0 +1,131 @@
1
+ /**
2
+ * A transform gizmo as a Foldkit submodel (roadmap, "Beyond parity" 5): the core's pure
3
+ * gizmo (`gizmoNodes`, `pressGizmo`, `dragGizmo`, after three's `TransformControls`)
4
+ * with Messages for the scene's pointer events and OutMessages for what the parent does
5
+ * with them. The parent owns where its objects are; the submodel owns only the gizmo:
6
+ *
7
+ * - `Pressed` (a press on one of its handles, with the object's placement and the
8
+ * camera) takes hold;
9
+ * - `Dragged` reports `Moved { placement }` for the parent to apply;
10
+ * - `Released` lets go and reports `Finished { placement }`, the moment to record a step
11
+ * of history;
12
+ * - `Hovered` highlights a handle; `ChoseMode` and `ChoseSpace` switch them.
13
+ *
14
+ * `handleOf(model, key)` says whether a hit is on a handle, and `nodes(model, placement,
15
+ * camera)` draws them, for the parent's scene and view.
16
+ *
17
+ * @module
18
+ */
19
+ import {
20
+ type Camera,
21
+ dragGizmo,
22
+ type Gizmo,
23
+ gizmo,
24
+ type GizmoHandle,
25
+ gizmoHandleOf,
26
+ gizmoNodes,
27
+ hoverGizmo,
28
+ type Placement,
29
+ pressGizmo,
30
+ releaseGizmo,
31
+ SceneSchema,
32
+ type SceneNode,
33
+ type Vec3,
34
+ } from '@opsydyn/effect-three';
35
+ import { Option, Schema } from 'effect';
36
+ import { defineMessageUnion } from 'foldkit/message';
37
+ import type { ReturnWithOutMessage } from 'foldkit/update';
38
+
39
+ export const Model = Schema.Struct({
40
+ /** The key its handles are drawn under: `${key}:x`, `${key}:xy` and so on. */
41
+ key: Schema.String,
42
+ gizmo: SceneSchema.Gizmo,
43
+ /** The placement the drag last reported, so `Released` can report where it ended. */
44
+ last: Schema.Option(SceneSchema.Placement),
45
+ });
46
+ export type Model = typeof Model.Type;
47
+
48
+ const Handle = Schema.Literals(['x', 'y', 'z', 'xy', 'yz', 'xz', 'xyz']);
49
+
50
+ export const Message = defineMessageUnion({
51
+ Pressed: {
52
+ handle: Handle,
53
+ origin: SceneSchema.Vec3,
54
+ direction: SceneSchema.Vec3,
55
+ placement: SceneSchema.Placement,
56
+ camera: SceneSchema.Camera,
57
+ },
58
+ Dragged: { origin: SceneSchema.Vec3, direction: SceneSchema.Vec3 },
59
+ Released: {},
60
+ Hovered: { handle: Schema.NullOr(Handle) },
61
+ ChoseMode: { mode: Schema.Literals(['translate', 'rotate', 'scale']) },
62
+ ChoseSpace: { space: Schema.Literals(['world', 'local']) },
63
+ });
64
+ export type Message = typeof Message.Type;
65
+
66
+ export const OutMessage = defineMessageUnion({
67
+ /** The object's placement now, during a drag. */
68
+ Moved: { placement: SceneSchema.Placement },
69
+ /** The drag is over, the object here: a step for history. */
70
+ Finished: { placement: SceneSchema.Placement },
71
+ });
72
+ export type OutMessage = typeof OutMessage.Type;
73
+
74
+ /** A gizmo drawn under `key`, with the core's defaults or the options given. */
75
+ export const init = (key: string, options: Parameters<typeof gizmo>[0] = {}): Model => ({
76
+ key,
77
+ gizmo: gizmo(options),
78
+ last: Option.none(),
79
+ });
80
+
81
+ type Return = ReturnWithOutMessage<Model, Message, OutMessage>;
82
+
83
+ const withGizmo = (model: Model, next: Gizmo): Model => ({ ...model, gizmo: next });
84
+
85
+ const finished = (model: Model): Return => ({
86
+ model: { ...withGizmo(model, releaseGizmo(model.gizmo)), last: Option.none() },
87
+ ...Option.match(model.last, {
88
+ onNone: () => ({}),
89
+ onSome: (placement) => ({ outMessage: OutMessage.Finished({ placement }) }),
90
+ }),
91
+ });
92
+
93
+ const dragged = (model: Model, origin: Vec3, direction: Vec3): Return => {
94
+ const placement = Option.fromNullishOr(dragGizmo(model.gizmo, { origin, direction }));
95
+ return Option.match(placement, {
96
+ onNone: () => ({ model }),
97
+ onSome: (placement) => ({
98
+ model: { ...model, last: Option.some(placement) },
99
+ outMessage: OutMessage.Moved({ placement }),
100
+ }),
101
+ });
102
+ };
103
+
104
+ export const update = (model: Model, message: Message): Return =>
105
+ Message.match<Return>(message, {
106
+ Pressed: ({ handle, origin, direction, placement, camera }) => ({
107
+ model: {
108
+ ...withGizmo(
109
+ model,
110
+ pressGizmo(model.gizmo, placement, handle, { origin, direction }, camera),
111
+ ),
112
+ last: Option.none(),
113
+ },
114
+ }),
115
+ Dragged: ({ origin, direction }) => dragged(model, origin, direction),
116
+ Released: () => finished(model),
117
+ Hovered: ({ handle }) => ({ model: withGizmo(model, hoverGizmo(model.gizmo, handle)) }),
118
+ ChoseMode: ({ mode }) => ({ model: withGizmo(model, { ...model.gizmo, mode }) }),
119
+ ChoseSpace: ({ space }) => ({ model: withGizmo(model, { ...model.gizmo, space }) }),
120
+ });
121
+
122
+ /** Whether a hit `key` is one of this gizmo's handles, and which. */
123
+ export const handleOf = (model: Model, key: string): Option.Option<GizmoHandle> =>
124
+ Option.fromNullishOr(gizmoHandleOf(model.key, key));
125
+
126
+ /** Whether the gizmo is held: the parent leaves the camera alone while it is. */
127
+ export const held = (model: Model): boolean => model.gizmo.grab !== null;
128
+
129
+ /** The handles for `placement`, seen from `camera`, for the parent's scene. */
130
+ export const nodes = (model: Model, placement: Placement, camera: Camera): SceneNode =>
131
+ gizmoNodes(model.key, model.gizmo, placement, camera);