flutter3d_education 1.0.0-rc.1 copy "flutter3d_education: ^1.0.0-rc.1" to clipboard
flutter3d_education: ^1.0.0-rc.1 copied to clipboard

Education on flutter3d: virtual laboratory simulations a server can replay, the experiment document a lesson is written in, and a lesson's steps. Plain Dart, no Flutter.

example/lib/main.dart

/// A virtual chemistry bench.
///
///     flutter run -d chrome
///     flutter run -d macos
///
/// Glass turned from profiles, labels typeset in TeX and wrapped on, and
/// liquid that keeps level and sloshes. Drag to turn round the bench, scroll
/// to come closer, pick a vessel and pour, tilt or tap it.
///
/// The bench is drawn by one [Flutter3dView], which opens the device and owns
/// the frame clock; it runs only while a liquid moves, and a turn of the
/// camera or a label put on draws one frame by building it again. The other
/// laboratory here, a pendulum on the accurate profile, is
/// `lib/pendulum_main.dart`.
library;

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter3d/flutter3d.dart';
import 'package:flutter3d_app/flutter3d_app.dart';
import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'
    show preparePhysics, usePhysics;

import 'chemlab.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // The run's physics, chosen once and before the bench is made: the core,
  // which the browser fetches as WebAssembly, or the reference where it
  // will not start. The bench's liquids are stepped on it.
  await preparePhysics();
  runApp(const ChemLabApp());
}

class ChemLabApp extends StatelessWidget {
  const ChemLabApp({super.key});

  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'Chemistry bench',
    debugShowCheckedModeBanner: false,
    theme: ThemeData(
      colorScheme: ColorScheme.fromSeed(
        seedColor: const Color(0xFF2F6FD6),
        brightness: Brightness.dark,
      ),
    ),
    home: const BenchScreen(),
  );
}

class BenchScreen extends StatefulWidget {
  const BenchScreen({super.key});

  @override
  State<BenchScreen> createState() => _BenchScreenState();
}

class _BenchScreenState extends State<BenchScreen> {
  final CameraNode _camera = CameraNode(name: 'eye');
  // A bench is looked at from above and from not too far: the controller's
  // own limits let the camera go under the table and out of the room.
  late final OrbitController _orbit = OrbitController(
    _camera,
    // Life size: a bench of test tubes sixteen millimetres across.
    target: Vector3(0.01, 0.034, 0),
    distance: 0.27,
    yaw: 0.0,
    pitch: 0.2,
    minDistance: 0.06,
    maxDistance: 0.6,
  )..rotateSensitivity = 0.005;

  static const double _lowestPitch = 0.05;
  static const double _highestPitch = 1.35;

  void _turn(double dx, double dy) {
    _orbit.rotate(dx, dy);
    final pitch = _orbit.pitch.clamp(_lowestPitch, _highestPitch);
    if (pitch != _orbit.pitch) {
      _orbit
        ..pitch = pitch
        ..apply();
    }
  }

  Bench? _bench;
  int _selected = 1;

  /// Whether a liquid moves: the view's clock runs while one does, and stops
  /// when every one is still.
  bool _moving = false;

  void _open(Flutter3dEngine engine) {
    final bench = Bench(engine.device, photons: true, physics: usePhysics())
      ..retire = engine.renderer.releaseMeshAfterFrame
      ..retireTexture = engine.renderer.releaseTextureAfterFrame;
    engine
      ..scene = bench.scene
      ..view.clearColor = LinearColor.fromSrgb(0.1, 0.11, 0.14, 1.0);
    setState(() => _bench = bench);
  }

  void _step(EngineFrame f) {
    final frame = f.frame;
    final bench = _bench;
    if (bench == null) return;
    // A frame late is a frame long: held to a thirtieth, so a stall does not
    // throw the liquid out of its glass.
    if (!bench.step(frame.seconds.clamp(0.0, 1 / 30))) {
      setState(() => _moving = false);
    }
  }

  /// The way the camera looks, which a vessel leans about so that it leans
  /// in the picture rather than towards the eye.
  Vector3 _lookingAlong() {
    final m = _camera.worldMatrix;
    return Vector3(-m.entry(0, 2), 0, -m.entry(2, 2));
  }

  /// Something has set a liquid moving: run the clock if it is not running.
  void _stir() {
    if (!_moving) setState(() => _moving = true);
  }

