sceneview-mcp 3.6.2 → 3.6.5

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.
@@ -0,0 +1,576 @@
1
+ /**
2
+ * generate-animation.ts
3
+ *
4
+ * Generates compilable code for animation playback, morph targets,
5
+ * transitions, and spring physics in SceneView (Android + iOS).
6
+ */
7
+ const ANDROID_ANIMATIONS = {
8
+ "model-playback": {
9
+ description: "Play embedded glTF skeletal animation with autoAnimate or manual control",
10
+ code: `@Composable
11
+ fun AnimatedModelScreen() {
12
+ val engine = rememberEngine()
13
+ val modelLoader = rememberModelLoader(engine)
14
+ val modelInstance = rememberModelInstance(modelLoader, "models/character.glb")
15
+
16
+ SceneView(
17
+ modifier = Modifier.fillMaxSize(),
18
+ engine = engine,
19
+ modelLoader = modelLoader,
20
+ cameraManipulator = rememberCameraManipulator()
21
+ ) {
22
+ modelInstance?.let { instance ->
23
+ ModelNode(
24
+ modelInstance = instance,
25
+ scaleToUnits = 1.0f,
26
+ centerOrigin = Position(0f, 0f, 0f),
27
+ // autoAnimate plays the first embedded animation in a loop
28
+ autoAnimate = true
29
+ )
30
+ }
31
+
32
+ LightNode(
33
+ engine = engine,
34
+ type = LightManager.Type.DIRECTIONAL,
35
+ apply = {
36
+ intensity(100_000f)
37
+ castShadows(true)
38
+ }
39
+ )
40
+ }
41
+ }`,
42
+ notes: [
43
+ "The GLB file must contain embedded animations (skeletal or morph target).",
44
+ "Use `autoAnimate = true` for automatic looping of the first animation.",
45
+ "For manual control, use the Animator API in an `onFrame` callback.",
46
+ ],
47
+ },
48
+ "morph-targets": {
49
+ description: "Animate morph targets (blend shapes) on a glTF model",
50
+ code: `@Composable
51
+ fun MorphTargetScreen() {
52
+ val engine = rememberEngine()
53
+ val modelLoader = rememberModelLoader(engine)
54
+ val modelInstance = rememberModelInstance(modelLoader, "models/face.glb")
55
+ var smileWeight by remember { mutableFloatStateOf(0f) }
56
+
57
+ val infiniteTransition = rememberInfiniteTransition(label = "morph")
58
+ val animatedWeight by infiniteTransition.animateFloat(
59
+ initialValue = 0f,
60
+ targetValue = 1f,
61
+ animationSpec = infiniteRepeatable(
62
+ animation = tween(durationMillis = 2000, easing = FastOutSlowInEasing),
63
+ repeatMode = RepeatMode.Reverse
64
+ ),
65
+ label = "smile"
66
+ )
67
+
68
+ SceneView(
69
+ modifier = Modifier.fillMaxSize(),
70
+ engine = engine,
71
+ modelLoader = modelLoader,
72
+ cameraManipulator = rememberCameraManipulator(),
73
+ onFrame = { _ ->
74
+ modelInstance?.let { instance ->
75
+ // Apply morph target weights
76
+ // Index 0 is typically the first morph target defined in the GLB
77
+ instance.animator.let { animator ->
78
+ if (animator.morphTargetCount > 0) {
79
+ animator.setMorphTargetWeight(0, animatedWeight)
80
+ }
81
+ }
82
+ }
83
+ }
84
+ ) {
85
+ modelInstance?.let { instance ->
86
+ ModelNode(
87
+ modelInstance = instance,
88
+ scaleToUnits = 1.0f,
89
+ centerOrigin = Position(0f, 0f, 0f)
90
+ )
91
+ }
92
+
93
+ LightNode(
94
+ engine = engine,
95
+ type = LightManager.Type.DIRECTIONAL,
96
+ apply = { intensity(100_000f) }
97
+ )
98
+ }
99
+ }`,
100
+ notes: [
101
+ "The GLB model must contain morph targets (also called blend shapes).",
102
+ "Morph target indices correspond to the order defined in the glTF file.",
103
+ "Check `animator.morphTargetCount` before applying weights.",
104
+ "Weight range is 0.0 to 1.0.",
105
+ ],
106
+ },
107
+ "spring-position": {
108
+ description: "Spring-based position animation using KMP core SpringAnimation",
109
+ code: `@Composable
110
+ fun SpringPositionDemo() {
111
+ val engine = rememberEngine()
112
+ val modelLoader = rememberModelLoader(engine)
113
+ val modelInstance = rememberModelInstance(modelLoader, "models/cube.glb")
114
+
115
+ var targetY by remember { mutableFloatStateOf(0f) }
116
+ val springY = remember {
117
+ SpringAnimation(
118
+ spring = Spring(
119
+ stiffness = Spring.StiffnessMedium,
120
+ dampingRatio = Spring.DampingRatioMediumBouncy
121
+ ),
122
+ initialValue = 0f
123
+ )
124
+ }
125
+
126
+ Column(modifier = Modifier.fillMaxSize()) {
127
+ SceneView(
128
+ modifier = Modifier.weight(1f).fillMaxWidth(),
129
+ engine = engine,
130
+ modelLoader = modelLoader,
131
+ cameraManipulator = rememberCameraManipulator(),
132
+ onFrame = { _ ->
133
+ springY.target = targetY
134
+ springY.advance(0.016f)
135
+ }
136
+ ) {
137
+ modelInstance?.let { instance ->
138
+ ModelNode(
139
+ modelInstance = instance,
140
+ scaleToUnits = 0.5f,
141
+ position = Position(0f, springY.value, 0f)
142
+ )
143
+ }
144
+
145
+ LightNode(
146
+ engine = engine,
147
+ type = LightManager.Type.DIRECTIONAL,
148
+ apply = { intensity(100_000f) }
149
+ )
150
+ }
151
+
152
+ Button(
153
+ onClick = { targetY = if (targetY < 1f) 2f else 0f },
154
+ modifier = Modifier.padding(16.dp).fillMaxWidth()
155
+ ) {
156
+ Text("Bounce")
157
+ }
158
+ }
159
+ }`,
160
+ notes: [
161
+ "SpringAnimation is from the `sceneview-core` KMP module.",
162
+ "Spring presets: StiffnessHigh (snappy), StiffnessMedium (general), StiffnessLow (heavy bounce).",
163
+ "DampingRatio: NoBouncy (1.0), LowBouncy (0.75), MediumBouncy (0.5), HighBouncy (0.2).",
164
+ ],
165
+ },
166
+ "spring-scale": {
167
+ description: "Spring-based scale animation for bouncy resize effects",
168
+ code: `@Composable
169
+ fun SpringScaleDemo() {
170
+ val engine = rememberEngine()
171
+ val modelLoader = rememberModelLoader(engine)
172
+ val modelInstance = rememberModelInstance(modelLoader, "models/star.glb")
173
+
174
+ var isExpanded by remember { mutableStateOf(false) }
175
+ val scale by animateFloatAsState(
176
+ targetValue = if (isExpanded) 2.0f else 1.0f,
177
+ animationSpec = spring(
178
+ dampingRatio = Spring.DampingRatioMediumBouncy,
179
+ stiffness = Spring.StiffnessMedium
180
+ ),
181
+ label = "scale"
182
+ )
183
+
184
+ Box(modifier = Modifier.fillMaxSize()) {
185
+ SceneView(
186
+ modifier = Modifier.fillMaxSize(),
187
+ engine = engine,
188
+ modelLoader = modelLoader,
189
+ cameraManipulator = rememberCameraManipulator(),
190
+ onTouchEvent = { event, _ ->
191
+ if (event.action == MotionEvent.ACTION_UP) {
192
+ isExpanded = !isExpanded
193
+ }
194
+ true
195
+ }
196
+ ) {
197
+ modelInstance?.let { instance ->
198
+ ModelNode(
199
+ modelInstance = instance,
200
+ scaleToUnits = scale,
201
+ centerOrigin = Position(0f, 0f, 0f)
202
+ )
203
+ }
204
+
205
+ LightNode(
206
+ engine = engine,
207
+ type = LightManager.Type.DIRECTIONAL,
208
+ apply = { intensity(100_000f) }
209
+ )
210
+ }
211
+
212
+ Text(
213
+ "Tap to \${if (isExpanded) "shrink" else "expand"}",
214
+ modifier = Modifier.align(Alignment.BottomCenter).padding(24.dp),
215
+ color = Color.White,
216
+ fontSize = 18.sp
217
+ )
218
+ }
219
+ }`,
220
+ notes: [
221
+ "Uses Compose's built-in `animateFloatAsState` with spring spec.",
222
+ "Tap anywhere on the scene to toggle between expanded and normal size.",
223
+ "The spring animation gives a natural bouncy feel.",
224
+ ],
225
+ },
226
+ "compose-rotation": {
227
+ description: "Continuous rotation animation using Compose InfiniteTransition",
228
+ code: `@Composable
229
+ fun RotatingModelScreen() {
230
+ val engine = rememberEngine()
231
+ val modelLoader = rememberModelLoader(engine)
232
+ val modelInstance = rememberModelInstance(modelLoader, "models/shoe.glb")
233
+
234
+ val infiniteTransition = rememberInfiniteTransition(label = "rotate")
235
+ val rotationY by infiniteTransition.animateFloat(
236
+ initialValue = 0f,
237
+ targetValue = 360f,
238
+ animationSpec = infiniteRepeatable(
239
+ animation = tween(durationMillis = 5000, easing = LinearEasing),
240
+ repeatMode = RepeatMode.Restart
241
+ ),
242
+ label = "rotY"
243
+ )
244
+
245
+ SceneView(
246
+ modifier = Modifier.fillMaxSize(),
247
+ engine = engine,
248
+ modelLoader = modelLoader,
249
+ cameraManipulator = rememberCameraManipulator()
250
+ ) {
251
+ modelInstance?.let { instance ->
252
+ ModelNode(
253
+ modelInstance = instance,
254
+ scaleToUnits = 1.0f,
255
+ rotation = Rotation(0f, rotationY, 0f),
256
+ centerOrigin = Position(0f, 0f, 0f)
257
+ )
258
+ }
259
+
260
+ LightNode(
261
+ engine = engine,
262
+ type = LightManager.Type.DIRECTIONAL,
263
+ apply = {
264
+ intensity(100_000f)
265
+ castShadows(true)
266
+ }
267
+ )
268
+ }
269
+ }`,
270
+ notes: [
271
+ "Uses Compose's InfiniteTransition for smooth, battery-efficient rotation.",
272
+ "Rotation is in degrees around the Y axis (turntable style).",
273
+ "Change `durationMillis` to control rotation speed.",
274
+ ],
275
+ },
276
+ "compose-scale": {
277
+ description: "Animate scale with Compose animateFloatAsState for toggle effects",
278
+ code: `@Composable
279
+ fun ScaleToggleScreen() {
280
+ val engine = rememberEngine()
281
+ val modelLoader = rememberModelLoader(engine)
282
+ val modelInstance = rememberModelInstance(modelLoader, "models/trophy.glb")
283
+
284
+ var showModel by remember { mutableStateOf(true) }
285
+ val scale by animateFloatAsState(
286
+ targetValue = if (showModel) 1.0f else 0.01f,
287
+ animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing),
288
+ label = "scale"
289
+ )
290
+
291
+ SceneView(
292
+ modifier = Modifier.fillMaxSize(),
293
+ engine = engine,
294
+ modelLoader = modelLoader,
295
+ cameraManipulator = rememberCameraManipulator()
296
+ ) {
297
+ modelInstance?.let { instance ->
298
+ ModelNode(
299
+ modelInstance = instance,
300
+ scaleToUnits = scale,
301
+ centerOrigin = Position(0f, 0f, 0f)
302
+ )
303
+ }
304
+
305
+ LightNode(
306
+ engine = engine,
307
+ type = LightManager.Type.DIRECTIONAL,
308
+ apply = { intensity(100_000f) }
309
+ )
310
+ }
311
+ }`,
312
+ notes: [
313
+ "Animate scale to near-zero (0.01f) instead of removing the node for smooth disappear.",
314
+ "Combine with `animateColorAsState` for material color transitions.",
315
+ ],
316
+ },
317
+ "smooth-follow": {
318
+ description: "Smooth interpolation for a node following a moving target",
319
+ code: `@Composable
320
+ fun SmoothFollowDemo() {
321
+ val engine = rememberEngine()
322
+ val modelLoader = rememberModelLoader(engine)
323
+ val modelInstance = rememberModelInstance(modelLoader, "models/drone.glb")
324
+
325
+ val smoothPosition = remember { SmoothTransform(smoothTime = 0.3f) }
326
+ var targetPosition by remember { mutableStateOf(Position(0f, 1f, 0f)) }
327
+ var time by remember { mutableFloatStateOf(0f) }
328
+
329
+ SceneView(
330
+ modifier = Modifier.fillMaxSize(),
331
+ engine = engine,
332
+ modelLoader = modelLoader,
333
+ cameraManipulator = rememberCameraManipulator(),
334
+ onFrame = { frameTimeNanos ->
335
+ time += 0.016f
336
+ // Move target in a circle
337
+ targetPosition = Position(
338
+ kotlin.math.sin(time) * 2f,
339
+ 1f + kotlin.math.sin(time * 2f) * 0.5f,
340
+ kotlin.math.cos(time) * 2f
341
+ )
342
+ smoothPosition.target = targetPosition
343
+ smoothPosition.advance(0.016f)
344
+ }
345
+ ) {
346
+ modelInstance?.let { instance ->
347
+ ModelNode(
348
+ modelInstance = instance,
349
+ scaleToUnits = 0.3f,
350
+ position = smoothPosition.current
351
+ )
352
+ }
353
+
354
+ LightNode(
355
+ engine = engine,
356
+ type = LightManager.Type.DIRECTIONAL,
357
+ apply = { intensity(100_000f) }
358
+ )
359
+ }
360
+ }`,
361
+ notes: [
362
+ "SmoothTransform is from the `sceneview-core` KMP module.",
363
+ "Ideal for camera follow, target tracking, or any smooth interpolation.",
364
+ "`smoothTime` controls how quickly the node catches up (lower = faster).",
365
+ ],
366
+ },
367
+ "transition": {
368
+ description: "Animated state transition between two model positions",
369
+ code: `@Composable
370
+ fun TransitionDemo() {
371
+ val engine = rememberEngine()
372
+ val modelLoader = rememberModelLoader(engine)
373
+ val modelA = rememberModelInstance(modelLoader, "models/chair.glb")
374
+ val modelB = rememberModelInstance(modelLoader, "models/table.glb")
375
+
376
+ var isSlotA by remember { mutableStateOf(true) }
377
+ val positionX by animateFloatAsState(
378
+ targetValue = if (isSlotA) -1.5f else 1.5f,
379
+ animationSpec = spring(
380
+ dampingRatio = Spring.DampingRatioLowBouncy,
381
+ stiffness = Spring.StiffnessMediumLow
382
+ ),
383
+ label = "posX"
384
+ )
385
+
386
+ Column(modifier = Modifier.fillMaxSize()) {
387
+ SceneView(
388
+ modifier = Modifier.weight(1f).fillMaxWidth(),
389
+ engine = engine,
390
+ modelLoader = modelLoader,
391
+ cameraManipulator = rememberCameraManipulator()
392
+ ) {
393
+ modelA?.let { instance ->
394
+ ModelNode(
395
+ modelInstance = instance,
396
+ scaleToUnits = 0.8f,
397
+ position = Position(positionX, 0f, 0f),
398
+ centerOrigin = Position(0f, 0f, 0f)
399
+ )
400
+ }
401
+
402
+ modelB?.let { instance ->
403
+ ModelNode(
404
+ modelInstance = instance,
405
+ scaleToUnits = 0.8f,
406
+ position = Position(-positionX, 0f, 0f),
407
+ centerOrigin = Position(0f, 0f, 0f)
408
+ )
409
+ }
410
+
411
+ LightNode(
412
+ engine = engine,
413
+ type = LightManager.Type.DIRECTIONAL,
414
+ apply = { intensity(100_000f) }
415
+ )
416
+ }
417
+
418
+ Button(
419
+ onClick = { isSlotA = !isSlotA },
420
+ modifier = Modifier.padding(16.dp).fillMaxWidth()
421
+ ) {
422
+ Text("Swap Positions")
423
+ }
424
+ }
425
+ }`,
426
+ notes: [
427
+ "Uses Compose spring animation for smooth position transitions.",
428
+ "Both models animate simultaneously in opposite directions.",
429
+ "Adjust spring parameters for different feels.",
430
+ ],
431
+ },
432
+ };
433
+ const IOS_ANIMATIONS = {
434
+ "model-playback": {
435
+ description: "Play embedded USDZ animation in SwiftUI",
436
+ code: `import SwiftUI
437
+ import SceneViewSwift
438
+ import RealityKit
439
+
440
+ struct AnimatedModelView: View {
441
+ @State private var model: ModelNode?
442
+
443
+ var body: some View {
444
+ SceneView { root in
445
+ if let model {
446
+ root.addChild(model.entity)
447
+ // RealityKit plays animations automatically if present
448
+ model.entity.availableAnimations.forEach { animation in
449
+ model.entity.playAnimation(animation.repeat())
450
+ }
451
+ }
452
+ }
453
+ .cameraControls(.orbit)
454
+ .task {
455
+ do {
456
+ model = try await ModelNode.load("models/character.usdz")
457
+ model?.scaleToUnits(1.0)
458
+ } catch {
459
+ print("Failed to load model: \\(error)")
460
+ }
461
+ }
462
+ }
463
+ }`,
464
+ notes: [
465
+ "USDZ files can embed animations. RealityKit plays them via `playAnimation()`.",
466
+ "Use `.repeat()` for looping, `.repeat(count:)` for finite repeats.",
467
+ "Check `entity.availableAnimations` to list all embedded animations.",
468
+ ],
469
+ },
470
+ "spring-position": {
471
+ description: "Spring-based position animation in SwiftUI with withAnimation",
472
+ code: `import SwiftUI
473
+ import SceneViewSwift
474
+ import RealityKit
475
+
476
+ struct SpringPositionView: View {
477
+ @State private var model: ModelNode?
478
+ @State private var isUp = false
479
+
480
+ var body: some View {
481
+ VStack {
482
+ SceneView { root in
483
+ if let model {
484
+ root.addChild(model.entity)
485
+ let y: Float = isUp ? 1.5 : 0.0
486
+ model.entity.position = .init(x: 0, y: y, z: 0)
487
+ }
488
+ }
489
+ .cameraControls(.orbit)
490
+
491
+ Button("Bounce") {
492
+ withAnimation(.spring(response: 0.5, dampingFraction: 0.4, blendDuration: 0)) {
493
+ isUp.toggle()
494
+ }
495
+ }
496
+ .padding()
497
+ }
498
+ .task {
499
+ do {
500
+ model = try await ModelNode.load("models/cube.usdz")
501
+ model?.scaleToUnits(0.5)
502
+ } catch {
503
+ print("Failed to load: \\(error)")
504
+ }
505
+ }
506
+ }
507
+ }`,
508
+ notes: [
509
+ "SwiftUI's `withAnimation(.spring(...))` animates state changes.",
510
+ "For RealityKit entity transforms, use `move(to:relativeTo:duration:)` for smooth transitions.",
511
+ "Adjust `response` (speed) and `dampingFraction` (bounciness) for different spring feels.",
512
+ ],
513
+ },
514
+ };
515
+ export function generateAnimationCode(animationType, platform = "android") {
516
+ if (platform === "ios") {
517
+ const iosAnim = IOS_ANIMATIONS[animationType];
518
+ if (iosAnim) {
519
+ return {
520
+ code: iosAnim.code,
521
+ platform: "ios",
522
+ animationType,
523
+ description: iosAnim.description,
524
+ notes: iosAnim.notes,
525
+ };
526
+ }
527
+ // Fallback: not all types have iOS equivalents
528
+ return {
529
+ code: IOS_ANIMATIONS["model-playback"].code,
530
+ platform: "ios",
531
+ animationType: "model-playback",
532
+ description: `iOS equivalent for '${animationType}' not available. Showing model-playback instead.`,
533
+ notes: [`The '${animationType}' animation type is Android-specific. Use RealityKit's native animation APIs for iOS.`],
534
+ };
535
+ }
536
+ const anim = ANDROID_ANIMATIONS[animationType];
537
+ if (!anim)
538
+ return null;
539
+ return {
540
+ code: anim.code,
541
+ platform: "android",
542
+ animationType,
543
+ description: anim.description,
544
+ notes: anim.notes,
545
+ };
546
+ }
547
+ export const ANIMATION_TYPES = [
548
+ "model-playback",
549
+ "morph-targets",
550
+ "spring-position",
551
+ "spring-scale",
552
+ "compose-rotation",
553
+ "compose-scale",
554
+ "smooth-follow",
555
+ "transition",
556
+ ];
557
+ export function formatAnimationCode(result) {
558
+ const lang = result.platform === "ios" ? "swift" : "kotlin";
559
+ const parts = [
560
+ `## Animation: ${result.animationType}`,
561
+ `**Platform:** ${result.platform === "ios" ? "iOS (SwiftUI + RealityKit)" : "Android (Jetpack Compose + Filament)"}`,
562
+ `**Description:** ${result.description}`,
563
+ ``,
564
+ `### Code`,
565
+ ``,
566
+ "```" + lang,
567
+ result.code,
568
+ "```",
569
+ ``,
570
+ ];
571
+ if (result.notes.length > 0) {
572
+ parts.push(`### Notes`);
573
+ result.notes.forEach((n, i) => parts.push(`${i + 1}. ${n}`));
574
+ }
575
+ return parts.join("\n");
576
+ }