@wandelbots/wandelbots-js-react-components 2.27.1 → 2.28.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 (48) hide show
  1. package/README.md +1 -1
  2. package/dist/Setup.d.ts +1 -1
  3. package/dist/Setup.d.ts.map +1 -1
  4. package/dist/components/3d-viewport/SafetyZonesRenderer.d.ts +2 -2
  5. package/dist/components/3d-viewport/SafetyZonesRenderer.d.ts.map +1 -1
  6. package/dist/components/3d-viewport/TrajectoryRenderer.d.ts +1 -1
  7. package/dist/components/3d-viewport/TrajectoryRenderer.d.ts.map +1 -1
  8. package/dist/components/robots/DHRobot.d.ts.map +1 -1
  9. package/dist/components/robots/GenericRobot.d.ts +2 -2
  10. package/dist/components/robots/GenericRobot.d.ts.map +1 -1
  11. package/dist/components/robots/Robot.d.ts +2 -2
  12. package/dist/components/robots/Robot.d.ts.map +1 -1
  13. package/dist/components/robots/RobotAnimator.d.ts.map +1 -1
  14. package/dist/components/robots/RobotAnimator.test.d.ts +2 -0
  15. package/dist/components/robots/RobotAnimator.test.d.ts.map +1 -0
  16. package/dist/components/robots/SupportedRobot.d.ts +3 -3
  17. package/dist/components/robots/SupportedRobot.d.ts.map +1 -1
  18. package/dist/components/utils/interpolation.d.ts +159 -0
  19. package/dist/components/utils/interpolation.d.ts.map +1 -0
  20. package/dist/components/utils/interpolation.test.d.ts +2 -0
  21. package/dist/components/utils/interpolation.test.d.ts.map +1 -0
  22. package/dist/externalizeComponent.d.ts +1 -1
  23. package/dist/externalizeComponent.d.ts.map +1 -1
  24. package/dist/index.cjs +39 -47
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +8251 -9820
  29. package/dist/index.js.map +1 -1
  30. package/dist/test/setup.d.ts +2 -0
  31. package/dist/test/setup.d.ts.map +1 -0
  32. package/package.json +33 -32
  33. package/src/Setup.tsx +1 -1
  34. package/src/components/3d-viewport/SafetyZonesRenderer.tsx +2 -2
  35. package/src/components/3d-viewport/TrajectoryRenderer.tsx +1 -1
  36. package/src/components/jogging/JoggingOptions.tsx +1 -1
  37. package/src/components/robots/DHRobot.tsx +37 -10
  38. package/src/components/robots/GenericRobot.tsx +4 -5
  39. package/src/components/robots/Robot.tsx +2 -2
  40. package/src/components/robots/RobotAnimator.test.tsx +113 -0
  41. package/src/components/robots/RobotAnimator.tsx +38 -23
  42. package/src/components/robots/SupportedRobot.tsx +3 -3
  43. package/src/components/utils/converters.ts +1 -1
  44. package/src/components/utils/interpolation.test.ts +1123 -0
  45. package/src/components/utils/interpolation.ts +379 -0
  46. package/src/externalizeComponent.tsx +1 -1
  47. package/src/index.ts +1 -0
  48. package/src/test/setup.ts +111 -0
