luster 0.1.0 copy "luster: ^0.1.0" to clipboard
luster: ^0.1.0 copied to clipboard

Mints SVG artwork into a lit 3D badge, rendered with flutter_scene. The engine is Luster's Rust core.

example/lib/main.dart

import 'dart:async';
import 'dart:io';

import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart' show CupertinoIcons;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:luster/luster.dart';

/// `--dart-define=LUSTER_CYCLE=true` mints the sample repeatedly and prints the
/// longest gap between frames on the UI isolate while each mint ran, then
/// exits. The UI is kept drawing while a mint runs, so a long gap is a hitch.
const cycle = bool.fromEnvironment('LUSTER_CYCLE');

/// `LUSTER_LIGHTING=off` in the environment opens on the lamps' light, for
/// photographing the same badge the same way on every platform.
final startingLighting = Platform.environment['LUSTER_LIGHTING'] ?? '';

/// The document the demo opens on; any other is opened from a file.
const sample = (asset: 'assets/svg/namiura.svg', title: 'Namiura');

/// `--dart-define=LUSTER_URL=<url>` opens on a document on the web instead.
const startingUrl = String.fromEnvironment('LUSTER_URL');

void main() {
  // Start the engine without waiting: the badge view waits for it, so the rest
  // of the UI shows at once.
  unawaited(Luster.init());
  runApp(const DemoApp());
}

/// Follows the system's light or dark appearance, in greys, as the iOS demo
/// does.
class DemoApp extends StatelessWidget {
  const DemoApp({super.key});

  @override
  Widget build(BuildContext context) => MaterialApp(
        debugShowCheckedModeBanner: false,
        title: 'Luster',
        theme: _theme(Brightness.light),
        darkTheme: _theme(Brightness.dark),
        home: const DemoPage(),
      );

  static ThemeData _theme(Brightness brightness) => ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.grey,
          brightness: brightness,
          dynamicSchemeVariant: DynamicSchemeVariant.monochrome,
        ),
        scaffoldBackgroundColor:
            brightness == Brightness.dark ? Colors.black : Colors.white,
      );
}

/// The choices the menus offer, as the iOS demo names them.
const lines = [('Plated', false), ('Metal', true)];
/// Each metal is read from the engine when it is used, as the engine may
/// still be loading while the menus show.
final metals = <(String, LusterColor? Function())>[
  ('Gold', () => null), // the engine's own
  ('Silver', () => LusterColor.silver),
  ('Copper', () => LusterColor.copper),
];
const lightings = [
  ('Showcase', LusterLighting.showcase),
  ('Off', LusterLighting.off),
];

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

  @override
  State<DemoPage> createState() => _DemoPageState();
}

class _DemoPageState extends State<DemoPage> {
  /// The document on screen: bytes from the bundle or a file, or a web URL.
  LusterSource? source;
  set svg(Uint8List bytes) => source = LusterSource.bytes(bytes);
  String svgTitle = sample.title;
  bool metalLines = false;
  String metal = metals.first.$1;
  LusterLighting lighting = switch (startingLighting) {
    'off' => LusterLighting.off,
    _ => LusterLighting.showcase,
  };
  String status = 'loading';
  /// The badge on screen, once it is ready: what the GLB button writes out.
  LusterBadge? badge;
  bool exporting = false;
  Duration longestFrame = Duration.zero;
  Duration? lastFrame;
  bool minting = false;
  /// Times a mint from the moment it is asked for.
  final Stopwatch mintWatch = Stopwatch();

  @override
  void initState() {
    super.initState();
    SchedulerBinding.instance.addPersistentFrameCallback((stamp) {
      final last = lastFrame;
      if (last != null && stamp - last > longestFrame) longestFrame = stamp - last;
      lastFrame = stamp;
      // A badge at rest is not redrawn, so nothing else asks for frames while
      // a mint runs; asking here keeps them coming.
      if (minting) SchedulerBinding.instance.scheduleFrame();
    });
    if (startingUrl.isNotEmpty) {
      final url = Uri.parse(startingUrl);
      svgTitle = url.pathSegments.last.replaceFirst(RegExp(r'\.svg$', caseSensitive: false), '');
      source = LusterSource.url(url);
      return;
    }
    rootBundle.load(sample.asset).then((data) {
      setState(() => svg = data.buffer.asUint8List());
      if (cycle) _cycle(data.buffer.asUint8List());
    });
  }

