diff --git a/.github/workflows/generate-artifacts.yml b/.github/workflows/generate-artifacts.yml index db294a005..3a484e441 100644 --- a/.github/workflows/generate-artifacts.yml +++ b/.github/workflows/generate-artifacts.yml @@ -66,6 +66,11 @@ jobs: filters: | native-changed: - 'thermion_dart/native/**' + # These drive the web (Emscripten) build too: the Makefile's wasm + # target and the scripts it calls decide what goes into the + # shipped thermion_dart.wasm. + - 'Makefile' + - 'scripts/download_reactphysics3d.sh' materials-changed: - 'materials/**' - 'examples/assets/*.mat' @@ -197,6 +202,20 @@ jobs: restore-keys: | filament-web- + # ReactPhysics3D checkout + the published libreactphysics3d.a that gets + # linked into thermion_dart.wasm. Keyed on the download script so a tag + # bump in it re-fetches. + - name: Cache ReactPhysics3D web artifact + if: steps.changes.outputs.native-changed == 'true' + uses: actions/cache@v4 + with: + path: thermion_dart/native/web/lib/external + key: rp3d-web-${{ hashFiles('scripts/download_reactphysics3d.sh') }} + + # Builds the single thermion_dart WASM with ReactPhysics3D linked in via + # the EXTERNAL_PROJECTS hook. The engine library comes from the + # reactphysics3d_dart release that the download script pins; that + # repository's own build-artifacts workflow verifies its exports. - name: Compile web if: steps.changes.outputs.native-changed == 'true' run: make wasm diff --git a/.tickets/the-tvf4.md b/.tickets/the-tvf4.md new file mode 100644 index 000000000..06a90cda1 --- /dev/null +++ b/.tickets/the-tvf4.md @@ -0,0 +1,39 @@ +--- +id: the-tvf4 +status: in_progress +deps: [] +links: [] +created: 2026-08-14T14:54:21Z +type: feature +priority: 2 +assignee: Nick Fisher +tags: [dart, examples, physics] +--- +# Create a Dart sample demonstrating basic physics + +Create a Dart sample (thermion example) showing basic physics. Use reactphysics3d_dart for the physics simulation and thermion_dart for rendering. + +## Context + +- Reference app: ~/Documents/mixworld (Flutter app, mounted read-only). + It already wires thermion_dart + reactphysics3d_dart together. Read it to + learn the integration pattern. +- Physics package: reactphysics3d_dart (mounted read-only at + /Volumes/T7/projects/reactphysics3d_dart). It is a git repo on branch + master. It has an example/ dir with a heightfield example. +- The sample lives in this repo (thermion), under examples/dart. + Follow the existing example structure (examples_lib / web_gallery). +- Physics + rendering should run in the browser (WASM), like the other + dart examples. + +## Acceptance criteria + +1. New dart example under examples/dart that shows basic physics: + objects falling, bouncing, or colliding, rendered with thermion. +2. Registered in the example registry so it appears in the web gallery + (see examples/dart/examples_lib/lib/src/registry.dart and + examples/dart/web_gallery). +3. Works in the browser (WASM build), verified by building/running it. +4. Code follows existing example style and passes `flutter analyze` / + `dart analyze` where applicable. + diff --git a/Makefile b/Makefile index 0c05ca7db..5a9f8540d 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,15 @@ +# ReactPhysics3D is compiled into the single thermion_dart WASM through the +# EXTERNAL_PROJECTS hook in thermion_dart/native/web/CMakeLists.txt: thermion +# itself never references the rp3d C API, so its objects are only kept alive by +# the -Wl,--whole-archive group and its _rp3d_* functions are exported from the +# same Module object as the _Thermion_* ones. That is what lets one +# NativeLibrary.initBindings("thermion_dart") serve both packages on the web. +# scripts/download_reactphysics3d.sh stages the reactphysics3d_dart checkout +# and the published libreactphysics3d.a that the hook links in. +# Set EXTERNAL_PROJECTS_CMAKE= (empty) to build a WASM without physics. +RP3D_EXTERNAL_DIR := thermion_dart/native/web/lib/external +EXTERNAL_PROJECTS_CMAKE ?= $(RP3D_EXTERNAL_DIR)/reactphysics3d_dart/native/web/reactphysics3d_dart.cmake + wasm: @if [ ! -f thermion_dart/native/web/lib/release/filament-v1.75.0-web-release.zip ]; then \ echo "Downloading filament-v1.75.0-web-release.zip..."; \ @@ -9,13 +21,17 @@ wasm: cd thermion_dart/native/web/lib/release && \ rm -rf include lib && \ unzip -o filament-v1.75.0-web-release.zip +ifneq ($(strip $(EXTERNAL_PROJECTS_CMAKE)),) + scripts/download_reactphysics3d.sh +endif cd thermion_dart/native/web && \ mkdir -p build && \ cd build && \ - emcmake cmake .. && \ + emcmake cmake $(if $(strip $(EXTERNAL_PROJECTS_CMAKE)),-DEXTERNAL_PROJECTS=$(abspath $(EXTERNAL_PROJECTS_CMAKE)),) .. && \ emmake make wasm-clean: cd thermion_dart/native/web && rm -rf build + @echo "Note: $(RP3D_EXTERNAL_DIR) is kept (cached ReactPhysics3D checkout/artifact)." wasm-example-web: cd examples/dart/js_wasm mkdir -p build diff --git a/docs/research/web-physics-scope.md b/docs/research/web-physics-scope.md new file mode 100644 index 000000000..e71d516c1 --- /dev/null +++ b/docs/research/web-physics-scope.md @@ -0,0 +1,294 @@ +# Scoping: unblocking the `physics_basics` example on the web path + +Date: 2026-08-19 +Branch: `asb/dart-physics-sample-clean` (PR #239, commit `470e44c9` — read via `refs/pull/239/head`) +Status: research only — no code changed. + +## Background + +PR #239 adds `examples/dart/examples_lib/lib/src/physics_basics.dart` (boxes and +spheres dropped onto a ground plane, simulated with `reactphysics3d_dart`, +rendered with `thermion_dart`). The example is registered in `registry.dart` +(headless CLI runner) but deliberately NOT in `galleryScenes` (web gallery), +because the web path is blocked. The original ticket (`.tickets/the-tvf4.md` in +the PR) required browser/WASM support as an acceptance criterion; it was +descoped for the reasons below. + +Three blockers were claimed in the example's doc comment: + +1. `reactphysics3d_dart` pins `ffigen_js 0.0.5-pre`; `thermion_dart` needs + `^0.0.14-pre`; they only resolve via `dependency_overrides` in the example + pubspecs. +2. Both packages' generated web bindings assign a single shared + `NativeLibrary.instance` inside `initBindings`, so loading a second WASM + module clobbers the first. +3. The `reactphysics3d_dart` WASM runtime path is unverified. + +This document verifies each claim against the actual sources, then evaluates +options and recommends a path. Reference checkouts used (cloned at their +remote HEADs, plus the exact ref the PR pins): + +- `reactphysics3d_dart` @ `52eec72e06b322080d395415bc1dfaffd8c45d2f` — this IS + the current `master` HEAD; the PR pins it directly. +- `ffigen_js` @ `06d47ad` (master, `0.0.14-pre`). + +## 1. Exact version state + +| Package | ffigen_js constraint | File | +|---|---|---| +| thermion_dart | `^0.0.14-pre` | `thermion_dart/pubspec.yaml:24` | +| reactphysics3d_dart | `0.0.5-pre` (exact pin, no caret) | `pubspec.yaml` (dependencies) | + +- ffigen_js master is `0.0.14-pre`. There are **no git tags**; versions are + pubspec-only bumps. Version→commit map (from history): `0.0.5-pre` = + `901616e`, `0.0.13-pre` = `65324d3`, `0.0.14-pre` = `06d47ad`. +- `reactphysics3d_dart` has no newer branch/tag relaxing the pin (branches: + `feat/trigger`, `fix/linux-arm64-artifact`; neither touches `ffigen_js`). +- **The pin is a real dependency, not conservatism.** The checked-in generated + web bindings `lib/src/bindings/src/rp3d_js_interop.g.dart` (3153 lines, + header says "Generated by `package:jsgen`", the ffigen_js predecessor name) + were produced by the 0.0.5-era generator and are **runtime-incompatible** + with the 0.0.14 runtime, not merely version-locked: + - Struct field access in the 0.0.5-generated code uses byte-offset pointer + arithmetic, e.g. (from `RP3D_Vector3`): + ```dart + set x(double val) { + NativeLibrary.instance.setValue(this.address + 4, val.toJS, 'float'); + } + ``` + In 0.0.5, `Pointer.operator +` meant *byte offset*. In 0.0.14 + (`ffigen_js/lib/src/types.dart:39`) it means *element count*: + `Pointer(addr + numElements * sizeOf())` — and + `sizeOf()` **throws `UnsupportedError`** (`types.dart:26-35` + only knows primitives). thermion's own regenerated bindings use the new + pattern (`this.address.addr + 0`, constructing a new `Pointer` — e.g. + `thermion_dart_js_interop.g.dart:8788`), which is why thermion needs + `^0.0.14-pre`. + - Between 0.0.5 and 0.0.14 the runtime also changed `NativeType` subtypes + from extension types to abstract classes, added heap-tracking to + `Pointer.free()`, and (0.0.13, then restored in 0.0.14) fixed typed-data + `.address` handling. Generated code from 0.0.5 is not guaranteed to + compile, and definitely not to run, against 0.0.14. +- Consequence for the PR's `dependency_overrides` (`ffigen_js: ^0.0.14-pre` in + `examples_lib`, `headless_runner`, and `web_gallery` pubspecs): it is "safe" + only because nothing *runs* rp3d code on web today. The web build still + *compiles* `rp3d_js_interop.g.dart` (it is exported from + `examples_lib.dart` → `physics_basics.dart` → `reactphysics3d_dart` → + conditional export), but any struct field access would throw at runtime. + The override hides the conflict; it does not resolve it. + +## 2. The `NativeLibrary.instance` singleton + +**It is one process-wide global. There is no per-module or per-binding +keying, and no multi-module support in any ffigen_js version.** + +- `ffigen_js/lib/src/types.dart:319`: `late NativeLibrary _lib;` — a single + package-level global. +- `extension type NativeLibrary` (`types.dart:455-522`) exposes + `static NativeLibrary get instance => _lib;`, + `static set instance(NativeLibrary lib) { _lib = lib; }`, and + `static void initBindings(String moduleName)` which does + `globalContext.getProperty(moduleName.toJS)` (a lookup of a global JS + variable by name, e.g. `window.thermion_dart`) and assigns `_lib`. +- The generator template (`ffigen_js/lib/src/jsgen/code_generator/writer.dart`, + the block emitting `extension type GeneratedBindings(NativeLibrary _)`) + generates into every bindings file: + ```dart + static GeneratedBindings get instance => NativeLibrary.instance as GeneratedBindings; + static void initBindings(String moduleName) { ... NativeLibrary.instance = lib as NativeLibrary; } + ``` + and every generated wrapper calls + `GeneratedBindings.instance._fn(...)` (`func.dart:303`). Both + `thermion_dart/lib/src/bindings/src/thermion_dart_js_interop.g.dart:11-20` + and `reactphysics3d_dart/lib/src/bindings/src/rp3d_js_interop.g.dart:11-20` + carry exactly this pattern. +- Critically, **all** ffigen_js runtime helpers route through the same `_lib`: + `stackAlloc`/`malloc`/`free`, `makeUint8List`/`makeFloat32List`/…, + `String.toNativeUtf8()`, `getValue`/`setValue` (used by *every* generated + struct getter/setter in both packages), `addFunction`/`removeFunction`, and + `HEAPU8`/`HEAPF32` (used by `asTypedList`). +- So with two separately loaded Emscripten modules, the second `initBindings` + call doesn't just "clobber a variable": the loser's subsequent + `setValue`/`stackAlloc` calls execute against the **wrong module's heap** + (addresses are plain ints), i.e. silent memory corruption, not a clean + error. Hand-written code reads the same singleton too + (`thermion_dart/lib/src/bindings/src/js_interop.dart:18`). + +How the JS side loads a module today (thermion gallery): +`web_gallery/web/index.html:136-147` loads `thermion_dart.js` +(Emscripten glue, `-sEXPORT_NAME=thermion_dart -sMODULARIZE`), awaits it onto +`window.thermion_dart`, then boots the dart2wasm `main.wasm`; +`web_gallery/web/main.dart:56` calls `NativeLibrary.initBindings("thermion_dart")`. +Two modules *can* coexist as JS globals (different `EXPORT_NAME`s), and each +exports the runtime methods ffigen_js needs (both builds pass the same +`-sEXPORTED_RUNTIME_METHODS=getValue,setValue,UTF8ToString,stringToUTF8,writeArrayToMemory,lengthBytesUTF8,HEAPU8,stackSave,stackRestore` +and `-sEXPORTED_FUNCTIONS=_malloc,stackAlloc,_free`); the blocker is purely +the Dart-side singleton. + +## 3. True state of the `reactphysics3d_dart` web path (worse than "unverified") + +- Generated web bindings **exist** (`rp3d_js_interop.g.dart`, 0.0.5-era — a + usable starting point for regeneration). +- **Nothing initializes them.** No call to `initBindings` anywhere in the + package; no loader JS; no `