From 49be9fe895732d0474e87c4e4c182d72e324b76a Mon Sep 17 00:00:00 2001 From: Jakub Freisler Date: Wed, 7 Oct 2026 00:10:12 +0200 Subject: [PATCH 1/8] feat: publish as @frsource/box3d-wasm The fork is the product: the package, repository, bug tracker and homepage now point at FRSOURCE, the scope is public, and the README says what the fork adds over monteslu/box3d-wasm. Co-Authored-By: Claude Fable 5.1 --- README.md | 30 ++++++++++++++++-------------- package.json | 16 +++++++++++----- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6d4e79d..ff6cc30 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ -# box3d-wasm +# @frsource/box3d-wasm -[![npm version](https://img.shields.io/npm/v/box3d-wasm.svg)](https://www.npmjs.com/package/box3d-wasm) -[![CI](https://github.com/monteslu/box3d-wasm/actions/workflows/ci.yml/badge.svg)](https://github.com/monteslu/box3d-wasm/actions/workflows/ci.yml) -[![license](https://img.shields.io/npm/l/box3d-wasm.svg)](LICENSE) +[![npm version](https://img.shields.io/npm/v/@frsource/box3d-wasm.svg)](https://www.npmjs.com/package/@frsource/box3d-wasm) +[![CI](https://github.com/FRSOURCE/box3d-wasm/actions/workflows/ci.yml/badge.svg)](https://github.com/FRSOURCE/box3d-wasm/actions/workflows/ci.yml) +[![license](https://img.shields.io/npm/l/@frsource/box3d-wasm.svg)](LICENSE) [Box3D](https://github.com/erincatto/box3d) compiled to WebAssembly, with SIMD and optional wasm threads. Works in browsers and Node.js from a single package. +This is a fork of [monteslu/box3d-wasm](https://github.com/monteslu/box3d-wasm) published under the `@frsource` npm scope. It tracks Box3D `main` automatically (see [Tracking Box3D](#tracking-box3d)), ships generated TypeScript declarations, and is where the bindings needed by [`@frsource/babylon-box3d`](https://github.com/FRSOURCE/babylon-box3d) are added. + See it in action: [live three.js demo](https://box3d.netlify.app/) ([source](https://github.com/monteslu/threejs-box3d-demo)) with ragdolls, dominoes, a drivable buggy, and more. Box3D is a 3D rigid body physics engine written by Erin Catto, the author of Box2D. All engine design and implementation credit belongs to him. This package only compiles his library to wasm and adds a JavaScript binding layer. Box3D is MIT licensed by Erin Catto; the full upstream license ships in this package as LICENSE.box3d.txt. @@ -13,13 +15,13 @@ Box3D is a 3D rigid body physics engine written by Erin Catto, the author of Box ## Install ```bash -npm i box3d-wasm +npm i @frsource/box3d-wasm ``` ## Quick start ```js -import Box3D from 'box3d-wasm'; +import Box3D from '@frsource/box3d-wasm'; const b3 = await Box3D(); @@ -52,15 +54,15 @@ The same code runs in Node.js and in the browser. The default import auto-detect ## Flavours -| import | threads | picked by the default import when | -| --------------------- | ------- | ---------------------------------------------------------------------- | -| `box3d-wasm/deluxe` | yes | SharedArrayBuffer is usable (Node.js, or a cross-origin isolated page) | -| `box3d-wasm/standard` | no | everything else | +| import | threads | picked by the default import when | +| ------------------------------- | ------- | ---------------------------------------------------------------------- | +| `@frsource/box3d-wasm/deluxe` | yes | SharedArrayBuffer is usable (Node.js, or a cross-origin isolated page) | +| `@frsource/box3d-wasm/standard` | no | everything else | -`box3d-wasm` (the default import) runs the detection above and returns whichever module fits. Import a specific flavour directly when you want to skip detection: +`@frsource/box3d-wasm` (the default import) runs the detection above and returns whichever module fits. Import a specific flavour directly when you want to skip detection: ```js -import Box3D from 'box3d-wasm/deluxe'; +import Box3D from '@frsource/box3d-wasm/deluxe'; const b3 = await Box3D(); const world = new b3.World({ gravity: { x: 0, y: -10, z: 0 }, workerCount: 4 }); @@ -162,14 +164,14 @@ Every body and shape gets an auto-assigned numeric `userData` tag (you can overw Wrapper objects returned by embind (`World`, `Body`, `Shape`, joints) are tiny handles. Call `.delete()` when you no longer need the JS handle, and `.destroy()` to remove the underlying object from the simulation. Destroying a world frees every body, shape, and joint inside it. -## The isomorphic surface (`box3d-wasm/iso`) +## The isomorphic surface (`@frsource/box3d-wasm/iso`) For projects where the same game source also compiles to a NATIVE binary (e.g. [scriptc-game](https://github.com/monteslu/scriptc-game)), the `/iso` entry serves the same engine through a shared binding frontend: ```js -import Box3D from 'box3d-wasm/iso'; +import Box3D from '@frsource/box3d-wasm/iso'; const b3 = await Box3D(); const world = new b3.World({ gravity: { x: 0, y: -10, z: 0 } }); diff --git a/package.json b/package.json index df8460b..63706b2 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "box3d-wasm", + "name": "@frsource/box3d-wasm", "version": "0.2.0", "description": "Box3D, Erin Catto's 3D physics engine, compiled to WebAssembly with SIMD and optional wasm threads. Isomorphic: works in browsers and Node.js, auto-detecting thread support.", "type": "module", @@ -50,12 +50,15 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/monteslu/box3d-wasm.git" + "url": "git+https://github.com/FRSOURCE/box3d-wasm.git" }, "bugs": { - "url": "https://github.com/monteslu/box3d-wasm/issues" + "url": "https://github.com/FRSOURCE/box3d-wasm/issues" + }, + "homepage": "https://github.com/FRSOURCE/box3d-wasm#readme", + "publishConfig": { + "access": "public" }, - "homepage": "https://github.com/monteslu/box3d-wasm#readme", "devDependencies": { "@frsource/eslint-config": "1.86.0", "@frsource/prettier-config": "1.48.0", @@ -67,5 +70,8 @@ "semantic-release": "25.0.9", "typescript": "6.0.3" }, - "packageManager": "pnpm@12.5.1" + "packageManager": "pnpm@12.5.1", + "contributors": [ + "Jakub Freisler (https://www.frsource.org/)" + ] } From 192a5fc51711dd269d0d5e2eb447c6f4df513255 Mon Sep 17 00:00:00 2001 From: Jakub Freisler Date: Wed, 7 Oct 2026 00:10:40 +0200 Subject: [PATCH 2/8] feat: emit TypeScript declarations from the embind registrations emcc --emit-tsd writes dist/box3d.d.ts and dist/box3d.deluxe.d.ts at link time, so the published types cannot drift from the binding. The auto-detecting entry gets a hand-written entry.d.ts that re-exports the standard flavour's types, and package.json exports point at them. Co-Authored-By: Claude Fable 5.1 --- README.md | 6 +++++- package.json | 22 ++++++++++++++++------ scripts/build.sh | 5 ++++- src/entry.d.ts | 6 ++++++ 4 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 src/entry.d.ts diff --git a/README.md b/README.md index ff6cc30..59cfaac 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ Requires [emsdk](https://emscripten.org/docs/getting_started/downloads.html) (te nvm use # picks the Node version from .nvmrc pnpm install pnpm fetch-deps # clones Box3D at the SHA pinned in scripts/versions.json -pnpm build # builds standard and deluxe flavours into dist/ +pnpm build # builds standard and deluxe flavours plus their .d.ts into dist/ pnpm test pnpm lint # eslint + prettier + clang-format pnpm fix # auto-fixes what the linters can @@ -204,6 +204,10 @@ pnpm fix # auto-fixes what the linters can Commits follow the [Angular convention](https://github.com/angular/angular/blob/main/CONTRIBUTING.md#commit); every push to `main` is released by [semantic-release](https://github.com/semantic-release/semantic-release) and published to npm through trusted publishing. +## TypeScript + +The declarations in `dist/*.d.ts` are generated by emscripten from the embind registrations at build time, so they always match the compiled binding. Parameters that take plain objects (vectors, quaternions, option bags) are typed as `any` for now. + ## License MIT for the wrapper and build scripts, see LICENSE. diff --git a/package.json b/package.json index 63706b2..c1c8f14 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,20 @@ "type": "module", "main": "dist/entry.mjs", "exports": { - ".": "./dist/entry.mjs", - "./standard": "./dist/box3d.mjs", - "./deluxe": "./dist/box3d.deluxe.mjs", - "./package.json": "./package.json", - "./iso": "./dist/iso.mjs" + ".": { + "types": "./dist/entry.d.ts", + "default": "./dist/entry.mjs" + }, + "./standard": { + "types": "./dist/box3d.d.ts", + "default": "./dist/box3d.mjs" + }, + "./deluxe": { + "types": "./dist/box3d.deluxe.d.ts", + "default": "./dist/box3d.deluxe.mjs" + }, + "./iso": "./dist/iso.mjs", + "./package.json": "./package.json" }, "files": [ "dist", @@ -73,5 +82,6 @@ "packageManager": "pnpm@12.5.1", "contributors": [ "Jakub Freisler (https://www.frsource.org/)" - ] + ], + "types": "dist/entry.d.ts" } diff --git a/scripts/build.sh b/scripts/build.sh index 68fdc69..9928996 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -100,14 +100,17 @@ for FLAVOUR in $FLAVOURS; do esac echo "==> emcc link ($FLAVOUR) -> dist/$BASENAME.mjs" + # --emit-tsd derives dist/$BASENAME.d.ts from the embind registrations, so + # the published types can never drift from the compiled binding. emcc "$ROOT/csrc/glue.cpp" "$ROOT/csrc/flat.cpp" "$LIB" \ -I "$BOX3D_SRC/include" \ "${EMCC_OPTS[@]}" \ "${FLAVOUR_FLAGS[@]:-}" \ + --emit-tsd "$BASENAME.d.ts" \ -o "$ROOT/dist/$BASENAME.mjs" done -cp "$ROOT/src/entry.mjs" "$ROOT/dist/entry.mjs" +cp "$ROOT/src/entry.mjs" "$ROOT/src/entry.d.ts" "$ROOT/dist/" # The isomorphic surface: the shared TS frontend (type-STRIPPED, not # transformed -- the same file must compile under a native host's static diff --git a/src/entry.d.ts b/src/entry.d.ts new file mode 100644 index 0000000..9c0b32b --- /dev/null +++ b/src/entry.d.ts @@ -0,0 +1,6 @@ +// The auto-detecting entry resolves to either flavour at runtime; both expose +// the same embind surface, so the standard build's generated types describe it. +import type { MainModule } from './box3d'; + +export type * from './box3d'; +export default function Box3D(options?: unknown): Promise; From 33d05c17a3822c576c25ebac4a0de92d339666b0 Mon Sep 17 00:00:00 2001 From: Jakub Freisler Date: Wed, 7 Oct 2026 00:13:32 +0200 Subject: [PATCH 3/8] feat: build from a Box3D git dependency and update it to e77352c Box3D is now the @erincatto/box3d devDependency pinned to a commit of github:erincatto/box3d, so pnpm install fetches the engine source and Renovate bumps the pin as a digest update (committed as feat(engine) and automerged once CI is green). scripts/fetch-deps.sh and versions.json are gone, and the build reads the source from node_modules. The pin moves from 29bf523 to e77352c (2026-10-03), which renamed b3Body_Get{Local,World}CenterOfMass to b3Body_Get{Local,World}Center. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 3 +-- .gitignore | 1 - README.md | 7 +++++-- csrc/glue.cpp | 4 ++-- package.json | 3 ++- pnpm-lock.yaml | 9 +++++++++ renovate.json | 11 ++++++++++- scripts/build.sh | 8 +++++--- scripts/fetch-deps.sh | 24 ------------------------ scripts/versions.json | 6 ------ 10 files changed, 34 insertions(+), 42 deletions(-) delete mode 100755 scripts/fetch-deps.sh delete mode 100644 scripts/versions.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4048a73..d62f964 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,9 +63,8 @@ jobs: with: version: 4.0.18 actions-cache-folder: emsdk-cache + # also fetches the pinned Box3D source: the @erincatto/box3d git dependency - run: pnpm install --frozen-lockfile - - name: Fetch pinned Box3D source - run: pnpm fetch-deps - run: pnpm build - run: pnpm test - uses: actions/upload-artifact@v7 diff --git a/.gitignore b/.gitignore index fc2cac3..b80bc3d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # Dependency directories node_modules/ -deps/ # Build output build/ diff --git a/README.md b/README.md index 59cfaac..af7a0c0 100644 --- a/README.md +++ b/README.md @@ -194,8 +194,7 @@ Requires [emsdk](https://emscripten.org/docs/getting_started/downloads.html) (te ```bash nvm use # picks the Node version from .nvmrc -pnpm install -pnpm fetch-deps # clones Box3D at the SHA pinned in scripts/versions.json +pnpm install # also fetches the pinned Box3D source (the @erincatto/box3d git dependency) pnpm build # builds standard and deluxe flavours plus their .d.ts into dist/ pnpm test pnpm lint # eslint + prettier + clang-format @@ -204,6 +203,10 @@ pnpm fix # auto-fixes what the linters can Commits follow the [Angular convention](https://github.com/angular/angular/blob/main/CONTRIBUTING.md#commit); every push to `main` is released by [semantic-release](https://github.com/semantic-release/semantic-release) and published to npm through trusted publishing. +## Tracking Box3D + +Box3D is pinned as a git dependency in `package.json` (`@erincatto/box3d`: `github:erincatto/box3d#`). Renovate opens a pull request whenever Box3D's `main` moves; CI rebuilds both flavours and runs the tests against the new commit, and a green build is merged and released automatically. A Box3D change that breaks the binding fails CI and waits for a fix in `csrc/glue.cpp`. + ## TypeScript The declarations in `dist/*.d.ts` are generated by emscripten from the embind registrations at build time, so they always match the compiled binding. Parameters that take plain objects (vectors, quaternions, option bags) are typed as `any` for now. diff --git a/csrc/glue.cpp b/csrc/glue.cpp index f839721..347b382 100644 --- a/csrc/glue.cpp +++ b/csrc/glue.cpp @@ -928,12 +928,12 @@ struct Body val getLocalCenterOfMass() const { - return fromVec3( b3Body_GetLocalCenterOfMass( id ) ); + return fromVec3( b3Body_GetLocalCenter( id ) ); } val getWorldCenterOfMass() const { - return fromVec3( b3Body_GetWorldCenterOfMass( id ) ); + return fromVec3( b3Body_GetWorldCenter( id ) ); } val getLocalPoint( val worldPoint ) const diff --git a/package.json b/package.json index c1c8f14..196f1ba 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,11 @@ "node": ">=22" }, "scripts": { - "fetch-deps": "bash scripts/fetch-deps.sh", "build": "bash scripts/build.sh", "build:debug": "TARGET_TYPE=Debug bash scripts/build.sh", "test": "node --test \"test/*.test.mjs\"", "prepublishOnly": "pnpm test", + "prepack": "pnpm build", "lint": "eslint . && prettier . --check && pnpm lint:native", "lint:native": "clang-format --dry-run --Werror csrc/*.cpp", "fix": "eslint . --fix && prettier . --write && clang-format -i csrc/*.cpp", @@ -69,6 +69,7 @@ "access": "public" }, "devDependencies": { + "@erincatto/box3d": "github:erincatto/box3d#e77352cd606dc1a34209094076199549a52ea0a1", "@frsource/eslint-config": "1.86.0", "@frsource/prettier-config": "1.48.0", "@frsource/semantic-release-config": "1.117.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7da1b98..a3a2edd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: .: devDependencies: + '@erincatto/box3d': + specifier: github:erincatto/box3d#e77352cd606dc1a34209094076199549a52ea0a1 + version: https://codeload.github.com/erincatto/box3d/tar.gz/e77352cd606dc1a34209094076199549a52ea0a1 '@frsource/eslint-config': specifier: 1.86.0 version: 1.86.0(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) @@ -226,6 +229,10 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@erincatto/box3d@https://codeload.github.com/erincatto/box3d/tar.gz/e77352cd606dc1a34209094076199549a52ea0a1': + resolution: {gitHosted: true, integrity: sha512-Vig3o/9xfDY7G14TUCUVVgOFn2k1ORokJjelyafyZiSWT6566MSeHjKrhBnHL3bkrF3wUIWSHm49bRDixjsAjw==, tarball: https://codeload.github.com/erincatto/box3d/tar.gz/e77352cd606dc1a34209094076199549a52ea0a1} + version: 0.0.0 + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1897,6 +1904,8 @@ snapshots: '@colors/colors@1.5.0': optional: true + '@erincatto/box3d@https://codeload.github.com/erincatto/box3d/tar.gz/e77352cd606dc1a34209094076199549a52ea0a1': {} + '@eslint-community/eslint-utils@4.10.1(eslint@10.12.0(supports-color@7.2.0))': dependencies: eslint: 10.12.0(supports-color@7.2.0) diff --git a/renovate.json b/renovate.json index b8d2509..e12c738 100644 --- a/renovate.json +++ b/renovate.json @@ -1,4 +1,13 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["github>FRSOURCE/renovate-config"] + "extends": ["github>FRSOURCE/renovate-config"], + "packageRules": [ + { + "matchDepNames": ["erincatto/box3d"], + "semanticCommitType": "feat", + "semanticCommitScope": "engine", + "automerge": true, + "schedule": ["at any time"] + } + ] } diff --git a/scripts/build.sh b/scripts/build.sh index 9928996..3a2f14c 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -20,10 +20,12 @@ ROOT="$(dirname "$DIR")" FLAVOURS="${FLAVOURS:-standard deluxe}" TARGET_TYPE="${TARGET_TYPE:-Release}" -BOX3D_SRC="$ROOT/deps/box3d" +# Box3D is a git dependency in package.json (github:erincatto/box3d#), so +# pnpm puts its source tree here. Renovate bumps the pinned commit. +BOX3D_SRC="$ROOT/node_modules/@erincatto/box3d" if [ ! -f "$BOX3D_SRC/CMakeLists.txt" ]; then - echo "deps/box3d missing. Run: npm run fetch-deps" >&2 + echo "node_modules/@erincatto/box3d missing. Run: pnpm install" >&2 exit 1 fi @@ -62,7 +64,7 @@ for FLAVOUR in $FLAVOURS; do > "$CMAKE_BUILD_DIR.cmake.log" 2>&1 || { cat "$CMAKE_BUILD_DIR.cmake.log" >&2; exit 1; } echo "==> build libbox3d.a ($FLAVOUR)" - cmake --build "$CMAKE_BUILD_DIR" -j"$(nproc)" > "$CMAKE_BUILD_DIR.build.log" 2>&1 \ + cmake --build "$CMAKE_BUILD_DIR" -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" > "$CMAKE_BUILD_DIR.build.log" 2>&1 \ || { tail -50 "$CMAKE_BUILD_DIR.build.log" >&2; exit 1; } LIB="$CMAKE_BUILD_DIR/src/libbox3d.a" diff --git a/scripts/fetch-deps.sh b/scripts/fetch-deps.sh deleted file mode 100755 index b1d1769..0000000 --- a/scripts/fetch-deps.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# Fetch upstream Box3D source at the SHA pinned in scripts/versions.json. -# Idempotent: skips the fetch when deps/box3d is already at the pin. -set -euo pipefail - -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" -ROOT="$(dirname "$DIR")" - -SHA="$(node -p "JSON.parse(require('fs').readFileSync('$DIR/versions.json','utf8')).box3d.sha")" -REPO="$(node -p "JSON.parse(require('fs').readFileSync('$DIR/versions.json','utf8')).box3d.repo")" -DEST="$ROOT/deps/box3d" - -if [ -d "$DEST/.git" ] && [ "$(git -C "$DEST" rev-parse HEAD 2>/dev/null)" = "$SHA" ]; then - echo "box3d already at $SHA" - exit 0 -fi - -rm -rf "$DEST" -mkdir -p "$DEST" -git -C "$DEST" init -q -git -C "$DEST" remote add origin "$REPO" -git -C "$DEST" fetch -q --depth 1 origin "$SHA" -git -C "$DEST" checkout -q FETCH_HEAD -echo "box3d fetched at $SHA" diff --git a/scripts/versions.json b/scripts/versions.json deleted file mode 100644 index 7999143..0000000 --- a/scripts/versions.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "box3d": { - "repo": "https://github.com/erincatto/box3d.git", - "sha": "29bf523ce7bc4590aba9f17c9db791cdc5c4397e" - } -} From ffa8ca2e1a3244caf2434e7a1a5c89d3da58f230 Mon Sep 17 00:00:00 2001 From: Jakub Freisler Date: Wed, 7 Oct 2026 00:36:09 +0200 Subject: [PATCH 4/8] fix(ci): keep emsdk outside the workspace so --emit-tsd can run tsc emsdk installs emscripten's npm deps with --production, so emcc falls back to the project's tsc (TypeScript 6) and runs it from the emsdk directory. TypeScript 6 walks up from there and, with the cache inside the workspace, finds the project tsconfig.json and exits with TS5112. Caching emsdk one level above the workspace keeps that search empty. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d62f964..9baf9ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,11 @@ jobs: - uses: mymindstorm/setup-emsdk@v16 with: version: 4.0.18 - actions-cache-folder: emsdk-cache + # Outside the workspace: emcc --emit-tsd runs tsc from the emsdk + # directory, and TypeScript 6 refuses (TS5112) when it finds a + # tsconfig.json in any parent directory. + actions-cache-folder: ../emsdk-cache + cache-key: emsdk-${{ runner.os }}-${{ hashFiles('.github/workflows/ci.yml') }} # also fetches the pinned Box3D source: the @erincatto/box3d git dependency - run: pnpm install --frozen-lockfile - run: pnpm build From dfa372f2f7c671844313eaece8c40f2fdc666827 Mon Sep 17 00:00:00 2001 From: Jakub Freisler Date: Wed, 7 Oct 2026 00:38:55 +0200 Subject: [PATCH 5/8] fix(ci): install emsdk through the runner tool cache instead of the workspace actions/cache rejects relative paths that leave the workspace, so the only way to keep emsdk out of the tsconfig.json search path is to drop actions-cache-folder. The uncached install takes about thirty seconds, roughly what restoring the 500 MB cache tarball took. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9baf9ab..b0e0f56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,14 +59,12 @@ jobs: with: node-version-file: '.nvmrc' cache: 'pnpm' + # No actions-cache-folder: that would put emsdk inside the workspace, and + # emcc --emit-tsd runs tsc from the emsdk directory, where TypeScript 6 + # refuses (TS5112) once it finds a tsconfig.json in a parent directory. - uses: mymindstorm/setup-emsdk@v16 with: version: 4.0.18 - # Outside the workspace: emcc --emit-tsd runs tsc from the emsdk - # directory, and TypeScript 6 refuses (TS5112) when it finds a - # tsconfig.json in any parent directory. - actions-cache-folder: ../emsdk-cache - cache-key: emsdk-${{ runner.os }}-${{ hashFiles('.github/workflows/ci.yml') }} # also fetches the pinned Box3D source: the @erincatto/box3d git dependency - run: pnpm install --frozen-lockfile - run: pnpm build From 7b26217151e33b815e627a000d45ecb53ddd0bec Mon Sep 17 00:00:00 2001 From: Jakub Freisler Date: Wed, 7 Oct 2026 00:41:45 +0200 Subject: [PATCH 6/8] fix(ci): build with emsdk 6.0.2 so --emit-tsd works with TypeScript 6 emscripten 4.0.18 passes --outFile to tsc when generating the embind declarations, and TypeScript 6 rejects that option as deprecated (TS5101). emscripten stopped passing it in 6.0.0; 6.0.2 is the version the local build and tests were run with. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0e0f56..7225248 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: # refuses (TS5112) once it finds a tsconfig.json in a parent directory. - uses: mymindstorm/setup-emsdk@v16 with: - version: 4.0.18 + version: 6.0.2 # also fetches the pinned Box3D source: the @erincatto/box3d git dependency - run: pnpm install --frozen-lockfile - run: pnpm build diff --git a/README.md b/README.md index af7a0c0..f99c0c0 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ with the embind API above, which remains the default export. ## Building from source -Requires [emsdk](https://emscripten.org/docs/getting_started/downloads.html) (tested with 4.0.18), CMake, the Node version from `.nvmrc` and pnpm. +Requires [emsdk](https://emscripten.org/docs/getting_started/downloads.html) (tested with 6.0.2), CMake, the Node version from `.nvmrc` and pnpm. ```bash nvm use # picks the Node version from .nvmrc From 2e3840de4a81bf5660d792dfc5d05e3b0666969e Mon Sep 17 00:00:00 2001 From: Jakub Freisler Date: Wed, 7 Oct 2026 00:49:27 +0200 Subject: [PATCH 7/8] docs: rewrite the README in the FRSOURCE layout with a full API reference Centered header, quick start, flavour table and a reference that covers every bound option and method: world, bodies, shapes, the nine joint types, queries, events, memory handling and the generated types. Defaults are taken from the pinned Box3D definitions. Co-Authored-By: Claude Fable 5.1 --- README.md | 402 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 318 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index f99c0c0..671ab98 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,68 @@ -# @frsource/box3d-wasm +

