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; +}