  @override
  Widget build(BuildContext context) {
    final bench = _bench;
    return Scaffold(
      backgroundColor: const Color(0xFF14161A),
      body: Stack(
        children: [
          // The labels, drawn out of sight and put on as they arrive; until
          // then the paper is blank.
          if (bench != null)
            Positioned(
              left: 0,
              top: 0,
              width: 1,
              height: 1,
              child: LabelPrinter(
                solutions: [
                  for (final v in bench.vessels)
                    if (v.solution != null) v.solution!,
                ],
                // setState, because a view that is not running draws when it
                // is built: a label put on between frames is not seen until
                // the next one.
                onPrinted: (i, image) => setState(
                  () => bench.dress(
                    bench.vessels.where((v) => v.solution != null).elementAt(i),
                    image,
                  ),
                ),
              ),
            ),
          Positioned.fill(
            child: Listener(
              // In setState, for the same reason: a camera moved without a
              // rebuild is not drawn until something else is.
              onPointerMove: (PointerMoveEvent event) =>
                  setState(() => _turn(event.delta.dx, event.delta.dy)),
              onPointerSignal: (PointerSignalEvent event) {
                if (event is PointerScrollEvent) {
                  setState(
                    () => _orbit.zoom(
                      event.scrollDelta.dy > 0.0 ? 1.1 : 1.0 / 1.1,
                    ),
                  );
                }
              },
              child: Flutter3dView(
                camera: _camera,
                onCreated: _open,
                onBeforeFrame: (_) => _orbit.syncProjectionDepth(_camera),
                onFrame: _step,
                view: ViewSettings(
                  settings: bench?.settings ?? const RenderSettings(),
                  continuous: _moving,
                  placeholder: const Center(child: CircularProgressIndicator()),
                  failure: (Object error) => DidNotStart(
                    error,
                    background: const Color(0xFF14161A),
                    foreground: const Color(0xFFFF8A80),
                  ),
                ),
              ),
            ),
          ),
          if (bench != null)
            Positioned(
              left: 16,
              right: 16,
              bottom: 16,
              child: _Controls(
                bench: bench,
                selected: _selected,
                onSelect: (i) => setState(() => _selected = i),
                onPour: (level) {
                  setState(() => bench.pour(bench.vessels[_selected], level));
                  _stir();
                },
                onLean: (angle) {
                  setState(
                    () => bench.lean(
                      bench.vessels[_selected],
                      angle,
                      across: _lookingAlong(),
                    ),
                  );
                  _stir();
                },
                onTap: () {
                  bench.tap(bench.vessels[_selected]);
                  _stir();
                },
                onShare: () {
                  setState(
                    () => bench.share(
                      bench.vessels[_selected],
                      across: _lookingAlong(),
                    ),
                  );
                  _stir();
                },
              ),
            ),
        ],
      ),
    );
  }
}

class _Controls extends StatelessWidget {
  const _Controls({
    required this.bench,
    required this.selected,
    required this.onSelect,
    required this.onPour,
    required this.onLean,
    required this.onTap,
    required this.onShare,
  });

  final Bench bench;
  final int selected;
  final ValueChanged<int> onSelect;
  final ValueChanged<double> onPour;
  final ValueChanged<double> onLean;
  final VoidCallback onTap;

  /// Pours the picked vessel into the clean tube until both hold the same.
  final VoidCallback onShare;

  @override
  Widget build(BuildContext context) {
    final vessel = bench.vessels[selected];
    return Card(
      color: const Color(0xE61C1F26),
      child: Padding(
        padding: const EdgeInsets.fromLTRB(10, 8, 10, 4),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // One row that scrolls rather than wraps, so the panel stays
            // two lines high in a small frame and the bench stays in view.
            SingleChildScrollView(
              scrollDirection: Axis.horizontal,
              child: Row(
                children: [
                  for (var i = 0; i < bench.vessels.length; i++)
                    Padding(
                      padding: const EdgeInsets.only(right: 6),
                      child: ChoiceChip(
                        visualDensity: VisualDensity.compact,
                        materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
                        label: Text(bench.vessels[i].name),
                        selected: i == selected,
                        avatar: CircleAvatar(
                          backgroundColor: bench.color(bench.vessels[i]),
                        ),
                        onSelected: (_) => onSelect(i),
                      ),
                    ),
                ],
              ),
            ),
            Row(
              children: [
                const Text('Pour'),
                Expanded(
                  child: Slider(
                    // Held to the slider's range: a pour tips and empties
                    // the vessel past what the controls offer, and a slider
                    // given a value outside its range throws.
                    value: vessel.level.clamp(vessel.lowest, vessel.highest),
                    min: vessel.lowest,
                    max: vessel.highest,
                    onChanged: bench.busy ? null : onPour,
                  ),
                ),
                const Text('Tilt'),
                Expanded(
                  child: Slider(
                    // Where the hand is going, not where the glass is yet:
                    // it turns at a hand's pace, and a slider that followed
                    // it would pull back under the finger.
                    value: vessel.aimTilt.clamp(
                      -bench.maxLean(vessel),
                      bench.maxLean(vessel),
                    ),
                    min: -bench.maxLean(vessel),
                    max: bench.maxLean(vessel),
                    onChanged: bench.busy ? null : onLean,
                  ),
                ),
              ],
            ),
            // The buttons on a line of their own, which wraps: sharing one
            // with the sliders squeezed them to nothing in a narrow window.
            Wrap(
              spacing: 6,
              runSpacing: 4,
              crossAxisAlignment: WrapCrossAlignment.center,
              children: [
                ActionChip(
                  visualDensity: VisualDensity.compact,
                  materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
                  label: const Text('Tap'),
                  onPressed: onTap,
                ),
                Tooltip(
                  message:
                      'Pour into the clean tube at the front until the two '
                      'hold the same',
                  child: ActionChip(
                    visualDensity: VisualDensity.compact,
                    materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
                    label: const Text('Share with clean tube'),
                    onPressed: bench.canShare(vessel) ? onShare : null,
                  ),
                ),
                Text(
                  'Drag to turn, scroll to zoom',
                  style: Theme.of(context).textTheme.bodySmall,
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}
0
likes
150
points
50
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Education on flutter3d: virtual laboratory simulations a server can replay, the experiment document a lesson is written in, and a lesson's steps. Plain Dart, no Flutter.

Topics

#education #simulation #laboratory

License

MIT (license)

Dependencies

flutter3d_foundation, flutter3d_matter, flutter3d_sim

More

Packages that depend on flutter3d_education