From 46c2fb32d5166dfe2bf0df6f46be5cfb5595ab62 Mon Sep 17 00:00:00 2001 From: Akihiko Komada Date: Sun, 17 May 2026 17:59:16 +0900 Subject: [PATCH] Add Node.fromGlbStream factory for Stream> input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling to the existing Node.fromGlbBytes / Node.fromGlbAsset factories, accepting a Stream> source instead of a materialised Uint8List or asset path. Use cases this opens up: - HTTP response bodies via `package:http` (response.stream) - `dart:io` File.openRead() pipes - Websocket frame sources - Any caller with a chunked byte source but no full buffer up-front Implementation drains the stream into a BytesBuilder and delegates to fromGlbBytes — peak memory equals the full GLB size, matching fromGlbBytes semantics. True incremental parsing of the GLB container is intentionally out of scope for this factory. The factory accepts Stream> (Dart-idiomatic; matches dart:io and package:http conventions); Stream callers work transparently since Uint8List implements List. Test covers stream-collection contract directly without flutter_gpu pipeline instantiation (which is unavailable in pure dart:test): chunked input produces byte-identical output for fcar.glb; Stream upcast handles correctly; empty stream produces empty bytes. --- packages/flutter_scene/lib/src/node.dart | 29 ++++++ .../test/node_from_glb_stream_test.dart | 90 +++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 packages/flutter_scene/test/node_from_glb_stream_test.dart diff --git a/packages/flutter_scene/lib/src/node.dart b/packages/flutter_scene/lib/src/node.dart index 4b870f0..3171b88 100644 --- a/packages/flutter_scene/lib/src/node.dart +++ b/packages/flutter_scene/lib/src/node.dart @@ -300,6 +300,35 @@ base class Node implements SceneGraph { return importGlb(bytes); } + /// Load a single-file `.glb` model from a [Stream] of byte chunks. + /// + /// Convenience wrapper for [fromGlbBytes] that drains the stream + /// into a single buffer before parsing. Useful when the caller has + /// a `Stream>` (e.g. an `http` response body, a `dart:io` + /// `File.openRead()` pipe, or a websocket frame source) but not the + /// full byte buffer up-front. + /// + /// This factory buffers the entire stream in memory before + /// delegating to [fromGlbBytes] — peak memory equals the full GLB + /// size, matching [fromGlbBytes] semantics. True incremental + /// parsing of the GLB container is out of scope for this factory. + /// + /// Accepts `Stream>` for compatibility with `dart:io` and + /// `package:http`; `Stream` callers also work since + /// `Uint8List` implements `List`. + /// + /// ```dart + /// final response = await http.Client().send(http.Request('GET', url)); + /// final node = await Node.fromGlbStream(response.stream); + /// ``` + static Future fromGlbStream(Stream> stream) async { + final builder = BytesBuilder(copy: false); + await for (final chunk in stream) { + builder.add(chunk); + } + return fromGlbBytes(builder.toBytes()); + } + /// Convenience wrapper for [fromGlbBytes] that loads from the asset bundle. static Future fromGlbAsset(String assetPath) async { final byteData = await rootBundle.load(assetPath); diff --git a/packages/flutter_scene/test/node_from_glb_stream_test.dart b/packages/flutter_scene/test/node_from_glb_stream_test.dart new file mode 100644 index 0000000..33a84b7 --- /dev/null +++ b/packages/flutter_scene/test/node_from_glb_stream_test.dart @@ -0,0 +1,90 @@ +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:test/test.dart'; + +void main() { + // We test the stream-collection contract directly without instantiating + // a flutter_gpu pipeline. The Node.fromGlbStream factory's only job is + // to drain a Stream> into a single Uint8List and then call + // Node.fromGlbBytes; the latter is already covered by existing + // runtime_importer_byte_comparison_test.dart. + // + // This test guards the collection step: that chunked input produces + // the same byte image as the original file. + + test('stream-collection: chunked input reassembles to original bytes', () async { + final glbPath = _resolve('examples/assets_src/fcar.glb'); + if (!File(glbPath).existsSync()) { + print('Test data missing — skipping.'); + return; + } + + final original = File(glbPath).readAsBytesSync(); + + // Chunk into 3 unequal pieces to exercise mid-buffer boundaries. + final cut1 = original.length ~/ 4; + final cut2 = (original.length * 7) ~/ 10; + final chunks = >[ + original.sublist(0, cut1), + original.sublist(cut1, cut2), + original.sublist(cut2), + ]; + + final stream = Stream>.fromIterable(chunks); + + final collected = await _collect(stream); + + expect(collected.length, equals(original.length), + reason: 'stream-collected bytes must total to original size'); + expect(collected, equals(original), + reason: 'stream-collected bytes must equal original bytes'); + }); + + test('stream-collection: handles Stream (subtype of Stream>)', () async { + final original = Uint8List.fromList(List.generate(1024, (i) => i & 0xFF)); + + final stream = Stream.fromIterable([ + Uint8List.sublistView(original, 0, 256), + Uint8List.sublistView(original, 256, 768), + Uint8List.sublistView(original, 768), + ]); + + // ignore: omit_local_variable_types + final Stream> upcast = stream; + final collected = await _collect(upcast); + + expect(collected, equals(original)); + }); + + test('stream-collection: empty stream produces empty bytes', () async { + final stream = Stream>.empty(); + final collected = await _collect(stream); + expect(collected, isEmpty); + }); +} + +/// Mirror of Node.fromGlbStream's stream-collection step. Kept local to +/// this test so the test does not require flutter_gpu initialisation +/// (which is unavailable in a pure dart:test environment). +Future _collect(Stream> stream) async { + final builder = BytesBuilder(copy: false); + await for (final chunk in stream) { + builder.add(chunk); + } + return builder.toBytes(); +} + +String _resolve(String relative) { + // Walk up from CWD to find the repo root that contains the path. + var dir = Directory.current; + for (var i = 0; i < 6; i++) { + final candidate = File('${dir.path}/$relative'); + if (candidate.existsSync()) return candidate.path; + dir = dir.parent; + } + return relative; +}