diff --git a/crates/nexus_mpm2d/Cargo.toml b/crates/nexus_mpm2d/Cargo.toml index 7fe5f5dd..c50eced4 100644 --- a/crates/nexus_mpm2d/Cargo.toml +++ b/crates/nexus_mpm2d/Cargo.toml @@ -1,11 +1,13 @@ [package] name = "nexus_mpm2d" authors = { workspace = true } -description = "Cross-platform 2D GPU-accelerated rigid-body physics." +description = "Cross-platform 2D GPU-accelerated MPM physics." repository = { workspace = true } version = { workspace = true } edition = { workspace = true } license = { workspace = true } +readme = { workspace = true } +exclude = { workspace = true } [lib] name = "nexus_mpm2d" diff --git a/crates/nexus_mpm3d/Cargo.toml b/crates/nexus_mpm3d/Cargo.toml index 9350c180..854e1870 100644 --- a/crates/nexus_mpm3d/Cargo.toml +++ b/crates/nexus_mpm3d/Cargo.toml @@ -6,6 +6,8 @@ repository = { workspace = true } version = { workspace = true } edition = { workspace = true } license = { workspace = true } +readme = { workspace = true } +exclude = { workspace = true } [lib] name = "nexus_mpm3d" diff --git a/crates/nexus_mpm_shaders2d/Cargo.toml b/crates/nexus_mpm_shaders2d/Cargo.toml index bde234f4..9c85829f 100644 --- a/crates/nexus_mpm_shaders2d/Cargo.toml +++ b/crates/nexus_mpm_shaders2d/Cargo.toml @@ -6,6 +6,8 @@ repository = { workspace = true } version = { workspace = true } edition = { workspace = true } license = { workspace = true } +readme = { workspace = true } +exclude = { workspace = true } links = "nexus_mpm_shaders2d" build = "build.rs" diff --git a/crates/nexus_mpm_shaders3d/Cargo.toml b/crates/nexus_mpm_shaders3d/Cargo.toml index ba2e2fd1..b1c0a1ba 100644 --- a/crates/nexus_mpm_shaders3d/Cargo.toml +++ b/crates/nexus_mpm_shaders3d/Cargo.toml @@ -6,6 +6,8 @@ repository = { workspace = true } version = { workspace = true } edition = { workspace = true } license = { workspace = true } +readme = { workspace = true } +exclude = { workspace = true } links = "nexus_mpm_shaders3d" build = "build.rs" diff --git a/publish.sh b/publish.sh index 8a3c9d99..df82003b 100755 --- a/publish.sh +++ b/publish.sh @@ -1,27 +1,31 @@ #! /bin/bash # -# Publishes every nexus crate to crates.io with a single -# `cargo publish --workspace` invocation: +# Publishes every nexus crate to crates.io: # # - nexus2d / nexus3d # - nexus_rbd2d / nexus_rbd3d # - nexus_rbd_shaders2d / nexus_rbd_shaders3d +# - nexus_mpm2d / nexus_mpm3d +# - nexus_mpm_shaders2d / nexus_mpm_shaders3d # - nexus_viewer2d / nexus_viewer3d # -# Cargo computes the dependency order itself and waits for each crate to become -# available on the registry before publishing the ones that depend on it. The -# example crates and the python-binding crate are marked `publish = false`, so -# `--workspace` skips them. +# Publishing happens in waves (see `WAVES` below); within a wave cargo computes +# the dependency order itself and waits for each crate to become available on +# the registry before publishing the ones that depend on it. The example crates +# and the python-binding crate are marked `publish = false` and are simply not +# listed. # # Why this script exists # ---------------------- # Each 2d/3d crate pair shares a single source tree at the repo root, # referenced from each manifest as `path = "../..//lib.rs"`: # -# - nexus2d / nexus3d -> src -# - nexus_rbd2d / nexus_rbd3d -> src_rbd +# - nexus2d / nexus3d -> src +# - nexus_rbd2d / nexus_rbd3d -> src_rbd # - nexus_rbd_shaders2d / nexus_rbd_shaders3d -> src_rbd_shaders -# - nexus_viewer2d / nexus_viewer3d -> src_viewer +# - nexus_mpm2d / nexus_mpm3d -> src_mpm +# - nexus_mpm_shaders2d / nexus_mpm_shaders3d -> src_mpm_shaders +# - nexus_viewer2d / nexus_viewer3d -> src_viewer # # Those paths point outside the crate directory, which `cargo publish` refuses # to package. @@ -35,12 +39,37 @@ # trap restores the manifests and removes the symlinks on exit (including on # error or Ctrl-C), leaving the tree exactly as it was. # -# Extra arguments are forwarded to `cargo publish`, e.g.: -# ./publish.sh --dry-run +# Why the waves +# ------------- +# When several crates are published at once, cargo verifies them against a +# temporary local registry (`target/package/tmp-registry`) holding the siblings +# it is about to upload. That overlay is only configured for the verification +# build itself: the *nested* cargo that a build script spawns does not see it. +# +# The host crates compile their shaders through `khal-builder`, which runs +# `cargo gpu build` on the packaged shader crate; that nested cargo resolves the +# shader crate's own dependencies against the real crates.io. So any nexus crate +# a shader crate depends on must already be live on crates.io before the crates +# that compile that shader can be verified, which the waves below guarantee. +# +# "Live" means served by the index, not merely uploaded: between waves the +# script polls index.crates.io itself, because `cargo publish` only warns (it +# does not fail) when a crate has not propagated within its own timeout. +# +# Re-running after a failure is safe: crates already published at this version +# are skipped, and the leftovers of the failed run are purged first (see +# `purge_publish_cache`). +# +# Extra arguments are forwarded to every `cargo publish` invocation, e.g.: +# ./publish.sh --dry-run --no-verify # ./publish.sh --token "$CARGO_TOKEN" # -# Requires cargo >= 1.90 (for `cargo publish --workspace`). The shader crates -# compile their SPIR-V in build.rs during the verification build, so the +# Note that a plain `--dry-run` only rehearses the first wave: nothing is +# actually uploaded, so the later waves fail to verify for the reason described +# above. Add `--no-verify` to rehearse the packaging of the whole set. +# +# Requires cargo >= 1.90 (for publishing several crates at once). The shader +# crates compile their SPIR-V in build.rs during the verification build, so the # rust-gpu toolchain must be installed (`cargo gpu install`). set -euo pipefail @@ -57,10 +86,27 @@ CRATES=( nexus_rbd3d:src_rbd nexus_rbd_shaders2d:src_rbd_shaders nexus_rbd_shaders3d:src_rbd_shaders + nexus_mpm2d:src_mpm + nexus_mpm3d:src_mpm + nexus_mpm_shaders2d:src_mpm_shaders + nexus_mpm_shaders3d:src_mpm_shaders nexus_viewer2d:src_viewer nexus_viewer3d:src_viewer ) +# Publication waves, published one after the other. A crate may only share a +# wave with a shader crate it compiles; every *other* nexus crate that shader +# crate depends on must be live on crates.io by then (see "Why the waves"). +# Today the only such edge is nexus_mpm_shaders* -> nexus_rbd_shaders*, so two +# waves are enough. Adding a shader crate that depends on another nexus shader +# crate means adding a wave. +WAVES=( + "nexus_rbd_shaders2d nexus_rbd_shaders3d" + "nexus_rbd2d nexus_rbd3d nexus_mpm_shaders2d nexus_mpm_shaders3d nexus_mpm2d nexus_mpm3d nexus2d nexus3d nexus_viewer2d nexus_viewer3d" +) + +VERSION="$(sed -n '/^\[workspace.package\]/,/^\[/ s/^version = "\(.*\)"/\1/p' Cargo.toml)" + # Refuse to run on a dirty tree: the only diff during publishing must be our own # temporary edits, so the restore at the end is guaranteed to be correct. if [ -n "$(git status --porcelain)" ]; then @@ -106,6 +152,111 @@ for entry in "${CRATES[@]}"; do link_shared "${entry%%:*}" "${entry#*:}" done -# Publish the whole workspace. `--allow-dirty` is required because our temporary -# edits make the tree dirty; the clean-tree check above keeps that safe. -cargo publish --workspace --allow-dirty "$@" +# True if `$1` is already on crates.io at `$VERSION`. Since the waves upload +# incrementally, a run that fails halfway leaves the earlier waves published; +# skipping them makes the script re-runnable. An unreachable index means "not +# published", so a network hiccup can never silently skip a crate. +already_published() { + local crate="$1" prefix body + case "${#crate}" in + 1 | 2) prefix="${#crate}" ;; + 3) prefix="3/${crate:0:1}" ;; + *) prefix="${crate:0:2}/${crate:2:2}" ;; + esac + body="$(curl -sf "https://index.crates.io/$prefix/$crate" || true)" + case "$body" in + *"\"vers\":\"$VERSION\""*) return 0 ;; + *) return 1 ;; + esac +} + +# Block until every crate named in `$@` is actually served by the index. +# `cargo publish` does wait for propagation on its own, but it gives up after a +# timeout with a warning rather than an error; the next wave then starts while +# crates.io still serves the previous version, and the nested cargo-gpu +# resolution fails exactly as if the wave order were wrong. +wait_for_index() { + local deadline=$((SECONDS + 900)) crate pending + + while :; do + pending="" + for crate in "$@"; do + already_published "$crate" || pending="$pending $crate" + done + + if [ -z "$pending" ]; then + # Our probe and cargo's own fetch may land on different CDN edges, + # so let the slower ones catch up before building against them. + sleep 20 + return + fi + + if [ "$SECONDS" -ge "$deadline" ]; then + echo "error: timed out waiting for crates.io to serve:$pending" >&2 + exit 1 + fi + + echo "waiting for crates.io to serve:$pending" + sleep 10 + done +} + +# Cargo unpacks the temporary publish registry into `$CARGO_HOME/registry` and +# keys those copies by name and version alone, so a previous run of the same +# version leaves sources behind that the next run silently reuses, lockfile +# checksums included. Once a wave has been uploaded those checksums no longer +# match what crates.io serves, and the nested cargo-gpu resolution dies with +# "checksum for changed between lock files". Dropping the stale copies +# makes every run start from freshly packaged sources. Only our own crates are +# removed, and only from the `file://` overlay registries (their directory name +# starts with `-`, since such a URL has no host); crates.io is left alone. +purge_publish_cache() { + local registry_dir entry crate + + rm -rf target/package + + for registry_dir in "${CARGO_HOME:-$HOME/.cargo}"/registry/src/-* \ + "${CARGO_HOME:-$HOME/.cargo}"/registry/cache/-*; do + [ -d "$registry_dir" ] || continue + for entry in "${CRATES[@]}"; do + crate="${entry%%:*}" + rm -rf "$registry_dir/$crate-$VERSION" "$registry_dir/$crate-$VERSION.crate" + done + done +} + +purge_publish_cache + +# `--dry-run` uploads nothing, so there is never anything to wait for. +dry_run=0 +for arg in "$@"; do + if [ "$arg" = "--dry-run" ]; then + dry_run=1 + fi +done + +# `--allow-dirty` is required because our temporary edits make the tree dirty; +# the clean-tree check above keeps that safe. +for wave in "${WAVES[@]}"; do + pkgs=() + names="" + for crate in $wave; do + if already_published "$crate"; then + echo "skipping $crate $VERSION: already on crates.io" + else + pkgs+=(-p "$crate") + names="$names $crate" + fi + done + + if [ "${#pkgs[@]}" -eq 0 ]; then + continue + fi + + echo "publishing wave:$names" + cargo publish "${pkgs[@]}" --allow-dirty "$@" + + if [ "$dry_run" -eq 0 ]; then + wait_for_index $names + fi +done diff --git a/website/package.json b/website/package.json index 4dfb39de..0b1453f1 100644 --- a/website/package.json +++ b/website/package.json @@ -46,5 +46,6 @@ }, "engines": { "node": ">=20.0" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/website/scripts/build-demos.sh b/website/scripts/build-demos.sh index 4f1f2b03..db5e9087 100755 --- a/website/scripts/build-demos.sh +++ b/website/scripts/build-demos.sh @@ -173,35 +173,55 @@ write_index_html() { } .error { color: #ff6b6b; + line-height: 1.5; } .error::after { display: none; } + .error a { + color: #ff6b6b; + } @keyframes spin { to { transform: rotate(360deg); } }
Loading WebAssembly...
diff --git a/website/src/pages/demos.module.css b/website/src/pages/demos.module.css index 9761c1b7..fe51bfc0 100644 --- a/website/src/pages/demos.module.css +++ b/website/src/pages/demos.module.css @@ -57,13 +57,27 @@ } } -/* WebGPU warning */ -.webgpuWarning { - padding: 0.75rem 1rem; - background: var(--ifm-color-warning-contrast-background, #fff8e6); - color: var(--ifm-color-warning-contrast-foreground, #4d3800); - border-bottom: 1px solid var(--ifm-color-emphasis-300); - font-size: 0.9rem; +/* Shown instead of the viewer when the browser can't run the demos */ +.unsupported { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 0.75rem; + padding: 2rem; + text-align: center; +} + +.unsupported h2 { + margin: 0; + font-size: 1.5rem; +} + +.unsupported p { + margin: 0; + max-width: 42rem; + color: var(--ifm-color-emphasis-700); } /* Viewer */ diff --git a/website/src/pages/demos.tsx b/website/src/pages/demos.tsx index 1d055d97..0ae69b46 100644 --- a/website/src/pages/demos.tsx +++ b/website/src/pages/demos.tsx @@ -20,16 +20,31 @@ const demos = [ }, ]; +// What prevents the demo from running here, if anything. Resolved on the +// client only (`navigator` doesn't exist while the site is pre-rendered), so +// `undefined` means "not determined yet". +type Blocker = 'safari' | 'webgpu' | null; + +function detectBlocker(): Blocker { + // Safari matches every WebKit-based UA except the Chromium/Firefox ones, + // which advertise themselves as Safari too. + const isSafari = /^((?!chromium|chrome|crios|fxios|edgios|android).)*safari/i + .test(navigator.userAgent); + if (isSafari) return 'safari'; + // Nexus runs its physics as WebGPU compute shaders: no WebGPU, no demo. + if (!(navigator as any).gpu) return 'webgpu'; + return null; +} + export default function Demos(): ReactNode { const [selected, setSelected] = useState(null); const [activeDemo, setActiveDemo] = useState(null); const [isLoading, setIsLoading] = useState(false); - const [webgpuSupported, setWebgpuSupported] = useState(true); + const [blocker, setBlocker] = useState(undefined); const iframeRef = useRef(null); - // Nexus runs its physics as WebGPU compute shaders: no WebGPU, no demo. useEffect(() => { - setWebgpuSupported(typeof navigator !== 'undefined' && !!(navigator as any).gpu); + setBlocker(detectBlocker()); }, []); // Handle URL hash for deep linking @@ -52,6 +67,9 @@ export default function Demos(): ReactNode { // Handle demo transitions - clear iframe first to release WebGPU context useEffect(() => { + // Nothing is loaded until the browser is known to support the demos + // (`undefined` = still unknown, non-null = unsupported). + if (blocker !== null) return; if (selected === activeDemo) return; setIsLoading(true); @@ -69,7 +87,7 @@ export default function Demos(): ReactNode { }, 500); return () => clearTimeout(timer); - }, [selected]); + }, [selected, blocker]); const handleSelect = (name: string) => { setSelected(name); @@ -97,24 +115,46 @@ export default function Demos(): ReactNode { ))} - - Pick individual demos from the panel inside the viewer. First load - may take a while (the physics engine ships as a large WASM module). - + {!blocker && ( + + Pick individual demos from the panel inside the viewer. First load + may take a while (the physics engine ships as a large WASM module). + + )} - {!webgpuSupported && ( -
- WebGPU is not available in this browser. Nexus - runs its physics as WebGPU compute shaders. On Firefox, enable{' '} - dom.webgpu.enabled in about:config; on - Chromium, enable Unsafe WebGPU Support in{' '} - chrome://flags. Safari is currently not supported. -
- )} -
- {activeDemo ? ( + {blocker === 'safari' ? ( +
+

Safari is not supported

+

+ Nexus does not currently run in Safari. Please open this page + in a recent Chromium-based browser or in Firefox. +

+
+ ) : blocker === 'webgpu' ? ( +
+

WebGPU is required

+

+ Nexus runs its physics as WebGPU compute shaders, and WebGPU is + not available in this browser. See{' '} + + caniuse.com/webgpu + {' '} + for browser support. +

+

+ On Firefox, enable dom.webgpu.enabled in{' '} + about:config; on Chromium, enable{' '} + Unsafe WebGPU Support in{' '} + chrome://flags. +

+
+ ) : activeDemo ? ( <>