  /// Mints fresh copies of the sample (so nothing is cached) and reports the
  /// longest frame while each was in flight.
  Future<void> _cycle(Uint8List base) async {
    await Future<void>.delayed(const Duration(seconds: 3));
    debugPrint('round\tmint+scene ms\tlongest frame gap ms');
    for (var round = 0; round < 6; round++) {
      final bytes = Uint8List.fromList([...base, ...'<!-- $round -->'.codeUnits]);
      longestFrame = Duration.zero;
      final watch = Stopwatch()..start();
      setState(() => svg = bytes);
      while (!status.startsWith('ready')) {
        await Future<void>.delayed(const Duration(milliseconds: 5));
        if (watch.elapsed > const Duration(seconds: 10)) break;
      }
      await Future<void>.delayed(const Duration(milliseconds: 200));
      debugPrint('$round\t${watch.elapsedMilliseconds}\t'
          '${(longestFrame.inMicroseconds / 1000).toStringAsFixed(1)}');
      status = '';
    }
    debugPrint('done');
    exit(0);
  }

  /// Puts an SVG the user picks on the badge, under its name.
  Future<void> _open() async {
    try {
      final file =
          await FilePicker.pickFile(type: FileType.custom, allowedExtensions: ['svg']);
      if (file == null) return;
      final bytes = await file.readAsBytes();
      setState(() {
        svg = bytes;
        svgTitle = file.name.replaceFirst(RegExp(r'\.svg$', caseSensitive: false), '');
      });
    } catch (error) {
      setState(() => status = 'failed: $error');
    }
  }

  /// Writes the badge on screen as a GLB, in the metal it is shown in, where
  /// the user says.
  Future<void> _export() async {
    final badge = this.badge;
    if (badge == null) return;
    setState(() => exporting = true);
    try {
      final bytes = await badge.glb(metal: metals.firstWhere((m) => m.$1 == metal).$2());
      final saved = await FilePicker.saveFile(
        fileName: '$svgTitle.glb',
        bytes: bytes,
        mimeType: 'model/gltf-binary',
        type: FileType.custom,
        allowedExtensions: ['glb'],
      );
      if (saved != null) {
        setState(() => status = 'exported $svgTitle.glb · ${bytes.length ~/ 1024} KB');
      }
    } catch (error) {
      setState(() => status = 'failed: $error');
    } finally {
      if (mounted) setState(() => exporting = false);
    }
  }

  void _onState(LusterState state) => setState(() {
        badge = null;
        minting = state is LusterMinting;
        switch (state) {
          case LusterIdle():
            status = 'idle';
          case LusterMinting():
            longestFrame = Duration.zero;
            lastFrame = null;
            mintWatch
              ..reset()
              ..start();
            status = 'minting…';
          case LusterReady(:final badge):
            this.badge = badge;
            status = 'ready ${badge.designKey.substring(0, 6)} · '
                '${mintWatch.elapsedMilliseconds} ms · longest frame gap '
                '${longestFrame.inMilliseconds} ms';
          case LusterFailed(:final error):
            status = 'failed: $error';
        }
      });

