@toolproof-core/visualization 1.0.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/dist/_lib/types.d.ts +2 -0
- package/dist/_lib/types.d.ts.map +1 -0
- package/dist/_lib/types.js +2 -0
- package/dist/_lib/types.js.map +1 -0
- package/dist/primitives/Portal.d.ts +23 -0
- package/dist/primitives/Portal.d.ts.map +1 -0
- package/dist/primitives/Portal.js +102 -0
- package/dist/primitives/Portal.js.map +1 -0
- package/dist/primitives/RingOfMeshes.d.ts +53 -0
- package/dist/primitives/RingOfMeshes.d.ts.map +1 -0
- package/dist/primitives/RingOfMeshes.js +67 -0
- package/dist/primitives/RingOfMeshes.js.map +1 -0
- package/dist/primitives/SpokeOfMeshes.d.ts +42 -0
- package/dist/primitives/SpokeOfMeshes.d.ts.map +1 -0
- package/dist/primitives/SpokeOfMeshes.js +78 -0
- package/dist/primitives/SpokeOfMeshes.js.map +1 -0
- package/dist/runtime/RuntimeController.d.ts +48 -0
- package/dist/runtime/RuntimeController.d.ts.map +1 -0
- package/dist/runtime/RuntimeController.js +81 -0
- package/dist/runtime/RuntimeController.js.map +1 -0
- package/dist/runtime/RuntimeView.d.ts +20 -0
- package/dist/runtime/RuntimeView.d.ts.map +1 -0
- package/dist/runtime/RuntimeView.js +41 -0
- package/dist/runtime/RuntimeView.js.map +1 -0
- package/package.json +48 -0
- package/src/_lib/types.ts +0 -0
- package/src/primitives/Portal.tsx +261 -0
- package/src/primitives/RingOfMeshes.tsx +149 -0
- package/src/primitives/SpokeOfMeshes.tsx +168 -0
- package/src/runtime/RuntimeController.tsx +163 -0
- package/src/runtime/RuntimeView.tsx +93 -0
- package/tsconfig.json +15 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Canvas } from '@react-three/fiber';
|
|
4
|
+
import { OrbitControls } from '@react-three/drei';
|
|
5
|
+
import { XR, createXRStore, useXR } from '@react-three/xr';
|
|
6
|
+
import * as THREE from 'three';
|
|
7
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
8
|
+
export const runtimeXrStore = createXRStore();
|
|
9
|
+
function RuntimeControls({ orbitControlsRef, target, }) {
|
|
10
|
+
const session = useXR((state) => state.session);
|
|
11
|
+
return (_jsx(OrbitControls, { ref: orbitControlsRef, target: target, makeDefault: true, enableDamping: true, dampingFactor: 0.05, enabled: !session }));
|
|
12
|
+
}
|
|
13
|
+
export default function RuntimeView(props) {
|
|
14
|
+
const { children, cameraConfig, background = 'skyblue' } = props;
|
|
15
|
+
const fallbackOrbitControlsRef = useRef(null);
|
|
16
|
+
const fallbackCameraRef = useRef(null);
|
|
17
|
+
const orbitControlsRef = props.orbitControlsRef ?? fallbackOrbitControlsRef;
|
|
18
|
+
const cameraRef = props.cameraRef ?? fallbackCameraRef;
|
|
19
|
+
const initialCamera = useMemo(() => ({ position: cameraConfig.position, fov: cameraConfig.fov }),
|
|
20
|
+
// Only used for initial mount; follow-up updates happen via effect.
|
|
21
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
22
|
+
[]);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const camera = cameraRef.current;
|
|
25
|
+
const controls = orbitControlsRef.current;
|
|
26
|
+
if (!camera || !controls)
|
|
27
|
+
return;
|
|
28
|
+
camera.position.set(...cameraConfig.position);
|
|
29
|
+
if ('fov' in camera && typeof camera.fov === 'number') {
|
|
30
|
+
camera.fov = cameraConfig.fov;
|
|
31
|
+
camera.updateProjectionMatrix();
|
|
32
|
+
}
|
|
33
|
+
controls.target.set(...cameraConfig.target);
|
|
34
|
+
controls.update();
|
|
35
|
+
}, [cameraConfig, cameraRef, orbitControlsRef]);
|
|
36
|
+
return (_jsx(Canvas, { style: { width: '100%', height: '100%' }, camera: initialCamera, onCreated: ({ camera, scene }) => {
|
|
37
|
+
cameraRef.current = camera;
|
|
38
|
+
scene.background = new THREE.Color(background);
|
|
39
|
+
}, children: _jsxs(XR, { store: runtimeXrStore, children: [_jsx(RuntimeControls, { orbitControlsRef: orbitControlsRef, target: cameraConfig.target }), children] }) }));
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=RuntimeView.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RuntimeView.js","sourceRoot":"","sources":["../../src/runtime/RuntimeView.tsx"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@toolproof-core/visualization",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"exports": {
|
|
6
|
+
"./runtime-controller": {
|
|
7
|
+
"types": "./dist/runtime/RuntimeController.d.ts",
|
|
8
|
+
"default": "./dist/runtime/RuntimeController.js"
|
|
9
|
+
},
|
|
10
|
+
"./ring-of-meshes": {
|
|
11
|
+
"types": "./dist/primitives/RingOfMeshes.d.ts",
|
|
12
|
+
"default": "./dist/primitives/RingOfMeshes.js"
|
|
13
|
+
},
|
|
14
|
+
"./spoke-of-meshes": {
|
|
15
|
+
"types": "./dist/primitives/SpokeOfMeshes.d.ts",
|
|
16
|
+
"default": "./dist/primitives/SpokeOfMeshes.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"keywords": [],
|
|
21
|
+
"author": "",
|
|
22
|
+
"license": "ISC",
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^20.19.25",
|
|
25
|
+
"ts-node": "^10.9.2",
|
|
26
|
+
"typescript": "^5.9.3",
|
|
27
|
+
"@types/three": "^0.177.0",
|
|
28
|
+
"@types/react": "19.2.3",
|
|
29
|
+
"@types/react-dom": "19.2.3"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": "19.2.3",
|
|
33
|
+
"react-dom": "19.2.3",
|
|
34
|
+
"@react-three/drei": "^10.7.7",
|
|
35
|
+
"@react-three/fiber": "^9.4.2",
|
|
36
|
+
"@react-three/xr": "^6.6.29",
|
|
37
|
+
"three": "^0.177.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@toolproof-core/schema": "^1.0.18",
|
|
41
|
+
"@toolproof-core/lib": "1.0.12",
|
|
42
|
+
"gsap": "^3.14.2"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
46
|
+
"build": "tsc -b"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import React, { useEffect, useLayoutEffect, useRef } from "react";
|
|
2
|
+
import * as THREE from "three";
|
|
3
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
4
|
+
import type { ThreeEvent } from "@react-three/fiber";
|
|
5
|
+
import {
|
|
6
|
+
Environment,
|
|
7
|
+
MeshPortalMaterial,
|
|
8
|
+
Sparkles,
|
|
9
|
+
Stars,
|
|
10
|
+
} from "@react-three/drei";
|
|
11
|
+
import gsap from "gsap";
|
|
12
|
+
|
|
13
|
+
type OrbitControlsLike = {
|
|
14
|
+
target: THREE.Vector3;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type MeshPortalMaterialImpl = React.ElementRef<typeof MeshPortalMaterial>;
|
|
18
|
+
|
|
19
|
+
// ... types remain the same
|
|
20
|
+
|
|
21
|
+
export type PortalInteractionPolicy =
|
|
22
|
+
| "none"
|
|
23
|
+
| "activate-only"
|
|
24
|
+
| "deactivate-only"
|
|
25
|
+
| "toggle";
|
|
26
|
+
|
|
27
|
+
export interface PortalProps {
|
|
28
|
+
isActive: boolean;
|
|
29
|
+
setIsActive: (active: boolean) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Controls whether user gestures can change portal active state.
|
|
32
|
+
* Defaults to "toggle" to preserve existing behavior.
|
|
33
|
+
*/
|
|
34
|
+
interactionPolicy?: PortalInteractionPolicy;
|
|
35
|
+
orbitControlsRef?: React.RefObject<OrbitControlsLike | null>;
|
|
36
|
+
onTransitionComplete?: (active: boolean) => void;
|
|
37
|
+
targetScale?: number;
|
|
38
|
+
onScaleAnimationEnd?: () => void;
|
|
39
|
+
children?: React.ReactNode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PortalRing: React.FC<{
|
|
43
|
+
radius: number;
|
|
44
|
+
rotation?: number;
|
|
45
|
+
speed?: number;
|
|
46
|
+
}> = ({ radius, rotation = 0, speed = 1 }) => {
|
|
47
|
+
const ringRef = useRef<THREE.Mesh>(null);
|
|
48
|
+
|
|
49
|
+
useFrame((state) => {
|
|
50
|
+
if (ringRef.current) {
|
|
51
|
+
ringRef.current.rotation.z = state.clock.elapsedTime * speed + rotation;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<mesh ref={ringRef}>
|
|
57
|
+
<torusGeometry args={[radius, 0.08, 16, 100]} />
|
|
58
|
+
<meshStandardMaterial
|
|
59
|
+
color="#00d4ff"
|
|
60
|
+
emissive="#00d4ff"
|
|
61
|
+
emissiveIntensity={2}
|
|
62
|
+
transparent
|
|
63
|
+
opacity={0.8}
|
|
64
|
+
/>
|
|
65
|
+
</mesh>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default function Portal({
|
|
70
|
+
isActive,
|
|
71
|
+
setIsActive,
|
|
72
|
+
interactionPolicy = "toggle",
|
|
73
|
+
orbitControlsRef,
|
|
74
|
+
onTransitionComplete,
|
|
75
|
+
targetScale = 1,
|
|
76
|
+
onScaleAnimationEnd,
|
|
77
|
+
children,
|
|
78
|
+
}: PortalProps) {
|
|
79
|
+
const portalMaterial = useRef<MeshPortalMaterialImpl | null>(null);
|
|
80
|
+
const groupRef = useRef<THREE.Group>(null);
|
|
81
|
+
const { camera } = useThree();
|
|
82
|
+
|
|
83
|
+
const onTransitionCompleteRef =
|
|
84
|
+
useRef<PortalProps["onTransitionComplete"]>(undefined);
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
onTransitionCompleteRef.current = onTransitionComplete;
|
|
87
|
+
}, [onTransitionComplete]);
|
|
88
|
+
|
|
89
|
+
/* useLayoutEffect(() => {
|
|
90
|
+
if (groupRef.current) {
|
|
91
|
+
gsap.to(groupRef.current.scale, {
|
|
92
|
+
x: isActive ? targetScale : 0,
|
|
93
|
+
y: isActive ? targetScale : 0,
|
|
94
|
+
z: isActive ? targetScale : 0,
|
|
95
|
+
duration: 1,
|
|
96
|
+
ease: "back.out(1.7)",
|
|
97
|
+
onComplete: onScaleAnimationEnd,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}, [isActive, targetScale, onScaleAnimationEnd]); */
|
|
101
|
+
|
|
102
|
+
// useLayoutEffect(() => {
|
|
103
|
+
// if (!portalMaterial.current) return;
|
|
104
|
+
|
|
105
|
+
// gsap.to(portalMaterial.current, {
|
|
106
|
+
// blend: isActive ? 1 : 0,
|
|
107
|
+
// duration: 0.7,
|
|
108
|
+
// ease: "power2.inOut",
|
|
109
|
+
// });
|
|
110
|
+
// }, [isActive]);
|
|
111
|
+
|
|
112
|
+
// useLayoutEffect(() => {
|
|
113
|
+
// const targetPos = isActive
|
|
114
|
+
// ? new THREE.Vector3(5, 5, 10)
|
|
115
|
+
// : new THREE.Vector3(10, 10, 0);
|
|
116
|
+
// const targetLookAt = isActive
|
|
117
|
+
// ? new THREE.Vector3(0, 0, 20)
|
|
118
|
+
// : new THREE.Vector3(0, 0, 0);
|
|
119
|
+
|
|
120
|
+
// const tl = gsap.timeline({
|
|
121
|
+
// defaults: { duration: 1.5, ease: "power3.inOut" },
|
|
122
|
+
// onComplete: () => onTransitionCompleteRef.current?.(isActive),
|
|
123
|
+
// });
|
|
124
|
+
|
|
125
|
+
// tl.to(camera.position, {
|
|
126
|
+
// x: targetPos.x,
|
|
127
|
+
// y: targetPos.y,
|
|
128
|
+
// z: targetPos.z,
|
|
129
|
+
// });
|
|
130
|
+
|
|
131
|
+
// const orbitControls = orbitControlsRef?.current ?? null;
|
|
132
|
+
// if (orbitControls) {
|
|
133
|
+
// tl.to(
|
|
134
|
+
// orbitControls.target,
|
|
135
|
+
// {
|
|
136
|
+
// x: targetLookAt.x,
|
|
137
|
+
// y: targetLookAt.y,
|
|
138
|
+
// z: targetLookAt.z,
|
|
139
|
+
// },
|
|
140
|
+
// 0,
|
|
141
|
+
// );
|
|
142
|
+
// }
|
|
143
|
+
|
|
144
|
+
// return () => {
|
|
145
|
+
// tl.kill();
|
|
146
|
+
// };
|
|
147
|
+
// }, [isActive, camera, orbitControlsRef]);
|
|
148
|
+
|
|
149
|
+
const handleDoubleClick = (e: ThreeEvent<MouseEvent>) => {
|
|
150
|
+
let nextActive: boolean | null = null;
|
|
151
|
+
|
|
152
|
+
switch (interactionPolicy) {
|
|
153
|
+
case "none":
|
|
154
|
+
return;
|
|
155
|
+
case "toggle":
|
|
156
|
+
nextActive = !isActive;
|
|
157
|
+
break;
|
|
158
|
+
case "activate-only":
|
|
159
|
+
nextActive = isActive ? null : true;
|
|
160
|
+
break;
|
|
161
|
+
case "deactivate-only":
|
|
162
|
+
nextActive = isActive ? false : null;
|
|
163
|
+
break;
|
|
164
|
+
default:
|
|
165
|
+
// Defensive: if a consumer passes an unknown value, preserve legacy toggle.
|
|
166
|
+
nextActive = !isActive;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (nextActive === null) return;
|
|
171
|
+
e.stopPropagation();
|
|
172
|
+
setIsActive(nextActive);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<group ref={groupRef}>
|
|
177
|
+
<PortalRing radius={8.5} rotation={0} speed={0.3} />
|
|
178
|
+
<PortalRing radius={9} rotation={Math.PI / 4} speed={-0.2} />
|
|
179
|
+
<PortalRing radius={9.5} rotation={Math.PI / 2} speed={0.15} />
|
|
180
|
+
|
|
181
|
+
<Sparkles
|
|
182
|
+
count={100}
|
|
183
|
+
size={3}
|
|
184
|
+
scale={[20, 20, 6]}
|
|
185
|
+
speed={0.5}
|
|
186
|
+
color="#00d4ff"
|
|
187
|
+
/>
|
|
188
|
+
|
|
189
|
+
<mesh onDoubleClick={handleDoubleClick}>
|
|
190
|
+
<circleGeometry args={[8, 64]} />
|
|
191
|
+
<MeshPortalMaterial
|
|
192
|
+
ref={portalMaterial}
|
|
193
|
+
side={THREE.DoubleSide}
|
|
194
|
+
transparent
|
|
195
|
+
blur={0.3}
|
|
196
|
+
resolution={512}
|
|
197
|
+
>
|
|
198
|
+
<ambientLight intensity={0.3} />
|
|
199
|
+
<pointLight position={[0, 5, 5]} intensity={2} color="#00d4ff" />
|
|
200
|
+
<pointLight position={[0, -5, 5]} intensity={1} color="#8b5cf6" />
|
|
201
|
+
<Environment preset="night" />
|
|
202
|
+
|
|
203
|
+
<mesh>
|
|
204
|
+
<sphereGeometry args={[30, 64, 64]} />
|
|
205
|
+
<meshStandardMaterial color="#050520" side={THREE.BackSide} />
|
|
206
|
+
</mesh>
|
|
207
|
+
|
|
208
|
+
<mesh>
|
|
209
|
+
<sphereGeometry args={[28, 32, 32]} />
|
|
210
|
+
<meshBasicMaterial
|
|
211
|
+
color="#1a0a30"
|
|
212
|
+
transparent
|
|
213
|
+
opacity={0.5}
|
|
214
|
+
side={THREE.BackSide}
|
|
215
|
+
/>
|
|
216
|
+
</mesh>
|
|
217
|
+
|
|
218
|
+
<Stars
|
|
219
|
+
radius={25}
|
|
220
|
+
depth={50}
|
|
221
|
+
count={3000}
|
|
222
|
+
factor={4}
|
|
223
|
+
saturation={0.5}
|
|
224
|
+
fade
|
|
225
|
+
speed={1}
|
|
226
|
+
/>
|
|
227
|
+
|
|
228
|
+
<group>{children}</group>
|
|
229
|
+
|
|
230
|
+
<Sparkles
|
|
231
|
+
count={200}
|
|
232
|
+
size={2}
|
|
233
|
+
scale={[20, 20, 20]}
|
|
234
|
+
speed={0.3}
|
|
235
|
+
color="#ffffff"
|
|
236
|
+
/>
|
|
237
|
+
</MeshPortalMaterial>
|
|
238
|
+
</mesh>
|
|
239
|
+
|
|
240
|
+
<mesh>
|
|
241
|
+
<ringGeometry args={[7.8, 8.2, 64]} />
|
|
242
|
+
<meshBasicMaterial
|
|
243
|
+
color="#00d4ff"
|
|
244
|
+
transparent
|
|
245
|
+
opacity={0.8}
|
|
246
|
+
side={THREE.DoubleSide}
|
|
247
|
+
/>
|
|
248
|
+
</mesh>
|
|
249
|
+
|
|
250
|
+
<mesh>
|
|
251
|
+
<ringGeometry args={[8, 8.5, 64]} />
|
|
252
|
+
<meshBasicMaterial
|
|
253
|
+
color="#00d4ff"
|
|
254
|
+
transparent
|
|
255
|
+
opacity={0.4}
|
|
256
|
+
side={THREE.DoubleSide}
|
|
257
|
+
/>
|
|
258
|
+
</mesh>
|
|
259
|
+
</group>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { NucleusBaseSmall } from '@toolproof-core/lib/types';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import { useEffect, useMemo } from 'react';
|
|
4
|
+
import * as THREE from 'three';
|
|
5
|
+
import type { ThreeElements } from '@react-three/fiber';
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
export type RingMeshConfig = {
|
|
9
|
+
geometry: THREE.BufferGeometry;
|
|
10
|
+
material: THREE.Material | THREE.Material[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type RingGuideConfig = {
|
|
14
|
+
/** Number of segments for the circle polyline. */
|
|
15
|
+
segments?: number;
|
|
16
|
+
/** Y offset for the guide relative to ring center. */
|
|
17
|
+
y?: number;
|
|
18
|
+
/** Provide your own material if you want full control. */
|
|
19
|
+
material?: THREE.LineBasicMaterial;
|
|
20
|
+
/** Convenience when not providing a material. */
|
|
21
|
+
color?: THREE.ColorRepresentation;
|
|
22
|
+
/** Convenience when not providing a material. Defaults to 0.35. */
|
|
23
|
+
opacity?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type RingOfMeshesProps<TItem extends NucleusBaseSmall = NucleusBaseSmall> = {
|
|
27
|
+
items: readonly TItem[];
|
|
28
|
+
ringRadius: number;
|
|
29
|
+
ringRotation?: [number, number, number];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Optional angular range (in radians) to distribute items across, instead of a full 2π ring.
|
|
33
|
+
* When provided:
|
|
34
|
+
* - count === 1: item is placed at the midpoint of the range
|
|
35
|
+
* - count > 1: items are evenly distributed from start → end
|
|
36
|
+
*/
|
|
37
|
+
angleRange?: { start: number; end: number };
|
|
38
|
+
/**
|
|
39
|
+
* Optional visual guide (circle line) drawn through the centers of the meshes.
|
|
40
|
+
* - Omit/undefined/false: do not render.
|
|
41
|
+
* - Provide config object: render with given config.
|
|
42
|
+
*/
|
|
43
|
+
ringGuide?: false | RingGuideConfig;
|
|
44
|
+
meshConfig: RingMeshConfig;
|
|
45
|
+
getMeshConfig?: (item: TItem, index: number) => RingMeshConfig;
|
|
46
|
+
getMeshProps?: (item: TItem, index: number) => ThreeElements['mesh'];
|
|
47
|
+
getItemGroupProps?: (item: TItem, index: number) => ThreeElements['group'];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Optional slot rendered after the mesh inside each positioned item group.
|
|
51
|
+
* Keeps this component focused on layout + mesh while allowing callers
|
|
52
|
+
* to render labels/overlays without duplicating ring math.
|
|
53
|
+
*/
|
|
54
|
+
renderItemChildren?: (item: TItem, index: number) => ReactNode;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function RingOfMeshes<TItem extends NucleusBaseSmall = NucleusBaseSmall>(props: RingOfMeshesProps<TItem>) {
|
|
58
|
+
const {
|
|
59
|
+
items,
|
|
60
|
+
ringRadius,
|
|
61
|
+
ringRotation = [0, 0, 0],
|
|
62
|
+
angleRange,
|
|
63
|
+
meshConfig,
|
|
64
|
+
ringGuide,
|
|
65
|
+
getMeshConfig,
|
|
66
|
+
getMeshProps,
|
|
67
|
+
getItemGroupProps,
|
|
68
|
+
renderItemChildren,
|
|
69
|
+
} = props;
|
|
70
|
+
|
|
71
|
+
const count = items.length;
|
|
72
|
+
|
|
73
|
+
const showRingGuide = ringGuide != null && ringGuide !== false;
|
|
74
|
+
const ringGuideConfig = showRingGuide ? ringGuide : undefined;
|
|
75
|
+
|
|
76
|
+
const ringGuideProvidedMaterial = ringGuideConfig?.material;
|
|
77
|
+
const ringGuideColor = ringGuideConfig?.color;
|
|
78
|
+
const ringGuideOpacity = ringGuideConfig?.opacity;
|
|
79
|
+
|
|
80
|
+
const ringGuideSegments = ringGuideConfig?.segments ?? 128;
|
|
81
|
+
const ringGuideY = ringGuideConfig?.y ?? 0;
|
|
82
|
+
|
|
83
|
+
const ringGuideGeometry = useMemo(() => {
|
|
84
|
+
if (!showRingGuide || count === 0) return null;
|
|
85
|
+
const points: THREE.Vector3[] = [];
|
|
86
|
+
for (let i = 0; i < ringGuideSegments; i++) {
|
|
87
|
+
const angle = (i / ringGuideSegments) * Math.PI * 2;
|
|
88
|
+
points.push(new THREE.Vector3(Math.cos(angle) * ringRadius, ringGuideY, Math.sin(angle) * ringRadius));
|
|
89
|
+
}
|
|
90
|
+
const geometry = new THREE.BufferGeometry();
|
|
91
|
+
geometry.setFromPoints(points);
|
|
92
|
+
return geometry;
|
|
93
|
+
}, [showRingGuide, count, ringGuideSegments, ringGuideY, ringRadius]);
|
|
94
|
+
|
|
95
|
+
const ringGuideMaterial = useMemo(() => {
|
|
96
|
+
if (!showRingGuide || count === 0) return null;
|
|
97
|
+
if (ringGuideProvidedMaterial) return ringGuideProvidedMaterial;
|
|
98
|
+
const opacity = ringGuideOpacity ?? 0.35;
|
|
99
|
+
return new THREE.LineBasicMaterial({
|
|
100
|
+
color: ringGuideColor ?? 'white',
|
|
101
|
+
transparent: opacity < 1,
|
|
102
|
+
opacity,
|
|
103
|
+
});
|
|
104
|
+
}, [showRingGuide, count, ringGuideProvidedMaterial, ringGuideOpacity, ringGuideColor]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
return () => {
|
|
108
|
+
ringGuideGeometry?.dispose();
|
|
109
|
+
if (showRingGuide && ringGuideProvidedMaterial == null) {
|
|
110
|
+
ringGuideMaterial?.dispose();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}, [ringGuideGeometry, ringGuideMaterial, showRingGuide, ringGuideProvidedMaterial]);
|
|
114
|
+
|
|
115
|
+
if (count === 0) return null;
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<group rotation={ringRotation}>
|
|
119
|
+
{showRingGuide && ringGuideGeometry && ringGuideMaterial ? (
|
|
120
|
+
<lineLoop geometry={ringGuideGeometry} material={ringGuideMaterial} />
|
|
121
|
+
) : null}
|
|
122
|
+
{items.map((item, index) => {
|
|
123
|
+
const angle = (() => {
|
|
124
|
+
if (angleRange) {
|
|
125
|
+
const { start, end } = angleRange;
|
|
126
|
+
if (count <= 1) return (start + end) / 2;
|
|
127
|
+
const t = index / (count - 1);
|
|
128
|
+
return start + t * (end - start);
|
|
129
|
+
}
|
|
130
|
+
return (index / count) * Math.PI * 2;
|
|
131
|
+
})();
|
|
132
|
+
const x = Math.cos(angle) * ringRadius;
|
|
133
|
+
const z = Math.sin(angle) * ringRadius;
|
|
134
|
+
|
|
135
|
+
const resolvedMeshConfig = getMeshConfig ? getMeshConfig(item, index) : meshConfig;
|
|
136
|
+
|
|
137
|
+
const rawGroupProps = getItemGroupProps ? getItemGroupProps(item, index) : undefined;
|
|
138
|
+
const rawMeshProps = getMeshProps ? getMeshProps(item, index) : undefined;
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<group key={item.identity} {...rawGroupProps} position={[x, 0, z]}>
|
|
142
|
+
<mesh {...rawMeshProps} geometry={resolvedMeshConfig.geometry} material={resolvedMeshConfig.material} />
|
|
143
|
+
{renderItemChildren ? renderItemChildren(item, index) : null}
|
|
144
|
+
</group>
|
|
145
|
+
);
|
|
146
|
+
})}
|
|
147
|
+
</group>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { NucleusBaseSmall } from '@toolproof-core/lib/types';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import { useEffect, useMemo } from 'react';
|
|
4
|
+
import * as THREE from 'three';
|
|
5
|
+
import type { ThreeElements } from '@react-three/fiber';
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
export type SpokeMeshConfig = {
|
|
9
|
+
geometry: THREE.BufferGeometry;
|
|
10
|
+
material: THREE.Material | THREE.Material[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type SpokeGuideConfig = {
|
|
14
|
+
/** Provide your own material if you want full control. */
|
|
15
|
+
material?: THREE.LineBasicMaterial;
|
|
16
|
+
/** Convenience when not providing a material. */
|
|
17
|
+
color?: THREE.ColorRepresentation;
|
|
18
|
+
/** Convenience when not providing a material. Defaults to 0.35. */
|
|
19
|
+
opacity?: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type SpokeOfMeshesProps<TItem extends NucleusBaseSmall = NucleusBaseSmall> = {
|
|
23
|
+
items: readonly TItem[];
|
|
24
|
+
|
|
25
|
+
/** World position for the origin-nearest mesh (items[0]). */
|
|
26
|
+
originNearestPosition: [number, number, number];
|
|
27
|
+
|
|
28
|
+
/** Direction in which the line extends. Will be normalized internally. */
|
|
29
|
+
direction: [number, number, number];
|
|
30
|
+
|
|
31
|
+
/** Optional rotation around the anchor position. */
|
|
32
|
+
spokeRotation?: [number, number, number];
|
|
33
|
+
|
|
34
|
+
/** Optional visual guide (straight line) drawn through the centers of the meshes. */
|
|
35
|
+
spokeGuide?: false | SpokeGuideConfig;
|
|
36
|
+
|
|
37
|
+
meshConfig: SpokeMeshConfig;
|
|
38
|
+
getMeshConfig?: (item: TItem, index: number) => SpokeMeshConfig;
|
|
39
|
+
getMeshProps?: (item: TItem, index: number) => ThreeElements['mesh'];
|
|
40
|
+
getItemGroupProps?: (item: TItem, index: number) => ThreeElements['group'];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Explicit spacing between adjacent meshes.
|
|
44
|
+
* If omitted, spacing is derived from `meshConfig.geometry` bounding sphere.
|
|
45
|
+
*/
|
|
46
|
+
itemSpacing?: number;
|
|
47
|
+
|
|
48
|
+
/** Multiplier applied when deriving spacing from geometry. */
|
|
49
|
+
itemSpacingMultiplier?: number;
|
|
50
|
+
|
|
51
|
+
/** Optional slot rendered after the mesh inside each positioned item group. */
|
|
52
|
+
renderItemChildren?: (item: TItem, index: number) => ReactNode;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function SpokeOfMeshes<TItem extends NucleusBaseSmall = NucleusBaseSmall>(props: SpokeOfMeshesProps<TItem>) {
|
|
56
|
+
const {
|
|
57
|
+
items,
|
|
58
|
+
originNearestPosition,
|
|
59
|
+
direction,
|
|
60
|
+
spokeRotation: spokeRotation = [0, 0, 0],
|
|
61
|
+
spokeGuide,
|
|
62
|
+
meshConfig,
|
|
63
|
+
getMeshConfig,
|
|
64
|
+
getMeshProps,
|
|
65
|
+
getItemGroupProps,
|
|
66
|
+
renderItemChildren,
|
|
67
|
+
itemSpacing,
|
|
68
|
+
itemSpacingMultiplier,
|
|
69
|
+
} = props;
|
|
70
|
+
|
|
71
|
+
const count = items.length;
|
|
72
|
+
const showSpokeGuide = spokeGuide != null && spokeGuide !== false;
|
|
73
|
+
const spokeGuideConfig = showSpokeGuide ? spokeGuide : undefined;
|
|
74
|
+
|
|
75
|
+
const spokeGuideProvidedMaterial = spokeGuideConfig?.material;
|
|
76
|
+
const spokeGuideColor = spokeGuideConfig?.color;
|
|
77
|
+
const spokeGuideOpacity = spokeGuideConfig?.opacity;
|
|
78
|
+
|
|
79
|
+
const directionUnit = useMemo(() => {
|
|
80
|
+
const [dx, dy, dz] = direction;
|
|
81
|
+
const vec = new THREE.Vector3(dx, dy, dz);
|
|
82
|
+
if (vec.lengthSq() < 1e-12) {
|
|
83
|
+
vec.set(1, 0, 0);
|
|
84
|
+
} else {
|
|
85
|
+
vec.normalize();
|
|
86
|
+
}
|
|
87
|
+
return vec;
|
|
88
|
+
}, [direction]);
|
|
89
|
+
|
|
90
|
+
const resolvedItemSpacing = useMemo(() => {
|
|
91
|
+
if (typeof itemSpacing === 'number' && Number.isFinite(itemSpacing) && itemSpacing > 0) {
|
|
92
|
+
return itemSpacing;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const geometry = meshConfig.geometry;
|
|
96
|
+
if (!geometry.boundingSphere) {
|
|
97
|
+
geometry.computeBoundingSphere();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const radius = geometry.boundingSphere?.radius;
|
|
101
|
+
const diameter = typeof radius === 'number' && Number.isFinite(radius) && radius > 0 ? radius * 2 : 1;
|
|
102
|
+
const mult = typeof itemSpacingMultiplier === 'number' && Number.isFinite(itemSpacingMultiplier) && itemSpacingMultiplier > 0 ? itemSpacingMultiplier : 1.0;
|
|
103
|
+
return diameter * mult;
|
|
104
|
+
}, [itemSpacing, itemSpacingMultiplier, meshConfig.geometry]);
|
|
105
|
+
|
|
106
|
+
const spokeGuideGeometry = useMemo(() => {
|
|
107
|
+
if (!showSpokeGuide || count < 2) return null;
|
|
108
|
+
|
|
109
|
+
const start = new THREE.Vector3(0, 0, 0);
|
|
110
|
+
const end = directionUnit.clone().multiplyScalar(resolvedItemSpacing * (count - 1));
|
|
111
|
+
|
|
112
|
+
const geometry = new THREE.BufferGeometry();
|
|
113
|
+
geometry.setFromPoints([start, end]);
|
|
114
|
+
return geometry;
|
|
115
|
+
}, [showSpokeGuide, count, directionUnit, resolvedItemSpacing]);
|
|
116
|
+
|
|
117
|
+
const spokeGuideMaterial = useMemo(() => {
|
|
118
|
+
if (!showSpokeGuide || count < 2) return null;
|
|
119
|
+
if (spokeGuideProvidedMaterial) return spokeGuideProvidedMaterial;
|
|
120
|
+
|
|
121
|
+
const opacity = spokeGuideOpacity ?? 0.35;
|
|
122
|
+
return new THREE.LineBasicMaterial({
|
|
123
|
+
color: spokeGuideColor ?? 'white',
|
|
124
|
+
transparent: opacity < 1,
|
|
125
|
+
opacity,
|
|
126
|
+
});
|
|
127
|
+
}, [showSpokeGuide, count, spokeGuideProvidedMaterial, spokeGuideOpacity, spokeGuideColor]);
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
return () => {
|
|
131
|
+
spokeGuideGeometry?.dispose();
|
|
132
|
+
if (showSpokeGuide && spokeGuideProvidedMaterial == null) {
|
|
133
|
+
spokeGuideMaterial?.dispose();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}, [spokeGuideGeometry, spokeGuideMaterial, showSpokeGuide, spokeGuideProvidedMaterial]);
|
|
137
|
+
|
|
138
|
+
if (count === 0) return null;
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<group position={originNearestPosition} rotation={spokeRotation}>
|
|
142
|
+
{showSpokeGuide && spokeGuideGeometry && spokeGuideMaterial ? (
|
|
143
|
+
<lineSegments geometry={spokeGuideGeometry} material={spokeGuideMaterial} />
|
|
144
|
+
) : null}
|
|
145
|
+
|
|
146
|
+
{items.map((item, index) => {
|
|
147
|
+
const offset = resolvedItemSpacing * index;
|
|
148
|
+
const x = directionUnit.x * offset;
|
|
149
|
+
const y = directionUnit.y * offset;
|
|
150
|
+
const z = directionUnit.z * offset;
|
|
151
|
+
|
|
152
|
+
const resolvedMeshConfig = getMeshConfig ? getMeshConfig(item, index) : meshConfig;
|
|
153
|
+
|
|
154
|
+
const rawGroupProps = getItemGroupProps ? getItemGroupProps(item, index) : undefined;
|
|
155
|
+
const rawMeshProps = getMeshProps ? getMeshProps(item, index) : undefined;
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<group key={item.identity} position={[x, y, z]}>
|
|
159
|
+
<group {...rawGroupProps}>
|
|
160
|
+
<mesh {...rawMeshProps} geometry={resolvedMeshConfig.geometry} material={resolvedMeshConfig.material} />
|
|
161
|
+
{renderItemChildren ? renderItemChildren(item, index) : null}
|
|
162
|
+
</group>
|
|
163
|
+
</group>
|
|
164
|
+
);
|
|
165
|
+
})}
|
|
166
|
+
</group>
|
|
167
|
+
);
|
|
168
|
+
}
|