@@ -0,0 +1,379 @@
1
+ import React from "react"
2
+
3
+ /**
4
+ * Smooth value interpolation utility using spring physics with tension and friction.
5
+ * Designed for React Three Fiber applications with smooth, natural animations.
6
+ *
7
+ * Features:
8
+ * - Spring physics with configurable tension and friction
9
+ * - Frame-rate independent using delta timing
10
+ * - Handles irregular frame timing and rapid target updates
11
+ * - Manual update() calls for useFrame integration (no automatic RAF loop)
12
+ * - Direct mutation for performance
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * // Basic spring animation
17
+ * const interpolator = new ValueInterpolator([0, 0, 0], {
18
+ * tension: 120, // Higher = faster, stiffer spring (default: 120)
19
+ * friction: 20, // Higher = more damping, less oscillation (default: 20)
20
+ * onChange: (values) => {
21
+ * robot.joints.forEach((joint, i) => {
22
+ * joint.rotation.y = values[i]
23
+ * })
24
+ * }
25
+ * })
26
+ *
27
+ * interpolator.setTarget([1.5, -0.8, 2.1])
28
+ *
29
+ * // React Three Fiber usage
30
+ * function MyComponent() {
31
+ * const [interpolator] = useInterpolation([0, 0, 0])
32
+ *
33
+ * useFrame((state, delta) => {
34
+ * interpolator.update(delta)
35
+ * })
36
+ *
37
+ * useEffect(() => {
38
+ * interpolator.setTarget([1, 2, 3])
39
+ * }, [])
40
+ *
41
+ * return <mesh position={interpolator.getCurrentValues()} />
42
+ * }
43
+ * ```
44
+ */
45
+
46
+ export interface InterpolationOptions {
47
+ /** Spring tension (higher = faster, stiffer spring) - default: 120 */
48
+ tension?: number
49
+ /** Spring friction (higher = more damping, less oscillation) - default: 20 */
50
+ friction?: number
51
+ /** Minimum threshold to consider interpolation complete - default: 0.001 */
52
+ threshold?: number
53
+ /** Callback when values change during interpolation */
54
+ onChange?: (values: number[]) => void
55
+ /** Callback when interpolation reaches target values */
56
+ onComplete?: (values: number[]) => void
57
+ }
58
+
59
+ export class ValueInterpolator {
60
+ private currentValues: number[] = []
61
+ private targetValues: number[] = []
62
+ private previousTargetValues: number[] = []
63
+ private targetUpdateTime: number = 0
64
+ private animationId: number | null = null
65
+ private options: Required<InterpolationOptions>
66
+ private updateCount: number = 0 // Track how many updates have occurred
67
+
68
+ // Spring physics state
69
+ private velocities: number[] = []
70
+
71
+ constructor(
72
+ initialValues: number[] = [],
73
+ options: InterpolationOptions = {},
74
+ ) {
75
+ this.options = {
76
+ tension: 120,
77
+ friction: 20,
78
+ threshold: 0.001,
79
+ onChange: () => {},
80
+ onComplete: () => {},
81
+ ...options,
82
+ }
83
+
84
+ this.currentValues = [...initialValues]
85
+ this.targetValues = [...initialValues]
86
+ this.previousTargetValues = [...initialValues]
87
+ this.velocities = new Array(initialValues.length).fill(0)
88
+ this.targetUpdateTime = performance.now()
89
+ this.updateCount = 0
90
+ }
91
+
92
+ /**
93
+ * Update interpolation using spring physics
94
+ *
95
+ * Call this method every frame for smooth animation. In React Three Fiber,
96
+ * call from within useFrame callback with the provided delta time.
97
+ *
98
+ * @param delta - Time elapsed since last update in seconds (e.g., 1/60 ≈ 0.0167 for 60fps)
99
+ * @returns true when interpolation is complete (all values reached their targets)
100
+ */
101
+ update(delta: number = 1 / 60): boolean {
102
+ let hasChanges = false
103
+ let isComplete = true
104
+
105
+ // Increment update counter for initialization smoothing
106
+ this.updateCount++
107
+
108
+ // Limit delta to prevent physics instability during large frame drops
109
+ const clampedDelta = Math.min(delta, 1 / 15) // Maximum 66ms frame time allowed
110
+
111
+ // Apply gentle ramp-up for the first few frames to prevent initial jumpiness
112
+ // Only apply reduced force for the very first frame to prevent jarring starts
113
+ const initializationFactor =
114
+ this.updateCount === 1
115
+ ? 0.7 // Slightly reduced force only on the very first frame
116
+ : 1
117
+
118
+ for (let i = 0; i < this.currentValues.length; i++) {
119
+ const current = this.currentValues[i]
120
+ const target = this.targetValues[i]
121
+ const velocity = this.velocities[i]
122
+
123
+ // Calculate spring physics forces
124
+ const displacement = target - current
125
+ const springForce =
126
+ displacement * this.options.tension * initializationFactor
127
+ const dampingForce = velocity * this.options.friction
128
+
129
+ // Calculate acceleration from net force (F = ma, assuming mass = 1)
130
+ const acceleration = springForce - dampingForce
131
+
132
+ // Integrate physics using Verlet method for numerical stability
133
+ const newVelocity = velocity + acceleration * clampedDelta
134
+ const newValue = current + newVelocity * clampedDelta
135
+
136
+ // Determine if this value has settled (close to target with low velocity)
137
+ const isValueComplete =
138
+ Math.abs(displacement) < this.options.threshold &&
139
+ Math.abs(newVelocity) < this.options.threshold * 10
140
+
141
+ if (!isValueComplete) {
142
+ isComplete = false
143
+ // Continue spring animation
144
+ this.currentValues[i] = newValue
145
+ this.velocities[i] = newVelocity
146
+ hasChanges = true
147
+ } else {
148
+ // Snap exactly to target when close enough (prevents endless micro-movements)
149
+ if (this.currentValues[i] !== target) {
150
+ this.currentValues[i] = target
151
+ this.velocities[i] = 0
152
+ hasChanges = true
153
+ }
154
+ }
155
+ }
156
+
157
+ if (hasChanges) {
158
+ this.options.onChange(this.currentValues)
159
+ }
160
+
161
+ if (isComplete) {
162
+ this.options.onComplete(this.currentValues)
163
+ }
164
+
165
+ return isComplete
166
+ }
167
+
168
+ /**
169
+ * Set new target values for the interpolation to move towards
170
+ *
171
+ * Includes smart blending for very rapid target updates (faster than 120fps)
172
+ * to prevent jarring movements when targets change frequently.
173
+ */
174
+ setTarget(newValues: number[]): void {
175
+ const now = performance.now()
176
+ const timeSinceLastUpdate = now - this.targetUpdateTime
177
+
178
+ // Store previous target for smooth transitions
179
+ this.previousTargetValues = [...this.targetValues]
180
+ this.targetValues = [...newValues]
181
+ this.targetUpdateTime = now
182
+
183
+ // Reset update counter for smooth initialization when target changes
184
+ this.updateCount = 0
185
+
186
+ // Apply target blending for extremely rapid updates to prevent jarring jumps
187
+ // Only activates when targets change faster than 120fps (< 8ms between updates)
188
+ // AND this is not the first target being set (avoid blending initial target with initial values)
189
+ const isInitialTargetSet = this.previousTargetValues.every(
190
+ (val, i) => val === this.currentValues[i],
191
+ )
192
+
193
+ if (
194
+ timeSinceLastUpdate < 8 &&
195
+ timeSinceLastUpdate > 0 &&
196
+ this.previousTargetValues.length > 0 &&
197
+ !isInitialTargetSet // Don't blend if this is the first meaningful target change
198
+ ) {
199
+ // Blend between previous and new target based on time elapsed
200
+ const blendFactor = Math.min(timeSinceLastUpdate / 8, 1) // 0 to 1 over 8ms
201
+
202
+ for (let i = 0; i < this.targetValues.length; i++) {
203
+ const prev = this.previousTargetValues[i] || 0
204
+ const next = newValues[i] || 0
205
+
206
+ // Only blend significant changes to avoid unnecessary smoothing
207
+ const change = Math.abs(next - prev)
208
+ if (change > 0.1) {
209
+ this.targetValues[i] = prev + (next - prev) * blendFactor
210
+ }
211
+ }
212
+ }
213
+
214
+ // Ensure value and velocity arrays have matching lengths
215
+ while (this.currentValues.length < newValues.length) {
216
+ this.currentValues.push(newValues[this.currentValues.length])
217
+ this.velocities.push(0) // New values start with zero velocity
218
+ }
219
+ if (this.currentValues.length > newValues.length) {
220
+ this.currentValues = this.currentValues.slice(0, newValues.length)
221
+ this.velocities = this.velocities.slice(0, newValues.length)
222
+ }
223
+
224
+ // Does not start automatic interpolation - requires manual update() calls
225
+ // This design prevents conflicts when using with React Three Fiber's useFrame
226
+ }
227
+
228
+ /**
229
+ * Get a copy of all current interpolated values
230
+ */
231
+ getCurrentValues(): number[] {
232
+ return [...this.currentValues]
233
+ }
234
+
235
+ /**
236
+ * Get a single interpolated value by its array index
237
+ */
238
+ getValue(index: number): number {
239
+ return this.currentValues[index] ?? 0
240
+ }
241
+
242
+ /**
243
+ * Check if automatic interpolation is currently running
244
+ *
245
+ * This only tracks auto-interpolation started with startAutoInterpolation().
246
+ * Manual update() calls are not tracked by this method.
247
+ */
248
+ isInterpolating(): boolean {
249
+ return this.animationId !== null
250
+ }
251
+
252
+ /**
253
+ * Stop automatic interpolation if it's running
254
+ *
255
+ * This cancels the internal animation frame loop but does not affect
256
+ * manual update() calls.
257
+ */
258
+ stop(): void {
259
+ if (this.animationId !== null) {
260
+ cancelAnimationFrame(this.animationId)
261
+ this.animationId = null
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Instantly set values without interpolation
267
+ */
268
+ setImmediate(values: number[]): void {
269
+ this.stop()
270
+ this.currentValues = [...values]
271
+ this.targetValues = [...values]
272
+ this.previousTargetValues = [...values]
273
+ this.velocities = new Array(values.length).fill(0) // Reset velocities
274
+ this.targetUpdateTime = performance.now()
275
+ this.updateCount = 0 // Reset update counter
276
+ this.options.onChange(this.currentValues)
277
+ }
278
+
279
+ /**
280
+ * Update interpolation options
281
+ */
282
+ updateOptions(newOptions: Partial<InterpolationOptions>): void {
283
+ this.options = { ...this.options, ...newOptions }
284
+ }
285
+
286
+ /**
287
+ * Start automatic interpolation with an animation loop
288
+ *
289
+ * This begins a requestAnimationFrame loop that calls update() automatically.
290
+ * For React Three Fiber components, prefer using manual update() calls
291
+ * within useFrame hooks instead.
292
+ */
293
+ startAutoInterpolation(): void {
294
+ this.startInterpolation()
295
+ }
296
+
297
+ /**
298
+ * Clean up all resources and stop any running animations
299
+ *
300
+ * This cancels any active animation frames and resets internal state.
301
+ * Call this when the component unmounts or is no longer needed.
302
+ */
303
+ destroy(): void {
304
+ this.stop()
305
+ }
306
+
307
+ private startInterpolation(): void {
308
+ if (this.animationId !== null) {
309
+ return // Already interpolating
310
+ }
311
+
312
+ this.animate()
313
+ }
314
+
315
+ private animate = (): void => {
316
+ // Use delta timing with a fallback for consistent automatic animations
317
+ const isComplete = this.update(1 / 60) // Simulate 60fps for auto-interpolation
318
+
319
+ if (!isComplete) {
320
+ this.animationId = requestAnimationFrame(this.animate)
321
+ } else {
322
+ this.animationId = null
323
+ }
324
+ }
325
+ }
326
+
327
+ /**
328
+ * React hook for using the ValueInterpolator with useFrame
329
+ *
330
+ * This hook creates a ValueInterpolator that uses spring physics for smooth,
331
+ * natural animations. Call interpolator.update(delta) in your useFrame callback.
332
+ *
333
+ * @example
334
+ * ```tsx
335
+ * function AnimatedMesh() {
336
+ * const [interpolator] = useInterpolation([0, 0, 0], {
337
+ * tension: 120, // Higher = faster spring
338
+ * friction: 20 // Higher = more damping
339
+ * })
340
+ * const meshRef = useRef()
341
+ *
342
+ * useFrame((state, delta) => {
343
+ * if (interpolator.update(delta)) {
344
+ * // Animation complete
345
+ * }
346
+ * // Apply current values directly to mesh
347
+ * const [x, y, z] = interpolator.getCurrentValues()
348
+ * meshRef.current.position.set(x, y, z)
349
+ * })
350
+ *
351
+ * return <mesh ref={meshRef} />
352
+ * }
353
+ * ```
354
+ */
355
+ export function useInterpolation(
356
+ initialValues: number[] = [],
357
+ options: InterpolationOptions = {},
358
+ ): [ValueInterpolator] {
359
+ const interpolatorRef = React.useRef<ValueInterpolator | null>(null)
360
+
361
+ // Initialize interpolator
362
+ if (!interpolatorRef.current) {
363
+ interpolatorRef.current = new ValueInterpolator(initialValues, options)
364
+ }
365
+
366
+ // Update options when they change
367
+ React.useEffect(() => {
368
+ interpolatorRef.current?.updateOptions(options)
369
+ }, [options])
370
+
371
+ // Cleanup on unmount
372
+ React.useEffect(() => {
373
+ return () => {
374
+ interpolatorRef.current?.destroy()
375
+ }
376
+ }, [])
377
+
378
+ return [interpolatorRef.current!]
379
+ }
@@ -7,7 +7,7 @@ import { i18n } from "./i18n/config"
7
7
  * be provided by the user application; this wrapper ensures
8
8
  * they can be used either way.
9
9
  */
10
- export function externalizeComponent<T extends JSX.ElementType>(
10
+ export function externalizeComponent<T extends React.JSX.ElementType>(
11
11
  Component: T,
12
12
  ): T {
13
13
  const WrappedComponent = ((props: T) => (
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export * from "./components/robots/SupportedRobot"
16
16
  export * from "./components/safetyBar/SafetyBar"
17
17
  export * from "./components/SelectableFab"
18
18
  export * from "./components/utils/hooks"
19
+ export * from "./components/utils/interpolation"
19
20
  export * from "./components/VelocitySlider"
20
21
  export * from "./components/wandelscript-editor/WandelscriptEditor"
21
22
  export * from "./i18n/config"
@@ -0,0 +1,111 @@
1
+ import "@testing-library/jest-dom"
2
+ import { beforeAll, vi } from "vitest"
3
+
4
+ // Mock ResizeObserver
5
+ global.ResizeObserver = vi.fn().mockImplementation(() => ({
6
+ observe: vi.fn(),
7
+ unobserve: vi.fn(),
8
+ disconnect: vi.fn(),
9
+ }))
10
+
11
+ // Mock IntersectionObserver
12
+ global.IntersectionObserver = vi.fn().mockImplementation(() => ({
13
+ observe: vi.fn(),
14
+ unobserve: vi.fn(),
15
+ disconnect: vi.fn(),
16
+ }))
17
+
18
+ // Mock matchMedia
19
+ Object.defineProperty(window, "matchMedia", {
20
+ writable: true,
21
+ value: vi.fn().mockImplementation((query) => ({
22
+ matches: false,
23
+ media: query,
24
+ onchange: null,
25
+ addListener: vi.fn(),
26
+ removeListener: vi.fn(),
27
+ addEventListener: vi.fn(),
28
+ removeEventListener: vi.fn(),
29
+ dispatchEvent: vi.fn(),
30
+ })),
31
+ })
32
+
33
+ // Mock WebGL context for Three.js
34
+ const mockWebGLContext = {
35
+ getExtension: vi.fn(),
36
+ getParameter: vi.fn(),
37
+ createShader: vi.fn(),
38
+ shaderSource: vi.fn(),
39
+ compileShader: vi.fn(),
40
+ createProgram: vi.fn(),
41
+ attachShader: vi.fn(),
42
+ linkProgram: vi.fn(),
43
+ useProgram: vi.fn(),
44
+ createBuffer: vi.fn(),
45
+ bindBuffer: vi.fn(),
46
+ bufferData: vi.fn(),
47
+ createTexture: vi.fn(),
48
+ bindTexture: vi.fn(),
49
+ texImage2D: vi.fn(),
50
+ texParameteri: vi.fn(),
51
+ generateMipmap: vi.fn(),
52
+ viewport: vi.fn(),
53
+ clearColor: vi.fn(),
54
+ clear: vi.fn(),
55
+ drawArrays: vi.fn(),
56
+ drawElements: vi.fn(),
57
+ enableVertexAttribArray: vi.fn(),
58
+ vertexAttribPointer: vi.fn(),
59
+ uniform1f: vi.fn(),
60
+ uniform2f: vi.fn(),
61
+ uniform3f: vi.fn(),
62
+ uniform4f: vi.fn(),
63
+ uniformMatrix4fv: vi.fn(),
64
+ getUniformLocation: vi.fn(),
65
+ getAttribLocation: vi.fn(),
66
+ enable: vi.fn(),
67
+ disable: vi.fn(),
68
+ depthFunc: vi.fn(),
69
+ blendFunc: vi.fn(),
70
+ cullFace: vi.fn(),
71
+ frontFace: vi.fn(),
72
+ VERTEX_SHADER: 35633,
73
+ FRAGMENT_SHADER: 35632,
74
+ ARRAY_BUFFER: 34962,
75
+ ELEMENT_ARRAY_BUFFER: 34963,
76
+ STATIC_DRAW: 35044,
77
+ DYNAMIC_DRAW: 35048,
78
+ TEXTURE_2D: 3553,
79
+ RGBA: 6408,
80
+ UNSIGNED_BYTE: 5121,
81
+ TEXTURE_MAG_FILTER: 10240,
82
+ TEXTURE_MIN_FILTER: 10241,
83
+ LINEAR: 9729,
84
+ COLOR_BUFFER_BIT: 16384,
85
+ DEPTH_BUFFER_BIT: 256,
86
+ DEPTH_TEST: 2929,
87
+ BLEND: 3042,
88
+ SRC_ALPHA: 770,
89
+ ONE_MINUS_SRC_ALPHA: 771,
90
+ CULL_FACE: 2884,
91
+ BACK: 1029,
92
+ CCW: 2305,
93
+ FLOAT: 5126,
94
+ FALSE: 0,
95
+ TRUE: 1,
96
+ }
97
+
98
+ HTMLCanvasElement.prototype.getContext = vi
99
+ .fn()
100
+ .mockImplementation((contextType) => {
101
+ if (contextType === "webgl" || contextType === "experimental-webgl") {
102
+ return mockWebGLContext
103
+ }
104
+ return null
105
+ })
106
+
107
+ beforeAll(() => {
108
+ // Suppress console errors in tests
109
+ vi.spyOn(console, "error").mockImplementation(() => {})
110
+ vi.spyOn(console, "warn").mockImplementation(() => {})
111
+ })