  @override
  Widget build(BuildContext context) {
    final scheme = ColorScheme.of(context);
    return Scaffold(
      body: Column(
        children: [
          Expanded(
            child: LusterView(
              source: source,
              options: LusterOptions(metalLines: metalLines),
              appearance: LusterAppearance(
                metal: metals.firstWhere((m) => m.$1 == metal).$2(),
                lighting: lighting,
              ),
              onStateChange: _onState,
            ),
          ),
          SafeArea(
            top: false,
            minimum: const EdgeInsets.only(bottom: 16),
            child: Padding(
              padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  Row(
                    spacing: 4,
                    children: [
                      Expanded(
                        child: BarButton(
                          icon: CupertinoIcons.folder,
                          label: svgTitle,
                          onPressed: _open,
                        ),
                      ),
                      Expanded(
                        child: ChoiceMenu(
                          title: 'Lines',
                          icon: CupertinoIcons.scribble,
                          choices: lines,
                          selected: metalLines,
                          onSelected: (v) => setState(() => metalLines = v),
                        ),
                      ),
                      Expanded(
                        child: ChoiceMenu(
                          title: 'Metal',
                          icon: CupertinoIcons.paintbrush,
                          choices: [for (final (name, _) in metals) (name, name)],
                          selected: metal,
                          // Only gold is shown until the engine is ready.
                          onSelected: (v) async {
                            await Luster.init();
                            if (mounted) setState(() => metal = v);
                          },
                        ),
                      ),
                      Expanded(
                        child: ChoiceMenu(
                          title: 'Light',
                          icon: CupertinoIcons.lightbulb,
                          choices: lightings,
                          selected: lighting,
                          onSelected: (v) => setState(() => lighting = v),
                        ),
                      ),
                      Expanded(
                        child: BarButton(
                          icon: CupertinoIcons.square_arrow_up,
                          label: 'GLB',
                          // Until a badge is ready there is nothing to write.
                          onPressed: badge == null || exporting ? null : _export,
                        ),
                      ),
                    ],
                  ),
                  const SizedBox(height: 8),
                  Text(
                    status,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: TextStyle(
                      fontFamily: 'Menlo',
                      fontFamilyFallback: const ['monospace'],
                      fontSize: 11,
                      color: scheme.onSurfaceVariant,
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

/// A grey capsule with an icon over a caption, like the iOS demo's buttons.
class BarButton extends StatelessWidget {
  const BarButton({
    super.key,
    required this.icon,
    required this.label,
    required this.onPressed,
  });

  final IconData icon;
  final String label;
  final VoidCallback? onPressed;

  @override
  Widget build(BuildContext context) {
    final scheme = ColorScheme.of(context);
    return FilledButton(
      onPressed: onPressed,
      style: FilledButton.styleFrom(
        backgroundColor: scheme.surfaceContainerHighest,
        foregroundColor: scheme.onSurface,
        minimumSize: const Size.fromHeight(56),
        padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
        shape: const StadiumBorder(),
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(icon, size: 22),
          const SizedBox(height: 3),
          // A longer name ("Showcase") shrinks a little rather than being cut.
          FittedBox(
            fit: BoxFit.scaleDown,
            child: Text(
              label,
              maxLines: 1,
              style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w500),
            ),
          ),
        ],
      ),
    );
  }
}

/// A bar button that opens a menu of choices, checking the one that is set,
/// and shows that one as its caption.
class ChoiceMenu<T> extends StatelessWidget {
  const ChoiceMenu({
    super.key,
    required this.title,
    required this.icon,
    required this.choices,
    required this.selected,
    required this.onSelected,
  });

  final String title;
  final IconData icon;
  final List<(String, T)> choices;
  final T selected;
  final ValueChanged<T> onSelected;

  @override
  Widget build(BuildContext context) {
    final current = choices.where((c) => c.$2 == selected).firstOrNull;
    return MenuAnchor(
      menuChildren: [
        Padding(
          padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
          child: Text(title, style: TextTheme.of(context).labelSmall),
        ),
        for (final (name, value) in choices)
          MenuItemButton(
            leadingIcon: value == selected
                ? const Icon(Icons.check, size: 18)
                : const SizedBox(width: 18),
            onPressed: () => onSelected(value),
            child: Text(name),
          ),
      ],
      builder: (context, controller, _) => BarButton(
        icon: icon,
        label: current?.$1 ?? '—',
        onPressed: () => controller.isOpen ? controller.close() : controller.open(),
      ),
    );
  }
}
1
likes
160
points
12
downloads

Documentation

API reference

Publisher

verified publisherfamio.dev

Weekly Downloads

Mints SVG artwork into a lit 3D badge, rendered with flutter_scene. The engine is Luster's Rust core.

Repository (GitHub)
View/report issues
Contributing

Topics

#svg #graphics #badge #rendering #rust

License

MIT (license)

Dependencies

flutter, flutter_gpu_shaders, flutter_rust_bridge, flutter_scene, hooks, vector_math

More

Packages that depend on luster

Packages that implement luster