+ + NPM version badge + + + CI status badge + + + semantic-release badge + + + license MIT badge + +

+ +

Box3D WASM - Erin Catto's 3D physics engine for browsers and Node.js! 🧊

+ +

+ Getting Started + · + API + · + Demo + · + File an Issue + · + Have a question or an idea? +
+

+ +

+
+ Box3D compiled to WebAssembly with SIMD and optional wasm threads +
One package for browsers and Node.js, auto-detecting thread support +
Plain { x, y, z } vectors and { x, y, z, w } quaternions that drop straight into three.js or Babylon.js +
Rigid bodies, boxes, spheres, capsules, convex hulls, nine joint types, ray casts, explosions and events +
TypeScript declarations generated from the binding itself at build time +
Tracks Box3D main automatically: every upstream commit is rebuilt, tested and released +
Published under MIT license
+
+
+

+ +Box3D is a 3D rigid body physics engine written by [Erin Catto](https://github.com/erincatto), the author of Box2D. All engine design and implementation credit belongs to him; this package compiles his library to wasm and adds a JavaScript binding layer. + +This is a fork of [monteslu/box3d-wasm](https://github.com/monteslu/box3d-wasm) published under the `@frsource` npm scope. It is where the bindings needed by [`@frsource/babylon-box3d`](https://github.com/FRSOURCE/babylon-box3d) are added. See it in action in the [live three.js demo](https://box3d.netlify.app/) ([source](https://github.com/monteslu/threejs-box3d-demo)) with ragdolls, dominoes and a drivable buggy. -[![npm version](https://img.shields.io/npm/v/@frsource/box3d-wasm.svg)](https://www.npmjs.com/package/@frsource/box3d-wasm) -[![CI](https://github.com/FRSOURCE/box3d-wasm/actions/workflows/ci.yml/badge.svg)](https://github.com/FRSOURCE/box3d-wasm/actions/workflows/ci.yml) -[![license](https://img.shields.io/npm/l/@frsource/box3d-wasm.svg)](LICENSE) - -[Box3D](https://github.com/erincatto/box3d) compiled to WebAssembly, with SIMD and optional wasm threads. Works in browsers and Node.js from a single package. - -This is a fork of [monteslu/box3d-wasm](https://github.com/monteslu/box3d-wasm) published under the `@frsource` npm scope. It tracks Box3D `main` automatically (see [Tracking Box3D](#tracking-box3d)), ships generated TypeScript declarations, and is where the bindings needed by [`@frsource/babylon-box3d`](https://github.com/FRSOURCE/babylon-box3d) are added. +## Quick start -See it in action: [live three.js demo](https://box3d.netlify.app/) ([source](https://github.com/monteslu/threejs-box3d-demo)) with ragdolls, dominoes, a drivable buggy, and more. +### Installation -Box3D is a 3D rigid body physics engine written by Erin Catto, the author of Box2D. All engine design and implementation credit belongs to him. This package only compiles his library to wasm and adds a JavaScript binding layer. Box3D is MIT licensed by Erin Catto; the full upstream license ships in this package as LICENSE.box3d.txt. +```bash +npm install @frsource/box3d-wasm -## Install +yarn add @frsource/box3d-wasm -```bash -npm i @frsource/box3d-wasm +pnpm add @frsource/box3d-wasm ``` -## Quick start +### Modern JS/TypeScript -```js +```ts import Box3D from '@frsource/box3d-wasm'; const b3 = await Box3D(); - const world = new b3.World({ gravity: { x: 0, y: -10, z: 0 } }); const ground = world.createBody({ @@ -33,46 +71,49 @@ const ground = world.createBody({ }); ground.createBox({ halfExtents: { x: 20, y: 0.5, z: 20 } }); -const body = world.createBody({ +const crate = world.createBody({ type: 'dynamic', position: { x: 0, y: 5, z: 0 }, }); -body.createBox({ +crate.createBox({ halfExtents: { x: 0.5, y: 0.5, z: 0.5 }, density: 1, friction: 0.5, }); for (let i = 0; i < 120; i++) { - world.step(1 / 60, 4); + world.step(1 / 60, 4); // timeStep in seconds, sub-step count } -console.log(body.getPosition()); // { x: ~0, y: ~0.5, z: ~0 } +crate.getPosition(); // { x: ~0, y: ~0.5, z: ~0 } + +world.destroy(); // frees every body, shape and joint in it ``` -The same code runs in Node.js and in the browser. The default import auto-detects thread support and loads the best build. Both builds use wasm SIMD, which every modern browser and Node.js supports. The wasm file is loaded relative to the module, so bundlers that understand `new URL(..., import.meta.url)` (Vite, webpack 5, Rollup) pick it up automatically. +The same code runs in Node.js and in the browser. The wasm file is loaded relative to the module, so bundlers that understand `new URL(..., import.meta.url)` (Vite, webpack 5, Rollup) pick it up automatically. -## Flavours +### Flavours + +Both builds use wasm SIMD, which every modern browser and Node.js supports. The only runtime question is threads: | import | threads | picked by the default import when | | ------------------------------- | ------- | ---------------------------------------------------------------------- | | `@frsource/box3d-wasm/deluxe` | yes | SharedArrayBuffer is usable (Node.js, or a cross-origin isolated page) | | `@frsource/box3d-wasm/standard` | no | everything else | -`@frsource/box3d-wasm` (the default import) runs the detection above and returns whichever module fits. Import a specific flavour directly when you want to skip detection: +`@frsource/box3d-wasm` (the default import) runs the detection and returns whichever module fits. Import a flavour directly to skip detection: -```js +```ts import Box3D from '@frsource/box3d-wasm/deluxe'; const b3 = await Box3D(); +b3.threaded; // true const world = new b3.World({ gravity: { x: 0, y: -10, z: 0 }, workerCount: 4 }); ``` -`workerCount` enables Box3D's internal multithreaded solver. It is clamped to `[1, b3.maxWorkers]`. The single threaded builds ignore it. Check `b3.threaded` at runtime to see which build you got. - -### Serving requirements for threads +`workerCount` enables Box3D's internal multithreaded solver. It is clamped to `[1, b3.maxWorkers]` and ignored by the single threaded build. -Wasm threads use SharedArrayBuffer. Browsers require the page to be cross-origin isolated, so serve your app with: +Wasm threads use SharedArrayBuffer, which browsers only expose on cross-origin isolated pages, so serve your app with: ``` Cross-Origin-Opener-Policy: same-origin @@ -81,34 +122,72 @@ Cross-Origin-Embedder-Policy: require-corp Node.js needs no special setup; worker threads are used automatically. -## API overview +## API -Vectors are plain objects `{ x, y, z }` and quaternions are `{ x, y, z, w }`, so values pass directly to and from libraries like three.js. +### Conventions + +- Vectors are plain `{ x, y, z }` objects and quaternions are `{ x, y, z, w }`. Transforms are `{ position, rotation }`. Values pass directly to and from three.js, Babylon.js and friends. +- Every creator takes one options object applied over the Box3D default definition, so every field is optional. Unknown keys are ignored. +- Angles are radians, lengths are meters, masses are kilograms, and `hertz` / `dampingRatio` pairs configure Box3D's soft constraints. +- `World`, `Body`, `Shape` and joint objects are tiny handles over Box3D ids. `.destroy()` removes the object from the simulation; `.delete()` frees the JS handle (see [Memory](#memory)). `.isValid()` tells whether the underlying object still exists. +- Bodies and shapes carry a numeric `userData` tag. One is auto-assigned at creation (starting at 1; 0 means untagged) and you can overwrite it with your own number. Events and ray casts report these tags, so a plain `Map` connects physics back to your scene. + +### Module + +```ts +const b3 = await Box3D(emscriptenOptions?); +``` + +| member | description | +| --------------- | --------------------------------------------------------------------- | +| `b3.World` | the world class, see below | +| `b3.threaded` | `true` in the deluxe (wasm threads) build | +| `b3.maxWorkers` | upper bound for `workerCount` | +| `b3.HEAPF32` … | the usual emscripten runtime exports (`HEAPF32`, `HEAPU8`, `HEAPU32`) | ### World -```js +```ts const world = new b3.World({ gravity: { x: 0, y: -10, z: 0 }, enableSleep: true, enableContinuous: true, workerCount: 4, // deluxe build only }); - -world.step(1 / 60, 4); // timeStep, subStepCount -world.setGravity({ x: 0, y: -3.7, z: 0 }); -world.getAwakeBodyCount(); -world.castRayClosest(origin, translation, filter); -world.explode({ position, radius: 3, falloff: 2, impulsePerArea: 10 }); -world.getBodyEvents(); // [{ userData, position, rotation, fellAsleep }] -world.getContactEvents(); // { begin: [...], end: [...], hit: [...] } -world.getSensorEvents(); // { begin: [...], end: [...] } -world.destroy(); ``` +| option | default | description | +| ---------------------- | --------------- | ---------------------------------------------------------- | +| `gravity` | `{ 0, -10, 0 }` | world gravity vector | +| `enableSleep` | `true` | let resting bodies fall asleep | +| `enableContinuous` | `true` | continuous collision for fast bodies against static shapes | +| `restitutionThreshold` | Box3D default | relative speed below which restitution is ignored | +| `hitEventThreshold` | Box3D default | approach speed above which hit events are reported | +| `contactHertz` | Box3D default | contact stiffness | +| `contactDampingRatio` | Box3D default | contact damping | +| `contactSpeed` | Box3D default | maximum contact push-out speed | +| `maximumLinearSpeed` | Box3D default | speed clamp for all bodies | +| `workerCount` | `1` | solver threads, clamped to `[1, b3.maxWorkers]` | + +| method | description | +| -------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `step(timeStep, subStepCount)` | advance the simulation; `1 / 60` and `4` are good defaults | +| `getGravity()` / `setGravity(v)` | world gravity | +| `enableSleeping(flag)` | toggle sleeping for the whole world | +| `enableContinuous(flag)` | toggle continuous collision | +| `getAwakeBodyCount()` | number of bodies currently simulating | +| `getWorkerCount()` | solver threads in use | +| `createBody(opts)` | see [Bodies](#bodies) | +| `createJoint(bodyA, bodyB, opts)` | see [Joints](#joints) | +| `castRayClosest(origin, translation, filter)` | see [Queries](#queries) | +| `explode(opts)` | see [Queries](#queries) | +| `getBodyEvents()` / `getContactEvents()` / `getSensorEvents()` | see [Events](#events) | +| `getProfile()` | `{ step, pairs, collide, solve }` timings in milliseconds for the last step | +| `isValid()` / `destroy()` | destroying a world frees every body, shape and joint inside it | + ### Bodies -```js +```ts const body = world.createBody({ type: 'dynamic', // 'static' | 'kinematic' | 'dynamic' position: { x: 0, y: 5, z: 0 }, @@ -116,79 +195,228 @@ const body = world.createBody({ linearVelocity: { x: 0, y: 0, z: 0 }, angularDamping: 0.05, motionLocks: { angularX: true, angularZ: true }, + userData: 42, }); - -body.getPosition(); -body.getRotation(); -body.getTransform(); -body.setLinearVelocity(v); -body.applyLinearImpulseToCenter(v, true); -body.applyForce(force, worldPoint, true); -body.applyTorque(t, true); -body.getMass(); -body.isAwake(); -body.setAwake(true); -body.destroy(); ``` +| option | default | description | +| ------------------------------------ | ------------- | ---------------------------------------------------------------------- | +| `type` | `'static'` | `'static'`, `'kinematic'` or `'dynamic'` | +| `position` / `rotation` | identity | initial transform | +| `linearVelocity` / `angularVelocity` | zero | initial velocities | +| `linearDamping` / `angularDamping` | `0` | velocity damping | +| `gravityScale` | `1` | per-body gravity multiplier | +| `sleepThreshold` | Box3D default | speed below which the body may sleep | +| `enableSleep` | `true` | allow this body to sleep | +| `isAwake` | `true` | start awake | +| `isBullet` | `false` | continuous collision against other dynamic bodies too | +| `isEnabled` | `true` | start enabled | +| `allowFastRotation` | `false` | skip the angular speed clamp | +| `enableContactRecycling` | Box3D default | reuse contact data between steps | +| `motionLocks` | none | `{ linearX, linearY, linearZ, angularX, angularY, angularZ }` booleans | +| `userData` | auto tag | your numeric tag | +| `name` | `''` | debug name | + +| method | description | +| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `getPosition()` / `getRotation()` / `getTransform()` | current pose | +| `setTransform(position, rotation)` | teleport; either argument may be omitted to keep the current value | +| `setTargetTransform(transform, timeStep, wake)` | move a kinematic body by velocity so it still pushes things | +| `getLinearVelocity()` / `setLinearVelocity(v)` | linear velocity | +| `getAngularVelocity()` / `setAngularVelocity(v)` | angular velocity | +| `applyForce(force, worldPoint, wake)` / `applyForceToCenter(force, wake)` | forces for the next step | +| `applyTorque(torque, wake)` | torque for the next step | +| `applyLinearImpulse(impulse, worldPoint, wake)` / `applyLinearImpulseToCenter(impulse, wake)` | instant velocity change | +| `applyAngularImpulse(impulse, wake)` | instant angular velocity change | +| `getMass()` / `applyMassFromShapes()` | mass is derived from shape densities; recompute after changing them | +| `getLocalCenterOfMass()` / `getWorldCenterOfMass()` | center of mass | +| `getLocalPoint(worldPoint)` / `getWorldPoint(localPoint)` | frame conversion | +| `getLinearDamping()` / `setLinearDamping(d)` / `getAngularDamping()` / `setAngularDamping(d)` | damping | +| `getGravityScale()` / `setGravityScale(s)` | per-body gravity | +| `isAwake()` / `setAwake(flag)` / `enableSleep(flag)` | sleep state | +| `isEnabled()` / `setEnabled(flag)` | disabled bodies leave the simulation but keep their shapes | +| `isBullet()` / `setBullet(flag)` | continuous collision against dynamic bodies | +| `getMotionLocks()` / `setMotionLocks(locks)` | lock axes, e.g. keep a character upright | +| `getType()` / `setType(type)` | body type as a string | +| `getName()` / `setName(name)` / `getUserData()` / `setUserData(tag)` | identification | +| `getShapeCount()` / `computeAABB()` | `{ lowerBound, upperBound }` around all shapes | +| `createBox(opts)` / `createSphere(opts)` / `createCapsule(opts)` / `createHull(opts)` | see [Shapes](#shapes) | +| `isValid()` / `destroy()` | destroying a body destroys its shapes and joints | + ### Shapes -Each shape creator takes one options object with the geometry plus material fields (`density`, `friction`, `restitution`, `isSensor`, `filter`, event flags): +Each creator takes one options object with the geometry plus the material and event fields below: -```js +```ts body.createBox({ halfExtents: { x: 1, y: 0.5, z: 2 }, friction: 0.7 }); +body.createBox({ hx: 1, hy: 0.5, hz: 2, offset: { x: 0, y: 1, z: 0 } }); body.createSphere({ radius: 0.5, restitution: 0.8 }); body.createCapsule({ height: 1.2, radius: 0.3 }); -body.createHull({ points: [{ x, y, z }, ...] }); +body.createHull({ + points: [ + { x: -1, y: 0, z: -1 }, + { x: 1, y: 0, z: -1 }, + { x: 0, y: 1, z: 0 }, + { x: 0, y: 0, z: 1 }, + ], +}); ``` +| geometry option | shape | description | +| ------------------------------- | --------------- | ------------------------------------------------------ | +| `halfExtents` or `hx`/`hy`/`hz` | box | half sizes, default `0.5` each | +| `offset` / `rotation` | box | place the box away from the body origin | +| `radius` | sphere, capsule | default `0.5` | +| `center` | sphere | sphere center in body space | +| `height` | capsule | distance between the hemisphere centers, along local y | +| `center1` / `center2` | capsule | explicit hemisphere centers when `height` is not given | +| `points` | hull | array of vectors; the convex hull is computed for you | +| `maxVertices` | hull | hull simplification budget, default `32` | + +| material / event option | default | description | +| ----------------------- | ------------- | ---------------------------------------------------------------- | +| `density` | `1` | mass per volume; `0` makes a massless shape | +| `friction` | Box3D default | Coulomb friction | +| `restitution` | `0` | bounciness | +| `rollingResistance` | `0` | slows rolling spheres and capsules | +| `tangentVelocity` | zero | conveyor belt surface velocity | +| `userMaterialId` | `0` | your material id, reported nowhere yet but stored | +| `isSensor` | `false` | detect overlaps without collision response | +| `enableSensorEvents` | `false` | let this shape be seen by sensors / report visitors if it is one | +| `enableContactEvents` | `true` | report begin / end touch events | +| `enableHitEvents` | `false` | report impacts above `hitEventThreshold` | +| `invokeContactCreation` | Box3D default | create contacts immediately for static shapes | +| `updateBodyMass` | `true` | recompute the body mass after adding the shape | +| `filter` | collide all | `{ categoryBits, maskBits, groupIndex }`, see below | +| `userData` | auto tag | your numeric tag | + +| method | description | +| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `getType()` | `'sphere'`, `'capsule'`, `'hull'`, `'mesh'`, `'heightField'` or `'compound'` | +| `getFriction()` / `setFriction(f)` | friction | +| `getRestitution()` / `setRestitution(r)` | restitution | +| `getDensity()` / `setDensity(d, updateBodyMass)` | density | +| `isSensor()` | sensor flag | +| `enableSensorEvents(flag)` / `enableContactEvents(flag)` / `enableHitEvents(flag)` | event flags | +| `getFilter()` / `setFilter(filter)` | collision filter | +| `getAABB()` | `{ lowerBound, upperBound }` | +| `rayCast(origin, translation)` | `{ hit, point, normal, fraction }` against this shape only | +| `getUserData()` / `setUserData(tag)` | tag | +| `isValid()` / `destroy(updateBodyMass)` | remove the shape from its body | + +Collision filtering follows Box3D: two shapes collide when each one's `categoryBits` is in the other's `maskBits`, unless they share a non-zero `groupIndex`, in which case a positive group always collides and a negative group never does. Bits are plain numbers (up to 2^53). + ### Joints -Distance, revolute, spherical, prismatic, weld, motor, wheel, parallel, and filter joints are bound: +Every joint connects two bodies and is created on the world: -```js -const hinge = world.createRevoluteJoint(bodyA, bodyB, { - localFrameA: { position: { x: 0, y: 1, z: 0 } }, +```ts +const hinge = world.createRevoluteJoint(chassis, wheel, { + localFrameA: { position: { x: 1, y: 0, z: 1 } }, + localFrameB: { position: { x: 0, y: 0, z: 0 } }, enableMotor: true, motorSpeed: 5, maxMotorTorque: 100, }); hinge.getAngle(); +hinge.setMotorSpeed(-5); ``` -### Events and userData tags +Common options: `localFrameA` / `localFrameB` (`{ position, rotation }` in each body's space, identity by default), `anchorA` / `anchorB` (shorthand for just the frame positions), `collideConnected` (default `false`), `forceThreshold` / `torqueThreshold` (joint event thresholds). + +Common methods: `getType()`, `getLocalFrameA()` / `getLocalFrameB()`, `getCollideConnected()` / `setCollideConnected(flag)`, `getConstraintForce()` / `getConstraintTorque()`, `wakeBodies()`, `isValid()`, `destroy(wakeAttached)`. + +| joint | creation options | runtime methods | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `createDistanceJoint` | `length`, `enableSpring`, `hertz`, `dampingRatio`, `lowerSpringForce`, `upperSpringForce`, `enableLimit`, `minLength`, `maxLength`, `enableMotor`, `maxMotorForce`, `motorSpeed` | `getLength` / `setLength`, `getCurrentLength`, `enableSpring`, `setSpringHertz`, `setSpringDampingRatio`, `enableLimit`, `setLengthRange`, `enableMotor`, `setMotorSpeed`, `setMaxMotorForce` | +| `createRevoluteJoint` | `targetAngle`, `enableSpring`, `hertz`, `dampingRatio`, `enableLimit`, `lowerAngle`, `upperAngle`, `enableMotor`, `maxMotorTorque`, `motorSpeed` | `getAngle`, `enableSpring`, `setSpringHertz`, `setSpringDampingRatio`, `setTargetAngle`, `enableLimit`, `setLimits`, `enableMotor`, `setMotorSpeed`, `setMaxMotorTorque`, `getMotorTorque` | +| `createSphericalJoint` | `enableSpring`, `hertz`, `dampingRatio`, `targetRotation`, `enableConeLimit`, `coneAngle`, `enableTwistLimit`, `lowerTwistAngle`, `upperTwistAngle`, `enableMotor`, `maxMotorTorque`, `motorVelocity` | `enableConeLimit`, `setConeLimit`, `getConeAngle`, `enableTwistLimit`, `setTwistLimits`, `getTwistAngle`, `enableSpring`, `setSpringHertz`, `setSpringDampingRatio`, `setTargetRotation`, `enableMotor`, `setMotorVelocity`, `setMaxMotorTorque` | +| `createPrismaticJoint` | `enableSpring`, `hertz`, `dampingRatio`, `targetTranslation`, `enableLimit`, `lowerTranslation`, `upperTranslation`, `enableMotor`, `maxMotorForce`, `motorSpeed` | `getTranslation`, `getSpeed`, `enableSpring`, `setSpringHertz`, `setSpringDampingRatio`, `setTargetTranslation`, `enableLimit`, `setLimits`, `enableMotor`, `setMotorSpeed`, `setMaxMotorForce` | +| `createWeldJoint` | `linearHertz`, `angularHertz`, `linearDampingRatio`, `angularDampingRatio` | `setLinearHertz`, `setLinearDampingRatio`, `setAngularHertz`, `setAngularDampingRatio` | +| `createMotorJoint` | `linearVelocity`, `maxVelocityForce`, `angularVelocity`, `maxVelocityTorque`, `linearHertz`, `linearDampingRatio`, `maxSpringForce`, `angularHertz`, `angularDampingRatio`, `maxSpringTorque` | `setLinearVelocity`, `setAngularVelocity`, `setMaxVelocityForce`, `setMaxVelocityTorque`, `setLinearHertz`, `setLinearDampingRatio`, `setAngularHertz`, `setAngularDampingRatio`, `setMaxSpringForce`, `setMaxSpringTorque` | +| `createWheelJoint` | `enableSuspensionSpring`, `suspensionHertz`, `suspensionDampingRatio`, `enableSuspensionLimit`, `lowerSuspensionLimit`, `upperSuspensionLimit`, `enableSpinMotor`, `maxSpinTorque`, `spinSpeed`, `enableSteering`, `steeringHertz`, `steeringDampingRatio`, `targetSteeringAngle`, `maxSteeringTorque`, `enableSteeringLimit`, `lowerSteeringLimit`, `upperSteeringLimit` | `enableSuspension`, `setSuspensionHertz`, `setSuspensionDampingRatio`, `enableSuspensionLimit`, `setSuspensionLimits`, `enableSpinMotor`, `setSpinMotorSpeed`, `setMaxSpinTorque`, `getSpinSpeed`, `enableSteering`, `setSteeringHertz`, `setSteeringDampingRatio`, `setMaxSteeringTorque`, `enableSteeringLimit`, `setSteeringLimits`, `setTargetSteeringAngle`, `getSteeringAngle` | +| `createParallelJoint` | `hertz`, `dampingRatio`, `maxTorque` | `setSpringHertz`, `setSpringDampingRatio`, `setMaxTorque` | +| `createFilterJoint` | common options only | common methods only; disables collision between the two bodies | + +### Queries + +```ts +const hit = world.castRayClosest(origin, translation, { maskBits: 0xffff }); +if (hit.hit) { + hit.point; // world point + hit.normal; // surface normal + hit.fraction; // 0..1 along translation + hit.shapeUserData; // tags to look your objects up with + hit.bodyUserData; + hit.shape.delete(); // a Shape handle; free it when done +} + +world.explode({ + position: { x: 0, y: 1, z: 0 }, + radius: 3, + falloff: 2, + impulsePerArea: 10, + maskBits: 0xffff, // optional, which shape categories are affected +}); +``` + +`castRayClosest` casts from `origin` along `translation` (direction and length in one vector) and returns `{ hit: false }` or the fields above. The filter argument is optional and takes `categoryBits` / `maskBits`. `shape.rayCast(origin, translation)` does the same against a single shape and returns `{ hit, point, normal, fraction }`. + +### Events -Every body and shape gets an auto-assigned numeric `userData` tag (you can overwrite it with your own number). Event arrays reference these tags, so you can map physics events back to your scene objects with a plain `Map`. +Events are collected during `step()` and read back as plain arrays afterwards. Read them every step; the next step overwrites them. -### Memory notes +```ts +world.step(1 / 60, 4); + +for (const e of world.getBodyEvents()) { + // { userData, position, rotation, fellAsleep } for every body that moved + meshes.get(e.userData)?.position.copy(e.position); +} + +const contacts = world.getContactEvents(); +contacts.begin; // [{ shapeUserDataA, shapeUserDataB }] +contacts.end; // [{ shapeUserDataA, shapeUserDataB }] (null when the shape was destroyed) +contacts.hit; // [{ shapeUserDataA, shapeUserDataB, point, normal, approachSpeed }] + +const sensors = world.getSensorEvents(); +sensors.begin; // [{ sensorUserData, visitorUserData }] +sensors.end; // [{ sensorUserData, visitorUserData }] (null when the shape was destroyed) +``` -Wrapper objects returned by embind (`World`, `Body`, `Shape`, joints) are tiny handles. Call `.delete()` when you no longer need the JS handle, and `.destroy()` to remove the underlying object from the simulation. Destroying a world frees every body, shape, and joint inside it. +Body move events are always on. Contact begin / end events need `enableContactEvents` on at least one of the two shapes (on by default), hit events need `enableHitEvents` and an approach speed above the world's `hitEventThreshold`, and sensor events need a shape created with `isSensor: true` plus `enableSensorEvents: true` on the visitors you want it to notice. + +### Memory + +Objects returned by the binding are embind handles. Two different calls free two different things: + +- `.destroy()` removes the world, body, shape or joint from the simulation. Destroying a world frees every object inside it; destroying a body frees its shapes and joints. +- `.delete()` frees the JS-side handle. Handles are tiny, but each one you keep is a small leak until deleted, and that includes the `shape` returned by `castRayClosest`. Handles also implement `Symbol.dispose`, so `using shape = body.createBox(...)` works where explicit resource management is available. + +A handle whose object was destroyed reports `isValid() === false`; calling anything else on it is undefined behaviour, as in Box3D itself. + +### TypeScript + +`dist/box3d.d.ts` and `dist/box3d.deluxe.d.ts` are generated by emscripten from the embind registrations at build time, so they always match the compiled binding, and the package `exports` point at them. Parameters that take plain objects (vectors, quaternions, option bags) are typed as `any` for now. ## The isomorphic surface (`@frsource/box3d-wasm/iso`) -For projects where the same game source also compiles to a NATIVE binary -(e.g. [scriptc-game](https://github.com/monteslu/scriptc-game)), the -`/iso` entry serves the same engine through a shared binding frontend: +For projects where the same game source also compiles to a native binary (e.g. [scriptc-game](https://github.com/monteslu/scriptc-game)), the `/iso` entry serves the same engine through a shared binding frontend: -```js +```ts import Box3D from '@frsource/box3d-wasm/iso'; const b3 = await Box3D(); const world = new b3.World({ gravity: { x: 0, y: -10, z: 0 } }); ``` -`src/frontend.ts` is the entire API personality, written against a flat -scalar contract (`src/backend.d.ts`). Here it drives the wasm build; a -native host vendors the identical file and implements the same contract -over FFI against Box3D built as a static library at the same pinned SHA. -One binding source, two worlds. +`src/frontend.ts` is the entire API personality, written against a flat scalar contract (`src/backend.d.ts`). Here it drives the wasm build; a native host vendors the identical file and implements the same contract over FFI against Box3D built as a static library at the same pinned commit. One binding source, two worlds. + +The iso surface currently covers worlds (step, gravity, explode, worker threads), rigid bodies (create / read, velocities, impulses, teleports, gravity scale, motion locks) and box / sphere shapes; it grows toward parity with the embind API above, which remains the default export. -The iso surface currently covers worlds (step, gravity, explode, worker -threads), rigid bodies (create/read, velocities, impulses, teleports, -gravity scale, motion locks) and box/sphere shapes; it grows toward parity -with the embind API above, which remains the default export. +## Development -## Building from source +### Building from source Requires [emsdk](https://emscripten.org/docs/getting_started/downloads.html) (tested with 6.0.2), CMake, the Node version from `.nvmrc` and pnpm. @@ -203,16 +431,22 @@ pnpm fix # auto-fixes what the linters can Commits follow the [Angular convention](https://github.com/angular/angular/blob/main/CONTRIBUTING.md#commit); every push to `main` is released by [semantic-release](https://github.com/semantic-release/semantic-release) and published to npm through trusted publishing. -## Tracking Box3D +### Tracking Box3D Box3D is pinned as a git dependency in `package.json` (`@erincatto/box3d`: `github:erincatto/box3d#`). Renovate opens a pull request whenever Box3D's `main` moves; CI rebuilds both flavours and runs the tests against the new commit, and a green build is merged and released automatically. A Box3D change that breaks the binding fails CI and waits for a fix in `csrc/glue.cpp`. -## TypeScript +## Questions + +Don't hesitate to ask a question directly on the [discussions board](https://github.com/FRSOURCE/box3d-wasm/discussions)! -The declarations in `dist/*.d.ts` are generated by emscripten from the embind registrations at build time, so they always match the compiled binding. Parameters that take plain objects (vectors, quaternions, option bags) are typed as `any` for now. +## Changelog + +Changes for every release are documented in the [release notes](https://github.com/FRSOURCE/box3d-wasm/releases) and [CHANGELOG file](https://github.com/FRSOURCE/box3d-wasm/blob/main/CHANGELOG.md). ## License -MIT for the wrapper and build scripts, see LICENSE. +[MIT](https://opensource.org/licenses/MIT) for the wrapper and build scripts, see [LICENSE](LICENSE). + +Copyright (c) 2026 Luis Montes (box3d-wasm). This fork is maintained by Jakub FRS Freisler, FRSOURCE. -Box3D itself is Copyright (c) Erin Catto and MIT licensed, see LICENSE.box3d.txt and the upstream repository at https://github.com/erincatto/box3d. If you use this package, the physics engine you are running is his work. +Box3D itself is Copyright (c) Erin Catto and MIT licensed, see [LICENSE.box3d.txt](LICENSE.box3d.txt) and the [upstream repository](https://github.com/erincatto/box3d). If you use this package, the physics engine you are running is his work. From d0757e028b64a48395fff10c823d28182bf328d4 Mon Sep 17 00:00:00 2001 From: Jakub Freisler Date: Wed, 7 Oct 2026 00:49:44 +0200 Subject: [PATCH 8/8] docs: correct the shape density and contact event defaults Box3D's default shape density is 1000 (water) and contact events are off until enableContactEvents is set. Co-Authored-By: Claude Fable 5.1 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 671ab98..8653b3e 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,7 @@ body.createHull({ | material / event option | default | description | | ----------------------- | ------------- | ---------------------------------------------------------------- | -| `density` | `1` | mass per volume; `0` makes a massless shape | +| `density` | `1000` | mass per volume (water); `0` makes a massless shape | | `friction` | Box3D default | Coulomb friction | | `restitution` | `0` | bounciness | | `rollingResistance` | `0` | slows rolling spheres and capsules | @@ -283,7 +283,7 @@ body.createHull({ | `userMaterialId` | `0` | your material id, reported nowhere yet but stored | | `isSensor` | `false` | detect overlaps without collision response | | `enableSensorEvents` | `false` | let this shape be seen by sensors / report visitors if it is one | -| `enableContactEvents` | `true` | report begin / end touch events | +| `enableContactEvents` | `false` | report begin / end touch events | | `enableHitEvents` | `false` | report impacts above `hitEventThreshold` | | `invokeContactCreation` | Box3D default | create contacts immediately for static shapes | | `updateBodyMass` | `true` | recompute the body mass after adding the shape | @@ -384,7 +384,7 @@ sensors.begin; // [{ sensorUserData, visitorUserData }] sensors.end; // [{ sensorUserData, visitorUserData }] (null when the shape was destroyed) ``` -Body move events are always on. Contact begin / end events need `enableContactEvents` on at least one of the two shapes (on by default), hit events need `enableHitEvents` and an approach speed above the world's `hitEventThreshold`, and sensor events need a shape created with `isSensor: true` plus `enableSensorEvents: true` on the visitors you want it to notice. +Body move events are always on. Contact begin / end events need `enableContactEvents` on at least one of the two shapes, hit events need `enableHitEvents` and an approach speed above the world's `hitEventThreshold`, and sensor events need a shape created with `isSensor: true` plus `enableSensorEvents: true` on the visitors you want it to notice. ### Memory