@opsydyn/effect-three-foldkit 0.2.0 → 0.4.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/CHANGELOG.md +18 -1
- package/README.md +2 -1
- package/dist/boxSelect.d.ts +150 -0
- package/dist/boxSelect.d.ts.map +1 -0
- package/dist/dom/webgl.d.ts.map +1 -1
- package/dist/dom/webgpu.d.ts.map +1 -1
- package/dist/element.d.ts +104 -2
- package/dist/element.d.ts.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +535 -32
- package/dist/index.js.map +13 -7
- package/dist/inspector.d.ts +24 -0
- package/dist/inspector.d.ts.map +1 -0
- package/dist/measuringTape.d.ts +76 -0
- package/dist/measuringTape.d.ts.map +1 -0
- package/dist/pivotControls.d.ts +106 -0
- package/dist/pivotControls.d.ts.map +1 -0
- package/dist/transformGizmo.d.ts +158 -0
- package/dist/transformGizmo.d.ts.map +1 -0
- package/dist/view.d.ts +7 -0
- package/dist/view.d.ts.map +1 -1
- package/dist/viewportGizmo.d.ts +146 -0
- package/dist/viewportGizmo.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/boxSelect.ts +158 -0
- package/src/dom/webgl.ts +15 -0
- package/src/dom/webgpu.ts +15 -0
- package/src/element.ts +2 -0
- package/src/index.ts +7 -0
- package/src/inspector.ts +112 -0
- package/src/measuringTape.ts +124 -0
- package/src/pivotControls.ts +118 -0
- package/src/transformGizmo.ts +131 -0
- package/src/view.ts +9 -0
- package/src/viewportGizmo.ts +119 -0
package/src/boxSelect.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box selection as a Foldkit submodel (roadmap, "Beyond parity" 5), after Drei's `Select`
|
|
3
|
+
* over three.js's `SelectionBox`: a shift-drag that begins on nothing draws a box, and
|
|
4
|
+
* its release selects what the box holds, through the core's pure `boxSelect`. The parent
|
|
5
|
+
* owns what is selected; the submodel owns only the box:
|
|
6
|
+
*
|
|
7
|
+
* - `Dragged` (each scene `Drag` gesture the box `claims`, made with `dragOf`) starts the box
|
|
8
|
+
* where the press was and moves its far corner by the gesture's deltas;
|
|
9
|
+
* - `Ended` (the scene's `GestureEnd`, with the camera and what can be selected) reports
|
|
10
|
+
* `Selected { keys }`, which replaces the selection as Drei's box does, and clears the
|
|
11
|
+
* box;
|
|
12
|
+
* - `Cancelled` clears the box and selects nothing.
|
|
13
|
+
*
|
|
14
|
+
* `rectangle(model)` places the box on the canvas in CSS pixels, for an HTML outline, and
|
|
15
|
+
* `inside(model, camera, selectable)` says what the box holds so far, for highlighting
|
|
16
|
+
* while it is drawn (Drei selects as the box moves).
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
import { boxSelect, type Camera, type Gesture, SceneSchema } from '@opsydyn/effect-three';
|
|
21
|
+
import { Option, Schema } from 'effect';
|
|
22
|
+
import { defineMessageUnion } from 'foldkit/message';
|
|
23
|
+
import type { ReturnWithOutMessage } from 'foldkit/update';
|
|
24
|
+
|
|
25
|
+
const Corner = Schema.Struct({ x: Schema.Finite, y: Schema.Finite });
|
|
26
|
+
|
|
27
|
+
/** Something the box can select: its key, and the point that must fall inside. */
|
|
28
|
+
const Selectable = Schema.Struct({ key: Schema.String, at: SceneSchema.Vec3 });
|
|
29
|
+
|
|
30
|
+
/** A box being drawn: its corners in the canvas's normalised coordinates, −1 to 1, y up. */
|
|
31
|
+
export const Box = Schema.Struct({
|
|
32
|
+
from: Corner,
|
|
33
|
+
to: Corner,
|
|
34
|
+
width: Schema.Finite,
|
|
35
|
+
height: Schema.Finite,
|
|
36
|
+
});
|
|
37
|
+
export type Box = typeof Box.Type;
|
|
38
|
+
|
|
39
|
+
export const Model = Schema.Struct({ box: Schema.Option(Box) });
|
|
40
|
+
export type Model = typeof Model.Type;
|
|
41
|
+
|
|
42
|
+
export const Message = defineMessageUnion({
|
|
43
|
+
Dragged: {
|
|
44
|
+
x: Schema.Finite,
|
|
45
|
+
y: Schema.Finite,
|
|
46
|
+
deltaX: Schema.Finite,
|
|
47
|
+
deltaY: Schema.Finite,
|
|
48
|
+
width: Schema.Finite,
|
|
49
|
+
height: Schema.Finite,
|
|
50
|
+
},
|
|
51
|
+
Ended: { camera: SceneSchema.Camera, selectable: Schema.Array(Selectable) },
|
|
52
|
+
Cancelled: {},
|
|
53
|
+
});
|
|
54
|
+
export type Message = typeof Message.Type;
|
|
55
|
+
|
|
56
|
+
export const OutMessage = defineMessageUnion({
|
|
57
|
+
/** The box was let go: the keys inside it, for the new selection. */
|
|
58
|
+
Selected: { keys: Schema.Array(Schema.String) },
|
|
59
|
+
});
|
|
60
|
+
export type OutMessage = typeof OutMessage.Type;
|
|
61
|
+
|
|
62
|
+
/** No box drawn. */
|
|
63
|
+
export const init = (): Model => ({ box: Option.none() });
|
|
64
|
+
|
|
65
|
+
type Return = ReturnWithOutMessage<Model, Message, OutMessage>;
|
|
66
|
+
|
|
67
|
+
/** The box with its far corner moved by a drag's CSS-pixel deltas (down positive). */
|
|
68
|
+
const moved = (box: Box, deltaX: number, deltaY: number): Box => ({
|
|
69
|
+
...box,
|
|
70
|
+
to: { x: box.to.x + (deltaX * 2) / box.width, y: box.to.y - (deltaY * 2) / box.height },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const dragged = (model: Model, message: Extract<Message, { _tag: 'Dragged' }>): Model => {
|
|
74
|
+
const { x, y, deltaX, deltaY, width, height } = message;
|
|
75
|
+
const start: Box = { from: { x, y }, to: { x, y }, width, height };
|
|
76
|
+
const box = Option.getOrElse(model.box, () => start);
|
|
77
|
+
return { box: Option.some(moved(box, deltaX, deltaY)) };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type Selectables = ReadonlyArray<typeof Selectable.Type>;
|
|
81
|
+
|
|
82
|
+
const keysIn = (box: Box, camera: Camera, selectable: Selectables): ReadonlyArray<string> =>
|
|
83
|
+
boxSelect(camera, box, box.from, box.to, selectable);
|
|
84
|
+
|
|
85
|
+
const ended = (model: Model, camera: Camera, selectable: Selectables): Return =>
|
|
86
|
+
Option.match(model.box, {
|
|
87
|
+
onNone: () => ({ model }),
|
|
88
|
+
onSome: (box) => ({
|
|
89
|
+
model: init(),
|
|
90
|
+
outMessage: OutMessage.Selected({ keys: keysIn(box, camera, selectable) }),
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export const update = (model: Model, message: Message): Return =>
|
|
95
|
+
Message.match<Return>(message, {
|
|
96
|
+
Dragged: (drag) => ({ model: dragged(model, drag) }),
|
|
97
|
+
Ended: ({ camera, selectable }) => ended(model, camera, selectable),
|
|
98
|
+
Cancelled: () => ({ model: init() }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** Whether a box is being drawn: the parent leaves the camera alone while it is. */
|
|
102
|
+
export const drawing = (model: Model): boolean => Option.isSome(model.box);
|
|
103
|
+
|
|
104
|
+
/** A scene `Drag` gesture: the only kind a box takes. */
|
|
105
|
+
export type DragGesture = Extract<Gesture, { readonly _tag: 'Drag' }>;
|
|
106
|
+
|
|
107
|
+
/** The primary button: a box starts only with it. */
|
|
108
|
+
const primaryButton = 0;
|
|
109
|
+
|
|
110
|
+
/** A primary-button shift-drag that began on nothing starts a box. */
|
|
111
|
+
const startsBox = (drag: DragGesture): boolean =>
|
|
112
|
+
drag.button === primaryButton && drag.modifiers.shift && drag.key === undefined;
|
|
113
|
+
|
|
114
|
+
/** Whether the box takes `drag`: every drag while one is drawn, and one that starts it. */
|
|
115
|
+
export const claims = (model: Model, drag: DragGesture): boolean =>
|
|
116
|
+
drawing(model) || startsBox(drag);
|
|
117
|
+
|
|
118
|
+
/** The `Dragged` Message for a drag the box claims. */
|
|
119
|
+
export const dragOf = (gesture: DragGesture): Message =>
|
|
120
|
+
Message.Dragged({
|
|
121
|
+
x: gesture.x,
|
|
122
|
+
y: gesture.y,
|
|
123
|
+
deltaX: gesture.deltaX,
|
|
124
|
+
deltaY: gesture.deltaY,
|
|
125
|
+
width: gesture.width,
|
|
126
|
+
height: gesture.height,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/** What the box holds so far, seen through `camera`: none while no box is drawn. */
|
|
130
|
+
export const inside = (
|
|
131
|
+
model: Model,
|
|
132
|
+
camera: Camera,
|
|
133
|
+
selectable: Selectables,
|
|
134
|
+
): ReadonlyArray<string> =>
|
|
135
|
+
Option.match(model.box, {
|
|
136
|
+
onNone: () => [],
|
|
137
|
+
onSome: (box) => keysIn(box, camera, selectable),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/** A box on the canvas, in CSS pixels from its top left. */
|
|
141
|
+
export type Rectangle = {
|
|
142
|
+
readonly left: number;
|
|
143
|
+
readonly top: number;
|
|
144
|
+
readonly width: number;
|
|
145
|
+
readonly height: number;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const onCanvas = (box: Box): Rectangle => {
|
|
149
|
+
const left = ((Math.min(box.from.x, box.to.x) + 1) / 2) * box.width;
|
|
150
|
+
const right = ((Math.max(box.from.x, box.to.x) + 1) / 2) * box.width;
|
|
151
|
+
const top = ((1 - Math.max(box.from.y, box.to.y)) / 2) * box.height;
|
|
152
|
+
const bottom = ((1 - Math.min(box.from.y, box.to.y)) / 2) * box.height;
|
|
153
|
+
return { left, top, width: right - left, height: bottom - top };
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** Where the box sits on the canvas, while one is drawn. */
|
|
157
|
+
export const rectangle = (model: Model): Option.Option<Rectangle> =>
|
|
158
|
+
Option.map(model.box, onCanvas);
|
package/src/dom/webgl.ts
CHANGED
|
@@ -184,6 +184,21 @@ export const webglRendererFactory = (
|
|
|
184
184
|
// Effects apply to the single full view only; views draw straight to the screen.
|
|
185
185
|
renderer.render(scene, camera);
|
|
186
186
|
},
|
|
187
|
+
renderOver: (scene, camera) => {
|
|
188
|
+
if (renderer.xr.isPresenting) return;
|
|
189
|
+
if (scissored) {
|
|
190
|
+
const { width, height } = renderer.getSize(drawingSize);
|
|
191
|
+
renderer.setScissorTest(false);
|
|
192
|
+
renderer.setViewport(0, 0, width, height);
|
|
193
|
+
scissored = false;
|
|
194
|
+
}
|
|
195
|
+
// As Drei's Hud: keep what is drawn, clear depth so the HUD is never hidden by it.
|
|
196
|
+
const autoClear = renderer.autoClear;
|
|
197
|
+
renderer.autoClear = false;
|
|
198
|
+
renderer.clearDepth();
|
|
199
|
+
renderer.render(scene, camera);
|
|
200
|
+
renderer.autoClear = autoClear;
|
|
201
|
+
},
|
|
187
202
|
createTarget: (width, height) => createTarget(targets, width, height),
|
|
188
203
|
createEnvironment: () => createRoom(renderer),
|
|
189
204
|
configure: (settings) => {
|
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,6 +59,7 @@ 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,
|
|
@@ -92,6 +93,7 @@ export const domEventName = {
|
|
|
92
93
|
Drag: 'scene-drag',
|
|
93
94
|
DragEnd: 'scene-drag-end',
|
|
94
95
|
Gesture: 'scene-gesture',
|
|
96
|
+
GestureEnd: 'scene-gesture-end',
|
|
95
97
|
AutoRotated: 'scene-auto-rotated',
|
|
96
98
|
Collision: 'scene-collision',
|
|
97
99
|
ContactForce: 'scene-contact-force',
|
package/src/index.ts
CHANGED
|
@@ -44,7 +44,13 @@ 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 {
|
|
50
56
|
type AutoRotatedDetail,
|
|
@@ -53,6 +59,7 @@ export {
|
|
|
53
59
|
type ContactForceDetail,
|
|
54
60
|
type DragDetail,
|
|
55
61
|
type Gesture,
|
|
62
|
+
type GestureEndDetail,
|
|
56
63
|
type HitDetail,
|
|
57
64
|
type KeyDetail,
|
|
58
65
|
type LabelMeasuredDetail,
|
package/src/inspector.ts
ADDED
|
@@ -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
|
+
});
|
|
@@ -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);
|