diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index f19b3d2859..4322f2791b 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -6,6 +6,13 @@ "plugins": [ { "name": "pascal-agent-skills", + "version": "0.1.8", + "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.", + "author": { + "name": "Pascal", + "email": "support@pascal.app", + "url": "https://pascal.app" + }, "source": { "source": "local", "path": "./" diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 974a9cf311..f52ffc4303 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-marketplace.json", "name": "pascal", - "version": "0.1.7", + "version": "0.1.8", "description": "Public Pascal workflows for MCP-capable agents.", "owner": { "name": "Pascal" @@ -9,11 +9,16 @@ "plugins": [ { "name": "pascal-agent-skills", - "source": "./", - "description": "Create and inspect editable Pascal scenes and run bounded furniture footprint assessments with blocker-aware next actions.", - "version": "0.1.7", + "source": "./skills", + "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.", + "author": { + "name": "Pascal", + "email": "support@pascal.app", + "url": "https://pascal.app" + }, + "version": "0.1.8", "category": "productivity", - "skills": ["./skills/pascal-3d", "./skills/furniture-fit"] + "skills": ["./pascal-3d", "./furniture-fit"] } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json deleted file mode 100644 index 1e9d7a7db5..0000000000 --- a/.claude-plugin/plugin.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/claude-code-plugin.json", - "name": "pascal-agent-skills", - "displayName": "Pascal agent skills", - "version": "0.1.7", - "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.", - "author": { - "name": "Pascal" - }, - "homepage": "https://editor.pascal.app/docs/developers/mcp", - "repository": "https://github.com/pascalorg/editor", - "license": "MIT", - "keywords": ["pascal", "3d", "architecture", "mcp", "furniture", "spatial"] -} diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 326964edad..777a39a15e 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "pascal-agent-skills", - "version": "0.1.7", + "version": "0.1.8", "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.", "author": { "name": "Pascal", @@ -13,7 +13,7 @@ "keywords": ["pascal", "3d", "architecture", "mcp", "furniture", "spatial"], "skills": "./skills/", "interface": { - "displayName": "Pascal agent skills", + "displayName": "Pascal", "shortDescription": "Build 3D scenes and check fit", "longDescription": "Use Pascal's MCP tools to work with editable building scenes, validate and save results, and produce bounded furniture footprint reports with explicit evidence, limitations, and blocker-aware next actions.", "developerName": "Pascal", @@ -24,7 +24,6 @@ "Assess furniture footprints" ], "websiteURL": "https://editor.pascal.app/docs/developers/mcp", - "supportURL": "https://github.com/pascalorg/editor/issues", "privacyPolicyURL": "https://editor.pascal.app/privacy", "termsOfServiceURL": "https://editor.pascal.app/terms", "defaultPrompt": [ @@ -33,6 +32,6 @@ ], "brandColor": "#171717", "composerIcon": "./assets/pascal-mark.svg", - "logo": "./assets/pascal-mark.svg" + "logo": "./assets/pascal-mark-plate.svg" } } diff --git a/.cursor-plugin/mcp.json b/.cursor-plugin/mcp.json new file mode 100644 index 0000000000..06e0fd9155 --- /dev/null +++ b/.cursor-plugin/mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "pascal": { + "type": "stdio", + "command": "pascal", + "args": ["mcp", "connect"] + }, + "pascal-hosted": { + "type": "http", + "url": "https://editor.pascal.app/api/mcp", + "headers": { + "Authorization": "Bearer ${PASCAL_API_KEY}" + } + } + } +} diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json new file mode 100644 index 0000000000..52c44b247c --- /dev/null +++ b/.cursor-plugin/plugin.json @@ -0,0 +1,42 @@ +{ + "name": "pascal-agent-skills", + "displayName": "Pascal", + "version": "0.1.8", + "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.", + "author": { + "name": "Pascal", + "email": "support@pascal.app" + }, + "homepage": "https://editor.pascal.app/docs/developers/mcp", + "repository": "https://github.com/pascalorg/editor", + "license": "MIT", + "logo": "assets/pascal-mark-plate.svg", + "keywords": [ + "pascal", + "3d", + "architecture", + "mcp", + "furniture", + "spatial" + ], + "category": "developer-tools", + "tags": [ + "3d", + "architecture", + "floor-plan", + "furniture", + "mcp" + ], + "skills": "./skills/", + "variables": { + "type": "object", + "properties": { + "PASCAL_API_KEY": { + "type": "string", + "title": "Pascal API key (hosted)", + "description": "Optional. Create a key under Settings -> API keys at editor.pascal.app to reach your hosted projects, Pascal Capture scans, and shared workspaces. Leave it unset to use only the local Pascal CLI." + } + } + }, + "mcpServers": "./.cursor-plugin/mcp.json" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 369de39848..fce7f42723 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,7 @@ jobs: run: bun run check - name: Validate agent skills and plugin packages - run: | - bun test scripts/clawhub-ignore-policy.test.ts scripts/claude-mcp-config-policy.test.ts scripts/openai-tool-annotation-policy.test.ts scripts/public-skill-discovery-policy.test.ts - bun scripts/validate-skills.ts + run: bun run skills:validate - name: Type check run: bun run check-types diff --git a/.github/workflows/mcp-registry.yml b/.github/workflows/mcp-registry.yml index 183ea05056..9a813b6a88 100644 --- a/.github/workflows/mcp-registry.yml +++ b/.github/workflows/mcp-registry.yml @@ -39,7 +39,7 @@ jobs: - name: Verify hosted endpoint contract run: | jq --exit-status ' - .version == "0.6.1" + (.version | strings | test("^[0-9]+\\.[0-9]+\\.[0-9]+$")) and .remotes == [{ "type": "streamable-http", "url": "https://editor.pascal.app/api/mcp", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0659a8d62..3242c33854 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,8 +8,6 @@ on: required: true type: choice options: - - capture-protocol - - capture-viewer - core - viewer - editor @@ -19,7 +17,7 @@ on: - cli - all bump: - description: "Version bump (beta publishes a prerelease on the beta dist-tag)" + description: "Version bump (beta publishes a prerelease on the beta dist-tag; patch/minor/major on a prerelease graduates it to its base version on latest)" required: true type: choice options: @@ -70,6 +68,11 @@ jobs: permissions: contents: write id-token: write + env: + # Verbose npm logs show the OIDC token exchange and the registry's + # rejection reason when trusted publishing is misconfigured; tokens are + # redacted by npm. + NPM_CONFIG_LOGLEVEL: verbose steps: - uses: actions/checkout@v4 with: @@ -79,11 +82,21 @@ jobs: with: bun-version: 1.3.14 + # No registry-url here: with it, actions/setup-node writes an .npmrc whose + # auth token falls back to the placeholder XXXXX-XXXXX-XXXXX-XXXXX, npm + # sends that fake token, the registry answers 404, and the OIDC trusted + # publishing exchange never runs. npm publishes to registry.npmjs.org by + # default and each publish step passes --access public explicitly. - uses: actions/setup-node@v4 with: node-version: 22 - registry-url: "https://registry.npmjs.org" + # npm refuses direct publishing with 2FA-bypass tokens (EOTP, see + # https://gh.io/npm-gat-bypass2fa-deprecation), so no NODE_AUTH_TOKEN is set + # and npm >= 11.5 exchanges the GitHub Actions OIDC token itself. Every + # @pascal-app package must list this repository, this workflow file and + # the `npm` environment as a trusted publisher on npmjs.com; a package that + # does not exist on npm yet needs one manual first publish before that. - name: Enable npm trusted publishing run: | npm install --global npm@11.19.1 @@ -120,11 +133,16 @@ jobs: fi return fi + if [ "$BUMP" = "none" ]; then echo "$v"; return; fi + # A prerelease graduates to its base version on any stable bump + # (1.0.0-beta.5 + major|minor|patch -> 1.0.0), matching npm semver. + # Splitting "1.0.0-beta.5" on dots would otherwise yield 2.0.0 or + # break the patch arithmetic. + if [[ "$v" == *-* ]]; then echo "${v%%-*}"; return; fi IFS='.' read -r MAJ MIN PAT <<< "$v" if [ "$BUMP" = "major" ]; then MAJ=$((MAJ+1)); MIN=0; PAT=0; fi if [ "$BUMP" = "minor" ]; then MIN=$((MIN+1)); PAT=0; fi if [ "$BUMP" = "patch" ]; then PAT=$((PAT+1)); fi - if [ "$BUMP" = "none" ]; then echo "$v"; return; fi echo "$MAJ.$MIN.$PAT" } @@ -139,7 +157,7 @@ jobs: # peerDeps sync below must use shell vars, not env indirection. declare -A NEW_VERSIONS - for pkg in capture-protocol core viewer capture-viewer editor nodes mcp ifc-converter cli; do + for pkg in core viewer editor nodes mcp ifc-converter cli; do if [ "$TARGET" = "$pkg" ] || [ "$TARGET" = "all" ]; then CUR=$(jq -r '.version' packages/$pkg/package.json) NEW=$(bump_version "$CUR") @@ -154,9 +172,9 @@ jobs: # Sync inter-package references in dependencies, peerDependencies, and devDependencies. # Anything that references a bumped @pascal-app/* package is updated to ^NEW. - for pkg in capture-protocol core viewer capture-viewer editor nodes mcp ifc-converter cli; do + for pkg in core viewer editor nodes mcp ifc-converter cli; do FILE=packages/$pkg/package.json - for dep in capture-protocol core viewer capture-viewer editor nodes mcp ifc-converter cli; do + for dep in core viewer editor nodes mcp ifc-converter cli; do VAL="${NEW_VERSIONS[$dep]}" [ -z "$VAL" ] && continue jq --arg name "@pascal-app/$dep" --arg v "^$VAL" ' @@ -168,7 +186,7 @@ jobs: done echo "=== @pascal-app/* refs after sync ===" - for pkg in capture-protocol core viewer capture-viewer editor nodes mcp ifc-converter cli; do + for pkg in core viewer editor nodes mcp ifc-converter cli; do echo "--- packages/$pkg/package.json ---" jq '{ dependencies: (.dependencies // {} | with_entries(select(.key | startswith("@pascal-app/")))), peerDependencies: (.peerDependencies // {} | with_entries(select(.key | startswith("@pascal-app/")))), devDependencies: (.devDependencies // {} | with_entries(select(.key | startswith("@pascal-app/")))) }' packages/$pkg/package.json done @@ -180,7 +198,7 @@ jobs: # A single-package release may depend on another package introduced # by this monorepo. Refuse to publish an uninstallable package when # that dependency is not part of this run and is absent from npm. - RELEASE_PACKAGES="capture-protocol core viewer capture-viewer editor nodes mcp ifc-converter cli" + RELEASE_PACKAGES="core viewer editor nodes mcp ifc-converter cli" if [ "$TARGET" != "all" ]; then FILE="packages/$TARGET/package.json" while IFS=$'\t' read -r DEP RANGE; do @@ -206,23 +224,6 @@ jobs: ) fi - - name: Build & publish capture protocol - if: inputs.package == 'capture-protocol' || inputs.package == 'all' - working-directory: packages/capture-protocol - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - bun run build - if [ "${{ inputs.dry-run }}" = "true" ]; then - echo "🏜️ Dry run β€” would publish @pascal-app/capture-protocol@$CAPTURE_PROTOCOL_VERSION" - npm publish --dry-run --access public --tag "$NPM_TAG" - elif npm view "@pascal-app/capture-protocol@$CAPTURE_PROTOCOL_VERSION" version >/dev/null 2>&1; then - echo "πŸ“¦ @pascal-app/capture-protocol@$CAPTURE_PROTOCOL_VERSION is already published; continuing release recovery" - else - npm publish --access public --tag "$NPM_TAG" - echo "πŸ“¦ Published @pascal-app/capture-protocol@$CAPTURE_PROTOCOL_VERSION" - fi - - name: Validate portable editor runtime if: inputs.package == 'cli' || inputs.package == 'all' env: @@ -237,8 +238,6 @@ jobs: - name: Build & publish core if: inputs.package == 'core' || inputs.package == 'all' working-directory: packages/core - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | bun run build if [ "${{ inputs.dry-run }}" = "true" ]; then @@ -254,8 +253,6 @@ jobs: - name: Build & publish viewer if: inputs.package == 'viewer' || inputs.package == 'all' working-directory: packages/viewer - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | bun run build if [ "${{ inputs.dry-run }}" = "true" ]; then @@ -268,28 +265,9 @@ jobs: echo "πŸ“¦ Published @pascal-app/viewer@$VIEWER_VERSION" fi - - name: Build & publish capture viewer - if: inputs.package == 'capture-viewer' || inputs.package == 'all' - working-directory: packages/capture-viewer - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - bun run build - if [ "${{ inputs.dry-run }}" = "true" ]; then - echo "🏜️ Dry run β€” would publish @pascal-app/capture-viewer@$CAPTURE_VIEWER_VERSION" - npm publish --dry-run --access public --tag "$NPM_TAG" - elif npm view "@pascal-app/capture-viewer@$CAPTURE_VIEWER_VERSION" version >/dev/null 2>&1; then - echo "πŸ“¦ @pascal-app/capture-viewer@$CAPTURE_VIEWER_VERSION is already published; continuing release recovery" - else - npm publish --access public --tag "$NPM_TAG" - echo "πŸ“¦ Published @pascal-app/capture-viewer@$CAPTURE_VIEWER_VERSION" - fi - - name: Publish editor if: inputs.package == 'editor' || inputs.package == 'all' working-directory: packages/editor - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | if [ "${{ inputs.dry-run }}" = "true" ]; then echo "🏜️ Dry run β€” would publish @pascal-app/editor@$EDITOR_VERSION" @@ -304,8 +282,6 @@ jobs: - name: Build & publish nodes if: inputs.package == 'nodes' || inputs.package == 'all' working-directory: packages/nodes - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | bun run build if [ "${{ inputs.dry-run }}" = "true" ]; then @@ -335,8 +311,6 @@ jobs: - name: Build & publish ifc-converter if: inputs.package == 'ifc-converter' || inputs.package == 'all' - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | # ifc-converter depends on @pascal-app/core (workspace) β€” build it first bun run build --filter @pascal-app/core 2>/dev/null || (cd packages/core && bun run build) @@ -357,6 +331,13 @@ jobs: # Keep token auth unset so npm can exchange the GitHub OIDC identity. run: | cd packages/cli + # The tarball embeds the URL and digest of the web runtime archive, so the archive + # must be staged (by "Validate portable editor runtime") before anything is published. + ARCHIVE="build/pascal-web-runtime-$CLI_VERSION.tar.gz" + test -f "$ARCHIVE" + test -f "$ARCHIVE.sha256" + test -f dist/services/pascal-mcp.mjs + jq -e --arg v "$CLI_VERSION" '.version == $v' dist/runtime-source.json if [ "${{ inputs.dry-run }}" = "true" ]; then echo "🏜️ Dry run β€” would publish @pascal-app/cli@$CLI_VERSION" npm publish --ignore-scripts --dry-run --access public --tag "$NPM_TAG" @@ -374,10 +355,6 @@ jobs: PKGS="" TAGS="" - if [ -n "$CAPTURE_PROTOCOL_VERSION" ]; then - PKGS="$PKGS @pascal-app/capture-protocol@$CAPTURE_PROTOCOL_VERSION" - TAGS="$TAGS @pascal-app/capture-protocol@$CAPTURE_PROTOCOL_VERSION" - fi if [ -n "$CORE_VERSION" ]; then PKGS="$PKGS @pascal-app/core@$CORE_VERSION" TAGS="$TAGS @pascal-app/core@$CORE_VERSION" @@ -386,10 +363,6 @@ jobs: PKGS="$PKGS @pascal-app/viewer@$VIEWER_VERSION" TAGS="$TAGS @pascal-app/viewer@$VIEWER_VERSION" fi - if [ -n "$CAPTURE_VIEWER_VERSION" ]; then - PKGS="$PKGS @pascal-app/capture-viewer@$CAPTURE_VIEWER_VERSION" - TAGS="$TAGS @pascal-app/capture-viewer@$CAPTURE_VIEWER_VERSION" - fi if [ -n "$EDITOR_VERSION" ]; then PKGS="$PKGS @pascal-app/editor@$EDITOR_VERSION" TAGS="$TAGS @pascal-app/editor@$EDITOR_VERSION" @@ -422,3 +395,20 @@ jobs: done git push --atomic origin HEAD:main $TAGS + + # The npm package points at this asset, so it is uploaded in the same job, immediately + # after the tag it hangs off exists on the remote. + - name: Upload the CLI web runtime release asset + if: ${{ inputs.dry-run == false && (inputs.package == 'cli' || inputs.package == 'all') }} + working-directory: packages/cli + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="@pascal-app/cli@$CLI_VERSION" + ARCHIVE="build/pascal-web-runtime-$CLI_VERSION.tar.gz" + if ! gh release view "$TAG" >/dev/null 2>&1; then + [ "$NPM_TAG" = "beta" ] && PRERELEASE=--prerelease || PRERELEASE= + gh release create "$TAG" $PRERELEASE --title "$TAG" --notes "The Pascal web editor runtime for \`@pascal-app/cli@$CLI_VERSION\`. The CLI downloads \`$(basename "$ARCHIVE")\` the first time a command starts the editor and verifies it against the digest published inside the npm package. Offline installs can pass the archive directly: \`pascal editor --runtime $(basename "$ARCHIVE")\`." + fi + gh release upload "$TAG" "$ARCHIVE" "$ARCHIVE.sha256" --clobber + echo "🌐 Uploaded $(basename "$ARCHIVE") to $TAG" diff --git a/AGENTS.md b/AGENTS.md index 9ddb52fb38..5329655586 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,8 @@ Public, open-source home of `@pascal-app/{core,viewer,editor,mcp}` and the stand | Path | Purpose | |---|---| -| `packages/core` | Scene graph, node schemas, stores, event bus, core systems β€” pure logic, no Three.js | -| `packages/viewer` | Standalone 3D canvas: renderers, viewer systems, presentation state | +| `packages/core` | Scene graph, node schemas, stores, event bus, core systems β€” pure logic, no Three.js. `src/capture/` holds the capture-session contracts published as `@pascal-app/core/capture` | +| `packages/viewer` | Standalone 3D canvas: renderers, viewer systems, presentation state. `src/capture/` holds the capture runtime and reference layers published as `@pascal-app/viewer/capture` | | `packages/editor` | Editor UI components reused by the standalone app and embedders | | `packages/mcp` | MCP server and scene storage adapters | | `apps/editor` | Standalone editor app β€” composes `viewer` + `editor` + tools | diff --git a/CHANGELOG.md b/CHANGELOG.md index 537d733c01..d1034bbc41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,81 @@ # Changelog -## Unreleased +## 1.0.0 (2026-09-12) + +### Features + +- Add plugin-contributed editor panels and viewer presentations with project-local configuration persistence. +- Include Environment in the standalone app's Plugins catalogue, with a pinned GitHub dependency and registered editor panel and viewer presentation. +- Refresh Environment's authoring controls and include its interactive color picker. +- Expose generic atmosphere and ground-replacement adapters, Site-scoped floorplan output, bake-only GLB geometry, and plugin-owned selection materials. +- Add portable GLB/USDZ downloads with asynchronous material baking, procedural-content filters, and opt-in static viewer-presentation exports. +- **Public agent skills** β€” `pascal-3d` and `furniture-fit` teach MCP-capable agents to build, inspect, validate, and hand off scenes, and to report measured furniture footprints with evidence-scoped conclusions, fail-closed input gates, blocker-aware next actions, and an optional no-sign-in footprint pre-check link ([#777](https://github.com/pascalorg/editor/pull/777), [#781](https://github.com/pascalorg/editor/pull/781), [#791](https://github.com/pascalorg/editor/pull/791), [#794](https://github.com/pascalorg/editor/pull/794), [#824](https://github.com/pascalorg/editor/pull/824)) +- **Plugin bundles 0.1.3 β†’ 0.1.8** β€” the same canonical `skills/` source ships as one versioned plugin with a recorded validation ledger per bundle ([#782](https://github.com/pascalorg/editor/pull/782), [#795](https://github.com/pascalorg/editor/pull/795), [#802](https://github.com/pascalorg/editor/pull/802), [#811](https://github.com/pascalorg/editor/pull/811), [#825](https://github.com/pascalorg/editor/pull/825)) +- **Claude Code and Codex plugin marketplaces** β€” this repository is installable as `pascal-agent-skills@pascal`, with a credential-free local `pascal mcp connect` server bundled for Claude Code ([#777](https://github.com/pascalorg/editor/pull/777), [#810](https://github.com/pascalorg/editor/pull/810)) +- **Hosted MCP server in the Claude Code plugin** β€” `pascal-agent-skills@pascal` now also bundles a `pascal-hosted` Streamable HTTP server for `https://editor.pascal.app/api/mcp`, authenticated with an optional Pascal API key collected as sensitive plugin user configuration and stored in the OS keychain rather than any file ([#835](https://github.com/pascalorg/editor/pull/835)) +- **OpenAI portable plugin manifest** β€” root `plugin.json` carries the Agent Plugins schema, listing metadata, branding assets, a With MCP review packet, and per-tool annotation justifications for all 46 MCP tools ([#796](https://github.com/pascalorg/editor/pull/796), [#797](https://github.com/pascalorg/editor/pull/797), [#799](https://github.com/pascalorg/editor/pull/799), [#812](https://github.com/pascalorg/editor/pull/812), [#813](https://github.com/pascalorg/editor/pull/813)) +- **ClawHub publication readiness** β€” scoped `.clawhubignore` policies with regression tests that reject re-inclusion and legacy-override rules ([#798](https://github.com/pascalorg/editor/pull/798), [#806](https://github.com/pascalorg/editor/pull/806)) +- **Official MCP Registry entry** β€” `io.github.pascalorg/editor` 0.6.1 publishes the hosted Streamable HTTP endpoint, with CI validating the manifest against the live API catalog ([#808](https://github.com/pascalorg/editor/pull/808), [#809](https://github.com/pascalorg/editor/pull/809)) +- **Portable `mcp.json`, Cursor manifest, Gemini extension, plate logo** β€” Codex and Cursor now register the bundled MCP server (the spec reads root `mcp.json`, not `.mcp.json`); `.cursor-plugin/plugin.json` and `gemini-extension.json` add those marketplaces; the MCP Registry entry gains `repository` and `icons`; marketplace logos use the brand mark on its `#171717` plate; `bun run skills:validate` asserts parity across every descriptor ([#829](https://github.com/pascalorg/editor/pull/829)) +- **`pascal agent claim` and `pascal agent status`** β€” an agent can open a prefilled 15-minute human handoff and verify its hosted key without storing or printing it, shipped in the npm-published CLI ([#815](https://github.com/pascalorg/editor/pull/815), [#818](https://github.com/pascalorg/editor/pull/818), [#819](https://github.com/pascalorg/editor/pull/819), [#821](https://github.com/pascalorg/editor/pull/821), [#822](https://github.com/pascalorg/editor/pull/822)) +- **Small CLI, downloaded web runtime** β€” `@pascal-app/cli` now installs from a 0.5 MB npm package instead of 65 MB: the MCP service ships inside it, so `pascal mcp connect` works with no editor process and no download, while the web editor runtime is fetched once per version from its release asset, verified against a SHA-256 digest published in the package, and installed atomically; `--runtime ` covers offline hosts, `HTTPS_PROXY`/`NO_PROXY` are honoured, and an editor started by a pre-split CLI keeps its own MCP child, which must be stopped once by hand after upgrading (#845) +- **Optional hosted key in the Cursor plugin** β€” `.cursor-plugin/plugin.json` declares an optional `PASCAL_API_KEY` variable and points at a Cursor-dialect `.cursor-plugin/mcp.json` that adds a `pascal-hosted` server for `https://editor.pascal.app/api/mcp`, so a Cursor install can reach hosted projects, Capture scans, and shared workspaces while the credential-free local server keeps working; the portable `mcp.json` stays credential-free because Agent Plugins 1.0.0 forbids secrets and placeholder expansion in `headers`, so Codex configures the hosted endpoint with `codex mcp add --bearer-token-env-var PASCAL_API_KEY` instead ([#849](https://github.com/pascalorg/editor/pull/849)) +- **Capture packages folded into core and viewer** β€” `@pascal-app/capture-protocol` is now `@pascal-app/core/capture` and `@pascal-app/capture-viewer` is now `@pascal-app/viewer/capture` (plus `@pascal-app/viewer/capture/preview`), so 1.0.0 ships seven packages instead of nine. Neither package was ever published to npm, so there is no npm migration; in-repo and workspace consumers change their import paths only. ### Fixes +- Localize terrain and ground-cover brush updates, preserve pending dab uploads, and keep Environment's day/night light graph stable. +- Keep Site-scoped floorplan overlays aligned with live move and rotation previews. +- Preserve Site ownership and same-kind sibling context in synchronous and asynchronous export geometry, including Site children without a parent ID. +- Exclude detached Site children and their descendants from visible-only exports when their owning Site is hidden, including children without a parent ID. +- Preserve unsaved presentation settings when a project receives its first ID, without overwriting an existing project's stored configuration. +- Avoid native TypeScript compiler inference overflow in atmosphere fog references without changing rendering. +- Omit stale viewer-surroundings selections from GLB/USDZ downloads after a presentation is unregistered or its plugin is uninstalled. +- Keep export settings scrollable and group advanced model options in a keyboard-accessible disclosure. +- Preserve child geometry when exporting empty mesh containers to USDZ. +- Export the viewer's shadow-only layer for plugin consumers. +- Remove the nonworking god-ray post-process and its dedicated viewer API; preserve sky, fog, lighting, and ordinary shadows. +- Preserve grass and procedural material colors in portable exports; freeze instancing and deformation without changing the live scene or saved-viewer animation clips. +- Stop registered placement tools when their plugin is uninstalled in either view, preserving authored nodes and requiring explicit reactivation after reinstall. +- Include enabled, visible Site contributions below architecture in floorplan PDFs, preserving building transforms, inline images, and even-odd holes. Hidden Sites also hide children associated through their declared child list. +- The Claude Code plugin root is now `skills/` instead of the repository root, so installing `pascal-agent-skills@pascal` copies the two skill bundles and their MCP configuration instead of caching the whole monorepo and running `bun install` against the root lockfile ([#832](https://github.com/pascalorg/editor/pull/832)) - Preserve custom scene materials across save, load, clone, fork, and live sync. Materials were dropped at every persistence boundary, so a scene reopened with default surfaces. Collections were dropped on MCP import for the same reason ([#597](https://github.com/pascalorg/editor/pull/597)) by [@ShiroKSH](https://github.com/ShiroKSH) - Wall junction mitering is now deterministic for exactly-collinear walls, so identical scenes produce identical geometry regardless of node iteration order ([#596](https://github.com/pascalorg/editor/pull/596)) by [@tomatotomata](https://github.com/tomatotomata) +### Packages + +All seven public packages are published as `1.0.0` under the npm `latest` +dist-tag: `core`, `viewer`, `editor`, `nodes`, `mcp`, `ifc-converter`, and +`cli`. `@pascal-app/capture-protocol` and `@pascal-app/capture-viewer` were +folded into `@pascal-app/core/capture` and `@pascal-app/viewer/capture` before +the release and were never published. + +### Contributors + +Thank you to [@wass08](https://github.com/wass08), +[@Snoopy147](https://github.com/Snoopy147), +[@sudhir9297](https://github.com/sudhir9297), +[@ActArtech](https://github.com/ActArtech), +[@anton-pascal](https://github.com/anton-pascal), +[@toycenterboss-bot](https://github.com/toycenterboss-bot), +[@JimmyZheng-ZJU](https://github.com/JimmyZheng-ZJU), +[@ShiroKSH](https://github.com/ShiroKSH), +[@tomatotomata](https://github.com/tomatotomata), +[@alxbouchard](https://github.com/alxbouchard), +[@SomSamantray](https://github.com/SomSamantray), +[@konevenkatesh](https://github.com/konevenkatesh), +[@maherm](https://github.com/maherm), +[@rootsbymenda](https://github.com/rootsbymenda), +[@tamg](https://github.com/tamg), +[@tylergibbs1](https://github.com/tylergibbs1), +[@vjureta](https://github.com/vjureta), +[@yorhodes](https://github.com/yorhodes), and +[@ztffn](https://github.com/ztffn) for their work across the editor, viewer, +node library, MCP integration, plugins, documentation, and stability fixes. + +**Full changelog**: +https://github.com/pascalorg/editor/compare/v1.0.0-beta.1...v1.0.0 + ## 1.0.0-beta.1 (2026-07-30) The first Pascal Editor 1.0 beta. Relative to diff --git a/README.md b/README.md index 7c37722ae9..2f4b9a1be5 100644 --- a/README.md +++ b/README.md @@ -22,48 +22,21 @@ npx @pascal-app/cli editor ``` The CLI starts the editor and an authenticated MCP service in the background, selects -collision-free loopback ports, and keeps projects in `~/.pascal/data/pascal.db`. Configure -an agent to launch `pascal mcp connect`. See [Run Pascal locally](https://editor.pascal.app/docs/developers/local-editor) +collision-free loopback ports, and keeps projects in `~/.pascal/data/pascal.db`. The npm +package holds the CLI and that MCP service; the web editor runtime is downloaded once per +version on the first command that starts the editor and verified against a digest published +inside the package. Configure an agent to launch `pascal mcp connect`, which needs neither +the editor process nor that download. Install the `pascal` command with +`npm install --global @pascal-app/cli`. See [Run Pascal locally](https://editor.pascal.app/docs/developers/local-editor) for pnpm/Bun commands, project management, MCP setup, updates, storage paths, and -troubleshooting. The npm release is the older runtime described below; use the verified -GitHub preview when a task needs the new read-only furniture candidate check. - -## Verified CLI preview - -The npm `beta` tag currently resolves to `@pascal-app/cli@1.0.0-beta.1`, which predates the read-only furniture candidate input and hosted agent claim/status commands in this repository. To use those capabilities before the next npm release, install the verified GitHub prerelease built from commit `5dabbc3b56109c9f79dc8a378443a4c520d9ee0a`: - -```bash -PASCAL_PREVIEW_VERSION='1.0.0-beta.2.status.0' -PASCAL_PREVIEW_PREFIX="${XDG_DATA_HOME:-$HOME/.local/share}/pascal-preview" -PASCAL_PREVIEW_DOWNLOAD="$(mktemp -d)" -cd "$PASCAL_PREVIEW_DOWNLOAD" - -curl --fail --location --remote-name \ - "https://github.com/pascalorg/editor/releases/download/cli-v1.0.0-beta.2-status.0/pascal-app-cli-${PASCAL_PREVIEW_VERSION}.tgz" -curl --fail --location --remote-name \ - "https://github.com/pascalorg/editor/releases/download/cli-v1.0.0-beta.2-status.0/SHA256SUMS.txt" - -# macOS -shasum -a 256 -c SHA256SUMS.txt -# Linux: use `sha256sum -c SHA256SUMS.txt` instead. - -npm install --global --prefix "$PASCAL_PREVIEW_PREFIX" --ignore-scripts \ - "./pascal-app-cli-${PASCAL_PREVIEW_VERSION}.tgz" -export PATH="$PASCAL_PREVIEW_PREFIX/bin:$PATH" -pascal --version -pascal update --version "$PASCAL_PREVIEW_VERSION" -pascal editor --no-open -# For an existing hosted autonomous-agent key: -PASCAL_API_KEY='sk_live_...' pascal agent claim -PASCAL_API_KEY='sk_live_...' pascal agent status --json -``` - -The expected archive SHA-256 is `15628baeeb174fb7786a1643db08f0554bf6d18afaaa3979f01922c5cd40019a`. The same-version `update` command installs and activates this CLI's bundled runtime, restarting an older running service when necessary. Keep an existing `PASCAL_HOME` unchanged so stored projects remain in the same data directory; `pascal editor` alone reuses any healthy service, including an older one. Keep the preview prefix on the agent host's `PATH` before using the Claude plugin-provided connector, running `pascal mcp setup claude` or `pascal mcp setup codex` for another installation path, or configuring `pascal mcp connect` manually. `pascal agent claim` opens a prefilled 15-minute human handoff; `pascal agent status` verifies the key and reports the bounded claim state. Neither command stores or prints the hosted key. This GitHub prerelease is not an npm version. +troubleshooting. Use one active agent client per local CLI service. The standalone local HTTP runtime shares active scene state between clients; use separate `PASCAL_HOME` directories and service processes when independent concurrent work is required. ## Agent skills +[![Install with skills](https://skills.sh/b/pascalorg/editor)](https://skills.sh/pascalorg/editor) + Install Pascal's public agent workflows from this repository with [skills.sh](https://skills.sh): ```bash @@ -79,7 +52,9 @@ Claude Code users can install the same canonical skill source as a plugin: /plugin install pascal-agent-skills@pascal ``` -The Claude plugin also supplies the local `pascal mcp connect` server. Install and start the Pascal CLI first, and keep `pascal` on the `PATH` used to launch Claude Code. This local connector needs no Pascal account or API key and does not upload projects automatically. +The Claude plugin also supplies the local `pascal mcp connect` server. Install and start the Pascal CLI first, and keep `pascal` on the `PATH` used to launch Claude Code. This local connector needs no Pascal account or API key and does not upload projects automatically. Its plugin root is this repository's `skills/` directory, so an install copies only the skill bundles and their plugin metadata rather than the repository. + +The plugin bundles two servers: the local `pascal` connector above and a hosted `pascal-hosted` server for `https://editor.pascal.app/api/mcp`, which prompts for an optional Pascal API key at enable time and stores it in the OS keychain. Leave the key empty to run local-only. Claude Code 2.1.258 loads both the user-scoped `pascal` server created by `pascal mcp setup claude` and the plugin-provided server. Remove the manual entry before reloading or restarting Claude Code so only the plugin owns the connection lifecycle: @@ -115,11 +90,11 @@ owner publishes an approved version from `main` with the official registry publi The viewer runtime and built-in node definitions are separate packages. Install the full built-in viewer set, then load the built-in plugin once before mounting ``. Capture sessions are an -optional transport-neutral extension: +optional extension shipped inside those packages as the `@pascal-app/core/capture` and +`@pascal-app/viewer/capture` subpaths: ```bash npm install @pascal-app/core @pascal-app/viewer @pascal-app/editor @pascal-app/nodes -npm install @pascal-app/capture-protocol @pascal-app/capture-viewer ``` ```typescript @@ -142,10 +117,8 @@ editor/ β”œβ”€β”€ apps/ β”‚ └── editor/ # Next.js application β”œβ”€β”€ packages/ -β”‚ β”œβ”€β”€ core/ # Schemas, scene state, and registry contracts -β”‚ β”œβ”€β”€ viewer/ # 3D rendering runtime and shared systems -β”‚ β”œβ”€β”€ capture-protocol/ # Static/live capture-session contracts -β”‚ β”œβ”€β”€ capture-viewer/ # Capture source runtime and reference renderers +β”‚ β”œβ”€β”€ core/ # Schemas, scene state, registry contracts, capture contracts +β”‚ β”œβ”€β”€ viewer/ # 3D rendering runtime, shared systems, capture runtime β”‚ β”œβ”€β”€ editor/ # Editing tools and UI components β”‚ β”œβ”€β”€ nodes/ # Built-in node definitions, renderers, and systems β”‚ β”œβ”€β”€ cli/ # Persistent local editor installer and process manager @@ -157,10 +130,8 @@ editor/ | Package | Responsibility | |---------|---------------| -| **@pascal-app/core** | Node schemas, scene state (Zustand), registry contracts, spatial queries, and event bus | -| **@pascal-app/viewer** | 3D rendering via React Three Fiber, shared render systems, default camera/controls, and post-processing | -| **@pascal-app/capture-protocol** | Versioned capture manifests, normalized streams, and transport-neutral static/live sources | -| **@pascal-app/capture-viewer** | Viewer child runtime and reference model, device-motion, and point-cloud layers | +| **@pascal-app/core** | Node schemas, scene state (Zustand), registry contracts, spatial queries, and event bus. `core/capture` adds versioned capture manifests, normalized streams, and transport-neutral static/live sources | +| **@pascal-app/viewer** | 3D rendering via React Three Fiber, shared render systems, default camera/controls, and post-processing. `viewer/capture` adds the capture runtime and reference model, device-motion, point-cloud, and surface-mesh layers | | **@pascal-app/editor** | Editing tools, panels, selection, and direct-manipulation UI | | **@pascal-app/nodes** | Built-in registry plugin with node definitions, renderers, geometry, and systems | | **@pascal-app/cli** | Installs and manages a versioned standalone editor runtime and persistent local data | @@ -525,14 +496,12 @@ turbo build --filter=@pascal-app/core ### Publishing Packages -```bash -# Build packages -turbo build --filter=@pascal-app/core --filter=@pascal-app/viewer - -# Publish to npm -npm publish --workspace=@pascal-app/core --access public -npm publish --workspace=@pascal-app/viewer --access public -``` +Releases run from `.github/workflows/release.yml` (`workflow_dispatch`, with +`package`, `bump`, and `dry-run` inputs). The workflow bumps versions, rewrites +the internal `@pascal-app/*` ranges, builds, publishes in dependency order +(`core` β†’ `viewer` β†’ `editor` β†’ `nodes` β†’ `mcp` β†’ `ifc-converter` β†’ `cli`), +then commits the release and pushes one tag per package. A dry run validates +the builds without touching the registry. --- diff --git a/SETUP.md b/SETUP.md index 80c33bc024..837ed17e72 100644 --- a/SETUP.md +++ b/SETUP.md @@ -13,6 +13,11 @@ bun dev The editor will be running at **http://localhost:3002**. +Environment is included as a pinned GitHub dependency, like the other bundled +plugins. Open **+ β†’ Plugins β†’ Environment** to manage its installation for the +current project, then open **Environment** in the sidebar. No separate plugin +checkout, local tarball, or synchronization script is needed. + ## Environment Variables (optional) Copy `.env.example` to `.env` if you need: @@ -58,13 +63,16 @@ background, and open it in the browser without a repository checkout: npx @pascal-app/cli editor ``` -The command starts the editor and its authenticated local MCP service together. Configure -an agent to launch `pascal mcp connect`; for example, run `pascal mcp setup codex`. +The command starts the editor and its authenticated local MCP service together, downloading +the web editor runtime for that CLI version on the first run and verifying it against a +digest published in the npm package. Configure an agent to launch `pascal mcp connect`; for +example, run `pascal mcp setup codex`. That connector needs neither the editor process nor +the runtime download, and `--runtime ` covers an offline host. Use `npx @pascal-app/cli doctor` to check the runtime, storage, editor, and MCP state. Saved scenes live in `~/.pascal/data/pascal.db` independently from installed runtime versions. The CLI retains old runtime versions for rollback and warns after more than three have -accumulated. It also replaces a damaged copy of its bundled runtime on the next start; +accumulated. It also replaces a damaged copy of the installed runtime on the next start; neither operation modifies the data directory. The complete command and storage reference is in [Run Pascal locally](https://editor.pascal.app/docs/developers/local-editor). diff --git a/apps/editor/app/globals.css b/apps/editor/app/globals.css index 61f2d36f64..77fd6e1d02 100644 --- a/apps/editor/app/globals.css +++ b/apps/editor/app/globals.css @@ -6,6 +6,7 @@ @source "../../../node_modules/@pascal-app/plugin-trees/src"; @source "../../../node_modules/@mint/pascal-plugin/src"; @source "../../../node_modules/@pascal-app/plugin-streetscape/src"; +@source "../../../node_modules/@pascal-app/plugin-environment/src"; @custom-variant dark (&:is(.dark *)); diff --git a/apps/editor/bunfig.toml b/apps/editor/bunfig.toml new file mode 100644 index 0000000000..eec7d338da --- /dev/null +++ b/apps/editor/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-preload-three.ts"] + +[test] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 508deb3225..995b453141 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -336,13 +336,14 @@ export function BuildTab() { // On open, land on the first build tool β€” parity with the community Build // sidebar, so switching to Build immediately arms a usable tool. Skip when a - // build tool is already active (e.g. the B shortcut armed one before this - // panel mounted): the active tool is the source of truth, not this default. + // Build-tab tool or special mode is already active: the current editor state + // is the source of truth, including entry from another panel. const didInitRef = useRef(false) useEffect(() => { if (didInitRef.current) return didInitRef.current = true const ed = useEditor.getState() + if (ed.mode === 'material-paint' || ed.mode === 'terrain-sculpt') return if (ed.mode === 'build' && ed.tool) return const firstType = buildTypes.find((t) => t.kind) if (firstType) handleTypeClick(firstType) @@ -586,7 +587,7 @@ export function BuildTab() { if (option.id !== 'fittingType') return undefined if (kind === 'duct-fitting' && value === 'elbow') return '/icons/duct-fitting.webp' - return `/icons/fittings/${kind === 'duct-fitting' ? 'duct' : 'pipe'}-${value}.png` + return `/icons/fittings/${kind === 'duct-fitting' ? 'duct' : 'pipe'}-${value}.webp` }} kind={kind} onSelect={(option, value) => { diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index 3e7e13b289..fbebec2d12 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -10,10 +10,19 @@ import { import { registerEditorHostPanel } from '@pascal-app/editor' import { builtinPlugin } from '@pascal-app/nodes' import { bonesHostPanel, bonesPlugin } from '@pascal-app/plugin-bones' +import { + environmentHostPanel, + environmentPlugin, + environmentPresentation, +} from '@pascal-app/plugin-environment' +import { poolHostPanel, poolPlugin } from '@pascal-app/plugin-pool' import { streetscapeHostPanel, streetscapePlugin } from '@pascal-app/plugin-streetscape' import { treesHostPanel, treesPlugin } from '@pascal-app/plugin-trees' +import { registerViewerPresentation } from '@pascal-app/viewer' -// Each module evaluation loads builtins once; development reloads replace stale definitions. +// Idempotency guards: HMR can reload this module, but `registerNode` +// throws on duplicate kinds. Flags live in the module closure so they +// reset on a hard reload but survive within a session. let builtinsLoaded = false let externalsKickedOff = false @@ -39,7 +48,10 @@ function loadBuiltinsSync(): void { if (builtinsLoaded) return builtinsLoaded = true for (const def of builtinPlugin.nodes ?? []) { - if (nodeRegistry.has((def as AnyNodeDefinition).kind) && !isDev()) continue + // Skip kinds the registry already has. The module-closure flag + // above resets on HMR, but the registry singleton (in @pascal-app/core) + // persists β€” without this guard we'd throw on the first duplicate. + if (nodeRegistry.has((def as AnyNodeDefinition).kind)) continue registerNode(def as AnyNodeDefinition) } @@ -83,12 +95,17 @@ export async function loadExternalPlugins(): Promise { // so it is registered separately from the core plugin manifest. extendPluginDiscovery(async () => [treesPlugin]) registerEditorHostPanel(treesHostPanel) +extendPluginDiscovery(async () => [environmentPlugin]) +registerEditorHostPanel(environmentHostPanel) +registerViewerPresentation(environmentPresentation) extendPluginDiscovery(async () => [bonesPlugin]) // Opt-in: Bones ships uninstalled β€” users enable it per scene from the // Plugins panel (engineering X-ray is a specialist view, not a default). registerEditorHostPanel({ ...bonesHostPanel, defaultInstalled: false }) extendPluginDiscovery(async () => [mintPlugin]) registerEditorHostPanel(mintHostPanel) +extendPluginDiscovery(async () => [poolPlugin]) +registerEditorHostPanel(poolHostPanel) extendPluginDiscovery(async () => [streetscapePlugin]) // The upstream manifest still names 'Pascal' as creator; credit the author. registerEditorHostPanel({ diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 48578416b9..44cffefdbc 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -32,10 +32,12 @@ const nextConfig: NextConfig = { '@pascal-app/core', '@pascal-app/editor', '@pascal-app/mcp', + '@pascal-app/plugin-pool', '@pascal-app/plugin-streetscape', '@pascal-app/plugin-trees', '@mint/pascal-plugin', '@pascal-app/plugin-bones', + '@pascal-app/plugin-environment', '@dgreenheck/ez-tree', ], turbopack: { diff --git a/apps/editor/package.json b/apps/editor/package.json index 31b4cde8a5..d60a2f7ab0 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,13 +13,15 @@ }, "dependencies": { "@iconify/react": "^6.0.2", - "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@mint/pascal-plugin": "github:mintdotgg/mint-pascal-plugin#902c546dbaece6b31455c0dc394afe6b9cd136fe", "@number-flow/react": "^0.6.0", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", "@pascal-app/nodes": "*", + "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", + "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295", + "@pascal-app/plugin-pool": "file:./vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz", "@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", @@ -33,10 +35,11 @@ "next": "16.3.0", "postcss": "^8.5.6", "react": "^19.2.4", + "react-colorful": "^5.6.1", "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.185.0", + "three": "^0.186.0", "zod": ">=4.5.4 <4.6" }, "devDependencies": { diff --git a/apps/editor/public/audios/radios/classic/Ballroom in Miniature.mp3 b/apps/editor/public/audios/radios/classic/Ballroom in Miniature.mp3 deleted file mode 100644 index ebcf4dd7b7..0000000000 Binary files a/apps/editor/public/audios/radios/classic/Ballroom in Miniature.mp3 and /dev/null differ diff --git a/apps/editor/public/audios/radios/classic/Blueprints in Springtime.mp3 b/apps/editor/public/audios/radios/classic/Blueprints in Springtime.mp3 deleted file mode 100644 index 973955ba31..0000000000 Binary files a/apps/editor/public/audios/radios/classic/Blueprints in Springtime.mp3 and /dev/null differ diff --git a/apps/editor/public/audios/radios/classic/Clockwork Tea Party (Alternate).mp3 b/apps/editor/public/audios/radios/classic/Clockwork Tea Party (Alternate).mp3 deleted file mode 100644 index d4ec67d752..0000000000 Binary files a/apps/editor/public/audios/radios/classic/Clockwork Tea Party (Alternate).mp3 and /dev/null differ diff --git a/apps/editor/public/audios/radios/classic/Clockwork Tea Party.mp3 b/apps/editor/public/audios/radios/classic/Clockwork Tea Party.mp3 deleted file mode 100644 index 88915bad39..0000000000 Binary files a/apps/editor/public/audios/radios/classic/Clockwork Tea Party.mp3 and /dev/null differ diff --git a/apps/editor/public/audios/radios/classic/Clockwork Teacups.mp3 b/apps/editor/public/audios/radios/classic/Clockwork Teacups.mp3 deleted file mode 100644 index a8967b205d..0000000000 Binary files a/apps/editor/public/audios/radios/classic/Clockwork Teacups.mp3 and /dev/null differ diff --git a/apps/editor/public/audios/radios/classic/Evening in the Parlor.mp3 b/apps/editor/public/audios/radios/classic/Evening in the Parlor.mp3 deleted file mode 100644 index ed7b590fee..0000000000 Binary files a/apps/editor/public/audios/radios/classic/Evening in the Parlor.mp3 and /dev/null differ diff --git a/apps/editor/public/audios/radios/classic/Glass Atrium.mp3 b/apps/editor/public/audios/radios/classic/Glass Atrium.mp3 deleted file mode 100644 index 460f72f910..0000000000 Binary files a/apps/editor/public/audios/radios/classic/Glass Atrium.mp3 and /dev/null differ diff --git a/apps/editor/public/audios/radios/classic/Moonlight On The Drafting Table.mp3 b/apps/editor/public/audios/radios/classic/Moonlight On The Drafting Table.mp3 deleted file mode 100644 index 9b4e527515..0000000000 Binary files a/apps/editor/public/audios/radios/classic/Moonlight On The Drafting Table.mp3 and /dev/null differ diff --git a/apps/editor/public/audios/radios/classic/Sunlit Garden Reverie.mp3 b/apps/editor/public/audios/radios/classic/Sunlit Garden Reverie.mp3 deleted file mode 100644 index 120d20ab00..0000000000 Binary files a/apps/editor/public/audios/radios/classic/Sunlit Garden Reverie.mp3 and /dev/null differ diff --git a/apps/editor/public/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3 b/apps/editor/public/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3 deleted file mode 100644 index 037cd88902..0000000000 Binary files a/apps/editor/public/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3 and /dev/null differ diff --git a/apps/editor/public/icons/fittings/README.md b/apps/editor/public/icons/fittings/README.md index 9d3bc525ce..f1ca01776d 100644 --- a/apps/editor/public/icons/fittings/README.md +++ b/apps/editor/public/icons/fittings/README.md @@ -2,25 +2,31 @@ Generated with the built-in image generation tool using `../duct-fitting.webp` as the style reference. The existing duct elbow keeps that original icon. +The generator emits a large PNG; re-encode it before committing, because these ship in the portable CLI runtime and render at 56 px: + +```sh +cwebp -q 85 -m 6 -alpha_q 100 -resize 256 256 .png -o .webp +``` + ## Prompt template Create one catalog thumbnail asset for SUBJECT. Reference image is STYLE REFERENCE ONLY. Match its polished lavender purple 3D isometric product icon, soft lilac highlights, darker violet interiors, fine bright edges. Actual subject must be SUBJECT, not the reference elbow. Single isolated object centered, occupying 78% of square canvas. Three-quarter view from above showing its identifying geometry clearly at 56px. Transparent background, no floor, no text, no labels, no watermark, no additional objects. Save generated asset. Asset identifier NAME. ## Subjects -- `duct-tee.png`: rectangular HVAC duct T junction with exactly three rectangular flanged openings. -- `duct-cross.png`: rectangular HVAC duct cross junction with exactly four rectangular flanged openings. -- `duct-reducer.png`: round HVAC concentric reducer, wide circular opening tapering into a smaller circular opening. -- `duct-transition.png`: HVAC transition from a wide rectangular flanged opening to a round circular collar. -- `duct-end-cap.png`: short rectangular HVAC duct end cap with a sealed flat rectangular face and flanged rim. -- `duct-damper.png`: rectangular HVAC balancing damper, short flanged hollow rectangular sleeve with a visible internal blade and external adjustment lever. -- `duct-access-panel.png`: rectangular HVAC access door panel, flat framed closed door with two hinges and two latches. -- `duct-coupling.png`: short straight rectangular HVAC coupling sleeve with two opposite equal rectangular openings and a center seam. -- `pipe-elbow.png`: round plumbing pipe 90 degree curved elbow, exactly two circular socket openings. -- `pipe-wye.png`: round plumbing pipe Y junction with three circular socket openings, branch at 45 degrees. -- `pipe-sanitary-tee.png`: round plumbing sanitary tee with three circular socket openings, curved sweeping side branch. -- `pipe-cross.png`: round plumbing cross fitting with four circular socket openings. -- `pipe-end-cap.png`: round plumbing pipe end cap with a closed circular end. -- `pipe-cleanout.png`: round plumbing cleanout fitting with a prominent threaded hexagonal removable plug sealing its end. -- `pipe-reducer.png`: round plumbing concentric reducer with wide circular socket tapering to narrow circular socket. -- `pipe-coupling.png`: short straight round plumbing coupling with two equal circular socket openings. +- `duct-tee.webp`: rectangular HVAC duct T junction with exactly three rectangular flanged openings. +- `duct-cross.webp`: rectangular HVAC duct cross junction with exactly four rectangular flanged openings. +- `duct-reducer.webp`: round HVAC concentric reducer, wide circular opening tapering into a smaller circular opening. +- `duct-transition.webp`: HVAC transition from a wide rectangular flanged opening to a round circular collar. +- `duct-end-cap.webp`: short rectangular HVAC duct end cap with a sealed flat rectangular face and flanged rim. +- `duct-damper.webp`: rectangular HVAC balancing damper, short flanged hollow rectangular sleeve with a visible internal blade and external adjustment lever. +- `duct-access-panel.webp`: rectangular HVAC access door panel, flat framed closed door with two hinges and two latches. +- `duct-coupling.webp`: short straight rectangular HVAC coupling sleeve with two opposite equal rectangular openings and a center seam. +- `pipe-elbow.webp`: round plumbing pipe 90 degree curved elbow, exactly two circular socket openings. +- `pipe-wye.webp`: round plumbing pipe Y junction with three circular socket openings, branch at 45 degrees. +- `pipe-sanitary-tee.webp`: round plumbing sanitary tee with three circular socket openings, curved sweeping side branch. +- `pipe-cross.webp`: round plumbing cross fitting with four circular socket openings. +- `pipe-end-cap.webp`: round plumbing pipe end cap with a closed circular end. +- `pipe-cleanout.webp`: round plumbing cleanout fitting with a prominent threaded hexagonal removable plug sealing its end. +- `pipe-reducer.webp`: round plumbing concentric reducer with wide circular socket tapering to narrow circular socket. +- `pipe-coupling.webp`: short straight round plumbing coupling with two equal circular socket openings. diff --git a/apps/editor/public/icons/fittings/duct-access-panel.png b/apps/editor/public/icons/fittings/duct-access-panel.png deleted file mode 100644 index 24f81134a0..0000000000 Binary files a/apps/editor/public/icons/fittings/duct-access-panel.png and /dev/null differ diff --git a/apps/editor/public/icons/fittings/duct-access-panel.webp b/apps/editor/public/icons/fittings/duct-access-panel.webp new file mode 100644 index 0000000000..e111436c31 Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-access-panel.webp differ diff --git a/apps/editor/public/icons/fittings/duct-coupling.png b/apps/editor/public/icons/fittings/duct-coupling.png deleted file mode 100644 index bb5cf3d1e6..0000000000 Binary files a/apps/editor/public/icons/fittings/duct-coupling.png and /dev/null differ diff --git a/apps/editor/public/icons/fittings/duct-coupling.webp b/apps/editor/public/icons/fittings/duct-coupling.webp new file mode 100644 index 0000000000..7a2850397a Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-coupling.webp differ diff --git a/apps/editor/public/icons/fittings/duct-cross.png b/apps/editor/public/icons/fittings/duct-cross.png deleted file mode 100644 index 328eb2d8b5..0000000000 Binary files a/apps/editor/public/icons/fittings/duct-cross.png and /dev/null differ diff --git a/apps/editor/public/icons/fittings/duct-cross.webp b/apps/editor/public/icons/fittings/duct-cross.webp new file mode 100644 index 0000000000..2bf4440cd7 Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-cross.webp differ diff --git a/apps/editor/public/icons/fittings/duct-damper.png b/apps/editor/public/icons/fittings/duct-damper.png deleted file mode 100644 index 0ab31b265d..0000000000 Binary files a/apps/editor/public/icons/fittings/duct-damper.png and /dev/null differ diff --git a/apps/editor/public/icons/fittings/duct-damper.webp b/apps/editor/public/icons/fittings/duct-damper.webp new file mode 100644 index 0000000000..d6b097fbbe Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-damper.webp differ diff --git a/apps/editor/public/icons/fittings/duct-end-cap.png b/apps/editor/public/icons/fittings/duct-end-cap.png deleted file mode 100644 index 4bcfa350e7..0000000000 Binary files a/apps/editor/public/icons/fittings/duct-end-cap.png and /dev/null differ diff --git a/apps/editor/public/icons/fittings/duct-end-cap.webp b/apps/editor/public/icons/fittings/duct-end-cap.webp new file mode 100644 index 0000000000..ac03974669 Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-end-cap.webp differ diff --git a/apps/editor/public/icons/fittings/duct-reducer.png b/apps/editor/public/icons/fittings/duct-reducer.png deleted file mode 100644 index f2ab6a1b06..0000000000 Binary files a/apps/editor/public/icons/fittings/duct-reducer.png and /dev/null differ diff --git a/apps/editor/public/icons/fittings/duct-reducer.webp b/apps/editor/public/icons/fittings/duct-reducer.webp new file mode 100644 index 0000000000..5af2254b8b Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-reducer.webp differ diff --git a/apps/editor/public/icons/fittings/duct-tee.png b/apps/editor/public/icons/fittings/duct-tee.png deleted file mode 100644 index 621533a398..0000000000 Binary files a/apps/editor/public/icons/fittings/duct-tee.png and /dev/null differ diff --git a/apps/editor/public/icons/fittings/duct-tee.webp b/apps/editor/public/icons/fittings/duct-tee.webp new file mode 100644 index 0000000000..9afdc9f266 Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-tee.webp differ diff --git a/apps/editor/public/icons/fittings/duct-transition.png b/apps/editor/public/icons/fittings/duct-transition.png deleted file mode 100644 index f0fab667e2..0000000000 Binary files a/apps/editor/public/icons/fittings/duct-transition.png and /dev/null differ diff --git a/apps/editor/public/icons/fittings/duct-transition.webp b/apps/editor/public/icons/fittings/duct-transition.webp new file mode 100644 index 0000000000..4af29ceb75 Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-transition.webp differ diff --git a/apps/editor/public/icons/fittings/pipe-cleanout.png b/apps/editor/public/icons/fittings/pipe-cleanout.png deleted file mode 100644 index 502fef5245..0000000000 Binary files a/apps/editor/public/icons/fittings/pipe-cleanout.png and /dev/null differ diff --git a/apps/editor/public/icons/fittings/pipe-cleanout.webp b/apps/editor/public/icons/fittings/pipe-cleanout.webp new file mode 100644 index 0000000000..8837457471 Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-cleanout.webp differ diff --git a/apps/editor/public/icons/fittings/pipe-coupling.png b/apps/editor/public/icons/fittings/pipe-coupling.png deleted file mode 100644 index 0c89d05530..0000000000 Binary files a/apps/editor/public/icons/fittings/pipe-coupling.png and /dev/null differ diff --git a/apps/editor/public/icons/fittings/pipe-coupling.webp b/apps/editor/public/icons/fittings/pipe-coupling.webp new file mode 100644 index 0000000000..ead21a241e Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-coupling.webp differ diff --git a/apps/editor/public/icons/fittings/pipe-cross.png b/apps/editor/public/icons/fittings/pipe-cross.png deleted file mode 100644 index e6da6da425..0000000000 Binary files a/apps/editor/public/icons/fittings/pipe-cross.png and /dev/null differ diff --git a/apps/editor/public/icons/fittings/pipe-cross.webp b/apps/editor/public/icons/fittings/pipe-cross.webp new file mode 100644 index 0000000000..de6c033e61 Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-cross.webp differ diff --git a/apps/editor/public/icons/fittings/pipe-elbow.png b/apps/editor/public/icons/fittings/pipe-elbow.png deleted file mode 100644 index f99c369437..0000000000 Binary files a/apps/editor/public/icons/fittings/pipe-elbow.png and /dev/null differ diff --git a/apps/editor/public/icons/fittings/pipe-elbow.webp b/apps/editor/public/icons/fittings/pipe-elbow.webp new file mode 100644 index 0000000000..fd7ca58e34 Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-elbow.webp differ diff --git a/apps/editor/public/icons/fittings/pipe-end-cap.png b/apps/editor/public/icons/fittings/pipe-end-cap.png deleted file mode 100644 index 1b58ae1cf5..0000000000 Binary files a/apps/editor/public/icons/fittings/pipe-end-cap.png and /dev/null differ diff --git a/apps/editor/public/icons/fittings/pipe-end-cap.webp b/apps/editor/public/icons/fittings/pipe-end-cap.webp new file mode 100644 index 0000000000..24695065a8 Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-end-cap.webp differ diff --git a/apps/editor/public/icons/fittings/pipe-reducer.png b/apps/editor/public/icons/fittings/pipe-reducer.png deleted file mode 100644 index 6be0236f7c..0000000000 Binary files a/apps/editor/public/icons/fittings/pipe-reducer.png and /dev/null differ diff --git a/apps/editor/public/icons/fittings/pipe-reducer.webp b/apps/editor/public/icons/fittings/pipe-reducer.webp new file mode 100644 index 0000000000..ef3a835947 Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-reducer.webp differ diff --git a/apps/editor/public/icons/fittings/pipe-sanitary-tee.png b/apps/editor/public/icons/fittings/pipe-sanitary-tee.png deleted file mode 100644 index a8536f422e..0000000000 Binary files a/apps/editor/public/icons/fittings/pipe-sanitary-tee.png and /dev/null differ diff --git a/apps/editor/public/icons/fittings/pipe-sanitary-tee.webp b/apps/editor/public/icons/fittings/pipe-sanitary-tee.webp new file mode 100644 index 0000000000..fb2bf2bcad Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-sanitary-tee.webp differ diff --git a/apps/editor/public/icons/fittings/pipe-wye.png b/apps/editor/public/icons/fittings/pipe-wye.png deleted file mode 100644 index e367f252ce..0000000000 Binary files a/apps/editor/public/icons/fittings/pipe-wye.png and /dev/null differ diff --git a/apps/editor/public/icons/fittings/pipe-wye.webp b/apps/editor/public/icons/fittings/pipe-wye.webp new file mode 100644 index 0000000000..8eb29b1e3b Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-wye.webp differ diff --git a/apps/editor/public/items/rectangular-ceiling-light/Screenshot 2026-01-23 at 12.15.00.png b/apps/editor/public/items/rectangular-ceiling-light/Screenshot 2026-01-23 at 12.15.00.png deleted file mode 100644 index edfdc6af86..0000000000 Binary files a/apps/editor/public/items/rectangular-ceiling-light/Screenshot 2026-01-23 at 12.15.00.png and /dev/null differ diff --git a/apps/editor/public/items/small-kitchen-cabinet/model.glb b/apps/editor/public/items/small-kitchen-cabinet/model.glb deleted file mode 100644 index aa28eb1ced..0000000000 Binary files a/apps/editor/public/items/small-kitchen-cabinet/model.glb and /dev/null differ diff --git a/apps/editor/public/items/small-kitchen-cabinet/thumbnail.webp b/apps/editor/public/items/small-kitchen-cabinet/thumbnail.webp deleted file mode 100644 index e8a1ad05f3..0000000000 Binary files a/apps/editor/public/items/small-kitchen-cabinet/thumbnail.webp and /dev/null differ diff --git a/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz b/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz new file mode 100644 index 0000000000..4461b548cb Binary files /dev/null and b/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz differ diff --git a/apps/ifc-converter/components/IfcConverter.tsx b/apps/ifc-converter/components/IfcConverter.tsx index 1daf37a740..663e9aa024 100644 --- a/apps/ifc-converter/components/IfcConverter.tsx +++ b/apps/ifc-converter/components/IfcConverter.tsx @@ -66,7 +66,7 @@ export default function IfcConverter() { }, [pascalData]) const elementTypes = useMemo(() => { - const order = ['wall', 'slab', 'door', 'window', 'stair', 'roof', 'column', 'item'] + const order = ['wall', 'slab', 'door', 'window', 'stair', 'roof', 'column', 'block', 'item'] return order.filter((t) => typeCounts[t]) }, [typeCounts]) diff --git a/apps/ifc-converter/package.json b/apps/ifc-converter/package.json index 67ec77a56e..c5798f6e32 100644 --- a/apps/ifc-converter/package.json +++ b/apps/ifc-converter/package.json @@ -29,7 +29,7 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.185.0", + "three": "^0.186.0", "web-ifc": "^0.0.77" }, "devDependencies": { diff --git a/assets/pascal-mark-plate.png b/assets/pascal-mark-plate.png new file mode 100644 index 0000000000..d9c7b80dff Binary files /dev/null and b/assets/pascal-mark-plate.png differ diff --git a/assets/pascal-mark-plate.svg b/assets/pascal-mark-plate.svg new file mode 100644 index 0000000000..0f8a41d8d1 --- /dev/null +++ b/assets/pascal-mark-plate.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/bun.lock b/bun.lock index d785effb1b..b63a089de4 100644 --- a/bun.lock +++ b/bun.lock @@ -36,6 +36,8 @@ "@pascal-app/mcp": "*", "@pascal-app/nodes": "*", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", + "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295", + "@pascal-app/plugin-pool": "file:./vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz", "@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", @@ -49,10 +51,11 @@ "next": "16.3.0", "postcss": "^8.5.6", "react": "^19.2.4", + "react-colorful": "^5.6.1", "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.185.0", + "three": "^0.186.0", "zod": ">=4.5.4 <4.6", }, "devDependencies": { @@ -87,7 +90,7 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.185.0", + "three": "^0.186.0", "web-ifc": "^0.0.77", }, "devDependencies": { @@ -99,48 +102,9 @@ "typescript": "7.0.2", }, }, - "packages/capture-protocol": { - "name": "@pascal-app/capture-protocol", - "version": "1.0.0-beta.4", - "dependencies": { - "zod": ">=4.5.4 <4.6", - }, - "devDependencies": { - "@pascal/typescript-config": "*", - "@types/bun": "^1.3.0", - "typescript": "6.0.3", - }, - }, - "packages/capture-viewer": { - "name": "@pascal-app/capture-viewer", - "version": "1.0.0-beta.4", - "devDependencies": { - "@pascal-app/capture-protocol": "^1.0.0-beta.4", - "@pascal-app/core": "^1.0.0-beta.4", - "@pascal-app/viewer": "^1.0.0-beta.4", - "@pascal/typescript-config": "*", - "@react-three/drei": "^10.7.7", - "@react-three/fiber": "^9.5.0", - "@types/bun": "^1.3.0", - "@types/react": "^19.2.2", - "@types/three": "^0.184.0", - "react": "^19.2.4", - "three": "^0.185.0", - "typescript": "6.0.3", - }, - "peerDependencies": { - "@pascal-app/capture-protocol": "^1.0.0-beta.4", - "@pascal-app/core": "^1.0.0-beta.4", - "@pascal-app/viewer": "^1.0.0-beta.4", - "@react-three/drei": "^10", - "@react-three/fiber": "^9", - "react": "^18 || ^19", - "three": "^0.185", - }, - }, "packages/cli": { "name": "@pascal-app/cli", - "version": "1.0.0-beta.1", + "version": "1.0.0", "bin": { "pascal": "dist/bin/pascal.js", }, @@ -155,9 +119,8 @@ }, "packages/core": { "name": "@pascal-app/core", - "version": "1.0.0-beta.5", + "version": "1.0.0", "dependencies": { - "@pascal-app/capture-protocol": "^1.0.0-beta.4", "dedent": "^1.7.1", "idb-keyval": "^6.2.2", "mitt": "^3.0.1", @@ -178,12 +141,12 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.185", + "three": "^0.186", }, }, "packages/editor": { "name": "@pascal-app/editor", - "version": "1.0.0-beta.5", + "version": "1.0.0", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -223,9 +186,10 @@ "zustand": "^5.0.11", }, "devDependencies": { - "@pascal-app/core": "^1.0.0-beta.5", - "@pascal-app/viewer": "^1.0.0-beta.5", + "@pascal-app/core": "^1.0.0", + "@pascal-app/viewer": "^1.0.0", "@pascal/typescript-config": "*", + "@react-three/test-renderer": "^9.1.0", "@types/blob-stream": "^0.1.33", "@types/bun": "^1.3.0", "@types/howler": "^2.2.12", @@ -237,14 +201,14 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^1.0.0-beta.5", - "@pascal-app/viewer": "^1.0.0-beta.5", + "@pascal-app/core": "^1.0.0", + "@pascal-app/viewer": "^1.0.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", "next": ">=15", "react": "^18 || ^19", "react-dom": "^18 || ^19", - "three": "^0.185", + "three": "^0.186", }, }, "packages/eslint-config": { @@ -266,9 +230,9 @@ }, "packages/ifc-converter": { "name": "@pascal-app/ifc-converter", - "version": "1.0.0-beta.5", + "version": "1.0.0", "dependencies": { - "@pascal-app/core": "*", + "@pascal-app/core": "^1.0.0", "nanoid": "^5.1.6", "web-ifc": "^0.0.77", }, @@ -280,7 +244,7 @@ }, "packages/mcp": { "name": "@pascal-app/mcp", - "version": "1.0.0-beta.6", + "version": "1.0.0", "bin": { "pascal-mcp": "./dist/bin/pascal-mcp.js", }, @@ -290,38 +254,40 @@ "zod": ">=4.5.4 <4.6", }, "devDependencies": { - "@pascal-app/core": "^1.0.0-beta.5", + "@pascal-app/core": "^1.0.0", "@pascal/typescript-config": "*", "@types/node": "^22.19.20", "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^1.0.0-beta.5", + "@pascal-app/core": "^1.0.0", }, }, "packages/nodes": { "name": "@pascal-app/nodes", - "version": "1.0.0-beta.5", + "version": "1.0.0", "devDependencies": { - "@pascal-app/core": "^1.0.0-beta.5", - "@pascal-app/editor": "^1.0.0-beta.5", - "@pascal-app/viewer": "^1.0.0-beta.5", + "@pascal-app/core": "^1.0.0", + "@pascal-app/editor": "^1.0.0", + "@pascal-app/viewer": "^1.0.0", "@pascal/typescript-config": "*", "@types/bun": "^1.3.0", "@types/node": "^22.19.12", "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.3", "@types/three": "^0.184.0", "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^1.0.0-beta.5", - "@pascal-app/editor": "^1.0.0-beta.5", - "@pascal-app/viewer": "^1.0.0-beta.5", + "@pascal-app/core": "^1.0.0", + "@pascal-app/editor": "^1.0.0", + "@pascal-app/viewer": "^1.0.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", "lucide-react": "^1", "react": "^18 || ^19", - "three": "^0.185", + "react-dom": "^18 || ^19", + "three": "^0.186", "zustand": "^5", }, }, @@ -348,15 +314,16 @@ }, "packages/viewer": { "name": "@pascal-app/viewer", - "version": "1.0.0-beta.5", + "version": "1.0.0", "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8", "zustand": "^5", }, "devDependencies": { - "@pascal-app/core": "^1.0.0-beta.5", + "@pascal-app/core": "^1.0.0", "@pascal/typescript-config": "*", + "@react-three/test-renderer": "^9.1.0", "@types/node": "^22", "@types/react": "^19.2.2", "@types/react-dom": "19.2.2", @@ -364,12 +331,12 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^1.0.0-beta.5", + "@pascal-app/core": "^1.0.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", - "three": "^0.185", + "three": "^0.186", }, }, "tooling/typescript": { @@ -383,7 +350,7 @@ "@types/three": "0.184.1", "next": "16.3.0", "react-grab": "0.1.50", - "three": "0.185.1", + "three": "0.186.0", }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -768,10 +735,6 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.69.0", "", { "os": "win32", "cpu": "x64" }, "sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA=="], - "@pascal-app/capture-protocol": ["@pascal-app/capture-protocol@workspace:packages/capture-protocol"], - - "@pascal-app/capture-viewer": ["@pascal-app/capture-viewer@workspace:packages/capture-viewer"], - "@pascal-app/cli": ["@pascal-app/cli@workspace:packages/cli"], "@pascal-app/core": ["@pascal-app/core@workspace:packages/core"], @@ -788,6 +751,10 @@ "@pascal-app/plugin-bones": ["@pascal-app/plugin-bones@github:pascalorg/plugin-bones#5679260", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-bones-5679260", "sha512-uQkyHHOl/VuYx2+d/MmcJui/KEAQZ2UUnO4ywp1XaopmD2YfN+Yx/fR/lS/VL2joEoyyZeBLC3KaXK1Sx5qEvg=="], + "@pascal-app/plugin-environment": ["@pascal-app/plugin-environment@github:AxiomeCG/environment#40baf63", { "peerDependencies": { "@dgreenheck/ez-tree": "^1.1.0", "@pascal-app/core": ">=1.0.0-beta.6 <2", "@pascal-app/editor": ">=1.0.0-beta.6 <2", "@pascal-app/viewer": ">=1.0.0-beta.6 <2", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/fiber": "^9", "lucide-react": "^1.7.0", "react": "^18 || ^19", "react-colorful": "^5.8.1", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "AxiomeCG-environment-40baf63", "sha512-mb9i4IFq1c62BcAhtGJAKgJHBVQtFYySFW/LzkmnNfnWSOlGdUavoiR4f8cq5Y4xg6MTvGFX0ytO1Urk8wOPPQ=="], + + "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@./vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sha512-nu/7x5xF1k0IYnduNu1rh9YJ9hZrmjaRUiXADcDqF82Kc+GLjZFrPRRHCci37QplREY2lqePIXBYfTOXCQczgg=="], + "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:sudhir9297/streetscape-pascal-plugin#1c04ec9", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sudhir9297-streetscape-pascal-plugin-1c04ec9", "sha512-X7Zg7wi0ghZRcTtbH5LF6xye493uSZ2ft3AmAQBtguBCU6VCwCexA7pdKDr5AnVxX/JRj2s6JSd/OX4aIZ9Y6Q=="], "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-trees-56d978c", "sha512-16VzWot1oadvxCPqsRwMJbaP0a3u5FESFy7F7++pY5wAAFq9JTFwzHvKAsllrY5TZ5TJ7Y5vSqvbmQk8sy8HaA=="], @@ -884,6 +851,8 @@ "@react-three/fiber": ["@react-three/fiber@9.6.1", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg=="], + "@react-three/test-renderer": ["@react-three/test-renderer@9.1.1", "", { "peerDependencies": { "@react-three/fiber": ">=9.0.0", "react": "^19.0.0", "three": ">=0.156" } }, "sha512-4DmLn0tg+AP8aU0Mb5vekjHqFrwRJ3z11HfQGOJIDj6DxZ/5BjRHRsDxY0Y+g4l8+WHysQ6PeuSdzTDDYoGuXg=="], + "@repo/eslint-config": ["@repo/eslint-config@workspace:packages/eslint-config"], "@repo/typescript-config": ["@repo/typescript-config@workspace:packages/typescript-config"], @@ -1520,7 +1489,7 @@ "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], - "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], @@ -1810,6 +1779,8 @@ "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + "react-colorful": ["react-colorful@5.8.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-oz68bhsnFWnpDf1ZR8daiQbYpXUnM2h2J6hl9Zg2rTpM/DU6vCqe1E+CpqmqLnJucMZetHZeifSAfJ+geN9lcA=="], + "react-doctor": ["react-doctor@0.5.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@effect/platform-node-shared": "4.0.0-beta.70", "@sentry/node": "^10.54.0", "agent-install": "0.0.5", "conf": "^15.1.0", "confbox": "^0.2.4", "deslop-js": "^0.0.21", "effect": "4.0.0-beta.70", "eslint-plugin-react-hooks": "^7.1.1", "jiti": "^2.7.0", "magicast": "^0.5.3", "oxlint": "^1.66.0", "oxlint-plugin-react-doctor": "0.5.0", "prompts": "^2.4.2", "typescript": ">=5.0.4 <7", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-uri": "^3.1.0" }, "bin": { "react-doctor": "bin/react-doctor.js" } }, "sha512-MEx7RgRv2kXp6HoPMzc7fFz5Mm3vCqS3Z7clkjmDbsPamjCz7TrDDWQarfaPr7woWA6FOAaWHt1bGhQ88cfEEw=="], "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], @@ -1942,7 +1913,7 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="], + "three": ["three@0.186.0", "", {}, "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ=="], "three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="], @@ -2166,6 +2137,8 @@ "postcss/nanoid": ["nanoid@3.3.17", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="], + "promise-worker-transferable/is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], + "react-doctor/agent-install": ["agent-install@0.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ=="], "react-doctor/eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], @@ -2174,8 +2147,6 @@ "react-scan/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "router/is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "three-stdlib/fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="], diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000000..958800cebc --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["./scripts/bun-preload-three.ts"] + +[test] +preload = ["./scripts/bun-preload-three.ts"] diff --git a/gemini-extension.json b/gemini-extension.json new file mode 100644 index 0000000000..3d7c6ff80d --- /dev/null +++ b/gemini-extension.json @@ -0,0 +1,12 @@ +{ + "name": "pascal", + "version": "0.1.8", + "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.", + "contextFileName": "skills/README.md", + "mcpServers": { + "pascal": { + "command": "pascal", + "args": ["mcp", "connect"] + } + } +} diff --git a/.mcp.json b/mcp.json similarity index 64% rename from .mcp.json rename to mcp.json index 12a490e574..8f2b4523da 100644 --- a/.mcp.json +++ b/mcp.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", "mcpServers": { "pascal": { "type": "stdio", diff --git a/package.json b/package.json index 732ddfcbfa..7d1528c169 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "check:fix": "biome check --write", "check-types": "turbo run check-types", "test": "turbo run test", + "skills:validate": "bun scripts/validate-skills.ts && bun test scripts/clawhub-ignore-policy.test.ts scripts/claude-mcp-config-policy.test.ts scripts/openai-tool-annotation-policy.test.ts scripts/path-containment.test.ts scripts/public-skill-discovery-policy.test.ts", "kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'", "clean:cache": "rm -rf apps/*/.next apps/*/.swc apps/*/.turbo packages/*/.turbo tooling/*/.turbo .turbo node_modules/.cache", "restart": "bun kill && bun clean:cache && bun dev", @@ -46,7 +47,7 @@ "@types/three": "0.184.1", "next": "16.3.0", "react-grab": "0.1.50", - "three": "0.185.1" + "three": "0.186.0" }, "optionalDependencies": { "@tailwindcss/oxide-darwin-arm64": "4.3.0", diff --git a/packages/capture-protocol/LICENSE b/packages/capture-protocol/LICENSE deleted file mode 100644 index 083fd9e323..0000000000 --- a/packages/capture-protocol/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Pascal Group Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/capture-protocol/README.md b/packages/capture-protocol/README.md deleted file mode 100644 index a569059558..0000000000 --- a/packages/capture-protocol/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# `@pascal-app/capture-protocol` - -Transport-neutral capture-session contracts for Pascal viewers and hosts. - -The package contains versioned static manifests, a normalized session descriptor, packet headers -for incremental data, and a `CaptureSource` interface that can be backed by HTTP, WebSocket, -WebRTC, local files, or an in-memory producer. It does not contain authentication, persistence, -React, Three.js, or a canonical network transport. - -```ts -import { - createHttpCaptureSource, - type CaptureSessionLocator, -} from '@pascal-app/capture-protocol' - -const locator: CaptureSessionLocator = { - sessionId: 'capture_123', - manifestUrl: '/api/captures/capture_123/manifest', -} - -const source = createHttpCaptureSource(locator, { credentials: 'include' }) -const descriptor = await source.describe() -``` - -For live producers, use `PushCaptureSource` directly or implement `CaptureSource.subscribe()` with -the same descriptor and packet event contract. - -The package exports its TypeScript source under the `react-native` condition so Metro can consume -the workspace package from a clean checkout. Web and Node consumers continue to use the compiled -ES module output. diff --git a/packages/capture-protocol/package.json b/packages/capture-protocol/package.json deleted file mode 100644 index 598b39e2ee..0000000000 --- a/packages/capture-protocol/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@pascal-app/capture-protocol", - "version": "1.0.0-beta.4", - "description": "Transport-neutral capture-session manifests and live stream sources for Pascal", - "type": "module", - "main": "./dist/index.js", - "types": "./src/index.ts", - "exports": { - ".": { - "types": "./src/index.ts", - "react-native": "./src/index.ts", - "browser": "./src/index.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "files": [ - "dist", - "src", - "README.md" - ], - "scripts": { - "build": "tsc --build", - "dev": "tsgo --build --watch", - "test": "bun test src", - "prepublishOnly": "npm run build" - }, - "dependencies": { - "zod": ">=4.5.4 <4.6" - }, - "devDependencies": { - "@pascal/typescript-config": "*", - "@types/bun": "^1.3.0", - "typescript": "6.0.3" - }, - "keywords": [ - "3d", - "capture", - "point-cloud", - "sensor-fusion", - "streaming" - ], - "repository": { - "type": "git", - "url": "https://github.com/pascalorg/editor.git", - "directory": "packages/capture-protocol" - }, - "license": "MIT", - "homepage": "https://github.com/pascalorg/editor/tree/main/packages/capture-protocol#readme", - "bugs": "https://github.com/pascalorg/editor/issues" -} diff --git a/packages/capture-protocol/tsconfig.json b/packages/capture-protocol/tsconfig.json deleted file mode 100644 index 06b4bb5999..0000000000 --- a/packages/capture-protocol/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "@pascal/typescript-config/react-library.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "noEmit": false, - "composite": true, - "incremental": true, - "types": ["bun"] - }, - "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] -} diff --git a/packages/capture-viewer/LICENSE b/packages/capture-viewer/LICENSE deleted file mode 100644 index 083fd9e323..0000000000 --- a/packages/capture-viewer/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Pascal Group Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/capture-viewer/README.md b/packages/capture-viewer/README.md deleted file mode 100644 index f6a63e3f01..0000000000 --- a/packages/capture-viewer/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# `@pascal-app/capture-viewer` - -Reference capture layers for `@pascal-app/viewer`. - -Mount `CaptureRuntime` as a child of `Viewer` and provide a source resolver. The host owns access -control and transport; the runtime owns source lifecycle, scan-node placement, layer visibility, -and reference renderers for RoomPlan models, device trajectories, and PLY/live point clouds. - -```tsx - - reportCaptureError(error, context)} - resolveSource={(locator) => - createHttpCaptureSource(locator, { credentials: 'include' }) - } - retryKey={retryVersion} - /> - -``` - -Unknown streams remain in the descriptor and can be rendered by passing a custom renderer keyed by -stream role or kind. A live transport implements `CaptureSource.subscribe()`; no particular -WebSocket, WebRTC, or collaboration backend is required by this package. - -`CaptureRuntime` keeps telemetry host-neutral: pass `onError` to report source or per-stream -failures in the host, then increment `retryKey` to reload every affected session. Direct -`useCaptureSource()` consumers can call its `retry()` function instead. - -Hosts can pass `defaultLayerVisibility` to keep expensive optional layers disabled until a user -enables them. Persisted values in the scan node's `layers` map always override those host defaults; -without host defaults, every available layer remains visible for backwards compatibility. -Hidden sessions and layers are unmounted rather than only made visually transparent, so they stop -raycasting, artifact work, animation, and live packet subscriptions while disabled. - -## Local surface previews - -`@pascal-app/capture-viewer/preview` exports `createSurfaceMeshGeometry` and `createClayMatcap` -without importing the React viewer runtime. A host can render a locally saved surface immediately, -before its archive is uploaded. Browser and React Native exports resolve source, so an embedded -DOM bundle does not depend on generated workspace `dist` files. - -The geometry decoder uses the shared capture-protocol validator, including the native -20,000-face budget, byte lengths, and index bounds. It returns `null` for invalid input. -The host owns the returned geometry and matcap texture and must dispose them on teardown. - -Direct `CaptureStreamLayer` consumers can pass -`meshPresentation={{ previewMaterial: 'clay', dollhouse: true }}`. Clay replaces preliminary -vertex colors; dollhouse enables front-face rendering for surface previews and room models, -revealing inward-facing room surfaces from outside. It changes per-instance materials, not -geometry or loader-cached materials. Omitting these options preserves the existing presentation. diff --git a/packages/capture-viewer/package.json b/packages/capture-viewer/package.json deleted file mode 100644 index 9b7db8a290..0000000000 --- a/packages/capture-viewer/package.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "name": "@pascal-app/capture-viewer", - "version": "1.0.0-beta.4", - "description": "Open capture-session runtime and reference renderers for the Pascal viewer", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - }, - "./preview": { - "types": "./src/preview.ts", - "react-native": "./src/preview.ts", - "browser": "./src/preview.ts", - "import": "./dist/preview.js", - "default": "./dist/preview.js" - } - }, - "files": [ - "dist", - "src", - "README.md" - ], - "scripts": { - "build": "tsc --build", - "dev": "tsgo --build --watch", - "test": "bun test src", - "prepublishOnly": "npm run build" - }, - "peerDependencies": { - "@pascal-app/capture-protocol": "^1.0.0-beta.4", - "@pascal-app/core": "^1.0.0-beta.4", - "@pascal-app/viewer": "^1.0.0-beta.4", - "@react-three/drei": "^10", - "@react-three/fiber": "^9", - "react": "^18 || ^19", - "three": "^0.185" - }, - "devDependencies": { - "@pascal-app/capture-protocol": "^1.0.0-beta.4", - "@pascal-app/core": "^1.0.0-beta.4", - "@pascal-app/viewer": "^1.0.0-beta.4", - "@pascal/typescript-config": "*", - "@react-three/drei": "^10.7.7", - "@react-three/fiber": "^9.5.0", - "@types/bun": "^1.3.0", - "@types/react": "^19.2.2", - "@types/three": "^0.184.0", - "react": "^19.2.4", - "three": "^0.185.0", - "typescript": "6.0.3" - }, - "keywords": [ - "3d", - "capture", - "point-cloud", - "react-three-fiber", - "viewer" - ], - "repository": { - "type": "git", - "url": "https://github.com/pascalorg/editor.git", - "directory": "packages/capture-viewer" - }, - "license": "MIT", - "homepage": "https://github.com/pascalorg/editor/tree/main/packages/capture-viewer#readme", - "bugs": "https://github.com/pascalorg/editor/issues" -} diff --git a/packages/capture-viewer/tsconfig.json b/packages/capture-viewer/tsconfig.json deleted file mode 100644 index 3b503afab8..0000000000 --- a/packages/capture-viewer/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "@pascal/typescript-config/react-library.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "noEmit": false, - "composite": true, - "incremental": true, - "types": ["bun"] - }, - "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"], - "references": [{ "path": "../capture-protocol" }, { "path": "../core" }, { "path": "../viewer" }] -} diff --git a/packages/cli/README.md b/packages/cli/README.md index 698467cace..db1d3dc3ae 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -17,10 +17,10 @@ for `status`, `logs`, `stop`, and future sessions without another setup step. If global installation is unavailable because of local npm permissions, the editor remains running and the CLI shows the equivalent `npx` commands plus the manual install command. -The first run walks through local storage, runtime installation, automatic editor and -MCP port selection, process startup, and both health checks with live terminal feedback. -It then opens `http://pascal.localhost:`. Your projects are stored separately from -the runtime, so updating the CLI does not replace your work. +The first run walks through local storage, the one-time web runtime download, automatic +editor and MCP port selection, process startup, and both health checks with live terminal +feedback. It then opens `http://pascal.localhost:`. Your projects are stored +separately from the runtime, so updating the CLI does not replace your work. ## Why use the CLI? @@ -36,42 +36,12 @@ the runtime, so updating the CLI does not replace your work. - Node.js 22.13 or newer - npm, including when the CLI itself is launched with pnpm or Bun - A browser, unless you pass `--no-open` +- Network access the first time you start the editor, or a local copy of the web runtime + archive (see [The web editor runtime](#the-web-editor-runtime)); `pascal mcp connect` + needs neither -The initial supported release is macOS. The GitHub preview below also passed a clean -claim-command installation in a Linux arm64 container. This is not an x86_64 or Windows -result. - -## Verified CLI preview - -The npm `beta` tag currently resolves to `@pascal-app/cli@1.0.0-beta.1`, which predates the read-only furniture candidate input and hosted agent claim/status commands in this repository. To use those capabilities before the next npm release, install the verified GitHub prerelease built from commit `5dabbc3b56109c9f79dc8a378443a4c520d9ee0a`: - -```bash -PASCAL_PREVIEW_VERSION='1.0.0-beta.2.status.0' -PASCAL_PREVIEW_PREFIX="${XDG_DATA_HOME:-$HOME/.local/share}/pascal-preview" -PASCAL_PREVIEW_DOWNLOAD="$(mktemp -d)" -cd "$PASCAL_PREVIEW_DOWNLOAD" - -curl --fail --location --remote-name \ - "https://github.com/pascalorg/editor/releases/download/cli-v1.0.0-beta.2-status.0/pascal-app-cli-${PASCAL_PREVIEW_VERSION}.tgz" -curl --fail --location --remote-name \ - "https://github.com/pascalorg/editor/releases/download/cli-v1.0.0-beta.2-status.0/SHA256SUMS.txt" - -# macOS -shasum -a 256 -c SHA256SUMS.txt -# Linux: use `sha256sum -c SHA256SUMS.txt` instead. - -npm install --global --prefix "$PASCAL_PREVIEW_PREFIX" --ignore-scripts \ - "./pascal-app-cli-${PASCAL_PREVIEW_VERSION}.tgz" -export PATH="$PASCAL_PREVIEW_PREFIX/bin:$PATH" -pascal --version -pascal update --version "$PASCAL_PREVIEW_VERSION" -pascal editor --no-open -# For an existing hosted autonomous-agent key: -PASCAL_API_KEY='sk_live_...' pascal agent claim -PASCAL_API_KEY='sk_live_...' pascal agent status --json -``` - -The expected archive SHA-256 is `15628baeeb174fb7786a1643db08f0554bf6d18afaaa3979f01922c5cd40019a`. The same-version `update` command installs and activates this CLI's bundled runtime, restarting an older running service when necessary. Keep an existing `PASCAL_HOME` unchanged so stored projects remain in the same data directory; `pascal editor` alone reuses any healthy service, including an older one. Keep the preview prefix on the agent host's `PATH` before running `pascal mcp setup claude`, `pascal mcp setup codex`, or configuring `pascal mcp connect` manually. `pascal agent claim` opens a prefilled 15-minute human handoff; `pascal agent status` verifies the key and reports the bounded claim state. Neither command stores or prints the hosted key. This GitHub prerelease is not an npm version. +The initial supported release is macOS. A clean claim-command installation also passed in +a Linux arm64 container. This is not an x86_64 or Windows result. Use one active agent client per local CLI service. The standalone local HTTP runtime shares active scene state between clients; use separate `PASCAL_HOME` directories and service processes when independent concurrent work is required. @@ -112,12 +82,57 @@ npx @pascal-app/cli editor --no-open npx @pascal-app/cli editor --foreground --no-open ``` +## The web editor runtime + +The npm package carries the CLI and the MCP service only: about 0.5 MB compressed and +2.5 MB installed. The web editor itselfβ€”the Next.js server, its static assets, and the +bundled item libraryβ€”is published as one archive per CLI version, about 64 MB compressed +and 106 MB on disk. + +Every command that starts the editor (`editor`, `start`, `open`, `resume`, `projects`, +`project open`, `update`) resolves that runtime in this order: + +1. `PASCAL_BUNDLED_RUNTIME_DIR`, an already-extracted runtime directory. +2. `--runtime `, which every one of those commands accepts. +3. The runtime already installed in `~/.pascal/runtime/` for this CLI version. +4. The release asset recorded in the package, streamed into `~/.pascal/tmp` with download + progress in the terminal. + +A downloaded archive is checked against the SHA-256 digest published inside the npm +package before anything is extracted. On a mismatch the CLI deletes the temporary file and +installs nothing, so a corrupted or substituted archive never becomes your runtime. +Concurrent first runs share one download through the runtime install lock. + +An offline or air-gapped machine can take the archive from the release page: + +```bash +# On a connected machine +curl --fail --location --remote-name \ + "https://github.com/pascalorg/editor/releases/download/@pascal-app/cli@/pascal-web-runtime-.tar.gz" + +# On the target machine +pascal editor --runtime ./pascal-web-runtime-.tar.gz +``` + +An archive passed with `--runtime` is digest-verified exactly like a download. A directory +is installed as it is, which is the escape hatch for a runtime you built yourself from this +repository. + +`HTTPS_PROXY` (or `ALL_PROXY`), including a proxy that requires basic authentication, and +`NO_PROXY` are honoured; only `https://` URLs are accepted. When a download fails, the CLI +prints the archive URL, the expected digest, and the `--runtime` command to run after +copying the file across. + +Agent tools need none of this. `pascal mcp connect` starts the MCP service that ships in +the npm package, so an agent can read and write local projects on a machine that has never +downloaded the web runtime. + ## Commands | Command | Purpose | | --- | --- | -| `pascal editor` | Install if needed, ensure the editor is running, and open it. | -| `pascal start` | Ensure the editor is running without opening a browser. | +| `pascal editor [--runtime ]` | Install the web runtime if needed, ensure the editor is running, and open it. | +| `pascal start [--runtime ]` | Ensure the editor is running without opening a browser. | | `pascal stop [--force]` | Stop the managed editor and MCP processes; `--force` is a guarded recovery path. | | `pascal restart` | Restart the editor and MCP service with their current configuration. | | `pascal status [--json]` | Show editor and MCP health, version, PIDs, ports, URL, and runtime metadata. | @@ -125,14 +140,14 @@ npx @pascal-app/cli editor --foreground --no-open | `pascal resume [project]` | Open the latest project, or a selected project. | | `pascal projects [--json]` | List local projects. | | `pascal logs [--follow]` | Read or follow the managed editor log. | -| `pascal update [--version ]` | Health-check and activate the current CLI's bundled runtime or an npm-published target. | +| `pascal update [--version ] [--runtime ]` | Health-check and activate the runtime this CLI publishes, or an npm-published target. | | `pascal doctor [--json]` | Diagnose Node.js, storage, runtime, process, and plugin state. | | `pascal info [--json]` | Print platform, paths, runtime, and plugin context. | | `pascal project list [--json]` | Explicit form of `pascal projects`. | | `pascal project open ` | Explicit form of `pascal open `. | | `pascal agent claim [--no-open] [--json]` | Link an autonomous hosted agent to the person accountable for it. | | `pascal agent status [--json]` | Verify the hosted agent credential and inspect its claim and organization scope. | -| `pascal mcp connect` | Stable local connector for MCP clients; discovers the dynamic managed service. | +| `pascal mcp connect` | Stable local connector for MCP clients; starts the bundled MCP service without the web runtime. | | `pascal mcp status [--json]` | Show managed MCP health. | | `pascal mcp config [--json]` | Print generic MCP client configuration. | | `pascal mcp setup ` | Configure an installed client without overwriting existing entries. | @@ -149,11 +164,13 @@ directory; client configuration never contains that token. ```text ~/.pascal/ - runtime// installed editor runtimes + runtime// installed web editor runtimes data/pascal.db projects and scenes - logs/editor.log detached editor output - run/editor.json managed editor and MCP process identity + logs/editor.log detached editor and MCP output + run/editor.json managed editor process identity + run/mcp.json managed MCP service identity run/mcp-token private local MCP token + tmp/ runtime downloads in progress plugins/ reserved verified-plugin storage pascal.plugins.lock reserved managed-plugin lock ``` @@ -165,17 +182,19 @@ have accumulated. ## Local AI agents -The MCP server starts automatically with `pascal editor`. Add the stable connector to -your client once: +The MCP service ships in the npm package. It starts automatically with `pascal editor`, and +`pascal mcp connect` starts it on its ownβ€”no web runtime download, no editor process. Add +the stable connector to your client once: ```bash pascal mcp setup codex pascal mcp setup claude ``` -Or use `pascal mcp config` for JSON-based clients. The connector also starts Pascal -when an agent connects while it is stopped. Ask the agent to read -`pascal://agent-guide`, list or load a scene, edit it, and return the `editorUrl`. +Or use `pascal mcp config` for JSON-based clients. Ask the agent to read +`pascal://agent-guide`, list or load a scene, edit it, and return the `editorUrl`. Those +`editorUrl` values point at the local editor; run `pascal editor` to open one, which is +also when the web runtime is downloaded. ## Hosted autonomous agents diff --git a/packages/cli/package.json b/packages/cli/package.json index 39693329ee..962a699758 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@pascal-app/cli", - "version": "1.0.0-beta.1", + "version": "1.0.0", "description": "Run the open-source Pascal 3D editor, local projects, and MCP agent tools from your terminal", "type": "module", "bin": { diff --git a/packages/cli/scripts/smoke-packed-runtime.ts b/packages/cli/scripts/smoke-packed-runtime.ts index 763fd39ff4..9db2d6db73 100644 --- a/packages/cli/scripts/smoke-packed-runtime.ts +++ b/packages/cli/scripts/smoke-packed-runtime.ts @@ -1,5 +1,7 @@ import { spawn } from 'node:child_process' -import { mkdtemp, rm } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { copyFile, mkdtemp, open, readFile, rm, stat } from 'node:fs/promises' import http from 'node:http' import os from 'node:os' import path from 'node:path' @@ -11,10 +13,17 @@ const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url const smokeRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-smoke-')) let tarballPath: string | null = null let smokeExecutable: string | null = null +let mcpOnlyExecutable: string | null = null const defaultPortBlocker = http.createServer((_request, response) => { response.setHeader('content-type', 'application/json') response.end(JSON.stringify({ status: 'ok', app: 'foreign' })) }) +/** MCP-only mode is verified in its own home so no web runtime can be installed there. */ +const mcpOnlyEnvironment = { + ...process.env, + PASCAL_HOME: path.join(smokeRoot, 'home-mcp-only'), + PASCAL_NO_OPEN: '1', +} const smokeEnvironment = { ...process.env, PASCAL_HOME: path.join(smokeRoot, 'home'), @@ -31,22 +40,141 @@ try { if (!artifact) throw new Error('npm pack did not return an artifact') tarballPath = path.join(packageDirectory, artifact.filename) enforceArtifactBudget(artifact) + const runtimeArchive = await verifyStagedWebRuntime() const installDirectory = path.join(smokeRoot, 'install') await run('npm', ['install', '--ignore-scripts', '--prefix', installDirectory, tarballPath]) - smokeExecutable = path.join(installDirectory, 'node_modules/@pascal-app/cli/dist/bin/pascal.js') + const executable = path.join(installDirectory, 'node_modules/@pascal-app/cli/dist/bin/pascal.js') + + mcpOnlyExecutable = executable + await checkMcpWithoutWebRuntime(executable) + mcpOnlyExecutable = null + + smokeExecutable = executable + await checkTamperedArchiveIsRejected(executable, runtimeArchive.file) + await checkEditorFromLocalArchive(executable, runtimeArchive.file) + smokeExecutable = null + + console.log( + `Packed CLI smoke passed (${formatMb(artifact.size)} MB compressed, ${formatMb(artifact.unpackedSize)} MB unpacked, ${artifact.entryCount} files).`, + ) + console.log( + `Web runtime archive ${path.basename(runtimeArchive.file)} (${formatMb(runtimeArchive.size)} MB) verified against ${runtimeArchive.url}`, + ) +} finally { + await close(defaultPortBlocker) + for (const [command, environment] of [ + [smokeExecutable, smokeEnvironment], + [mcpOnlyExecutable, mcpOnlyEnvironment], + ] as Array<[string | null, NodeJS.ProcessEnv]>) { + if (!command) continue + await run( + process.execPath, + [command, 'stop', '--force', '--json'], + undefined, + environment, + ).catch(() => undefined) + } + if (tarballPath) await rm(tarballPath, { force: true }) + await rm(smokeRoot, { recursive: true, force: true }) +} + +/** + * Phase 1: agent tools must work on a machine that has never downloaded the web runtime. + */ +async function checkMcpWithoutWebRuntime(executable: string): Promise { + const client = new Client({ name: 'pascal-cli-smoke-mcp-only', version: '0.0.0' }) + const transport = new StdioClientTransport({ + command: process.execPath, + args: [executable, 'mcp', 'connect'], + env: mcpOnlyEnvironment as Record, + stderr: 'pipe', + }) + try { + await client.connect(transport) + const tools = await client.listTools() + if (!tools.tools.some((tool) => tool.name === 'save_scene')) { + throw new Error('MCP-only mode did not expose save_scene') + } + const saved = await client.callTool({ + name: 'save_scene', + arguments: { id: 'mcp-only-project', name: 'MCP only project' }, + }) + if (saved.isError) throw new Error(`MCP-only save_scene failed: ${JSON.stringify(saved)}`) + const listed = await client.callTool({ name: 'list_scenes', arguments: {} }) + if (listed.isError || !JSON.stringify(listed).includes('mcp-only-project')) { + throw new Error(`MCP-only list_scenes failed: ${JSON.stringify(listed)}`) + } + console.log( + `MCP-only mode exposed ${tools.tools.length} tools and stored a scene with no web runtime installed.`, + ) + } finally { + await client.close() + } + const status = JSON.parse( + (await run(process.execPath, [executable, 'status', '--json'], undefined, mcpOnlyEnvironment)) + .stdout, + ) as { installed: boolean; running: boolean; runtime: unknown; mcp: { healthy: boolean } } + if (status.installed || status.runtime !== null || status.running) { + throw new Error('MCP-only mode installed or started the web runtime') + } + if (!status.mcp.healthy) throw new Error('the managed MCP service is not healthy on its own') + const stopped = JSON.parse( + (await run(process.execPath, [executable, 'stop', '--json'], undefined, mcpOnlyEnvironment)) + .stdout, + ) as { stopped: boolean } + if (!stopped.stopped) throw new Error('stop did not report the MCP-only service as stopped') +} + +/** Phase 2: a modified archive must never reach the runtime directory. */ +async function checkTamperedArchiveIsRejected( + executable: string, + archiveFile: string, +): Promise { + const tampered = path.join(smokeRoot, 'tampered-web-runtime.tar.gz') + await copyFile(archiveFile, tampered) + const handle = await open(tampered, 'r+') + try { + const offset = Math.floor((await handle.stat()).size / 2) + const byte = Buffer.alloc(1) + await handle.read(byte, 0, 1, offset) + byte[0] = ((byte[0] ?? 0) ^ 0xff) & 0xff + await handle.write(byte, 0, 1, offset) + } finally { + await handle.close() + } + const failure = await runExpectingFailure( + process.execPath, + [executable, 'editor', '--no-open', '--json', '--runtime', tampered], + smokeEnvironment, + ) + const reported = JSON.parse(failure.stderr) as { error: string; message: string } + if (reported.error !== 'runtime_digest_mismatch') { + throw new Error(`a tampered archive was not rejected: ${failure.stderr}`) + } + await stat(tampered) + const status = JSON.parse( + (await run(process.execPath, [executable, 'status', '--json'], undefined, smokeEnvironment)) + .stdout, + ) as { installed: boolean } + if (status.installed) throw new Error('a tampered archive was installed') + console.log(`Tampered archive rejected: ${reported.message.split('\n')[0]}`) +} +/** Phase 3: the offline install path, then the full editor and MCP flow over that runtime. */ +async function checkEditorFromLocalArchive(executable: string, archiveFile: string): Promise { const started = JSON.parse( ( await run( process.execPath, - [smokeExecutable, 'editor', '--no-open', '--json'], + [executable, 'editor', '--no-open', '--json', '--runtime', archiveFile], undefined, smokeEnvironment, ) ).stdout, - ) as { pid: number; port: number; url: string } + ) as { pid: number; port: number; url: string; mcp: { port: number } } if (started.port === 3000) throw new Error('editor reused the occupied default port') + if (!started.mcp?.port) throw new Error('the editor did not report a managed MCP port') const rootResponse = await fetch(`http://127.0.0.1:${started.port}/`) if (!rootResponse.ok) throw new Error(`editor root returned ${rootResponse.status}`) const scenesResponse = await fetch(`${started.url}/scenes`) @@ -55,7 +183,7 @@ try { ( await run( process.execPath, - [smokeExecutable, 'editor', '--no-open', '--port', '0', '--json'], + [executable, 'editor', '--no-open', '--port', '0', '--json'], undefined, smokeEnvironment, ) @@ -70,7 +198,7 @@ try { } const humanStart = await run( process.execPath, - [smokeExecutable, 'editor', '--no-open'], + [executable, 'editor', '--no-open'], undefined, smokeEnvironment, ) @@ -82,13 +210,13 @@ try { } await run( process.execPath, - [smokeExecutable, 'project', 'list', '--json'], + [executable, 'project', 'list', '--json'], undefined, smokeEnvironment, ) const mcpTransport = new StdioClientTransport({ command: process.execPath, - args: [smokeExecutable, 'mcp', 'connect'], + args: [executable, 'mcp', 'connect'], env: smokeEnvironment as Record, stderr: 'pipe', }) @@ -111,7 +239,7 @@ try { ( await run( process.execPath, - [smokeExecutable, 'resume', 'Smoke project', '--json'], + [executable, 'resume', 'Smoke project', '--json'], undefined, smokeEnvironment, ) @@ -120,25 +248,57 @@ try { if (resumed.project.id !== 'smoke-project' || !resumed.url.endsWith('/scene/smoke-project')) { throw new Error('CLI project resume did not resolve the MCP-saved project') } - await run(process.execPath, [smokeExecutable, 'doctor', '--json'], undefined, smokeEnvironment) - await run(process.execPath, [smokeExecutable, 'stop', '--json'], undefined, smokeEnvironment) - smokeExecutable = null - console.log( - `Packed runtime smoke passed (${formatMb(artifact.size)} MB compressed, ${formatMb(artifact.unpackedSize)} MB unpacked, ${artifact.entryCount} files).`, + `Editor installed from ${path.basename(archiveFile)} on port ${started.port}, MCP on port ${started.mcp.port}, and a scene round-tripped between MCP and the CLI.`, ) -} finally { - await close(defaultPortBlocker) - if (smokeExecutable) { - await run( - process.execPath, - [smokeExecutable, 'stop', '--force', '--json'], - undefined, - smokeEnvironment, - ).catch(() => undefined) + await run(process.execPath, [executable, 'doctor', '--json'], undefined, smokeEnvironment) + await run(process.execPath, [executable, 'stop', '--json'], undefined, smokeEnvironment) +} + +async function verifyStagedWebRuntime(): Promise<{ file: string; size: number; url: string }> { + const source = JSON.parse( + await readFile(path.join(packageDirectory, 'dist/runtime-source.json'), 'utf8'), + ) as { version: string; url: string; sha256: string; size: number } + const packageVersion = ( + JSON.parse(await readFile(path.join(packageDirectory, 'package.json'), 'utf8')) as { + version: string + } + ).version + if (source.version !== packageVersion) { + throw new Error(`dist/runtime-source.json targets ${source.version}, not ${packageVersion}`) } - if (tarballPath) await rm(tarballPath, { force: true }) - await rm(smokeRoot, { recursive: true, force: true }) + const archiveName = `pascal-web-runtime-${packageVersion}.tar.gz` + const expectedUrl = `https://github.com/pascalorg/editor/releases/download/@pascal-app/cli@${packageVersion}/${archiveName}` + if (source.url !== expectedUrl) { + throw new Error(`dist/runtime-source.json points at ${source.url}, not ${expectedUrl}`) + } + const file = path.join(packageDirectory, 'build', archiveName) + const { size } = await stat(file) + if (size !== source.size) { + throw new Error(`${archiveName} is ${size} bytes; runtime-source.json records ${source.size}`) + } + const maximumArchiveSize = 70 * 1024 * 1024 + if (size > maximumArchiveSize) { + throw new Error( + `the web runtime archive exceeds its release budget: ${formatMb(size)} MB > ${formatMb(maximumArchiveSize)} MB`, + ) + } + const digestFile = `${file}.sha256` + const recordedDigest = (await readFile(digestFile, 'utf8')).trim().split(/\s+/)[0] + if (recordedDigest !== source.sha256) { + throw new Error(`${digestFile} does not match dist/runtime-source.json`) + } + const hashed = await sha256(file) + if (hashed !== source.sha256) { + throw new Error(`${archiveName} hashes to ${hashed}, not the published ${source.sha256}`) + } + return { file, size, url: source.url } +} + +async function sha256(filePath: string): Promise { + const hash = createHash('sha256') + for await (const chunk of createReadStream(filePath)) hash.update(chunk as Buffer) + return hash.digest('hex') } async function listen(server: http.Server): Promise { @@ -164,14 +324,18 @@ interface PackedArtifact { entryCount: number } +/** + * The npm package carries the CLI and the MCP service only. The web runtime rides a GitHub + * release asset, so both budgets are enforced separately. + */ function enforceArtifactBudget(artifact: { size: number unpackedSize: number entryCount: number }): void { - const maximumSize = 105 * 1024 * 1024 - const maximumUnpackedSize = 160 * 1024 * 1024 - const maximumEntryCount = 4_000 + const maximumSize = 3 * 1024 * 1024 + const maximumUnpackedSize = 10 * 1024 * 1024 + const maximumEntryCount = 250 if ( artifact.size > maximumSize || artifact.unpackedSize > maximumUnpackedSize || @@ -189,6 +353,31 @@ async function run( cwd?: string, env: NodeJS.ProcessEnv = process.env, ): Promise<{ stdout: string; stderr: string }> { + const result = await capture(command, args, cwd, env) + if (result.exitCode !== 0) { + throw new Error(`${command} ${args.join(' ')} failed (${result.exitCode}): ${result.stderr}`) + } + return result +} + +async function runExpectingFailure( + command: string, + args: string[], + env: NodeJS.ProcessEnv, +): Promise<{ stdout: string; stderr: string }> { + const result = await capture(command, args, undefined, env) + if (result.exitCode === 0) { + throw new Error(`${command} ${args.join(' ')} succeeded but should have failed`) + } + return result +} + +async function capture( + command: string, + args: string[], + cwd?: string, + env: NodeJS.ProcessEnv = process.env, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { const executable = process.platform === 'win32' && command === 'npm' ? 'npm.cmd' : command const child = spawn(executable, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }) const stdout: Buffer[] = [] @@ -199,14 +388,11 @@ async function run( child.once('error', reject) child.once('exit', (code) => resolve(code ?? 1)) }) - const result = { + return { + exitCode, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), } - if (exitCode !== 0) { - throw new Error(`${command} ${args.join(' ')} failed (${exitCode}): ${result.stderr}`) - } - return result } function formatMb(bytes: number): string { diff --git a/packages/cli/scripts/stage-runtime.ts b/packages/cli/scripts/stage-runtime.ts index 69e27591d9..c973558fdd 100644 --- a/packages/cli/scripts/stage-runtime.ts +++ b/packages/cli/scripts/stage-runtime.ts @@ -1,14 +1,58 @@ import { spawn } from 'node:child_process' -import { chmod, cp, mkdir, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { + chmod, + cp, + mkdir, + readdir, + readFile, + realpath, + rm, + stat, + writeFile, +} from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { fileSha256 } from '../src/runtime-download.js' +import { createRuntimeArchive } from '../src/tar.js' const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const repositoryRoot = path.resolve(packageDirectory, '../..') const appDirectory = path.join(repositoryRoot, 'apps/editor') const standaloneDirectory = path.join(appDirectory, '.next/standalone') const standaloneAppDirectory = path.join(standaloneDirectory, 'apps/editor') -const outputDirectory = path.join(packageDirectory, 'dist/runtime') +/** + * The web runtime is a release asset, not part of the npm package: it is staged and archived + * under `build/`, while `dist/` only gains the MCP service and the digest of that archive. + */ +const buildDirectory = path.join(packageDirectory, 'build') +const outputDirectory = path.join(buildDirectory, 'runtime') +const releaseAssetBaseUrl = 'https://github.com/pascalorg/editor/releases/download' + +/** + * `next build` copies its tracing root into `.next/standalone`, so the portable runtime + * inherits app sources, repository documentation and build-time-only assets that + * `server.js` never reads. Every entry below was checked against the staged tree: nothing + * in `.next`, `node_modules` or the bundled MCP server resolves it. + */ +const buildOnlyRuntimePaths = [ + 'apps/editor/app', + 'apps/editor/components', + 'apps/editor/lib', + 'apps/editor/AGENTS.md', + 'apps/editor/CLAUDE.md', + 'apps/editor/README.md', + 'apps/editor/bunfig.toml', + 'apps/editor/next.config.ts', + 'apps/editor/postcss.config.mjs', + 'apps/editor/tsconfig.json', + 'apps/editor/vercel.json', + // The radio catalogue is played by the hosted community app, which serves its own copy. + 'apps/editor/public/audios/radios', + // `next/dist/server/font-utils.js` is the sole reader of these font metrics and is + // itself unreachable from the standalone server. + 'node_modules/next/dist/server/capsize-font-metrics.json', + 'node_modules/next/dist/server/font-utils.js', +] const packageJson = JSON.parse( await readFile(path.join(packageDirectory, 'package.json'), 'utf8'), @@ -16,7 +60,15 @@ const packageJson = JSON.parse( version: string } +const archiveName = `pascal-web-runtime-${packageJson.version}.tar.gz` +const archiveFile = path.join(buildDirectory, archiveName) +const assetUrl = `${releaseAssetBaseUrl}/@pascal-app/cli@${packageJson.version}/${archiveName}` + await chmod(path.join(packageDirectory, 'dist/bin/pascal.js'), 0o755) +await bundleMcpServer( + path.join(packageDirectory, 'dist/services/pascal-mcp.mjs'), + packageJson.version, +) await assertFile(path.join(standaloneAppDirectory, 'server.js')) await rm(outputDirectory, { recursive: true, force: true }) await mkdir(path.dirname(outputDirectory), { recursive: true }) @@ -31,12 +83,12 @@ await cp( path.join(outputDirectory, 'apps/editor/.next/static'), { recursive: true, force: true }, ) -await bundleMcpServer(outputDirectory, packageJson.version) - +await rm(path.join(outputDirectory, 'apps/editor/vendor'), { recursive: true, force: true }) await removeUnusedSharp(outputDirectory) await flattenBunNodeModules(outputDirectory) await materializeSymlinks(outputDirectory) await rm(path.join(outputDirectory, 'node_modules/.bun'), { recursive: true, force: true }) +await pruneBuildOnlyFiles(outputDirectory) const nativeFiles = await findNativeModules(outputDirectory) if (nativeFiles.length > 0) { throw new Error(`portable runtime contains native modules:\n${nativeFiles.join('\n')}`) @@ -45,23 +97,32 @@ if (nativeFiles.length > 0) { await writeFile( path.join(outputDirectory, 'runtime-manifest.json'), `${JSON.stringify( - { - schemaVersion: 1, - version: packageJson.version, - entrypoint: 'apps/editor/server.js', - mcpEntrypoint: 'services/pascal-mcp.mjs', - healthPath: '/api/health', - mcpHealthPath: '/health', - }, + { schemaVersion: 2, version: packageJson.version, entrypoint: 'apps/editor/server.js' }, + null, + 2, + )}\n`, +) + +const archive = await createRuntimeArchive(outputDirectory, archiveFile) +const sha256 = await fileSha256(archiveFile) +await writeFile(`${archiveFile}.sha256`, `${sha256} ${archiveName}\n`) +await writeFile( + path.join(packageDirectory, 'dist/runtime-source.json'), + `${JSON.stringify( + { version: packageJson.version, url: assetUrl, sha256, size: archive.size }, null, 2, )}\n`, ) -console.log(`Staged Pascal editor runtime ${packageJson.version} at ${outputDirectory}`) +console.log(`Staged Pascal web runtime ${packageJson.version} at ${outputDirectory}`) +console.log( + `Archived ${archive.entryCount} entries to ${archiveFile} (${formatMegabytes(archive.size)} MB)`, +) +console.log(`Digest ${sha256}`) +console.log(`Release asset ${assetUrl}`) -async function bundleMcpServer(runtimeDirectory: string, version: string): Promise { - const output = path.join(runtimeDirectory, 'services/pascal-mcp.mjs') +async function bundleMcpServer(output: string, version: string): Promise { await mkdir(path.dirname(output), { recursive: true }) const child = spawn( process.execPath, @@ -100,6 +161,70 @@ async function assertFile(filePath: string): Promise { } } +async function pruneBuildOnlyFiles(root: string): Promise { + await Promise.all( + buildOnlyRuntimePaths.map((relative) => + rm(path.join(root, relative), { recursive: true, force: true }), + ), + ) + await removeStrayItemAssets(path.join(root, 'apps/editor/public/items')) + await removeTraceArtifacts(path.join(root, 'apps/editor/.next')) +} + +/** + * Item directories are addressed by convention (`model.glb`, `thumbnail.*`, `floor-plan.*`). + * Anything else is an authoring leftover, so it is dropped and named on stdout: a future + * asset that does not follow the convention has to be reported rather than silently lost. + */ +async function removeStrayItemAssets(itemsDirectory: string): Promise { + let entries + try { + entries = await readdir(itemsDirectory, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + return + } + const isConventional = (name: string): boolean => + name === 'model.glb' || name.startsWith('thumbnail.') || name.startsWith('floor-plan.') + for (const entry of entries) { + if (!entry.isDirectory()) continue + const itemDirectory = path.join(itemsDirectory, entry.name) + for (const asset of await readdir(itemDirectory, { withFileTypes: true })) { + if (!asset.isFile() || isConventional(asset.name)) continue + const assetPath = path.join(itemDirectory, asset.name) + const { size } = await stat(assetPath) + await rm(assetPath, { force: true }) + console.log( + `Dropped unreferenced item asset ${entry.name}/${asset.name} (${formatMegabytes(size)} MB)`, + ) + } + } +} + +function formatMegabytes(bytes: number): string { + return (bytes / 1024 / 1024).toFixed(2) +} + +async function removeTraceArtifacts(nextDirectory: string): Promise { + const walk = async (directory: string): Promise => { + let entries + try { + entries = await readdir(directory, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + return + } + for (const entry of entries) { + const absolute = path.join(directory, entry.name) + if (entry.isDirectory()) await walk(absolute) + else if (entry.name.endsWith('.nft.json') || entry.name.endsWith('.map')) { + await rm(absolute, { force: true }) + } + } + } + await walk(nextDirectory) +} + async function removeUnusedSharp(root: string): Promise { const nodeModules = path.join(root, 'node_modules') await rm(path.join(nodeModules, 'sharp'), { recursive: true, force: true }) diff --git a/packages/cli/src/bin/pascal.ts b/packages/cli/src/bin/pascal.ts index 9cc24f472d..9cf8e4dbfe 100755 --- a/packages/cli/src/bin/pascal.ts +++ b/packages/cli/src/bin/pascal.ts @@ -18,9 +18,10 @@ import { import { CliError, toCliError } from '../errors.js' import { readJsonFile } from '../json-files.js' import { connectManagedMcp } from '../mcp-connector.js' +import { getMcpServiceStatus } from '../mcp-service.js' import { resolvePascalPaths } from '../paths.js' import { listLocalProjects, projectUrl, resolveLocalProject } from '../projects.js' -import { installBundledRuntime } from '../runtime.js' +import { ensureWebRuntime } from '../runtime-download.js' import { TerminalProgress } from '../terminal-progress.js' import { version } from '../version.js' @@ -38,8 +39,8 @@ ENABLE THE SHORT GLOBAL COMMAND: pascal USAGE: - pascal editor [--foreground] [--no-open] [--port ] - pascal start [--foreground] [--port ] + pascal editor [--foreground] [--no-open] [--port ] [--runtime ] + pascal start [--foreground] [--port ] [--runtime ] pascal stop | restart | status pascal open [project] pascal resume [project] @@ -56,12 +57,21 @@ USAGE: pascal mcp connect | status | config | setup pascal plugin list [--json] +THE WEB EDITOR RUNTIME: + The npm package holds the CLI and the MCP service. The web editor runtime is + downloaded once per version into ~/.pascal/runtime the first time a command + starts the editor, and verified against a digest published with this CLI. + Offline: pass --runtime . "pascal mcp connect" needs no + download at all. + Documentation: https://editor.pascal.app/docs/developers/local-editor ` const MCP_HELP = `Pascal MCP β€” connect AI agents to local projects -The authenticated MCP service starts and stops with the Pascal editor. +The authenticated MCP service ships inside this package. It starts on demand and +needs neither the web editor nor its downloaded runtime, so agents can read and +write local projects on a machine that never runs the editor. USAGE: pascal mcp status [--json] Check the managed MCP service @@ -142,7 +152,7 @@ async function main(): Promise { case 'mcp': return runMcp(args) case '_install-runtime': - return output(true, await installBundledRuntime(paths, undefined, { activate: false }), '') + return output(true, (await ensureWebRuntime({ paths, activate: false })).runtime, '') default: throw new CliError('unknown_command', `Unknown command: ${command}`, { command }, 2) } @@ -157,6 +167,7 @@ async function runStart(args: string[], shouldOpen: boolean): Promise { open: { type: 'boolean', default: shouldOpen }, 'no-open': { type: 'boolean', default: false }, port: { type: 'string' }, + runtime: { type: 'string' }, json: { type: 'boolean', default: false }, help: { type: 'boolean', short: 'h', default: false }, }, @@ -171,7 +182,8 @@ async function runStart(args: string[], shouldOpen: boolean): Promise { paths, port, foreground: values.foreground, - onProgress: progress ? (event) => reportStartProgress(progress, event) : undefined, + runtimeSource: values.runtime, + onProgress: progress ? createStartProgressReporter(progress) : undefined, }) } catch (error) { progress?.stop() @@ -197,12 +209,12 @@ async function runStart(args: string[], shouldOpen: boolean): Promise { const commandPrefix = useShortCommand ? 'pascal' : 'npx @pascal-app/cli' output( values.json, - { ...result.state, alreadyRunning: result.alreadyRunning }, + { ...result.state, mcp: result.mcp, alreadyRunning: result.alreadyRunning }, [ result.alreadyRunning ? `Pascal is already running at ${result.state.url}` : `Pascal is ready at ${result.state.url}`, - `MCP is ready on port ${result.state.mcp?.port}`, + `MCP is ready on port ${result.mcp.port}`, `Projects stay in ${paths.data}`, '', `Manage it with ${useShortCommand ? 'pascal' : 'npx'}:`, @@ -232,11 +244,54 @@ async function runStart(args: string[], shouldOpen: boolean): Promise { } } +/** + * Download progress arrives far more often than a non-TTY log should print, so percentages + * are reported per whole percent on a terminal and per tenth otherwise. + */ +function createStartProgressReporter( + progress: TerminalProgress, +): (event: EditorStartProgress) => void { + const perPercent = Boolean(process.stderr.isTTY) + let lastReportedStep = -1 + return (event) => { + if (event.step !== 'runtime-downloading') return reportStartProgress(progress, event) + if (event.received === 0) { + lastReportedStep = -1 + progress.start(`Downloading the editor runtime from ${event.url}`) + return + } + const percent = event.total + ? Math.min(100, Math.floor((event.received / event.total) * 100)) + : 0 + const step = perPercent ? percent : Math.floor(percent / 10) + if (step === lastReportedStep) return + lastReportedStep = step + progress.update( + event.total + ? `Downloading the editor runtime ${percent}% (${formatMegabytes(event.received)} of ${formatMegabytes(event.total)})` + : `Downloading the editor runtime (${formatMegabytes(event.received)})`, + ) + } +} + +function formatMegabytes(bytes: number): string { + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} + function reportStartProgress(progress: TerminalProgress, event: EditorStartProgress): void { switch (event.step) { case 'storage-ready': progress.succeed(`Local data directory ready at ${event.dataDirectory}`) return + case 'runtime-downloading': + progress.update('Downloading the editor runtime') + return + case 'runtime-verifying': + progress.update('Verifying the editor runtime digest') + return + case 'runtime-extracting': + progress.update('Extracting the editor runtime') + return case 'runtime-installing': progress.start('Installing the editor runtime') return @@ -271,6 +326,12 @@ function reportStartProgress(progress: TerminalProgress, event: EditorStartProgr case 'mcp-health-checking': progress.update('Checking that MCP is ready') return + case 'mcp-ready': + progress.succeed(`MCP is ready on port ${event.port}`) + return + case 'mcp-already-running': + progress.succeed(`MCP is already running on port ${event.port}`) + return case 'ready': progress.succeed('Pascal Editor and MCP are ready') return @@ -300,20 +361,20 @@ async function runRestart(args: string[]): Promise { async function runStatus(args: string[]): Promise { const json = booleanOption(args, 'json') - const status = await getEditorStatus(paths) + const [status, mcp] = await Promise.all([getEditorStatus(paths), getMcpServiceStatus(paths)]) output( json, - status, + { ...status, mcp }, status.healthy ? [ `Pascal ${status.state?.version} is running at ${status.state?.url}`, - `MCP is ready on port ${status.state?.mcp?.port}`, + mcp.healthy ? `MCP is ready on port ${mcp.state?.port}` : 'MCP is stopped.', ].join('\n') : status.running ? 'Pascal has a running but unhealthy process.' : status.installed ? `Pascal ${status.runtime?.version} is installed and stopped.` - : 'Pascal is not installed.', + : 'The Pascal web runtime is not installed yet.', ) if (status.running && !status.healthy) process.exitCode = 1 } @@ -323,13 +384,13 @@ async function runOpen(args: string[]): Promise { args, strict: true, allowPositionals: true, - options: { json: { type: 'boolean', default: false } }, + options: { json: { type: 'boolean', default: false }, runtime: { type: 'string' } }, }) if (positionals.length > 1) { throw new CliError('invalid_option', 'Use "pascal open [project]".', undefined, 2) } if (positionals[0]) return runProjectOpen(args, false) - const status = await ensureRunningEditor() + const status = await ensureRunningEditor(values.runtime) openBrowser(status.state.url) output(values.json, { url: status.state.url }, status.state.url) } @@ -377,9 +438,9 @@ async function runInfo(args: string[]): Promise { `CLI: ${version}`, `Node: ${info.cli.node}`, `Home: ${paths.root}`, - `Runtime: ${info.editor.runtime?.version ?? 'not installed'}`, + `Web runtime: ${info.editor.runtime?.version ?? 'not installed'}`, `Editor: ${info.editor.healthy ? info.editor.state?.url : 'stopped'}`, - `MCP: ${info.editor.components.mcp.healthy ? `ready on port ${info.editor.state?.mcp?.port}` : 'stopped'}`, + `MCP: ${info.mcp.healthy ? `ready on port ${info.mcp.state?.port}` : 'stopped'}`, `Plugins: ${info.plugins.length}`, ].join('\n'), ) @@ -389,7 +450,11 @@ async function runUpdate(args: string[]): Promise { const { values } = parseArgs({ args, strict: true, - options: { version: { type: 'string' }, json: { type: 'boolean', default: false } }, + options: { + version: { type: 'string' }, + runtime: { type: 'string' }, + json: { type: 'boolean', default: false }, + }, }) const target = values.version ?? 'latest' if (!isAllowedUpdateVersion(target)) { @@ -402,7 +467,8 @@ async function runUpdate(args: string[]): Promise { } let candidate if (target === version) { - candidate = await installBundledRuntime(paths, undefined, { activate: false }) + candidate = (await ensureWebRuntime({ paths, runtimeSource: values.runtime, activate: false })) + .runtime } else { const spec = `@pascal-app/cli@${target}` const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm' @@ -457,11 +523,15 @@ async function runUpdate(args: string[]): Promise { async function runProject(args: string[]): Promise { const [subcommand, ...rest] = args if (subcommand === 'list') { - const json = booleanOption(rest, 'json') - const status = await ensureRunningEditor() + const { values } = parseArgs({ + args: rest, + strict: true, + options: { json: { type: 'boolean', default: false }, runtime: { type: 'string' } }, + }) + const status = await ensureRunningEditor(values.runtime) const projects = await listLocalProjects(status.state) output( - json, + values.json, { projects }, projects.length ? projects @@ -493,7 +563,7 @@ async function runProjectOpen(args: string[], latestWhenMissing: boolean): Promi args, strict: true, allowPositionals: true, - options: { json: { type: 'boolean', default: false } }, + options: { json: { type: 'boolean', default: false }, runtime: { type: 'string' } }, }) if (positionals.length > 1 || (!latestWhenMissing && positionals.length !== 1)) { throw new CliError( @@ -503,7 +573,7 @@ async function runProjectOpen(args: string[], latestWhenMissing: boolean): Promi 2, ) } - const status = await ensureRunningEditor() + const status = await ensureRunningEditor(values.runtime) const projects = await listLocalProjects(status.state) const project = resolveLocalProject(projects, positionals[0]) const url = projectUrl(status.state, project) @@ -522,11 +592,11 @@ async function runMcp(args: string[]): Promise { } if (subcommand === 'status') { const json = booleanOption(rest, 'json') - const status = await getEditorStatus(paths) + const status = await getMcpServiceStatus(paths) const result = { - running: status.components.mcp.running, - healthy: status.components.mcp.healthy, - port: status.state?.mcp?.port ?? null, + running: status.running, + healthy: status.healthy, + port: status.state?.port ?? null, } output( json, @@ -535,7 +605,7 @@ async function runMcp(args: string[]): Promise { ? `Pascal MCP is ready on port ${result.port}.` : result.running ? 'Pascal MCP is running but unhealthy.' - : 'Pascal MCP is stopped.', + : 'Pascal MCP is stopped. It starts when an MCP client runs "pascal mcp connect".', ) if (result.running && !result.healthy) process.exitCode = 1 return @@ -684,10 +754,20 @@ async function runPlugin(args: string[]): Promise { ) } -async function ensureRunningEditor() { +async function ensureRunningEditor(runtimeSource?: string) { const status = await getEditorStatus(paths) if (status.healthy && status.state) return { ...status, state: status.state } - const started = await startEditor({ paths }) + const progress = process.stderr.isTTY ? new TerminalProgress() : undefined + let started: Awaited> + try { + started = await startEditor({ + paths, + runtimeSource, + onProgress: progress ? createStartProgressReporter(progress) : undefined, + }) + } finally { + progress?.stop() + } return { ...(await getEditorStatus(paths)), state: started.state, diff --git a/packages/cli/src/diagnostics.ts b/packages/cli/src/diagnostics.ts index b499341b1a..7b91ffdb7d 100644 --- a/packages/cli/src/diagnostics.ts +++ b/packages/cli/src/diagnostics.ts @@ -2,6 +2,7 @@ import { constants } from 'node:fs' import { access, readdir, stat } from 'node:fs/promises' import { ensurePascalDirectories, getEditorStatus } from './editor-process.js' import { readJsonFile } from './json-files.js' +import { getMcpServiceStatus } from './mcp-service.js' import type { PascalPaths } from './paths.js' export interface DiagnosticCheck { @@ -46,39 +47,31 @@ export async function runDoctor(paths: PascalPaths): Promise }) } try { - const status = await getEditorStatus(paths) + const [status, mcp] = await Promise.all([getEditorStatus(paths), getMcpServiceStatus(paths)]) checks.push({ id: 'runtime', status: status.installed ? 'pass' : 'warn', message: status.runtime - ? `Installed runtime ${status.runtime.version}` - : 'No runtime installed yet.', + ? `Installed web runtime ${status.runtime.version}` + : 'No web runtime installed yet. It downloads when the editor first starts.', }) checks.push({ id: 'editor', - status: status.components.editor.healthy - ? 'pass' - : status.components.editor.running - ? 'fail' - : 'warn', - message: status.components.editor.healthy + status: status.healthy ? 'pass' : status.running ? 'fail' : 'warn', + message: status.healthy ? `Healthy at ${status.state?.url}` - : status.components.editor.running + : status.running ? 'A recorded editor process is running but unhealthy.' : 'The editor is stopped.', }) checks.push({ id: 'mcp', - status: status.components.mcp.healthy - ? 'pass' - : status.components.mcp.running - ? 'fail' - : 'warn', - message: status.components.mcp.healthy - ? `MCP is healthy on loopback port ${status.state?.mcp?.port}.` - : status.components.mcp.running + status: mcp.healthy ? 'pass' : mcp.running ? 'fail' : 'warn', + message: mcp.healthy + ? `MCP is healthy on loopback port ${mcp.state?.port}.` + : mcp.running ? 'The managed MCP process is running but unhealthy.' - : 'MCP is stopped with the editor.', + : 'MCP is stopped. "pascal mcp connect" starts it on demand.', }) const runtimeVersions = (await readdir(paths.runtime, { withFileTypes: true })) .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) @@ -123,14 +116,16 @@ function errorMessage(error: unknown): string { export async function collectInfo(paths: PascalPaths) { await ensurePascalDirectories(paths) - const [status, runtimeVersions, pluginLock] = await Promise.all([ + const [status, mcp, runtimeVersions, pluginLock] = await Promise.all([ getEditorStatus(paths), + getMcpServiceStatus(paths), readdir(paths.runtime).catch(() => [] as string[]), readJsonFile<{ schemaVersion?: number; plugins?: unknown[] }>(paths.pluginLock), ]) return { cli: { node: process.versions.node, platform: process.platform, arch: process.arch }, editor: status, + mcp, paths, runtimes: runtimeVersions.filter((entry) => !entry.startsWith('.')).sort(), plugins: pluginLock?.plugins ?? [], diff --git a/packages/cli/src/editor-process.ts b/packages/cli/src/editor-process.ts index 8017105310..0c4d260bc0 100644 --- a/packages/cli/src/editor-process.ts +++ b/packages/cli/src/editor-process.ts @@ -1,20 +1,33 @@ -import { type ChildProcess, execFile, spawn } from 'node:child_process' -import { randomBytes, randomUUID } from 'node:crypto' +import { type ChildProcess, spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' import { closeSync, openSync } from 'node:fs' -import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' -import net from 'node:net' +import { mkdir, open, rename, rm, stat } from 'node:fs/promises' import path from 'node:path' import { CliError } from './errors.js' import { withFileLock } from './file-lock.js' import { readJsonFile, writeJsonFile } from './json-files.js' +import { + ensureMcpService, + type McpServiceState, + type McpStartProgress, + stopMcpService, +} from './mcp-service.js' import type { PascalPaths } from './paths.js' +import { + errorMessage, + findAvailablePort, + isProcessRunning, + processCommand, + terminateProcess, + waitForSpawn, +} from './process-control.js' import { type ActiveRuntime, activateRuntime, - installBundledRuntime, readActiveRuntime, readRuntimeManifest, } from './runtime.js' +import { ensureWebRuntime, type RuntimeProvisionProgress } from './runtime-download.js' export interface EditorState { schemaVersion: 1 @@ -26,14 +39,6 @@ export interface EditorState { instanceId: string runtimeDirectory: string startedAt: string - mcp?: McpState -} - -export interface McpState { - pid: number - port: number - host: '127.0.0.1' - url: string } export interface EditorStatus { @@ -42,35 +47,31 @@ export interface EditorStatus { healthy: boolean state: EditorState | null runtime: ActiveRuntime | null - components: { - editor: { running: boolean; healthy: boolean } - mcp: { running: boolean; healthy: boolean } - } } export interface StartEditorOptions { paths: PascalPaths port?: number foreground?: boolean - sourceDirectory?: string + /** A web-runtime directory or `.tar.gz` archive to install instead of downloading one. */ + runtimeSource?: string onProgress?: (event: EditorStartProgress) => void } export type EditorStartProgress = | { step: 'storage-ready'; dataDirectory: string } - | { step: 'runtime-installing' } | { step: 'runtime-ready'; version: string; installed: boolean } | { step: 'port-ready'; port: number; preferredPort: number } | { step: 'process-starting'; port: number } | { step: 'health-checking'; port: number } - | { step: 'mcp-port-ready'; port: number } - | { step: 'mcp-starting'; port: number } - | { step: 'mcp-health-checking'; port: number } | { step: 'ready'; port: number } | { step: 'already-running'; port: number } + | RuntimeProvisionProgress + | McpStartProgress export interface StartEditorResult { state: EditorState + mcp: McpServiceState alreadyRunning: boolean child?: ChildProcess } @@ -86,8 +87,8 @@ export interface RuntimeActivationResult { export async function ensurePascalDirectories(paths: PascalPaths): Promise { await Promise.all( - [paths.root, paths.runtime, paths.data, paths.plugins, paths.run, paths.logs].map((directory) => - mkdir(directory, { recursive: true, mode: 0o700 }), + [paths.root, paths.runtime, paths.data, paths.plugins, paths.run, paths.logs, paths.tmp].map( + (directory) => mkdir(directory, { recursive: true, mode: 0o700 }), ), ) } @@ -98,34 +99,15 @@ export async function getEditorStatus(paths: PascalPaths): Promise readJsonFile(paths.state), ]) if (state?.schemaVersion !== 1 || typeof state.pid !== 'number') { - return { - installed: Boolean(runtime), - running: false, - healthy: false, - state: null, - runtime, - components: { - editor: { running: false, healthy: false }, - mcp: { running: false, healthy: false }, - }, - } + return { installed: Boolean(runtime), running: false, healthy: false, state: null, runtime } } - const editorRunning = isProcessRunning(state.pid) - const mcpRunning = Boolean(state.mcp && isProcessRunning(state.mcp.pid)) - const [editorHealthy, mcpHealthy] = await Promise.all([ - editorRunning ? checkHealth(state) : false, - mcpRunning ? checkMcpHealth(paths, state) : false, - ]) + const running = isProcessRunning(state.pid) return { installed: Boolean(runtime), - running: editorRunning || mcpRunning, - healthy: editorHealthy && mcpHealthy, + running, + healthy: running ? await checkHealth(state) : false, state, runtime, - components: { - editor: { running: editorRunning, healthy: editorHealthy }, - mcp: { running: mcpRunning, healthy: mcpHealthy }, - }, } } @@ -136,7 +118,6 @@ export async function startEditor(options: StartEditorOptions): Promise { await ensurePascalDirectories(options.paths) options.onProgress?.({ step: 'storage-ready', dataDirectory: options.paths.data }) - let installedRuntime = false let currentStatus: EditorStatus try { currentStatus = await getEditorStatus(options.paths) @@ -144,32 +125,35 @@ async function startEditorUnlocked(options: StartEditorOptions): Promise { - return withEditorLifecycleLock(paths, () => stopEditorUnlocked(paths, options)) + const editorStopped = await withEditorLifecycleLock(paths, () => + stopEditorUnlocked(paths, options), + ) + const mcpStopped = await stopMcpService(paths, options) + return editorStopped || mcpStopped } async function stopEditorUnlocked( @@ -293,34 +244,23 @@ async function stopEditorUnlocked( options: StopEditorOptions = {}, ): Promise { const state = await readJsonFile(paths.state) - const editorRunning = Boolean(state && isProcessRunning(state.pid)) - const mcpRunning = Boolean(state?.mcp && isProcessRunning(state.mcp.pid)) - if (!state || (!editorRunning && !mcpRunning)) { + if (!state || !isProcessRunning(state.pid)) { await rm(paths.state, { force: true }) - await rm(paths.mcpToken, { force: true }) return false } - const [editorHealthy, mcpHealthy] = await Promise.all([ - editorRunning ? checkHealth(state) : true, - mcpRunning ? checkMcpHealth(paths, state) : true, - ]) - const editorIdentified = - editorHealthy || - (options.force && editorRunning && (await matchesRecordedEditorProcess(paths, state))) - const mcpIdentified = - mcpHealthy || (options.force && mcpRunning && (await matchesRecordedMcpProcess(paths, state))) - if (!editorIdentified || !mcpIdentified) { + const identified = + (await checkHealth(state)) || + (options.force && (await matchesRecordedEditorProcess(paths, state))) + if (!identified) { throw new CliError( 'state_conflict', options.force ? 'Refusing to stop a process whose health identity and operating-system command do not match the recorded Pascal runtime.' - : 'A Pascal process identity is unavailable. Inspect "pascal status --json", then use "pascal stop --force" only if the recorded commands are trusted.', + : 'The Pascal editor identity is unavailable. Inspect "pascal status --json", then use "pascal stop --force" only if the recorded command is trusted.', ) } - if (mcpRunning && state.mcp) await terminateProcess(state.mcp.pid) - if (editorRunning) await terminateProcess(state.pid) + await terminateProcess(state.pid) await rm(paths.state, { force: true }) - await rm(paths.mcpToken, { force: true }) return true } @@ -348,28 +288,21 @@ export async function activateEditorRuntime( let previousStatus: EditorStatus if (previousRuntimeWasInvalid) { const state = await readJsonFile(paths.state) - const editorRunning = Boolean(state && isProcessRunning(state.pid)) - const mcpRunning = Boolean(state?.mcp && isProcessRunning(state.mcp.pid)) - const editorHealthy = Boolean(state && editorRunning && (await checkHealth(state))) - const mcpHealthy = Boolean(state && mcpRunning && (await checkMcpHealth(paths, state))) + const running = Boolean(state && isProcessRunning(state.pid)) previousStatus = { installed: false, - running: editorRunning || mcpRunning, - healthy: editorHealthy && mcpHealthy, + running, + healthy: Boolean(state && running && (await checkHealth(state))), state: state ?? null, runtime: null, - components: { - editor: { running: editorRunning, healthy: editorHealthy }, - mcp: { running: mcpRunning, healthy: mcpHealthy }, - }, } } else { previousStatus = await getEditorStatus(paths) } - if (previousStatus.running && !statusComponentsAreIdentified(previousStatus)) { + if (previousStatus.running && !previousStatus.healthy) { throw new CliError( 'state_conflict', - 'A recorded Pascal process is running but its identity could not be verified. Recover or stop it before updating.', + 'A recorded Pascal editor process is running but its identity could not be verified. Recover or stop it before updating.', ) } if ( @@ -386,11 +319,15 @@ export async function activateEditorRuntime( try { await activateRuntime(paths, candidate.version, candidate.directory) await startEditorUnlocked({ paths, port: previousPort }) - if (!wasRunning) await stopEditorUnlocked(paths) + if (!wasRunning) { + await stopEditorUnlocked(paths) + await stopMcpService(paths) + } return { runtime: candidate, restarted: wasRunning } } catch (error) { try { await stopEditorUnlocked(paths, { force: true }) + await stopMcpService(paths, { force: true }) } catch {} let rollbackError: unknown if (previousRuntime) { @@ -478,7 +415,7 @@ export async function followLog(filePath: string): Promise { } } -export async function checkHealth(state: EditorState): Promise { +async function checkHealth(state: EditorState): Promise { return (await probeHealth(state)) === 'healthy' } @@ -529,141 +466,6 @@ export async function waitForHealth(state: EditorState, timeoutMs: number): Prom throw new CliError('health_timeout', `Pascal did not become healthy within ${timeoutMs}ms.`) } -export async function checkMcpHealth(paths: PascalPaths, state: EditorState): Promise { - return (await probeMcpHealth(paths, state)) === 'healthy' -} - -async function probeMcpHealth( - paths: PascalPaths, - state: EditorState, -): Promise<'healthy' | 'foreign' | 'unreachable'> { - if (!state.mcp) return 'unreachable' - let token: string - try { - token = (await readFile(paths.mcpToken, 'utf8')).trim() - } catch { - return 'unreachable' - } - if (!token) return 'unreachable' - try { - const response = await fetch(`http://127.0.0.1:${state.mcp.port}/health`, { - headers: { authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(1_000), - }) - if (!response.ok) return 'foreign' - const body = (await response.json()) as { - status?: string - app?: string - version?: string - instanceId?: string - } - return body.status === 'ok' && - body.app === 'mcp' && - body.version === state.version && - body.instanceId === state.instanceId - ? 'healthy' - : 'foreign' - } catch { - return 'unreachable' - } -} - -async function waitForMcpHealth( - paths: PascalPaths, - state: EditorState, - timeoutMs: number, -): Promise { - if (!state.mcp) throw new CliError('start_failed', 'Pascal MCP state was not created.') - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - const health = await probeMcpHealth(paths, state) - if (health === 'healthy') return - if (health === 'foreign') { - throw new CliError( - 'port_conflict', - `Port ${state.mcp.port} is responding as another application. Run Pascal again to choose another port.`, - ) - } - if (!isProcessRunning(state.mcp.pid)) { - throw new CliError('start_failed', 'Pascal MCP exited before becoming healthy.') - } - await new Promise((resolve) => setTimeout(resolve, 200)) - } - throw new CliError('health_timeout', `Pascal MCP did not become healthy within ${timeoutMs}ms.`) -} - -export function isProcessRunning(pid: number): boolean { - if (!Number.isSafeInteger(pid) || pid <= 0) return false - try { - process.kill(pid, 0) - return true - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'EPERM' - } -} - -async function findAvailablePort(preferredPort: number): Promise { - if (!Number.isInteger(preferredPort) || preferredPort < 0 || preferredPort > 65_535) { - throw new CliError('invalid_port', `Invalid port: ${preferredPort}`) - } - if (preferredPort === 0) return probePort(0) - if (!(await isPortAcceptingConnections(preferredPort))) { - try { - return await probePort(preferredPort) - } catch {} - } - return probePort(0) -} - -async function isPortAcceptingConnections(port: number): Promise { - return new Promise((resolve) => { - const socket = net.connect({ host: '127.0.0.1', port }) - let settled = false - const finish = (result: boolean) => { - if (settled) return - settled = true - socket.destroy() - resolve(result) - } - socket.setTimeout(250) - socket.once('connect', () => finish(true)) - socket.once('timeout', () => finish(false)) - socket.once('error', () => finish(false)) - }) -} - -async function probePort(port: number): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer() - server.unref() - server.once('error', reject) - server.listen({ host: '127.0.0.1', port }, () => { - const address = server.address() - const resolvedPort = typeof address === 'object' && address ? address.port : port - server.close((error) => (error ? reject(error) : resolve(resolvedPort))) - }) - }) -} - -async function terminateProcess(pid: number): Promise { - try { - process.kill(pid, 'SIGTERM') - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ESRCH') return - throw error - } - const deadline = Date.now() + 10_000 - while (Date.now() < deadline) { - if (!isProcessRunning(pid)) return - await new Promise((resolve) => setTimeout(resolve, 100)) - } - try { - process.kill(pid, 'SIGKILL') - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error - } -} - async function withEditorLifecycleLock( paths: PascalPaths, action: () => Promise, @@ -676,15 +478,6 @@ async function withEditorLifecycleLock( ) } -async function waitForSpawn(child: ChildProcess, binary: string): Promise { - await new Promise((resolve, reject) => { - child.once('spawn', resolve) - child.once('error', reject) - }).catch((error) => { - throw new CliError('start_failed', `Unable to launch ${binary}: ${errorMessage(error)}`) - }) -} - async function matchesRecordedEditorProcess( paths: PascalPaths, state: EditorState, @@ -703,28 +496,6 @@ async function matchesRecordedEditorProcess( return command.includes(expectedEntrypoint) } -async function matchesRecordedMcpProcess(paths: PascalPaths, state: EditorState): Promise { - if (process.platform === 'win32' || !state.mcp) return false - const runtimeDirectory = path.resolve(state.runtimeDirectory) - if (!runtimeDirectory.startsWith(`${path.resolve(paths.runtime)}${path.sep}`)) return false - let expectedEntrypoint: string - try { - const manifest = await readRuntimeManifest(runtimeDirectory) - expectedEntrypoint = path.resolve(runtimeDirectory, manifest.mcpEntrypoint) - } catch { - expectedEntrypoint = path.join(runtimeDirectory, 'services/pascal-mcp.mjs') - } - return (await processCommand(state.mcp.pid)).includes(expectedEntrypoint) -} - -async function processCommand(pid: number): Promise { - return new Promise((resolve) => { - execFile('ps', ['-ww', '-p', String(pid), '-o', 'command='], (error, stdout) => { - resolve(error ? '' : stdout.trim()) - }) - }) -} - async function rotateEditorLog(filePath: string): Promise { try { if ((await stat(filePath)).size <= 10 * 1024 * 1024) return @@ -735,14 +506,3 @@ async function rotateEditorLog(filePath: string): Promise { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } } - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -function statusComponentsAreIdentified(status: EditorStatus): boolean { - return ( - (!status.components.editor.running || status.components.editor.healthy) && - (!status.components.mcp.running || status.components.mcp.healthy) - ) -} diff --git a/packages/cli/src/file-lock.ts b/packages/cli/src/file-lock.ts index ffb0ddea23..75f4f1df5a 100644 --- a/packages/cli/src/file-lock.ts +++ b/packages/cli/src/file-lock.ts @@ -19,10 +19,11 @@ export async function withFileLock( code: string, message: string, action: () => Promise, + options: { timeoutMs?: number } = {}, ): Promise { await mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 }) const token = randomUUID() - const deadline = Date.now() + DEFAULT_TIMEOUT_MS + const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS) while (!(await tryAcquire(lockPath, token))) { if (await reclaimStaleLock(lockPath)) continue diff --git a/packages/cli/src/http-download.ts b/packages/cli/src/http-download.ts new file mode 100644 index 0000000000..9dd7d5a340 --- /dev/null +++ b/packages/cli/src/http-download.ts @@ -0,0 +1,235 @@ +import { createWriteStream } from 'node:fs' +import http from 'node:http' +import https from 'node:https' +import type { Socket } from 'node:net' +import tls from 'node:tls' +import { CliError } from './errors.js' +import { version } from './version.js' + +const DEFAULT_TIMEOUT_MS = 60_000 +const MAX_REDIRECTS = 5 +const PROGRESS_INTERVAL_MS = 200 + +export interface DownloadProgress { + received: number + total: number | null +} + +export interface DownloadOptions { + environment?: NodeJS.ProcessEnv + onProgress?: (progress: DownloadProgress) => void + timeoutMs?: number +} + +/** + * Streams an HTTPS URL to disk without adding a dependency. Node's built-in `fetch` only + * honours `HTTPS_PROXY` when the process was started with `--use-env-proxy`, which a + * published CLI cannot retrofit onto its own entrypoint, so the proxy tunnel is explicit. + */ +export async function downloadToFile( + url: string, + destination: string, + options: DownloadOptions = {}, +): Promise { + const environment = options.environment ?? process.env + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + let target = parseHttpsUrl(url) + for (let redirect = 0; ; redirect += 1) { + const response = await requestOnce(target, environment, timeoutMs) + const status = response.statusCode ?? 0 + if (status >= 300 && status < 400 && response.headers.location) { + response.resume() + if (redirect >= MAX_REDIRECTS) { + throw new CliError('download_failed', `${url} redirected more than ${MAX_REDIRECTS} times.`) + } + target = parseHttpsUrl(new URL(response.headers.location, target).toString()) + continue + } + if (status !== 200) { + response.resume() + throw new CliError('download_failed', `${target.href} returned HTTP ${status}.`) + } + return writeResponse(response, destination, options.onProgress) + } +} + +export function resolveProxyUrl(target: URL, environment: NodeJS.ProcessEnv): string | null { + if (isProxyBypassed(target.hostname, environment.NO_PROXY ?? environment.no_proxy)) return null + const configured = + environment.HTTPS_PROXY ?? + environment.https_proxy ?? + environment.ALL_PROXY ?? + environment.all_proxy + return configured?.trim() ? configured.trim() : null +} + +export function isProxyBypassed(hostname: string, noProxy: string | undefined): boolean { + if (!noProxy?.trim()) return false + const host = hostname.toLowerCase().replace(/^\[|\]$/g, '') + for (const raw of noProxy.split(/[,\s]+/)) { + const entry = raw.trim().toLowerCase() + if (!entry) continue + if (entry === '*') return true + const pattern = entry.replace(/^\*/, '').replace(/^\./, '').replace(/:\d+$/, '') + if (!pattern) continue + if (host === pattern || host.endsWith(`.${pattern}`)) return true + } + return false +} + +function parseHttpsUrl(value: string): URL { + let url: URL + try { + url = new URL(value) + } catch { + throw new CliError('download_failed', `Invalid download URL: ${value}`) + } + if (url.protocol !== 'https:') { + throw new CliError('download_failed', `Only https downloads are supported: ${value}`) + } + return url +} + +async function requestOnce( + target: URL, + environment: NodeJS.ProcessEnv, + timeoutMs: number, +): Promise { + const proxy = resolveProxyUrl(target, environment) + const agent = proxy + ? new TunnelAgent( + await openProxyTunnel(parseProxyUrl(proxy), target, timeoutMs), + target.hostname, + ) + : undefined + const request = https.request({ + hostname: target.hostname, + port: target.port || 443, + path: `${target.pathname}${target.search}`, + method: 'GET', + headers: { + accept: 'application/octet-stream, */*', + 'accept-encoding': 'identity', + 'user-agent': `pascal-cli/${version}`, + }, + ...(agent ? { agent } : {}), + }) + request.setTimeout(timeoutMs, () => + request.destroy(new Error(`no response from ${target.host} within ${timeoutMs}ms`)), + ) + request.end() + return new Promise((resolve, reject) => { + request.once('response', resolve) + request.once('error', (error) => + reject(new CliError('download_failed', `Unable to reach ${target.href}: ${error.message}`)), + ) + }) +} + +async function writeResponse( + response: http.IncomingMessage, + destination: string, + onProgress: ((progress: DownloadProgress) => void) | undefined, +): Promise { + const declared = Number(response.headers['content-length']) + const total = Number.isFinite(declared) && declared > 0 ? declared : null + const file = createWriteStream(destination, { mode: 0o600 }) + let received = 0 + let lastReport = 0 + await new Promise((resolve, reject) => { + const fail = (error: Error) => { + response.destroy() + file.destroy() + reject(error) + } + response.on('data', (chunk: Buffer) => { + received += chunk.byteLength + if (!file.write(chunk)) response.pause() + const now = Date.now() + if (onProgress && now - lastReport >= PROGRESS_INTERVAL_MS) { + lastReport = now + onProgress({ received, total }) + } + }) + file.on('drain', () => response.resume()) + response.once('error', fail) + file.once('error', fail) + response.once('end', () => file.end(resolve)) + }) + onProgress?.({ received, total }) + return received +} + +function parseProxyUrl(value: string): URL { + const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}` + let proxy: URL + try { + proxy = new URL(candidate) + } catch { + throw new CliError('download_failed', `Invalid proxy URL: ${value}`) + } + if (proxy.protocol !== 'http:' && proxy.protocol !== 'https:') { + throw new CliError('download_failed', `Unsupported proxy protocol: ${proxy.protocol}`) + } + return proxy +} + +async function openProxyTunnel(proxy: URL, target: URL, timeoutMs: number): Promise { + const authority = `${target.hostname}:${target.port || 443}` + const headers: Record = { host: authority } + if (proxy.username) { + const credentials = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}` + headers['proxy-authorization'] = `Basic ${Buffer.from(credentials).toString('base64')}` + } + const requestFn = proxy.protocol === 'https:' ? https.request : http.request + const request = requestFn({ + host: proxy.hostname, + port: proxy.port || (proxy.protocol === 'https:' ? 443 : 80), + method: 'CONNECT', + path: authority, + headers, + }) + request.setTimeout(timeoutMs, () => + request.destroy(new Error(`proxy ${proxy.host} did not answer CONNECT within ${timeoutMs}ms`)), + ) + request.end() + return new Promise((resolve, reject) => { + request.once('connect', (response, socket) => { + if (response.statusCode !== 200) { + socket.destroy() + reject( + new CliError( + 'download_failed', + `Proxy ${proxy.host} refused CONNECT ${authority} with HTTP ${response.statusCode}.`, + ), + ) + return + } + resolve(socket) + }) + request.once('error', (error) => + reject( + new CliError('download_failed', `Unable to reach proxy ${proxy.host}: ${error.message}`), + ), + ) + }) +} + +class TunnelAgent extends https.Agent { + private readonly tunnel: Socket + private readonly servername: string + + constructor(tunnel: Socket, servername: string) { + super({ keepAlive: false, maxSockets: 1 }) + this.tunnel = tunnel + this.servername = servername + } + + override createConnection(): tls.TLSSocket { + return tls.connect({ + socket: this.tunnel, + servername: this.servername, + ALPNProtocols: ['http/1.1'], + }) + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6134f0db67..ba86bb163b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,7 +5,6 @@ export { type EditorStatus, ensurePascalDirectories, getEditorStatus, - type McpState, type RuntimeActivationResult, restartEditor, type StopEditorOptions, @@ -13,6 +12,13 @@ export { stopEditor, } from './editor-process.js' export { CliError } from './errors.js' +export { + ensureMcpService, + getMcpServiceStatus, + type McpServiceState, + type McpServiceStatus, + stopMcpService, +} from './mcp-service.js' export { type PascalPaths, resolvePascalPaths } from './paths.js' export { type ActiveRuntime, @@ -21,4 +27,10 @@ export { readActiveRuntime, readRuntimeManifest, } from './runtime.js' +export { + ensureWebRuntime, + type RuntimeSource, + readRuntimeSource, + verifyArchiveDigest, +} from './runtime-download.js' export { version } from './version.js' diff --git a/packages/cli/src/mcp-connector.ts b/packages/cli/src/mcp-connector.ts index 21688e0d70..1ff5d3fed4 100644 --- a/packages/cli/src/mcp-connector.ts +++ b/packages/cli/src/mcp-connector.ts @@ -2,24 +2,19 @@ import { readFile } from 'node:fs/promises' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js' -import { getEditorStatus, startEditor } from './editor-process.js' import { CliError } from './errors.js' +import { ensureMcpService } from './mcp-service.js' import type { PascalPaths } from './paths.js' +/** + * Bridges stdio to the managed MCP service. The service ships with the CLI, so this never + * starts the web editor and never needs the downloaded web runtime. + */ export async function connectManagedMcp(paths: PascalPaths): Promise { - let status = await getEditorStatus(paths) - if (!status.healthy) { - await startEditor({ paths }) - status = await getEditorStatus(paths) - } - if (!(status.healthy && status.state?.mcp)) { - throw new CliError('mcp_unavailable', 'Pascal MCP is not healthy. Run "pascal doctor".') - } - - const token = (await readFile(paths.mcpToken, 'utf8')).trim() - if (!token) throw new CliError('mcp_unavailable', 'Pascal MCP credentials are missing.') + const { state } = await ensureMcpService({ paths }) + const token = await readMcpToken(paths) - const remote = new StreamableHTTPClientTransport(new URL(status.state.mcp.url), { + const remote = new StreamableHTTPClientTransport(new URL(state.url), { requestInit: { headers: { authorization: `Bearer ${token}` } }, }) const stdio = new StdioServerTransport() @@ -42,6 +37,15 @@ export async function connectManagedMcp(paths: PascalPaths): Promise { await stdio.start() } +async function readMcpToken(paths: PascalPaths): Promise { + let token = '' + try { + token = (await readFile(paths.mcpToken, 'utf8')).trim() + } catch {} + if (!token) throw new CliError('mcp_unavailable', 'Pascal MCP credentials are missing.') + return token +} + function applyProtocolVersion( transport: StreamableHTTPClientTransport, message: JSONRPCMessage, diff --git a/packages/cli/src/mcp-service.test.ts b/packages/cli/src/mcp-service.test.ts new file mode 100644 index 0000000000..2f2c56c351 --- /dev/null +++ b/packages/cli/src/mcp-service.test.ts @@ -0,0 +1,165 @@ +import { afterAll, afterEach, describe, expect, test } from 'bun:test' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { + ensureMcpService, + getMcpServiceStatus, + type McpServiceState, + stopMcpService, +} from './mcp-service.js' +import { type PascalPaths, resolvePascalPaths } from './paths.js' +import { readActiveRuntime } from './runtime.js' +import { writeFakeMcpService } from './test-support/fake-mcp-service.js' + +const roots: string[] = [] +const started: PascalPaths[] = [] +/** The MCP service ships with the CLI; the tests inject a stand-in for the bundled bundle. */ +const serviceRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-mcp-service-')) +process.env.PASCAL_MCP_SERVICE_PATH = await writeFakeMcpService(serviceRoot) + +afterEach(async () => { + for (const paths of started.splice(0)) { + await stopMcpService(paths, { force: true }).catch(() => undefined) + } + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +afterAll(() => rm(serviceRoot, { recursive: true, force: true })) + +describe('managed MCP service', () => { + test('starts on demand without a web runtime installed', async () => { + const paths = await temporaryPaths() + + const result = await ensureMcpService({ paths }) + + expect(result.alreadyRunning).toBe(false) + expect(result.state.editorOrigin).toBeNull() + expect(result.state.host).toBe('127.0.0.1') + expect(result.state.url).toBe(`http://127.0.0.1:${result.state.port}/mcp`) + expect(await readActiveRuntime(paths)).toBeNull() + expect(paths.mcpState.endsWith(path.join('run', 'mcp.json'))).toBe(true) + const status = await getMcpServiceStatus(paths) + expect(status).toMatchObject({ running: true, healthy: true }) + expect(status.state?.pid).toBe(result.state.pid) + expect((await stat(paths.mcpToken)).mode & 0o077).toBe(0) + }) + + test('reuses a healthy service instead of starting a second one', async () => { + const paths = await temporaryPaths() + const first = await ensureMcpService({ paths }) + + const second = await ensureMcpService({ paths }) + + expect(second.alreadyRunning).toBe(true) + expect(second.state.pid).toBe(first.state.pid) + expect(second.state.instanceId).toBe(first.state.instanceId) + }) + + test('serializes concurrent starts into one service', async () => { + const paths = await temporaryPaths() + + const [first, second] = await Promise.all([ + ensureMcpService({ paths }), + ensureMcpService({ paths }), + ]) + + expect(first.state.pid).toBe(second.state.pid) + expect([first.alreadyRunning, second.alreadyRunning].sort()).toEqual([false, true]) + }) + + test('keeps the recorded editor origin when the caller does not run the editor', async () => { + const paths = await temporaryPaths() + const editorOrigin = 'http://pascal.localhost:41234' + const first = await ensureMcpService({ paths, editorOrigin }) + + const connected = await ensureMcpService({ paths }) + + expect(connected.alreadyRunning).toBe(true) + expect(connected.state.pid).toBe(first.state.pid) + expect(connected.state.editorOrigin).toBe(editorOrigin) + expect(await reportedEditorOrigin(paths, connected.state)).toBe(editorOrigin) + }) + + test('restarts with the new origin when the editor moves to another port', async () => { + const paths = await temporaryPaths() + const first = await ensureMcpService({ paths, editorOrigin: 'http://pascal.localhost:41234' }) + + const moved = await ensureMcpService({ paths, editorOrigin: 'http://pascal.localhost:41235' }) + + expect(moved.alreadyRunning).toBe(false) + expect(moved.state.pid).not.toBe(first.state.pid) + expect(moved.state.editorOrigin).toBe('http://pascal.localhost:41235') + expect(await reportedEditorOrigin(paths, moved.state)).toBe('http://pascal.localhost:41235') + }) + + test('stops the service once and clears its state and token', async () => { + const paths = await temporaryPaths() + await ensureMcpService({ paths }) + + expect(await stopMcpService(paths)).toBe(true) + expect(await stopMcpService(paths)).toBe(false) + expect(await getMcpServiceStatus(paths)).toEqual({ + running: false, + healthy: false, + state: null, + }) + expect(await exists(paths.mcpState)).toBe(false) + expect(await exists(paths.mcpToken)).toBe(false) + }) + + test('refuses to stop a recorded process that is not the MCP service', async () => { + const paths = await temporaryPaths(false) + await writeFile( + paths.mcpState, + JSON.stringify({ + schemaVersion: 1, + pid: process.pid, + port: 1, + host: '127.0.0.1', + url: 'http://127.0.0.1:1/mcp', + version: '0.0.0', + instanceId: 'not-the-service', + servicePath: path.join(serviceRoot, 'pascal-mcp.mjs'), + editorOrigin: null, + startedAt: new Date().toISOString(), + }), + ) + + await expect(stopMcpService(paths)).rejects.toMatchObject({ code: 'state_conflict' }) + await expect(stopMcpService(paths, { force: true })).rejects.toMatchObject({ + code: 'state_conflict', + }) + await rm(paths.mcpState, { force: true }) + }) +}) + +async function temporaryPaths(tracked = true): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-mcp-test-')) + roots.push(root) + const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) + await mkdir(paths.run, { recursive: true, mode: 0o700 }) + if (tracked) started.push(paths) + return paths +} + +/** The stand-in service echoes the origin it was started with, proving the restart repointed it. */ +async function reportedEditorOrigin( + paths: PascalPaths, + state: McpServiceState, +): Promise { + const token = (await readFile(paths.mcpToken, 'utf8')).trim() + const response = await fetch(`http://127.0.0.1:${state.port}/health`, { + headers: { authorization: `Bearer ${token}` }, + }) + return ((await response.json()) as { editorOrigin: string | null }).editorOrigin +} + +async function exists(file: string): Promise { + try { + await stat(file) + return true + } catch { + return false + } +} diff --git a/packages/cli/src/mcp-service.ts b/packages/cli/src/mcp-service.ts new file mode 100644 index 0000000000..af633b8bf8 --- /dev/null +++ b/packages/cli/src/mcp-service.ts @@ -0,0 +1,292 @@ +import type { ChildProcess } from 'node:child_process' +import { spawn } from 'node:child_process' +import { randomBytes, randomUUID } from 'node:crypto' +import { closeSync, openSync } from 'node:fs' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { CliError } from './errors.js' +import { withFileLock } from './file-lock.js' +import { readJsonFile, writeJsonFile } from './json-files.js' +import type { PascalPaths } from './paths.js' +import { + findAvailablePort, + isProcessRunning, + processCommand, + terminateProcess, + waitForSpawn, +} from './process-control.js' +import { version } from './version.js' + +export interface McpServiceState { + schemaVersion: 1 + pid: number + port: number + host: '127.0.0.1' + url: string + version: string + instanceId: string + servicePath: string + editorOrigin: string | null + startedAt: string +} + +export interface McpServiceStatus { + running: boolean + healthy: boolean + state: McpServiceState | null +} + +export type McpStartProgress = + | { step: 'mcp-port-ready'; port: number } + | { step: 'mcp-starting'; port: number } + | { step: 'mcp-health-checking'; port: number } + | { step: 'mcp-ready'; port: number } + | { step: 'mcp-already-running'; port: number } + +export interface EnsureMcpServiceOptions { + paths: PascalPaths + /** + * The editor origin the MCP service should format `editorUrl` values against. Omit it when + * the caller does not run the web editor: a recorded origin is then kept as it is. + */ + editorOrigin?: string + foreground?: boolean + onProgress?: (event: McpStartProgress) => void +} + +export interface McpServiceResult { + state: McpServiceState + alreadyRunning: boolean + child?: ChildProcess +} + +/** + * The MCP service is bundled with the CLI itself, not with the downloaded web runtime, so + * agent tools work before (and without) any editor runtime being installed. + */ +export function resolveMcpServicePath(environment: NodeJS.ProcessEnv = process.env): string { + if (environment.PASCAL_MCP_SERVICE_PATH) { + return path.resolve(environment.PASCAL_MCP_SERVICE_PATH) + } + const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)) + return path.basename(moduleDirectory) === 'dist' + ? path.join(moduleDirectory, 'services/pascal-mcp.mjs') + : path.resolve(moduleDirectory, '../dist/services/pascal-mcp.mjs') +} + +export async function getMcpServiceStatus(paths: PascalPaths): Promise { + const state = await readJsonFile(paths.mcpState) + if (state?.schemaVersion !== 1 || typeof state.pid !== 'number') { + return { running: false, healthy: false, state: null } + } + const running = isProcessRunning(state.pid) + return { running, healthy: running ? await checkMcpHealth(paths, state) : false, state } +} + +export async function ensureMcpService( + options: EnsureMcpServiceOptions, +): Promise { + return withMcpLifecycleLock(options.paths, () => ensureMcpServiceUnlocked(options)) +} + +export async function stopMcpService( + paths: PascalPaths, + options: { force?: boolean } = {}, +): Promise { + return withMcpLifecycleLock(paths, () => stopMcpServiceUnlocked(paths, options)) +} + +async function ensureMcpServiceUnlocked( + options: EnsureMcpServiceOptions, +): Promise { + const { paths } = options + const status = await getMcpServiceStatus(paths) + if (status.healthy && status.state) { + const originMatches = + options.editorOrigin === undefined || options.editorOrigin === status.state.editorOrigin + if (originMatches) { + options.onProgress?.({ step: 'mcp-already-running', port: status.state.port }) + return { state: status.state, alreadyRunning: true } + } + } + if (status.running && status.state) { + if (!(status.healthy || (await matchesRecordedMcpProcess(status.state)))) { + throw new CliError( + 'state_conflict', + 'A recorded Pascal MCP process is running but its identity could not be verified. Inspect "pascal mcp status --json", then use "pascal stop --force" only if the recorded command is trusted.', + ) + } + await terminateProcess(status.state.pid) + } + await rm(paths.mcpState, { force: true }) + await rm(paths.mcpToken, { force: true }) + + const servicePath = resolveMcpServicePath() + const port = await findAvailablePort(0) + const instanceId = randomUUID() + const token = randomBytes(32).toString('base64url') + const state: McpServiceState = { + schemaVersion: 1, + pid: 0, + port, + host: '127.0.0.1', + url: `http://127.0.0.1:${port}/mcp`, + version, + instanceId, + servicePath, + editorOrigin: options.editorOrigin ?? null, + startedAt: new Date().toISOString(), + } + options.onProgress?.({ step: 'mcp-port-ready', port }) + await Promise.all( + [paths.run, paths.logs, paths.data].map((directory) => + mkdir(directory, { recursive: true, mode: 0o700 }), + ), + ) + await writeFile(paths.mcpToken, `${token}\n`, { mode: 0o600 }) + + const environment: NodeJS.ProcessEnv = { + ...process.env, + NODE_ENV: 'production', + PASCAL_DATA_DIR: paths.data, + PASCAL_INSTANCE_ID: instanceId, + PASCAL_RUNTIME_VERSION: version, + PASCAL_MCP_HTTP_TOKEN: token, + ...(state.editorOrigin ? { PASCAL_EDITOR_ORIGIN: state.editorOrigin } : {}), + } + const nodeBinary = process.env.PASCAL_NODE_BINARY || 'node' + const logDescriptor = options.foreground ? undefined : openSync(paths.editorLog, 'a', 0o600) + options.onProgress?.({ step: 'mcp-starting', port }) + const child = spawn( + nodeBinary, + [servicePath, '--http', '--host', state.host, '--port', String(port)], + { + cwd: path.dirname(servicePath), + env: environment, + detached: !options.foreground, + stdio: options.foreground + ? ['ignore', 'inherit', 'inherit'] + : ['ignore', logDescriptor!, logDescriptor!], + }, + ) + if (logDescriptor !== undefined) closeSync(logDescriptor) + try { + await waitForSpawn(child, nodeBinary) + if (!child.pid) throw new CliError('start_failed', 'The Pascal MCP process did not start.') + state.pid = child.pid + await writeJsonFile(paths.mcpState, state) + if (!options.foreground) child.unref() + options.onProgress?.({ step: 'mcp-health-checking', port }) + await waitForMcpHealth(paths, state, 20_000) + options.onProgress?.({ step: 'mcp-ready', port }) + } catch (error) { + if (child.pid) await terminateProcess(child.pid) + await rm(paths.mcpState, { force: true }) + await rm(paths.mcpToken, { force: true }) + throw error + } + return { state, alreadyRunning: false, child: options.foreground ? child : undefined } +} + +async function stopMcpServiceUnlocked( + paths: PascalPaths, + options: { force?: boolean }, +): Promise { + const status = await getMcpServiceStatus(paths) + if (!status.state || !status.running) { + await rm(paths.mcpState, { force: true }) + await rm(paths.mcpToken, { force: true }) + return false + } + if (!(status.healthy || (options.force && (await matchesRecordedMcpProcess(status.state))))) { + throw new CliError( + 'state_conflict', + options.force + ? 'Refusing to stop a process whose health identity and operating-system command do not match the recorded Pascal MCP service.' + : 'The Pascal MCP identity is unavailable. Inspect "pascal mcp status --json", then use "pascal stop --force" only if the recorded command is trusted.', + ) + } + await terminateProcess(status.state.pid) + await rm(paths.mcpState, { force: true }) + await rm(paths.mcpToken, { force: true }) + return true +} + +export async function checkMcpHealth(paths: PascalPaths, state: McpServiceState): Promise { + return (await probeMcpHealth(paths, state)) === 'healthy' +} + +async function probeMcpHealth( + paths: PascalPaths, + state: McpServiceState, +): Promise<'healthy' | 'foreign' | 'unreachable'> { + let token: string + try { + token = (await readFile(paths.mcpToken, 'utf8')).trim() + } catch { + return 'unreachable' + } + if (!token) return 'unreachable' + try { + const response = await fetch(`http://127.0.0.1:${state.port}/health`, { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(1_000), + }) + if (!response.ok) return 'foreign' + const body = (await response.json()) as { + status?: string + app?: string + version?: string + instanceId?: string + } + return body.status === 'ok' && + body.app === 'mcp' && + body.version === state.version && + body.instanceId === state.instanceId + ? 'healthy' + : 'foreign' + } catch { + return 'unreachable' + } +} + +async function waitForMcpHealth( + paths: PascalPaths, + state: McpServiceState, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const health = await probeMcpHealth(paths, state) + if (health === 'healthy') return + if (health === 'foreign') { + throw new CliError( + 'port_conflict', + `Port ${state.port} is responding as another application. Run the command again to choose another port.`, + ) + } + if (!isProcessRunning(state.pid)) { + throw new CliError('start_failed', 'Pascal MCP exited before becoming healthy.') + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + throw new CliError('health_timeout', `Pascal MCP did not become healthy within ${timeoutMs}ms.`) +} + +async function matchesRecordedMcpProcess(state: McpServiceState): Promise { + if (process.platform === 'win32') return false + const servicePath = path.resolve(state.servicePath) + if (path.basename(servicePath) !== 'pascal-mcp.mjs') return false + return (await processCommand(state.pid)).includes(servicePath) +} + +async function withMcpLifecycleLock(paths: PascalPaths, action: () => Promise): Promise { + return withFileLock( + path.join(paths.run, 'mcp-lifecycle.lock'), + 'mcp_locked', + 'Another Pascal MCP lifecycle operation is active.', + action, + { timeoutMs: 30_000 }, + ) +} diff --git a/packages/cli/src/paths.ts b/packages/cli/src/paths.ts index e07529fc95..f352dd83af 100644 --- a/packages/cli/src/paths.ts +++ b/packages/cli/src/paths.ts @@ -8,7 +8,9 @@ export interface PascalPaths { plugins: string run: string logs: string + tmp: string state: string + mcpState: string currentRuntime: string pluginLock: string database: string @@ -25,7 +27,9 @@ export function resolvePascalPaths(environment: NodeJS.ProcessEnv = process.env) plugins: path.join(root, 'plugins'), run: path.join(root, 'run'), logs: path.join(root, 'logs'), + tmp: path.join(root, 'tmp'), state: path.join(root, 'run/editor.json'), + mcpState: path.join(root, 'run/mcp.json'), currentRuntime: path.join(root, 'run/current-runtime.json'), pluginLock: path.join(root, 'pascal.plugins.lock'), database: path.join(root, 'data/pascal.db'), diff --git a/packages/cli/src/process-control.ts b/packages/cli/src/process-control.ts new file mode 100644 index 0000000000..f19027f48d --- /dev/null +++ b/packages/cli/src/process-control.ts @@ -0,0 +1,96 @@ +import { type ChildProcess, execFile } from 'node:child_process' +import net from 'node:net' +import { CliError } from './errors.js' + +export function isProcessRunning(pid: number): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) return false + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +export async function terminateProcess(pid: number): Promise { + try { + process.kill(pid, 'SIGTERM') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return + throw error + } + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + if (!isProcessRunning(pid)) return + await new Promise((resolve) => setTimeout(resolve, 100)) + } + try { + process.kill(pid, 'SIGKILL') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error + } +} + +export async function findAvailablePort(preferredPort: number): Promise { + if (!Number.isInteger(preferredPort) || preferredPort < 0 || preferredPort > 65_535) { + throw new CliError('invalid_port', `Invalid port: ${preferredPort}`) + } + if (preferredPort === 0) return probePort(0) + if (!(await isPortAcceptingConnections(preferredPort))) { + try { + return await probePort(preferredPort) + } catch {} + } + return probePort(0) +} + +async function isPortAcceptingConnections(port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ host: '127.0.0.1', port }) + let settled = false + const finish = (result: boolean) => { + if (settled) return + settled = true + socket.destroy() + resolve(result) + } + socket.setTimeout(250) + socket.once('connect', () => finish(true)) + socket.once('timeout', () => finish(false)) + socket.once('error', () => finish(false)) + }) +} + +async function probePort(port: number): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.once('error', reject) + server.listen({ host: '127.0.0.1', port }, () => { + const address = server.address() + const resolvedPort = typeof address === 'object' && address ? address.port : port + server.close((error) => (error ? reject(error) : resolve(resolvedPort))) + }) + }) +} + +export async function processCommand(pid: number): Promise { + return new Promise((resolve) => { + execFile('ps', ['-ww', '-p', String(pid), '-o', 'command='], (error, stdout) => { + resolve(error ? '' : stdout.trim()) + }) + }) +} + +export async function waitForSpawn(child: ChildProcess, binary: string): Promise { + await new Promise((resolve, reject) => { + child.once('spawn', resolve) + child.once('error', reject) + }).catch((error) => { + throw new CliError('start_failed', `Unable to launch ${binary}: ${errorMessage(error)}`) + }) +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/cli/src/runtime-download.test.ts b/packages/cli/src/runtime-download.test.ts new file mode 100644 index 0000000000..decae30cd9 --- /dev/null +++ b/packages/cli/src/runtime-download.test.ts @@ -0,0 +1,339 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { copyFile, mkdir, mkdtemp, open, rm, stat, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { isProxyBypassed, resolveProxyUrl } from './http-download.js' +import { resolvePascalPaths } from './paths.js' +import { findInstalledRuntime, installBundledRuntime, readActiveRuntime } from './runtime.js' +import { + ensureWebRuntime, + fileSha256, + type RuntimeSource, + readRuntimeSource, + verifyArchiveDigest, +} from './runtime-download.js' +import { createRuntimeArchive } from './tar.js' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('archive digest verification', () => { + test('accepts a matching digest whatever case it is written in', async () => { + const fixture = await createFixture() + + await verifyArchiveDigest(fixture.archiveFile, fixture.source.sha256) + await verifyArchiveDigest(fixture.archiveFile, fixture.source.sha256.toUpperCase()) + }) + + test('rejects a changed archive and deletes it when asked to', async () => { + const fixture = await createFixture() + const tampered = await tamper(fixture.archiveFile, path.join(fixture.root, 'tampered.tar.gz')) + + await expect( + verifyArchiveDigest(tampered, fixture.source.sha256, { deleteOnMismatch: true }), + ).rejects.toMatchObject({ + code: 'runtime_digest_mismatch', + message: expect.stringContaining(fixture.source.sha256), + }) + expect(await exists(tampered)).toBe(false) + }) + + test('keeps a caller-supplied archive that fails verification', async () => { + const fixture = await createFixture() + const tampered = await tamper(fixture.archiveFile, path.join(fixture.root, 'tampered.tar.gz')) + + await expect(verifyArchiveDigest(tampered, fixture.source.sha256)).rejects.toMatchObject({ + code: 'runtime_digest_mismatch', + }) + expect(await exists(tampered)).toBe(true) + }) +}) + +describe('published runtime source', () => { + test('reads the archive URL and digest committed with the CLI', async () => { + const fixture = await createFixture() + + expect(await readRuntimeSource(fixture.sourceFile)).toEqual(fixture.source) + }) + + test.each([ + ['is missing', null], + ['is not JSON', '{not-json'], + ['omits the digest', { version: '1.2.3', url: 'https://example.com/a.tar.gz', size: 10 }], + [ + 'carries a truncated digest', + { version: '1.2.3', url: 'https://example.com/a.tar.gz', sha256: 'abc123', size: 10 }, + ], + [ + 'points at a plain-http URL', + { version: '1.2.3', url: 'http://example.com/a.tar.gz', sha256: 'a'.repeat(64), size: 10 }, + ], + [ + 'declares an empty archive', + { version: '1.2.3', url: 'https://example.com/a.tar.gz', sha256: 'a'.repeat(64), size: 0 }, + ], + [ + 'carries a path-like version', + { + version: '../escape', + url: 'https://example.com/a.tar.gz', + sha256: 'a'.repeat(64), + size: 10, + }, + ], + ])('refuses a runtime source that %s', async (_label, content) => { + const root = await temporaryRoot() + const sourceFile = path.join(root, 'runtime-source.json') + if (content !== null) { + await writeFile(sourceFile, typeof content === 'string' ? content : JSON.stringify(content)) + } + + await expect(readRuntimeSource(sourceFile)).rejects.toMatchObject({ + code: 'invalid_runtime_source', + message: expect.stringContaining('--runtime'), + }) + }) +}) + +describe('web runtime resolution order', () => { + test('prefers an explicit runtime directory over the environment override', async () => { + const fixture = await createFixture() + const other = await fakeRuntimeDirectory(fixture.root, '9.9.9') + + const result = await ensureWebRuntime({ + paths: fixture.paths, + runtimeSource: fixture.sourceDirectory, + sourceFile: fixture.sourceFile, + environment: { PASCAL_BUNDLED_RUNTIME_DIR: other }, + }) + + expect(result.runtime.version).toBe('1.2.3') + expect((await readActiveRuntime(fixture.paths))?.version).toBe('1.2.3') + expect(await findInstalledRuntime(fixture.paths, '9.9.9')).toBeNull() + }) + + test('falls back to PASCAL_BUNDLED_RUNTIME_DIR when no flag is passed', async () => { + const fixture = await createFixture() + + const result = await ensureWebRuntime({ + paths: fixture.paths, + sourceFile: fixture.sourceFile, + environment: { PASCAL_BUNDLED_RUNTIME_DIR: fixture.sourceDirectory }, + }) + + expect(result).toMatchObject({ installed: true, runtime: { version: '1.2.3' } }) + }) + + test('installs a local archive that matches the published digest', async () => { + const fixture = await createFixture() + + const result = await ensureWebRuntime({ + paths: fixture.paths, + runtimeSource: fixture.archiveFile, + sourceFile: fixture.sourceFile, + environment: {}, + }) + + expect(result.runtime.directory).toBe(path.join(fixture.paths.runtime, '1.2.3')) + expect(await exists(path.join(result.runtime.directory, 'apps/editor/server.js'))).toBe(true) + }) + + test('installs nothing when a local archive fails verification', async () => { + const fixture = await createFixture() + const tampered = await tamper(fixture.archiveFile, path.join(fixture.root, 'tampered.tar.gz')) + + await expect( + ensureWebRuntime({ + paths: fixture.paths, + runtimeSource: tampered, + sourceFile: fixture.sourceFile, + environment: {}, + }), + ).rejects.toMatchObject({ code: 'runtime_digest_mismatch' }) + expect(await findInstalledRuntime(fixture.paths, '1.2.3')).toBeNull() + expect(await exists(tampered)).toBe(true) + }) + + test('reports a missing runtime path instead of reaching for the network', async () => { + const fixture = await createFixture() + + await expect( + ensureWebRuntime({ + paths: fixture.paths, + runtimeSource: path.join(fixture.root, 'absent.tar.gz'), + sourceFile: fixture.sourceFile, + environment: {}, + }), + ).rejects.toMatchObject({ code: 'runtime_source_missing' }) + }) + + test('reuses the installed runtime for this version without downloading', async () => { + const fixture = await createFixture() + await installBundledRuntime(fixture.paths, fixture.sourceDirectory, { activate: false }) + + const result = await ensureWebRuntime({ + paths: fixture.paths, + sourceFile: fixture.sourceFile, + environment: {}, + }) + + expect(result).toEqual({ + installed: false, + runtime: { + schemaVersion: 1, + version: '1.2.3', + directory: path.join(fixture.paths.runtime, '1.2.3'), + }, + }) + expect((await readActiveRuntime(fixture.paths))?.version).toBe('1.2.3') + }) + + test('installs without activating so an update can health-check first', async () => { + const fixture = await createFixture() + + const result = await ensureWebRuntime({ + paths: fixture.paths, + runtimeSource: fixture.sourceDirectory, + sourceFile: fixture.sourceFile, + activate: false, + environment: {}, + }) + + expect(result.runtime.version).toBe('1.2.3') + expect(await readActiveRuntime(fixture.paths)).toBeNull() + expect(await findInstalledRuntime(fixture.paths, '1.2.3')).not.toBeNull() + }) + + test('names the archive, the digest and the offline escape hatch when the download fails', async () => { + const fixture = await createFixture() + + const failure = await ensureWebRuntime({ + paths: fixture.paths, + sourceFile: fixture.sourceFile, + environment: {}, + }).catch((error: unknown) => error) + + expect(failure).toMatchObject({ code: 'runtime_download_failed' }) + const message = (failure as Error).message + expect(message).toContain(fixture.source.url) + expect(message).toContain(fixture.source.sha256) + expect(message).toContain('pascal editor --runtime') + expect(message).toContain('HTTPS_PROXY') + expect(await findInstalledRuntime(fixture.paths, '1.2.3')).toBeNull() + }) +}) + +describe('proxy configuration', () => { + test('prefers HTTPS_PROXY and trims the configured value', () => { + const target = new URL('https://github.com/pascalorg/editor') + + expect(resolveProxyUrl(target, { HTTPS_PROXY: ' http://proxy:3128 ' })).toBe( + 'http://proxy:3128', + ) + expect(resolveProxyUrl(target, { ALL_PROXY: 'http://all:3128' })).toBe('http://all:3128') + expect(resolveProxyUrl(target, { HTTPS_PROXY: ' ' })).toBeNull() + expect(resolveProxyUrl(target, {})).toBeNull() + }) + + test('honours NO_PROXY for the download host', () => { + const target = new URL('https://github.com/pascalorg/editor') + + expect(resolveProxyUrl(target, { HTTPS_PROXY: 'http://proxy:3128', NO_PROXY: '*' })).toBeNull() + expect( + resolveProxyUrl(target, { HTTPS_PROXY: 'http://proxy:3128', no_proxy: 'github.com' }), + ).toBeNull() + expect( + resolveProxyUrl(target, { HTTPS_PROXY: 'http://proxy:3128', NO_PROXY: 'example.com' }), + ).toBe('http://proxy:3128') + }) + + test('matches NO_PROXY entries by suffix and ignores ports', () => { + expect(isProxyBypassed('release-assets.githubusercontent.com', '.githubusercontent.com')).toBe( + true, + ) + expect(isProxyBypassed('github.com', 'github.com:443')).toBe(true) + expect(isProxyBypassed('github.com', '*.github.com')).toBe(true) + expect(isProxyBypassed('notgithub.com', 'github.com')).toBe(false) + expect(isProxyBypassed('github.com', '')).toBe(false) + expect(isProxyBypassed('github.com', undefined)).toBe(false) + }) +}) + +interface Fixture { + root: string + paths: ReturnType + sourceDirectory: string + archiveFile: string + sourceFile: string + source: RuntimeSource +} + +/** An unroutable port keeps every download test offline; the connection is refused at once. */ +const UNREACHABLE_HOST = 'https://127.0.0.1:1' + +async function createFixture(version = '1.2.3'): Promise { + const root = await temporaryRoot() + const sourceDirectory = await fakeRuntimeDirectory(root, version) + const archiveFile = path.join(root, `pascal-web-runtime-${version}.tar.gz`) + await createRuntimeArchive(sourceDirectory, archiveFile) + const source: RuntimeSource = { + version, + url: `${UNREACHABLE_HOST}/pascal-web-runtime-${version}.tar.gz`, + sha256: await fileSha256(archiveFile), + size: (await stat(archiveFile)).size, + } + const sourceFile = path.join(root, 'runtime-source.json') + await writeFile(sourceFile, JSON.stringify(source)) + return { + root, + paths: resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }), + sourceDirectory, + archiveFile, + sourceFile, + source, + } +} + +async function temporaryRoot(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-download-test-')) + roots.push(root) + return root +} + +async function fakeRuntimeDirectory(root: string, version: string): Promise { + const runtime = path.join(root, `source-${version}`) + await mkdir(path.join(runtime, 'apps/editor'), { recursive: true }) + await writeFile( + path.join(runtime, 'runtime-manifest.json'), + JSON.stringify({ schemaVersion: 2, version, entrypoint: 'apps/editor/server.js' }), + ) + await writeFile(path.join(runtime, 'apps/editor/server.js'), `// pascal ${version}\n`) + return runtime +} + +async function tamper(archiveFile: string, destination: string): Promise { + await copyFile(archiveFile, destination) + const handle = await open(destination, 'r+') + try { + const offset = Math.floor((await handle.stat()).size / 2) + const byte = Buffer.alloc(1) + await handle.read(byte, 0, 1, offset) + byte[0] = ((byte[0] ?? 0) ^ 0xff) & 0xff + await handle.write(byte, 0, 1, offset) + } finally { + await handle.close() + } + return destination +} + +async function exists(file: string): Promise { + try { + await stat(file) + return true + } catch { + return false + } +} diff --git a/packages/cli/src/runtime-download.ts b/packages/cli/src/runtime-download.ts new file mode 100644 index 0000000000..8b54b50b0d --- /dev/null +++ b/packages/cli/src/runtime-download.ts @@ -0,0 +1,256 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { CliError } from './errors.js' +import { downloadToFile } from './http-download.js' +import { readJsonFile } from './json-files.js' +import type { PascalPaths } from './paths.js' +import { + type ActiveRuntime, + activateRuntime, + findInstalledRuntime, + installBundledRuntime, + installRuntimeDirectory, + withRuntimeInstallLock, +} from './runtime.js' +import { extractTarGzip } from './tar.js' + +/** A peer process may be downloading the same archive; wait for it instead of duplicating it. */ +const DOWNLOAD_LOCK_TIMEOUT_MS = 20 * 60_000 + +export interface RuntimeSource { + version: string + url: string + sha256: string + size: number +} + +export interface WebRuntimeResult { + runtime: ActiveRuntime + installed: boolean +} + +export type RuntimeProvisionProgress = + | { step: 'runtime-downloading'; url: string; received: number; total: number | null } + | { step: 'runtime-verifying' } + | { step: 'runtime-extracting' } + | { step: 'runtime-installing' } + +export interface EnsureWebRuntimeOptions { + paths: PascalPaths + /** A directory or `.tar.gz` archive from `--runtime`; archives are digest-verified. */ + runtimeSource?: string + /** `false` installs the runtime without pointing the active runtime at it (used by updates). */ + activate?: boolean + environment?: NodeJS.ProcessEnv + sourceFile?: string + onProgress?: (event: RuntimeProvisionProgress) => void +} + +/** + * Resolves the web runtime for the commands that start the Next server. The npm package + * ships the CLI and the MCP service only; the runtime is downloaded once per version and + * verified against the digest committed in `dist/runtime-source.json`. + */ +export async function ensureWebRuntime( + options: EnsureWebRuntimeOptions, +): Promise { + const { paths } = options + const environment = options.environment ?? process.env + const override = options.runtimeSource ?? environment.PASCAL_BUNDLED_RUNTIME_DIR + if (override) return installOverride(paths, override, options) + + const source = await readRuntimeSource(options.sourceFile) + const existing = await findInstalledRuntime(paths, source.version) + if (existing) return { runtime: await useInstalled(paths, existing, options), installed: false } + return withRuntimeInstallLock( + paths, + async () => { + const peerInstalled = await findInstalledRuntime(paths, source.version) + if (peerInstalled) { + return { runtime: await useInstalled(paths, peerInstalled, options), installed: false } + } + return withWorkDirectory(paths, async (workDirectory) => { + const archiveFile = path.join(workDirectory, `pascal-web-runtime-${source.version}.tar.gz`) + await download(source, archiveFile, options) + options.onProgress?.({ step: 'runtime-verifying' }) + await verifyArchiveDigest(archiveFile, source.sha256, { deleteOnMismatch: true }) + return { + runtime: await extractAndInstall(archiveFile, workDirectory, options, (directory) => + installRuntimeDirectory(paths, directory, { activate: options.activate }), + ), + installed: true, + } + }) + }, + { timeoutMs: DOWNLOAD_LOCK_TIMEOUT_MS }, + ) +} + +export function resolveRuntimeSourceFile(): string { + const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)) + return path.basename(moduleDirectory) === 'dist' + ? path.join(moduleDirectory, 'runtime-source.json') + : path.resolve(moduleDirectory, '../dist/runtime-source.json') +} + +export async function readRuntimeSource(sourceFile?: string): Promise { + const file = sourceFile ?? resolveRuntimeSourceFile() + let source: RuntimeSource | null + try { + source = await readJsonFile(file) + } catch { + source = null + } + if ( + !source || + typeof source.version !== 'string' || + !/^[0-9A-Za-z][0-9A-Za-z._-]*$/.test(source.version) || + typeof source.url !== 'string' || + !source.url.startsWith('https://') || + typeof source.sha256 !== 'string' || + !/^[0-9a-f]{64}$/.test(source.sha256) || + !Number.isSafeInteger(source.size) || + source.size <= 0 + ) { + throw new CliError( + 'invalid_runtime_source', + `This CLI cannot resolve the Pascal web runtime it was published with (${file}). Reinstall @pascal-app/cli, or pass "--runtime ".`, + ) + } + return { version: source.version, url: source.url, sha256: source.sha256, size: source.size } +} + +export async function fileSha256(filePath: string): Promise { + const hash = createHash('sha256') + for await (const chunk of createReadStream(filePath)) hash.update(chunk as Buffer) + return hash.digest('hex') +} + +export async function verifyArchiveDigest( + archiveFile: string, + expectedSha256: string, + options: { deleteOnMismatch?: boolean } = {}, +): Promise { + const actual = await fileSha256(archiveFile) + if (actual === expectedSha256.toLowerCase()) return + if (options.deleteOnMismatch) await rm(archiveFile, { force: true }) + throw new CliError( + 'runtime_digest_mismatch', + [ + 'The Pascal web runtime archive does not match the digest published with this CLI.', + ` archive: ${archiveFile}`, + ` expected: ${expectedSha256}`, + ` actual: ${actual}`, + 'The archive was not installed. Download it again from the Pascal release page.', + ].join('\n'), + ) +} + +async function installOverride( + paths: PascalPaths, + override: string, + options: EnsureWebRuntimeOptions, +): Promise { + const resolved = path.resolve(override) + let info: Awaited> + try { + info = await stat(resolved) + } catch { + throw new CliError('runtime_source_missing', `No Pascal web runtime exists at ${resolved}.`) + } + if (info.isDirectory()) { + options.onProgress?.({ step: 'runtime-installing' }) + return { + runtime: await installBundledRuntime(paths, resolved, { activate: options.activate }), + installed: true, + } + } + const source = await readRuntimeSource(options.sourceFile) + options.onProgress?.({ step: 'runtime-verifying' }) + await verifyArchiveDigest(resolved, source.sha256) + return { + runtime: await withWorkDirectory(paths, (workDirectory) => + extractAndInstall(resolved, workDirectory, options, (directory) => + installBundledRuntime(paths, directory, { activate: options.activate }), + ), + ), + installed: true, + } +} + +async function useInstalled( + paths: PascalPaths, + runtime: ActiveRuntime, + options: EnsureWebRuntimeOptions, +): Promise { + return options.activate === false + ? runtime + : activateRuntime(paths, runtime.version, runtime.directory) +} + +async function extractAndInstall( + archiveFile: string, + workDirectory: string, + options: EnsureWebRuntimeOptions, + install: (directory: string) => Promise, +): Promise { + const extracted = path.join(workDirectory, 'runtime') + options.onProgress?.({ step: 'runtime-extracting' }) + await extractTarGzip(archiveFile, extracted) + options.onProgress?.({ step: 'runtime-installing' }) + return install(extracted) +} + +async function download( + source: RuntimeSource, + archiveFile: string, + options: EnsureWebRuntimeOptions, +): Promise { + options.onProgress?.({ step: 'runtime-downloading', url: source.url, received: 0, total: null }) + try { + await downloadToFile(source.url, archiveFile, { + environment: options.environment ?? process.env, + onProgress: ({ received, total }) => + options.onProgress?.({ + step: 'runtime-downloading', + url: source.url, + received, + total: total ?? source.size, + }), + }) + } catch (error) { + await rm(archiveFile, { force: true }) + throw new CliError( + 'runtime_download_failed', + [ + `Unable to download the Pascal web runtime ${source.version}.`, + ` archive: ${source.url}`, + ` sha256: ${source.sha256}`, + ` reason: ${error instanceof Error ? error.message : String(error)}`, + 'Download that archive on a connected machine, copy it over, then run:', + ` pascal editor --runtime /path/to/pascal-web-runtime-${source.version}.tar.gz`, + 'HTTPS_PROXY and NO_PROXY are honoured. "pascal mcp connect" needs no web runtime.', + ].join('\n'), + ) + } +} + +/** + * Downloads and extraction stay out of `runtime/`: `installRuntimeDirectory` deletes every + * `.install-*` directory there before it copies, which would race a partial extraction. + */ +async function withWorkDirectory( + paths: PascalPaths, + action: (directory: string) => Promise, +): Promise { + await mkdir(paths.tmp, { recursive: true, mode: 0o700 }) + const workDirectory = await mkdtemp(path.join(paths.tmp, 'runtime-')) + try { + return await action(workDirectory) + } finally { + await rm(workDirectory, { recursive: true, force: true }) + } +} diff --git a/packages/cli/src/runtime.test.ts b/packages/cli/src/runtime.test.ts index 2fe7c21214..1e48a58347 100644 --- a/packages/cli/src/runtime.test.ts +++ b/packages/cli/src/runtime.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterAll, afterEach, describe, expect, test } from 'bun:test' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import http from 'node:http' import os from 'node:os' @@ -10,15 +10,22 @@ import { stopEditor, waitForHealth, } from './editor-process.js' +import { getMcpServiceStatus } from './mcp-service.js' import { resolvePascalPaths } from './paths.js' import { installBundledRuntime, readActiveRuntime } from './runtime.js' +import { writeFakeMcpService } from './test-support/fake-mcp-service.js' const roots: string[] = [] +/** The MCP service ships with the CLI, so it is injected instead of staged in the runtime. */ +const serviceRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-test-service-')) +process.env.PASCAL_MCP_SERVICE_PATH = await writeFakeMcpService(serviceRoot) afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) +afterAll(() => rm(serviceRoot, { recursive: true, force: true })) + describe('managed runtime', () => { test('installs a bundled runtime outside the package-runner cache', async () => { const root = await temporaryRoot() @@ -39,24 +46,43 @@ describe('managed runtime', () => { await mkdir(paths.data, { recursive: true }) await writeFile(paths.database, 'persistent') - const started = await startEditor({ paths, sourceDirectory: source }) + const started = await startEditor({ paths, runtimeSource: source }) expect(started.alreadyRunning).toBe(false) expect((await getEditorStatus(paths)).healthy).toBe(true) - expect((await startEditor({ paths, sourceDirectory: source })).alreadyRunning).toBe(true) + expect((await startEditor({ paths, runtimeSource: source })).alreadyRunning).toBe(true) expect(await stopEditor(paths)).toBe(true) expect((await getEditorStatus(paths)).running).toBe(false) expect(await Bun.file(paths.database).text()).toBe('persistent') }) + test('preserves a configured Mint host origin in the editor process', async () => { + const root = await temporaryRoot() + const source = await fakeRuntime(root, '1.2.3') + const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) + const previousMintOrigin = process.env.MINT_PASCAL_HOST_ORIGIN + process.env.MINT_PASCAL_HOST_ORIGIN = 'https://pascal.example.com' + + try { + const started = await startEditor({ paths, runtimeSource: source }) + const response = await fetch(`http://127.0.0.1:${started.state.port}/mint-origin`) + + expect(await response.text()).toBe('https://pascal.example.com') + } finally { + await stopEditor(paths) + if (previousMintOrigin === undefined) delete process.env.MINT_PASCAL_HOST_ORIGIN + else process.env.MINT_PASCAL_HOST_ORIGIN = previousMintOrigin + } + }) + test('serializes concurrent starts into one managed editor', async () => { const root = await temporaryRoot() const source = await fakeRuntime(root, '1.2.3') const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) const [first, second] = await Promise.all([ - startEditor({ paths, port: 0, sourceDirectory: source }), - startEditor({ paths, port: 0, sourceDirectory: source }), + startEditor({ paths, port: 0, runtimeSource: source }), + startEditor({ paths, port: 0, runtimeSource: source }), ]) expect(first.state.pid).toBe(second.state.pid) @@ -80,7 +106,7 @@ describe('managed runtime', () => { const started = await startEditor({ paths, port: address.port, - sourceDirectory: source, + runtimeSource: source, }) expect(started.state.port).not.toBe(address.port) @@ -153,7 +179,7 @@ describe('managed runtime', () => { const active = await installBundledRuntime(paths, source) await rm(path.join(active.directory, 'apps/editor/server.js')) - const started = await startEditor({ paths, port: 0, sourceDirectory: source }) + const started = await startEditor({ paths, port: 0, runtimeSource: source }) expect(started.state.version).toBe('1.2.3') expect((await getEditorStatus(paths)).healthy).toBe(true) @@ -168,7 +194,7 @@ describe('managed runtime', () => { const active = await installBundledRuntime(paths, source) await writeFile(path.join(active.directory, 'runtime-manifest.json'), '{not-json') - const started = await startEditor({ paths, port: 0, sourceDirectory: source }) + const started = await startEditor({ paths, port: 0, runtimeSource: source }) expect(started.state.version).toBe('1.2.3') expect((await getEditorStatus(paths)).healthy).toBe(true) @@ -182,7 +208,7 @@ describe('managed runtime', () => { await mkdir(paths.run, { recursive: true }) await writeFile(paths.currentRuntime, '{not-json') - const started = await startEditor({ paths, port: 0, sourceDirectory: source }) + const started = await startEditor({ paths, port: 0, runtimeSource: source }) expect(started.state.version).toBe('1.2.3') expect((await getEditorStatus(paths)).healthy).toBe(true) @@ -206,7 +232,7 @@ describe('managed runtime', () => { const root = await temporaryRoot() const source = await fakeRuntime(root, '1.2.3') const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) - const started = await startEditor({ paths, port: 0, sourceDirectory: source }) + const started = await startEditor({ paths, port: 0, runtimeSource: source }) await writeFile( paths.state, `${JSON.stringify({ ...started.state, instanceId: 'no-longer-healthy' }, null, 2)}\n`, @@ -220,7 +246,7 @@ describe('managed runtime', () => { const root = await temporaryRoot() const source = await fakeRuntime(root, '1.2.3') const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) - const started = await startEditor({ paths, port: 0, sourceDirectory: source }) + const started = await startEditor({ paths, port: 0, runtimeSource: source }) await writeFile(path.join(started.state.runtimeDirectory, 'runtime-manifest.json'), '{not-json') await writeFile( paths.state, @@ -235,7 +261,7 @@ describe('managed runtime', () => { const firstSource = await fakeRuntime(root, '1.2.3') const brokenSource = await fakeRuntime(root, '2.0.0', false) const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) - await startEditor({ paths, port: 0, sourceDirectory: firstSource }) + await startEditor({ paths, port: 0, runtimeSource: firstSource }) const candidate = await installBundledRuntime(paths, brokenSource, { activate: false }) await expect(activateEditorRuntime(paths, candidate)).rejects.toMatchObject({ @@ -246,25 +272,24 @@ describe('managed runtime', () => { await stopEditor(paths) }) - test('upgrades a running editor state that predates managed MCP', async () => { + test('restarts the editor and repoints MCP when a new runtime is activated', async () => { const root = await temporaryRoot() const firstSource = await fakeRuntime(root, '1.2.3') const secondSource = await fakeRuntime(root, '2.0.0') const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) - const started = await startEditor({ paths, sourceDirectory: firstSource }) - const oldMcpPid = started.state.mcp?.pid - if (!oldMcpPid) throw new Error('test MCP did not start') - process.kill(oldMcpPid, 'SIGTERM') - await waitUntilStopped(oldMcpPid) - const legacyState = { ...started.state, mcp: undefined } - await writeFile(paths.state, `${JSON.stringify(legacyState, null, 2)}\n`) - await rm(paths.mcpToken, { force: true }) + const started = await startEditor({ paths, runtimeSource: firstSource }) const candidate = await installBundledRuntime(paths, secondSource, { activate: false }) const result = await activateEditorRuntime(paths, candidate) expect(result.restarted).toBe(true) - expect((await getEditorStatus(paths)).healthy).toBe(true) + const status = await getEditorStatus(paths) + expect(status.healthy).toBe(true) + expect(status.state?.version).toBe('2.0.0') + expect(status.state?.pid).not.toBe(started.state.pid) + const mcp = await getMcpServiceStatus(paths) + expect(mcp.healthy).toBe(true) + expect(mcp.state?.editorOrigin).toBe(status.state?.url ?? '') await stopEditor(paths) }) @@ -274,7 +299,7 @@ describe('managed runtime', () => { const secondSource = await fakeRuntime(root, '2.0.0') const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) await installBundledRuntime(paths, firstSource) - const seeded = await startEditor({ paths, port: 0, sourceDirectory: firstSource }) + const seeded = await startEditor({ paths, port: 0, runtimeSource: firstSource }) await stopEditor(paths) await writeFile(paths.state, `${JSON.stringify(seeded.state, null, 2)}\n`) const candidate = await installBundledRuntime(paths, secondSource, { activate: false }) @@ -293,35 +318,13 @@ async function temporaryRoot(): Promise { return root } -async function waitUntilStopped(pid: number): Promise { - const deadline = Date.now() + 2_000 - while (Date.now() < deadline) { - try { - process.kill(pid, 0) - } catch { - return - } - await Bun.sleep(20) - } - throw new Error(`process ${pid} did not stop`) -} - async function fakeRuntime(root: string, version: string, healthy = true): Promise { const runtime = path.join(root, `source-${version}`) const app = path.join(runtime, 'apps/editor') - const services = path.join(runtime, 'services') await mkdir(app, { recursive: true }) - await mkdir(services, { recursive: true }) await writeFile( path.join(runtime, 'runtime-manifest.json'), - JSON.stringify({ - schemaVersion: 1, - version, - entrypoint: 'apps/editor/server.js', - mcpEntrypoint: 'services/pascal-mcp.mjs', - healthPath: '/api/health', - mcpHealthPath: '/health', - }), + JSON.stringify({ schemaVersion: 2, version, entrypoint: 'apps/editor/server.js' }), ) await writeFile( path.join(app, 'server.js'), @@ -339,6 +342,10 @@ const server = http.createServer((request, response) => { })) return } + if (request.url === '/mint-origin') { + response.end(process.env.MINT_PASCAL_HOST_ORIGIN ?? '') + return + } response.end('{}') }) server.listen(Number(process.env.PORT), process.env.HOSTNAME) @@ -346,31 +353,5 @@ process.on('SIGTERM', () => server.close(() => process.exit(0))) ` : 'process.exit(1)\n', ) - await writeFile( - path.join(services, 'pascal-mcp.mjs'), - `import http from 'node:http' -const token = process.env.PASCAL_MCP_HTTP_TOKEN -const server = http.createServer((request, response) => { - if (request.headers.authorization !== \`Bearer \${token}\`) { - response.writeHead(401).end() - return - } - response.setHeader('content-type', 'application/json') - if (request.url === '/health') { - response.end(JSON.stringify({ - status: 'ok', - app: 'mcp', - version: process.env.PASCAL_RUNTIME_VERSION, - instanceId: process.env.PASCAL_INSTANCE_ID, - })) - return - } - response.writeHead(404).end('{}') -}) -const portIndex = process.argv.indexOf('--port') -server.listen(Number(process.argv[portIndex + 1]), '127.0.0.1') -process.on('SIGTERM', () => server.close(() => process.exit(0))) -`, - ) return runtime } diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index 004d48ad5e..495a726713 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -1,18 +1,14 @@ import { cp, mkdir, readdir, rename, rm, stat } from 'node:fs/promises' import path from 'node:path' -import { fileURLToPath } from 'node:url' import { CliError } from './errors.js' import { withFileLock } from './file-lock.js' import { readJsonFile, writeJsonFile } from './json-files.js' import type { PascalPaths } from './paths.js' export interface RuntimeManifest { - schemaVersion: 1 + schemaVersion: 2 version: string entrypoint: string - mcpEntrypoint: string - healthPath: string - mcpHealthPath: string } export interface ActiveRuntime { @@ -21,18 +17,6 @@ export interface ActiveRuntime { directory: string } -export function resolveBundledRuntimeDirectory( - environment: NodeJS.ProcessEnv = process.env, -): string { - if (environment.PASCAL_BUNDLED_RUNTIME_DIR) { - return path.resolve(environment.PASCAL_BUNDLED_RUNTIME_DIR) - } - const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)) - return path.basename(moduleDirectory) === 'dist' - ? path.join(moduleDirectory, 'runtime') - : path.resolve(moduleDirectory, '../dist/runtime') -} - export async function readRuntimeManifest(directory: string): Promise { let manifest: RuntimeManifest | null try { @@ -41,12 +25,9 @@ export async function readRuntimeManifest(directory: string): Promise( + paths: PascalPaths, + action: () => Promise, + options: { timeoutMs?: number } = {}, +): Promise { + return withFileLock( + path.join(paths.run, 'runtime-install.lock'), + 'install_locked', + 'Another Pascal runtime installation is active.', + action, + options, + ) +} + export async function installBundledRuntime( paths: PascalPaths, - sourceDirectory = resolveBundledRuntimeDirectory(), + sourceDirectory: string, + options: { activate?: boolean } = {}, +): Promise { + return withRuntimeInstallLock(paths, () => + installRuntimeDirectory(paths, sourceDirectory, options), + ) +} + +/** Requires `withRuntimeInstallLock`; call `installBundledRuntime` when no lock is held. */ +export async function installRuntimeDirectory( + paths: PascalPaths, + sourceDirectory: string, options: { activate?: boolean } = {}, ): Promise { const sourceManifest = await readRuntimeManifest(sourceDirectory) const targetDirectory = path.join(paths.runtime, sourceManifest.version) await mkdir(paths.runtime, { recursive: true, mode: 0o700 }) - - return withFileLock( - path.join(paths.run, 'runtime-install.lock'), - 'install_locked', - 'Another Pascal runtime installation is active.', - async () => { - await removeAbandonedInstallDirectories(paths.runtime) - const installed = await readInstalledManifest(targetDirectory) - if ( - installed?.version === sourceManifest.version && - (await isRuntimeValid(targetDirectory, sourceManifest.version)) - ) { - return options.activate === false - ? runtimeRecord(sourceManifest.version, targetDirectory) - : activateRuntime(paths, sourceManifest.version, targetDirectory) - } - const temporaryDirectory = path.join( - paths.runtime, - `.install-${sourceManifest.version}-${process.pid}`, - ) - await rm(temporaryDirectory, { recursive: true, force: true }) - await cp(sourceDirectory, temporaryDirectory, { recursive: true, dereference: false }) - await readRuntimeManifest(temporaryDirectory) - await rm(targetDirectory, { recursive: true, force: true }) - await rename(temporaryDirectory, targetDirectory) - return options.activate === false - ? runtimeRecord(sourceManifest.version, targetDirectory) - : activateRuntime(paths, sourceManifest.version, targetDirectory) - }, + await removeAbandonedInstallDirectories(paths.runtime) + const installed = await readInstalledManifest(targetDirectory) + if ( + installed?.version === sourceManifest.version && + (await isRuntimeValid(targetDirectory, sourceManifest.version)) + ) { + return options.activate === false + ? runtimeRecord(sourceManifest.version, targetDirectory) + : activateRuntime(paths, sourceManifest.version, targetDirectory) + } + const temporaryDirectory = path.join( + paths.runtime, + `.install-${sourceManifest.version}-${process.pid}`, ) + await rm(temporaryDirectory, { recursive: true, force: true }) + await cp(sourceDirectory, temporaryDirectory, { recursive: true, dereference: false }) + await readRuntimeManifest(temporaryDirectory) + await rm(targetDirectory, { recursive: true, force: true }) + await rename(temporaryDirectory, targetDirectory) + return options.activate === false + ? runtimeRecord(sourceManifest.version, targetDirectory) + : activateRuntime(paths, sourceManifest.version, targetDirectory) } export async function readActiveRuntime(paths: PascalPaths): Promise { @@ -157,6 +154,14 @@ export async function activateRuntime( return active } +export async function findInstalledRuntime( + paths: PascalPaths, + version: string, +): Promise { + const directory = path.join(paths.runtime, version) + return (await isRuntimeValid(directory, version)) ? runtimeRecord(version, directory) : null +} + function runtimeRecord(version: string, directory: string): ActiveRuntime { return { schemaVersion: 1, version, directory } } diff --git a/packages/cli/src/tar.test.ts b/packages/cli/src/tar.test.ts new file mode 100644 index 0000000000..566c2ec12c --- /dev/null +++ b/packages/cli/src/tar.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { createHash } from 'node:crypto' +import { createWriteStream } from 'node:fs' +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + stat, + symlink, + utimes, + writeFile, +} from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { createGzip } from 'node:zlib' +import { createRuntimeArchive, extractTarGzip, tarHeaderBlock } from './tar.js' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('runtime archive', () => { + test('writes the same bytes for the same tree regardless of timestamps', async () => { + const root = await temporaryRoot() + const source = path.join(root, 'runtime') + await mkdir(path.join(source, 'apps/editor/.next'), { recursive: true }) + await writeFile(path.join(source, 'runtime-manifest.json'), '{"schemaVersion":2}') + await writeFile(path.join(source, 'apps/editor/server.js'), 'console.log(1)\n') + await writeFile(path.join(source, 'apps/editor/.next/build.txt'), 'build\n') + /** Longer than the 100-byte ustar name field, so the archive needs a long-name entry. */ + const deep = path.join(source, 'apps/editor', 'a'.repeat(60), 'b'.repeat(60)) + await mkdir(deep, { recursive: true }) + await writeFile(path.join(deep, 'long-path.txt'), 'long\n') + const executable = path.join(source, 'apps/editor/run.sh') + await writeFile(executable, '#!/bin/sh\n') + await chmod(executable, 0o755) + + const first = path.join(root, 'first.tar.gz') + const firstResult = await createRuntimeArchive(source, first) + await utimes(path.join(source, 'apps/editor/server.js'), new Date(0), new Date(0)) + const second = path.join(root, 'second.tar.gz') + const secondResult = await createRuntimeArchive(source, second) + + expect(firstResult.entryCount).toBe(secondResult.entryCount) + expect(await sha256(first)).toBe(await sha256(second)) + + const target = path.join(root, 'extracted') + await extractTarGzip(first, target) + expect(await readFile(path.join(target, 'apps/editor/server.js'), 'utf8')).toBe( + 'console.log(1)\n', + ) + expect( + await readFile(path.join(target, deep.slice(source.length + 1), 'long-path.txt'), 'utf8'), + ).toBe('long\n') + expect((await stat(path.join(target, 'apps/editor/run.sh'))).mode & 0o111).not.toBe(0) + }) + + test('refuses to archive a symbolic link', async () => { + const root = await temporaryRoot() + const source = path.join(root, 'runtime') + await mkdir(source, { recursive: true }) + await writeFile(path.join(source, 'real.txt'), 'real\n') + await symlink('real.txt', path.join(source, 'link.txt')) + + await expect(createRuntimeArchive(source, path.join(root, 'out.tar.gz'))).rejects.toMatchObject( + { + code: 'archive_failed', + }, + ) + }) +}) + +describe('runtime archive extraction safety', () => { + test.each([ + ['a parent traversal', '../escaped.txt'], + ['a nested parent traversal', 'apps/../../escaped.txt'], + ['an absolute path', '/tmp/pascal-escaped.txt'], + ['a Windows drive path', 'C:/pascal-escaped.txt'], + ])('rejects %s', async (_label, name) => { + const root = await temporaryRoot() + const archive = path.join(root, 'malicious.tar.gz') + await writeArchive(archive, fileEntry(name, 'escaped\n')) + + await expect(extractTarGzip(archive, path.join(root, 'target'))).rejects.toMatchObject({ + code: 'invalid_runtime_archive', + }) + expect(await exists(path.join(root, 'escaped.txt'))).toBe(false) + expect(await exists('/tmp/pascal-escaped.txt')).toBe(false) + }) + + test('rejects a symbolic-link entry that would point out of the target', async () => { + const root = await temporaryRoot() + const archive = path.join(root, 'symlink.tar.gz') + await writeArchive(archive, [ + tarHeaderBlock({ name: 'apps/editor/escape', size: 0, mode: 0o777, typeflag: '2' }), + ]) + + await expect(extractTarGzip(archive, path.join(root, 'target'))).rejects.toMatchObject({ + code: 'invalid_runtime_archive', + }) + expect(await exists(path.join(root, 'target/apps/editor/escape'))).toBe(false) + }) + + test('rejects a hard-link entry', async () => { + const root = await temporaryRoot() + const archive = path.join(root, 'hardlink.tar.gz') + await writeArchive(archive, [ + tarHeaderBlock({ name: 'apps/editor/linked', size: 0, mode: 0o644, typeflag: '1' }), + ]) + + await expect(extractTarGzip(archive, path.join(root, 'target'))).rejects.toMatchObject({ + code: 'invalid_runtime_archive', + }) + }) + + test('rejects a header whose checksum was rewritten', async () => { + const root = await temporaryRoot() + const archive = path.join(root, 'tampered.tar.gz') + const [header, ...rest] = fileEntry('apps/editor/server.js', 'console.log(1)\n') + if (!header) throw new Error('the test archive has no header block') + const rewritten = Buffer.from(header) + rewritten.write('X', 0, 1, 'ascii') + await writeArchive(archive, [rewritten, ...rest]) + + await expect(extractTarGzip(archive, path.join(root, 'target'))).rejects.toMatchObject({ + code: 'invalid_runtime_archive', + }) + }) +}) + +async function temporaryRoot(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-tar-test-')) + roots.push(root) + return root +} + +function fileEntry(name: string, body: string): Buffer[] { + const data = Buffer.from(body, 'utf8') + const padded = Buffer.alloc(Math.ceil(data.byteLength / 512) * 512) + data.copy(padded) + return [tarHeaderBlock({ name, size: data.byteLength, mode: 0o644, typeflag: '0' }), padded] +} + +async function writeArchive(file: string, blocks: Buffer[]): Promise { + await pipeline( + Readable.from([Buffer.concat([...blocks, Buffer.alloc(1024)])]), + createGzip(), + createWriteStream(file), + ) +} + +async function sha256(file: string): Promise { + return createHash('sha256') + .update(await readFile(file)) + .digest('hex') +} + +async function exists(file: string): Promise { + try { + await stat(file) + return true + } catch { + return false + } +} diff --git a/packages/cli/src/tar.ts b/packages/cli/src/tar.ts new file mode 100644 index 0000000000..470a4213ea --- /dev/null +++ b/packages/cli/src/tar.ts @@ -0,0 +1,304 @@ +import { createReadStream, createWriteStream } from 'node:fs' +import { mkdir, readdir, rm, stat } from 'node:fs/promises' +import path from 'node:path' +import { Readable, type Writable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { createGunzip, createGzip } from 'node:zlib' +import { CliError } from './errors.js' + +const BLOCK_SIZE = 512 +const LONG_NAME_ENTRY = '././@LongLink' + +export interface TarHeaderFields { + name: string + size: number + mode: number + typeflag: string +} + +export interface RuntimeArchiveResult { + size: number + entryCount: number +} + +interface ArchiveEntry { + relative: string + absolute: string + directory: boolean + size: number + mode: number +} + +/** + * Writes a byte-for-byte reproducible tar.gz: entries sorted by path, zero mtime, zero + * uid/gid, empty owner names and normalized modes. Two runs over the same tree therefore + * produce the same SHA-256, which is what `dist/runtime-source.json` pins. + */ +export async function createRuntimeArchive( + sourceDirectory: string, + destinationFile: string, +): Promise { + const root = path.resolve(sourceDirectory) + const destination = path.resolve(destinationFile) + const entries = await collectEntries(root) + await mkdir(path.dirname(destination), { recursive: true }) + await rm(destination, { force: true }) + await pipeline( + Readable.from(archiveBlocks(entries), { objectMode: false }), + createGzip({ level: 9 }), + createWriteStream(destination), + ) + return { size: (await stat(destination)).size, entryCount: entries.length } +} + +export async function extractTarGzip(archiveFile: string, targetDirectory: string): Promise { + const root = path.resolve(targetDirectory) + await mkdir(root, { recursive: true, mode: 0o700 }) + const source = createReadStream(archiveFile) + const gunzip = createGunzip() + source.on('error', (error) => gunzip.destroy(error)) + const reader = new BlockReader(source.pipe(gunzip)) + let pendingLongName: string | null = null + try { + for (;;) { + const header = await reader.read(BLOCK_SIZE) + if (!header || isZeroBlock(header)) break + verifyChecksum(header) + const typeflag = String.fromCharCode(header[156] ?? 0) + const size = readOctal(header, 124, 12) + const mode = readOctal(header, 100, 8) + if (typeflag === 'L') { + const data = await reader.read(paddedSize(size)) + if (!data) throw invalidArchive('a long-name entry is truncated') + pendingLongName = data.subarray(0, size).toString('utf8').replace(/\0+$/, '') + continue + } + const name = pendingLongName ?? readHeaderString(header, 0, 100) + pendingLongName = null + if (typeflag !== '0' && typeflag !== '\0' && typeflag !== '5') { + throw invalidArchive(`entry ${JSON.stringify(name)} uses unsupported type "${typeflag}"`) + } + const destination = resolveEntryPath(root, name) + if (typeflag === '5') { + await mkdir(destination, { recursive: true, mode: 0o755 }) + continue + } + await mkdir(path.dirname(destination), { recursive: true, mode: 0o755 }) + await reader.writeTo( + createWriteStream(destination, { mode: (mode & 0o111) !== 0 ? 0o755 : 0o644 }), + size, + ) + const padding = paddedSize(size) - size + if (padding > 0 && !(await reader.read(padding))) { + throw invalidArchive(`entry ${JSON.stringify(name)} is truncated`) + } + } + } finally { + gunzip.destroy() + source.destroy() + } +} + +export function tarHeaderBlock(fields: TarHeaderFields): Buffer { + const block = Buffer.alloc(BLOCK_SIZE) + Buffer.from(fields.name, 'utf8').subarray(0, 100).copy(block, 0) + writeOctal(block, fields.mode & 0o7777, 100, 8) + writeOctal(block, 0, 108, 8) + writeOctal(block, 0, 116, 8) + writeOctal(block, fields.size, 124, 12) + writeOctal(block, 0, 136, 12) + block.write(fields.typeflag, 156, 1, 'ascii') + block.write('ustar\0', 257, 6, 'ascii') + block.write('00', 263, 2, 'ascii') + block.fill(0x20, 148, 156) + let checksum = 0 + for (const byte of block) checksum += byte + block.write(`${checksum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'ascii') + return block +} + +async function* archiveBlocks(entries: ArchiveEntry[]): AsyncGenerator { + for (const entry of entries) { + const name = entry.directory ? `${entry.relative}/` : entry.relative + const nameBytes = Buffer.from(name, 'utf8') + if (nameBytes.byteLength > 100) { + yield tarHeaderBlock({ + name: LONG_NAME_ENTRY, + size: nameBytes.byteLength + 1, + mode: 0o644, + typeflag: 'L', + }) + const data = Buffer.concat([nameBytes, Buffer.of(0)]) + yield data + yield* paddingBlocks(data.byteLength) + } + yield tarHeaderBlock({ + name, + size: entry.directory ? 0 : entry.size, + mode: entry.mode, + typeflag: entry.directory ? '5' : '0', + }) + if (entry.directory) continue + let written = 0 + for await (const chunk of createReadStream(entry.absolute)) { + const buffer = chunk as Buffer + written += buffer.byteLength + yield buffer + } + if (written !== entry.size) { + throw new CliError( + 'archive_failed', + `${entry.relative} changed size while the archive was being written.`, + ) + } + yield* paddingBlocks(written) + } + yield Buffer.alloc(2 * BLOCK_SIZE) +} + +function* paddingBlocks(size: number): Generator { + const padding = (BLOCK_SIZE - (size % BLOCK_SIZE)) % BLOCK_SIZE + if (padding > 0) yield Buffer.alloc(padding) +} + +async function collectEntries(root: string): Promise { + const entries: ArchiveEntry[] = [] + const walk = async (directory: string, prefix: string): Promise => { + for (const child of await readdir(directory, { withFileTypes: true })) { + const absolute = path.join(directory, child.name) + const relative = prefix ? `${prefix}/${child.name}` : child.name + if (child.isSymbolicLink()) { + throw new CliError('archive_failed', `Cannot archive the symbolic link ${relative}.`) + } + if (child.isDirectory()) { + entries.push({ relative, absolute, directory: true, size: 0, mode: 0o755 }) + await walk(absolute, relative) + continue + } + if (!child.isFile()) { + throw new CliError('archive_failed', `Cannot archive the special file ${relative}.`) + } + const info = await stat(absolute) + entries.push({ + relative, + absolute, + directory: false, + size: info.size, + mode: (info.mode & 0o111) !== 0 ? 0o755 : 0o644, + }) + } + } + await walk(root, '') + return entries.sort((left, right) => + Buffer.compare(Buffer.from(left.relative, 'utf8'), Buffer.from(right.relative, 'utf8')), + ) +} + +class BlockReader { + private readonly iterator: AsyncIterator + private pending: Buffer = Buffer.alloc(0) + + constructor(stream: Readable) { + this.iterator = stream[Symbol.asyncIterator]() as AsyncIterator + } + + async read(size: number): Promise { + if (size === 0) return Buffer.alloc(0) + while (this.pending.byteLength < size) { + const next = await this.iterator.next() + if (next.done) break + this.pending = + this.pending.byteLength === 0 + ? Buffer.from(next.value) + : Buffer.concat([this.pending, next.value]) + } + if (this.pending.byteLength < size) return null + const result = this.pending.subarray(0, size) + this.pending = this.pending.subarray(size) + return result + } + + async writeTo(target: Writable, size: number): Promise { + let remaining = size + try { + while (remaining > 0) { + const chunk = await this.read(Math.min(remaining, 1024 * 1024)) + if (!chunk) throw invalidArchive('an entry ends before its recorded size') + remaining -= chunk.byteLength + if (!target.write(chunk)) { + await new Promise((resolve, reject) => { + target.once('drain', resolve) + target.once('error', reject) + }) + } + } + } catch (error) { + target.destroy() + throw error + } + await new Promise((resolve, reject) => { + target.once('error', reject) + target.end(resolve) + }) + } +} + +function resolveEntryPath(root: string, name: string): string { + const normalized = name.replace(/\/+$/, '') + const segments = normalized.split('/') + if ( + !normalized || + normalized.includes('\0') || + normalized.startsWith('/') || + path.isAbsolute(normalized) || + /^[A-Za-z]:/.test(normalized) || + segments.some((segment) => segment === '..' || segment === '') + ) { + throw invalidArchive(`entry ${JSON.stringify(name)} is not a safe relative path`) + } + const destination = path.resolve(root, ...segments) + if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) { + throw invalidArchive(`entry ${JSON.stringify(name)} escapes the extraction directory`) + } + return destination +} + +function verifyChecksum(header: Buffer): void { + const expected = readOctal(header, 148, 8) + let checksum = 0 + for (let index = 0; index < BLOCK_SIZE; index += 1) { + checksum += index >= 148 && index < 156 ? 0x20 : (header[index] ?? 0) + } + if (checksum !== expected) throw invalidArchive('an entry header checksum does not match') +} + +function isZeroBlock(block: Buffer): boolean { + return block.every((byte) => byte === 0) +} + +function paddedSize(size: number): number { + return size + ((BLOCK_SIZE - (size % BLOCK_SIZE)) % BLOCK_SIZE) +} + +function readHeaderString(block: Buffer, offset: number, length: number): string { + const field = block.subarray(offset, offset + length) + const end = field.indexOf(0) + return field.subarray(0, end === -1 ? field.byteLength : end).toString('utf8') +} + +function readOctal(block: Buffer, offset: number, length: number): number { + const text = readHeaderString(block, offset, length).trim() + if (!/^[0-7]*$/.test(text)) throw invalidArchive('an entry header field is not octal') + return text ? Number.parseInt(text, 8) : 0 +} + +function writeOctal(block: Buffer, value: number, offset: number, length: number): void { + block.write(`${value.toString(8).padStart(length - 1, '0')}\0`, offset, length, 'ascii') +} + +function invalidArchive(reason: string): CliError { + return new CliError( + 'invalid_runtime_archive', + `The Pascal web runtime archive is invalid: ${reason}.`, + ) +} diff --git a/packages/cli/src/test-support/fake-mcp-service.ts b/packages/cli/src/test-support/fake-mcp-service.ts new file mode 100644 index 0000000000..7dd70fed55 --- /dev/null +++ b/packages/cli/src/test-support/fake-mcp-service.ts @@ -0,0 +1,47 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' + +/** + * A stand-in for the bundled `services/pascal-mcp.mjs`: it answers the authenticated health + * probe the CLI uses to identify its own MCP process, and records the environment it was + * started with so tests can assert the editor origin handed to it. + */ +const FAKE_MCP_SERVICE = `import { writeFileSync } from 'node:fs' +import http from 'node:http' +const token = process.env.PASCAL_MCP_HTTP_TOKEN +const server = http.createServer((request, response) => { + if (request.headers.authorization !== \`Bearer \${token}\`) { + response.writeHead(401).end() + return + } + response.setHeader('content-type', 'application/json') + if (request.url === '/health') { + response.end(JSON.stringify({ + status: 'ok', + app: 'mcp', + version: process.env.PASCAL_RUNTIME_VERSION, + instanceId: process.env.PASCAL_INSTANCE_ID, + editorOrigin: process.env.PASCAL_EDITOR_ORIGIN ?? null, + })) + return + } + response.writeHead(404).end('{}') +}) +const portIndex = process.argv.indexOf('--port') +server.listen(Number(process.argv[portIndex + 1]), '127.0.0.1') +if (process.env.PASCAL_MCP_TEST_RECORD) { + writeFileSync(process.env.PASCAL_MCP_TEST_RECORD, JSON.stringify({ + pid: process.pid, + editorOrigin: process.env.PASCAL_EDITOR_ORIGIN ?? null, + dataDirectory: process.env.PASCAL_DATA_DIR, + })) +} +process.on('SIGTERM', () => server.close(() => process.exit(0))) +` + +export async function writeFakeMcpService(directory: string): Promise { + await mkdir(directory, { recursive: true }) + const servicePath = path.join(directory, 'pascal-mcp.mjs') + await writeFile(servicePath, FAKE_MCP_SERVICE) + return servicePath +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 8a0ae091f9..2cb2be14e6 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -9,5 +9,5 @@ "types": ["node"] }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "scripts"] + "exclude": ["node_modules", "dist", "**/*.test.ts", "src/test-support", "scripts"] } diff --git a/packages/core/README.md b/packages/core/README.md index 2f39c6ff83..e9edb80cf8 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -23,6 +23,9 @@ npm install react three @react-three/fiber @react-three/drei - **Spatial Grid** - Collision detection and placement validation - **Event Bus** - Typed event emitter for inter-component communication - **Asset Storage** - IndexedDB-based file storage for user-uploaded assets +- **Capture Contracts** (`@pascal-app/core/capture`) - Versioned capture-session manifests, + normalized stream descriptors, packet headers, and transport-neutral static/live `CaptureSource` + implementations ## Usage @@ -78,6 +81,26 @@ Load the plugin before mounting `@pascal-app/viewer`. See the [`@pascal-app/viewer` quick start](https://github.com/pascalorg/editor/tree/main/packages/viewer#usage) for a React example. +## Capture Sessions + +Capture contracts are a self-contained subpath β€” no React, no Three.js, no prescribed transport: + +```typescript +import { createHttpCaptureSource, type CaptureSessionLocator } from '@pascal-app/core/capture' + +const locator: CaptureSessionLocator = { + sessionId: 'capture_123', + manifestUrl: '/api/captures/capture_123/manifest', +} + +const source = createHttpCaptureSource(locator, { credentials: 'include' }) +const descriptor = await source.describe() +``` + +For live producers, use `PushCaptureSource` directly or implement `CaptureSource.subscribe()` with +the same descriptor and packet event contract. The reference renderers that consume these sources +ship in [`@pascal-app/viewer/capture`](https://github.com/pascalorg/editor/tree/main/packages/viewer#capture-sessions). + ## License MIT diff --git a/packages/core/bunfig.toml b/packages/core/bunfig.toml new file mode 100644 index 0000000000..eec7d338da --- /dev/null +++ b/packages/core/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-preload-three.ts"] + +[test] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/packages/core/package.json b/packages/core/package.json index 1d6ff58f0e..e3dc0681fb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@pascal-app/core", - "version": "1.0.0-beta.5", + "version": "1.0.0", "description": "Core library for Pascal 3D building editor", "type": "module", "main": "./dist/index.js", @@ -21,6 +21,11 @@ "import": "./dist/utils/scene-migrations.js", "default": "./dist/utils/scene-migrations.js" }, + "./capture": { + "types": "./dist/capture/index.d.ts", + "import": "./dist/capture/index.js", + "default": "./dist/capture/index.js" + }, "./registry": { "types": "./dist/registry/index.d.ts", "import": "./dist/registry/index.js", @@ -73,10 +78,9 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.185" + "three": "^0.186" }, "dependencies": { - "@pascal-app/capture-protocol": "^1.0.0-beta.4", "dedent": "^1.7.1", "idb-keyval": "^6.2.2", "mitt": "^3.0.1", diff --git a/packages/capture-protocol/src/index.ts b/packages/core/src/capture/index.ts similarity index 100% rename from packages/capture-protocol/src/index.ts rename to packages/core/src/capture/index.ts diff --git a/packages/capture-protocol/src/schema.test.ts b/packages/core/src/capture/schema.test.ts similarity index 100% rename from packages/capture-protocol/src/schema.test.ts rename to packages/core/src/capture/schema.test.ts diff --git a/packages/capture-protocol/src/schema.ts b/packages/core/src/capture/schema.ts similarity index 100% rename from packages/capture-protocol/src/schema.ts rename to packages/core/src/capture/schema.ts diff --git a/packages/capture-protocol/src/source.test.ts b/packages/core/src/capture/source.test.ts similarity index 100% rename from packages/capture-protocol/src/source.test.ts rename to packages/core/src/capture/source.test.ts diff --git a/packages/capture-protocol/src/source.ts b/packages/core/src/capture/source.ts similarity index 100% rename from packages/capture-protocol/src/source.ts rename to packages/core/src/capture/source.ts diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts index d96ee2f36e..f84ed61625 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts @@ -4,7 +4,7 @@ import { encodeTerrainField } from '../../lib/terrain-codec' import { applyHeightPatch, createTerrainField, flattenPatch } from '../../lib/terrain-field' import { nodeRegistry, registerNode } from '../../registry' import type { AnyNodeDefinition } from '../../registry/types' -import type { AnyNode, AnyNodeId } from '../../schema' +import { type AnyNode, type AnyNodeId, ItemNode, LevelNode, SlabNode, WallNode } from '../../schema' import useLiveTerrain from '../../store/use-live-terrain' import useScene, { clearSceneHistory } from '../../store/use-scene' import { spatialGridManager } from './spatial-grid-manager' @@ -565,3 +565,221 @@ describe('spatial-grid sync dirty rules (terrain support)', () => { expect(marked).toEqual(['wall_ground', 'wall_fill', 'slab_fill', 'column_a']) }) }) + +describe('temporal writes update slab support dependencies', () => { + let stop = () => {} + let restore = () => {} + beforeEach(() => { + restore = nodeRegistry._snapshot() + nodeRegistry._register({ + kind: 'item', + schemaVersion: 1, + schema: ItemNode, + capabilities: { + floorPlaced: { footprint: () => ({ dimensions: [0.2, 1, 0.2], rotation: [0, 0, 0] }) }, + }, + } as never) + spatialGridManager.clear() + }) + afterEach(() => { + stop() + restore() + spatialGridManager.clear() + clearSceneHistory() + }) + + test('undo/redo of wall thickness re-elevates an unchanged item on the former rendered slab band', async () => { + const level = LevelNode.parse({ id: 'level_band_history' }) + const wall = WallNode.parse({ + id: 'wall_band_history', + parentId: level.id, + start: [4, 0], + end: [4, 4], + thickness: 0.8, + }) + const slab = SlabNode.parse({ + parentId: level.id, + polygon: SQUARE, + elevation: 0.4, + thickness: 0.4, + }) + const item = ItemNode.parse({ + parentId: level.id, + position: [4.45, 0, 2], + asset: { + id: 'test', + name: 'test', + category: 'test', + thumbnail: '', + src: '/test.glb', + dimensions: [0.2, 1, 0.2], + }, + }) + const remote = { ...item, id: 'item_remote_band', position: [20, 0, 20] } as AnyNode + const interior = { ...item, id: 'item_interior_band', position: [2, 0, 2] } as AnyNode + const interiorWall = WallNode.parse({ + id: 'wall_interior_band', + parentId: level.id, + start: [1, 1], + end: [2, 1], + }) + const upper = LevelNode.parse({ id: 'level_other_band', level: 1 }) + const upperItem = { ...item, id: 'item_upper_band', parentId: upper.id } as AnyNode + const upperWall = { ...wall, id: 'wall_upper_band', parentId: upper.id } as AnyNode + const upperSlab = { ...slab, id: 'slab_upper_band', parentId: upper.id } as AnyNode + useScene.setState({ + nodes: nodesFor( + level, + wall, + slab, + item, + remote, + interior, + interiorWall, + upper, + upperItem, + upperWall, + upperSlab, + ), + dirtyNodes: new Set(), + readOnly: false, + }) + clearSceneHistory() + stop = initSpatialGridSync() + const elevation = () => + spatialGridManager.getSlabSupportForItem(level.id, item.position, [0.2, 1, 0.2], [0, 0, 0]) + .elevation + expect(elevation()).toBeCloseTo(0.4) + useScene.setState({ + nodes: { ...useScene.getState().nodes, [wall.id]: { ...wall, thickness: 0.1 } }, + }) + expect(elevation()).toBe(0) + expect(useScene.getState().dirtyNodes.has(item.id)).toBe(true) + for (const [jump, expected] of [ + [useScene.temporal.getState().undo, 0.4], + [useScene.temporal.getState().redo, 0], + ] as const) { + useScene.getState().dirtyNodes.clear() + jump() + // The support subscription runs on the write, before the temporal microtask. + expect(useScene.getState().dirtyNodes.has(item.id)).toBe(true) + expect(elevation()).toBeCloseTo(expected) + await Promise.resolve() + expect(useScene.getState().nodes[item.id]).toBe(item) + expect(useScene.getState().nodes[slab.id]).toBe(slab) + for (const unaffected of [ + remote, + interior, + interiorWall, + upper, + upperItem, + upperWall, + upperSlab, + ]) { + expect(useScene.getState().dirtyNodes.has(unaffected.id)).toBe(false) + } + } + }) + + test('slab reparent and undo mark covering dependents below both parent levels', async () => { + const levels = [0, 1, 2, 3].map((ordinal) => + makeLevel(`level_${ordinal}`, ordinal, 2.5, [ + `wall_covering_${ordinal}`, + `ceiling_covering_${ordinal}`, + ...(ordinal === 2 ? ['slab_reparent'] : []), + ]), + ) + const consumers = levels.flatMap((level, ordinal) => [ + { + ...makeChild(`wall_covering_${ordinal}`, 'wall', level.id), + start: [20, 0], + end: [24, 0], + } as AnyNode, + makeChild(`ceiling_covering_${ordinal}`, 'ceiling', level.id), + ]) + const slab = makeSlab('slab_reparent', 'level_2') + useScene.setState({ + nodes: nodesFor(...levels, ...consumers, slab), + rootNodeIds: levels.map((level) => level.id), + installedPlugins: [], + dirtyNodes: new Set(), + readOnly: false, + }) + clearSceneHistory() + stop = initSpatialGridSync() + const coveringDirtyIds = () => dirtyIds().filter((id) => id.includes('_covering_')) + const expected = [ + 'ceiling_covering_1', + 'ceiling_covering_2', + 'wall_covering_1', + 'wall_covering_2', + ] + useScene.getState().dirtyNodes.clear() + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + [slab.id]: { ...slab, parentId: 'level_3' } as AnyNode, + level_2: { ...levels[2]!, children: ['wall_covering_2', 'ceiling_covering_2'] } as AnyNode, + level_3: { + ...levels[3]!, + children: ['wall_covering_3', 'ceiling_covering_3', slab.id], + } as AnyNode, + }, + }) + await Promise.resolve() + expect(coveringDirtyIds()).toEqual(expected) + + useScene.getState().dirtyNodes.clear() + useScene.temporal.getState().undo() + await Promise.resolve() + expect(useScene.getState().nodes[slab.id]?.parentId).toBe('level_2') + expect(coveringDirtyIds()).toEqual(expected) + }) + + test('slab elevation and level height subscribers fire during real temporal restoration', async () => { + const level = LevelNode.parse({ + id: 'level_vertical_history', + children: ['wall_vertical_history'], + }) + const wall = WallNode.parse({ + id: 'wall_vertical_history', + parentId: level.id, + start: [0, 0], + end: [4, 0], + }) + const slab = SlabNode.parse({ + parentId: level.id, + polygon: SQUARE, + elevation: 1, + thickness: 0.1, + }) + const item = ItemNode.parse({ + parentId: level.id, + position: [2, 0, 2], + asset: { id: 'test', name: 'test', category: 'test', thumbnail: '', src: '/test.glb' }, + }) + useScene.setState({ + nodes: nodesFor(level, wall, slab, item), + dirtyNodes: new Set(), + readOnly: false, + }) + clearSceneHistory() + stop = initSpatialGridSync() + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + [slab.id]: { ...slab, elevation: 2 }, + [level.id]: { ...level, height: 4 }, + }, + }) + useScene.getState().dirtyNodes.clear() + useScene.temporal.getState().undo() + expect(useScene.getState().dirtyNodes.has(item.id)).toBe(true) + expect(useScene.getState().dirtyNodes.has(wall.id)).toBe(true) + expect( + spatialGridManager.getSlabSupportForItem(level.id, item.position, [0.2, 1, 0.2], [0, 0, 0]) + .elevation, + ).toBe(1) + await Promise.resolve() + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index d05d0d68e4..01869c84c1 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -1,3 +1,4 @@ +import { subtractPolygonsFromPolygon } from '../../lib/polygon-union' import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import { isLevelAtSiteDatum, isLevelBaseConsumer } from '../../lib/terrain-support' import { nodeRegistry } from '../../registry' @@ -111,6 +112,38 @@ export function initSpatialGridSync(): () => void { // Subscribe to all changes const unsubscribeScene = store.subscribe((state, prevState) => { + if (state.nodes === prevState.nodes) return + const changedSlabContextLevels = new Set() + const checkSlabContext = (id: string) => { + const previous = prevState.nodes[id as AnyNodeId] + const next = state.nodes[id as AnyNodeId] + if (previous === next) return + const wallChanged = + (previous?.type === 'wall' || next?.type === 'wall') && + (previous?.type !== 'wall' || + next?.type !== 'wall' || + previous.parentId !== next.parentId || + previous.start !== next.start || + previous.end !== next.end || + previous.thickness !== next.thickness || + previous.curveOffset !== next.curveOffset) + const slabChanged = + (previous?.type === 'slab' || next?.type === 'slab') && + (previous?.type !== 'slab' || + next?.type !== 'slab' || + previous.parentId !== next.parentId || + previous.polygon !== next.polygon || + previous.elevation !== next.elevation) + if (!(wallChanged || slabChanged)) return + if (previous) changedSlabContextLevels.add(resolveLevelId(previous, prevState.nodes)) + if (next) changedSlabContextLevels.add(resolveLevelId(next, state.nodes)) + } + + for (const id in prevState.nodes) checkSlabContext(id) + for (const id in state.nodes) { + if (!prevState.nodes[id as AnyNodeId]) checkSlabContext(id) + } + // Detect added nodes for (const [id, node] of Object.entries(state.nodes)) { if (!prevState.nodes[id as AnyNode['id']]) { @@ -140,7 +173,7 @@ export function initSpatialGridSync(): () => void { // When a slab is removed, mark items/walls that were on it dirty (using current state) if (node.type === 'slab') { - markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) + markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty, prevState.nodes) markCoveringDependentsBelow(levelId, state.nodes, markDirty) } @@ -183,7 +216,13 @@ export function initSpatialGridSync(): () => void { const levelId = resolveLevelId(node, state.nodes) spatialGridManager.handleNodeUpdated(node, levelId) } - markSlabChangeDependents(prev as SlabNode, node as SlabNode, state.nodes, markDirty) + markSlabChangeDependents( + prev as SlabNode, + node as SlabNode, + state.nodes, + markDirty, + prevState.nodes, + ) } else if (node.type === 'level' && prev.type === 'level') { if (node.height !== prev.height) { markLevelHeightDependents(node as LevelNode, state.nodes, markDirty) @@ -210,6 +249,47 @@ export function initSpatialGridSync(): () => void { } } } + + // Unchanged slabs can lose an adopted wall band or a sibling seam. Their + // stored polygons cannot identify objects standing on the former boundary. + if (changedSlabContextLevels.size === 0) return + const beforeContext = slabBoundaryContext(prevState.nodes, changedSlabContextLevels) + const afterContext = slabBoundaryContext(state.nodes, changedSlabContextLevels) + for (const context of afterContext.values()) { + for (const slab of context.slabs) { + const previous = prevState.nodes[slab.id] + if (previous?.type !== 'slab') continue + if ( + slab.parentId !== previous.parentId || + slab.polygon !== previous.polygon || + slab.elevation !== previous.elevation || + slab.holes !== previous.holes + ) + continue + const previousContext = beforeContext.get(resolveLevelId(previous, prevState.nodes))! + const beforePolygon = cachedSlabPolygon(previous, previousContext) + const afterPolygon = cachedSlabPolygon(slab, context) + if ( + beforePolygon.length === afterPolygon.length && + beforePolygon.every((point, i) => arraysEqual(point, afterPolygon[i]!)) + ) + continue + // Support only changed in the gained/lost bands, not across the slab interior. + const changedBands = [ + ...subtractPolygonsFromPolygon(beforePolygon, [afterPolygon]), + ...subtractPolygonsFromPolygon(afterPolygon, [beforePolygon]), + ] + for (const polygon of changedBands) { + markNodesOverlappingPolygon( + resolveLevelId(slab, state.nodes), + polygon, + state.nodes, + markDirty, + context.consumers, + ) + } + } + } }) // Live terrain is deliberately not written into `useScene` per dab: doing so @@ -282,14 +362,16 @@ export function markSlabChangeDependents( next: SlabNode, nodes: Record, markDirty: (id: AnyNodeId) => void, + previousNodes = nodes, ) { const supportChanged = + next.parentId !== previous.parentId || next.polygon !== previous.polygon || next.elevation !== previous.elevation || next.holes !== previous.holes if (supportChanged) { - markNodesOverlappingSlab(previous, nodes, markDirty) + markNodesOverlappingSlab(previous, nodes, markDirty, previousNodes) markNodesOverlappingSlab(next, nodes, markDirty) } if (next.elevation !== previous.elevation) { @@ -300,7 +382,14 @@ export function markSlabChangeDependents( next.thickness !== previous.thickness || next.recessed !== previous.recessed ) { - markCoveringDependentsBelow(resolveLevelId(next, nodes), nodes, markDirty) + const nextLevelId = resolveLevelId(next, nodes) + markCoveringDependentsBelow(nextLevelId, nodes, markDirty) + if (next.parentId !== previous.parentId) { + const previousLevelId = resolveLevelId(previous, previousNodes) + if (previousLevelId !== nextLevelId) { + markCoveringDependentsBelow(previousLevelId, nodes, markDirty) + } + } } } @@ -396,22 +485,8 @@ export function markCoveringDependentsBelow( } } -/** - * Mark all floor items and walls that may be affected by a slab change as dirty. - */ -function markNodesOverlappingSlab( - slab: SlabNode, - nodes: Record, - markDirty: (id: AnyNodeId) => void, -) { - if (slab.polygon.length < 3) return +function renderableSlabPolygon(slab: SlabNode, nodes: Record) { const slabLevelId = resolveLevelId(slab, nodes) - - // Walls AND floor-placed nodes follow the slab's RENDERED footprint - // (band-adopted edges reach the wall's outer face), so the dirty gate - // must test the same polygon the support queries re-evaluate β€” a stored - // polygon that stops short of the wall body would otherwise never - // re-elevate nodes sitting over the adopted band. const levelWalls: WallNode[] = [] const siblingSlabs: SlabNode[] = [] for (const node of Object.values(nodes)) { @@ -425,9 +500,33 @@ function markNodesOverlappingSlab( siblingSlabs.push(node as SlabNode) } } - const renderedPolygon = getRenderableSlabPolygon(slab, { walls: levelWalls, siblingSlabs }) + return getRenderableSlabPolygon(slab, { walls: levelWalls, siblingSlabs }) +} - for (const node of Object.values(nodes)) { +/** + * Mark all floor items and walls that may be affected by a slab change as dirty. + */ +function markNodesOverlappingSlab( + slab: SlabNode, + nodes: Record, + markDirty: (id: AnyNodeId) => void, + contextNodes = nodes, +) { + if (slab.polygon.length < 3) return + const slabLevelId = resolveLevelId(slab, contextNodes) + const renderedPolygon = renderableSlabPolygon(slab, contextNodes) + + markNodesOverlappingPolygon(slabLevelId, renderedPolygon, nodes, markDirty) +} + +function markNodesOverlappingPolygon( + slabLevelId: string, + renderedPolygon: [number, number][], + nodes: Record, + markDirty: (id: AnyNodeId) => void, + candidates: Iterable = Object.values(nodes), +) { + for (const node of candidates) { if (node.type === 'wall') { const wall = node as WallNode if (resolveLevelId(node, nodes) !== slabLevelId) continue @@ -477,3 +576,42 @@ function markNodesOverlappingSlab( } } } + +type SlabBoundaryContext = { + walls: WallNode[] + slabs: SlabNode[] + consumers: AnyNode[] + polygons: Map +} + +function slabBoundaryContext(nodes: Record, levels: Set) { + const contexts = new Map() + for (const id in nodes) { + const node = nodes[id]! + const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced + if (node.type !== 'wall' && node.type !== 'slab' && !floorPlaced) continue + const levelId = resolveLevelId(node, nodes) + if (!levels.has(levelId)) continue + let context = contexts.get(levelId) + if (!context) { + context = { walls: [], slabs: [], consumers: [], polygons: new Map() } + contexts.set(levelId, context) + } + if (node.type === 'wall') context.walls.push(node) + if (node.type === 'slab') context.slabs.push(node) + if (node.type === 'wall' || floorPlaced) context.consumers.push(node) + } + return contexts +} + +function cachedSlabPolygon(slab: SlabNode, context: SlabBoundaryContext) { + let polygon = context.polygons.get(slab.id) + if (!polygon) { + polygon = getRenderableSlabPolygon(slab, { + walls: context.walls, + siblingSlabs: context.slabs.filter((sibling) => sibling.id !== slab.id), + }) + context.polygons.set(slab.id, polygon) + } + return polygon +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1d250404a9..5e263ce933 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -153,8 +153,12 @@ export { export { resolveSelectionProxyId, selectionProxyIdFromMetadata } from './lib/selection-proxy' export { getRenderableSlabPolygon, + prepareSlabPolygonContext, type SlabEdgeWallBandSnap, type SlabPolygonContext, + scopeSlabPolygonContext, + slabPolygonContextChanges, + slabPolygonContextForLevel, slabPolygonContextFromGeometry, snapSlabEdgeToWallBand, } from './lib/slab-polygon' @@ -311,6 +315,7 @@ export { type SceneSnapshot, subscribeSceneCommits, } from './store/history-control' +export { getHistoryDirtyNodeIds } from './store/history-invalidation' export { type ControlValue, type DoorAnimationState, @@ -395,12 +400,25 @@ export { isSplineFence, sampleFenceSpline, } from './systems/fence/fence-spline' +export { resolveRoofElevation, resolveRoofWallTopElevation } from './systems/roof/roof-elevation' +export { RoofElevationSystem } from './systems/roof/roof-elevation-system' +export { + fitRoofFootprint, + type RoofFootprintTarget, + resolveRoomRoofFootprint, + resolveRoomRoofFootprintOnLevel, +} from './systems/roof/roof-footprint' export { resolveSlabPlacementElevation } from './systems/slab/slab-placement' export { clampSlabElevationForWalls, getSlabElevationUpperBound, type SlabElevationClamp, } from './systems/slab/slab-support' +export { + createDefaultStairSegment, + createStairFlightFromStair, + type StairFlightOverrides, +} from './systems/stair/stair-flight' export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint' export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview' export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' diff --git a/packages/core/src/lib/slab-polygon.test.ts b/packages/core/src/lib/slab-polygon.test.ts index ef89df43e4..d3bdd555e2 100644 --- a/packages/core/src/lib/slab-polygon.test.ts +++ b/packages/core/src/lib/slab-polygon.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from 'bun:test' import { SlabNode, WallNode } from '../schema' import { pointInPolygon } from './polygon-relations' -import { getRenderableSlabPolygon, snapSlabEdgeToWallBand } from './slab-polygon' +import { + getRenderableSlabPolygon, + prepareSlabPolygonContext, + scopeSlabPolygonContext, + snapSlabEdgeToWallBand, +} from './slab-polygon' function wallOf(start: [number, number], end: [number, number], thickness = 0.1) { return WallNode.parse({ start, end, thickness }) @@ -908,3 +913,36 @@ describe('snapSlabEdgeToWallBand', () => { expect(snap!.edge[0][1]).toBeCloseTo(0) }) }) + +test('prepared bounds preserve unfiltered polygons across curves, long bands and floating seams', () => { + for (const angle of [0, 0.3, 1.2]) { + const rotate = ([x, z]: [number, number]): [number, number] => [ + x * Math.cos(angle) - z * Math.sin(angle), + x * Math.sin(angle) + z * Math.cos(angle), + ] + for (const thickness of [0.1, 0.4, 2]) { + for (const curveOffset of [0, 0.2, -0.3]) { + const walls = [ + WallNode.parse({ start: rotate([-10, 0]), end: rotate([10, 0]), thickness, curveOffset }), + WallNode.parse({ start: rotate([4, 0]), end: rotate([4, 6]), thickness }), + WallNode.parse({ start: [100, 100], end: [110, 100], thickness: 4 }), + ] + const slabs = [ + slabOf(roomA.map(rotate)), + slabOf(roomB.map(rotate), false, 0.4, 0.4), + slabOf(roomB.map(rotate), false, 2), + ] + const prepared = prepareSlabPolygonContext({ walls, siblingSlabs: slabs }) + for (const slab of slabs) { + const full = getRenderableSlabPolygon(slab, { + walls, + siblingSlabs: slabs.filter((other) => other.id !== slab.id), + }) + expect(getRenderableSlabPolygon(slab, scopeSlabPolygonContext(slab, prepared))).toEqual( + full, + ) + } + } + } + } +}) diff --git a/packages/core/src/lib/slab-polygon.ts b/packages/core/src/lib/slab-polygon.ts index e8fded0d15..3aa8ffc894 100644 --- a/packages/core/src/lib/slab-polygon.ts +++ b/packages/core/src/lib/slab-polygon.ts @@ -1,5 +1,5 @@ import type { GeometryContext } from '../registry/types' -import type { AnyNodeId, SlabNode, WallNode } from '../schema' +import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../schema' import { isCurvedWall, sampleWallCenterline } from '../systems/wall/wall-curve' import { getWallThickness } from '../systems/wall/wall-footprint' @@ -115,7 +115,9 @@ type Segment = [number, number, number, number] /** A sibling slab edge and the direction of its polygon interior. */ type NeighborSegment = { + slab: SlabNode segment: Segment + bounds: Bounds elevation: number /** Unit normal pointing into the sibling polygon at this edge. */ inwardX: number @@ -141,27 +143,34 @@ export function slabPolygonContextFromGeometry( ): SlabPolygonContext { if (!ctx) return { walls: [], siblingSlabs: [] } - const siblingSlabs = ctx.siblings.filter( - (node): node is SlabNode => node.type === 'slab', - ) as SlabNode[] + return { + ...slabPolygonContextForLevel(ctx.parent, ctx.resolve), + siblingSlabs: ctx.siblings.filter((node): node is SlabNode => node.type === 'slab'), + } +} +export function slabPolygonContextForLevel( + parent: AnyNode | null, + resolve: (id: AnyNodeId) => AnyNode | undefined, + fallbackSlabs: SlabNode[] = [], +): SlabPolygonContext { const walls: WallNode[] = [] - const parentChildIds = (ctx.parent as { children?: AnyNodeId[] } | null)?.children - if (Array.isArray(parentChildIds)) { - for (const childId of parentChildIds) { - const child = ctx.resolve(childId) - if ((child as { type?: string } | undefined)?.type === 'wall') { - walls.push(child as WallNode) - } - } + const siblingSlabs: SlabNode[] = [] + const childIds = (parent as { children?: AnyNodeId[] } | null)?.children + if (!Array.isArray(childIds)) { + return { walls, siblingSlabs: parent ? fallbackSlabs : [] } + } + for (const id of childIds) { + const child = resolve(id) + if (child?.type === 'wall') walls.push(child) + else if (child?.type === 'slab') siblingSlabs.push(child) } - return { walls, siblingSlabs } } export function getRenderableSlabPolygon( slabNode: SlabNode, - context: SlabPolygonContext, + context: SlabPolygonContext | PreparedSlabPolygonContext, ): Array<[number, number]> { const polygon = slabNode.polygon if (polygon.length < 3 || isFloatingSlab(slabNode)) { @@ -171,7 +180,8 @@ export function getRenderableSlabPolygon( const subSpans = computeEdgeSubSpans( polygon, slabNode.elevation ?? DEFAULT_SLAB_ELEVATION, - context, + 'wallCandidates' in context ? context : prepareSlabPolygonContext(context), + slabNode.id, ) if (subSpans.every((spans) => spans.length === 1 && spans[0]!.offset === 0)) { return polygon.map(([x, z]) => [x, z] as [number, number]) @@ -375,23 +385,107 @@ type EdgeSubSpan = { key: string } -/** - * Split every polygon edge at candidate span boundaries and classify - * each sub-span independently. Returns one non-empty span list per - * edge, covering [0, edgeLength] without gaps. - */ -function computeEdgeSubSpans( - polygon: Array<[number, number]>, - selfElevation: number, - context: SlabPolygonContext, -): EdgeSubSpan[][] { - const n = polygon.length +type Bounds = [number, number, number, number] - // Winding sign: the outward normal of an edge with direction `dir` is - // `s * (dirZ, -dirX)`, so a target lateral `L` measured along - // `(dirZ, -dirX)` is `s * L` along the outward normal. - const s = polygonWindingSign(polygon) +function polygonBounds(polygon: Array<[number, number]>): Bounds { + const bounds: Bounds = [Infinity, Infinity, -Infinity, -Infinity] + for (const [x, z] of polygon) { + bounds[0] = Math.min(bounds[0], x) + bounds[1] = Math.min(bounds[1], z) + bounds[2] = Math.max(bounds[2], x) + bounds[3] = Math.max(bounds[3], z) + } + return bounds +} + +function boundsOverlap(a: Bounds, b: Bounds, padding: number): boolean { + return ( + a[0] <= b[2] + padding && + a[2] >= b[0] - padding && + a[1] <= b[3] + padding && + a[3] >= b[1] - padding + ) +} + +export function scopeSlabPolygonContext( + slab: SlabNode, + context: PreparedSlabPolygonContext, +): PreparedSlabPolygonContext { + const bounds = polygonBounds(slab.polygon) + const wallCandidates = context.wallCandidates.filter((candidate) => + boundsOverlap(bounds, candidate.bounds, candidate.halfThickness + WALL_ADOPTION_TOLERANCE), + ) + // Keep the level-wide tolerance: even an unadopted wall can widen sibling + // breakpoint collection, which may affect short-span fusion. + const neighborSegments = context.neighborSegments.filter( + (neighbor) => + neighbor.slab.id !== slab.id && + boundsOverlap(bounds, neighbor.bounds, context.siblingBreakTolerance), + ) + return { + ...context, + walls: wallCandidates.map((candidate) => candidate.wall), + siblingSlabs: [...new Set(neighborSegments.map((neighbor) => neighbor.slab))], + wallCandidates, + neighborSegments, + siblingBreakTolerance: context.siblingBreakTolerance, + } +} + +export function slabPolygonContextChanges( + before: PreparedSlabPolygonContext, + after: PreparedSlabPolygonContext, +): (slab: SlabNode) => boolean { + if (before.siblingBreakTolerance !== after.siblingBreakTolerance) return () => true + const changedWalls = new Set() + const changedSlabs = new Set() + for (let i = 0; i < Math.max(before.walls.length, after.walls.length); i++) { + if (before.walls[i] === after.walls[i]) continue + if (before.walls[i]) changedWalls.add(before.walls[i]!) + if (after.walls[i]) changedWalls.add(after.walls[i]!) + } + for (let i = 0; i < Math.max(before.siblingSlabs.length, after.siblingSlabs.length); i++) { + if (before.siblingSlabs[i] === after.siblingSlabs[i]) continue + if (before.siblingSlabs[i]) changedSlabs.add(before.siblingSlabs[i]!) + if (after.siblingSlabs[i]) changedSlabs.add(after.siblingSlabs[i]!) + } + const walls = [...before.wallCandidates, ...after.wallCandidates].filter((candidate) => + changedWalls.has(candidate.wall), + ) + const neighbors = [...before.neighborSegments, ...after.neighborSegments].filter((neighbor) => + changedSlabs.has(neighbor.slab), + ) + return (slab) => { + const bounds = polygonBounds(slab.polygon) + return ( + walls.some((candidate) => + boundsOverlap(bounds, candidate.bounds, candidate.halfThickness + WALL_ADOPTION_TOLERANCE), + ) || + neighbors.some( + (neighbor) => + neighbor.slab.id !== slab.id && + boundsOverlap(bounds, neighbor.bounds, after.siblingBreakTolerance), + ) + ) + } +} + +type PreparedWallCandidate = WallCandidate & { bounds: Bounds } +type PreparedSlabPolygonContext = SlabPolygonContext & { + wallCache: WeakMap + neighborCache: WeakMap + wallCandidates: PreparedWallCandidate[] + neighborSegments: NeighborSegment[] + siblingBreakTolerance: number +} + +export function prepareSlabPolygonContext( + context: SlabPolygonContext, + previous?: PreparedSlabPolygonContext, +): PreparedSlabPolygonContext { + const wallCache = previous?.wallCache ?? new WeakMap() + const neighborCache = previous?.neighborCache ?? new WeakMap() const neighborSegments: NeighborSegment[] = [] for (const sibling of context.siblingSlabs) { // A floating deck keeps its drawn polygon, so it can't be a seam @@ -399,6 +493,12 @@ function computeEdgeSubSpans( // deck's stays put (asymmetric seam), and the higher/lower band rules // only describe room floors meeting under a wall. if (isFloatingSlab(sibling)) continue + const cached = neighborCache.get(sibling) + if (cached) { + neighborSegments.push(...cached) + continue + } + const segments: NeighborSegment[] = [] const siblingPolygon = sibling.polygon if (siblingPolygon.length < 2) continue const elevation = sibling.elevation ?? DEFAULT_SLAB_ELEVATION @@ -410,20 +510,37 @@ function computeEdgeSubSpans( const dz = to[1] - from[1] const length = Math.hypot(dx, dz) if (length < 1e-9) continue - neighborSegments.push({ + segments.push({ + slab: sibling, + bounds: polygonBounds([from, to]), segment: [from[0], from[1], to[0], to[1]], elevation, inwardX: (-siblingWinding * dz) / length, inwardZ: (siblingWinding * dx) / length, }) } + neighborCache.set(sibling, segments) + neighborSegments.push(...segments) } - const wallCandidates: WallCandidate[] = context.walls.map((wall) => ({ - wall, - segments: wallCenterlineSegments(wall), - halfThickness: getWallThickness(wall) / 2, - })) + const wallCandidates: PreparedWallCandidate[] = context.walls.map((wall) => { + const cached = wallCache.get(wall) + if (cached) return cached + const segments = wallCenterlineSegments(wall) + const candidate: PreparedWallCandidate = { + wall, + segments, + halfThickness: getWallThickness(wall) / 2, + bounds: polygonBounds( + segments.flatMap(([ax, az, bx, bz]) => [ + [ax, az], + [bx, bz], + ]), + ), + } + wallCache.set(wall, candidate) + return candidate + }) // Sibling breakpoints also matter for legacy face-aligned polygons up // to a full wall band away from the edge (the band-sibling interior @@ -438,6 +555,37 @@ function computeEdgeSubSpans( ) } + return { + ...context, + wallCache, + neighborCache, + wallCandidates, + neighborSegments, + siblingBreakTolerance, + } +} + +/** + * Split every polygon edge at candidate span boundaries and classify + * each sub-span independently. Returns one non-empty span list per + * edge, covering [0, edgeLength] without gaps. + */ +function computeEdgeSubSpans( + polygon: Array<[number, number]>, + selfElevation: number, + context: PreparedSlabPolygonContext, + slabId: SlabNode['id'], +): EdgeSubSpan[][] { + const n = polygon.length + + // Winding sign: the outward normal of an edge with direction `dir` is + // `s * (dirZ, -dirX)`, so a target lateral `L` measured along + // `(dirZ, -dirX)` is `s * L` along the outward normal. + const s = polygonWindingSign(polygon) + + const { wallCandidates, siblingBreakTolerance } = context + const neighborSegments = context.neighborSegments.filter((segment) => segment.slab.id !== slabId) + const subSpans: EdgeSubSpan[][] = [] for (let index = 0; index < n; index += 1) { const a = polygon[index]! diff --git a/packages/core/src/lib/space-detection-reconciliation.test.ts b/packages/core/src/lib/space-detection-reconciliation.test.ts new file mode 100644 index 0000000000..f591b20c86 --- /dev/null +++ b/packages/core/src/lib/space-detection-reconciliation.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from 'bun:test' +import { CeilingNode, SlabNode, WallNode } from '../schema' +import { + detectSpacesForLevel, + planAutoCeilingsForLevel, + planAutoSlabsForLevel, + surfaceTouchesRooms, +} from './space-detection' + +type Tuple = [number, number] + +function bounded(polygon: Tuple[]) { + return { + polygon: polygon.map(([x, y]) => ({ x, y })), + bbox: { + minX: Math.min(...polygon.map(([x]) => x)), + minY: Math.min(...polygon.map(([, y]) => y)), + maxX: Math.max(...polygon.map(([x]) => x)), + maxY: Math.max(...polygon.map(([, y]) => y)), + }, + } +} + +function rectangle(x: number, y: number, width: number, height: number): Tuple[] { + return [ + [x, y], + [x + width, y], + [x + width, y + height], + [x, y + height], + ] +} + +describe('surfaceTouchesRooms bounding-box rejection', () => { + const surface = bounded(rectangle(0, 0, 4, 4)) + const cases: Array<[string, Tuple[], boolean]> = [ + ['disjoint right', rectangle(5, 0, 4, 4), false], + ['disjoint left', rectangle(-5, 0, 4, 4), false], + ['disjoint above', rectangle(0, 5, 4, 4), false], + ['disjoint below', rectangle(0, -5, 4, 4), false], + ['touching edge', rectangle(4, 0, 4, 4), false], + ['touching corner', rectangle(4, 4, 4, 4), false], + ['overlapping', rectangle(2, 2, 4, 4), true], + ['contained room', rectangle(1, 1, 2, 2), true], + ['contained surface', rectangle(-1, -1, 6, 6), true], + ['identical', rectangle(0, 0, 4, 4), true], + [ + 'overlapping bounds without polygon coverage', + [ + [3, 6], + [6, 3], + [6, 6], + ], + false, + ], + ] + + for (const [name, polygon, expected] of cases) { + test(name, () => { + const room = bounded(polygon) + expect(surfaceTouchesRooms(surface, [room])).toBe(expected) + expect(surfaceTouchesRooms(room, [surface])).toBe(expected) + }) + } + + test('disjoint bounds skip coverage and touching bounds retain coverage', () => { + for (const x of [4, 5]) { + const room = bounded(rectangle(x, 0, 4, 4)) + let reads = 0 + const measuredRoom = { + bbox: room.bbox, + get polygon() { + reads += 1 + return room.polygon + }, + } + expect(surfaceTouchesRooms(surface, [measuredRoom])).toBe(false) + expect(reads > 0).toBe(x === 4) + } + }) + + test('continues past disjoint rooms and handles no rooms', () => { + expect(surfaceTouchesRooms(surface, [])).toBe(false) + expect(surfaceTouchesRooms(surface, [bounded(rectangle(5, 5, 1, 1)), surface])).toBe(true) + }) +}) + +describe('auto surface planner ring preservation', () => { + const vertices = rectangle(0, 0, 4, 3) + const walls = vertices.map((start, index) => + WallNode.parse({ start, end: vertices[(index + 1) % vertices.length] }), + ) + const { roomPolygons } = detectSpacesForLevel('level_rotation', walls) + const ring = roomPolygons[0]!.map(({ x, y }): Tuple => [x, y]) + + for (const kind of ['slab', 'ceiling'] as const) { + function plan(polygon: Tuple[]) { + if (kind === 'slab') { + const surface = SlabNode.parse({ polygon, autoFromWalls: true }) + return { surface, result: planAutoSlabsForLevel(roomPolygons, [surface]) } + } + const surface = CeilingNode.parse({ polygon, autoFromWalls: true }) + return { surface, result: planAutoCeilingsForLevel(roomPolygons, [surface]) } + } + + test(`${kind}: every exact start rotation preserves the existing polygon`, () => { + for (let offset = 0; offset < ring.length; offset += 1) { + const polygon = [...ring.slice(offset), ...ring.slice(0, offset)] + const { surface, result } = plan(polygon) + const existingPolygon = surface.polygon + expect(result.update).toEqual([]) + expect(result.create).toEqual([]) + expect(result.delete).toEqual([]) + expect(surface.polygon).toBe(existingPolygon) + expect(surface.polygon).toEqual(polygon) + } + }) + + test(`${kind}: changed geometry still updates even within signature rounding`, () => { + for (const delta of [0.2, 0.00001]) { + const polygon = ring.map(([x, y]): Tuple => [x, y]) + polygon[0]![0] += delta + const { surface, result } = plan(polygon) + expect(result.update).toEqual([{ id: surface.id, data: { polygon: ring } }]) + expect(result.create).toEqual([]) + expect(result.delete).toEqual([]) + } + }) + + test(`${kind}: reversed winding still updates`, () => { + const { surface, result } = plan([...ring].reverse()) + expect(result.update).toEqual([{ id: surface.id, data: { polygon: ring } }]) + expect(result.create).toEqual([]) + expect(result.delete).toEqual([]) + }) + } +}) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 3594b0c6cc..6a3bbc9751 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -281,10 +281,14 @@ function bboxOverlapArea(a: ReturnType, b: ReturnType, +) { if (subject.length < 3 || covers.length === 0) return 0 - const bbox = bboxOf(subject) + const bbox = subjectBounds ?? bboxOf(subject) const width = bbox.maxX - bbox.minX const height = bbox.maxY - bbox.minY @@ -898,6 +902,18 @@ function sameTuplePolygon(current: Array<[number, number]>, next: Array<[number, ) } +function sameTuplePolygonRotation(current: Array<[number, number]>, next: Array<[number, number]>) { + if (sameTuplePolygon(current, next)) return true + if (current.length !== next.length) return false + // Preserve the stored ring when extraction only changes its starting vertex. + return next.some((_, offset) => + current.every((point, index) => { + const candidate = next[(index + offset) % next.length]! + return point[0] === candidate[0] && point[1] === candidate[1] + }), + ) +} + function sameTuplePolygons( current: Array>, next: Array>, @@ -1334,13 +1350,27 @@ function buildSpace(levelId: string, room: ExtractedRoom): Space { type RoomSurface = SlabNodeType | CeilingNodeType -function surfaceTouchesRooms(surface: RoomSurface, rooms: ExtractedRoom[]) { - const polygon = surface.polygon.map(pointFromTuple) - return rooms.some( - (room) => - polygonCoverageRatio(polygon, [room.polygon]) > 0 || - polygonCoverageRatio(room.polygon, [polygon]) > 0, - ) +type BoundedPolygon = { + polygon: Point2D[] + bbox: ReturnType +} + +export function surfaceTouchesRooms(surface: BoundedPolygon, rooms: BoundedPolygon[]) { + const { polygon, bbox } = surface + return rooms.some((room) => { + if ( + bbox.maxX < room.bbox.minX || + room.bbox.maxX < bbox.minX || + bbox.maxY < room.bbox.minY || + room.bbox.maxY < bbox.minY + ) { + return false + } + return ( + polygonCoverageRatio(polygon, [room.polygon], bbox) > 0 || + polygonCoverageRatio(room.polygon, [polygon], room.bbox) > 0 + ) + }) } function roomsAreRelated(beforeRoom: ExtractedRoom, currentRoom: ExtractedRoom) { @@ -1772,7 +1802,7 @@ export function planAutoSlabsForLevel( holeMetadata: [], }) const data: Partial = {} - if (!sameTuplePolygon(slab.polygon, update.polygon)) data.polygon = update.polygon + if (!sameTuplePolygonRotation(slab.polygon, update.polygon)) data.polygon = update.polygon if (!sameTuplePolygons(slab.holes, openings.holes)) data.holes = openings.holes if (!sameHoleMetadata(slab.holeMetadata, openings.holeMetadata)) { data.holeMetadata = openings.holeMetadata @@ -1950,7 +1980,8 @@ export function planAutoCeilingsForLevel( holeMetadata: [], }) const data: Partial = {} - if (!sameTuplePolygon(ceiling.polygon, update.polygon)) data.polygon = update.polygon + if (!sameTuplePolygonRotation(ceiling.polygon, update.polygon)) + data.polygon = update.polygon if (!sameTuplePolygons(ceiling.holes, openings.holes)) data.holes = openings.holes if (!sameHoleMetadata(ceiling.holeMetadata, openings.holeMetadata)) { data.holeMetadata = openings.holeMetadata @@ -2247,11 +2278,14 @@ function runIndexedSpaceDetection( ) } - const scopedRooms = [...topologyDelta.beforeRooms, ...topologyDelta.currentRooms] + const scopedRooms = [...topologyDelta.beforeRooms, ...topologyDelta.currentRooms].map((room) => ({ + polygon: room.polygon, + bbox: bboxOf(room.polygon), + })) if (scopedRooms.length > 0) { - const unaffectedRooms = topologyDelta.allCurrentRooms.filter( - (room) => !topologyDelta.currentRooms.includes(room), - ) + const unaffectedRooms = topologyDelta.allCurrentRooms + .filter((room) => !topologyDelta.currentRooms.includes(room)) + .map((room) => ({ polygon: room.polygon, bbox: bboxOf(room.polygon) })) const currentChildren = levelChildren(nodes, levelId) const allSlabs: SlabNodeType[] = currentChildren .filter((node: any): node is SlabNodeType => node.type === 'slab') @@ -2259,16 +2293,16 @@ function runIndexedSpaceDetection( const allCeilings: CeilingNodeType[] = currentChildren .filter((node: any): node is CeilingNodeType => node.type === 'ceiling') .map((ceiling: CeilingNodeType) => CeilingNode.parse(ceiling)) - const slabs = allSlabs.filter( - (slab) => - surfaceTouchesRooms(slab, scopedRooms) && - (!slab.autoFromWalls || !surfaceTouchesRooms(slab, unaffectedRooms)), - ) - const ceilings = allCeilings.filter( - (ceiling) => - surfaceTouchesRooms(ceiling, scopedRooms) && - (!ceiling.autoFromWalls || !surfaceTouchesRooms(ceiling, unaffectedRooms)), - ) + const touchesScopedRooms = (surface: RoomSurface) => { + const polygon = surface.polygon.map(pointFromTuple) + const bounded = { polygon, bbox: bboxOf(polygon) } + return ( + surfaceTouchesRooms(bounded, scopedRooms) && + (!surface.autoFromWalls || !surfaceTouchesRooms(bounded, unaffectedRooms)) + ) + } + const slabs = allSlabs.filter(touchesScopedRooms) + const ceilings = allCeilings.filter(touchesScopedRooms) const slabRooms = roomsEligibleForAutoSurface( topologyDelta.beforeRooms, topologyDelta.currentRooms, diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 3ebd632c2f..a54071dde1 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -33,6 +33,7 @@ export { isPresettableKind, isRegistryMovable, isRegistrySelectable, + isSelectionHighlightEnabled, kindsWithBakePolicy, kindsWithFloorplanScope, loadPlugin, @@ -64,6 +65,8 @@ export type { AlignmentFootprintConfig, AnyNodeDefinition, AssetRef, + BakeGeometryAsyncBuilder, + BakeGeometryBuilder, BakePolicy, BakeReplaceRenderer, Capabilities, @@ -97,6 +100,7 @@ export type { FloorplanMoveTargetSession, FloorplanPalette, FloorplanPoint, + FloorplanScope, FloorplanStyle, GeometryContext, GridSnapPositionArgs, diff --git a/packages/core/src/registry/registry.test.ts b/packages/core/src/registry/registry.test.ts index 91a1db63c8..50af71b0cd 100644 --- a/packages/core/src/registry/registry.test.ts +++ b/packages/core/src/registry/registry.test.ts @@ -9,6 +9,8 @@ import { isNodeKindEnabled, isPresettable, isPresettableKind, + isSelectionHighlightEnabled, + kindsWithFloorplanScope, loadPlugin, nodeRegistry, registerNode, @@ -64,6 +66,16 @@ describe('nodeRegistry', () => { expect(nodeRegistry.get('column')).toBe(def) }) + test('discovers explicit site-scoped kinds without changing the level default', () => { + registerNode(makeDefinition('level-default')) + registerNode(makeDefinition('building-owned', { floorplanScope: 'building' })) + registerNode(makeDefinition('site-owned', { floorplanScope: 'site' })) + + expect(kindsWithFloorplanScope('level')).toEqual(['level-default']) + expect(kindsWithFloorplanScope('building')).toEqual(['building-owned']) + expect(kindsWithFloorplanScope('site')).toEqual(['site-owned']) + }) + test('registerNode throws on duplicate kind in production', async () => { await inProduction(() => { registerNode(makeDefinition('column')) @@ -171,6 +183,23 @@ describe('isPresettable', () => { }) }) +describe('isSelectionHighlightEnabled', () => { + beforeEach(() => { + nodeRegistry._reset() + }) + + test('defaults to true when the capability or definition is omitted', () => { + registerNode(makeDefinition('default-highlight')) + expect(isSelectionHighlightEnabled('default-highlight')).toBe(true) + expect(isSelectionHighlightEnabled('unregistered')).toBe(true) + }) + + test('returns false when the definition explicitly opts out', () => { + registerNode(makeDefinition('paint-layer', { capabilities: { selectionHighlight: false } })) + expect(isSelectionHighlightEnabled('paint-layer')).toBe(false) + }) +}) + describe('getHostRefFields', () => { test('returns the declared hostRefFields verbatim', () => { const def = makeDefinition('door', { capabilities: { hostRefFields: ['wallId'] } }) diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 1d6e35ed4c..5289ddca71 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -2,6 +2,7 @@ import type { ZodObject } from 'zod' import type { AnyNodeDefinition, BakePolicy, + FloorplanScope, InspectorExtension, NodeRegistry, Plugin, @@ -224,14 +225,23 @@ export function isRegistrySelectable(kind: string): boolean { return nodeRegistry.get(kind)?.capabilities.selectable !== undefined } +/** + * Whether the editor should apply its material-based selection highlight to a + * kind. Selection highlighting is enabled by default, including for legacy or + * unregistered kinds; definitions may explicitly opt out. + */ +export function isSelectionHighlightEnabled(kind: string): boolean { + return nodeRegistry.get(kind)?.capabilities.selectionHighlight !== false +} + /** * Kinds whose `def.floorplanScope` matches the requested scope. Used by - * `FloorplanRegistryLayer` to discover building-scoped kinds (e.g. - * elevator) without hardcoding kind names in the editor layer. `'level'` - * is the default, so `kindsWithFloorplanScope('level')` includes kinds - * that didn't set the field at all. + * `FloorplanRegistryLayer` to discover building- and site-scoped kinds + * without hardcoding kind names in the editor layer. `'level'` is the + * default, so `kindsWithFloorplanScope('level')` includes kinds that + * didn't set the field at all. */ -export function kindsWithFloorplanScope(scope: 'level' | 'building'): string[] { +export function kindsWithFloorplanScope(scope: FloorplanScope): string[] { const result: string[] = [] for (const [kind, def] of nodeRegistry.entries()) { const declared = def.floorplanScope ?? 'level' diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 33d957e9dc..69ac14c229 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -244,6 +244,8 @@ export type DimensionTextPosition = 'above' | 'centered' export type FloorplanStyle = { stroke?: string fill?: string + /** Winding rule for compound paths. `evenodd` keeps nested contour rings hollow. */ + fillRule?: 'nonzero' | 'evenodd' strokeWidth?: number strokeDasharray?: string opacity?: number @@ -373,6 +375,7 @@ export type ToolHintChip = { tooltip?: string } +export type FloorplanScope = 'level' | 'building' | 'site' // ─── ToolOption ────────────────────────────────────────────────────── // // A declarative pick-one option row for a kind's build tool, chosen in a @@ -1083,6 +1086,27 @@ export type NodeDefinition> = { /** GLB bake treatment for this kind (default `'static'`). See {@link BakePolicy}. */ bake?: BakePolicy + /** + * Optional export-only geometry builder. The GLB exporter calls this against + * persisted scene data and replaces the registered node's cloned subtree + * with the returned local-space Object3D. The live editor object is never + * passed to the hook or mutated. + * + * Use this when the live geometry is unsuitable for a portable GLB (for + * example, a procedural NodeMaterial that masks a maximum candidate + * population on the GPU). The returned tree must be a complete static + * snapshot for this node and use exporter-supported Three.js materials. + */ + bakeGeometry?: BakeGeometryBuilder> + /** + * Optional asynchronous export-only geometry builder for textured static artifacts. + * Export preparation awaits this exactly once in place of {@link bakeGeometry}. + * Synchronous geometry-only callers continue to use `bakeGeometry`. + * + * The returned tree follows the same ownership contract: it is detached, + * local-space, complete for the node, and owned by the export artifact. + */ + bakeGeometryAsync?: BakeGeometryAsyncBuilder> /** * Renderer for this kind. Optional under the three-checkbox composition @@ -1179,8 +1203,9 @@ export type NodeDefinition> = { /** * Pure 2D builder for floor-plan rendering. Mirrors `geometry` but emits * plain `FloorplanGeometry` data (SVG-renderable) rather than three.js - * Object3D. Coordinates are level-local meters β€” the floor-plan panel - * applies the worldβ†’SVG transform. + * Object3D. Level- and building-scoped builders emit building-local metres. + * Site-scoped builders emit site-local metres; the floor-plan layer projects + * their output into the active building's plan coordinates. * * Returns `null` when the kind shouldn't appear in floor plan (e.g. an * invisible utility node, or a kind that's 3D-only). Kinds that need @@ -1205,8 +1230,11 @@ export type NodeDefinition> = { * building). For `'building'`-scoped kinds the layer iterates every * instance whose parent matches the active level's building, and * synthesises a `GeometryContext` whose `parent` is the active level. + * `'site'` discovers direct children of the active building's Site, + * supplies the real Site as `ctx.parent`, and projects site-local output + * into the active building's plan coordinates below level architecture. */ - floorplanScope?: 'level' | 'building' + floorplanScope?: FloorplanScope /** * 2D drag affordances keyed by the string identifier emitted on * `endpoint-handle` (and similar interactive floor-plan primitives) via @@ -1549,6 +1577,9 @@ export type BakeReplaceRenderer = { module: () => Promise<{ default: ComponentType<{ nodes: N[] }> }> } +export type BakeGeometryBuilder = (node: N, ctx: GeometryContext) => Object3D +export type BakeGeometryAsyncBuilder = (node: N, ctx: GeometryContext) => Promise + export type AssetRef = { id: string src: string @@ -1598,6 +1629,13 @@ export type Capabilities = { deletable?: boolean groupable?: boolean selectable?: SelectableConfig + /** + * Whether selecting this kind should replace its rendered mesh materials + * with the editor's selection tint. Defaults to `true`. Set to `false` for + * hidden interaction nodes whose rendered geometry must retain its authored + * materials while the node remains selected (for example, paint layers). + */ + selectionHighlight?: boolean interactive?: boolean floorPlaced?: FloorPlacedConfig /** diff --git a/packages/core/src/schema/nodes/roof-elevation.test.ts b/packages/core/src/schema/nodes/roof-elevation.test.ts new file mode 100644 index 0000000000..4b5186ab26 --- /dev/null +++ b/packages/core/src/schema/nodes/roof-elevation.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from 'bun:test' +import { RoofNode } from './roof' + +test('roofs default to custom level support', () => { + expect(RoofNode.parse({}).support).toEqual({ kind: 'level' }) +}) + +test('wall-follow support survives JSON parsing without host references', () => { + const roof = RoofNode.parse({ support: { kind: 'walls' }, position: [2, -0.5, 1] }) + expect(RoofNode.parse(JSON.parse(JSON.stringify(roof)))).toEqual(roof) +}) diff --git a/packages/core/src/schema/nodes/roof-segment-walls.test.ts b/packages/core/src/schema/nodes/roof-segment-walls.test.ts index 61c087f554..8b6004f82b 100644 --- a/packages/core/src/schema/nodes/roof-segment-walls.test.ts +++ b/packages/core/src/schema/nodes/roof-segment-walls.test.ts @@ -23,6 +23,15 @@ function segment(overrides: Partial = {}): RoofSegmentNode { } describe('roof wall face frames', () => { + test('zero-height gable profiles keep their base at zero and raise the eave to five centimeters', () => { + const face = getRoofSegmentWallFace(segment({ wallHeight: 0 }), 'right') + + expect(Math.min(...face.profile.map(([, v]) => v))).toBe(0) + expect(face.profile[2]?.[1]).toBe(0.05) + expect(face.profile[4]?.[1]).toBe(0.05) + expect(face.profile[3]?.[1]).toBeCloseTo(0.05 + 3.05 * Math.tan((40 * Math.PI) / 180)) + }) + test('frame z = 0 lands on the nominal footprint (wall mid-plane)', () => { const seg = segment() // front face, u at the face middle, v = 1, mid-plane. diff --git a/packages/core/src/schema/nodes/roof-segment-walls.ts b/packages/core/src/schema/nodes/roof-segment-walls.ts index d6fa9c5bc7..5595a2a8b2 100644 --- a/packages/core/src/schema/nodes/roof-segment-walls.ts +++ b/packages/core/src/schema/nodes/roof-segment-walls.ts @@ -16,7 +16,9 @@ import { getDutchRoofMetrics, getSegmentSlopeFrame } from './roof-segment' * (`getVol(wallThickness / 2, 0, 0, …)`): the volume is the segment * footprint extended outward by `wallThickness / 2`, which drops the eave * line by `(wallThickness / 2) Β· tanΞΈ` and raises the ridge by the same - * amount so the apex stays at `wallHeight + activeRh`. + * amount so the apex stays at `wallHeight + activeRh` unless the eave + * hits the CSG minimum. The base stays at 0; the eave is raised to at + * least 0.05 above it to avoid sinking the shell into the supporting wall. */ export type RoofWallFaceId = 'front' | 'back' | 'right' | 'left' @@ -76,7 +78,7 @@ function getWallVolumeFrame(node: SegmentWallInputs): WallVolumeFrame { const autoDrop = (wallThickness / 2) * tanTheta const wV = Math.max(0.01, node.width + wallThickness) const dV = Math.max(0.01, node.depth + wallThickness) - const eaveY = Math.max(0.01, node.wallHeight - autoDrop) + const eaveY = Math.max(0.05, node.wallHeight - autoDrop) let rh = activeRh if (activeRh > 0) { rh = activeRh + autoDrop diff --git a/packages/core/src/schema/nodes/roof.ts b/packages/core/src/schema/nodes/roof.ts index cc2ed84915..ca9ae35220 100644 --- a/packages/core/src/schema/nodes/roof.ts +++ b/packages/core/src/schema/nodes/roof.ts @@ -14,6 +14,7 @@ export type RoofSurfaceMaterialSpec = { export const RoofSupport = z .discriminatedUnion('kind', [ z.object({ kind: z.literal('level') }), + z.object({ kind: z.literal('walls') }), z.object({ kind: z.literal('roof'), roofSegmentId: RoofSegmentNode.shape.id, @@ -49,7 +50,7 @@ export const RoofNode = BaseNode.extend({ When not being edited, segments are visually combined into a single solid. - position: center position of the roof group - rotation: rotation around Y axis - - support: level placement or an explicit roof-surface attachment + - support: custom level placement, spatial wall-top following, or a roof-surface attachment - children: array of RoofSegmentNode IDs `, ) diff --git a/packages/core/src/schema/nodes/scan.ts b/packages/core/src/schema/nodes/scan.ts index 36e16a323a..75790a5cf7 100644 --- a/packages/core/src/schema/nodes/scan.ts +++ b/packages/core/src/schema/nodes/scan.ts @@ -1,5 +1,5 @@ -import { CaptureSessionLocatorSchema } from '@pascal-app/capture-protocol' import { z } from 'zod' +import { CaptureSessionLocatorSchema } from '../../capture/schema' import { AssetUrl } from '../asset-url' import { BaseNode, nodeType, objectId } from '../base' diff --git a/packages/core/src/store/fixtures/maxi-8x-endpoint.json b/packages/core/src/store/fixtures/maxi-8x-endpoint.json new file mode 100644 index 0000000000..dc3f8ea42a --- /dev/null +++ b/packages/core/src/store/fixtures/maxi-8x-endpoint.json @@ -0,0 +1,6466 @@ +{ + "levelId": "level_5jzpvy5og6mvl2h8", + "nodes": [ + { + "id": "building_zxhkcoipjmbb5wqk", + "type": "building", + "object": "node", + "visible": true, + "children": ["level_5jzpvy5og6mvl2h8"], + "parentId": "site_uxevo5jkbgxsxl3a", + "position": [0, 0, 0], + "rotation": [0, 0, 0] + }, + { + "id": "ceiling_0svpfqwohcf45z1i", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[75, -10], [75, 2], [64, 2], [64, -4], [64.25, -10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_1a9pgucnrldy36hb", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [ + [88, -3], + [88, 4], + [67, 4], + [67, 10], + [59, 10], + [59, 4.5], + [53.5, 4.5], + [53.5, 3.5], + [45, 3.5], + [45, 2], + [59, 2], + [59, -4], + [64, -4], + [64, 2], + [75, 2], + [75, -3] + ], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_1kqe29gse9kgw7kx", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[345, 3.5], [340, 3.5], [340, -10], [345, -10], [346, -4], [345, 2]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_1llnbfkdd8xcwwec", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[24, 9], [28, 9], [28, 13], [24, 13]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_1vjzldb9joddoye9", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[98.5, 3.5], [103.5, 3.5], [103.5, 10], [98.5, 10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_2ffgwst9wztrcp8w", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[195, 3.5], [190, 3.5], [190, -10], [195, -10], [196, -4], [195, 2]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_3hug7m8xc8zzclz6", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[325, -10], [325, 2], [314, 2], [314, -4], [314.25, -10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_3ytvmu1oawe2lmgo", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[98.5, 3.5], [98.5, 10], [90, 10], [90, 3.5]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_3zdxvli1xfd4vifh", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[375, -10], [375, 2], [364, 2], [364, -4], [364.25, -10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_4twr0babrxjhv24i", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[259, -4], [259, 2], [245, 2], [246, -4]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_4uedrv7j76yjacpt", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[195, -10], [214.25, -10], [214, -4], [196, -4]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_5bke4w631lygdacs", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[145, 3.5], [140, 3.5], [140, -10], [145, -10], [146, -4], [145, 2]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_5i82wt7z3tkqcmb3", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[88, -3], [75, -3], [75, -10], [88, -10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_5xhdqtsvo5n9fi90", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[374, 9], [378, 9], [378, 13], [374, 13]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_6dbud1bb2zz331b4", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[95, -10], [114.25, -10], [114, -4], [96, -4]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_6nrf58c5bke3n55e", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [ + [288, -3], + [288, 4], + [267, 4], + [267, 10], + [259, 10], + [259, 4.5], + [253.5, 4.5], + [253.5, 3.5], + [245, 3.5], + [245, 2], + [259, 2], + [259, -4], + [264, -4], + [264, 2], + [275, 2], + [275, -3] + ], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_7gm8acujlvmfcx4l", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[-5, -10], [14.25, -10], [14, -4], [-4, -4]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_7hhzcsm0wzbp3352", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[359, -4], [359, 2], [345, 2], [346, -4]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_7jyhj0n1fssndn0e", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[288, -3], [275, -3], [275, -10], [288, -10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_7nz5chi6vcwdgd1h", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[348.5, 3.5], [353.5, 3.5], [353.5, 10], [348.5, 10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_81wiwd7ohm2t202z", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [ + [38, -3], + [38, 4], + [17, 4], + [17, 10], + [9, 10], + [9, 4.5], + [3.5, 4.5], + [3.5, 3.5], + [-5, 3.5], + [-5, 2], + [9, 2], + [9, -4], + [14, -4], + [14, 2], + [25, 2], + [25, -3] + ], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_8dlpjavl61dxx1gq", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[345, -10], [364.25, -10], [364, -4], [346, -4]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_9u0zq27qdc67kbhy", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[275, -10], [275, 2], [264, 2], [264, -4], [264.25, -10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_auon1oxxc1w9av2c", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[198.5, 3.5], [203.5, 3.5], [203.5, 10], [198.5, 10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_bbl8ollvj0ddmis7", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[388, -3], [375, -3], [375, -10], [388, -10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_bt71mi04z9qr68fr", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[95, 3.5], [90, 3.5], [90, -10], [95, -10], [96, -4], [95, 2]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_cbo312mml7tou7xc", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[174, 9], [178, 9], [178, 13], [174, 13]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_d3b5qwx75kl8uf4t", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[59, -4], [59, 2], [45, 2], [46, -4]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_d9o5rc11auf9jpnv", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [ + [388, -3], + [388, 4], + [367, 4], + [367, 10], + [359, 10], + [359, 4.5], + [353.5, 4.5], + [353.5, 3.5], + [345, 3.5], + [345, 2], + [359, 2], + [359, -4], + [364, -4], + [364, 2], + [375, 2], + [375, -3] + ], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_eb8x71w1pkbtjja0", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[295, 3.5], [290, 3.5], [290, -10], [295, -10], [296, -4], [295, 2]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_ftu6s57w0hmm6o9h", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[148.5, 3.5], [153.5, 3.5], [153.5, 10], [148.5, 10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_g3ryjb0cr5nlmql5", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[188, -3], [175, -3], [175, -10], [188, -10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_gi2kvoue8pgdrpg4", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[159, -4], [159, 2], [145, 2], [146, -4]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_gu70jkiqj6iamjdn", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[175, -10], [175, 2], [164, 2], [164, -4], [164.25, -10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_hjc0zaq70lzbqwuy", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[148.5, 3.5], [148.5, 10], [140, 10], [140, 3.5]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_hk2kt913gl6ibuyt", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[-5, 3.5], [-10, 3.5], [-10, -10], [-5, -10], [-4, -4], [-5, 2]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_hqsz7xwoo6usw66a", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[48.5, 3.5], [53.5, 3.5], [53.5, 10], [48.5, 10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_i9b4eae95653lrsm", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [ + [138, -3], + [138, 4], + [117, 4], + [117, 10], + [109, 10], + [109, 4.5], + [103.5, 4.5], + [103.5, 3.5], + [95, 3.5], + [95, 2], + [109, 2], + [109, -4], + [114, -4], + [114, 2], + [125, 2], + [125, -3] + ], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_ix2ooee4bvyjpnni", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[338, -3], [325, -3], [325, -10], [338, -10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_jd9skf5isxx05uf3", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[109, -4], [109, 2], [95, 2], [96, -4]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_jvp1pwobcpcnamj1", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[145, -10], [164.25, -10], [164, -4], [146, -4]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_jyxl9fym3141elfv", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[-1.5, 3.5], [-1.5, 10], [-10, 10], [-10, 3.5]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_kqfu934ef6d3xerl", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[224, 9], [228, 9], [228, 13], [224, 13]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_kuzkfhzyf7xa5os5", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[125, -10], [125, 2], [114, 2], [114, -4], [114.25, -10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_kvqqq6bh7gpgm83o", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[248.5, 3.5], [248.5, 10], [240, 10], [240, 3.5]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_mpojykeqdlpxido3", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [ + [188, -3], + [188, 4], + [167, 4], + [167, 10], + [159, 10], + [159, 4.5], + [153.5, 4.5], + [153.5, 3.5], + [145, 3.5], + [145, 2], + [159, 2], + [159, -4], + [164, -4], + [164, 2], + [175, 2], + [175, -3] + ], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_mz37seonx142o28v", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[25, -10], [25, 2], [14, 2], [14, -4], [14.25, -10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_nllaca88wa2m59lx", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[238, -3], [225, -3], [225, -10], [238, -10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_o4i5sud8gezlzni4", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [ + [238, -3], + [238, 4], + [217, 4], + [217, 10], + [209, 10], + [209, 4.5], + [203.5, 4.5], + [203.5, 3.5], + [195, 3.5], + [195, 2], + [209, 2], + [209, -4], + [214, -4], + [214, 2], + [225, 2], + [225, -3] + ], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_qbvulkcrfr9dz11g", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[-1.5, 3.5], [3.5, 3.5], [3.5, 10], [-1.5, 10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_r7xxgprobvkbuzxk", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[309, -4], [309, 2], [295, 2], [296, -4]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_re70iakiii8ajd5w", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[274, 9], [278, 9], [278, 13], [274, 13]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_rtvb2fqsqfgosf4y", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[245, 3.5], [240, 3.5], [240, -10], [245, -10], [246, -4], [245, 2]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_ryzvpdl5qjtn306w", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[45, 3.5], [40, 3.5], [40, -10], [45, -10], [46, -4], [45, 2]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_s1gqxvfeufl3xwuv", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[245, -10], [264.25, -10], [264, -4], [246, -4]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_s5d9naiy45b03ce9", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[209, -4], [209, 2], [195, 2], [196, -4]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_t8e1owbc0njkays8", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[348.5, 3.5], [348.5, 10], [340, 10], [340, 3.5]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_tpzumwwf5219x1ys", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[45, -10], [64.25, -10], [64, -4], [46, -4]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_udmjpis2luebn88q", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[298.5, 3.5], [298.5, 10], [290, 10], [290, 3.5]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_vmwjaxkoumgxhjml", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[48.5, 3.5], [48.5, 10], [40, 10], [40, 3.5]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_w19f9xb18nb3rrhu", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[9, -4], [9, 2], [-5, 2], [-4, -4]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_x16zcdifmsnq8gex", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[198.5, 3.5], [198.5, 10], [190, 10], [190, 3.5]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_xflsespiytamiy43", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[38, -3], [25, -3], [25, -10], [38, -10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_xpmqcd5p2g1tvzmv", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[225, -10], [225, 2], [214, 2], [214, -4], [214.25, -10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_xtu08d9ffq6y7cyg", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[138, -3], [125, -3], [125, -10], [138, -10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_y7mij1hxgznchgvu", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[298.5, 3.5], [303.5, 3.5], [303.5, 10], [298.5, 10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_yw5anc22lf7gl7s5", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [ + [338, -3], + [338, 4], + [317, 4], + [317, 10], + [309, 10], + [309, 4.5], + [303.5, 4.5], + [303.5, 3.5], + [295, 3.5], + [295, 2], + [309, 2], + [309, -4], + [314, -4], + [314, 2], + [325, 2], + [325, -3] + ], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_yxocmekofbekw3p0", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[324, 9], [328, 9], [328, 13], [324, 13]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_z84bf4857pzswvxs", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[295, -10], [314.25, -10], [314, -4], [296, -4]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_ze953bzhjanya61p", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[74, 9], [78, 9], [78, 13], [74, 13]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_zqvx353ndpwfjn8b", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[124, 9], [128, 9], [128, 13], [124, 13]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "ceiling_zt44531w373nqf59", + "type": "ceiling", + "holes": [], + "height": 2.49, + "object": "node", + "polygon": [[248.5, 3.5], [253.5, 3.5], [253.5, 10], [248.5, 10]], + "visible": true, + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "level_5jzpvy5og6mvl2h8", + "type": "level", + "level": 0, + "height": 2.5, + "object": "node", + "visible": true, + "children": [ + "wall_p1rei8ifr2hj4bgv", + "wall_i85tevpn9di9rzme", + "wall_h5txsxe2mtgolvee", + "wall_zlccvcf94cp2y8zk", + "wall_ivneq74rz37v5xcl", + "wall_0jw4ebaapxpkqgvz", + "slab_6ew3702oocynivvh", + "ceiling_81wiwd7ohm2t202z", + "wall_kjdhqy4qr6hr17nb", + "wall_ntk2fs1f7ygei96p", + "wall_1tzuxg8du34lnmgi", + "wall_rjvki7eh6eu356nd", + "wall_wkpoxq842i2gmw4r", + "slab_hewpamvaspvq0czb", + "ceiling_qbvulkcrfr9dz11g", + "wall_9vuj392pn8i5ku5f", + "wall_ifbgnhf9nvdfiin9", + "slab_xs8leg4wtd5h5n8o", + "ceiling_jyxl9fym3141elfv", + "wall_ch03lzwruqsq1orp", + "wall_tpllpasugfw7fjfs", + "wall_pvpqfwfg360vpg2h", + "wall_cusnqb9ha9njnf2r", + "slab_0y22q7bcsbbgd0zd", + "ceiling_hk2kt913gl6ibuyt", + "wall_disskquzml337nwj", + "wall_o41r8argvhqqkstx", + "wall_p9w5c57fimfnn4k8", + "slab_sqp6ij2wwq8maeaw", + "ceiling_7gm8acujlvmfcx4l", + "wall_g22z4hv1t7azwxsp", + "wall_5ug5vmh9c7nrqm1r", + "wall_hwoye1vqnhkdcdy2", + "wall_z3kzssf9v2nypc31", + "wall_bz46jy8c7jjbbj4t", + "wall_3ixzzc6om7z53rvs", + "slab_t8enoaftieki4b4l", + "ceiling_w19f9xb18nb3rrhu", + "wall_gr0zlsqpln9g1i2f", + "wall_2a1cjwu0unv8bxu5", + "wall_snqe4djy13kdkvm0", + "wall_yhpbv6x5lxpihib2", + "slab_b6utz9bozyftaipw", + "ceiling_mz37seonx142o28v", + "wall_jaj4o5xrrybsfa2j", + "wall_njnmlgx1brwyikpj", + "wall_myl0ici33m0pl7fe", + "wall_hka8ov7mbds2pagw", + "wall_03hco8q7a608nubp", + "slab_cd726z017h52phs4", + "ceiling_xflsespiytamiy43", + "wall_xm50jlyv0j826h9q", + "wall_ryiuoswcp75pte9z", + "wall_vlk5wx85z5ak2y50", + "wall_fn64paumg07huhc5", + "slab_24zaz0e54zydg1gw", + "ceiling_1llnbfkdd8xcwwec", + "wall_qij21j7161n47hy2", + "wall_9qpr6tew9uco2n5k", + "wall_ujf5umwsnwgxyj34", + "wall_blvw82gld4zh070r", + "wall_o7q18swmy7fxn9zr", + "wall_4ip8jiromffntfe7", + "slab_f0i266bzztrr70um", + "ceiling_1a9pgucnrldy36hb", + "wall_ta03isfucmdkz9y6", + "wall_5cya8647og4rz3we", + "wall_y2lv56lcpbtzxk19", + "wall_k3it18ueohmgc82i", + "wall_u1yem55bj9kolijo", + "slab_by0e59fw1dninr7s", + "ceiling_hqsz7xwoo6usw66a", + "wall_vdejqilk8o6zfi16", + "wall_jwg2ntak7c9ol5b8", + "slab_z6q7kj1osd48b0gt", + "ceiling_vmwjaxkoumgxhjml", + "wall_ik06hb5dlnvap6sk", + "wall_49t1egf3ptn0inqe", + "wall_knaekmw3zcumquju", + "wall_n05qq7op2r74akll", + "slab_0uhtgvzopervdcvu", + "ceiling_ryzvpdl5qjtn306w", + "wall_6od1mqkuxjambj3h", + "wall_w0qbct2ghte5y8q2", + "wall_pelgystijzwyg4js", + "slab_mmaf2winmno3no7b", + "ceiling_tpzumwwf5219x1ys", + "wall_vlx17gf8lt858zhx", + "wall_xrbdcdektoqfkuvd", + "wall_qdkzssroudur0kwu", + "wall_8keoaibi315tm84z", + "wall_94capu3uvzl5j6g6", + "wall_p863ks6fv4nhh2hc", + "slab_4xlxpql6mxpwjd12", + "ceiling_d3b5qwx75kl8uf4t", + "wall_qkdlp1tq8js5wdou", + "wall_0sh04c22wj0d9tug", + "wall_fc21v6hqgnjoq8nc", + "wall_hvyccw83jktw4i9k", + "slab_ezqmgzny3wbyxjth", + "ceiling_0svpfqwohcf45z1i", + "wall_l27ho51ha6lakr98", + "wall_x7oovb9j1vxqybzj", + "wall_jhv5p0sm2f5vo1sg", + "wall_jtq5deo069a2rp55", + "wall_uu1lv3vvow0k90fz", + "slab_6gkt8bpp7f4zws9u", + "ceiling_5i82wt7z3tkqcmb3", + "wall_jnvfjcmxjgj9xxt6", + "wall_vlsd0wc35m313i7p", + "wall_uoidvd5w1ue5xe1x", + "wall_icddtmuae4h7zdbf", + "slab_rwcc0xt6roh3r5hi", + "ceiling_ze953bzhjanya61p", + "wall_28hi6g0m9lbe1jkw", + "wall_9a33c4n72zjxlxzk", + "wall_t2y1whj3aeb7cpn5", + "wall_lpxh5apjo5eu38tv", + "wall_2pdkoceci4g5oyk3", + "wall_12j1b3zx6h57ztz6", + "slab_mmf2f2fn2ojvw0a1", + "ceiling_i9b4eae95653lrsm", + "wall_oelzkeicrwvm2h2l", + "wall_s9dcjc9oz9l2z7q5", + "wall_zpx72734mqp4hayk", + "wall_4o2oxe2yk29icf3v", + "wall_1kgnwvrbjhg54t3s", + "slab_d2ngcuivsrixb177", + "ceiling_1vjzldb9joddoye9", + "wall_3dbgz1ovxwr14a0r", + "wall_9omraq1vbqjdsuv6", + "slab_hjtodp1qjxb95f52", + "ceiling_3ytvmu1oawe2lmgo", + "wall_inrulqy49ym0zwol", + "wall_r65s4gcpnu4pug95", + "wall_nldjkvthtslvwmpr", + "wall_53qkwvv4y8hfoisa", + "slab_yx7kj54wmfbpxeyc", + "ceiling_bt71mi04z9qr68fr", + "wall_k0xdntbnvhl0ozot", + "wall_7lm15ne1t0zg10rb", + "wall_kwjtrvlycc4sqvp6", + "slab_8xydky1ljohz6y49", + "ceiling_6dbud1bb2zz331b4", + "wall_ls0zqd56xdxlfypx", + "wall_rcczn3n8r60rsuv4", + "wall_k14mh69s2123xzrq", + "wall_2b4j2u22aavcnqtt", + "wall_kny2ygskp54w9cxj", + "wall_nptlkaxm9044yreg", + "slab_r4zhk03rcxlis54x", + "ceiling_jd9skf5isxx05uf3", + "wall_gepjp16bswz166e9", + "wall_u0maipdem3qao42n", + "wall_e8iibrzjmwp18ji3", + "wall_ahi5z73xbcavclk5", + "slab_j13a4ts9e4v9sjpw", + "ceiling_kuzkfhzyf7xa5os5", + "wall_kpr0565isucx7bx5", + "wall_cus0yp8o8nepoev0", + "wall_44wph5z58w37v4xh", + "wall_30q2wgp2s8dn4z5h", + "wall_g0cotd05xv4h5oug", + "slab_02warzosdw17ko2n", + "ceiling_xtu08d9ffq6y7cyg", + "wall_dwkqotqktpd24nd4", + "wall_sdwb4s3hphcfpcxb", + "wall_a2ad97mzx9rxgfgk", + "wall_i7yk9xsr7kf9rsvi", + "slab_te4qalwhe6jq0dng", + "ceiling_zqvx353ndpwfjn8b", + "wall_48ypkfcuhlc7qpk8", + "wall_et1qprx7uz4row43", + "wall_4xrcr0k658oy5x3r", + "wall_oksi29htn1u1yqts", + "wall_vweuvcfk7ifc33mg", + "wall_141t2nf8x69m9fam", + "slab_uejwayggfm1ptlxd", + "ceiling_mpojykeqdlpxido3", + "wall_bf16hw2b3jxgvzqa", + "wall_5k5xzq5pvpvbpyhw", + "wall_a4u4lnn7c7155kh0", + "wall_tg3r2dxqi82bj6mc", + "wall_ovp9pm6jg6v0v4qy", + "slab_8zizre27pcm0yqoo", + "ceiling_ftu6s57w0hmm6o9h", + "wall_9a68mfij8uugdr7d", + "wall_to2j2xrqvi22klpd", + "slab_d1g2hrnmwt2yznaz", + "ceiling_hjc0zaq70lzbqwuy", + "wall_qmti7areygfci482", + "wall_kcifge7vrj38u10f", + "wall_qlixcrqsyy03pjaa", + "wall_9cd4urrh62ns0ujw", + "slab_kal69mf3k8rnqfue", + "ceiling_5bke4w631lygdacs", + "wall_hdop6k5wpwixzk0z", + "wall_jmv8upv7bhpnl3so", + "wall_tfacjuu6fg7bgax3", + "slab_1gjim91b89qfrbac", + "ceiling_jvp1pwobcpcnamj1", + "wall_1qfkn9e9t6w4uczg", + "wall_92vwcrtmkbfn5xw8", + "wall_t15g478d2mwk2emk", + "wall_jrt1wleuwrwtm6c9", + "wall_w2efraazcef2fm1x", + "wall_wkeyes3jvwf7ezlw", + "slab_khgarbl57sqdyprx", + "ceiling_gi2kvoue8pgdrpg4", + "wall_g1a6iqxtb0r0s8u4", + "wall_u0pfc6xiroczgpg9", + "wall_tjcmsdcf6kamxs78", + "wall_jmsmgqkccqn5ct61", + "slab_eufkfvxmkjpxgcnt", + "ceiling_gu70jkiqj6iamjdn", + "wall_escup1mkwxfz0nmu", + "wall_1c8mprioc4tym91c", + "wall_tosf62s8y6c6jb25", + "wall_p7jocba6f44e0meg", + "wall_72rl8de1vz1yri2i", + "slab_hilp085i7fhwk7ll", + "ceiling_g3ryjb0cr5nlmql5", + "wall_ykh6e9320af73x6n", + "wall_y7b8ypt1yjt5h9i9", + "wall_9analk3ef7k33xju", + "wall_1xnckr6tw6infvko", + "slab_qh9sb4ljopm19ejz", + "ceiling_cbo312mml7tou7xc", + "wall_rjr2g2nx3e9ew5uf", + "wall_75s2rf85z9og0h91", + "wall_0jeaji1s9o2l1ec2", + "wall_1d9kgg97j0lxp4ap", + "wall_hvfbih6ap1sqiqj9", + "wall_0aztazd3cntvmchs", + "slab_o85ziwab1hvg628e", + "ceiling_o4i5sud8gezlzni4", + "wall_svcdfr8x4fahdg7d", + "wall_xq7f7r31yp5lf06f", + "wall_00vxijkgjtmjbreu", + "wall_f9cug652sfny9pai", + "wall_vzwd82mm47e3injl", + "slab_fm70uwnwhz9yw1oz", + "ceiling_auon1oxxc1w9av2c", + "wall_q4bs5wmk3o4tkbqj", + "wall_q4ll1k2ckl6hd917", + "slab_j2bpqbf8iix2p2yi", + "ceiling_x16zcdifmsnq8gex", + "wall_bbpijfn0gel5pyo5", + "wall_bedpclgujxv3ii2h", + "wall_xzzhjom15actlrnd", + "wall_oefkficzih3xix5p", + "slab_cieqnn9j8opwb8xu", + "ceiling_2ffgwst9wztrcp8w", + "wall_8pj77la1z3lxv569", + "wall_74iak6zcurzk9b6s", + "wall_u4q5pmdditset7gq", + "slab_w4j0a89ig47avvu8", + "ceiling_4uedrv7j76yjacpt", + "wall_9a7tbwqrs2xc7zgg", + "wall_x6ae19ye72th0go3", + "wall_a2d5nddgn9vyxza0", + "wall_cph6r0k69lsv11l0", + "wall_mw23lmtqk0yfvxzx", + "wall_0624ubnn9m52t2jk", + "slab_bw2fq8pinbcpu9d7", + "ceiling_s5d9naiy45b03ce9", + "wall_qwltr2xpxsrnzpor", + "wall_rq2gi7x19t696qsz", + "wall_zfhh2w55uoc3qqkf", + "wall_0bfc5479v897l26z", + "slab_gww0s4x0rrgzuid0", + "ceiling_xpmqcd5p2g1tvzmv", + "wall_ugd70n1dxpydo8xe", + "wall_vtbxpzmthofrstf3", + "wall_wcdedg2b1g750rqe", + "wall_394yukbf8xn3vjy3", + "wall_bywrhvlq0xyu72b4", + "slab_tuk2a4ttaxaetxpb", + "ceiling_nllaca88wa2m59lx", + "wall_v1gk6mjrfbg4l03m", + "wall_b877nh4ndam8ypfh", + "wall_biy6tgxepftorxzc", + "wall_d3yfawl5bc1v1du5", + "slab_in7lzbjkwya0lays", + "ceiling_kqfu934ef6d3xerl", + "wall_divniwmkkrwl7l12", + "wall_1sl0gtnaaxvyiy4k", + "wall_7mu2bysxy7y861av", + "wall_mv69xe1fd25rln9m", + "wall_w234cjc1wk18sies", + "wall_ctrdbri0pcjazm4j", + "slab_g74chjviczh6innv", + "ceiling_6nrf58c5bke3n55e", + "wall_jxfuhz889e30xtwj", + "wall_gz2lqli7wlnwk73k", + "wall_zdjnbular1c0j3aw", + "wall_l6mlyvvcz0xm1kin", + "wall_8veinjenvu9myqg8", + "slab_iwfqc1q6i27737u6", + "ceiling_zt44531w373nqf59", + "wall_kf56ud7nzumnpwv5", + "wall_o1d4gehcuhxbdq2t", + "slab_xiha7s4a7bowakwk", + "ceiling_kvqqq6bh7gpgm83o", + "wall_1m472vd0p9m8raip", + "wall_jtmq04ui45hxzazk", + "wall_pa7p20vw4n08qm7k", + "wall_j3ug8hjutpuab4l3", + "slab_kjmqgh3ml1mczuuc", + "ceiling_rtvb2fqsqfgosf4y", + "wall_1yzshmgbufj9zo73", + "wall_b5bofqedtv9ozebq", + "wall_lxo7hqhp5b1xo9n2", + "slab_9wn36oq5edvhgzcd", + "ceiling_s1gqxvfeufl3xwuv", + "wall_b1d5h9p8ozl46nqr", + "wall_wtz4jhv39keexept", + "wall_rvz19beniyaabmfd", + "wall_bvt7xa3ngy60fgn8", + "wall_050mpb5zyw2whc2a", + "wall_lbgizh71loaejkt7", + "slab_vaec4gehijvvp613", + "ceiling_4twr0babrxjhv24i", + "wall_ilc1e8726gf80lno", + "wall_rxyexvgiolckyhic", + "wall_3ypdg2wrelka7ru2", + "wall_81rzau4ucnbmqnpe", + "slab_9nfba7ul6qdpemtc", + "ceiling_9u0zq27qdc67kbhy", + "wall_4arzi7ai44a5wlp4", + "wall_gl3o6d9zaw3yx8dj", + "wall_hguap25rscb54in2", + "wall_0rwdh35132s8z2zr", + "wall_oa4bflp3y0xvst90", + "slab_72llin1uj7tdzp1k", + "ceiling_7jyhj0n1fssndn0e", + "wall_44oziypv0e5dvwcf", + "wall_4u6181i1utyn1jnx", + "wall_vsi67vwpqoifenjm", + "wall_i0u30s9ojooikupt", + "slab_cls7jbu1nw64i0zl", + "ceiling_re70iakiii8ajd5w", + "wall_9k3n1v247qwor9n5", + "wall_23xllnegk76c12y6", + "wall_0k5jm9vqj89al30s", + "wall_h465f08blx47o4od", + "wall_rx5b1w2s1m32ppx5", + "wall_t3zl179duh02lus8", + "slab_zh3ahlxlfuh1z5z4", + "ceiling_yw5anc22lf7gl7s5", + "wall_e7mnpyvpmvq6cihx", + "wall_sppk83pk35mj2cuo", + "wall_jt2giilydhvphb3t", + "wall_9k0248bui7w22inf", + "wall_amu9tz0eildbmtsb", + "slab_u3sbcegm4lc9sk7x", + "ceiling_y7mij1hxgznchgvu", + "wall_4matwhas8q4yo1xs", + "wall_s07zz13tobzrfnnf", + "slab_7nx5704fc77k5grm", + "ceiling_udmjpis2luebn88q", + "wall_jwny3ql8jux3fqhm", + "wall_s604h6mxe4c7lxa7", + "wall_e0qoc8iztao8nbjh", + "wall_998jcc6zv0dltp6o", + "slab_pzuywja2nb6fuo33", + "ceiling_eb8x71w1pkbtjja0", + "wall_yskwmz4ek9opvxh5", + "wall_zbqxzatbmyi0k964", + "wall_x9qsserambfj4jgx", + "slab_8vdckkqhsslt0m8q", + "ceiling_z84bf4857pzswvxs", + "wall_z4p1todu4htqh6se", + "wall_76j3h94lbxma887j", + "wall_t31t47858fmeaioi", + "wall_nvr9nbs11h0rsczw", + "wall_sgvaf5n202k02lux", + "wall_n3zi3fubud8az2c8", + "slab_3ut2oy1tbstuoygi", + "ceiling_r7xxgprobvkbuzxk", + "wall_7ik4jybteeprnwf6", + "wall_zhunaufjlh0ir9tp", + "wall_vqaucztp4iguoaeh", + "wall_gnyi66thv1r72i0d", + "slab_qjih2ctgv0kay144", + "ceiling_3hug7m8xc8zzclz6", + "wall_byqcygb2g4nfe271", + "wall_0i2oqfprej89wgog", + "wall_uxzvlwzyt8122yd2", + "wall_r3f63iz1lg81un3q", + "wall_7m0hxz7vo0yx3j18", + "slab_msm9w0utc1stfgj6", + "ceiling_ix2ooee4bvyjpnni", + "wall_7mr9z3x9lku3s587", + "wall_do68s6a7eoj0p15d", + "wall_fb8so9rh3lj59ox4", + "wall_96rxmcth7y3r4ns7", + "slab_sy1hprtpj1mr4wia", + "ceiling_yxocmekofbekw3p0", + "wall_5f3idhfb37zxsics", + "wall_ueelewge9mbc2tso", + "wall_36k7kwnmoco50xyp", + "wall_ow0diierj5jbe7fn", + "wall_w04cw307rgsz7pgp", + "wall_8mmz189dg71k8cqy", + "slab_t568g5tc5wr9vo4t", + "ceiling_d9o5rc11auf9jpnv", + "wall_mdwrru1emnrhfn1n", + "wall_lglqbzfnpmsaxprk", + "wall_5ovfm3pjbhd9yjfr", + "wall_vkaojz5o2gi5rlh1", + "wall_n3iwo7z8k03jlebt", + "slab_l1k0oj84u68crzl5", + "ceiling_7nz5chi6vcwdgd1h", + "wall_wch48nda8atejla5", + "wall_8ihxui2cj3pt8tif", + "slab_rci7mnsjqqs5lg9r", + "ceiling_t8e1owbc0njkays8", + "wall_ummci7mijiqtoqzl", + "wall_63s53bbt7i1odwza", + "wall_q1wpcr3bhw8i6v3z", + "wall_zx5trbvepar8tn3a", + "slab_4mcvxc288vv13bns", + "ceiling_1kqe29gse9kgw7kx", + "wall_5qnaczlrsuuobumr", + "wall_mgh657a4g1tsg2qq", + "wall_ilkqh6c19riw72gb", + "slab_xvk64er45jstobdw", + "ceiling_8dlpjavl61dxx1gq", + "wall_ei6rjr9u536l6301", + "wall_0ytvs795x65l656q", + "wall_i9vip5op8jevfeuq", + "wall_jx402vdydfvwc9ne", + "wall_hz16ao7twpx2rnku", + "wall_1yfknmk7i3o5a96n", + "slab_1ay42ei6ixca9ijv", + "ceiling_7hhzcsm0wzbp3352", + "wall_b5h17jry11qezmc1", + "wall_rbmgdbog58thua6b", + "wall_39m6na72nz5aq1uc", + "wall_6fby196mda3yk2f4", + "slab_e1i2aw2nspgmapxx", + "ceiling_3zdxvli1xfd4vifh", + "wall_4xs6i7mtnuaqm9o4", + "wall_jj65gvtnnawcindp", + "wall_pvzcd5bzn3tbc2en", + "wall_jseilh33ex54wvz0", + "wall_rirb4v35mexw1mbz", + "slab_4y9izljbdg4au3mi", + "ceiling_bbl8ollvj0ddmis7", + "wall_8iv7n3g4m0wkrpdh", + "wall_euq9kboqr2244y2h", + "wall_ops4egnwr6pmlx62", + "wall_dcw5wws2kv8idh4u", + "slab_xp9ntvq5q621ho79", + "ceiling_5xhdqtsvo5n9fi90" + ], + "parentId": "building_zxhkcoipjmbb5wqk", + "baseElevation": 0 + }, + { + "id": "site_uxevo5jkbgxsxl3a", + "type": "site", + "object": "node", + "polygon": { + "type": "polygon", + "points": [ + [-16.039329528808594, -20.12042808532715], + [46.18221664428711, -20.04703712463379], + [45.955780029296875, 18.084251403808594], + [-16.036903381347656, 18.044904708862305] + ] + }, + "visible": true, + "children": ["building_zxhkcoipjmbb5wqk"], + "parentId": null + }, + { + "id": "slab_02warzosdw17ko2n", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[138, -3], [125, -3], [125, -10], [138, -10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_0uhtgvzopervdcvu", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[45, 3.5], [40, 3.5], [40, -10], [45, -10], [46, -4], [45, 2]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_0y22q7bcsbbgd0zd", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[-5, 3.5], [-10, 3.5], [-10, -10], [-5, -10], [-4, -4], [-5, 2]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_1ay42ei6ixca9ijv", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[359, -4], [359, 2], [345, 2], [346, -4]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_1gjim91b89qfrbac", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[145, -10], [164.25, -10], [164, -4], [146, -4]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_24zaz0e54zydg1gw", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[24, 9], [28, 9], [28, 13], [24, 13]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_3ut2oy1tbstuoygi", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[309, -4], [309, 2], [295, 2], [296, -4]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_4mcvxc288vv13bns", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[345, 3.5], [340, 3.5], [340, -10], [345, -10], [346, -4], [345, 2]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_4xlxpql6mxpwjd12", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[59, -4], [59, 2], [45, 2], [46, -4]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_4y9izljbdg4au3mi", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[388, -3], [375, -3], [375, -10], [388, -10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_6ew3702oocynivvh", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [ + [38, -3], + [38, 4], + [17, 4], + [17, 10], + [9, 10], + [9, 4.5], + [3.5, 4.5], + [3.5, 3.5], + [-5, 3.5], + [-5, 2], + [9, 2], + [9, -4], + [14, -4], + [14, 2], + [25, 2], + [25, -3] + ], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_6gkt8bpp7f4zws9u", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[88, -3], [75, -3], [75, -10], [88, -10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_72llin1uj7tdzp1k", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[288, -3], [275, -3], [275, -10], [288, -10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_7nx5704fc77k5grm", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[298.5, 3.5], [298.5, 10], [290, 10], [290, 3.5]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_8vdckkqhsslt0m8q", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[295, -10], [314.25, -10], [314, -4], [296, -4]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_8xydky1ljohz6y49", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[95, -10], [114.25, -10], [114, -4], [96, -4]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_8zizre27pcm0yqoo", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[148.5, 3.5], [153.5, 3.5], [153.5, 10], [148.5, 10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_9nfba7ul6qdpemtc", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[275, -10], [275, 2], [264, 2], [264, -4], [264.25, -10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_9wn36oq5edvhgzcd", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[245, -10], [264.25, -10], [264, -4], [246, -4]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_b6utz9bozyftaipw", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[25, -10], [25, 2], [14, 2], [14, -4], [14.25, -10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_bw2fq8pinbcpu9d7", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[209, -4], [209, 2], [195, 2], [196, -4]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_by0e59fw1dninr7s", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[48.5, 3.5], [53.5, 3.5], [53.5, 10], [48.5, 10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_cd726z017h52phs4", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[38, -3], [25, -3], [25, -10], [38, -10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_cieqnn9j8opwb8xu", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[195, 3.5], [190, 3.5], [190, -10], [195, -10], [196, -4], [195, 2]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_cls7jbu1nw64i0zl", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[274, 9], [278, 9], [278, 13], [274, 13]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_d1g2hrnmwt2yznaz", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[148.5, 3.5], [148.5, 10], [140, 10], [140, 3.5]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_d2ngcuivsrixb177", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[98.5, 3.5], [103.5, 3.5], [103.5, 10], [98.5, 10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_e1i2aw2nspgmapxx", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[375, -10], [375, 2], [364, 2], [364, -4], [364.25, -10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_eufkfvxmkjpxgcnt", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[175, -10], [175, 2], [164, 2], [164, -4], [164.25, -10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_ezqmgzny3wbyxjth", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[75, -10], [75, 2], [64, 2], [64, -4], [64.25, -10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_f0i266bzztrr70um", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [ + [88, -3], + [88, 4], + [67, 4], + [67, 10], + [59, 10], + [59, 4.5], + [53.5, 4.5], + [53.5, 3.5], + [45, 3.5], + [45, 2], + [59, 2], + [59, -4], + [64, -4], + [64, 2], + [75, 2], + [75, -3] + ], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_fm70uwnwhz9yw1oz", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[198.5, 3.5], [203.5, 3.5], [203.5, 10], [198.5, 10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_g74chjviczh6innv", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [ + [288, -3], + [288, 4], + [267, 4], + [267, 10], + [259, 10], + [259, 4.5], + [253.5, 4.5], + [253.5, 3.5], + [245, 3.5], + [245, 2], + [259, 2], + [259, -4], + [264, -4], + [264, 2], + [275, 2], + [275, -3] + ], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_gww0s4x0rrgzuid0", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[225, -10], [225, 2], [214, 2], [214, -4], [214.25, -10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_hewpamvaspvq0czb", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[-1.5, 3.5], [3.5, 3.5], [3.5, 10], [-1.5, 10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_hilp085i7fhwk7ll", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[188, -3], [175, -3], [175, -10], [188, -10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_hjtodp1qjxb95f52", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[98.5, 3.5], [98.5, 10], [90, 10], [90, 3.5]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_in7lzbjkwya0lays", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[224, 9], [228, 9], [228, 13], [224, 13]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_iwfqc1q6i27737u6", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[248.5, 3.5], [253.5, 3.5], [253.5, 10], [248.5, 10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_j13a4ts9e4v9sjpw", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[125, -10], [125, 2], [114, 2], [114, -4], [114.25, -10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_j2bpqbf8iix2p2yi", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[198.5, 3.5], [198.5, 10], [190, 10], [190, 3.5]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_kal69mf3k8rnqfue", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[145, 3.5], [140, 3.5], [140, -10], [145, -10], [146, -4], [145, 2]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_khgarbl57sqdyprx", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[159, -4], [159, 2], [145, 2], [146, -4]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_kjmqgh3ml1mczuuc", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[245, 3.5], [240, 3.5], [240, -10], [245, -10], [246, -4], [245, 2]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_l1k0oj84u68crzl5", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[348.5, 3.5], [353.5, 3.5], [353.5, 10], [348.5, 10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_mmaf2winmno3no7b", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[45, -10], [64.25, -10], [64, -4], [46, -4]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_mmf2f2fn2ojvw0a1", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [ + [138, -3], + [138, 4], + [117, 4], + [117, 10], + [109, 10], + [109, 4.5], + [103.5, 4.5], + [103.5, 3.5], + [95, 3.5], + [95, 2], + [109, 2], + [109, -4], + [114, -4], + [114, 2], + [125, 2], + [125, -3] + ], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_msm9w0utc1stfgj6", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[338, -3], [325, -3], [325, -10], [338, -10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_o85ziwab1hvg628e", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [ + [238, -3], + [238, 4], + [217, 4], + [217, 10], + [209, 10], + [209, 4.5], + [203.5, 4.5], + [203.5, 3.5], + [195, 3.5], + [195, 2], + [209, 2], + [209, -4], + [214, -4], + [214, 2], + [225, 2], + [225, -3] + ], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_pzuywja2nb6fuo33", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[295, 3.5], [290, 3.5], [290, -10], [295, -10], [296, -4], [295, 2]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_qh9sb4ljopm19ejz", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[174, 9], [178, 9], [178, 13], [174, 13]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_qjih2ctgv0kay144", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[325, -10], [325, 2], [314, 2], [314, -4], [314.25, -10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_r4zhk03rcxlis54x", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[109, -4], [109, 2], [95, 2], [96, -4]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_rci7mnsjqqs5lg9r", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[348.5, 3.5], [348.5, 10], [340, 10], [340, 3.5]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_rwcc0xt6roh3r5hi", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[74, 9], [78, 9], [78, 13], [74, 13]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_sqp6ij2wwq8maeaw", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[-5, -10], [14.25, -10], [14, -4], [-4, -4]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_sy1hprtpj1mr4wia", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[324, 9], [328, 9], [328, 13], [324, 13]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_t568g5tc5wr9vo4t", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [ + [388, -3], + [388, 4], + [367, 4], + [367, 10], + [359, 10], + [359, 4.5], + [353.5, 4.5], + [353.5, 3.5], + [345, 3.5], + [345, 2], + [359, 2], + [359, -4], + [364, -4], + [364, 2], + [375, 2], + [375, -3] + ], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_t8enoaftieki4b4l", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[9, -4], [9, 2], [-5, 2], [-4, -4]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_te4qalwhe6jq0dng", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[124, 9], [128, 9], [128, 13], [124, 13]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_tuk2a4ttaxaetxpb", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[238, -3], [225, -3], [225, -10], [238, -10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_u3sbcegm4lc9sk7x", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[298.5, 3.5], [303.5, 3.5], [303.5, 10], [298.5, 10]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_uejwayggfm1ptlxd", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [ + [188, -3], + [188, 4], + [167, 4], + [167, 10], + [159, 10], + [159, 4.5], + [153.5, 4.5], + [153.5, 3.5], + [145, 3.5], + [145, 2], + [159, 2], + [159, -4], + [164, -4], + [164, 2], + [175, 2], + [175, -3] + ], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_vaec4gehijvvp613", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[259, -4], [259, 2], [245, 2], [246, -4]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_w4j0a89ig47avvu8", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[195, -10], [214.25, -10], [214, -4], [196, -4]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_xiha7s4a7bowakwk", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[248.5, 3.5], [248.5, 10], [240, 10], [240, 3.5]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_xp9ntvq5q621ho79", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[374, 9], [378, 9], [378, 13], [374, 13]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_xs8leg4wtd5h5n8o", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[-1.5, 3.5], [-1.5, 10], [-10, 10], [-10, 3.5]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_xvk64er45jstobdw", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[345, -10], [364.25, -10], [364, -4], [346, -4]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_yx7kj54wmfbpxeyc", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[95, 3.5], [90, 3.5], [90, -10], [95, -10], [96, -4], [95, 2]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_z6q7kj1osd48b0gt", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [[48.5, 3.5], [48.5, 10], [40, 10], [40, 3.5]], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "slab_zh3ahlxlfuh1z5z4", + "type": "slab", + "holes": [], + "object": "node", + "polygon": [ + [338, -3], + [338, 4], + [317, 4], + [317, 10], + [309, 10], + [309, 4.5], + [303.5, 4.5], + [303.5, 3.5], + [295, 3.5], + [295, 2], + [309, 2], + [309, -4], + [314, -4], + [314, 2], + [325, 2], + [325, -3] + ], + "visible": true, + "parentId": "level_5jzpvy5og6mvl2h8", + "recessed": false, + "elevation": 0.05, + "thickness": 0.05, + "holeMetadata": [], + "autoFromWalls": true + }, + { + "id": "wall_00vxijkgjtmjbreu", + "end": [198.5, 3.5], + "type": "wall", + "start": [198.5, 10], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_03hco8q7a608nubp", + "end": [38, -3], + "type": "wall", + "start": [25, -3], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_050mpb5zyw2whc2a", + "end": [245, 3.5], + "type": "wall", + "start": [245, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_0624ubnn9m52t2jk", + "end": [195, 2], + "type": "wall", + "start": [209, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_0aztazd3cntvmchs", + "end": [209, 10], + "type": "wall", + "start": [209, 4.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_0bfc5479v897l26z", + "end": [214.25, -10], + "type": "wall", + "start": [225, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_0i2oqfprej89wgog", + "end": [325, -10], + "type": "wall", + "start": [325, -3], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_0jeaji1s9o2l1ec2", + "end": [238, 4], + "type": "wall", + "start": [217, 4], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_0jw4ebaapxpkqgvz", + "end": [9, 10], + "type": "wall", + "start": [9, 4.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_0k5jm9vqj89al30s", + "end": [338, 4], + "type": "wall", + "start": [317, 4], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_0rwdh35132s8z2zr", + "end": [288, -10], + "type": "wall", + "start": [288, -3], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_0sh04c22wj0d9tug", + "end": [75, 2], + "type": "wall", + "start": [64, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_0ytvs795x65l656q", + "end": [364, -4], + "type": "wall", + "start": [359, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_12j1b3zx6h57ztz6", + "end": [109, 10], + "type": "wall", + "start": [109, 4.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_141t2nf8x69m9fam", + "end": [159, 10], + "type": "wall", + "start": [159, 4.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_1c8mprioc4tym91c", + "end": [175, -10], + "type": "wall", + "start": [175, -3], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_1d9kgg97j0lxp4ap", + "end": [203.5, 4.5], + "type": "wall", + "start": [203.5, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_1kgnwvrbjhg54t3s", + "end": [103.5, 4.5], + "type": "wall", + "start": [103.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_1m472vd0p9m8raip", + "end": [240, -10], + "type": "wall", + "start": [245, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_1qfkn9e9t6w4uczg", + "end": [159, -4], + "type": "wall", + "start": [146, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_1sl0gtnaaxvyiy4k", + "end": [267, 4], + "type": "wall", + "start": [267, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_1tzuxg8du34lnmgi", + "end": [-1.5, 3.5], + "type": "wall", + "start": [-1.5, 10], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_1xnckr6tw6infvko", + "end": [178, 13], + "type": "wall", + "start": [174, 13], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_1yfknmk7i3o5a96n", + "end": [345, 2], + "type": "wall", + "start": [359, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_1yzshmgbufj9zo73", + "end": [246, -4], + "type": "wall", + "start": [245, -10], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_23xllnegk76c12y6", + "end": [317, 4], + "type": "wall", + "start": [317, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_28hi6g0m9lbe1jkw", + "end": [117, 10], + "type": "wall", + "start": [109, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_2a1cjwu0unv8bxu5", + "end": [25, 2], + "type": "wall", + "start": [14, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_2b4j2u22aavcnqtt", + "end": [95, 2], + "type": "wall", + "start": [96, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_2pdkoceci4g5oyk3", + "end": [109, 4.5], + "type": "wall", + "start": [103.5, 4.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_30q2wgp2s8dn4z5h", + "end": [138, -10], + "type": "wall", + "start": [138, -3], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_36k7kwnmoco50xyp", + "end": [388, 4], + "type": "wall", + "start": [367, 4], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_394yukbf8xn3vjy3", + "end": [238, -10], + "type": "wall", + "start": [238, -3], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_39m6na72nz5aq1uc", + "end": [375, -10], + "type": "wall", + "start": [388, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_3dbgz1ovxwr14a0r", + "end": [90, 3.5], + "type": "wall", + "start": [90, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_3ixzzc6om7z53rvs", + "end": [-5, 2], + "type": "wall", + "start": [9, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_3ypdg2wrelka7ru2", + "end": [275, -10], + "type": "wall", + "start": [288, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_44oziypv0e5dvwcf", + "end": [278, 9], + "type": "wall", + "start": [274, 9], + "object": "node", + "visible": true, + "backSide": "exterior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "interior" + }, + { + "id": "wall_44wph5z58w37v4xh", + "end": [138, -3], + "type": "wall", + "start": [138, 4], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_48ypkfcuhlc7qpk8", + "end": [167, 10], + "type": "wall", + "start": [159, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_49t1egf3ptn0inqe", + "end": [40, 3.5], + "type": "wall", + "start": [44.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_4arzi7ai44a5wlp4", + "end": [275, -3], + "type": "wall", + "start": [275, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_4ip8jiromffntfe7", + "end": [59, 10], + "type": "wall", + "start": [59, 4.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_4matwhas8q4yo1xs", + "end": [290, 3.5], + "type": "wall", + "start": [290, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_4o2oxe2yk29icf3v", + "end": [103.5, 3.5], + "type": "wall", + "start": [98.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_4u6181i1utyn1jnx", + "end": [278, 13], + "type": "wall", + "start": [278, 9], + "object": "node", + "visible": true, + "backSide": "exterior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "interior" + }, + { + "id": "wall_4xrcr0k658oy5x3r", + "end": [188, 4], + "type": "wall", + "start": [167, 4], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_4xs6i7mtnuaqm9o4", + "end": [375, -3], + "type": "wall", + "start": [375, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_53qkwvv4y8hfoisa", + "end": [94.5, 3.5], + "type": "wall", + "start": [95, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_5cya8647og4rz3we", + "end": [53.5, 10], + "type": "wall", + "start": [48.5, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_5f3idhfb37zxsics", + "end": [367, 10], + "type": "wall", + "start": [359, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_5k5xzq5pvpvbpyhw", + "end": [153.5, 10], + "type": "wall", + "start": [148.5, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_5ovfm3pjbhd9yjfr", + "end": [348.5, 3.5], + "type": "wall", + "start": [348.5, 10], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_5qnaczlrsuuobumr", + "end": [346, -4], + "type": "wall", + "start": [345, -10], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_5ug5vmh9c7nrqm1r", + "end": [14, -4], + "type": "wall", + "start": [9, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_63s53bbt7i1odwza", + "end": [340, 3.5], + "type": "wall", + "start": [344.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_6fby196mda3yk2f4", + "end": [364.25, -10], + "type": "wall", + "start": [375, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_6od1mqkuxjambj3h", + "end": [46, -4], + "type": "wall", + "start": [45, -10], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_72rl8de1vz1yri2i", + "end": [188, -3], + "type": "wall", + "start": [175, -3], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_74iak6zcurzk9b6s", + "end": [195, -10], + "type": "wall", + "start": [214.25, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_75s2rf85z9og0h91", + "end": [217, 4], + "type": "wall", + "start": [217, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_76j3h94lbxma887j", + "end": [314, -4], + "type": "wall", + "start": [309, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_7ik4jybteeprnwf6", + "end": [314, 2], + "type": "wall", + "start": [314, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_7lm15ne1t0zg10rb", + "end": [95, -10], + "type": "wall", + "start": [114.25, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_7m0hxz7vo0yx3j18", + "end": [338, -3], + "type": "wall", + "start": [325, -3], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_7mr9z3x9lku3s587", + "end": [328, 9], + "type": "wall", + "start": [324, 9], + "object": "node", + "visible": true, + "backSide": "exterior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "interior" + }, + { + "id": "wall_7mu2bysxy7y861av", + "end": [288, 4], + "type": "wall", + "start": [267, 4], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_81rzau4ucnbmqnpe", + "end": [264.25, -10], + "type": "wall", + "start": [275, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_8ihxui2cj3pt8tif", + "end": [340, 10], + "type": "wall", + "start": [340, 3.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_8iv7n3g4m0wkrpdh", + "end": [378, 9], + "type": "wall", + "start": [374, 9], + "object": "node", + "visible": true, + "backSide": "exterior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "interior" + }, + { + "id": "wall_8keoaibi315tm84z", + "end": [45, 2], + "type": "wall", + "start": [46, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_8mmz189dg71k8cqy", + "end": [359, 10], + "type": "wall", + "start": [359, 4.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_8pj77la1z3lxv569", + "end": [196, -4], + "type": "wall", + "start": [195, -10], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_8veinjenvu9myqg8", + "end": [253.5, 4.5], + "type": "wall", + "start": [253.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_92vwcrtmkbfn5xw8", + "end": [164, -4], + "type": "wall", + "start": [159, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_94capu3uvzl5j6g6", + "end": [45, 3.5], + "type": "wall", + "start": [45, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_96rxmcth7y3r4ns7", + "end": [328, 13], + "type": "wall", + "start": [324, 13], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_998jcc6zv0dltp6o", + "end": [294.5, 3.5], + "type": "wall", + "start": [295, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_9a33c4n72zjxlxzk", + "end": [117, 4], + "type": "wall", + "start": [117, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_9a68mfij8uugdr7d", + "end": [140, 3.5], + "type": "wall", + "start": [140, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_9a7tbwqrs2xc7zgg", + "end": [209, -4], + "type": "wall", + "start": [196, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_9analk3ef7k33xju", + "end": [174, 13], + "type": "wall", + "start": [174, 9], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_9cd4urrh62ns0ujw", + "end": [144.5, 3.5], + "type": "wall", + "start": [145, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_9k0248bui7w22inf", + "end": [303.5, 3.5], + "type": "wall", + "start": [298.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_9k3n1v247qwor9n5", + "end": [317, 10], + "type": "wall", + "start": [309, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_9omraq1vbqjdsuv6", + "end": [90, 10], + "type": "wall", + "start": [90, 3.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_9qpr6tew9uco2n5k", + "end": [67, 4], + "type": "wall", + "start": [67, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_9vuj392pn8i5ku5f", + "end": [-10, 3.5], + "type": "wall", + "start": [-10, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_a2ad97mzx9rxgfgk", + "end": [124, 13], + "type": "wall", + "start": [124, 9], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_a2d5nddgn9vyxza0", + "end": [209, 2], + "type": "wall", + "start": [209, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_a4u4lnn7c7155kh0", + "end": [148.5, 3.5], + "type": "wall", + "start": [148.5, 10], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_ahi5z73xbcavclk5", + "end": [114.25, -10], + "type": "wall", + "start": [125, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_amu9tz0eildbmtsb", + "end": [303.5, 4.5], + "type": "wall", + "start": [303.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_b1d5h9p8ozl46nqr", + "end": [259, -4], + "type": "wall", + "start": [246, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_b5bofqedtv9ozebq", + "end": [245, -10], + "type": "wall", + "start": [264.25, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_b5h17jry11qezmc1", + "end": [364, 2], + "type": "wall", + "start": [364, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_b877nh4ndam8ypfh", + "end": [228, 13], + "type": "wall", + "start": [228, 9], + "object": "node", + "visible": true, + "backSide": "exterior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "interior" + }, + { + "id": "wall_bbpijfn0gel5pyo5", + "end": [190, -10], + "type": "wall", + "start": [195, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_bedpclgujxv3ii2h", + "end": [190, 3.5], + "type": "wall", + "start": [194.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_bf16hw2b3jxgvzqa", + "end": [148.5, 10], + "type": "wall", + "start": [140, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_biy6tgxepftorxzc", + "end": [224, 13], + "type": "wall", + "start": [224, 9], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_blvw82gld4zh070r", + "end": [53.5, 4.5], + "type": "wall", + "start": [53.5, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_bvt7xa3ngy60fgn8", + "end": [245, 2], + "type": "wall", + "start": [246, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_byqcygb2g4nfe271", + "end": [325, -3], + "type": "wall", + "start": [325, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_bywrhvlq0xyu72b4", + "end": [238, -3], + "type": "wall", + "start": [225, -3], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_bz46jy8c7jjbbj4t", + "end": [-5, 3.5], + "type": "wall", + "start": [-5, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_ch03lzwruqsq1orp", + "end": [-10, -10], + "type": "wall", + "start": [-5, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_cph6r0k69lsv11l0", + "end": [195, 2], + "type": "wall", + "start": [196, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_ctrdbri0pcjazm4j", + "end": [259, 10], + "type": "wall", + "start": [259, 4.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_cus0yp8o8nepoev0", + "end": [125, -10], + "type": "wall", + "start": [125, -3], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_cusnqb9ha9njnf2r", + "end": [-5.5, 3.5], + "type": "wall", + "start": [-5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_d3yfawl5bc1v1du5", + "end": [228, 13], + "type": "wall", + "start": [224, 13], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_dcw5wws2kv8idh4u", + "end": [378, 13], + "type": "wall", + "start": [374, 13], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_disskquzml337nwj", + "end": [-4, -4], + "type": "wall", + "start": [-5, -10], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_divniwmkkrwl7l12", + "end": [267, 10], + "type": "wall", + "start": [259, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_do68s6a7eoj0p15d", + "end": [328, 13], + "type": "wall", + "start": [328, 9], + "object": "node", + "visible": true, + "backSide": "exterior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "interior" + }, + { + "id": "wall_dwkqotqktpd24nd4", + "end": [128, 9], + "type": "wall", + "start": [124, 9], + "object": "node", + "visible": true, + "backSide": "exterior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "interior" + }, + { + "id": "wall_e0qoc8iztao8nbjh", + "end": [295, 3.5], + "type": "wall", + "start": [298.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_e7mnpyvpmvq6cihx", + "end": [298.5, 10], + "type": "wall", + "start": [290, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_e8iibrzjmwp18ji3", + "end": [125, -10], + "type": "wall", + "start": [138, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_ei6rjr9u536l6301", + "end": [359, -4], + "type": "wall", + "start": [346, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_escup1mkwxfz0nmu", + "end": [175, -3], + "type": "wall", + "start": [175, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_et1qprx7uz4row43", + "end": [167, 4], + "type": "wall", + "start": [167, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_euq9kboqr2244y2h", + "end": [378, 13], + "type": "wall", + "start": [378, 9], + "object": "node", + "visible": true, + "backSide": "exterior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "interior" + }, + { + "id": "wall_f9cug652sfny9pai", + "end": [203.5, 3.5], + "type": "wall", + "start": [198.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_fb8so9rh3lj59ox4", + "end": [324, 13], + "type": "wall", + "start": [324, 9], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_fc21v6hqgnjoq8nc", + "end": [75, -10], + "type": "wall", + "start": [88, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_fn64paumg07huhc5", + "end": [28, 13], + "type": "wall", + "start": [24, 13], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_g0cotd05xv4h5oug", + "end": [138, -3], + "type": "wall", + "start": [125, -3], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_g1a6iqxtb0r0s8u4", + "end": [164, 2], + "type": "wall", + "start": [164, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_g22z4hv1t7azwxsp", + "end": [9, -4], + "type": "wall", + "start": [-4, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_gepjp16bswz166e9", + "end": [114, 2], + "type": "wall", + "start": [114, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_gl3o6d9zaw3yx8dj", + "end": [275, -10], + "type": "wall", + "start": [275, -3], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_gnyi66thv1r72i0d", + "end": [314.25, -10], + "type": "wall", + "start": [325, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_gr0zlsqpln9g1i2f", + "end": [14, 2], + "type": "wall", + "start": [14, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_gz2lqli7wlnwk73k", + "end": [253.5, 10], + "type": "wall", + "start": [248.5, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_h465f08blx47o4od", + "end": [303.5, 4.5], + "type": "wall", + "start": [303.5, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_h5txsxe2mtgolvee", + "end": [38, 4], + "type": "wall", + "start": [17, 4], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_hdop6k5wpwixzk0z", + "end": [146, -4], + "type": "wall", + "start": [145, -10], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_hguap25rscb54in2", + "end": [288, -3], + "type": "wall", + "start": [288, 4], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_hka8ov7mbds2pagw", + "end": [38, -10], + "type": "wall", + "start": [38, -3], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_hvfbih6ap1sqiqj9", + "end": [209, 4.5], + "type": "wall", + "start": [203.5, 4.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_hvyccw83jktw4i9k", + "end": [64.25, -10], + "type": "wall", + "start": [75, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_hwoye1vqnhkdcdy2", + "end": [9, 2], + "type": "wall", + "start": [9, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_hz16ao7twpx2rnku", + "end": [345, 3.5], + "type": "wall", + "start": [345, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_i0u30s9ojooikupt", + "end": [278, 13], + "type": "wall", + "start": [274, 13], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_i7yk9xsr7kf9rsvi", + "end": [128, 13], + "type": "wall", + "start": [124, 13], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_i85tevpn9di9rzme", + "end": [17, 4], + "type": "wall", + "start": [17, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_i9vip5op8jevfeuq", + "end": [359, 2], + "type": "wall", + "start": [359, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_icddtmuae4h7zdbf", + "end": [78, 13], + "type": "wall", + "start": [74, 13], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_ifbgnhf9nvdfiin9", + "end": [-10, 10], + "type": "wall", + "start": [-10, 3.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_ik06hb5dlnvap6sk", + "end": [40, -10], + "type": "wall", + "start": [45, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_ilc1e8726gf80lno", + "end": [264, 2], + "type": "wall", + "start": [264, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_ilkqh6c19riw72gb", + "end": [364.25, -10], + "type": "wall", + "start": [364, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_inrulqy49ym0zwol", + "end": [90, -10], + "type": "wall", + "start": [95, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_ivneq74rz37v5xcl", + "end": [9, 4.5], + "type": "wall", + "start": [3.5, 4.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_j3ug8hjutpuab4l3", + "end": [244.5, 3.5], + "type": "wall", + "start": [245, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_jaj4o5xrrybsfa2j", + "end": [25, -3], + "type": "wall", + "start": [25, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_jhv5p0sm2f5vo1sg", + "end": [88, -3], + "type": "wall", + "start": [88, 4], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_jj65gvtnnawcindp", + "end": [375, -10], + "type": "wall", + "start": [375, -3], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_jmsmgqkccqn5ct61", + "end": [164.25, -10], + "type": "wall", + "start": [175, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_jmv8upv7bhpnl3so", + "end": [145, -10], + "type": "wall", + "start": [164.25, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_jnvfjcmxjgj9xxt6", + "end": [78, 9], + "type": "wall", + "start": [74, 9], + "object": "node", + "visible": true, + "backSide": "exterior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "interior" + }, + { + "id": "wall_jrt1wleuwrwtm6c9", + "end": [145, 2], + "type": "wall", + "start": [146, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_jseilh33ex54wvz0", + "end": [388, -10], + "type": "wall", + "start": [388, -3], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_jt2giilydhvphb3t", + "end": [298.5, 3.5], + "type": "wall", + "start": [298.5, 10], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_jtmq04ui45hxzazk", + "end": [240, 3.5], + "type": "wall", + "start": [244.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_jtq5deo069a2rp55", + "end": [88, -10], + "type": "wall", + "start": [88, -3], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_jwg2ntak7c9ol5b8", + "end": [40, 10], + "type": "wall", + "start": [40, 3.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_jwny3ql8jux3fqhm", + "end": [290, -10], + "type": "wall", + "start": [295, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_jx402vdydfvwc9ne", + "end": [345, 2], + "type": "wall", + "start": [346, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_jxfuhz889e30xtwj", + "end": [248.5, 10], + "type": "wall", + "start": [240, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_k0xdntbnvhl0ozot", + "end": [96, -4], + "type": "wall", + "start": [95, -10], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_k14mh69s2123xzrq", + "end": [109, 2], + "type": "wall", + "start": [109, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_k3it18ueohmgc82i", + "end": [53.5, 3.5], + "type": "wall", + "start": [48.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_kcifge7vrj38u10f", + "end": [140, 3.5], + "type": "wall", + "start": [144.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_kf56ud7nzumnpwv5", + "end": [240, 3.5], + "type": "wall", + "start": [240, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_kjdhqy4qr6hr17nb", + "end": [-1.5, 10], + "type": "wall", + "start": [-10, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_knaekmw3zcumquju", + "end": [45, 3.5], + "type": "wall", + "start": [48.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_kny2ygskp54w9cxj", + "end": [95, 3.5], + "type": "wall", + "start": [95, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_kpr0565isucx7bx5", + "end": [125, -3], + "type": "wall", + "start": [125, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_kwjtrvlycc4sqvp6", + "end": [114.25, -10], + "type": "wall", + "start": [114, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_l27ho51ha6lakr98", + "end": [75, -3], + "type": "wall", + "start": [75, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_l6mlyvvcz0xm1kin", + "end": [253.5, 3.5], + "type": "wall", + "start": [248.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_lbgizh71loaejkt7", + "end": [245, 2], + "type": "wall", + "start": [259, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_lglqbzfnpmsaxprk", + "end": [353.5, 10], + "type": "wall", + "start": [348.5, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_lpxh5apjo5eu38tv", + "end": [103.5, 4.5], + "type": "wall", + "start": [103.5, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_ls0zqd56xdxlfypx", + "end": [109, -4], + "type": "wall", + "start": [96, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_lxo7hqhp5b1xo9n2", + "end": [264.25, -10], + "type": "wall", + "start": [264, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_mdwrru1emnrhfn1n", + "end": [348.5, 10], + "type": "wall", + "start": [340, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_mgh657a4g1tsg2qq", + "end": [345, -10], + "type": "wall", + "start": [364.25, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_mv69xe1fd25rln9m", + "end": [253.5, 4.5], + "type": "wall", + "start": [253.5, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_mw23lmtqk0yfvxzx", + "end": [195, 3.5], + "type": "wall", + "start": [195, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_myl0ici33m0pl7fe", + "end": [38, -3], + "type": "wall", + "start": [38, 4], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_n05qq7op2r74akll", + "end": [44.5, 3.5], + "type": "wall", + "start": [45, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_n3iwo7z8k03jlebt", + "end": [353.5, 4.5], + "type": "wall", + "start": [353.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_n3zi3fubud8az2c8", + "end": [295, 2], + "type": "wall", + "start": [309, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_njnmlgx1brwyikpj", + "end": [25, -10], + "type": "wall", + "start": [25, -3], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_nldjkvthtslvwmpr", + "end": [95, 3.5], + "type": "wall", + "start": [98.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_nptlkaxm9044yreg", + "end": [95, 2], + "type": "wall", + "start": [109, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_ntk2fs1f7ygei96p", + "end": [3.5, 10], + "type": "wall", + "start": [-1.5, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_nvr9nbs11h0rsczw", + "end": [295, 2], + "type": "wall", + "start": [296, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_o1d4gehcuhxbdq2t", + "end": [240, 10], + "type": "wall", + "start": [240, 3.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_o41r8argvhqqkstx", + "end": [-5, -10], + "type": "wall", + "start": [14.25, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_o7q18swmy7fxn9zr", + "end": [59, 4.5], + "type": "wall", + "start": [53.5, 4.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_oa4bflp3y0xvst90", + "end": [288, -3], + "type": "wall", + "start": [275, -3], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_oefkficzih3xix5p", + "end": [194.5, 3.5], + "type": "wall", + "start": [195, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_oelzkeicrwvm2h2l", + "end": [98.5, 10], + "type": "wall", + "start": [90, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_oksi29htn1u1yqts", + "end": [153.5, 4.5], + "type": "wall", + "start": [153.5, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_ops4egnwr6pmlx62", + "end": [374, 13], + "type": "wall", + "start": [374, 9], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_ovp9pm6jg6v0v4qy", + "end": [153.5, 4.5], + "type": "wall", + "start": [153.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_ow0diierj5jbe7fn", + "end": [353.5, 4.5], + "type": "wall", + "start": [353.5, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_p1rei8ifr2hj4bgv", + "end": [17, 10], + "type": "wall", + "start": [9, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_p7jocba6f44e0meg", + "end": [188, -10], + "type": "wall", + "start": [188, -3], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_p863ks6fv4nhh2hc", + "end": [45, 2], + "type": "wall", + "start": [59, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_p9w5c57fimfnn4k8", + "end": [14.25, -10], + "type": "wall", + "start": [14, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_pa7p20vw4n08qm7k", + "end": [245, 3.5], + "type": "wall", + "start": [248.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_pelgystijzwyg4js", + "end": [64.25, -10], + "type": "wall", + "start": [64, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_pvpqfwfg360vpg2h", + "end": [-5, 3.5], + "type": "wall", + "start": [-1.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_pvzcd5bzn3tbc2en", + "end": [388, -3], + "type": "wall", + "start": [388, 4], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_q1wpcr3bhw8i6v3z", + "end": [345, 3.5], + "type": "wall", + "start": [348.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_q4bs5wmk3o4tkbqj", + "end": [190, 3.5], + "type": "wall", + "start": [190, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_q4ll1k2ckl6hd917", + "end": [190, 10], + "type": "wall", + "start": [190, 3.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_qdkzssroudur0kwu", + "end": [59, 2], + "type": "wall", + "start": [59, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_qij21j7161n47hy2", + "end": [67, 10], + "type": "wall", + "start": [59, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_qkdlp1tq8js5wdou", + "end": [64, 2], + "type": "wall", + "start": [64, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_qlixcrqsyy03pjaa", + "end": [145, 3.5], + "type": "wall", + "start": [148.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_qmti7areygfci482", + "end": [140, -10], + "type": "wall", + "start": [145, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_qwltr2xpxsrnzpor", + "end": [214, 2], + "type": "wall", + "start": [214, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_r3f63iz1lg81un3q", + "end": [338, -10], + "type": "wall", + "start": [338, -3], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_r65s4gcpnu4pug95", + "end": [90, 3.5], + "type": "wall", + "start": [94.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_rbmgdbog58thua6b", + "end": [375, 2], + "type": "wall", + "start": [364, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_rcczn3n8r60rsuv4", + "end": [114, -4], + "type": "wall", + "start": [109, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_rirb4v35mexw1mbz", + "end": [388, -3], + "type": "wall", + "start": [375, -3], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_rjr2g2nx3e9ew5uf", + "end": [217, 10], + "type": "wall", + "start": [209, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_rjvki7eh6eu356nd", + "end": [3.5, 3.5], + "type": "wall", + "start": [-1.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_rq2gi7x19t696qsz", + "end": [225, 2], + "type": "wall", + "start": [214, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_rvz19beniyaabmfd", + "end": [259, 2], + "type": "wall", + "start": [259, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_rx5b1w2s1m32ppx5", + "end": [309, 4.5], + "type": "wall", + "start": [303.5, 4.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_rxyexvgiolckyhic", + "end": [275, 2], + "type": "wall", + "start": [264, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_ryiuoswcp75pte9z", + "end": [28, 13], + "type": "wall", + "start": [28, 9], + "object": "node", + "visible": true, + "backSide": "exterior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "interior" + }, + { + "id": "wall_s07zz13tobzrfnnf", + "end": [290, 10], + "type": "wall", + "start": [290, 3.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_s604h6mxe4c7lxa7", + "end": [290, 3.5], + "type": "wall", + "start": [294.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_s9dcjc9oz9l2z7q5", + "end": [103.5, 10], + "type": "wall", + "start": [98.5, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_sdwb4s3hphcfpcxb", + "end": [128, 13], + "type": "wall", + "start": [128, 9], + "object": "node", + "visible": true, + "backSide": "exterior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "interior" + }, + { + "id": "wall_sgvaf5n202k02lux", + "end": [295, 3.5], + "type": "wall", + "start": [295, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_snqe4djy13kdkvm0", + "end": [25, -10], + "type": "wall", + "start": [38, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_sppk83pk35mj2cuo", + "end": [303.5, 10], + "type": "wall", + "start": [298.5, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_svcdfr8x4fahdg7d", + "end": [198.5, 10], + "type": "wall", + "start": [190, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_t15g478d2mwk2emk", + "end": [159, 2], + "type": "wall", + "start": [159, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_t2y1whj3aeb7cpn5", + "end": [138, 4], + "type": "wall", + "start": [117, 4], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_t31t47858fmeaioi", + "end": [309, 2], + "type": "wall", + "start": [309, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_t3zl179duh02lus8", + "end": [309, 10], + "type": "wall", + "start": [309, 4.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_ta03isfucmdkz9y6", + "end": [48.5, 10], + "type": "wall", + "start": [40, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_tfacjuu6fg7bgax3", + "end": [164.25, -10], + "type": "wall", + "start": [164, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_tg3r2dxqi82bj6mc", + "end": [153.5, 3.5], + "type": "wall", + "start": [148.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_tjcmsdcf6kamxs78", + "end": [175, -10], + "type": "wall", + "start": [188, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_to2j2xrqvi22klpd", + "end": [140, 10], + "type": "wall", + "start": [140, 3.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_tosf62s8y6c6jb25", + "end": [188, -3], + "type": "wall", + "start": [188, 4], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_tpllpasugfw7fjfs", + "end": [-10, 3.5], + "type": "wall", + "start": [-5.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_u0maipdem3qao42n", + "end": [125, 2], + "type": "wall", + "start": [114, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_u0pfc6xiroczgpg9", + "end": [175, 2], + "type": "wall", + "start": [164, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_u1yem55bj9kolijo", + "end": [53.5, 4.5], + "type": "wall", + "start": [53.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_u4q5pmdditset7gq", + "end": [214.25, -10], + "type": "wall", + "start": [214, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_ueelewge9mbc2tso", + "end": [367, 4], + "type": "wall", + "start": [367, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_ugd70n1dxpydo8xe", + "end": [225, -3], + "type": "wall", + "start": [225, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_ujf5umwsnwgxyj34", + "end": [88, 4], + "type": "wall", + "start": [67, 4], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_ummci7mijiqtoqzl", + "end": [340, -10], + "type": "wall", + "start": [345, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_uoidvd5w1ue5xe1x", + "end": [74, 13], + "type": "wall", + "start": [74, 9], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_uu1lv3vvow0k90fz", + "end": [88, -3], + "type": "wall", + "start": [75, -3], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_uxzvlwzyt8122yd2", + "end": [338, -3], + "type": "wall", + "start": [338, 4], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_v1gk6mjrfbg4l03m", + "end": [228, 9], + "type": "wall", + "start": [224, 9], + "object": "node", + "visible": true, + "backSide": "exterior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "interior" + }, + { + "id": "wall_vdejqilk8o6zfi16", + "end": [40, 3.5], + "type": "wall", + "start": [40, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_vkaojz5o2gi5rlh1", + "end": [353.5, 3.5], + "type": "wall", + "start": [348.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_vlk5wx85z5ak2y50", + "end": [24, 13], + "type": "wall", + "start": [24, 9], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_vlsd0wc35m313i7p", + "end": [78, 13], + "type": "wall", + "start": [78, 9], + "object": "node", + "visible": true, + "backSide": "exterior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "interior" + }, + { + "id": "wall_vlx17gf8lt858zhx", + "end": [59, -4], + "type": "wall", + "start": [46, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_vqaucztp4iguoaeh", + "end": [325, -10], + "type": "wall", + "start": [338, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_vsi67vwpqoifenjm", + "end": [274, 13], + "type": "wall", + "start": [274, 9], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_vtbxpzmthofrstf3", + "end": [225, -10], + "type": "wall", + "start": [225, -3], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_vweuvcfk7ifc33mg", + "end": [159, 4.5], + "type": "wall", + "start": [153.5, 4.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_vzwd82mm47e3injl", + "end": [203.5, 4.5], + "type": "wall", + "start": [203.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_w04cw307rgsz7pgp", + "end": [359, 4.5], + "type": "wall", + "start": [353.5, 4.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_w0qbct2ghte5y8q2", + "end": [45, -10], + "type": "wall", + "start": [64.25, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_w234cjc1wk18sies", + "end": [259, 4.5], + "type": "wall", + "start": [253.5, 4.5], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_w2efraazcef2fm1x", + "end": [145, 3.5], + "type": "wall", + "start": [145, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_wcdedg2b1g750rqe", + "end": [238, -3], + "type": "wall", + "start": [238, 4], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_wch48nda8atejla5", + "end": [340, 3.5], + "type": "wall", + "start": [340, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_wkeyes3jvwf7ezlw", + "end": [145, 2], + "type": "wall", + "start": [159, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_wkpoxq842i2gmw4r", + "end": [3.5, 4.5], + "type": "wall", + "start": [3.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_wtz4jhv39keexept", + "end": [264, -4], + "type": "wall", + "start": [259, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_x6ae19ye72th0go3", + "end": [214, -4], + "type": "wall", + "start": [209, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_x7oovb9j1vxqybzj", + "end": [75, -10], + "type": "wall", + "start": [75, -3], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_x9qsserambfj4jgx", + "end": [314.25, -10], + "type": "wall", + "start": [314, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_xm50jlyv0j826h9q", + "end": [28, 9], + "type": "wall", + "start": [24, 9], + "object": "node", + "visible": true, + "backSide": "exterior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "interior" + }, + { + "id": "wall_xq7f7r31yp5lf06f", + "end": [203.5, 10], + "type": "wall", + "start": [198.5, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_xrbdcdektoqfkuvd", + "end": [64, -4], + "type": "wall", + "start": [59, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_xzzhjom15actlrnd", + "end": [195, 3.5], + "type": "wall", + "start": [198.5, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_y2lv56lcpbtzxk19", + "end": [48.5, 3.5], + "type": "wall", + "start": [48.5, 10], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_y7b8ypt1yjt5h9i9", + "end": [178, 13], + "type": "wall", + "start": [178, 9], + "object": "node", + "visible": true, + "backSide": "exterior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "interior" + }, + { + "id": "wall_yhpbv6x5lxpihib2", + "end": [14.25, -10], + "type": "wall", + "start": [25, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_ykh6e9320af73x6n", + "end": [178, 9], + "type": "wall", + "start": [174, 9], + "object": "node", + "visible": true, + "backSide": "exterior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "interior" + }, + { + "id": "wall_yskwmz4ek9opvxh5", + "end": [296, -4], + "type": "wall", + "start": [295, -10], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_z3kzssf9v2nypc31", + "end": [-5, 2], + "type": "wall", + "start": [-4, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_z4p1todu4htqh6se", + "end": [309, -4], + "type": "wall", + "start": [296, -4], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_zbqxzatbmyi0k964", + "end": [295, -10], + "type": "wall", + "start": [314.25, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_zdjnbular1c0j3aw", + "end": [248.5, 3.5], + "type": "wall", + "start": [248.5, 10], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_zfhh2w55uoc3qqkf", + "end": [225, -10], + "type": "wall", + "start": [238, -10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_zhunaufjlh0ir9tp", + "end": [325, 2], + "type": "wall", + "start": [314, 2], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_zlccvcf94cp2y8zk", + "end": [3.5, 4.5], + "type": "wall", + "start": [3.5, 10], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "exterior" + }, + { + "id": "wall_zpx72734mqp4hayk", + "end": [98.5, 3.5], + "type": "wall", + "start": [98.5, 10], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + }, + { + "id": "wall_zx5trbvepar8tn3a", + "end": [344.5, 3.5], + "type": "wall", + "start": [345, 3.5], + "object": "node", + "visible": true, + "backSide": "unknown", + "children": [], + "parentId": "level_5jzpvy5og6mvl2h8", + "frontSide": "unknown" + } + ], + "updates": [ + { "id": "wall_3ixzzc6om7z53rvs", "data": { "start": [9.5, 2], "end": [-5, 2] } }, + { "id": "wall_bz46jy8c7jjbbj4t", "data": { "start": [-5, 2], "end": [-5, 3.5] } }, + { "id": "wall_hwoye1vqnhkdcdy2", "data": { "start": [9, -4], "end": [9.5, 2] } }, + { "id": "wall_z3kzssf9v2nypc31", "data": { "start": [-4, -4], "end": [-5, 2] } } + ] +} diff --git a/packages/core/src/store/history-invalidation.test.ts b/packages/core/src/store/history-invalidation.test.ts new file mode 100644 index 0000000000..101cc04795 --- /dev/null +++ b/packages/core/src/store/history-invalidation.test.ts @@ -0,0 +1,286 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { nodeRegistry } from '../registry' +import { + type AnyNode, + CeilingNode, + DoorNode, + ItemNode, + LevelNode, + SlabNode, + WallNode, + WindowNode, +} from '../schema' +import { getHistoryDirtyNodeIds } from './history-invalidation' +import useScene, { clearSceneHistory } from './use-scene' + +const level = LevelNode.parse({ id: 'level_history' }) +const wall = WallNode.parse({ id: 'wall_changed', parentId: level.id, start: [0, 0], end: [4, 0] }) +const remote = WallNode.parse({ + id: 'wall_remote', + parentId: level.id, + start: [20, 0], + end: [24, 0], +}) +const nodes = (...entries: AnyNode[]) => Object.fromEntries(entries.map((node) => [node.id, node])) +const ids = (before: Record, after: Record) => + [...getHistoryDirtyNodeIds(before, after)].sort() +const asset = { id: 'test', name: 'test', category: 'test', thumbnail: '', src: '/test.glb' } + +describe('history dependency closure', () => { + test.each([ + 'corner', + 'tee', + 'reverse tee', + 'curve', + ])('wall body move disconnects and restores a %s in both directions, only on its level', (junction) => { + const changed = junction === 'curve' ? { ...wall, curveOffset: 1 } : wall + const adjacent = WallNode.parse({ + id: 'wall_adjacent', + parentId: level.id, + start: + junction === 'corner' + ? [4, 0] + : junction === 'tee' + ? [2, 0] + : junction === 'curve' + ? [4, 0] + : [4, -2], + end: junction === 'reverse tee' ? [4, 2] : [2, -3], + }) + const otherLevel = { ...adjacent, id: 'wall_other_level', parentId: 'level_other' } as WallNode + const before = nodes(level, changed, adjacent, remote, otherLevel) + const after = { + ...before, + [changed.id]: { ...changed, start: [8, 8], end: [12, 8] } as WallNode, + } + expect(ids(before, after)).toEqual([level.id, adjacent.id, changed.id].sort()) + expect(ids(after, before)).toEqual(ids(before, after)) + }) + + test('endpoint move includes former and new neighbours without following a junction transitively', () => { + const old = WallNode.parse({ id: 'wall_old', parentId: level.id, start: [4, 0], end: [4, 4] }) + const next = WallNode.parse({ id: 'wall_next', parentId: level.id, start: [6, 0], end: [6, 4] }) + const beyond = WallNode.parse({ + id: 'wall_beyond', + parentId: level.id, + start: [6, 4], + end: [8, 4], + }) + const before = nodes(level, wall, old, next, beyond, remote) + const after = { ...before, [wall.id]: { ...wall, end: [6, 0] } as WallNode } + expect(ids(before, after)).toEqual([level.id, wall.id, old.id, next.id].sort()) + }) + + test.each([ + { thickness: 0.4 }, + { height: 4 }, + { curveOffset: 0.8 }, + ])('host shape change %j includes opening proxies and wall-side items', (patch) => { + const door = DoorNode.parse({ parentId: wall.id }) + const window = WindowNode.parse({ parentId: wall.id }) + const item = ItemNode.parse({ parentId: wall.id, asset: { ...asset, attachTo: 'wall-side' } }) + const other = DoorNode.parse({ parentId: remote.id }) + const before = nodes(level, wall, remote, door, window, item, other) + const after = { ...before, [wall.id]: { ...wall, ...patch } } + expect(ids(before, after)).toEqual([level.id, wall.id, door.id, window.id, item.id].sort()) + expect(ids(after, before)).toEqual(ids(before, after)) + }) + + test.each([ + DoorNode, + WindowNode, + ])('opening add/remove/move/resize/reparent marks surviving hosts', (schema) => { + const opening = schema.parse({ parentId: wall.id }) + const before = nodes(level, wall, remote, opening) + for (const patch of [{ position: [1, 1, 0] }, { width: 2 }, { parentId: remote.id }]) { + const after = { ...before, [opening.id]: { ...opening, ...patch } as AnyNode } + expect(ids(before, after)).toEqual( + [opening.id, wall.id, ...('parentId' in patch ? [remote.id] : [])].sort(), + ) + } + const absent = nodes(level, wall, remote) + expect(ids(absent, before)).toEqual([opening.id, wall.id].sort()) + expect(ids(before, absent)).toEqual([wall.id]) + }) + + test.each([ + 'floor', + 'wall host', + 'elevated slab', + ])('item move on %s marks the item and parent, preserving its asset', (support) => { + const item = ItemNode.parse({ + parentId: support === 'wall host' ? wall.id : level.id, + supportSlabId: support === 'elevated slab' ? 'slab_deck' : undefined, + asset, + }) + const before = nodes(level, wall, remote, item) + const moved = { ...item, position: [3, 0, 2], rotation: [0, 1, 0] } as AnyNode + const after = { ...before, [item.id]: moved } + expect(ids(before, after)).toEqual([item.id, item.parentId!].sort()) + expect((moved as ItemNode).asset).toBe(item.asset) + }) + + test.each([ + SlabNode, + CeilingNode, + ])('surface polygon/holes/elevation marks the surface; subscribers own support dependencies', (schema) => { + const surface = schema.parse({ + parentId: level.id, + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + }) + const before = nodes(level, wall, remote, surface) + for (const patch of [ + { + polygon: [ + [0, 0], + [8, 0], + [8, 4], + [0, 4], + ], + }, + schema === SlabNode ? { elevation: 1 } : { height: 4 }, + { + holes: [ + [ + [1, 1], + [2, 1], + [2, 2], + ], + ], + }, + ]) { + expect(ids(before, { ...before, [surface.id]: { ...surface, ...patch } as AnyNode })).toEqual( + [surface.id, level.id].sort(), + ) + } + }) + + test('level height leaves descendant invalidation to the spatial subscription', () => { + const before = nodes(level, wall, remote) + expect(ids(before, { ...before, [level.id]: { ...level, height: 4 } })).toEqual([level.id]) + }) + + test('subtree delete/restore filters missing ids and preserves the deletion sibling rule', () => { + const door = DoorNode.parse({ parentId: wall.id }) + const full = nodes( + { ...level, children: [wall.id, remote.id] }, + { ...wall, children: [door.id] }, + remote, + door, + ) + const removed = nodes({ ...level, children: [remote.id] }, remote) + expect(ids(full, removed)).toEqual([level.id, remote.id].sort()) + expect(ids(removed, full)).toEqual([level.id, wall.id, door.id].sort()) + expect(ids(full, full)).toEqual([]) + }) + test('wall reparent includes neighbours on the old and new levels', () => { + const upper = LevelNode.parse({ id: 'level_upper_history' }) + const old = WallNode.parse({ + id: 'wall_old_level', + parentId: level.id, + start: [4, 0], + end: [4, 4], + }) + const next = { ...old, id: 'wall_new_level', parentId: upper.id } as WallNode + const before = nodes(level, upper, wall, old, next, remote) + const after = { ...before, [wall.id]: { ...wall, parentId: upper.id } } + expect(ids(before, after)).toEqual([level.id, upper.id, wall.id, old.id, next.id].sort()) + }) + + test('thickness rebuilds a joined corner and T junction without dirtying a disconnected wall', () => { + const corner = WallNode.parse({ + id: 'wall_corner', + parentId: level.id, + start: [4, 0], + end: [4, 4], + }) + const tee = WallNode.parse({ id: 'wall_tee', parentId: level.id, start: [2, 0], end: [2, -4] }) + const before = nodes(level, wall, corner, tee, remote) + expect(ids(before, { ...before, [wall.id]: { ...wall, thickness: 0.5 } })).toEqual( + [level.id, wall.id, corner.id, tee.id].sort(), + ) + }) + + test('same-count subtree replacement restores original identities and excludes deleted ids', () => { + const door = DoorNode.parse({ parentId: wall.id }) + const replacement = DoorNode.parse({ parentId: remote.id }) + const before = nodes(level, { ...wall, children: [door.id] }, remote, door) + const after = nodes( + level, + { ...wall, children: [] }, + { ...remote, children: [replacement.id] }, + replacement, + ) + const dirty = getHistoryDirtyNodeIds(after, before) + expect(dirty.has(door.id)).toBe(true) + expect(dirty.has(replacement.id)).toBe(false) + expect(dirty.has(wall.id)).toBe(true) + expect(dirty.has(remote.id)).toBe(true) + }) +}) + +describe('consecutive temporal wall moves', () => { + let restore = () => {} + + beforeEach(() => { + restore = nodeRegistry._snapshot() + nodeRegistry._reset() + clearSceneHistory() + }) + + afterEach(() => { + clearSceneHistory() + restore() + }) + + test('three undos and redos mark exactly the neighbours in each pre/post-jump layout', async () => { + const neighbours = [0, 10, 20, 30].map((x, index) => + WallNode.parse({ + id: `wall_neighbour_${index}`, + parentId: level.id, + start: [x + 4, 0], + end: [x + 4, 4], + }), + ) + const layouts = [0, 10, 20, 30].map( + (x) => + ({ + ...wall, + start: [x, 0], + end: [x + 4, 0], + }) as WallNode, + ) + const unrelated = { ...remote, start: [100, 0], end: [104, 0] } as WallNode + useScene.setState({ + nodes: nodes(level, layouts[0]!, ...neighbours, unrelated), + rootNodeIds: [level.id], + collections: {}, + installedPlugins: [], + dirtyNodes: new Set(), + readOnly: false, + }) + clearSceneHistory() + for (const moved of layouts.slice(1)) { + useScene.setState({ nodes: { ...useScene.getState().nodes, [wall.id]: moved } }) + } + expect(useScene.temporal.getState().pastStates).toHaveLength(3) + + for (const direction of ['undo', 'redo'] as const) { + for (const beforeIndex of direction === 'undo' ? [3, 2, 1] : [0, 1, 2]) { + const afterIndex = beforeIndex + (direction === 'undo' ? -1 : 1) + useScene.getState().dirtyNodes.clear() + useScene.temporal.getState()[direction]() + await Promise.resolve() + expect(useScene.getState().nodes[wall.id]).toBe(layouts[afterIndex]) + expect([...useScene.getState().dirtyNodes].sort()).toEqual( + [level.id, wall.id, neighbours[beforeIndex]!.id, neighbours[afterIndex]!.id].sort(), + ) + } + } + }) +}) diff --git a/packages/core/src/store/history-invalidation.ts b/packages/core/src/store/history-invalidation.ts new file mode 100644 index 0000000000..5e20d6051e --- /dev/null +++ b/packages/core/src/store/history-invalidation.ts @@ -0,0 +1,80 @@ +import type { AnyNode, AnyNodeId, WallNode } from '../schema' +import { getAdjacentWallIds } from '../systems/wall/wall-mitering' + +export function getHistoryDirtyNodeIds( + before: Record, + after: Record, +): Set { + const dirty = new Set() + const changedWalls = new Set() + const changedHosts = new Set() + const add = (id: string | null | undefined) => { + if (id && after[id]) dirty.add(id as AnyNodeId) + } + + const visitChange = (id: string) => { + const previous = before[id] + const next = after[id] + if (previous === next) return + add(id) + add(previous?.parentId) + add(next?.parentId) + + if (previous?.type === 'wall' || next?.type === 'wall') { + changedWalls.add(id) + if ( + previous?.type !== 'wall' || + next?.type !== 'wall' || + previous.thickness !== next.thickness || + previous.height !== next.height || + previous.curveOffset !== next.curveOffset + ) { + changedHosts.add(id) + } + } + + if (previous && !next && previous.parentId) { + const parent = after[previous.parentId] + // Preserve deletion's sibling refresh for merged geometry consumers. + if (parent && 'children' in parent && Array.isArray(parent.children)) { + for (const childId of parent.children) add(childId) + } + } + } + + for (const id in before) visitChange(id) + for (const id in after) { + if (!before[id]) visitChange(id) + } + + if (changedWalls.size === 0) return dirty + + for (const nodes of [before, after]) { + const wallsByLevel = new Map() + for (const id of changedWalls) { + const wall = nodes[id] + if (wall?.type === 'wall' && !wallsByLevel.has(wall.parentId)) { + wallsByLevel.set(wall.parentId, []) + } + } + for (const id in nodes) { + const node = nodes[id]! + if (node.type === 'wall') { + wallsByLevel.get(node.parentId)?.push(node) + } + if ( + node.parentId && + changedHosts.has(node.parentId) && + (node.type === 'door' || node.type === 'window' || node.type === 'item') + ) { + add(node.id) + } + } + // Former junctions must rebuild too; the viewer only sees the restored layout. + for (const walls of wallsByLevel.values()) { + const changed = new Set(walls.filter((wall) => changedWalls.has(wall.id)).map((w) => w.id)) + for (const id of getAdjacentWallIds(walls, changed)) add(id) + } + } + return dirty +} diff --git a/packages/core/src/store/scene-hydration.ts b/packages/core/src/store/scene-hydration.ts new file mode 100644 index 0000000000..6316d813d8 --- /dev/null +++ b/packages/core/src/store/scene-hydration.ts @@ -0,0 +1,56 @@ +type Hydration = { pending: number; publish: () => void } + +let pendingHydration: Hydration | null = null +let normalizing: Hydration | null = null + +export function invalidatePendingHydration() { + pendingHydration = null +} + +export function isHydrationNormalization() { + return normalizing !== null && normalizing === pendingHydration +} + +function finishNormalization(hydration: Hydration) { + hydration.pending-- + if (hydration.pending === 0 && pendingHydration === hydration) { + pendingHydration = null + hydration.publish() + } +} + +export function runSceneHydration(normalize: () => void, publish: () => void) { + const hydration = { pending: 1, publish } + pendingHydration = hydration + const previous = normalizing + normalizing = hydration + try { + normalize() + } catch (error) { + if (pendingHydration === hydration) invalidatePendingHydration() + throw error + } finally { + normalizing = previous + finishNormalization(hydration) + } +} + +// Only normalization queued by this hydration may extend its boundary. A +// subsequent edit cancels publication even if these microtasks are still queued. +export function queueSceneNormalization(normalize: () => void) { + const hydration = pendingHydration + if (hydration) hydration.pending++ + queueMicrotask(() => { + const previous = normalizing + normalizing = hydration + try { + normalize() + } catch (error) { + if (pendingHydration === hydration) invalidatePendingHydration() + throw error + } finally { + normalizing = previous + if (hydration) finishNormalization(hydration) + } + }) +} diff --git a/packages/core/src/store/use-scene-dirty-tracking.test.ts b/packages/core/src/store/use-scene-dirty-tracking.test.ts index e26444711c..53ddb946d7 100644 --- a/packages/core/src/store/use-scene-dirty-tracking.test.ts +++ b/packages/core/src/store/use-scene-dirty-tracking.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { nodeRegistry } from '../registry/registry' import type { AnyNodeDefinition } from '../registry/types' import type { AnyNode, AnyNodeId } from '../schema/types' @@ -39,9 +39,8 @@ describe('dirty tracking', () => { beforeEach(() => { if (!nodeRegistry.has(untrackedDef.kind)) nodeRegistry._register(untrackedDef) if (!nodeRegistry.has(trackedDef.kind)) nodeRegistry._register(trackedDef) - // Clear rather than replace the dirty set: the store's own instance is the - // guarded one, and the raw-add tests below exercise that guard. - useScene.getState().dirtyNodes.clear() + useScene.getState().unloadScene() + useScene.setState({ readOnly: false }) useScene.setState({ nodes: { [UNTRACKED]: makeNode(UNTRACKED, 'test-untracked'), @@ -54,6 +53,11 @@ describe('dirty tracking', () => { useScene.temporal.getState().clear() }) + afterEach(() => { + useScene.getState().unloadScene() + useScene.temporal.getState().clear() + }) + // Membership asserts (not set size/equality): the scene store is a module // singleton, and subscribers leaked by other test files can add their own // dirty marks when `setState` fires. diff --git a/packages/core/src/store/use-scene-temporal-reconciliation.test.ts b/packages/core/src/store/use-scene-temporal-reconciliation.test.ts new file mode 100644 index 0000000000..db5d8cb11d --- /dev/null +++ b/packages/core/src/store/use-scene-temporal-reconciliation.test.ts @@ -0,0 +1,351 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { initSpaceDetectionSync, type SpaceTopologyReconcileEvent } from '../lib/space-detection' +import { + AnyNode, + type AnyNodeId, + BuildingNode, + CeilingNode, + LevelNode, + SlabNode, + WallNode, +} from '../schema' +import fixture from './fixtures/maxi-8x-endpoint.json' +import { runAsSingleSceneHistoryStep } from './history-control' +import useScene, { clearSceneHistory } from './use-scene' + +const originalRaf = globalThis.requestAnimationFrame +const originalCancelRaf = globalThis.cancelAnimationFrame +beforeEach(() => { + globalThis.requestAnimationFrame = (callback) => { + callback(0) + return 0 + } + globalThis.cancelAnimationFrame = () => {} +}) + +const cleanups: Array<() => void> = [] +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup() + useScene.getState().unloadScene() + clearSceneHistory() + globalThis.requestAnimationFrame = originalRaf + globalThis.cancelAnimationFrame = originalCancelRaf +}) + +function watch(nodes: Record) { + useScene.setState({ nodes, rootNodeIds: [], readOnly: false }) + clearSceneHistory() + const events: SpaceTopologyReconcileEvent[] = [] + const editor = { + spaces: {}, + setSpaces(spaces: Record) { + this.spaces = spaces + }, + } + const stop = initSpaceDetectionSync( + useScene, + { getState: () => editor }, + { onTopologyReconcile: (event) => events.push(event) }, + ) + cleanups.push(stop) + return { events, stop, editor } +} + +function canonicalPolygon(points: number[][]) { + const rotations = points.map((_, i) => + JSON.stringify([...points.slice(i), ...points.slice(0, i)]), + ) + return rotations.sort()[0] +} + +function graph(nodes: Record) { + return Object.fromEntries( + Object.entries(nodes).map(([id, node]) => [ + id, + 'polygon' in node && Array.isArray(node.polygon) + ? { ...node, polygon: canonicalPolygon(node.polygon) } + : node, + ]), + ) +} + +const baseline = () => + Object.fromEntries( + fixture.nodes.map((node) => { + const parsed = AnyNode.parse(node) + return [parsed.id, parsed] + }), + ) as Record + +function forward() { + runAsSingleSceneHistoryStep(useScene, () => + useScene.getState().updateNodes(fixture.updates as { id: AnyNodeId; data: Partial }[]), + ) +} + +test('Maxi 8Γ— endpoint undo/redo matches full-level graph and preserves unrelated surface identities', () => { + const initial = baseline() + const indexed = watch(initial) + forward() + const moved = useScene.getState().nodes + const target = useScene.temporal.getState().pastStates.at(-1)!.nodes + indexed.events.length = 0 + useScene.temporal.getState().undo() + const undone = useScene.getState().nodes + expect(indexed.events).toHaveLength(1) + expect(indexed.events[0]?.strategy).toBe('indexed') + const remoteSlab = 'slab_02warzosdw17ko2n' as AnyNodeId + expect(undone[remoteSlab]).toBe(target[remoteSlab]) + const undoGraph = graph(undone) + useScene.temporal.getState().redo() + const redoGraph = graph(useScene.getState().nodes) + expect(indexed.events).toHaveLength(2) + indexed.stop() + + const fullUndo = watch(moved) + useScene.setState({ nodes: target }) + expect(undoGraph).toEqual(graph(useScene.getState().nodes)) + fullUndo.stop() + const fullRedo = watch(undone) + useScene.setState({ nodes: moved }) + expect(redoGraph).toEqual(graph(useScene.getState().nodes)) + fullRedo.stop() +}) + +type Nodes = Record +type Transition = { before: Nodes; target: Nodes; actual: Nodes } + +function rooms() { + const building = BuildingNode.parse({ id: 'building_temporal', children: ['level_temporal'] }) + const level = LevelNode.parse({ id: 'level_temporal', parentId: building.id, height: 2.5 }) + const nodes: Nodes = { [building.id]: building, [level.id]: level } + for (const [label, x] of [ + ['left', 0], + ['right', 20], + ] as const) { + const polygon: [number, number][] = [ + [x, 0], + [x + 4, 0], + [x + 4, 3], + [x, 3], + ] + const walls = polygon.map((start, i) => + WallNode.parse({ + id: `wall_${label}_${i}`, + parentId: level.id, + start, + end: polygon[(i + 1) % 4], + frontSide: 'interior', + backSide: 'exterior', + }), + ) + const slab = SlabNode.parse({ + id: `slab_${label}`, + parentId: level.id, + polygon, + autoFromWalls: true, + }) + const ceiling = CeilingNode.parse({ + id: `ceiling_${label}`, + parentId: level.id, + polygon, + height: 2.49, + autoFromWalls: true, + }) + for (const node of [...walls, slab, ceiling]) { + nodes[node.id] = node + level.children.push(node.id) + } + } + return { nodes, level } +} + +function surfaces(nodes: Nodes) { + return Object.values(nodes).filter( + (node): node is SlabNode | CeilingNode => + (node.type === 'slab' || node.type === 'ceiling') && node.autoFromWalls, + ) +} + +function assertSurfaces(nodes: Nodes, rooms: number, height = 2.49) { + const auto = surfaces(nodes) + expect(auto.filter((node) => node.type === 'slab')).toHaveLength(rooms) + expect(auto.filter((node) => node.type === 'ceiling')).toHaveLength(rooms) + for (const node of auto) { + expect((nodes[node.parentId!] as LevelNode).children.filter((id) => id === node.id)).toEqual([ + node.id, + ]) + expect(node.polygon.length).toBeGreaterThanOrEqual(4) + if (node.type === 'ceiling') expect(node.height).toBeCloseTo(height) + else expect(node.elevation).toBeCloseTo(0.05) + } +} + +function jump(transitions: Transition[], direction: 'undo' | 'redo', steps = 1) { + const history = useScene.temporal.getState() + const states = direction === 'undo' ? history.pastStates : history.futureStates + const target = states.at(-steps)!.nodes + const before = useScene.getState().nodes + history[direction](steps) + const actual = useScene.getState().nodes + transitions.push({ before, target, actual }) + return actual +} + +function assertFullLevelOracle(transitions: Transition[]) { + for (const { before, target, actual } of transitions) { + const full = watch(before) + useScene.setState({ nodes: target }) + const expected = useScene.getState().nodes + expect( + surfaces(actual) + .map((node) => node.id) + .sort(), + ).toEqual( + surfaces(expected) + .map((node) => node.id) + .sort(), + ) + expect(graph(actual)).toEqual(graph(expected)) + full.stop() + } +} + +test('closing-wall deletion, undo and redo retain the full-level surface outcome', () => { + const { nodes, level } = rooms() + const sync = watch(nodes) + const transitions: Transition[] = [] + const before = useScene.getState().nodes + useScene.getState().deleteNode('wall_left_3') + const deleted = useScene.getState().nodes + transitions.push({ before, target: deleted, actual: deleted }) + assertSurfaces(deleted, 1) + expect((deleted[level.id] as LevelNode).children).not.toContain('wall_left_3') + const restored = jump(transitions, 'undo') + assertSurfaces(restored, 2) + expect( + surfaces(restored) + .map((node) => node.id) + .sort(), + ).toEqual( + surfaces(nodes) + .map((node) => node.id) + .sort(), + ) + expect((restored[level.id] as LevelNode).children).toContain('wall_left_3') + assertSurfaces(jump(transitions, 'redo'), 1) + sync.stop() + assertFullLevelOracle(transitions) +}) + +test('split, move and merge history preserves surfaces through the complete cycle', () => { + const { nodes, level } = rooms() + const sync = watch(nodes) + const transitions: Transition[] = [] + const divider = WallNode.parse({ + id: 'wall_divider', + parentId: level.id, + start: [2, 0], + end: [2, 3], + }) + useScene.getState().createNode(divider, level.id) + assertSurfaces(useScene.getState().nodes, 3) + useScene.getState().updateNode(divider.id, { start: [3, 0], end: [3, 3] }) + assertSurfaces(useScene.getState().nodes, 3) + useScene.getState().deleteNode(divider.id) + assertSurfaces(useScene.getState().nodes, 2) + assertSurfaces(jump(transitions, 'undo'), 3) + sync.events.length = 0 + const unmoved = jump(transitions, 'undo') + assertSurfaces(unmoved, 3) + expect(sync.events).toHaveLength(1) + expect(sync.events[0]?.strategy).toBe('indexed') + expect(sync.events[0]?.examinedWallIds.every((id) => !id.startsWith('wall_right'))).toBe(true) + expect((unmoved[divider.id] as WallNode).start).toEqual([2, 0]) + assertSurfaces(jump(transitions, 'undo'), 2) + for (const count of [3, 3, 2]) assertSurfaces(jump(transitions, 'redo'), count) + sync.stop() + assertFullLevelOracle(transitions) +}) + +test('two-step temporal jumps reconcile two distinct components against the full-level oracle', () => { + const { nodes } = rooms() + const sync = watch(nodes) + const transitions: Transition[] = [] + for (const [label, x] of [ + ['left', 0], + ['right', 20], + ] as const) { + useScene.getState().updateNodes([ + { id: `wall_${label}_0`, data: { end: [x + 5, 0] } }, + { id: `wall_${label}_1`, data: { start: [x + 5, 0], end: [x + 5, 3] } }, + { id: `wall_${label}_2`, data: { start: [x + 5, 3] } }, + ]) + } + const moved = useScene.getState().nodes + sync.events.length = 0 + const undone = jump(transitions, 'undo', 2) + assertSurfaces(undone, 2) + expect(sync.events).toHaveLength(1) + expect(sync.events[0]?.affectedBeforeRoomCount).toBe(2) + expect(sync.events[0]?.affectedCurrentRoomCount).toBe(2) + for (const label of ['left', 'right']) { + expect(sync.events[0]?.examinedWallIds).toContain(`wall_${label}_1`) + expect(canonicalPolygon((undone[`slab_${label}`] as SlabNode).polygon)).toEqual( + canonicalPolygon((nodes[`slab_${label}`] as SlabNode).polygon), + ) + } + const redone = jump(transitions, 'redo', 2) + assertSurfaces(redone, 2) + expect(sync.events).toHaveLength(2) + expect(graph(redone)).toEqual(graph(moved)) + sync.stop() + assertFullLevelOracle(transitions) +}) + +test('level hierarchy changes reconcile derived ceiling heights on undo and redo', () => { + const building = BuildingNode.parse({ id: 'building_heights', children: ['level_heights'] }) + const level = LevelNode.parse({ + id: 'level_heights', + parentId: building.id, + height: 2.5, + children: ['ceiling_heights', 'wall_heights'], + }) + // A stale explicit ceiling in a loaded snapshot makes skipping reconciliation + // observable: native restoration alone would bring its height back to 9. + const ceiling = CeilingNode.parse({ + id: 'ceiling_heights', + parentId: level.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + height: 9, + }) + const wall = WallNode.parse({ + id: 'wall_heights', + parentId: level.id, + start: [0, 0], + end: [4, 0], + }) + const nodes: Nodes = { + [building.id]: building, + [level.id]: level, + [ceiling.id]: ceiling, + [wall.id]: wall, + } + const sync = watch(nodes) + const transitions: Transition[] = [] + useScene.setState({ nodes: { ...nodes, [level.id]: { ...level, height: 4 } } }) + expect((useScene.getState().nodes[ceiling.id] as CeilingNode).height).toBeCloseTo(3.99) + expect(useScene.getState().nodes[wall.id]).toBe(wall) + const undone = jump(transitions, 'undo') + expect((undone[ceiling.id] as CeilingNode).height).toBeCloseTo(2.49) + expect((undone[level.id] as LevelNode).children).toEqual([ceiling.id, wall.id]) + const redone = jump(transitions, 'redo') + expect((redone[ceiling.id] as CeilingNode).height).toBeCloseTo(3.99) + sync.stop() + assertFullLevelOracle(transitions) +}) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 1f92432ade..2933d71645 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -2,7 +2,7 @@ import type { TemporalState } from 'zundo' import { temporal } from 'zundo' -import { create, type StoreApi, type UseBoundStore } from 'zustand' +import { create, type StateCreator, type StoreApi, type UseBoundStore } from 'zustand' import { parseMaterialRef, toSceneMaterialRef } from '../material-library' import { getNodePluginId, isNodeKindEnabled, nodeRegistry } from '../registry/registry' import { BuildingNode } from '../schema' @@ -38,6 +38,9 @@ import { type SceneMaterialId, } from '../schema/scene-material' import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema } from '../schema/types' +import { syncAutoElevatorOpenings } from '../systems/elevator/elevator-opening-sync' +import { syncAutoStairOpenings } from '../systems/stair/stair-opening-sync' +import { syncStairRises } from '../systems/stair/stair-rise' import { healSceneNodes } from '../utils/heal-scene-graph' import { removeRetiredDrawingSheetNodes } from '../utils/retired-scene-nodes' import { migrateVerticalSceneNodes } from '../utils/vertical-scene-migration' @@ -49,9 +52,17 @@ import { pauseSceneHistory, resetSceneHistoryPauseDepth, resumeSceneHistory, + runWithSceneCommitNodeIds, type SceneCommitOrigin, type SceneSnapshot, } from './history-control' +import { getHistoryDirtyNodeIds } from './history-invalidation' +import { + invalidatePendingHydration, + isHydrationNormalization, + queueSceneNormalization, + runSceneHydration, +} from './scene-hydration' import useLiveNodeOverrides from './use-live-node-overrides' import useLiveTransforms from './use-live-transforms' @@ -1193,6 +1204,11 @@ export type SceneState = { // 3. The "Dirty" Set: For the Wall/Physics systems dirtyNodes: Set + // Identifies a setScene hydration; later document writes invalidate it. + hydrationToken: object | null + hydrationId: object | null + invalidateHydration: () => void + // 4. Relational metadata β€” not nodes collections: Record materials: Record @@ -1383,7 +1399,54 @@ class GuardedDirtySet extends Set { } } -const useScene: UseSceneStore = create()( +type TemporalSceneCreator = StateCreator + +function createSceneStore(config: TemporalSceneCreator): UseSceneStore { + const hydratedConfig: TemporalSceneCreator = (set, get, store) => { + const setWithHydration: typeof set = (partial, replace) => { + const state = get() + let next = typeof partial === 'function' ? partial(state) : partial + const documentChanged = ( + ['nodes', 'rootNodeIds', 'materials', 'collections', 'installedPlugins'] as const + ).some((key) => (replace || key in next) && next[key] !== state[key]) + if (documentChanged && !isHydrationNormalization()) { + invalidatePendingHydration() + if (state.hydrationToken) next = { ...next, hydrationToken: null } + } + if (replace) set(next as SceneState, true) + else set(next) + } + store.setState = setWithHydration + return config(setWithHydration, get, store) + } + return create()(hydratedConfig) +} + +function runTemporalJump(target: Partial | undefined, jump: () => void): void { + if (!target?.nodes) { + jump() + return + } + const before = useScene.getState().nodes + const changed = new Set() + for (const id of new Set([...Object.keys(before), ...Object.keys(target.nodes)])) { + const nodeId = id as AnyNodeId + const previous = before[nodeId] + const next = target.nodes[nodeId] + if (previous === next) continue + // Structural hierarchy changes keep the full-level reconciliation fallback. + if ( + [previous, next].some((node) => node && ['site', 'building', 'level'].includes(node.type)) + ) { + jump() + return + } + changed.add(nodeId) + } + runWithSceneCommitNodeIds(changed, jump) +} + +const useScene: UseSceneStore = createSceneStore( temporal( (set, get) => ({ // 1. Flat dictionary of all nodes @@ -1395,6 +1458,13 @@ const useScene: UseSceneStore = create()( // 3. Dirty set dirtyNodes: new GuardedDirtySet(get), + hydrationToken: null, + hydrationId: null, + invalidateHydration: () => { + invalidatePendingHydration() + if (get().hydrationToken) set({ hydrationToken: null }) + }, + // 4. Collections collections: {} as Record, materials: {} as Record, @@ -1406,7 +1476,10 @@ const useScene: UseSceneStore = create()( setReadOnly: (readOnly: boolean) => set({ readOnly }), unloadScene: () => { + invalidatePendingHydration() set({ + hydrationToken: null, + hydrationId: null, nodes: {}, rootNodeIds: [], dirtyNodes: new GuardedDirtySet(get), @@ -1462,19 +1535,55 @@ const useScene: UseSceneStore = create()( // pre-write state onto `pastStates`. Writing the scene in two steps // (as this used to) exposed a half-normalized intermediate state β€” // and the pre-load (possibly empty) state β€” as undo targets. - set({ - nodes: cleanedNodes, - rootNodeIds: normalizedRootNodeIds, - dirtyNodes: new GuardedDirtySet(get), - collections: extra?.collections ?? {}, - materials, - installedPlugins: Array.from(new Set(extra?.installedPlugins ?? [])), - hasExplicitPluginInstallState: extra?.hasExplicitPluginInstallState ?? false, - }) - // Mark all nodes as dirty to trigger re-validation - Object.values(cleanedNodes).forEach((node) => { - get().markDirty(node.id) - }) + const hydrationId = {} + runSceneHydration( + () => { + set({ + hydrationToken: null, + hydrationId, + nodes: cleanedNodes, + rootNodeIds: normalizedRootNodeIds, + dirtyNodes: new GuardedDirtySet(get), + collections: extra?.collections ?? {}, + materials, + installedPlugins: Array.from(new Set(extra?.installedPlugins ?? [])), + hasExplicitPluginInstallState: extra?.hasExplicitPluginInstallState ?? false, + }) + const applyNormalization = (updates: { id: AnyNodeId; data: Partial }[]) => { + if (updates.length > 0) get().updateNodes(updates) + } + const hydratedNodes = Object.values(get().nodes) + if (!get().readOnly) { + pauseSceneHistory(useScene) + try { + if (hydratedNodes.some((node) => node.type === 'elevator')) { + applyNormalization(syncAutoElevatorOpenings(get().nodes)) + } + } finally { + resumeSceneHistory(useScene) + } + if (hydratedNodes.some((node) => node.type === 'stair')) { + // Spatial-grid subscribers must settle first. Owning this pass + // here also covers opening systems that mount after the load. + queueSceneNormalization(() => { + if (get().hydrationId !== hydrationId) return + pauseSceneHistory(useScene) + try { + applyNormalization(syncStairRises(get().nodes)) + applyNormalization(syncAutoStairOpenings(get().nodes)) + } finally { + resumeSceneHistory(useScene) + } + }) + } + } + // Mark all nodes as dirty to trigger re-validation + Object.values(get().nodes).forEach((node) => { + get().markDirty(node.id) + }) + }, + () => set({ hydrationToken: hydrationId }), + ) }, setInstalledPlugins: (pluginIds, options) => { @@ -1694,11 +1803,39 @@ const useScene: UseSceneStore = create()( current: sceneHistorySnapshotFromState(currentState), }) }, + wrapTemporal: (config) => (set, get, store) => { + const state = config(set, get, store) + return { + ...state, + undo: (steps = 1) => + runTemporalJump(get().pastStates.slice().splice(-steps, steps)[0], () => + state.undo(steps), + ), + redo: (steps = 1) => + runTemporalJump(get().futureStates.slice().splice(-steps, steps)[0], () => + state.redo(steps), + ), + } + }, limit: 50, // Limit to last 50 actions }, ), ) +// Live state belongs to the hydration owner so even a lazy consumer cannot +// miss an override that was set and cleared before its first frame. +const invalidateForLiveState = () => { + if ( + useLiveNodeOverrides.getState().overrides.size || + useLiveTransforms.getState().transforms.size + ) { + useScene.getState().invalidateHydration() + } +} +useLiveNodeOverrides.subscribe(invalidateForLiveState) +useLiveTransforms.subscribe(invalidateForLiveState) +useScene.subscribe(invalidateForLiveState) + export default useScene let sceneReadOnlyLeaseCount = 0 @@ -2117,6 +2254,9 @@ export function applySceneSnapshot( if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) { throw new Error('Cannot replace the scene snapshot during an active interaction') } + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + pauseSceneHistory(useScene) try { useScene.getState().setScene(snapshot.nodes, snapshot.rootNodeIds, { @@ -2129,19 +2269,15 @@ export function applySceneSnapshot( resumeSceneHistory(useScene) } - useLiveNodeOverrides.getState().clearAll() - useLiveTransforms.getState().clearAll() - const current = sceneHistorySnapshotFromState(useScene.getState()) if (areSceneSnapshotsEqual(before, current)) return false notifySceneCommit({ origin: options.origin, before, current }) return true } -// Track previous temporal state lengths and node snapshot for diffing +// Track previous temporal state lengths for identifying history jumps let prevPastLength = 0 let prevFutureLength = 0 -let prevNodesSnapshot: Record | null = null export function clearSceneHistory() { resetSceneHistoryPauseDepth() @@ -2154,11 +2290,17 @@ export function clearSceneHistory() { useScene.temporal.getState().clear() prevPastLength = 0 prevFutureLength = 0 - prevNodesSnapshot = null } // Subscribe to the temporal store (Undo/Redo events) -useScene.temporal.subscribe((state) => { +useScene.temporal.subscribe((state, previousState) => { + // Zundo mutates its source stack before writing the scene. Reconciliation's + // pause/resume notifications must not advance our pre-jump stack lengths. + if ( + state.pastStates === previousState.pastStates && + state.futureStates === previousState.futureStates + ) + return const currentPastLength = state.pastStates.length const currentFutureLength = state.futureStates.length @@ -2168,8 +2310,13 @@ useScene.temporal.subscribe((state) => { const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength if (didUndo || didRedo) { - // Capture the previous snapshot before RAF fires - const snapshotBefore = prevNodesSnapshot + // Capture both layouts before another synchronous jump can replace them. + // The state pushed onto the opposite stack includes history-paused derived + // writes (such as stair rise), unlike a snapshot saved at the last edit. + const snapshotBefore = didUndo + ? state.futureStates[prevFutureLength]?.nodes + : state.pastStates[prevPastLength]?.nodes + const snapshotAfter = useScene.getState().nodes // Defer to a microtask so the scene store has settled before we diff, // but still mark walls/items dirty before the next paint. @@ -2178,31 +2325,7 @@ useScene.temporal.subscribe((state) => { const { markDirty } = useScene.getState() if (snapshotBefore) { - // Diff: only mark nodes that actually changed - for (const [id, node] of Object.entries(currentNodes) as [AnyNodeId, AnyNode][]) { - if (snapshotBefore[id] !== node) { - markDirty(id) - // Also mark parent so merged geometries update - if (node.parentId) markDirty(node.parentId as AnyNodeId) - } - } - // Nodes that were deleted (exist in prev but not current) - for (const [id, node] of Object.entries(snapshotBefore) as [AnyNodeId, AnyNode][]) { - if (!currentNodes[id]) { - const parentId = node.parentId as AnyNodeId | undefined - if (parentId) { - markDirty(parentId) - // Mark sibling nodes dirty so they can update their geometry - // (e.g. adjacent walls need to recalculate miter/junction geometry) - const parent = currentNodes[parentId] - if (parent && 'children' in parent && Array.isArray(parent.children)) { - for (const childId of parent.children) { - markDirty(childId as AnyNodeId) - } - } - } - } - } + for (const id of getHistoryDirtyNodeIds(snapshotBefore, snapshotAfter)) markDirty(id) } else { // No snapshot to diff against β€” fall back to marking all for (const node of Object.values(currentNodes)) { @@ -2220,8 +2343,7 @@ useScene.temporal.subscribe((state) => { }) } - // Update tracked lengths and snapshot + // Update tracked lengths prevPastLength = currentPastLength prevFutureLength = currentFutureLength - prevNodesSnapshot = useScene.getState().nodes }) diff --git a/packages/core/src/systems/roof/roof-elevation-system.test.ts b/packages/core/src/systems/roof/roof-elevation-system.test.ts new file mode 100644 index 0000000000..0617edb6e4 --- /dev/null +++ b/packages/core/src/systems/roof/roof-elevation-system.test.ts @@ -0,0 +1,251 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' +import { initSpatialGridSync } from '../../hooks/spatial-grid/spatial-grid-sync' +import { type AnyNode, type AnyNodeId, LevelNode, RoofNode, SlabNode, WallNode } from '../../schema' +import { + pauseSceneHistory, + resumeSceneHistory, + type SceneCommit, + subscribeSceneCommits, +} from '../../store/history-control' +import useScene, { clearSceneHistory } from '../../store/use-scene' +import { initializeRoofElevationSync } from './roof-elevation-system' + +type RafFn = (callback: (time: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ( + callback, +) => { + callback(0) + return 0 +} +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +const originalState = useScene.getState() +let stopRoofSync = () => {} +let stopGridSync = () => {} +let stopCommitSubscription = () => {} + +beforeEach(() => { + spatialGridManager.clear() + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + readOnly: false, + }) + clearSceneHistory() +}) + +afterEach(() => { + stopRoofSync() + stopGridSync() + stopCommitSubscription() + spatialGridManager.clear() + useScene.setState(originalState) + clearSceneHistory() +}) + +function setup() { + const level = LevelNode.parse({ height: 3, level: 0 }) + const upper = LevelNode.parse({ height: 3, level: 1 }) + const wall = WallNode.parse({ parentId: level.id, start: [0, 0], end: [4, 0], height: 3 }) + const roof = RoofNode.parse({ + parentId: upper.id, + support: { kind: 'walls' }, + position: [2, 0, 1], + rotation: 0.4, + }) + const manual = RoofNode.parse({ parentId: level.id, position: [6, 7, 8] }) + const otherWalls = [ + WallNode.parse({ parentId: level.id, start: [4, 0], end: [4, 3], height: 3 }), + WallNode.parse({ parentId: level.id, start: [4, 3], end: [0, 3], height: 3 }), + WallNode.parse({ parentId: level.id, start: [0, 3], end: [0, 0], height: 3 }), + ] + level.children = [wall.id, ...otherWalls.map((node) => node.id), manual.id] + upper.children = [roof.id] + useScene.setState({ + nodes: Object.fromEntries( + [level, upper, wall, ...otherWalls, roof, manual].map((node) => [node.id, node]), + ) as Record, + rootNodeIds: [level.id, upper.id], + }) + clearSceneHistory() + stopRoofSync = initializeRoofElevationSync() + stopGridSync = initSpatialGridSync() + return { level, upper, wall, otherWalls, roof, manual } +} + +function currentRoof(id: RoofNode['id']): RoofNode { + return useScene.getState().nodes[id] as RoofNode +} + +describe('RoofElevationSystem', () => { + test('a wall height edit moves the roof after one microtask without adding an undo step', async () => { + const { wall, roof, manual } = setup() + await Promise.resolve() + const commits: SceneCommit[] = [] + stopCommitSubscription = subscribeSceneCommits((commit) => commits.push(commit)) + useScene.getState().updateNode(wall.id, { height: 4.2 }) + expect(currentRoof(roof.id).position).toEqual([2, 0, 1]) + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBeCloseTo(1.2) + expect(currentRoof(roof.id).rotation).toBe(0.4) + expect(currentRoof(manual.id).position).toEqual([6, 7, 8]) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + expect(commits).toHaveLength(2) + expect((commits[1]?.before.nodes[roof.id] as RoofNode).position[1]).toBe(0) + expect((commits[1]?.current.nodes[roof.id] as RoofNode).position[1]).toBeCloseTo(1.2) + expect(commits[1]?.changedNodeIds).toEqual(new Set([roof.id])) + useScene.temporal.getState().undo() + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBe(0) + useScene.temporal.getState().redo() + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBeCloseTo(1.2) + }) + + test('does not publish previews owned by an outer history pause', async () => { + const { wall, roof } = setup() + await Promise.resolve() + const commits: SceneCommit[] = [] + stopCommitSubscription = subscribeSceneCommits((commit) => commits.push(commit)) + pauseSceneHistory(useScene) + try { + useScene.getState().updateNode(wall.id, { height: 4 }) + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBe(1) + expect(useScene.temporal.getState().isTracking).toBe(false) + expect(commits).toHaveLength(0) + } finally { + resumeSceneHistory(useScene) + } + }) + + test('waits for the slab grid listener and tracks subsequent slab elevation edits', async () => { + const { level, roof } = setup() + const slab = SlabNode.parse({ + elevation: 0.5, + polygon: [ + [-1, -1], + [5, -1], + [5, 2], + [-1, 2], + ], + }) + useScene.getState().createNode(slab, level.id) + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBe(0.5) + useScene.getState().updateNode(slab.id, { elevation: 0.9 }) + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBeCloseTo(0.9) + }) + + test('keeps a custom Y edit after follow mode is disabled', async () => { + const { wall, roof } = setup() + await Promise.resolve() + useScene.getState().updateNode(roof.id, { support: { kind: 'level' }, position: [2, 9, 1] }) + useScene.getState().updateNode(wall.id, { height: 5 }) + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBe(9) + expect(currentRoof(roof.id).support).toEqual({ kind: 'level' }) + }) + + test('undo and redo restore custom/follow mode and Y together', async () => { + const { roof } = setup() + await Promise.resolve() + useScene.getState().updateNode(roof.id, { support: { kind: 'level' }, position: [2, 9, 1] }) + await Promise.resolve() + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + useScene.temporal.getState().undo() + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'walls' }, position: [2, 0, 1] }) + useScene.temporal.getState().redo() + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'level' }, position: [2, 9, 1] }) + useScene.getState().updateNode(roof.id, { support: { kind: 'walls' } }) + expect(currentRoof(roof.id).position[1]).toBe(9) + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'walls' }, position: [2, 0, 1] }) + expect(useScene.temporal.getState().pastStates).toHaveLength(2) + useScene.temporal.getState().undo() + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'level' }, position: [2, 9, 1] }) + useScene.temporal.getState().redo() + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'walls' }, position: [2, 0, 1] }) + }) + + test('deleting all walls freezes Y and redrawing an enclosure resumes following', async () => { + const { level, wall, otherWalls, roof } = setup() + useScene.getState().updateNode(wall.id, { height: 4 }) + await Promise.resolve() + const walls = [wall, ...otherWalls] + useScene.getState().deleteNodes(walls.map((node) => node.id)) + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'walls' }, position: [2, 1, 1] }) + const replacements = walls.map((node) => + WallNode.parse({ ...node, id: undefined, height: 2.5 }), + ) + useScene.getState().createNodes(replacements.map((node) => ({ node, parentId: level.id }))) + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ + support: { kind: 'walls' }, + position: [2, -0.5, 1], + }) + useScene.temporal.getState().undo() + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'walls' }, position: [2, 1, 1] }) + useScene.temporal.getState().redo() + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ + support: { kind: 'walls' }, + position: [2, -0.5, 1], + }) + }) + + test('an XZ-only move resolves a different enclosure without changing mode', async () => { + const { level, wall, otherWalls, roof } = setup() + const second = [wall, ...otherWalls].map((node) => + WallNode.parse({ + ...node, + id: undefined, + height: 5, + start: [node.start[0] + 10, node.start[1]], + end: [node.end[0] + 10, node.end[1]], + }), + ) + useScene.getState().createNodes(second.map((node) => ({ node, parentId: level.id }))) + await Promise.resolve() + useScene.getState().updateNode(roof.id, { position: [12, 0, 1] }) + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'walls' }, position: [12, 2, 1] }) + }) + + test('initialization never changes existing custom roofs', async () => { + const { roof } = setup() + stopRoofSync() + useScene.getState().updateNode(roof.id, { support: { kind: 'level' }, position: [2, 8, 1] }) + clearSceneHistory() + stopRoofSync = initializeRoofElevationSync() + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'level' }, position: [2, 8, 1] }) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + }) + + test('coalesces rapid edits, ignores epsilon drift and cancels queued work on disposal', async () => { + const { wall, roof } = setup() + await Promise.resolve() + useScene.getState().updateNode(wall.id, { height: 3.00001 }) + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBe(0) + useScene.getState().updateNode(wall.id, { height: 4 }) + useScene.getState().updateNode(wall.id, { height: 5 }) + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBe(2) + useScene.getState().updateNode(wall.id, { height: 6 }) + stopRoofSync() + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBe(2) + }) +}) diff --git a/packages/core/src/systems/roof/roof-elevation-system.tsx b/packages/core/src/systems/roof/roof-elevation-system.tsx new file mode 100644 index 0000000000..e5b9cd7ead --- /dev/null +++ b/packages/core/src/systems/roof/roof-elevation-system.tsx @@ -0,0 +1,105 @@ +'use client' + +import { useEffect } from 'react' +import type { AnyNode, AnyNodeId } from '../../schema' +import { + getSceneHistoryPauseDepth, + notifySceneCommit, + pauseSceneHistory, + resumeSceneHistory, + type SceneSnapshot, +} from '../../store/history-control' +import useScene from '../../store/use-scene' +import { resolveRoofElevation } from './roof-elevation' + +const ROOF_ELEVATION_EPSILON = 1e-4 + +function sceneSnapshot(): SceneSnapshot { + const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState() + return { nodes, rootNodeIds, collections, materials, installedPlugins } +} + +function isElevationRelevant(node: AnyNode | undefined): boolean { + return ( + node?.type === 'roof' || + node?.type === 'wall' || + node?.type === 'slab' || + node?.type === 'level' || + node?.type === 'building' || + node?.type === 'site' + ) +} + +export function initializeRoofElevationSync(): () => void { + let disposed = false + let queued = false + let syncing = false + + const schedule = () => { + if (queued) return + queued = true + // Wall bases use the spatial grid, whose listener must finish before this pass. + queueMicrotask(() => { + queued = false + if (disposed) return + const { nodes, updateNodes, readOnly } = useScene.getState() + if (readOnly) return + const updates: Array<{ id: AnyNodeId; data: Partial }> = [] + for (const node of Object.values(nodes)) { + if (node.type !== 'roof' || node.support?.kind !== 'walls') continue + const elevation = resolveRoofElevation(node, nodes) + if (Math.abs(node.position[1] - elevation) <= ROOF_ELEVATION_EPSILON) continue + updates.push({ + id: node.id, + data: { position: [node.position[0], elevation, node.position[2]] }, + }) + } + if (updates.length === 0) return + const before = sceneSnapshot() + const publishCommit = + useScene.temporal.getState().isTracking && getSceneHistoryPauseDepth() === 0 + syncing = true + pauseSceneHistory(useScene) + try { + updateNodes(updates) + } finally { + resumeSceneHistory(useScene) + syncing = false + } + // Deferred writes miss the originating commit; publish the settled roof without + // another undo step. An outer pause belongs to a gesture that owns its commit. + if (publishCommit) { + notifySceneCommit({ + origin: 'local', + before, + current: sceneSnapshot(), + changedNodeIds: new Set(updates.map(({ id }) => id)), + }) + } + }) + } + + const unsubscribe = useScene.subscribe((state, previous) => { + if (syncing || state.nodes === previous.nodes) return + const ids = new Set([...Object.keys(state.nodes), ...Object.keys(previous.nodes)]) + for (const id of ids) { + const next = state.nodes[id as AnyNodeId] + const prev = previous.nodes[id as AnyNodeId] + if (next !== prev && (isElevationRelevant(next) || isElevationRelevant(prev))) { + schedule() + return + } + } + }) + schedule() + + return () => { + disposed = true + unsubscribe() + } +} + +export function RoofElevationSystem() { + useEffect(initializeRoofElevationSync, []) + return null +} diff --git a/packages/core/src/systems/roof/roof-elevation.test.ts b/packages/core/src/systems/roof/roof-elevation.test.ts new file mode 100644 index 0000000000..827076622e --- /dev/null +++ b/packages/core/src/systems/roof/roof-elevation.test.ts @@ -0,0 +1,275 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' +import { + type AnyNode, + BuildingNode, + LevelNode, + RoofNode, + RoofSegmentNode, + SlabNode, + WallNode, +} from '../../schema' +import { planWallSplitAtPoint } from '../wall/wall-topology' +import { resolveRoofElevation } from './roof-elevation' + +beforeEach(() => spatialGridManager.clear()) +afterEach(() => spatialGridManager.clear()) + +function room(level: LevelNode, heights: Array, offsetX = 0) { + const polygon: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ] + return polygon.map((point, index) => { + const next = polygon[(index + 1) % polygon.length]! + return WallNode.parse({ + parentId: level.id, + start: [point[0] + offsetX, point[1]], + end: [next[0] + offsetX, next[1]], + height: heights[index % heights.length], + }) + }) +} + +function scene(heights: Array) { + const level = LevelNode.parse({ height: 3, level: 0 }) + const upper = LevelNode.parse({ height: 3, level: 1 }) + const walls = room(level, heights) + const roof = RoofNode.parse({ + parentId: upper.id, + support: { kind: 'walls' }, + position: [2, 0, 1], + }) + level.children = walls.map((wall) => wall.id) + upper.children = [roof.id] + const nodes: Record = Object.fromEntries( + [level, upper, roof, ...walls].map((node) => [node.id, node]), + ) + return { level, upper, walls, roof, nodes } +} + +describe('resolveRoofElevation', () => { + test("follows walls on the roof's own level when it sits on the top floor", () => { + const { roof, level, upper, nodes } = scene([2.5]) + // Move the roof onto the walls' level: no storey above, roof and walls share it. + delete nodes[upper.id] + roof.parentId = level.id + level.children = [...level.children, roof.id] + roof.position = [2, 2.5, 1] + expect(resolveRoofElevation(roof, nodes)).toBe(2.5) + }) + + test('follows the walls under the segment footprint when the room is not closed', () => { + const { roof, walls, nodes, level } = scene([4.5]) + // Drop the east wall: point-in-room finds no enclosure, the footprint still does. + const east = walls[1]! + delete nodes[east.id] + level.children = level.children.filter((id) => id !== east.id) + const segment = RoofSegmentNode.parse({ + parentId: roof.id, + roofType: 'gable', + width: 4, + depth: 3, + position: [0, 0, 0], + }) + roof.children = [segment.id] + nodes[segment.id] = segment + expect(resolveRoofElevation(roof, nodes)).toBe(1.5) + }) + + test('honors an explicit height above the storey in the roof level frame', () => { + const { roof, nodes } = scene([4.5]) + expect(resolveRoofElevation(roof, nodes)).toBe(1.5) + }) + + test('takes the highest top across mixed explicit and plane-bound walls', () => { + const { roof, nodes } = scene([3, undefined, 5, 2]) + expect(resolveRoofElevation(roof, nodes)).toBe(2) + }) + + test('includes the elected slab base and wall support offset', () => { + const { level, walls, roof, nodes } = scene([4.5]) + const slab = SlabNode.parse({ + parentId: level.id, + elevation: 0.8, + polygon: [ + [-1, -1], + [5, -1], + [5, 4], + [-1, 4], + ], + }) + spatialGridManager.handleNodeCreated(slab, level.id) + const wall = { ...walls[0]!, supportSlabId: slab.id, supportOffset: 0.2 } + expect(resolveRoofElevation(roof, { ...nodes, [wall.id]: wall, [slab.id]: slab })).toBe(2.5) + }) + + test('does not lift a plane-bound top when its base rises', () => { + const { walls, roof, nodes } = scene([undefined]) + const wall = { ...walls[0]!, supportOffset: 0.6 } + expect(resolveRoofElevation(roof, { ...nodes, [wall.id]: wall })).toBe(0) + }) + + test('2.5 m walls under a 3 m storey pull the roof down to -0.5 m', () => { + const { roof, nodes } = scene([2.5]) + expect(resolveRoofElevation(roof, nodes)).toBe(-0.5) + }) + + test('freezes without an enclosure, retaining follow intent', () => { + const { walls, roof, nodes } = scene([4]) + const remaining = { ...nodes } + delete remaining[walls[0]!.id] + expect(resolveRoofElevation(roof, remaining)).toBe(0) + for (const wall of walls) delete remaining[wall.id] + expect(resolveRoofElevation(roof, remaining)).toBe(0) + expect(roof.support).toEqual({ kind: 'walls' }) + expect(resolveRoofElevation(roof, nodes)).toBe(1) + }) + + test('level roofs and roof-surface attachments keep their Y', () => { + const { roof, nodes } = scene([4]) + expect(resolveRoofElevation({ ...roof, support: { kind: 'level' } }, nodes)).toBe(0) + const attached = RoofNode.parse({ + ...roof, + support: { kind: 'roof', roofSegmentId: 'rseg_host', localPosition: [0, 0] }, + }) + expect(resolveRoofElevation(attached, nodes)).toBe(0) + }) + + test('re-resolves replacement wall pieces after a topology split', () => { + const { level, walls, roof, nodes } = scene([4]) + const split = planWallSplitAtPoint(nodes, { levelId: level.id, point: [2, 0], radius: 0.05 }) + expect(split.ok).toBe(true) + if (!split.ok) throw new Error(split.reason) + const { create, delete: deleted } = split.plan.changes + expect(create).toHaveLength(2) + const next = { ...nodes } + for (const id of deleted) delete next[id] + for (const { node } of create) next[node.id] = { ...node, height: 5 } as WallNode + next[level.id] = { + ...level, + children: [ + ...walls.filter((wall) => !deleted.includes(wall.id)).map((wall) => wall.id), + ...create.map(({ node }) => node.id), + ], + } + expect(resolveRoofElevation(roof, next)).toBe(2) + }) + + test('chooses the smallest enclosure containing the roof centre', () => { + const { level, roof, nodes } = scene([5]) + const innerPolygon: Array<[number, number]> = [ + [1, 0.5], + [3, 0.5], + [3, 2.5], + [1, 2.5], + ] + const innerWalls = innerPolygon.map((start, index) => + WallNode.parse({ + parentId: level.id, + start, + end: innerPolygon[(index + 1) % innerPolygon.length], + height: 2.5, + }), + ) + const next = { + ...nodes, + ...Object.fromEntries(innerWalls.map((wall) => [wall.id, wall])), + [level.id]: { ...level, children: [...level.children, ...innerWalls.map((wall) => wall.id)] }, + } + expect(resolveRoofElevation(roof, next)).toBe(-0.5) + }) + + test('moving XZ chooses the enclosure at the new centre', () => { + const { level, roof, nodes } = scene([4]) + const secondWalls = room(level, [5], 10) + const next = { + ...nodes, + ...Object.fromEntries(secondWalls.map((wall) => [wall.id, wall])), + [level.id]: { + ...level, + children: [...level.children, ...secondWalls.map((wall) => wall.id)], + }, + } + expect(resolveRoofElevation(roof, next)).toBe(1) + expect(resolveRoofElevation({ ...roof, position: [12, 1, 1] }, next)).toBe(2) + expect(resolveRoofElevation({ ...roof, position: [20, 7, 1] }, next)).toBe(7) + }) + + test('uses the lower neighbour in the same building across ordinal gaps and offsets', () => { + const { level, upper, roof, nodes } = scene([4.5]) + const building = BuildingNode.parse({ children: [level.id, upper.id] }) + const otherBuilding = BuildingNode.parse({}) + const unrelated = LevelNode.parse({ parentId: otherBuilding.id, level: 8, height: 100 }) + const next = { + ...nodes, + [building.id]: building, + [otherBuilding.id]: otherBuilding, + [unrelated.id]: unrelated, + [level.id]: { ...level, parentId: building.id, level: -2, baseElevation: 2 }, + [upper.id]: { ...upper, parentId: building.id, level: 10, baseElevation: 0.5 }, + } + expect(resolveRoofElevation(roof, next)).toBe(1) + }) + + test('looks at its own level and the one below, never two floors down', () => { + const { level, upper, roof, nodes } = scene([4]) + const middle = LevelNode.parse({ level: 0.5, height: 2 }) + // A wall-less storey slipped between roof and walls: the walls are now two + // floors down and the roof freezes where it is. + expect(resolveRoofElevation(roof, { ...nodes, [middle.id]: middle })).toBe(0) + // Walls on the roof's own level count (top floor without a storey above). + const onParent = room(upper, [20]) + const next = { + ...nodes, + [upper.id]: { ...upper, children: [...upper.children, ...onParent.map((wall) => wall.id)] }, + ...Object.fromEntries(onParent.map((wall) => [wall.id, wall])), + } + expect(resolveRoofElevation(roof, next)).toBe(20) + expect(resolveRoofElevation({ ...roof, parentId: level.id }, nodes)).toBe(4) + }) + + test('matches a conical arc by centre and radius after replacement, without an enclosure', () => { + const { level, roof, nodes } = scene([4]) + const segment = RoofSegmentNode.parse({ + parentId: roof.id, + roofType: 'conical', + width: 4, + depth: 4, + conicalFullCircle: true, + }) + const curved = WallNode.parse({ + parentId: level.id, + start: [-2, 0], + end: [2, 0], + curveOffset: 2, + height: 2.5, + }) + const cone = { + ...roof, + children: [segment.id], + position: [0, 0, 0] as [number, number, number], + } + const next = { + ...nodes, + [level.id]: { ...level, children: [curved.id] }, + [curved.id]: curved, + [segment.id]: segment, + } + expect(resolveRoofElevation(cone, next)).toBe(-0.5) + const replacement = WallNode.parse({ ...curved, id: undefined, height: 5 }) + const replaced = { + ...next, + [level.id]: { ...level, children: [replacement.id] }, + [replacement.id]: replacement, + } + delete replaced[curved.id] + expect(resolveRoofElevation(cone, replaced)).toBe(2) + expect(resolveRoofElevation({ ...cone, position: [1, 7, 0] }, replaced)).toBe(7) + expect( + resolveRoofElevation(cone, { ...replaced, [segment.id]: { ...segment, width: 6 } }), + ).toBe(0) + }) +}) diff --git a/packages/core/src/systems/roof/roof-elevation.ts b/packages/core/src/systems/roof/roof-elevation.ts new file mode 100644 index 0000000000..1e75ab60f7 --- /dev/null +++ b/packages/core/src/systems/roof/roof-elevation.ts @@ -0,0 +1,119 @@ +import { + getWallBaseElevationForNodes, + getWallEffectiveHeightForNodes, +} from '../../hooks/spatial-grid/spatial-grid-manager' +import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' +import type { AnyNode, LevelNode, RoofNode, RoofSegmentNode, WallNode } from '../../schema' +import { findLevelBelowId, getLevelElevations } from '../../services/storey' +import { wallOverlapsSlabFootprint } from '../slab/slab-support' +import { getWallArcData } from '../wall/wall-curve' +import { resolveRoomRoofFootprintOnLevel } from './roof-footprint' + +export function resolveRoofWallTopElevation( + targetLevelId: LevelNode['id'], + wall: WallNode, + nodes: Readonly>, + elevations = getLevelElevations(nodes), +): number { + const sourceLevelY = elevations.get(resolveLevelId(wall, nodes))?.baseY ?? 0 + const targetLevelY = elevations.get(targetLevelId)?.baseY ?? 0 + return ( + sourceLevelY + + getWallBaseElevationForNodes(wall, nodes) + + getWallEffectiveHeightForNodes(wall, nodes) - + targetLevelY + ) +} + +export function resolveRoofElevation( + roof: RoofNode, + nodes: Readonly>, +): number { + if (roof.support?.kind !== 'walls') return roof.position[1] + const levelId = resolveLevelId(roof, nodes) + if (nodes[levelId]?.type !== 'level') return roof.position[1] + const elevations = getLevelElevations(nodes) + // A roof usually sits on the storey above its walls, but the top floor (or a + // roof armed from the walls' own level) keeps roof and walls on one level. + const belowId = findLevelBelowId(levelId, elevations) + const candidateWallIds = [levelId, belowId] + .map((id) => (id ? nodes[id] : undefined)) + .filter((node): node is LevelNode => node?.type === 'level') + .flatMap((level) => level.children) + + const conicalSegments = roof.children + .map((id) => nodes[id]) + .filter( + (node): node is RoofSegmentNode => + node?.type === 'roof-segment' && node.roofType === 'conical', + ) + const cos = Math.cos(roof.rotation) + const sin = Math.sin(roof.rotation) + const toLevel = (x: number, z: number): [number, number] => [ + roof.position[0] + x * cos + z * sin, + roof.position[2] - x * sin + z * cos, + ] + // Walls under the footprint, closed room or not: a room missing a wall, an + // L-shaped room whose centre falls outside, or a redrawn enclosure all still + // hold the roof up. The band test is curve- and thickness-aware and + // boundary-inclusive, so perimeter walls on the footprint edge count. + const footprints = roof.children + .map((id) => nodes[id]) + .filter((node): node is RoofSegmentNode => node?.type === 'roof-segment') + .map((segment) => { + const c = Math.cos(segment.rotation) + const s = Math.sin(segment.rotation) + const halfW = segment.width / 2 + const halfD = segment.depth / 2 + const corners: Array<[number, number]> = [ + [-halfW, -halfD], + [halfW, -halfD], + [halfW, halfD], + [-halfW, halfD], + ] + return corners.map(([x, z]) => + toLevel(segment.position[0] + x * c + z * s, segment.position[2] - x * s + z * c), + ) + }) + const wallIds = conicalSegments.length + ? candidateWallIds.filter((id) => { + const wall = nodes[id] + if (wall?.type !== 'wall') return false + const arc = getWallArcData(wall) + if (!arc) return false + return conicalSegments.some((segment) => { + const [centerX, centerZ] = toLevel(segment.position[0], segment.position[2]) + return ( + Math.hypot(arc.center.x - centerX, arc.center.y - centerZ) <= 1e-4 && + Math.abs(arc.radius - segment.width / 2) <= 1e-4 + ) + }) + }) + : footprints.length + ? candidateWallIds.filter((id) => { + const wall = nodes[id] + return ( + wall?.type === 'wall' && + footprints.some((polygon) => wallOverlapsSlabFootprint(wall, polygon)) + ) + }) + : ([levelId, belowId] + .map((id) => + id + ? resolveRoomRoofFootprintOnLevel(id as LevelNode['id'], nodes, [ + roof.position[0], + roof.position[2], + ]) + : null, + ) + .find((target) => target !== null)?.wallIds ?? []) + + let highest: number | undefined + for (const id of wallIds) { + const wall = nodes[id] + if (wall?.type !== 'wall') continue + const top = resolveRoofWallTopElevation(levelId as LevelNode['id'], wall, nodes, elevations) + highest = highest === undefined ? top : Math.max(highest, top) + } + return highest ?? roof.position[1] +} diff --git a/packages/core/src/systems/roof/roof-footprint.ts b/packages/core/src/systems/roof/roof-footprint.ts new file mode 100644 index 0000000000..5fa4b8adf1 --- /dev/null +++ b/packages/core/src/systems/roof/roof-footprint.ts @@ -0,0 +1,118 @@ +import { pointInPolygon as pointInPolygon2D } from '../../lib/polygon-relations' +import { detectSpacesForLevel } from '../../lib/space-detection' +import type { AnyNode, LevelNode, WallNode } from '../../schema' +import { getLevelBelow } from '../../services/storey' + +export type RoofFootprintTarget = { + id: string + polygon: Array<[number, number]> + wallIds: WallNode['id'][] + center: [number, number] + width: number + depth: number + rotation: number + rectangular: boolean +} + +function polygonArea(polygon: ReadonlyArray): number { + return Math.abs( + polygon.reduce((area, point, index) => { + const next = polygon[(index + 1) % polygon.length] + return next ? area + point[0] * next[1] - next[0] * point[1] : area + }, 0) / 2, + ) +} + +export function fitRoofFootprint( + id: string, + polygon: Array<[number, number]>, + wallIds: WallNode['id'][], +): RoofFootprintTarget | null { + if (polygon.length < 3) return null + + let best: + | { + center: [number, number] + width: number + depth: number + rotation: number + area: number + } + | undefined + + for (let index = 0; index < polygon.length; index++) { + const point = polygon[index] + const next = polygon[(index + 1) % polygon.length] + if (!(point && next)) continue + const rotation = Math.atan2(next[1] - point[1], next[0] - point[0]) + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + const rotated = polygon.map(([x, z]) => [x * cos + z * sin, -x * sin + z * cos] as const) + const xs = rotated.map(([x]) => x) + const zs = rotated.map(([, z]) => z) + const minX = Math.min(...xs) + const maxX = Math.max(...xs) + const minZ = Math.min(...zs) + const maxZ = Math.max(...zs) + const width = maxX - minX + const depth = maxZ - minZ + const area = width * depth + if (area <= 0 || (best && best.area <= area)) continue + const localCenterX = (minX + maxX) / 2 + const localCenterZ = (minZ + maxZ) / 2 + best = { + center: [localCenterX * cos - localCenterZ * sin, localCenterX * sin + localCenterZ * cos], + width, + depth, + rotation: -rotation, + area, + } + } + + if (!best) return null + return { + id, + polygon, + wallIds, + center: best.center, + width: best.width, + depth: best.depth, + rotation: best.rotation, + rectangular: polygonArea(polygon) / best.area >= 0.96, + } +} + +export function resolveRoomRoofFootprint( + levelId: LevelNode['id'], + nodes: Readonly>, + point: [number, number], + options: { rectangularOnly?: boolean } = {}, +): RoofFootprintTarget | null { + const activeTarget = resolveRoomRoofFootprintOnLevel(levelId, nodes, point) + if (activeTarget && (!options.rectangularOnly || activeTarget.rectangular)) return activeTarget + if (activeTarget) return null + const levelBelow = getLevelBelow(levelId, nodes as Record) + const levelBelowTarget = levelBelow + ? resolveRoomRoofFootprintOnLevel(levelBelow.id, nodes, point) + : null + return levelBelowTarget && (!options.rectangularOnly || levelBelowTarget.rectangular) + ? levelBelowTarget + : null +} + +export function resolveRoomRoofFootprintOnLevel( + levelId: LevelNode['id'], + nodes: Readonly>, + point: [number, number], +): RoofFootprintTarget | null { + const level = nodes[levelId] + if (level?.type !== 'level') return null + const walls = level.children + .map((id) => nodes[id]) + .filter((node): node is WallNode => node?.type === 'wall') + const spaces = detectSpacesForLevel(levelId, walls) + .spaces.filter((space) => !space.isExterior && pointInPolygon2D(point, space.polygon)) + .sort((left, right) => polygonArea(left.polygon) - polygonArea(right.polygon)) + const space = spaces[0] + return space ? fitRoofFootprint(space.id, space.polygon, space.wallIds) : null +} diff --git a/packages/core/src/systems/stair/stair-flight.ts b/packages/core/src/systems/stair/stair-flight.ts new file mode 100644 index 0000000000..fcdc7e9c84 --- /dev/null +++ b/packages/core/src/systems/stair/stair-flight.ts @@ -0,0 +1,41 @@ +import type { AnyNode, StairNode } from '../../schema' +import { StairSegmentNode } from '../../schema' +import { resolveStairTotalRise } from './stair-rise' + +const MIN_STAIR_FLIGHT_RISE = 0.1 +const MIN_STAIR_FLIGHT_STEP_COUNT = 2 + +export type StairFlightOverrides = Partial< + Pick< + StairSegmentNode, + 'width' | 'length' | 'height' | 'stepCount' | 'attachmentSide' | 'fillToFloor' | 'thickness' + > +> + +/** + * The single definition of a default straight flight. Anything left out falls + * through to the `StairSegmentNode` schema defaults (length 3 m, 10 steps, + * filled to floor) rather than being spelled again per call site, so the stair + * tool's seed segment, the flight the panel materializes when a curved stair + * becomes straight, and the viewer's fallback body all describe one stair. + */ +export function createDefaultStairSegment(overrides: StairFlightOverrides = {}): StairSegmentNode { + return StairSegmentNode.parse({ segmentType: 'stair', position: [0, 0, 0], ...overrides }) +} + +/** + * The flight a straight stair implies from its own fields β€” used wherever a + * straight stair has to stand in for missing `stair-segment` children. + */ +export function createStairFlightFromStair( + stair: StairNode, + nodes: Record, +): StairSegmentNode { + return createDefaultStairSegment({ + width: stair.width, + height: Math.max(resolveStairTotalRise(stair, nodes), MIN_STAIR_FLIGHT_RISE), + stepCount: Math.max(MIN_STAIR_FLIGHT_STEP_COUNT, Math.round(stair.stepCount ?? 10)), + thickness: stair.thickness, + fillToFloor: stair.fillToFloor, + }) +} diff --git a/packages/core/src/systems/stair/stair-opening-system.tsx b/packages/core/src/systems/stair/stair-opening-system.tsx index 357134e643..e61aa98a3d 100644 --- a/packages/core/src/systems/stair/stair-opening-system.tsx +++ b/packages/core/src/systems/stair/stair-opening-system.tsx @@ -1,8 +1,9 @@ 'use client' -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' import type { AnyNode, AnyNodeId } from '../../schema' import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control' +import { queueSceneNormalization } from '../../store/scene-hydration' import useLiveNodeOverrides from '../../store/use-live-node-overrides' import useLiveTransforms from '../../store/use-live-transforms' import useScene from '../../store/use-scene' @@ -42,120 +43,111 @@ function hasOpeningRelevantNodeChange( return false } -export const StairOpeningSystem = () => { - const syncingAutoOpeningsRef = useRef(false) - const syncingPreviewOpeningsRef = useRef(false) - const previewControllerRef = useRef(createSurfaceOpeningPreviewController()) - - useEffect(() => { - const applyUpdates = (updates: Array<{ id: AnyNodeId; data: Partial }>) => { - if (updates.length === 0) return - syncingAutoOpeningsRef.current = true - pauseSceneHistory(useScene) - try { - useScene.getState().updateNodes(updates) - } finally { - resumeSceneHistory(useScene) - } - queueMicrotask(() => { - syncingAutoOpeningsRef.current = false - }) +export function initializeStairOpeningSync() { + let syncingAutoOpenings = false + let syncingPreviewOpenings = false + const previewController = createSurfaceOpeningPreviewController() + const applyUpdates = (updates: Array<{ id: AnyNodeId; data: Partial }>) => { + if (updates.length === 0) return + syncingAutoOpenings = true + pauseSceneHistory(useScene) + try { + useScene.getState().updateNodes(updates) + } finally { + resumeSceneHistory(useScene) + syncingAutoOpenings = false } + } - const applyPreviewUpdates = (updates: ReturnType) => { - syncingPreviewOpeningsRef.current = true - previewControllerRef.current.apply(updates) - queueMicrotask(() => { - syncingPreviewOpeningsRef.current = false - }) - } + const applyPreviewUpdates = (updates: ReturnType) => { + syncingPreviewOpenings = true + previewController.apply(updates) + queueMicrotask(() => { + syncingPreviewOpenings = false + }) + } - const clearPreviewUpdates = () => { - if (previewControllerRef.current.previewSurfaceIds.size === 0) return - syncingPreviewOpeningsRef.current = true - previewControllerRef.current.clear() - queueMicrotask(() => { - syncingPreviewOpeningsRef.current = false - }) - } + const clearPreviewUpdates = () => { + if (previewController.previewSurfaceIds.size === 0) return + syncingPreviewOpenings = true + previewController.clear() + queueMicrotask(() => { + syncingPreviewOpenings = false + }) + } - const refreshLivePreview = () => { - if (syncingPreviewOpeningsRef.current) return - - const nodes = useScene.getState().nodes - const liveTransforms = useLiveTransforms.getState().transforms - const liveOverrides = useLiveNodeOverrides.getState().overrides - const previewSurfaceIds = previewControllerRef.current.previewSurfaceIds - - if (!hasLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, previewSurfaceIds)) { - clearPreviewUpdates() - return - } - - applyPreviewUpdates( - syncAutoStairOpenings( - getNodesWithLiveStairOpeningInputs( - nodes, - liveTransforms, - liveOverrides, - previewSurfaceIds, - ), - ), - ) - } + const refreshLivePreview = () => { + if (syncingPreviewOpenings) return - const runAutoSync = () => { - // Rise first: straight stairs converge their flight heights to the - // resolved rise (level height or deck elevation), and the opening pass - // reads those segment heights β€” so it must run against the post-rise - // nodes. - applyUpdates(syncStairRises(useScene.getState().nodes)) - applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) - } + const nodes = useScene.getState().nodes + const liveTransforms = useLiveTransforms.getState().transforms + const liveOverrides = useLiveNodeOverrides.getState().overrides + const previewSurfaceIds = previewController.previewSurfaceIds - let disposed = false - let autoSyncQueued = false - const scheduleAutoSync = () => { - if (autoSyncQueued) return - autoSyncQueued = true - // One microtask later so every other scene-store listener for the - // triggering transition (and, at mount, the editor's spatial-grid - // init) runs first β€” the spatial-grid sync in particular. The - // deck-attached rise elects the stair's floor-stack base elevation - // through the spatial grid; syncing before the grid listener would - // rescale flights against the pre-transition slab state. - queueMicrotask(() => { - autoSyncQueued = false - if (disposed) return - runAutoSync() - refreshLivePreview() - }) + if (!hasLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, previewSurfaceIds)) { + clearPreviewUpdates() + return } - scheduleAutoSync() + applyPreviewUpdates( + syncAutoStairOpenings( + getNodesWithLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, previewSurfaceIds), + ), + ) + } - const unsubscribeScene = useScene.subscribe((state, prevState) => { - if (syncingAutoOpeningsRef.current) return - if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return - scheduleAutoSync() - }) + const runAutoSync = () => { + // Rise first: straight stairs converge their flight heights to the + // resolved rise (level height or deck elevation), and the opening pass + // reads those segment heights β€” so it must run against the post-rise + // nodes. + applyUpdates(syncStairRises(useScene.getState().nodes)) + applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) + } - const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => { + let disposed = false + let syncGeneration = 0 + const scheduleAutoSync = () => { + const generation = ++syncGeneration + // One microtask later so every other scene-store listener for the + // triggering transition (and, at mount, the editor's spatial-grid + // init) runs first β€” the spatial-grid sync in particular. The + // deck-attached rise elects the stair's floor-stack base elevation + // through the spatial grid; syncing before the grid listener would + // rescale flights against the pre-transition slab state. + queueSceneNormalization(() => { + if (disposed || generation !== syncGeneration) return + runAutoSync() refreshLivePreview() }) + } - const unsubscribeLiveOverrides = useLiveNodeOverrides.subscribe(() => { - refreshLivePreview() - }) + scheduleAutoSync() - return () => { - disposed = true - unsubscribeScene() - unsubscribeLiveTransforms() - unsubscribeLiveOverrides() - previewControllerRef.current.clear() - } - }, []) + const unsubscribeScene = useScene.subscribe((state, prevState) => { + if (syncingAutoOpenings) return + if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return + scheduleAutoSync() + }) + + const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => { + refreshLivePreview() + }) + + const unsubscribeLiveOverrides = useLiveNodeOverrides.subscribe(() => { + refreshLivePreview() + }) + + return () => { + disposed = true + unsubscribeScene() + unsubscribeLiveTransforms() + unsubscribeLiveOverrides() + previewController.clear() + } +} +export const StairOpeningSystem = () => { + useEffect(() => initializeStairOpeningSync(), []) return null } diff --git a/packages/core/src/systems/stair/stair-rise.test.ts b/packages/core/src/systems/stair/stair-rise.test.ts index 8a2e78e7a3..d4939b81e7 100644 --- a/packages/core/src/systems/stair/stair-rise.test.ts +++ b/packages/core/src/systems/stair/stair-rise.test.ts @@ -95,6 +95,27 @@ function buildDeckScene(options: { return { deck, stair, nodes } } +function registerStairFootprint() { + registerNode({ + kind: 'stair', + schemaVersion: 1, + schema: z.object({ type: z.literal('stair') }) as never, + category: 'structure', + defaults: () => ({}) as never, + capabilities: { + floorPlaced: { + footprints: (node) => [ + { + position: (node as StairNodeType).position, + dimensions: [1, 1, 2] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + }, + ], + }, + }, + } as AnyNodeDefinition) +} + function buildLevelSceneWithSegments(options: { levelHeight: number totalRise?: number @@ -349,24 +370,7 @@ describe('deck-attached rise with a floor-lifted base', () => { ] beforeEach(() => { - registerNode({ - kind: 'stair', - schemaVersion: 1, - schema: z.object({ type: z.literal('stair') }) as never, - category: 'structure', - defaults: () => ({}) as never, - capabilities: { - floorPlaced: { - footprints: (node) => [ - { - position: (node as StairNodeType).position, - dimensions: [1, 1, 2] as [number, number, number], - rotation: [0, 0, 0] as [number, number, number], - }, - ], - }, - }, - } as AnyNodeDefinition) + registerStairFootprint() }) function makeFloorSlab(elevation: number) { @@ -497,3 +501,104 @@ describe('deck-attached rise with a floor-lifted base', () => { expect(resolveStairTotalRise(stair, nodes)).toBeCloseTo(1.25) }) }) + +// A level-destination stair climbs to the storey plane above, which is an +// absolute level-local height β€” so a slab that lifts the stair's own base eats +// into the rise. Without the subtraction the last step overshoots the floor +// above by the slab's thickness (and a tall storey used to be missed entirely). +describe('level rise with a floor-lifted base', () => { + const FLOOR_POLYGON: Array<[number, number]> = [ + [-5, -5], + [5, -5], + [5, 5], + [-5, 5], + ] + + beforeEach(() => { + registerStairFootprint() + }) + + function buildLiftedLevelScene(options: { + levelHeight: number + floorElevation?: number | null + totalRise?: number + segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }> + }) { + const scene = buildLevelSceneWithSegments({ + levelHeight: options.levelHeight, + totalRise: options.totalRise, + segments: options.segments ?? [], + }) + if (options.floorElevation == null) return { ...scene, floor: null } + + const floor = SlabNode.parse({ + id: 'slab_floor', + type: 'slab', + polygon: FLOOR_POLYGON, + elevation: options.floorElevation, + thickness: 0.05, + }) + spatialGridManager.handleNodeCreated(floor as AnyNode, 'level_1') + return { + ...scene, + floor, + nodes: { ...scene.nodes, [floor.id]: floor } as Record, + } + } + + it('lands the last step on the storey plane: rise = floor-to-floor βˆ’ elected base', () => { + const { stair, nodes } = buildLiftedLevelScene({ levelHeight: 5.3, floorElevation: 0.05 }) + const base = getFloorPlacedElevation({ + node: stair, + nodes, + position: stair.position, + rotation: stair.rotation, + levelId: 'level_1', + }) + expect(base).toBeCloseTo(0.05) + const rise = resolveStairTotalRise(stair, nodes) + expect(rise).toBeCloseTo(5.25) + expect(base + rise).toBeCloseTo(5.3) + }) + + it('keeps the full storey height when the stair stands on bare ground', () => { + const { stair, nodes } = buildLiftedLevelScene({ levelHeight: 5.3, floorElevation: null }) + expect(resolveStairTotalRise(stair, nodes)).toBeCloseTo(5.3) + }) + + it('lets an explicit totalRise win over the base-adjusted storey rise', () => { + const { stair, nodes } = buildLiftedLevelScene({ + levelHeight: 5.3, + floorElevation: 0.05, + totalRise: 2.7, + }) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.7) + }) + + it('converges a straight flight to the base-adjusted storey rise', () => { + const { nodes } = buildLiftedLevelScene({ + levelHeight: 5.3, + floorElevation: 0.05, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 2.5 }], + }) + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(1) + expect(updates[0]?.id).toBe('sseg_1' as never) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(5.25) + }) + + it('re-converges after the base slab elevation changes', () => { + const scene = buildLiftedLevelScene({ + levelHeight: 2.5, + floorElevation: 0.05, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 2.45 }], + }) + expect(syncStairRises(scene.nodes)).toEqual([]) + const movedFloor = { ...scene.floor, elevation: 0.3 } + const nodes = { ...scene.nodes, slab_floor: movedFloor as AnyNode } + spatialGridManager.handleNodeUpdated(movedFloor as AnyNode, 'level_1') + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(1) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(2.2) + }) +}) diff --git a/packages/core/src/systems/stair/stair-rise.ts b/packages/core/src/systems/stair/stair-rise.ts index f67a07755a..3e62daf6bc 100644 --- a/packages/core/src/systems/stair/stair-rise.ts +++ b/packages/core/src/systems/stair/stair-rise.ts @@ -10,32 +10,30 @@ export function resolveStairTotalRise(stair: StairNode, nodes: Record node.type === 'level' && node.children.includes(stair.id), ) + // Both destinations are absolute level-local heights, while the stair's own + // base may be lifted onto a floor slab by the floor-stack + // (`FloorElevationSystem` / `syncStairGroupElevation` put the group at + // `position[1] + elected slab elevation`). The rise is measured from that + // base, so subtract it β€” electing the base exactly the way the visual + // systems do (persisted `supportSlabId` honored, uncapped election + // otherwise) keeps base + rise landing precisely on the destination surface. + const baseElevation = getFloorStackedPosition({ + node: stair, + nodes, + position: stair.position, + rotation: stair.rotation, + levelId: level?.id ?? null, + })[1] + if (stair.deckSlabId) { + // The deck's `elevation` IS its walking surface (level-local). A stale + // reference (deck gone) falls through to the level-derived rise. const deck = nodes[stair.deckSlabId] - // The deck's `elevation` IS its walking surface (level-local), but the - // stair's own base may be lifted onto a floor slab by the floor-stack - // (`FloorElevationSystem` / `syncStairGroupElevation` put the group at - // `position[1] + elected slab elevation`). The rise is measured from - // that base, so subtract it β€” electing the base exactly the way the - // visual systems do (persisted `supportSlabId` honored, uncapped - // election otherwise) keeps base + rise landing precisely on the deck's - // walking surface. A stale reference (deck gone) falls through to the - // level-derived rise. - if (deck?.type === 'slab') { - const baseElevation = getFloorStackedPosition({ - node: stair, - nodes, - position: stair.position, - rotation: stair.rotation, - levelId: level?.id ?? null, - })[1] - return (deck.elevation ?? 0.05) - baseElevation - } + if (deck?.type === 'slab') return (deck.elevation ?? 0.05) - baseElevation } - return level?.type === 'level' - ? getLevelFloorToFloorHeight(level.id, nodes as Record) - : DEFAULT_LEVEL_HEIGHT + if (level?.type !== 'level') return DEFAULT_LEVEL_HEIGHT + return getLevelFloorToFloorHeight(level.id, nodes as Record) - baseElevation } const RISE_SYNC_EPSILON = 1e-4 diff --git a/packages/core/src/validation/validate-build-json.test.ts b/packages/core/src/validation/validate-build-json.test.ts index 0c7836fc9d..3f0488dce5 100644 --- a/packages/core/src/validation/validate-build-json.test.ts +++ b/packages/core/src/validation/validate-build-json.test.ts @@ -174,3 +174,60 @@ describe('scene materials', () => { expect(result.warnings.some((w) => w.code === 'invalid_materials')).toBe(true) }) }) + +describe('collections', () => { + const minimalGraph = () => ({ + nodes: { + building_1: { id: 'building_1', type: 'building', children: ['level_1'] }, + level_1: { id: 'level_1', type: 'level', children: [] }, + }, + rootNodeIds: ['building_1'], + }) + + test('carries valid collections through to parsed', () => { + const result = validateBuildJson({ + ...minimalGraph(), + collections: { + collection_a: { + id: 'collection_a', + name: 'Kitchen set', + color: '#ff0000', + nodeIds: ['item_1', 'item_2'], + }, + }, + }) + expect(result.ok).toBe(true) + expect(result.parsed?.collections?.collection_a?.name).toBe('Kitchen set') + expect(result.parsed?.collections?.collection_a?.nodeIds).toEqual(['item_1', 'item_2']) + }) + + test('skips invalid collection entries with a warning, keeps the rest', () => { + const result = validateBuildJson({ + ...minimalGraph(), + collections: { + collection_ok: { id: 'collection_ok', name: 'Fine', nodeIds: [] }, + collection_bad: { id: 'collection_bad', name: 'Broken', nodeIds: [42] }, + collection_worse: 'nope', + }, + }) + expect(result.ok).toBe(true) + expect(Object.keys(result.parsed?.collections ?? {})).toEqual(['collection_ok']) + const warning = result.warnings.find((w) => w.code === 'invalid_collections') + expect(warning).toBeDefined() + expect(warning?.message).toContain('collection_bad') + expect(warning?.message).toContain('collection_worse') + }) + + test('warns when collections is not an object', () => { + const result = validateBuildJson({ ...minimalGraph(), collections: [] }) + expect(result.ok).toBe(true) + expect(result.parsed?.collections).toBeUndefined() + expect(result.warnings.some((w) => w.code === 'invalid_collections')).toBe(true) + }) + + test('omits collections from parsed when absent', () => { + const result = validateBuildJson(minimalGraph()) + expect(result.ok).toBe(true) + expect('collections' in (result.parsed ?? {})).toBe(false) + }) +}) diff --git a/packages/core/src/validation/validate-build-json.ts b/packages/core/src/validation/validate-build-json.ts index f5bdbb8a20..0f9390c87f 100644 --- a/packages/core/src/validation/validate-build-json.ts +++ b/packages/core/src/validation/validate-build-json.ts @@ -1,4 +1,5 @@ import { nodeRegistry } from '../registry' +import type { Collection } from '../schema/collections' import { SceneMaterial } from '../schema/scene-material' import { AnyNode, type AnyNodeType, nodeKindOf } from '../schema/types' import { healSceneNodes } from '../utils/heal-scene-graph' @@ -27,6 +28,8 @@ export type ParsedBuildJson = { installedPlugins?: string[] /** Scene materials referenced by node `slots` (`scene:`). */ materials?: Record + /** Item collections; member nodes carry the matching `collectionIds`. */ + collections?: Record } export type SchemaIssue = { @@ -52,6 +55,18 @@ function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } +function isCollection(value: unknown): value is Collection { + if (!isPlainObject(value)) return false + return ( + typeof value.id === 'string' && + typeof value.name === 'string' && + Array.isArray(value.nodeIds) && + value.nodeIds.every((nodeId) => typeof nodeId === 'string') && + (value.color === undefined || typeof value.color === 'string') && + (value.controlNodeId === undefined || typeof value.controlNodeId === 'string') + ) +} + function polygonAreaM2(points: ReadonlyArray): number { if (points.length < 3) return 0 let area = 0 @@ -113,6 +128,7 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { const rootNodeIdsRaw = input.rootNodeIds const installedPluginsRaw = input.installedPlugins const materialsRaw = input.materials + const collectionsRaw = input.collections if (!isPlainObject(nodesRaw)) { errors.push({ @@ -206,6 +222,35 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { }) } + let collections: Record | undefined + if (isPlainObject(collectionsRaw)) { + const skippedIds: string[] = [] + const kept: Record = {} + for (const [id, value] of Object.entries(collectionsRaw)) { + if (isCollection(value)) { + kept[id] = value + } else { + skippedIds.push(id) + } + } + if (Object.keys(kept).length > 0) collections = kept + if (skippedIds.length > 0) { + warnings.push({ + severity: 'warning', + code: 'invalid_collections', + message: `Ignored ${skippedIds.length} invalid collection${ + skippedIds.length === 1 ? '' : 's' + }: ${skippedIds.join(', ')}.`, + }) + } + } else if (collectionsRaw !== undefined) { + warnings.push({ + severity: 'warning', + code: 'invalid_collections', + message: 'Ignored invalid "collections" β€” expected an object of id β†’ collection.', + }) + } + if (strippedChildRefs > 0 || droppedWallIds.length > 0) { warnings.push({ severity: 'warning', @@ -420,6 +465,7 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { rootNodeIds, ...(installedPlugins ? { installedPlugins } : {}), ...(materials ? { materials } : {}), + ...(collections ? { collections } : {}), } : null, stats, diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 957e2ce9cb..bf55849b88 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -9,6 +9,5 @@ "types": ["bun"] }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"], - "references": [{ "path": "../capture-protocol" }] + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] } diff --git a/packages/editor/bunfig.toml b/packages/editor/bunfig.toml new file mode 100644 index 0000000000..eec7d338da --- /dev/null +++ b/packages/editor/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-preload-three.ts"] + +[test] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/packages/editor/package.json b/packages/editor/package.json index b12dc98a4a..4efc3b7945 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@pascal-app/editor", - "version": "1.0.0-beta.5", + "version": "1.0.0", "description": "Pascal building editor component", "license": "MIT", "type": "module", @@ -13,14 +13,14 @@ "test": "bun test src" }, "peerDependencies": { - "@pascal-app/core": "^1.0.0-beta.5", - "@pascal-app/viewer": "^1.0.0-beta.5", + "@pascal-app/core": "^1.0.0", + "@pascal-app/viewer": "^1.0.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", "next": ">=15", "react": "^18 || ^19", "react-dom": "^18 || ^19", - "three": "^0.185" + "three": "^0.186" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -61,9 +61,10 @@ "zustand": "^5.0.11" }, "devDependencies": { - "@pascal-app/core": "^1.0.0-beta.5", - "@pascal-app/viewer": "^1.0.0-beta.5", + "@pascal-app/core": "^1.0.0", + "@pascal-app/viewer": "^1.0.0", "@pascal/typescript-config": "*", + "@react-three/test-renderer": "^9.1.0", "@types/blob-stream": "^0.1.33", "@types/bun": "^1.3.0", "@types/howler": "^2.2.12", diff --git a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx index 0a2280b66c..1c63b3a3cf 100644 --- a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx @@ -39,10 +39,13 @@ import { collectParticipants, computeGroupBox, expandToComponent, + type GroupPlanBounds, + groupPlanBounds, levelFrame, - participantExtents, + planBoundsCenter, rotateGroupPatches, rotateGroupSnapshots, + rotatePlanBounds, translateGroupPatches, type Vec2, } from '../editor/group-transform-shared' @@ -112,6 +115,9 @@ export function startFloorplanGroupMove( affectedIds: AnyNodeId[] candidates: ReturnType restAnchors: ReturnType + startBounds: GroupPlanBounds + restBounds: GroupPlanBounds + rotation: number restCenter: Vec2 lastDelta: Vec2 | null } @@ -140,20 +146,16 @@ export function startFloorplanGroupMove( // The group aligns as one rigid footprint: its bbox corners + center are // the moving anchors. `computeGroupBox` is world-space (the 3D scene stays // mounted under every view mode); plan coords are level-frame, so convert. - const restBox = computeGroupBox(fullIds) const { inverse: frameInv } = levelFrame(levelId) - const boxMin = restBox ? restBox.min.clone().applyMatrix4(frameInv) : null - const boxMax = restBox ? restBox.max.clone().applyMatrix4(frameInv) : null - const restAnchors = - boxMin && boxMax - ? bboxCornerAnchors( - 'group-move', - Math.min(boxMin.x, boxMax.x), - Math.min(boxMin.z, boxMax.z), - Math.max(boxMin.x, boxMax.x), - Math.max(boxMin.z, boxMax.z), - ) - : [] + const restBounds = groupPlanBounds(computeGroupBox(fullIds), starts, frameInv) + if (!restBounds) return null + const restAnchors = bboxCornerAnchors( + 'group-move', + restBounds.minX, + restBounds.minZ, + restBounds.maxX, + restBounds.maxZ, + ) for (const id of affectedIds) { useLiveTransforms.getState().clear(id) @@ -169,12 +171,23 @@ export function startFloorplanGroupMove( nodeId, handle: GROUP_MOVE_DRAG_LABEL, }) - // Rotation pivot for mid-drag R/T β€” the participant DATA extents' center - // (stable across the drag; rotations re-seed around the same point). - const ext = participantExtents(starts) - const restCenter: Vec2 = ext ? [(ext.minX + ext.maxX) / 2, (ext.minZ + ext.maxZ) / 2] : [0, 0] + // Rotation pivot for mid-drag R/T β€” the START footprint's center, the same + // point the 3D body drag, the idle keyboard rotate and the rotate gizmos + // orbit. Stable across the drag; rotations re-seed around the same point. + const restCenter = planBoundsCenter(restBounds) - return { starts, links, affectedIds, candidates, restAnchors, restCenter, lastDelta: null } + return { + starts, + links, + affectedIds, + candidates, + restAnchors, + startBounds: restBounds, + restBounds, + rotation: 0, + restCenter, + lastDelta: null, + } } const applyMove = (e: PointerEvent, s: Session) => { @@ -241,18 +254,20 @@ export function startFloorplanGroupMove( // current delta β€” the carried group turns exactly like the idle keyboard // rotate, and the commit stays a single updateNodes. const rotateSession = (s: Session, direction: 1 | -1) => { - const rotated = rotateGroupSnapshots( - s.starts, - s.links, - { x: s.restCenter[0], z: s.restCenter[1] }, - -direction * (Math.PI / 4), - ) + const pivot = { x: s.restCenter[0], z: s.restCenter[1] } + const delta = -direction * (Math.PI / 4) + const rotated = rotateGroupSnapshots(s.starts, s.links, pivot, delta) s.starts = rotated.starts s.links = rotated.links - const ext = participantExtents(rotated.starts) - if (ext) { - s.restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ) - } + s.rotation += delta + s.restBounds = rotatePlanBounds(s.startBounds, pivot, s.rotation) + s.restAnchors = bboxCornerAnchors( + 'group-move', + s.restBounds.minX, + s.restBounds.minZ, + s.restBounds.maxX, + s.restBounds.maxZ, + ) sfxEmitter.emit('sfx:item-rotate') applyDelta(s, s.lastDelta?.[0] ?? 0, s.lastDelta?.[1] ?? 0) } @@ -358,7 +373,22 @@ export function startFloorplanGroupMove( const onKeyDown = (e: KeyboardEvent) => { const key = e.key.toLowerCase() if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { - if (!session) return + // Armed but still under the drag threshold: engage first (exactly what + // the next pointer-move would do) so the rotation lands inside this + // session. Falling through to the global idle arm instead would write + // the scene behind snapshots already captured here, and the first + // `applyDelta` would republish them β€” undoing the rotation. + if (!session) { + session = engage() + if (!session) { + // No plane hit yet: swallow the chord and keep the gesture armed so + // the next pointer-move can still engage; the idle arm must not run + // behind the snapshots captured here. + e.preventDefault() + e.stopPropagation() + return + } + } e.preventDefault() e.stopPropagation() rotateSession(session, key === 'r' ? 1 : -1) @@ -415,9 +445,13 @@ export function startFloorplanGroupRotate(event: { const { starts, links } = collectParticipants(fullIds, nodes, levelId) if (starts.length === 0) return false const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] - const ext = participantExtents(starts) - if (!ext) return false - const pivot = { x: (ext.minX + ext.maxX) / 2, z: (ext.minZ + ext.maxZ) / 2 } + const { inverse: frameInv } = levelFrame(levelId) + const bounds = groupPlanBounds(computeGroupBox(fullIds), starts, frameInv) + if (!bounds) return false + // Same pivot as the dashed box the handles hang off (and as the 3D rotate + // gizmo): its centre, not the anchor points' centre. + const [pivotX, pivotZ] = planBoundsCenter(bounds) + const pivot = { x: pivotX, z: pivotZ } const startPlan = clientToPlan(event.clientX, event.clientY) if (!startPlan) return false // Bearing around the pivot in the plan frame β€” the same atan2 xβ†’z sense diff --git a/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx index 01e233e04e..12de92939d 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx @@ -3,6 +3,7 @@ import { createSceneApi, nodeRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { type ComponentType, lazy, Suspense, useCallback, useMemo } from 'react' +import { useRegisteredToolEnabled } from '../../hooks/use-registered-tool-enabled' import { type FloorplanToolContext, getFloorplanNodeExtension, @@ -35,6 +36,7 @@ function registeredFloorplanTool( export function FloorplanRegisteredToolLayer() { const mode = useEditor((state) => state.mode) const tool = useEditor((state) => state.tool) + const registeredToolEnabled = useRegisteredToolEnabled(tool) const floorplanMode = useFloorplanMode((state) => state.mode) const gridSnapStep = useEditor((state) => state.gridSnapStep) const toolDefaults = useEditor((state) => @@ -54,7 +56,7 @@ export function FloorplanRegisteredToolLayer() { useEditor.getState().setMode('select') }, []) if (mode !== 'build') return null - const Tool = registeredFloorplanTool(tool, floorplanMode) + const Tool = registeredToolEnabled ? registeredFloorplanTool(tool, floorplanMode) : null return Tool ? ( { expect(markup).toContain('data-floorplan-annotation-obstacle="bounds"') expect(markup).toContain('data-floorplan-annotation-obstacle="outline"') }) + test('forwards even-odd fill rules for compound plugin paths', () => { + const geometry = { + kind: 'path', + d: 'M0,0H4V4H0ZM1,1H3V3H1Z', + fill: '#3f6b2f', + fillRule: 'evenodd', + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('fill-rule="evenodd"') + }) }) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx index 84ed01b3fb..b448fdcf16 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx @@ -83,6 +83,7 @@ function styleAttrs( // filtered out by the caller's type bound). const s = g as unknown as { fill?: string + fillRule?: 'nonzero' | 'evenodd' fillOpacity?: number stroke?: string strokeWidth?: number @@ -109,6 +110,7 @@ function styleAttrs( 'data-floorplan-annotation-obstacle': floorplanAnnotationObstacleMode(g), 'data-floorplan-annotation-role': annotationMetadata.annotationRole, fill: documentStyle.fill ?? s.fill ?? 'none', + fillRule: s.fillRule, fillOpacity: s.fillOpacity, stroke: documentStyle.stroke ?? s.stroke, strokeWidth, diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts index e416ad0df0..e89a86edcf 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts @@ -16,7 +16,9 @@ import { floorplanGeometryMetadata, } from '../../../lib/floorplan/floorplan-extension' import { + buildFloorplanEntryGeometry, cancelFloorplanAffordanceDrag, + collectDirectFloorplanScopeNodes, collectFloorplanDependencyNodes, collectFloorplanLinkedLevelNodes, computeAffectedSiblingIds, @@ -25,10 +27,185 @@ import { InteractiveGeometry, isFloorplanOpeningPlacementState, resolveFloorplanHandleUnitsPerPixel, + siteToFloorplanTransform, splitFloorplanOverlay, subscribeFloorplanAffordanceToolCancel, } from './floorplan-registry-layer' +describe('site-scoped floorplan discovery', () => { + let restoreRegistry: () => void + + beforeEach(() => { + restoreRegistry = nodeRegistry._snapshot() + nodeRegistry._reset() + registerNode({ + kind: 'test:site-overlay', + schemaVersion: 1, + schema: z.object({ type: z.literal('test:site-overlay') }) as never, + category: 'utility', + defaults: () => ({}) as never, + capabilities: {}, + floorplanScope: 'site', + floorplan: (node) => { + const positioned = node as unknown as { + position?: [number, number, number] + rotation?: [number, number, number] + } + if (!(positioned.position && positioned.rotation)) return null + return { + kind: 'group', + children: [{ kind: 'circle', cx: 0, cy: 0, r: 1 }], + transform: { + translate: [positioned.position[0], positioned.position[2]], + rotate: positioned.rotation[1], + }, + } + }, + } as AnyNodeDefinition) + }) + + afterEach(() => restoreRegistry()) + + test('collects only direct children of the active Site', () => { + const activeSite = { + id: 'site_active', + type: 'site', + parentId: null, + children: ['overlay_declared'], + } as unknown as AnyNode + const nodes = { + [activeSite.id]: activeSite, + overlay_declared: { + id: 'overlay_declared', + type: 'test:site-overlay', + parentId: null, + } as unknown as AnyNode, + overlay_parented: { + id: 'overlay_parented', + type: 'test:site-overlay', + parentId: activeSite.id, + } as unknown as AnyNode, + overlay_other_site: { + id: 'overlay_other_site', + type: 'test:site-overlay', + parentId: 'site_other', + } as unknown as AnyNode, + } + + expect( + collectDirectFloorplanScopeNodes(nodes, activeSite, 'site').map((node) => String(node.id)), + ).toEqual(['overlay_declared', 'overlay_parented']) + }) + test('projects site-local coordinates through the inverse Three.js building transform', () => { + const transform = siteToFloorplanTransform([10, 0, 5], Math.PI / 2) + const [tx, ty] = transform.translate + const sitePoint = [10, 3] as const + const cos = Math.cos(transform.rotate) + const sin = Math.sin(transform.rotate) + + expect(tx + sitePoint[0] * cos - sitePoint[1] * sin).toBeCloseTo(2) + + expect(ty + sitePoint[0] * sin + sitePoint[1] * cos).toBeCloseTo(0) + }) + + test('rebuilds site geometry from a live pose and restores the committed pose when cleared', () => { + const site = { + id: 'site_active', + type: 'site', + parentId: null, + children: ['overlay_pond'], + visible: true, + } as unknown as AnyNode + const overlay = { + id: 'overlay_pond', + type: 'test:site-overlay', + parentId: site.id, + children: [], + visible: true, + position: [1, 0, 2], + rotation: [0, 0.25, 0], + } as unknown as AnyNode + const nodes = { + [site.id]: site, + [overlay.id]: overlay, + } + const geometryCache = new Map() + const liveOverrides = new Map() + const siteProjection = siteToFloorplanTransform([10, 0, 5], Math.PI / 2) + const common = { + automaticDimensions: false, + ctxOverrides: { + children: [], + siblings: [], + parent: site, + outputTransform: siteProjection, + trackAllNodes: true, + }, + geometryCache, + highlighted: false, + hovered: false, + interactiveElevators: {}, + levelDataCache: new Map(), + levelNodeIdsByType: new Map(), + liveOverride: undefined, + liveOverrides, + moving: true, + node: overlay, + nodeId: overlay.id, + nodes, + palette: undefined, + selected: false, + siblingEpoch: 0, + unit: 'metric' as const, + metricNotation: 'meters' as const, + wallDimensionReference: 'finished-faces' as const, + visibilityRootId: site.id, + } + const committedSnapshot = structuredClone(overlay) + const livePose = { + position: [4, 0, 5] as [number, number, number], + rotation: Math.PI / 3, + } + + const liveEntry = buildFloorplanEntryGeometry({ ...common, live: livePose }) + + expect(liveEntry?.node).toMatchObject({ + position: livePose.position, + rotation: [0, livePose.rotation, 0], + parentId: null, + }) + expect(liveEntry?.base).toEqual({ + kind: 'group', + children: [ + { + kind: 'group', + children: [{ kind: 'circle', cx: 0, cy: 0, r: 1 }], + transform: { translate: [4, 5], rotate: livePose.rotation }, + }, + ], + transform: siteProjection, + }) + expect(overlay).toEqual(committedSnapshot) + + const committedEntry = buildFloorplanEntryGeometry({ ...common, live: undefined }) + + expect(committedEntry).not.toBe(liveEntry) + expect(committedEntry?.node).toBe(overlay) + expect(committedEntry?.base).toEqual({ + kind: 'group', + children: [ + { + kind: 'group', + children: [{ kind: 'circle', cx: 0, cy: 0, r: 1 }], + transform: { translate: [1, 2], rotate: 0.25 }, + }, + ], + transform: siteProjection, + }) + expect(overlay).toEqual(committedSnapshot) + }) +}) + describe('floorplan selection handle sizing', () => { test('caps visual handle growth at extreme zoom-out', () => { expect(resolveFloorplanHandleUnitsPerPixel(0.01)).toBe(0.01) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 39814e0fbe..f2960f9549 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -10,6 +10,7 @@ import { type FloorplanGeometry, type FloorplanPalette, type FloorplanPoint, + type FloorplanScope, type GeometryContext, isNodeKindEnabled, isRegistryMovable, @@ -338,6 +339,8 @@ type FloorplanEntryDescriptor = { node: AnyNode dependsOnSiblingInputs: boolean ctxOverrides?: FloorplanContextOverrides + scopeRank?: number + visibilityRootId?: AnyNodeId } type NodeDeps = { @@ -357,6 +360,7 @@ type NodeDeps = { committedNodes: Record | null dependencyNodes: AnyNode[] interactiveElevators: unknown + ctxOverrides: FloorplanContextOverrides | undefined } type CacheEntry = { @@ -377,6 +381,40 @@ type FloorplanContextOverrides = { children: AnyNode[] siblings: AnyNode[] parent: AnyNode | null + outputTransform?: { translate?: FloorplanPoint; rotate?: number } + trackAllNodes?: boolean +} + +export function collectDirectFloorplanScopeNodes( + nodes: Record, + parent: AnyNode, + scope: FloorplanScope, +): AnyNode[] { + const scopedKinds = new Set(kindsWithFloorplanScope(scope)) + const declaredChildren = new Set( + Array.isArray((parent as { children?: AnyNodeId[] }).children) + ? (parent as { children: AnyNodeId[] }).children + : [], + ) + return Object.values(nodes).filter( + (node) => + scopedKinds.has(node.type) && (node.parentId === parent.id || declaredChildren.has(node.id)), + ) +} + +export function siteToFloorplanTransform( + buildingPosition: readonly [number, number, number], + buildingRotationY: number, +): { translate: FloorplanPoint; rotate: number } { + const cos = Math.cos(buildingRotationY) + const sin = Math.sin(buildingRotationY) + return { + translate: [ + -buildingPosition[0] * cos + buildingPosition[2] * sin, + -buildingPosition[0] * sin - buildingPosition[2] * cos, + ], + rotate: buildingRotationY, + } } type FloorplanLevelDataHook = (args: { @@ -487,6 +525,13 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const levelId = selectedLevelId ?? ambientLevelId const isAmbient = !selectedLevelId && !!ambientLevelId + const activeBuildingId = useMemo( + () => (levelId ? resolveBuildingForLevel(levelId as AnyNodeId, nodes) : null), + [levelId, nodes], + ) + const activeBuildingLiveTransform = useLiveTransforms((state) => + activeBuildingId ? state.transforms.get(activeBuildingId) : undefined, + ) const renderCtx = useFloorplanStaticRender() const sceneRotationDeg = renderCtx?.getSceneRotationDeg() ?? 0 const setMovingNode = useEditor((s) => s.setMovingNode) @@ -946,7 +991,12 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { collectLevelDataKind(levelId as AnyNodeId) - const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => { + const pushEntry = ( + id: AnyNodeId, + node: AnyNode, + ctxOverrides?: FloorplanContextOverrides, + options?: { scopeRank?: number; visibilityRootId?: AnyNodeId }, + ) => { if (!isNodeKindEnabled(node.type, installedPlugins)) return const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType) if (!drawingNode) return @@ -959,6 +1009,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { ) const descriptor: FloorplanEntryDescriptor = { id, node: drawingNode, dependsOnSiblingInputs } if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides + if (options?.scopeRank !== undefined) descriptor.scopeRank = options.scopeRank + if (options?.visibilityRootId) descriptor.visibilityRootId = options.visibilityRootId out.push(descriptor) } @@ -994,13 +1046,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // as siblings of the level, not under it β€” the `visit(levelId)` DFS // above doesn't reach them. Walk every node of those kinds whose // parent matches the active level's building, and synthesise a - // `GeometryContext` whose `parent` is the active level (so kind - // builders that gate on the current floor β€” e.g. elevator service - // range β€” keep working). Pure registry-driven dispatch: no kind - // name appears in this file. - const activeBuildingId = activeLevelNode - ? resolveBuildingForLevel(levelId as AnyNodeId, nodes) - : null + // `GeometryContext` whose `parent` is the active level. if (activeLevelNode && activeBuildingId) { const buildingScopedKinds = kindsWithFloorplanScope('building') const buildingScopedKindSet = new Set(buildingScopedKinds) @@ -1017,6 +1063,45 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { } } + // Site-scoped kinds are semantic children of the Site and therefore + // outside both the active-level DFS and the building sibling scan. + // Their builders stay site-local; wrap their output in the inverse active + // building transform so it aligns with this building-local plan. + const activeBuildingNode = activeBuildingId ? nodes[activeBuildingId] : undefined + const activeSiteNode = + activeBuildingNode?.type === 'building' && activeBuildingNode.parentId + ? nodes[activeBuildingNode.parentId as AnyNodeId] + : undefined + if (activeSiteNode?.type === 'site' && activeBuildingNode?.type === 'building') { + const siteScopedNodes = collectDirectFloorplanScopeNodes(nodes, activeSiteNode, 'site') + const siteProjection = siteToFloorplanTransform( + activeBuildingLiveTransform?.position ?? activeBuildingNode.position, + activeBuildingLiveTransform?.rotation ?? activeBuildingNode.rotation[1], + ) + for (const node of siteScopedNodes) { + const children = Array.isArray((node as { children?: AnyNodeId[] }).children) + ? (node as { children: AnyNodeId[] }).children + .map((id) => nodes[id]) + .filter((child): child is AnyNode => child !== undefined) + : [] + const siblings = siteScopedNodes.filter( + (candidate) => candidate.id !== node.id && candidate.type === node.type, + ) + pushEntry( + node.id, + node, + { + children, + siblings, + parent: activeSiteNode, + outputTransform: siteProjection, + trackAllNodes: true, + }, + { scopeRank: -1, visibilityRootId: activeSiteNode.id }, + ) + } + } + // Stable z-order sort. SVG renders in document order β€” later siblings // paint on top of earlier ones β€” so anything that should sit *under* // other floor-plan geometry has to come first in the entries array. @@ -1024,7 +1109,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // all belong on top of them. Within a layer bucket we preserve the // DFS visit order (stable sort) so siblings keep their relative // priority. - out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type)) + out.sort( + (a, b) => + (a.scopeRank ?? 0) - (b.scopeRank ?? 0) || + floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type), + ) const entryIds = new Set(out.map((entry) => entry.id)) for (const id of geometryCacheRef.current.keys()) { if (!entryIds.has(id as AnyNodeId)) geometryCacheRef.current.delete(id) @@ -1033,7 +1122,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type) } return { entries: out, levelNodeIdsByType } - }, [drawingType, installedPlugins, levelId, nodes]) + }, [activeBuildingId, activeBuildingLiveTransform, drawingType, installedPlugins, levelId, nodes]) // ── Generic 2D affordance dispatch ───────────────────────────────── // @@ -1448,7 +1537,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { unit={unit} metricNotation={metricNotation} wallDimensionReference={effectiveWallDimensionReference} - visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)} + visibilityRootId={ + entry.visibilityRootId ?? (entry.ctxOverrides ? undefined : (levelId as AnyNodeId)) + } ctxOverrides={entry.ctxOverrides} /> ))} @@ -1494,7 +1585,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { unit={unit} metricNotation={metricNotation} wallDimensionReference={effectiveWallDimensionReference} - visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)} + visibilityRootId={ + entry.visibilityRootId ?? (entry.ctxOverrides ? undefined : (levelId as AnyNodeId)) + } ctxOverrides={entry.ctxOverrides} /> ))} @@ -2101,7 +2194,7 @@ function floorplanEntryReferencedAnnotationRole( return dependencyIds.some((id) => selectedIds.has(id)) ? role : undefined } -function buildFloorplanEntryGeometry({ +export function buildFloorplanEntryGeometry({ automaticDimensions, ctxOverrides, geometryCache, @@ -2146,6 +2239,7 @@ function buildFloorplanEntryGeometry({ const deps: NodeDeps = { automaticDimensions, node, + ctxOverrides, live, unit, metricNotation, @@ -2159,7 +2253,7 @@ function buildFloorplanEntryGeometry({ siblingEpoch: dependsOnSiblingInputs ? siblingEpoch : 0, // Sibling-dependent kinds (wall miters, opening cuts) read other nodes' // committed state via `ctx`, so committed sibling edits still invalidate. - committedNodes: dependsOnSiblingInputs ? nodes : null, + committedNodes: dependsOnSiblingInputs || ctxOverrides?.trackAllNodes ? nodes : null, dependencyNodes, interactiveElevators, } @@ -2184,7 +2278,12 @@ function buildFloorplanEntryGeometry({ : r, } as AnyNode } - if ((def.capabilities?.floorPlaced || def.floorplanScope === 'building') && hasPosition) { + if ( + (def.capabilities?.floorPlaced || + def.floorplanScope === 'building' || + def.floorplanScope === 'site') && + hasPosition + ) { return applyPositionLiveTransform(sourceNode, live) } if (sourceNode.type === 'slab' || sourceNode.type === 'ceiling' || sourceNode.type === 'zone') { @@ -2279,10 +2378,18 @@ function buildFloorplanEntryGeometry({ const taggedContextualGeometry = withFloorplanGeometryMetadata(contextualGeometry, { annotationRole: 'contextual-dimension', }) - const geometry = + const unprojectedGeometry = modelGeometry && taggedContextualGeometry ? { kind: 'group' as const, children: [modelGeometry, taggedContextualGeometry] } : (modelGeometry ?? taggedContextualGeometry) + const geometry = + unprojectedGeometry && ctxOverrides?.outputTransform + ? { + kind: 'group' as const, + children: [unprojectedGeometry], + transform: ctxOverrides.outputTransform, + } + : unprojectedGeometry const { base, overlay } = geometry ? splitFloorplanOverlay(geometry) : { base: null, overlay: null } @@ -3179,12 +3286,15 @@ export function isFloorplanNodeVisible(node: AnyNode, liveOverride?: LiveNodeOve return (node as { visible?: boolean }).visible !== false } -function isFloorplanHierarchyVisible( +export function isFloorplanHierarchyVisible( node: AnyNode, nodes: Record, liveOverrides: Map, rootId: AnyNodeId, ): boolean { + const root = nodes[rootId] + if (root && !isFloorplanNodeVisible(root, liveOverrides.get(root.id))) return false + let current: AnyNode | undefined = node const seen = new Set() while (current) { @@ -3464,6 +3574,7 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean { 'liveOverride', 'palette', 'siblingEpoch', + 'ctxOverrides', 'committedNodes', 'dependencyNodes', 'interactiveElevators', diff --git a/packages/editor/src/components/editor/bake-exporter.tsx b/packages/editor/src/components/editor/bake-exporter.tsx index e84302cf0e..6aada0b36b 100644 --- a/packages/editor/src/components/editor/bake-exporter.tsx +++ b/packages/editor/src/components/editor/bake-exporter.tsx @@ -31,6 +31,7 @@ export function BakeExporter({ if (!sceneGroup) throw new Error('scene-renderer group not found') const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes, { textures: 'reference', + purpose: 'viewer', }) onComplete(buffer) } catch (err) { diff --git a/packages/editor/src/components/editor/export-manager.tsx b/packages/editor/src/components/editor/export-manager.tsx index 6e0475ef9b..7adb77d8d2 100644 --- a/packages/editor/src/components/editor/export-manager.tsx +++ b/packages/editor/src/components/editor/export-manager.tsx @@ -10,31 +10,37 @@ import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' import { exportSceneToGlb, nextFrames, prepareSceneForExport } from '../../lib/glb-export' import { exportSceneLevelsForPrint } from '../../lib/level-print-export' import type { ModelExport, ModelExportArtifact } from '../../lib/model-export' +import { + expandInstancedMeshes, + fixReflectedMeshWinding, + freezeDeformedMeshes, +} from '../../lib/portable-export' import { exportSceneToPrint3mf } from '../../lib/print-3mf' import { filterPreparedSceneForPrintContent } from '../../lib/print-content-scope' import { exportSceneToPrintStl, mergePrintExportDiagnostics } from '../../lib/print-export' import { applySemanticPrintFeatureThickness } from '../../lib/print-feature-thickness' import { compileSemanticPrintShellWithManifold } from '../../lib/print-shell-compiler-manifold-worker' +import { exportSceneToUsdz } from '../../lib/usdz-export' import useEditor from '../../store/use-editor' // prepareSceneForExport neutralises container meshes (door/window hitbox roots, // material-less renderables) with an attribute-less geometry β€” GLTFExporter // emits those as plain transform nodes, but STL/OBJExporter read // `position.count` unconditionally and crash. Swap in a geometry with an empty -// (count-0) position so they iterate zero vertices instead. Shared: the export -// scene is a throwaway clone, only its geometry *ref* is swapped. -const EMPTY_POSITION_GEOMETRY = new THREE.BufferGeometry() -EMPTY_POSITION_GEOMETRY.setAttribute( - 'position', - new THREE.Float32BufferAttribute(new Float32Array(0), 3), -) +// (count-0) position so they iterate zero vertices instead. Each replacement +// is export-owned and disposed with its prepared scene. +function createEmptyPositionGeometry(): THREE.BufferGeometry { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(0), 3)) + return geometry +} function ensurePositionAttributes(root: THREE.Object3D) { root.traverse((object) => { const renderable = object as THREE.Mesh & { isLine?: boolean; isPoints?: boolean } if (!(renderable.isMesh || renderable.isLine || renderable.isPoints)) return if (!renderable.geometry?.getAttribute('position')) { - renderable.geometry = EMPTY_POSITION_GEOMETRY + renderable.geometry = createEmptyPositionGeometry() } }) } @@ -64,9 +70,23 @@ export function ExportManager() { await nextFrames() if (format === 'glb') { - const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes, options) + const warnings: string[] = [] + const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes, { + ...options, + onWarning: (warning) => warnings.push(warning), + }) const blob = new Blob([buffer], { type: 'model/gltf-binary' }) - return finishArtifact(blob, `model_${date}.glb`, options.download) + return finishArtifact(blob, `model_${date}.glb`, options.download, undefined, warnings) + } + + if (format === 'usdz') { + const warnings: string[] = [] + const data = await exportSceneToUsdz(sceneGroup, useScene.getState().nodes, { + ...options, + onWarning: (warning) => warnings.push(warning), + }) + const blob = new Blob([data], { type: 'model/vnd.usdz+zip' }) + return finishArtifact(blob, `model_${date}.usdz`, options.download, undefined, warnings) } // Hide editor affordances that live on the scene layer (selection handles, @@ -78,112 +98,125 @@ export function ExportManager() { const nodes = useScene.getState().nodes let prepared: ReturnType try { - prepared = prepareSceneForExport(sceneGroup, nodes, options) + prepared = prepareSceneForExport(sceneGroup, nodes, { + ...options, + requireSynchronousBake: format === 'stl' || format === 'obj', + }) } finally { restoreLevels() emitter.emit('thumbnail:after-capture', undefined) } - let { scene: exportScene } = prepared - const printContent = options.printContent ?? 'structure' - const isPrintFormat = format === 'print-stl' || format === 'print-3mf' - if (isPrintFormat) { - exportScene = filterPreparedSceneForPrintContent(exportScene, nodes, printContent) - } - ensurePositionAttributes(exportScene) - - if (isPrintFormat) { - const printFormat = format === 'print-3mf' ? '3mf' : 'stl' - const scale = options.printScale ?? 100 - const compileShells = printContent === 'structure' - const minimumFeatureMm = compileShells ? options.printMinimumFeatureMm : undefined - if (options.printScope === 'levels') { - const plinth = - options.printBase === 'plinth' - ? { - marginMm: options.printPlinthMarginMm ?? 2, - thicknessMm: options.printPlinthThicknessMm ?? 2, - } - : undefined - const { data, report } = await exportSceneLevelsForPrint(exportScene, nodes, { - scale, - format: printFormat, - plinth, - minimumFeatureMm, - compileShells, - compileShell: compileShells ? compileSemanticPrintShellWithManifold : undefined, - }) - const blob = new Blob([data], { - type: printFormat === '3mf' ? 'model/3mf' : 'application/zip', - }) - return finishArtifact( - blob, - `print_levels_1-${scale}_${date}.${printFormat === '3mf' ? '3mf' : 'zip'}`, - options.download, - report, - ) + try { + let { scene: exportScene } = prepared + const printContent = options.printContent ?? 'structure' + const isPrintFormat = format === 'print-stl' || format === 'print-3mf' + if (isPrintFormat) { + exportScene = filterPreparedSceneForPrintContent(exportScene, nodes, printContent) } - if (options.printBase === 'plinth') { - throw new Error('Plinth generation is available only for per-level print packages.') + if (!isPrintFormat) { + expandInstancedMeshes(exportScene) + exportScene.updateMatrixWorld(true) + freezeDeformedMeshes(exportScene) + fixReflectedMeshWinding(exportScene) } - const compiled = compileShells - ? await compileSemanticPrintShellWithManifold(exportScene, nodes) - : null - try { - const printSource = compiled ? (compiled.scene ?? new THREE.Group()) : exportScene - const printOptions = { - scale, - compiled: compiled?.status === 'compiled', - indexedTopology: compiled?.backend === 'manifold-3d', + ensurePositionAttributes(exportScene) + + if (isPrintFormat) { + const printFormat = format === 'print-3mf' ? '3mf' : 'stl' + const scale = options.printScale ?? 100 + const compileShells = printContent === 'structure' + const minimumFeatureMm = compileShells ? options.printMinimumFeatureMm : undefined + if (options.printScope === 'levels') { + const plinth = + options.printBase === 'plinth' + ? { + marginMm: options.printPlinthMarginMm ?? 2, + thicknessMm: options.printPlinthThicknessMm ?? 2, + } + : undefined + const { data, report } = await exportSceneLevelsForPrint(exportScene, nodes, { + scale, + format: printFormat, + plinth, + minimumFeatureMm, + compileShells, + compileShell: compileShells ? compileSemanticPrintShellWithManifold : undefined, + }) + const blob = new Blob([data], { + type: printFormat === '3mf' ? 'model/3mf' : 'application/zip', + }) + return finishArtifact( + blob, + `print_levels_1-${scale}_${date}.${printFormat === '3mf' ? '3mf' : 'zip'}`, + options.download, + report, + ) + } + if (options.printBase === 'plinth') { + throw new Error('Plinth generation is available only for per-level print packages.') } - const output = - printFormat === '3mf' - ? exportSceneToPrint3mf(printSource, printOptions) - : exportSceneToPrintStl(printSource, printOptions) - let report = compiled - ? mergePrintExportDiagnostics( - output.report, - compiled.diagnostics, - new Set(['compiler_pending']), + const compiled = compileShells + ? await compileSemanticPrintShellWithManifold(exportScene, nodes) + : null + try { + const printSource = compiled ? (compiled.scene ?? new THREE.Group()) : exportScene + const printOptions = { + scale, + compiled: compiled?.status === 'compiled', + indexedTopology: compiled?.backend === 'manifold-3d', + } + const output = + printFormat === '3mf' + ? exportSceneToPrint3mf(printSource, printOptions) + : exportSceneToPrintStl(printSource, printOptions) + let report = compiled + ? mergePrintExportDiagnostics( + output.report, + compiled.diagnostics, + new Set(['compiler_pending']), + ) + : output.report + if (compiled) { + report = applySemanticPrintFeatureThickness( + report, + nodes, + compiled.sourceNodeIds, + minimumFeatureMm, ) - : output.report - if (compiled) { - report = applySemanticPrintFeatureThickness( + } + const { buffer } = output + const blob = new Blob([buffer], { + type: printFormat === '3mf' ? 'model/3mf' : 'model/stl', + }) + return finishArtifact( + blob, + `print_model_1-${scale}_${date}.${printFormat}`, + options.download, report, - nodes, - compiled.sourceNodeIds, - minimumFeatureMm, ) + } finally { + if (compiled?.scene) disposeObject3DResources(compiled.scene) } - const { buffer } = output - const blob = new Blob([buffer], { - type: printFormat === '3mf' ? 'model/3mf' : 'model/stl', - }) - return finishArtifact( - blob, - `print_model_1-${scale}_${date}.${printFormat}`, - options.download, - report, - ) - } finally { - if (compiled?.scene) disposeObject3DResources(compiled.scene) } - } - if (format === 'stl') { - const exporter = new STLExporter() - const result = exporter.parse(exportScene, { binary: true }) - const blob = new Blob([result], { type: 'model/stl' }) - return finishArtifact(blob, `model_${date}.stl`, options.download) - } + if (format === 'stl') { + const exporter = new STLExporter() + const result = exporter.parse(exportScene, { binary: true }) + const blob = new Blob([result], { type: 'model/stl' }) + return finishArtifact(blob, `model_${date}.stl`, options.download) + } - if (format === 'obj') { - const exporter = new OBJExporter() - const result = exporter.parse(exportScene) - const blob = new Blob([result], { type: 'model/obj' }) - return finishArtifact(blob, `model_${date}.obj`, options.download) - } + if (format === 'obj') { + const exporter = new OBJExporter() + const result = exporter.parse(exportScene) + const blob = new Blob([result], { type: 'model/obj' }) + return finishArtifact(blob, `model_${date}.obj`, options.download) + } - return null + return null + } finally { + prepared.dispose() + } } finally { useViewer.getState().setExporting(false) } @@ -208,9 +241,10 @@ function finishArtifact( filename: string, download: boolean | undefined, metadata?: unknown, + warnings?: readonly string[], ): ModelExportArtifact { if (download !== false) downloadBlob(blob, filename) - return { blob, filename, metadata } + return { blob, filename, metadata, warnings: warnings?.length ? warnings : undefined } } function downloadBlob(blob: Blob, filename: string) { diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index f05e375959..953a88b00a 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -94,6 +94,7 @@ const LOOK_SENSITIVITY = 0.002 // constant is an exponential approach rate, not a linear acceleration. const DRONE_SPEED = 7 const DRONE_RUN_MULTIPLIER = 3 +const DRONE_SLOW_MULTIPLIER = 0.2 const DRONE_SMOOTHING = 12 const CONTROLLER_CENTER_FROM_EYE = 0.85 const DOOR_INTERACTION_DISTANCE = 2.5 @@ -675,6 +676,7 @@ export const FirstPersonControls = () => { const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1) const crouchKeyRef = useRef(false) const droneAscendKeyRef = useRef(false) + const droneSlowKeyRef = useRef(false) const droneDescendKeyRef = useRef(false) const droneVelocityRef = useRef(new Vector3()) const suspendRef = useRef(false) @@ -1164,10 +1166,15 @@ export const FirstPersonControls = () => { // Shutter hold: the shot is rendering β€” a mouse twitch must not pan it. if (useEditor.getState().captureShutterHold) return - yawRef.current -= e.movementX * LOOK_SENSITIVITY + const lookSensitivity = + LOOK_SENSITIVITY * + (useEditor.getState().firstPersonMovementMode === 'drone' && droneSlowKeyRef.current + ? DRONE_SLOW_MULTIPLIER + : 1) + yawRef.current -= e.movementX * lookSensitivity pitchRef.current = Math.max( -(Math.PI / 2 - 0.05), - Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * LOOK_SENSITIVITY), + Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * lookSensitivity), ) } @@ -1280,6 +1287,10 @@ export const FirstPersonControls = () => { event.preventDefault() event.stopPropagation() if (!suspendRef.current) droneAscendKeyRef.current = true + } else if ((event.code === 'AltLeft' || event.code === 'AltRight') && isDroneMode) { + event.preventDefault() + event.stopPropagation() + if (!suspendRef.current) droneSlowKeyRef.current = true } else if (event.code === 'Escape') { event.preventDefault() event.stopPropagation() @@ -1336,6 +1347,9 @@ export const FirstPersonControls = () => { if (event.code === 'KeyE' && !suspendRef.current) { droneAscendKeyRef.current = false } + if ((event.code === 'AltLeft' || event.code === 'AltRight') && !suspendRef.current) { + droneSlowKeyRef.current = false + } applyMovementKey(event, false) } @@ -1344,6 +1358,7 @@ export const FirstPersonControls = () => { crouchKeyRef.current = false droneAscendKeyRef.current = false droneDescendKeyRef.current = false + droneSlowKeyRef.current = false } } @@ -1617,7 +1632,14 @@ export const FirstPersonControls = () => { if (droneDesiredVelocity.lengthSq() > 0) { droneDesiredVelocity .normalize() - .multiplyScalar(DRONE_SPEED * (movement.run ? DRONE_RUN_MULTIPLIER : 1)) + .multiplyScalar( + DRONE_SPEED * + (droneSlowKeyRef.current + ? DRONE_SLOW_MULTIPLIER + : movement.run + ? DRONE_RUN_MULTIPLIER + : 1), + ) } droneVelocityRef.current.lerp(droneDesiredVelocity, 1 - Math.exp(-step * DRONE_SMOOTHING)) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 0e0fa9493e..0e28a037f8 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -90,6 +90,7 @@ import { worldToFloorplanLocalPoint, } from '../../lib/floorplan' import { resolveGenericFloorplanGridEventPoint } from '../../lib/floorplan-grid-event-point' +import type { EditorGridEvent } from '../../lib/grid-event-presentation' import { groundHeightAt } from '../../lib/ground-surface' import { guideEmitter } from '../../lib/guide-events' import { measurementHint, parseMeasurement } from '../../lib/measurement-parser' @@ -150,6 +151,7 @@ import { isBoxSelectPointerSuppressed, markBoxSelectHandled, } from '../tools/select/box-select-state' +import { marqueePolygon } from '../tools/select/marquee-footprint' import { type Point2 as MarqueePoint2, polygonsIntersect as marqueePolygonsIntersect, @@ -865,7 +867,8 @@ function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement | { start?: unknown; end?: unknown; polygon?: unknown } | undefined if (!node) continue - const { start, end, polygon } = node + const { start, end } = node + const polygon = marqueePolygon(node) if (isMarqueeVec2(start) && isMarqueeVec2(end)) { dataTested.add(id) if (marqueeSegmentIntersectsPolygon(start, end, planQuad)) hitIdsFromData.add(id) @@ -8964,12 +8967,29 @@ export function FloorplanPanel({ const groundY = groundHeightAt(worldX, worldZ, floorplanGridWorldY) const worldY = groundY ?? floorplanGridWorldY const localY = groundY === null ? floorplanGridLocalY : groundY - buildingPosition[1] + const planScene = + nativeEvent.currentTarget.querySelector('[data-floorplan-scene]') + const screenMatrix = planScene?.getScreenCTM() - emitter.emit(`grid:${eventType}` as any, { + const gridEvent: EditorGridEvent = { nativeEvent: nativeEvent.nativeEvent as any, position: [worldX, worldY, worldZ], localPosition: [planPoint[0], localY, planPoint[1]], - }) + screenProjection: screenMatrix + ? { + pointer: [nativeEvent.clientX, nativeEvent.clientY], + localToScreen: [ + screenMatrix.a, + screenMatrix.b, + screenMatrix.c, + screenMatrix.d, + screenMatrix.e, + screenMatrix.f, + ], + } + : undefined, + } + emitter.emit(`grid:${eventType}` as any, gridEvent) }, [buildingPosition, buildingRotationY, floorplanGridLocalY, floorplanGridWorldY], ) diff --git a/packages/editor/src/components/editor/group-actions.ts b/packages/editor/src/components/editor/group-actions.ts index d14e8775ac..e2c0d68765 100644 --- a/packages/editor/src/components/editor/group-actions.ts +++ b/packages/editor/src/components/editor/group-actions.ts @@ -40,9 +40,11 @@ import { collectParticipants, computeGroupBox, expandToComponent, + groupPlanBounds, levelFrame, - participantExtents, + planBoundsCenter, rotateGroupSnapshots, + rotatePlanBounds, translateGroupPatches, type Vec2, } from './group-transform-shared' @@ -104,46 +106,15 @@ export function startGroupPickUp( if (starts.length === 0) return false const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] - // Rest bounds in the level frame. Prefer the mounted meshes' world box - // (footprint-accurate), but fall back to the participant DATA when the - // meshes aren't up yet β€” Duplicate starts the pick-up synchronously after - // `createNodes`, one frame before the clones' renderers mount. const { inverse: frameInv } = levelFrame(levelId) const restBox = computeGroupBox(fullIds) - let minX = Number.POSITIVE_INFINITY - let minZ = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let maxZ = Number.NEGATIVE_INFINITY - if (restBox) { - const boxMin = restBox.min.clone().applyMatrix4(frameInv) - const boxMax = restBox.max.clone().applyMatrix4(frameInv) - minX = Math.min(boxMin.x, boxMax.x) - minZ = Math.min(boxMin.z, boxMax.z) - maxX = Math.max(boxMin.x, boxMax.x) - maxZ = Math.max(boxMin.z, boxMax.z) - } else { - const reach = (x: number, z: number) => { - minX = Math.min(minX, x) - minZ = Math.min(minZ, z) - maxX = Math.max(maxX, x) - maxZ = Math.max(maxZ, z) - } - for (const s of starts) { - if (s.kind === 'endpoint') { - reach(s.start[0], s.start[1]) - reach(s.end[0], s.end[1]) - } else if (s.kind === 'polygon') { - for (const [x, z] of s.polygon) { - reach(x, z) - } - } else { - reach(s.position[0], s.position[2]) - } - } - } - if (!Number.isFinite(minX)) return false + const startBounds = groupPlanBounds(restBox, starts, frameInv) + if (!startBounds) return false + // Mutable: mid-carry R/T re-seeds the footprint around the same pivot. + let restBounds = startBounds + let carriedRotation = 0 // Rotation pivot for mid-carry R/T; stable across the whole pick-up. - const restCenter: [number, number] = [(minX + maxX) / 2, (minZ + maxZ) / 2] + const restCenter = planBoundsCenter(restBounds) // Ground plane for the 3D surface: the meshes' base when available, floor // level otherwise. Placements live in the level frame, so both surfaces // resolve into it before measuring. @@ -157,7 +128,13 @@ export function startGroupPickUp( if (n && !movingIdSet.has(nid)) staticNodes[nid] = n } const candidates = collectAlignmentAnchors(staticNodes, '', levelId) - let restAnchors = bboxCornerAnchors('group-move', minX, minZ, maxX, maxZ) + let restAnchors = bboxCornerAnchors( + 'group-move', + restBounds.minX, + restBounds.minZ, + restBounds.maxX, + restBounds.maxZ, + ) // Cursor β†’ level-frame plan point, whichever surface the pointer is over. const ndc = new Vector2() @@ -274,18 +251,20 @@ export function startGroupPickUp( // the current delta β€” the carried group turns exactly like the idle // keyboard rotate, and the placement stays a single updateNodes. const rotateCarried = (direction: 1 | -1) => { - const rotated = rotateGroupSnapshots( - starts, - links, - { x: restCenter[0], z: restCenter[1] }, - -direction * (Math.PI / 4), - ) + const pivot = { x: restCenter[0], z: restCenter[1] } + const delta = -direction * (Math.PI / 4) + const rotated = rotateGroupSnapshots(starts, links, pivot, delta) starts = rotated.starts links = rotated.links - const ext = participantExtents(rotated.starts) - if (ext) { - restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ) - } + carriedRotation += delta + restBounds = rotatePlanBounds(startBounds, pivot, carriedRotation) + restAnchors = bboxCornerAnchors( + 'group-move', + restBounds.minX, + restBounds.minZ, + restBounds.maxX, + restBounds.maxZ, + ) sfxEmitter.emit('sfx:item-rotate') applyDelta(lastDelta?.[0] ?? 0, lastDelta?.[1] ?? 0) } diff --git a/packages/editor/src/components/editor/group-move-3d.ts b/packages/editor/src/components/editor/group-move-3d.ts index 967238dafe..3a26251e5d 100644 --- a/packages/editor/src/components/editor/group-move-3d.ts +++ b/packages/editor/src/components/editor/group-move-3d.ts @@ -32,9 +32,12 @@ import { collectParticipants, computeGroupBox, expandToComponent, + type GroupPlanBounds, + groupPlanBounds, levelFrame, - participantExtents, + planBoundsCenter, rotateGroupSnapshots, + rotatePlanBounds, translateGroupPatches, type Vec2, } from './group-transform-shared' @@ -88,6 +91,9 @@ export function armGroupMove3d(args: { affectedIds: AnyNodeId[] candidates: ReturnType restAnchors: ReturnType + startBounds: GroupPlanBounds + restBounds: GroupPlanBounds + rotation: number restCenter: Vec2 plane: Plane startLocal: Vector3 @@ -130,14 +136,14 @@ export function armGroupMove3d(args: { if (n && !movingIdSet.has(nid)) staticNodes[nid] = n } const candidates = collectAlignmentAnchors(staticNodes, '', levelId) - const boxMin = restBox.min.clone().applyMatrix4(frameInv) - const boxMax = restBox.max.clone().applyMatrix4(frameInv) + const restBounds = groupPlanBounds(restBox, starts, frameInv) + if (!restBounds) return null const restAnchors = bboxCornerAnchors( 'group-move', - Math.min(boxMin.x, boxMax.x), - Math.min(boxMin.z, boxMax.z), - Math.max(boxMin.x, boxMax.x), - Math.max(boxMin.z, boxMax.z), + restBounds.minX, + restBounds.minZ, + restBounds.maxX, + restBounds.maxZ, ) for (const id of affectedIds) { @@ -154,9 +160,11 @@ export function armGroupMove3d(args: { nodeId, handle: GROUP_MOVE_DRAG_LABEL, }) - // Rotation pivot for mid-drag R/T β€” the participant DATA extents' center. - const ext = participantExtents(starts) - const restCenter: Vec2 = ext ? [(ext.minX + ext.maxX) / 2, (ext.minZ + ext.maxZ) / 2] : [0, 0] + // Rotation pivot for mid-drag R/T β€” the START footprint's center, the same + // point the idle keyboard rotate and the rotate gizmos orbit. Fixed for the + // whole session: the snapshots are start placements, and `applyDelta` adds + // the live drag delta on top of them. + const restCenter = planBoundsCenter(restBounds) return { starts, @@ -164,6 +172,9 @@ export function armGroupMove3d(args: { affectedIds, candidates, restAnchors, + startBounds: restBounds, + restBounds, + rotation: 0, restCenter, plane, startLocal, @@ -245,18 +256,22 @@ export function armGroupMove3d(args: { // current delta β€” the carried group turns exactly like the idle keyboard // rotate, and the commit stays a single updateNodes. const rotateSession = (s: Session, direction: 1 | -1) => { - const rotated = rotateGroupSnapshots( - s.starts, - s.links, - { x: s.restCenter[0], z: s.restCenter[1] }, - -direction * (Math.PI / 4), - ) + const pivot = { x: s.restCenter[0], z: s.restCenter[1] } + const delta = -direction * (Math.PI / 4) + const rotated = rotateGroupSnapshots(s.starts, s.links, pivot, delta) s.starts = rotated.starts s.links = rotated.links - const ext = participantExtents(rotated.starts) - if (ext) { - s.restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ) - } + // Re-fit from the start footprint at the accumulated angle: rotating the + // previous axis-aligned fit would inflate the box every step. + s.rotation += delta + s.restBounds = rotatePlanBounds(s.startBounds, pivot, s.rotation) + s.restAnchors = bboxCornerAnchors( + 'group-move', + s.restBounds.minX, + s.restBounds.minZ, + s.restBounds.maxX, + s.restBounds.maxZ, + ) sfxEmitter.emit('sfx:item-rotate') applyDelta(s, s.lastDelta?.[0] ?? 0, s.lastDelta?.[1] ?? 0) } @@ -369,7 +384,22 @@ export function armGroupMove3d(args: { const onKeyDown = (e: KeyboardEvent) => { const key = e.key.toLowerCase() if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { - if (!session) return + // Armed but still under the drag threshold: engage first (exactly what + // the next pointer-move would do) so the rotation lands inside this + // session. Falling through to the global idle arm instead would write + // the scene behind snapshots already captured here, and the first + // `applyDelta` would republish them β€” undoing the rotation. + if (!session) { + session = engage() + if (!session) { + // No plane hit yet: swallow the chord and keep the gesture armed so + // the next pointer-move can still engage; the idle arm must not run + // behind the snapshots captured here. + e.preventDefault() + e.stopPropagation() + return + } + } e.preventDefault() e.stopPropagation() rotateSession(session, key === 'r' ? 1 : -1) diff --git a/packages/editor/src/components/editor/group-transform-shared.test.ts b/packages/editor/src/components/editor/group-transform-shared.test.ts index 5b741b45d7..ff9bedfd24 100644 --- a/packages/editor/src/components/editor/group-transform-shared.test.ts +++ b/packages/editor/src/components/editor/group-transform-shared.test.ts @@ -4,7 +4,10 @@ import { z } from 'zod' import { classifyParticipant, collectParticipants, + planBoundsCenter, rotateGroupPatches, + rotateGroupSnapshots, + rotatePlanBounds, translateGroupPatches, } from './group-transform-shared' @@ -40,6 +43,34 @@ function registerElevatorTestKind() { } as AnyNodeDefinition) } +// A level holding one of each rigid placement shape: an item ([x,y,z] rotation) +// and a column (numeric rotation). +function placedNodes() { + return { + building_test: { id: 'building_test', type: 'building', children: ['level_test'] }, + level_test: { + id: 'level_test', + type: 'level', + parentId: 'building_test', + children: ['item_chair', 'column_post'], + }, + item_chair: { + id: 'item_chair', + type: 'item', + parentId: 'level_test', + position: [1, 0, 3], + rotation: [0, 0.5, 0], + }, + column_post: { + id: 'column_post', + type: 'column', + parentId: 'level_test', + position: [4, 0, -1], + rotation: 1.25, + }, + } as unknown as Record +} + describe('group transform participants', () => { beforeAll(() => { registerBuildingScopedTestKind() @@ -362,6 +393,53 @@ describe('group transform participants', () => { expect(lampPatch.position).toEqual([3, 2.4, 2]) }) + test('translate patches carry the snapshot rotation for vec3 and scalar kinds', () => { + const { starts } = collectParticipants( + ['item_chair', 'column_post'], + placedNodes(), + 'level_test', + ) + const patches = Object.fromEntries(translateGroupPatches(starts, [], 1, 2)) + + expect(patches.item_chair).toEqual({ position: [2, 0, 5], rotation: [0, 0.5, 0] }) + expect(patches.column_post).toEqual({ position: [5, 0, 1], rotation: 1.25 }) + }) + + test('a mid-drag rotation survives the next translate re-publish', () => { + const { starts } = collectParticipants( + ['item_chair', 'column_post'], + placedNodes(), + 'level_test', + ) + // What a mid-drag R does: turn the snapshots, then re-apply the live delta. + const rotated = rotateGroupSnapshots(starts, [], { x: 0, z: 0 }, Math.PI / 2) + const patches = Object.fromEntries( + translateGroupPatches(rotated.starts, rotated.links, 1, 2), + ) as Record + + // Orbited 90Β° in the atan2 xβ†’z sense ((x, z) β†’ (-z, x)), then slid. + expect(patches.item_chair!.position[0]).toBeCloseTo(-2) + expect(patches.item_chair!.position[2]).toBeCloseTo(3) + expect(patches.column_post!.position[0]).toBeCloseTo(2) + expect(patches.column_post!.position[2]).toBeCloseTo(6) + // …and the facings turned with it instead of reverting to the pre-drag yaw. + expect((patches.item_chair!.rotation as number[])[1]).toBeCloseTo(0.5 - Math.PI / 2) + expect(patches.column_post!.rotation as number).toBeCloseTo(1.25 - Math.PI / 2) + }) + + test('rotating the plan bounds keeps the footprint centred on the pivot', () => { + const bounds = { minX: 0, minZ: 0, maxX: 4, maxZ: 2 } + const [pivotX, pivotZ] = planBoundsCenter(bounds) + const rotated = rotatePlanBounds(bounds, { x: pivotX, z: pivotZ }, Math.PI / 2) + + expect(rotated.minX).toBeCloseTo(1) + expect(rotated.maxX).toBeCloseTo(3) + expect(rotated.minZ).toBeCloseTo(-1) + expect(rotated.maxZ).toBeCloseTo(3) + expect(planBoundsCenter(rotated)[0]).toBeCloseTo(pivotX) + expect(planBoundsCenter(rotated)[1]).toBeCloseTo(pivotZ) + }) + test('supports legacy level-parented elevators already loaded in the editor', () => { const nodes = { building_test: { diff --git a/packages/editor/src/components/editor/group-transform-shared.ts b/packages/editor/src/components/editor/group-transform-shared.ts index 8e077474c8..408eb2cc65 100644 --- a/packages/editor/src/components/editor/group-transform-shared.ts +++ b/packages/editor/src/components/editor/group-transform-shared.ts @@ -387,12 +387,11 @@ export function rotateGroupSnapshots( return { starts: rotatedStarts, links: rotatedLinks } } +export type GroupPlanBounds = { minX: number; minZ: number; maxX: number; maxZ: number } + // Level-frame XZ extents of the participant DATA β€” the mesh-free sibling of -// `computeGroupBox`, used when meshes aren't mounted yet and to re-seed -// alignment anchors after a mid-drag rotation. -export function participantExtents( - starts: ParticipantStart[], -): { minX: number; minZ: number; maxX: number; maxZ: number } | null { +// `computeGroupBox`, used when meshes aren't mounted yet. +function participantExtents(starts: ParticipantStart[]): GroupPlanBounds | null { let minX = Number.POSITIVE_INFINITY let minZ = Number.POSITIVE_INFINITY let maxX = Number.NEGATIVE_INFINITY @@ -419,8 +418,73 @@ export function participantExtents( return { minX, minZ, maxX, maxZ } } +// The one footprint every group transform measures itself against: the +// selection's mounted meshes (world box, converted into the level frame) with +// the participant DATA extents as the fallback when the meshes aren't up yet +// (Duplicate picks up its clones a frame before their renderers mount). Anchor +// points alone sit metres inside a wide selection's real footprint, so a +// gesture that pivots on the data extents orbits a different point than the +// idle keyboard rotate and the rotate gizmos, which both use the mesh box. +export function groupPlanBounds( + box: Box3 | null, + starts: ParticipantStart[], + frameInv: Matrix4, +): GroupPlanBounds | null { + if (!box) return participantExtents(starts) + const min = box.min.clone().applyMatrix4(frameInv) + const max = box.max.clone().applyMatrix4(frameInv) + return { + minX: Math.min(min.x, max.x), + minZ: Math.min(min.z, max.z), + maxX: Math.max(min.x, max.x), + maxZ: Math.max(min.z, max.z), + } +} + +export const planBoundsCenter = (b: GroupPlanBounds): Vec2 => [ + (b.minX + b.maxX) / 2, + (b.minZ + b.maxZ) / 2, +] + +// Re-seed the footprint after a mid-gesture rotation by orbiting the box +// corners and re-fitting an axis-aligned box. Re-measuring the rotated DATA +// extents instead would slide the centre off the pivot the snapshots turned +// around, dragging the alignment anchors away from the group under the cursor. +export function rotatePlanBounds( + b: GroupPlanBounds, + center: { x: number; z: number }, + delta: number, +): GroupPlanBounds { + const cos = Math.cos(delta) + const sin = Math.sin(delta) + let minX = Number.POSITIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + const corners: Vec2[] = [ + [b.minX, b.minZ], + [b.maxX, b.minZ], + [b.maxX, b.maxZ], + [b.minX, b.maxZ], + ] + for (const [x, z] of corners) { + const dx = x - center.x + const dz = z - center.z + const rx = center.x + dx * cos - dz * sin + const rz = center.z + dx * sin + dz * cos + minX = Math.min(minX, rx) + minZ = Math.min(minZ, rz) + maxX = Math.max(maxX, rx) + maxZ = Math.max(maxZ, rz) + } + return { minX, minZ, maxX, maxZ } +} + // Rigid group slide: shift every participant (and each linked neighbour's -// shared endpoint) by the same level-frame XZ delta. Y and rotations untouched. +// shared endpoint) by the same level-frame XZ delta. Y is untouched; the +// snapshot's rotation rides along because a mid-gesture R/T turns the +// SNAPSHOTS β€” dropping it here would republish (and commit) the pre-rotation +// facing, orbiting the layout while every member keeps its old bearing. export function translateGroupPatches( starts: ParticipantStart[], links: LinkedNeighbor[], @@ -437,7 +501,10 @@ export function translateGroupPatches( if (s.holes) patch.holes = s.holes.map((hole) => hole.map(shift)) patches.push([s.id, patch]) } else { - patches.push([s.id, { position: [s.position[0] + dx, s.position[1], s.position[2] + dz] }]) + const position: Vec3 = [s.position[0] + dx, s.position[1], s.position[2] + dz] + const rotation = + s.kind === 'vec3' ? ([s.rotation[0], s.rotation[1], s.rotation[2]] as Vec3) : s.rotation + patches.push([s.id, { position, rotation }]) } } for (const l of links) { diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx index db2dd92aa0..727c75f948 100644 --- a/packages/editor/src/components/editor/handles/handle-arrow.tsx +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -1,9 +1,8 @@ 'use client' import { type Cursor, emitter } from '@pascal-app/core' -import { markPureRaycast } from '@pascal-app/viewer' import type { ThreeEvent } from '@react-three/fiber' -import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { type ReactNode, useEffect, useMemo, useRef } from 'react' import { BoxGeometry, type BufferGeometry, @@ -31,13 +30,10 @@ import useEditor from '../../../store/use-editor' // (`wall:move` for openings, `grid:move` for free movers), freezing the drag. // Make every handle hit area inert for the duration; the indicator mesh still // renders (it's already NO_RAYCAST + depthTest off) so the grip stays visible. -export const hitAreaRaycast = markPureRaycast(function hitAreaRaycast( - this: Mesh, - raycaster: Raycaster, - intersects: Intersection[], -): void { +export function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void { + if (useEditor.getState().placementDragMode) return Mesh.prototype.raycast.call(this, raycaster, intersects) -}) +} export const ARROW_SCALE = 0.65 export const ARROW_COLOR = '#8381ed' @@ -283,6 +279,44 @@ export function createMoveCrossHandleGeometry() { return merged } +export function createArrowHitAreaGeometry() { + const length = CHEVRON_MAX_X - CHEVRON_MIN_X + HIT_AREA_MARGIN * 2 + const centerX = (CHEVRON_MIN_X + CHEVRON_MAX_X) / 2 + const geometry = new CylinderGeometry( + CHEVRON_HALF_WIDTH + HIT_AREA_MARGIN, + CHEVRON_HALF_WIDTH + HIT_AREA_MARGIN, + length, + 16, + ) + geometry.rotateZ(-Math.PI / 2) + geometry.translate(centerX, 0, 0) + geometry.computeBoundingSphere() + return geometry +} + +// The move cross is a plus, not a disk. A disk-shaped hit area fills the four +// corner gaps between the arms, so a neighbouring node sitting next to the +// selected node (a lamp by a door, a slab beside a wall) gets swallowed by the +// invisible grip and can't be picked. Wrap the visible arms instead: two flat +// arm boxes (length/width + margin) merged into a plus, leaving the corners +// empty so co-located neighbours stay selectable while the grip stays grabbable. +function createMoveCrossHitAreaGeometry() { + const armLength = (MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN) * 2 + const armWidth = (MOVE_CROSS_HEAD_HALF_WIDTH + HIT_AREA_MARGIN) * 2 + const armX = new BoxGeometry(armLength, HIT_AREA_THICKNESS, armWidth) + const armZ = new BoxGeometry(armWidth, HIT_AREA_THICKNESS, armLength) + const merged = mergeGeometries([armX, armZ], false) + if (!merged) { + armZ.dispose() + armX.computeBoundingSphere() + return armX + } + armX.dispose() + armZ.dispose() + merged.computeBoundingSphere() + return merged +} + function createPlusHandleGeometry() { const shape = new Shape() shape.moveTo(-PLUS_HALF_WIDTH, PLUS_HALF_LENGTH) @@ -331,44 +365,6 @@ function createPlusHitAreaGeometry() { return merged } -export function createArrowHitAreaGeometry() { - const length = CHEVRON_MAX_X - CHEVRON_MIN_X + HIT_AREA_MARGIN * 2 - const centerX = (CHEVRON_MIN_X + CHEVRON_MAX_X) / 2 - const geometry = new CylinderGeometry( - CHEVRON_HALF_WIDTH + HIT_AREA_MARGIN, - CHEVRON_HALF_WIDTH + HIT_AREA_MARGIN, - length, - 16, - ) - geometry.rotateZ(-Math.PI / 2) - geometry.translate(centerX, 0, 0) - geometry.computeBoundingSphere() - return geometry -} - -// The move cross is a plus, not a disk. A disk-shaped hit area fills the four -// corner gaps between the arms, so a neighbouring node sitting next to the -// selected node (a lamp by a door, a slab beside a wall) gets swallowed by the -// invisible grip and can't be picked. Wrap the visible arms instead: two flat -// arm boxes (length/width + margin) merged into a plus, leaving the corners -// empty so co-located neighbours stay selectable while the grip stays grabbable. -function createMoveCrossHitAreaGeometry() { - const armLength = (MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN) * 2 - const armWidth = (MOVE_CROSS_HEAD_HALF_WIDTH + HIT_AREA_MARGIN) * 2 - const armX = new BoxGeometry(armLength, HIT_AREA_THICKNESS, armWidth) - const armZ = new BoxGeometry(armWidth, HIT_AREA_THICKNESS, armLength) - const merged = mergeGeometries([armX, armZ], false) - if (!merged) { - armZ.dispose() - armX.computeBoundingSphere() - return armX - } - armX.dispose() - armZ.dispose() - merged.computeBoundingSphere() - return merged -} - export function createRotateArrowHitAreaGeometry() { const halfSweep = ROTATE_HANDLE_HALF_SWEEP + HIT_AREA_MARGIN / ROTATE_HANDLE_RADIUS const geometry = new TorusGeometry( @@ -440,7 +436,29 @@ function createHandleArrowHitGeometry(shape: HandleArrowShape, round = false) { } let sharedHitAreaMaterial: MeshBasicNodeMaterial | null = null -let sharedHitAreaMaterialRefs = 0 +const sharedHandleGeometries = new Map() +const sharedHandleHitGeometries = new Map() +const sharedHandleMaterials = new Map() + +function sharedHandleGeometry(shape: HandleArrowShape, thin: boolean, round: boolean) { + const key = `${shape}:${thin}:${round}` + let geometry = sharedHandleGeometries.get(key) + if (!geometry) { + geometry = createHandleArrowGeometry(shape, thin, round) + sharedHandleGeometries.set(key, geometry) + } + return geometry +} + +function sharedHandleHitGeometry(shape: HandleArrowShape, round: boolean) { + const key = `${shape}:${round}` + let geometry = sharedHandleHitGeometries.get(key) + if (!geometry) { + geometry = createHandleArrowHitGeometry(shape, round) + sharedHandleHitGeometries.set(key, geometry) + } + return geometry +} function createInvisibleHitAreaMaterial() { return new MeshBasicNodeMaterial({ @@ -455,23 +473,8 @@ function createInvisibleHitAreaMaterial() { } export function useInvisibleHitAreaMaterial(): MeshBasicNodeMaterial { - const materialRef = useRef(null) - if (!materialRef.current) { - sharedHitAreaMaterial ??= createInvisibleHitAreaMaterial() - materialRef.current = sharedHitAreaMaterial - } - useEffect(() => { - sharedHitAreaMaterialRefs += 1 - return () => { - sharedHitAreaMaterialRefs -= 1 - if (sharedHitAreaMaterialRefs <= 0 && sharedHitAreaMaterial) { - sharedHitAreaMaterial.dispose() - sharedHitAreaMaterial = null - sharedHitAreaMaterialRefs = 0 - } - } - }, []) - return materialRef.current + sharedHitAreaMaterial ??= createInvisibleHitAreaMaterial() + return sharedHitAreaMaterial } export function InvisibleHandleHitArea({ @@ -489,18 +492,6 @@ export function InvisibleHandleHitArea({ onPointerLeave: PointerHandler scale: number }) { - const ref = useRef(null) - useLayoutEffect(() => { - const mesh = ref.current - if (!mesh) return - // Subscribe synchronously so the next pointer event sees drag state before React renders. - const syncRaycast = () => { - mesh.raycast = useEditor.getState().placementDragMode ? NO_RAYCAST : hitAreaRaycast - } - syncRaycast() - return useEditor.subscribe(syncRaycast) - }, []) - return ( - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - side: DoubleSide, - transparent: true, - opacity: shape === 'corner-picker' ? 0.95 : 1, - depthTest: false, - depthWrite: shape !== 'corner-picker', - }), - [shape], - ) +function useHandleArrowMaterial(shape: HandleArrowShape, hover: boolean): MeshBasicNodeMaterial { + const key = `${shape}:${hover}` + let material = sharedHandleMaterials.get(key) + if (!material) { + material = new MeshBasicNodeMaterial({ + color: new Color(hover ? ARROW_HOVER_COLOR : ARROW_COLOR), + side: DoubleSide, + transparent: true, + opacity: shape === 'corner-picker' ? 0.95 : 1, + depthTest: false, + depthWrite: shape !== 'corner-picker', + }) + sharedHandleMaterials.set(key, material) + } + return material } function indicatorRenderOrder(shape: HandleArrowShape) { @@ -578,15 +570,9 @@ export function HandleArrow({ round = false, }: HandleArrowProps) { const visualShape = normalizeHandleArrowShape(shape, cursor) - const geometry = useMemo( - () => createHandleArrowGeometry(visualShape, thin, round), - [visualShape, thin, round], - ) - const hitGeometry = useMemo( - () => createHandleArrowHitGeometry(visualShape, round), - [visualShape, round], - ) - const indicatorMaterial = useHandleArrowMaterial(visualShape) + const geometry = sharedHandleGeometry(visualShape, thin, round) + const hitGeometry = sharedHandleHitGeometry(visualShape, round) + const indicatorMaterial = useHandleArrowMaterial(visualShape, hover) const hitMaterial = useInvisibleHitAreaMaterial() const rootRef = useRef(null) const rotation: [number, number, number] = placement.rotation @@ -598,9 +584,6 @@ export function HandleArrow({ const scale = (hover ? hoverScale : 1) * placement.baseScale const hitScale = visualShape === 'corner-picker' ? scale : placement.baseScale - useEffect(() => { - indicatorMaterial.color.set(hover ? ARROW_HOVER_COLOR : ARROW_COLOR) - }, [indicatorMaterial, hover]) useEffect(() => { const hideForCapture = () => { if (rootRef.current) rootRef.current.visible = false @@ -615,9 +598,6 @@ export function HandleArrow({ emitter.off('thumbnail:after-capture', restoreAfterCapture) } }, []) - useEffect(() => () => geometry.dispose(), [geometry]) - useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) - useEffect(() => () => indicatorMaterial.dispose(), [indicatorMaterial]) const handleEnter: PointerHandler = (event) => { event.stopPropagation() diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index d6544ee254..786f28299c 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -19,6 +19,7 @@ import { SceneEnvironment, useViewer, Viewer, + ViewerPresentations, } from '@pascal-app/viewer' import { memo, @@ -27,6 +28,7 @@ import { type ReactNode, useCallback, useEffect, + useLayoutEffect, useRef, useState, } from 'react' @@ -34,6 +36,11 @@ import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { useKeyboard } from '../../hooks/use-keyboard' +import { useSaveShortcut } from '../../hooks/use-save-shortcut' +import { + createLocalProjectPresentationPersistence, + type LocalProjectPresentationPersistence, +} from '../../lib/local-project-presentation-persistence' import { type ActivePaintMaterial, hasActivePaintMaterial } from '../../lib/material-paint' import { applySceneGraphToEditor, @@ -111,6 +118,8 @@ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8' const PAINT_CURSOR_BADGE_OFFSET_X = 14 const PAINT_CURSOR_BADGE_OFFSET_Y = 14 const SCENE_READY_FALLBACK_MS = 8000 +const PRESENTATION_PROJECT_NOT_RESTORED = Symbol('presentation-project-not-restored') +const useClientLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect type PaintCursorBadgeState = 'empty' | 'ready' | 'blocked' const recordEditorRender: ProfilerOnRenderCallback = (_id, _phase, actualDuration) => { if (PERF_OVERLAY_ENABLED) recordPerfSample('react-render', actualDuration) @@ -194,6 +203,12 @@ export interface EditorProps { // Persistence β€” defaults to localStorage when omitted onLoad?: () => Promise onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise + /** + * Cmd/Ctrl+S. Return true when the host handled the save (the community + * version checkpoint); anything else falls through to flushing the autosave, + * so the chord still saves when the host's control isn't mounted. + */ + onSaveShortcut?: () => boolean | undefined onDirty?: () => void onSaveStatusChange?: (status: SaveStatus) => void @@ -768,17 +783,17 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ isLoading, isFirstPersonMode, isStudioMode, - renderPaused, onThumbnailCapture, viewerSceneSlot, + presentationsReady, }: { isVersionPreviewMode: boolean isLoading: boolean isFirstPersonMode: boolean isStudioMode: boolean - renderPaused: boolean onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void viewerSceneSlot?: ReactNode + presentationsReady: boolean }) { // Studio mode is a clean render/snapshot surface β€” no selection or editing // affordances. It mirrors version-preview's chrome gating on the canvas. @@ -814,10 +829,11 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!(isLoading || noEditing) && } {isFirstPersonMode && } {isCaptureMode && } - + {!isFirstPersonMode && } + {presentationsReady ? : null} {!noEditing && viewerSceneSlot} ) @@ -1001,6 +1017,7 @@ const ViewerCanvas = memo(function ViewerCanvas({ sceneReadyKey, onSceneReadyChange, onThumbnailCapture, + presentationsReady, viewerSceneSlot, floorplanSceneSlot, disablePostFx = false, @@ -1014,6 +1031,7 @@ const ViewerCanvas = memo(function ViewerCanvas({ sceneReadyKey: number onSceneReadyChange: (ready: boolean) => void onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void + presentationsReady: boolean viewerSceneSlot?: ReactNode floorplanSceneSlot?: ReactNode disablePostFx?: boolean @@ -1145,10 +1163,10 @@ const ViewerCanvas = memo(function ViewerCanvas({ @@ -1229,6 +1247,7 @@ function EditorContent({ projectId, onLoad, onSave, + onSaveShortcut, onDirty, onSaveStatusChange, previewScene, @@ -1246,16 +1265,44 @@ function EditorContent({ }: EditorProps) { const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const isStudioMode = useEditor((s) => s.workspaceMode === 'studio') + const presentationProjectId = projectId ?? null + const presentationPersistenceRef = useRef(null) + const [restoredPresentationProjectId, setRestoredPresentationProjectId] = useState< + string | null | typeof PRESENTATION_PROJECT_NOT_RESTORED + >(PRESENTATION_PROJECT_NOT_RESTORED) + const presentationsReady = restoredPresentationProjectId === presentationProjectId + + useClientLayoutEffect(() => { + const persistence = createLocalProjectPresentationPersistence() + presentationPersistenceRef.current = persistence + return () => { + presentationPersistenceRef.current = null + persistence.dispose() + } + }, []) + + useClientLayoutEffect(() => { + const persistence = presentationPersistenceRef.current + if (!persistence) return + persistence.switchProject(presentationProjectId) + setRestoredPresentationProjectId(presentationProjectId) + }, [presentationProjectId]) useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode || isStudioMode }) - const { isLoadingSceneRef } = useAutoSave({ + const { isLoadingSceneRef, saveNow } = useAutoSave({ onSave, onDirty, onSaveStatusChange, isVersionPreviewMode, }) + const handleSaveShortcut = useCallback(() => { + if (onSaveShortcut?.() === true) return + saveNow() + }, [onSaveShortcut, saveNow]) + useSaveShortcut(handleSaveShortcut) + const [isSceneLoading, setIsSceneLoading] = useState(false) const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) // A failed `onLoad` is shown as an error with a retry, never as an empty @@ -1298,7 +1345,7 @@ function EditorContent({ } }, [projectId]) - // Load scene on mount (or when onLoad identity changes, e.g. project switch) + // Load on mount, project switches, and explicit retry attempts. useEffect(() => { void sceneLoadAttempt let cancelled = false @@ -1468,6 +1515,7 @@ function EditorContent({ + {presentationsReady ? : null} ) @@ -1481,6 +1529,7 @@ function EditorContent({ isVersionPreviewMode={isVersionPreviewMode} onSceneReadyChange={handleSceneReadyChange} onThumbnailCapture={onThumbnailCapture} + presentationsReady={presentationsReady} sceneReadyKey={sceneReadyKey} showLoader={showLoader} viewerSceneSlot={viewerSceneSlot} diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 7c79ef0cf5..84fc32a9c9 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -11,6 +11,7 @@ import { getSelectableKinds, type ItemNode, isRegistrySelectable, + isSelectionHighlightEnabled, type NodeEvent, nodeRegistry, type RoofEvent, @@ -2154,6 +2155,7 @@ const SelectionMaterialSync = () => { const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode) + const registryVersion = useRegistryVersion() const geometryRevision = useViewer((s) => s.geometryRevision) const activeHighlightKindsRef = useRef(new Map()) const highlightedMaterialsRef = useRef( @@ -2176,6 +2178,10 @@ const SelectionMaterialSync = () => { continue } + if (node && !isSelectionHighlightEnabled(node.type)) { + continue + } + const rootObject = sceneRegistry.nodes.get(id) if (!rootObject) { continue @@ -2231,6 +2237,7 @@ const SelectionMaterialSync = () => { }, []) useEffect(() => { + void registryVersion void geometryRevision const nextHighlightKinds = new Map() @@ -2246,6 +2253,7 @@ const SelectionMaterialSync = () => { syncSelectionMaterials() }, [ geometryRevision, + registryVersion, hoverHighlightMode, hoveredId, previewSelectedIds, @@ -2312,11 +2320,13 @@ const EditorOutlinerSync = () => { const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) const geometryRevision = useViewer((s) => s.geometryRevision) + const registryVersion = useRegistryVersion() const outliner = useViewer((s) => s.outliner) const nodes = useScene((s) => s.nodes) useEffect(() => { void geometryRevision + void registryVersion let idsToHighlight: string[] = [] // 1. Determine what should be highlighted based on Phase @@ -2351,7 +2361,8 @@ const EditorOutlinerSync = () => { // 2. Sync with the imperative outliner arrays (mutate in place to keep references) outliner.selectedObjects.length = 0 for (const id of idsToHighlight) { - if (!nodes[id as AnyNodeId]) continue + const node = nodes[id as AnyNodeId] + if (!(node && isSelectionHighlightEnabled(node.type))) continue const obj = sceneRegistry.nodes.get(id) if (obj?.parent) outliner.selectedObjects.push(obj) } @@ -2362,14 +2373,25 @@ const EditorOutlinerSync = () => { useViewer.setState({ hoveredId: null }) } else { const hoveredNode = nodes[hoveredId as AnyNodeId] - const obj = - hoveredNode?.type === 'roof-segment' - ? (getHoveredRoofSegmentOutlineProxy(hoveredId) ?? sceneRegistry.nodes.get(hoveredId)) - : sceneRegistry.nodes.get(hoveredId) - if (obj?.parent) outliner.hoveredObjects.push(obj) + if (hoveredNode && isSelectionHighlightEnabled(hoveredNode.type)) { + const obj = + hoveredNode.type === 'roof-segment' + ? (getHoveredRoofSegmentOutlineProxy(hoveredId) ?? sceneRegistry.nodes.get(hoveredId)) + : sceneRegistry.nodes.get(hoveredId) + if (obj?.parent) outliner.hoveredObjects.push(obj) + } } } - }, [geometryRevision, phase, previewSelectedIds, selection, hoveredId, outliner, nodes]) + }, [ + geometryRevision, + registryVersion, + phase, + previewSelectedIds, + selection, + hoveredId, + outliner, + nodes, + ]) return null } diff --git a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx index f97b8d98f6..226df5742b 100644 --- a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx +++ b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx @@ -159,6 +159,7 @@ const CAMERA_NAV_HINTS: Record { const mergedMesh = group.getObjectByName('merged-stair') const segmentsWrapper = group.getObjectByName('segments-wrapper') const isActive = activeStairIds.has(stairId) + // A straight stair with no segment children has an empty wrapper, so + // edit mode would hide the merged body and leave nothing on screen. + const isEditable = !isCurved && (stairNode?.children?.length ?? 0) > 0 - if (mergedMesh) mergedMesh.visible = !(isActive || isCurved) - if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved + if (mergedMesh) mergedMesh.visible = !((isActive && isEditable) || isCurved) + if (segmentsWrapper) segmentsWrapper.visible = isActive && isEditable if (stairNode?.children?.length) { const wasActive = prevActiveStairIds.current.has(stairId) diff --git a/packages/editor/src/components/tools/item/placement-strategies.test.ts b/packages/editor/src/components/tools/item/placement-strategies.test.ts index b01048dd45..73006ceb06 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.test.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { BlockNode, + type GridEvent, ItemNode, type LevelNode, type NodeEvent, @@ -9,7 +10,7 @@ import { type WallNode, } from '@pascal-app/core' import { BufferGeometry, Mesh, MeshBasicMaterial, type Object3D, Vector3 } from 'three' -import { faceHostStrategy, wallStrategy } from './placement-strategies' +import { faceHostStrategy, floorStrategy, wallStrategy } from './placement-strategies' import type { PlacementContext, SpatialValidators } from './placement-types' import { registerTestBlockFaceHost } from './test-face-host' @@ -455,3 +456,39 @@ describe('wallStrategy.move', () => { expect(result.cursorPosition[1]).toBeCloseTo(0.4 + 0.05, 6) }) }) + +describe('floorStrategy.move', () => { + function makeGridEvent(x: number, y: number, z: number): GridEvent { + return { + position: [x, y, z], + localPosition: [x, y, z], + nativeEvent: {} as GridEvent['nativeEvent'], + } + } + + test('follows the live grid Y so a raised placement keeps its height', () => { + const context = floorItemContext() + context.gridPosition.set(0, 0.9, 0) + + const result = floorStrategy.move(context, makeGridEvent(1.25, 0.9, 2.25)) + if (!result) throw new Error('expected a placement result') + + expect(result.gridPosition[1]).toBe(0.9) + expect(result.cursorPosition[1]).toBe(0.9) + }) + + // `detachItemSurfaceToFloor` zeroes the grid Y when an item is taken off a + // host; the floor path must honour that instead of a Y frozen at drag start, + // or the item commits floating at the shelf's height. + test('drops to the level plane once un-hosting zeroes the grid Y', () => { + const context = floorItemContext() + context.gridPosition.set(0, 0, 0) + + const result = floorStrategy.move(context, makeGridEvent(1.25, 0.9, 2.25)) + if (!result) throw new Error('expected a placement result') + + expect(result.gridPosition[1]).toBe(0) + expect(result.cursorPosition[1]).toBe(0) + expect(result.nodeUpdate?.position?.[1]).toBe(0) + }) +}) diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index ccc793d417..b6a66b393e 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -6,6 +6,7 @@ import { type CeilingEvent, collectAlignmentAnchors, emitter, + findLevelAncestorId, type GridEvent, getScaledDimensions, type ItemEvent, @@ -193,6 +194,27 @@ function getGridAlignedPreviewNode(item: ItemNode): ItemNode { } } +/** + * Building-local Y of the storey the floor-path ghost belongs to. + * + * The cursor group is mounted inside ToolManager's building-local group, which + * carries no per-floor elevation, while every floor-path position (grid + * position, `getFloorVisualPosition`) is LEVEL-local β€” so on an upper storey the + * wireframe and its dimension labels render a floor too low. The wall / ceiling + * / item-surface paths don't need this: they convert a world hit through + * `worldToBuildingLocal`, which already carries the storey. + * + * Read off the level mesh (same source as `LevelOffsetGroup`) rather than the + * stored elevation so the ghost also follows the exploded-view lerp. + */ +function getPlacementLevelY(draft: ItemNode | null | undefined): number { + const levelId = + (draft ? findLevelAncestorId(draft.id, useScene.getState().nodes) : null) ?? + useViewer.getState().selection.levelId + const levelMesh = levelId ? sceneRegistry.nodes.get(levelId as AnyNodeId) : null + return levelMesh ? levelMesh.position.y : 0 +} + // Shared materials for placement cursor - we just change colors, not swap materials // Note: EdgesGeometry doesn't work with dashed lines, so using solid lines const edgeMaterial = new LineBasicNodeMaterial({ @@ -521,7 +543,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!asset.attachTo && placementState.current.surface === 'floor') { gridPosition.current.y = 0 if (cursorGroupRef.current) { - cursorGroupRef.current.position.y = 0 + cursorGroupRef.current.position.y = getPlacementLevelY(draftNode.current) } } @@ -731,7 +753,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Init draft ---- configRef.current.initDraft(gridPosition.current) - const floorAuthoredY = draftNode.current?.position[1] ?? 0 const preserveDragOffset = configRef.current.preserveDragOffset === true // The host the item was grabbed from + its pre-drag host-local position. // Each surface's grab anchor preserves the grab offset only on THAT host, @@ -890,7 +911,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } } else if (cursorGroupRef.current) { + // No registered mesh yet (a just-created draft renders next tick), so + // fall back to the level-local grid position lifted onto its storey. cursorGroupRef.current.position.copy(gridPosition.current) + cursorGroupRef.current.position.y += getPlacementLevelY(draftNode.current) cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0 } } @@ -1064,9 +1088,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea useAlignmentGuides.getState().clear() } + // `result.gridPosition[1]` is the LIVE `gridPosition.current.y` β€” seeded + // from the draft's authored Y by `initDraft` (so a block-face / raised + // construction-plane item keeps its height) and zeroed by + // `detachItemSurfaceToFloor` / `faceHostStrategy.leave` when the item + // comes back down. Freezing it at drag start instead left an item taken + // off a shelf floating at the shelf's height. let gridPos: [number, number, number] = [ result.gridPosition[0] + alignX, - floorAuthoredY, + result.gridPosition[1], result.gridPosition[2] + alignZ, ] frozenSupportSlabIdRef.current = undefined @@ -1104,7 +1134,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!draft && asset.attachTo) { cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo) } - cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2]) + cursorGroupRef.current.position.set( + cursorPosition[0], + cursorPosition[1] + getPlacementLevelY(draft), + cursorPosition[2], + ) // Floor items only rotate on Y; keep the preview box (and the live // transform the 2D floorplan mirrors) aligned with the draft's // rotation. Without this the box stays at its seed rotation until a @@ -1684,7 +1718,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea levelId ? { parentId: levelId } : undefined, ) if (cursorGroupRef.current) { - cursorGroupRef.current.position.set(...floorVisualPosition) + cursorGroupRef.current.position.set( + floorVisualPosition[0], + floorVisualPosition[1] + getPlacementLevelY(draftNode.current), + floorVisualPosition[2], + ) } const draft = draftNode.current @@ -2271,8 +2309,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draft.position = [x, gridPosition.current.y, z] if (cursorGroupRef.current) { if (surface === 'floor') { + const visual = getFloorVisualPosition([x, gridPosition.current.y, z]) cursorGroupRef.current.position.set( - ...getFloorVisualPosition([x, gridPosition.current.y, z]), + visual[0], + visual[1] + getPlacementLevelY(draft), + visual[2], ) } else { cursorGroupRef.current.position.x = x @@ -2601,6 +2642,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // moving existing node has no draft here, so the grid reads that case straight // off the node's mesh. Cleared when idle. const surfaceNormalRef = useRef(new Vector3(0, 1, 0)) + const surfaceWorldPointRef = useRef(new Vector3()) const facingForwardRef = useRef(new Vector3(0, 0, 1)) const facingQuatRef = useRef(new Quaternion()) const ghostSurfaceQuatRef = useRef(new Quaternion()) @@ -2642,13 +2684,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const fwd = facingForwardRef.current.copy(n) if (fwd.lengthSq() > 1e-6) facingYaw = Math.atan2(fwd.x, fwd.z) // The forward triangle is a floor aid; drop it to the building-local floor - // under the hosted plane. - facingY = 0 + // under the hosted plane β€” the storey's floor, not world ground. + facingY = getPlacementLevelY(draftNode.current) } else { ghost.getWorldQuaternion(ghostSurfaceQuatRef.current) resolveItemPlacementSurfaceNormal(surf, ghostSurfaceQuatRef.current, null, n) } - publishPlacementSurface(ghost.position, n) + // `publishPlacementSurface` is a WORLD-space contract (the grid reads it in + // world space), but the ghost lives in the building-local tool group. + publishPlacementSurface(ghost.getWorldPosition(surfaceWorldPointRef.current), n) if (shape.depth > 0) { useFacingPose.getState().set({ @@ -2716,7 +2760,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.z, ]) mesh.position.y = visualPosition[1] - cursorGroupRef.current.position.y = visualPosition[1] + cursorGroupRef.current.position.y = + visualPosition[1] + getPlacementLevelY(draftNode.current) } } else if (placementState.current.surface === 'block-face') { const rotation = draftNode.current.rotation diff --git a/packages/editor/src/components/tools/registered-tool-install-lifecycle.test.tsx b/packages/editor/src/components/tools/registered-tool-install-lifecycle.test.tsx new file mode 100644 index 0000000000..0883882156 --- /dev/null +++ b/packages/editor/src/components/tools/registered-tool-install-lifecycle.test.tsx @@ -0,0 +1,256 @@ +import { expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeDefinition, + emitter, + type GridEvent, + loadPlugin, + nodeRegistry, + registerNode, + type SceneApi, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { act, create } from '@react-three/test-renderer' +import { type ComponentType, useEffect } from 'react' +import { z } from 'zod' +import { + FLOORPLAN_NODE_EXTENSION_KEY, + type FloorplanToolContext, +} from '../../lib/floorplan/floorplan-extension' +import useEditor from '../../store/use-editor' +import useFloorplanMode from '../../store/use-floorplan-mode' +import useInteractionScope from '../../store/use-interaction-scope' +import { FloorplanRegisteredToolLayer } from '../editor-2d/floorplan-registered-tool-layer' +import { useRegistryToolContext } from './registry-tool-context' +import { ToolManager } from './tool-manager' + +const PLUGIN_ID = 'test:registered-placement-plugin' +const PLUGIN_KIND = 'test:registered-placement' +const BUILTIN_KIND = 'test:builtin-placement' +const GRID_EVENT = { + position: [1, 0, 2], + localPosition: [1, 0, 2], + nativeEvent: {} as GridEvent['nativeEvent'], +} satisfies GridEvent + +type View = '2d' | '3d' +type Lifecycle = { + mounts: Record + unmounts: Record +} +type MountedHost = { unmount: () => Promise } + +const node = (id: string, type: string): AnyNode => + ({ id, type, object: 'node', parentId: null, visible: true, metadata: {} }) as AnyNode + +function usePlacementGesture(sceneApi: SceneApi, kind: string, view: View, lifecycle: Lifecycle) { + useEffect(() => { + lifecycle.mounts[view] += 1 + const draft = node(`${kind}:${view}:pending`, kind) + const begin = () => { + useInteractionScope.getState().begin({ + kind: 'placing', + node: draft, + nodeId: draft.id, + nodeType: draft.type, + view, + pressDrag: true, + driver: 'registry-tool', + }) + } + const commit = () => { + const scope = useInteractionScope.getState().scope + if (scope.kind !== 'placing' || scope.nodeId !== draft.id) return + sceneApi.upsert(draft) + useInteractionScope + .getState() + .endIf((active) => active.kind === 'placing' && active.nodeId === draft.id) + } + emitter.on('grid:pointerdown', begin) + emitter.on('grid:click', commit) + return () => { + emitter.off('grid:pointerdown', begin) + emitter.off('grid:click', commit) + useInteractionScope + .getState() + .endIf((active) => active.kind === 'placing' && active.nodeId === draft.id) + lifecycle.unmounts[view] += 1 + } + }, [kind, lifecycle, sceneApi, view]) +} + +function definition(kind: string, lifecycle: Lifecycle): AnyNodeDefinition { + const Tool3D = () => { + usePlacementGesture(useRegistryToolContext().sceneApi, kind, '3d', lifecycle) + return null + } + const Tool2D = ({ sceneApi }: FloorplanToolContext) => { + usePlacementGesture(sceneApi, kind, '2d', lifecycle) + return null + } + const load3D = async () => ({ default: Tool3D as ComponentType }) + const load2D = async () => ({ default: Tool2D as ComponentType }) + + return { + kind, + schemaVersion: 1, + schema: z + .object({ + id: z.string(), + type: z.literal(kind), + object: z.literal('node'), + parentId: z.string().nullable(), + visible: z.boolean(), + metadata: z.record(z.string(), z.unknown()), + }) + .passthrough(), + category: 'utility', + defaults: () => ({}), + capabilities: {}, + tool: load3D, + extensions: { + [FLOORPLAN_NODE_EXTENSION_KEY]: { tool: load2D }, + }, + } as AnyNodeDefinition +} + +const hosts: Array<{ name: string; view: View; component: ComponentType }> = [ + { name: '3D ToolManager', view: '3d', component: ToolManager }, + { name: '2D FloorplanRegisteredToolLayer', view: '2d', component: FloorplanRegisteredToolLayer }, +] + +for (const host of hosts) { + test(`${host.name} cancels an uninstalled registered placement tool and requires explicit reactivation`, async () => { + const restoreRegistry = nodeRegistry._snapshot() + const previousScene = useScene.getState() + const previousHistory = useScene.temporal.getState() + const previousEditor = useEditor.getState() + const previousViewer = useViewer.getState() + const previousFloorplanMode = useFloorplanMode.getState() + const previousInteraction = useInteractionScope.getState() + const lifecycle: Lifecycle = { + mounts: { '2d': 0, '3d': 0 }, + unmounts: { '2d': 0, '3d': 0 }, + } + const persisted = node('test:registered-placement:persisted', PLUGIN_KIND) + const persistedExpected = node('test:registered-placement:persisted', PLUGIN_KIND) + const pluginPendingId = node(`${PLUGIN_KIND}:${host.view}:pending`, PLUGIN_KIND).id + const builtinPendingId = node(`${BUILTIN_KIND}:${host.view}:pending`, BUILTIN_KIND).id + let renderer: MountedHost | undefined + + try { + nodeRegistry._reset() + await loadPlugin({ + id: PLUGIN_ID, + apiVersion: 1, + nodes: [definition(PLUGIN_KIND, lifecycle)], + }) + registerNode(definition(BUILTIN_KIND, lifecycle)) + useScene.setState({ + nodes: { [persisted.id]: persisted }, + rootNodeIds: [persisted.id], + installedPlugins: [PLUGIN_ID], + hasExplicitPluginInstallState: true, + readOnly: false, + } as never) + useViewer.setState({ + selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, + } as never) + useFloorplanMode.setState({ mode: 'default' }) + useInteractionScope.getState().end() + useEditor.getState().armToolMode({ mode: 'build', tool: PLUGIN_KIND as never }) + + const Host = host.component + renderer = await create() + expect(lifecycle.mounts[host.view]).toBeGreaterThan(0) + const pluginMountsWhileInstalled = lifecycle.mounts[host.view] + + await act(async () => { + emitter.emit('grid:pointerdown', GRID_EVENT) + }) + expect(useInteractionScope.getState().scope).toMatchObject({ + kind: 'placing', + nodeType: PLUGIN_KIND, + view: host.view, + }) + const pluginUnmountsBeforeRemoval = lifecycle.unmounts[host.view] + + await act(async () => { + useScene.getState().setInstalledPlugins([], { explicit: true }) + }) + expect(useInteractionScope.getState().scope.kind).toBe('idle') + expect(lifecycle.unmounts[host.view]).toBeGreaterThan(pluginUnmountsBeforeRemoval) + expect(useEditor.getState().toolMode).toEqual({ mode: 'select' }) + expect(useScene.getState().nodes[persisted.id]).toEqual(persistedExpected) + expect(useScene.getState().rootNodeIds).toEqual([persisted.id]) + + await act(async () => { + emitter.emit('grid:click', GRID_EVENT) + }) + expect(useScene.getState().nodes[pluginPendingId]).toBeUndefined() + + await act(async () => { + useScene.getState().setInstalledPlugins([PLUGIN_ID], { explicit: true }) + }) + expect(lifecycle.mounts[host.view]).toBe(pluginMountsWhileInstalled) + expect(useEditor.getState().toolMode).toEqual({ mode: 'select' }) + expect(useScene.getState().nodes[persisted.id]).toEqual(persistedExpected) + expect(useScene.getState().rootNodeIds).toEqual([persisted.id]) + + await act(async () => { + useEditor.getState().armToolMode({ mode: 'build', tool: PLUGIN_KIND as never }) + }) + expect(lifecycle.mounts[host.view]).toBeGreaterThan(pluginMountsWhileInstalled) + const pluginMountsAfterReactivation = lifecycle.mounts[host.view] + await act(async () => { + emitter.emit('grid:pointerdown', GRID_EVENT) + emitter.emit('grid:click', GRID_EVENT) + }) + expect(useScene.getState().nodes[pluginPendingId]).toMatchObject({ + type: PLUGIN_KIND, + }) + + await act(async () => { + useEditor.getState().armToolMode({ mode: 'build', tool: BUILTIN_KIND as never }) + }) + expect(lifecycle.mounts[host.view]).toBeGreaterThan(pluginMountsAfterReactivation) + const builtinMountsBeforeUninstall = lifecycle.mounts[host.view] + await act(async () => { + emitter.emit('grid:pointerdown', GRID_EVENT) + }) + expect(useInteractionScope.getState().scope).toMatchObject({ + kind: 'placing', + nodeType: BUILTIN_KIND, + view: host.view, + }) + + await act(async () => { + useScene.getState().setInstalledPlugins([], { explicit: true }) + }) + expect(useInteractionScope.getState().scope).toMatchObject({ + kind: 'placing', + nodeType: BUILTIN_KIND, + view: host.view, + }) + expect(useEditor.getState().toolMode).toEqual({ mode: 'build', tool: BUILTIN_KIND }) + expect(lifecycle.mounts[host.view]).toBe(builtinMountsBeforeUninstall) + + await act(async () => { + emitter.emit('grid:click', GRID_EVENT) + }) + expect(useScene.getState().nodes[builtinPendingId]).toMatchObject({ + type: BUILTIN_KIND, + }) + } finally { + await renderer?.unmount() + useInteractionScope.setState(previousInteraction, true) + useFloorplanMode.setState(previousFloorplanMode, true) + useViewer.setState(previousViewer, true) + useScene.setState(previousScene, true) + useScene.temporal.setState(previousHistory, true) + useEditor.setState(previousEditor, true) + restoreRegistry() + } + }) +} diff --git a/packages/editor/src/components/tools/registry-tool-context.tsx b/packages/editor/src/components/tools/registry-tool-context.tsx index a6d3867877..b814eeeea8 100644 --- a/packages/editor/src/components/tools/registry-tool-context.tsx +++ b/packages/editor/src/components/tools/registry-tool-context.tsx @@ -8,6 +8,7 @@ export type RegistryToolContextValue = { isCameraDragging: () => boolean sceneApi: SceneApi selectNode: (nodeId: AnyNodeId) => void + unit: 'metric' | 'imperial' } const RegistryToolContext = createContext(null) diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 1868208709..7b62a4473f 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -11,6 +11,7 @@ import { collectAlignmentAnchors, createSceneApi, emitter, + findLevelAncestorId, footprintAABBFrom, type GridEvent, type GroupMoveSnapResult, @@ -33,12 +34,16 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useThree } from '@react-three/fiber' +import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { Group } from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement' import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata' -import { resolvePrioritizedPlanarCursorPosition } from '../../../lib/planar-cursor-placement' +import { + offsetPlanPositionByLocalCenter, + resolvePrioritizedPlanarCursorPosition, +} from '../../../lib/planar-cursor-placement' import { resolveAttachmentPreviewRotation } from '../../../lib/rigid-plan-svg-transform' import { movementSfxStepKey } from '../../../lib/sfx/movement-tick' import { sfxEmitter } from '../../../lib/sfx-bus' @@ -95,20 +100,6 @@ type DragBoundsOverride = { centerY?: number } -function offsetPlanPositionByLocalCenter( - position: [number, number, number], - center: [number, number, number], - rotationY: number, -): [number, number, number] { - const cos = Math.cos(rotationY) - const sin = Math.sin(rotationY) - return [ - position[0] + center[0] * cos + center[2] * sin, - position[1] + center[1], - position[2] - center[0] * sin + center[2] * cos, - ] -} - /** * Alignment anchors for the moving node. When the kind declares * `capabilities.dragBounds` with an off-origin `center` (a composite cabinet @@ -233,6 +224,20 @@ const ALIGNMENT_THRESHOLD_M = 0.08 type ClickTriggerEvent = GridEvent | NodeEvent export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { + const previewGroupRef = useRef(null) + useFrame(() => { + if (!previewGroupRef.current) return + const nodes = useScene.getState().nodes + const parentId = nodes[node.id]?.parentId ?? node.parentId + const parent = parentId ? nodes[parentId as AnyNodeId] : undefined + // Building-parented kinds already preview in the tool group's frame. + const levelId = + (parentId ? findLevelAncestorId(parentId as AnyNodeId, nodes) : null) ?? + (parent?.type === 'building' ? null : useViewer.getState().selection.levelId) + previewGroupRef.current.position.y = levelId + ? (sceneRegistry.nodes.get(levelId)?.position.y ?? 0) + : 0 + }) // Live camera ref β€” the pointer-surface cap reconstructs the cursor world // ray (camera β†’ grid hit) to find which walking surface is aimed at. const camera = useThree((s) => s.camera) @@ -702,12 +707,23 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const magnetic = isMagneticSnapActive() const attachmentEnabled = magnetic || isGridSnapActive() + const absolute = useAbsoluteCursorPlacement || cursorAttached + const centerOffset: [number, number, number] = + absolute && dragBounds?.center + ? offsetPlanPositionByLocalCenter( + [0, 0, 0], + dragBounds.center, + previewRotationY(freeRotationRef.current), + ) + : [0, 0, 0] let attachmentRotationY: number | null = null const resolved = resolvePrioritizedPlanarCursorPosition({ cursor: [rawX, rawZ], original: [originalPlanPosition[0], originalPlanPosition[2]], anchor: dragAnchorRef.current, - mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative', + mode: absolute ? 'absolute' : 'relative', + localCenter: dragBounds?.center, + rotationY: previewRotationY(freeRotationRef.current), // Snap follows the mode (raw in Off via snapToGridStep); Alt = force only. snap: gridSnapPositionConfig ? undefined : snapToGridStep, snapPoint: @@ -715,7 +731,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ? ([planX, planZ]) => { const snappedPosition = gridSnapPositionConfig({ node, - candidatePosition: canonicalPositionFromPlan(planX, originalPosition[1], planZ), + // Kind-owned grid hooks exchange origins and apply their own footprint offsets. + candidatePosition: canonicalPositionFromPlan( + planX - centerOffset[0], + originalPosition[1], + planZ - centerOffset[2], + ), candidateRotation: freeRotationRef.current, movingIds: [node.id as AnyNodeId], nodes: useScene.getState().nodes as Record, @@ -729,7 +750,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { snappedPosition, freeRotationRef.current, ) - return [snappedPlanPosition[0], snappedPlanPosition[2]] + return [ + snappedPlanPosition[0] + centerOffset[0], + snappedPlanPosition[2] + centerOffset[2], + ] } : undefined, resolveAttachment: @@ -738,7 +762,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const snapArgs: Parameters>[0] = { node, candidatePosition: canonicalPositionFromPlan(planX, originalPosition[1], planZ), - candidateRotation: rotationRef.current, + candidateRotation: + absolute && dragBounds?.center ? freeRotationRef.current : rotationRef.current, movingIds: [node.id as AnyNodeId], nodes: useScene.getState().nodes as Record, levelId: @@ -1119,10 +1144,28 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ) if (nextFreeRotation === null) return sfxEmitter.emit('sfx:item-rotate') + let position = lastCursorRef.current + if ( + hasMovedRef.current && + (useAbsoluteCursorPlacement || cursorAttached) && + dragBounds?.center + ) { + const planCenter = offsetPlanPositionByLocalCenter( + getVisualPosition(position), + dragBounds.center, + previewRotationY(rotationRef.current), + ) + const planOrigin = offsetPlanPositionByLocalCenter( + planCenter, + [-dragBounds.center[0], 0, -dragBounds.center[2]], + previewRotationY(nextFreeRotation), + ) + position = canonicalPositionFromPlan(planOrigin[0], position[1], planOrigin[2]) + lastCursorRef.current = position + } freeRotationRef.current = nextFreeRotation rotationRef.current = freeRotationRef.current setCursorRotationY(previewRotationY(rotationRef.current)) - const position = lastCursorRef.current const visualPosition = getVisualPosition(position) setCursorPosition(visualPosition) applyMeshPose(position) @@ -1255,12 +1298,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { if (boxDimensions && !dragBounds?.center) { return ( - + + + ) } @@ -1270,7 +1315,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { : cursorPosition return ( - <> + - + ) } diff --git a/packages/editor/src/components/tools/select/box-select-tool.tsx b/packages/editor/src/components/tools/select/box-select-tool.tsx index 4fa848f748..e4c4092e07 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -19,6 +19,7 @@ import { isBoxSelectPointerSuppressed, markBoxSelectHandled, } from './box-select-state' +import { marqueePolygon } from './marquee-footprint' import { convexHull2D, type Point2, @@ -223,7 +224,8 @@ function collectNodeIdsInScreenRect( | { start?: unknown; end?: unknown; polygon?: unknown } | undefined if (node) { - const { start, end, polygon } = node + const { start, end } = node + const polygon = marqueePolygon(node) if (isVec2(start) && isVec2(end)) { if (segmentIntersectsPolygon(start, end, quad)) result.push(id) continue diff --git a/packages/editor/src/components/tools/select/marquee-footprint.test.ts b/packages/editor/src/components/tools/select/marquee-footprint.test.ts new file mode 100644 index 0000000000..c5e57c1ea1 --- /dev/null +++ b/packages/editor/src/components/tools/select/marquee-footprint.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from 'bun:test' +import { marqueePolygon } from './marquee-footprint' +import { type Point2, polygonsIntersect } from './marquee-geometry' + +const polygon: Point2[] = [ + [-4, -2], + [4, -2], + [4, 2], + [-4, 2], +] + +test('a translated pool is not selected by a pipe marquee over its untransformed outline', () => { + const pool = { polygon, position: [12, 0, 0], rotation: [0, 0, 0] } + const pipeMarquee: Point2[] = [ + [-1, -0.1], + [1, -0.1], + [1, 0.1], + [-1, 0.1], + ] + expect(polygonsIntersect(polygon, pipeMarquee)).toBe(true) + expect(polygonsIntersect(marqueePolygon(pool)!, pipeMarquee)).toBe(false) + expect( + polygonsIntersect(marqueePolygon(pool)!, [ + [11, -1], + [13, -1], + [13, 1], + [11, 1], + ]), + ).toBe(true) +}) + +test('pool rotation is applied before its position', () => { + const footprint = marqueePolygon({ + polygon, + position: [12, 0, 5], + rotation: [0, Math.PI / 2, 0], + })! + expect(footprint[0]![0]).toBeCloseTo(10) + expect(footprint[0]![1]).toBeCloseTo(9) + expect( + polygonsIntersect(footprint, [ + [15, 4], + [16, 4], + [16, 6], + [15, 6], + ]), + ).toBe(false) +}) + +test('level-space polygons keep their existing coordinates', () => { + expect(marqueePolygon({ polygon })).toBe(polygon) + expect(marqueePolygon({ polygon: undefined })).toBeNull() +}) diff --git a/packages/editor/src/components/tools/select/marquee-footprint.ts b/packages/editor/src/components/tools/select/marquee-footprint.ts new file mode 100644 index 0000000000..6e34403072 --- /dev/null +++ b/packages/editor/src/components/tools/select/marquee-footprint.ts @@ -0,0 +1,29 @@ +import { Euler, Vector3 } from 'three' +import type { Point2 } from './marquee-geometry' + +export function marqueePolygon(node: { + polygon?: unknown + position?: unknown + rotation?: unknown +}): Point2[] | null { + const { polygon, position, rotation } = node + if ( + !Array.isArray(polygon) || + polygon.length === 0 || + !polygon.every( + (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite), + ) + ) + return null + // Position-based kinds author their footprint locally, unlike slabs and zones. + if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) + return polygon as Point2[] + const angles = + Array.isArray(rotation) && rotation.length === 3 && rotation.every(Number.isFinite) + ? new Euler(rotation[0], rotation[1], rotation[2]) + : new Euler() + return polygon.map(([x, z]) => { + const point = new Vector3(x, 0, z).applyEuler(angles) + return [point.x + position[0], point.z + position[2]] + }) +} diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.ts index 1d0f4a5eea..8a0adbf5d9 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.ts @@ -1,4 +1,5 @@ import { + type AnyNode, type AnyNodeId, canHostOnTop, GROUND_SUPPORT_ID, @@ -194,7 +195,7 @@ export function resolvePointerSupportSurface( // convention β€” the election owns the invariant. const interactingNodeId = scopeNodeId(useInteractionScope.getState().scope) const isEligibleCandidate = (nodeId: AnyNodeId) => { - let current = nodes[nodeId] + let current: AnyNode | undefined = nodes[nodeId] const visited = new Set() while (current && !visited.has(current.id)) { if (current.id === interactingNodeId) return false diff --git a/packages/editor/src/components/tools/site/terrain-sculpt-grid.tsx b/packages/editor/src/components/tools/site/terrain-sculpt-grid.tsx index 7460493511..0ef96d7fdb 100644 --- a/packages/editor/src/components/tools/site/terrain-sculpt-grid.tsx +++ b/packages/editor/src/components/tools/site/terrain-sculpt-grid.tsx @@ -39,10 +39,15 @@ function writeGridHeights( } } + let first = (row0 * field.cols + col0) * 3 + let end = ((row1 - 1) * field.cols + col1) * 3 + // Several dabs can precede a render; none may discard an earlier pending upload. + for (const pending of attribute.updateRanges) { + first = Math.min(first, pending.start) + end = Math.max(end, pending.start + pending.count) + } attribute.clearUpdateRanges() - const first = (row0 * field.cols + col0) * 3 - const last = ((row1 - 1) * field.cols + (col1 - 1)) * 3 + 2 - attribute.addUpdateRange(first, last - first + 1) + attribute.addUpdateRange(first, end - first) attribute.needsUpdate = true } @@ -162,7 +167,7 @@ export function TerrainSculptGrid({ if (stroke?.lastPatch && stroke.lastPatch !== lastPatch) { lastPatch = stroke.lastPatch writeGridHeights(attribute, stroke.field, stroke.lastPatch) - target.geometry.computeBoundingSphere() + // This grid is neither frustum-culled nor raycast, so no per-dab bounds scan. return } @@ -173,7 +178,6 @@ export function TerrainSculptGrid({ const restored = terrainFieldOf(current) ?? sculptFieldForSite(current) if (restored.cols !== field.cols || restored.rows !== field.rows) return writeGridHeights(attribute, restored, null) - target.geometry.computeBoundingSphere() } }) }, [field.cols, field.rows, site.id]) diff --git a/packages/editor/src/components/tools/site/terrain-sculpt-tool.tsx b/packages/editor/src/components/tools/site/terrain-sculpt-tool.tsx index a2c8b434f7..4913f916c4 100644 --- a/packages/editor/src/components/tools/site/terrain-sculpt-tool.tsx +++ b/packages/editor/src/components/tools/site/terrain-sculpt-tool.tsx @@ -214,7 +214,7 @@ export const TerrainSculptTool: React.FC = () => { detachStrokeAnchor(active.stroke) return } - const point = groundPoint(event, active.field) + const point = groundPoint(event, active.stroke.snapshot) if (!point) return const brushPatch = advanceStroke(active.stroke, point[0], point[1]) if (!brushPatch) return diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index a07de846c2..71dabb502e 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -1,9 +1,13 @@ import { type AnyNode, collectAlignmentAnchors, + createDefaultStairSegment, createSurfaceOpeningPreviewController, + DEFAULT_LEVEL_HEIGHT, emitter, type GridEvent, + getFloorStackedPosition, + getLevelFloorToFloorHeight, type LevelNode, movingAlignmentAnchors, type NodeEvent, @@ -11,7 +15,7 @@ import { resolveFrozenFloorPlacementPatch, resolveSupportSlabPatch, StairNode, - StairSegmentNode, + type StairSegmentNode, syncAutoStairOpenings, useScene, } from '@pascal-app/core' @@ -50,7 +54,6 @@ import { DEFAULT_SPIRAL_TOP_LANDING_MODE, DEFAULT_STAIR_ATTACHMENT_SIDE, DEFAULT_STAIR_FILL_TO_FLOOR, - DEFAULT_STAIR_HEIGHT, DEFAULT_STAIR_LENGTH, DEFAULT_STAIR_OPENING_OFFSET, DEFAULT_STAIR_RAILING_HEIGHT, @@ -71,8 +74,8 @@ type MoveTriggerEvent = GridEvent | NodeEvent * Generates the step-profile geometry for the ghost preview. * Same algorithm as StairSystem's generateStairSegmentGeometry. */ -function createStairPreviewGeometry(): THREE.BufferGeometry { - const riserHeight = DEFAULT_STAIR_HEIGHT / DEFAULT_STAIR_STEP_COUNT +function createStairPreviewGeometry(rise: number): THREE.BufferGeometry { + const riserHeight = rise / DEFAULT_STAIR_STEP_COUNT const treadDepth = DEFAULT_STAIR_LENGTH / DEFAULT_STAIR_STEP_COUNT const shape = new THREE.Shape() @@ -103,19 +106,40 @@ function createStairPreviewGeometry(): THREE.BufferGeometry { } /** - * Creates a default straight stair segment. + * Creates a default straight stair segment climbing `rise` β€” the storey it is + * dropped on, not a constant: the placed stair has no explicit `totalRise`, so + * this is the height `syncStairRises` immediately converges it to anyway. */ -function createDefaultStairSegment() { - return StairSegmentNode.parse({ - segmentType: 'stair', +function resolvePlacedStairRise( + nodes: Record, + levelId: LevelNode['id'], + stair: StairNode, + supportSurface: PointerSupportSurface | null, +): number { + // Same contract as `resolveStairTotalRise` for a stair that is not in the + // scene yet: the storey height minus whatever slab lifts the drop point, + // capped by the surface the pointer actually aims at (a floor under an + // overlapping deck must not elect the deck). + const base = getFloorStackedPosition({ + node: stair, + nodes, + position: stair.position, + rotation: stair.rotation, + levelId, + maxElevation: supportSurface?.elevation ?? null, + })[1] + return getLevelFloorToFloorHeight(levelId, nodes) - base +} + +function createSeedStairSegment(rise: number) { + return createDefaultStairSegment({ width: DEFAULT_STAIR_WIDTH, length: DEFAULT_STAIR_LENGTH, - height: DEFAULT_STAIR_HEIGHT, + height: rise, stepCount: DEFAULT_STAIR_STEP_COUNT, attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE, fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR, thickness: DEFAULT_STAIR_THICKNESS, - position: [0, 0, 0], }) } @@ -178,7 +202,7 @@ function commitStairPlacement( const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length const name = `Staircase ${stairCount + 1}` - const segment = createDefaultStairSegment() + const seed = createSeedStairSegment(getLevelFloorToFloorHeight(placementLevelId, nodes)) const destinationPlan = resolveStairDestinationLevel({ createMissing: true, @@ -194,10 +218,14 @@ function commitStairPlacement( nextLevelId, position, rotation, - segmentId: segment.id, + segmentId: seed.id, }), parentId: placementLevelId, }) + const segment = { + ...seed, + height: resolvePlacedStairRise(nodes, placementLevelId, stair, supportSurface), + } const prospectiveNodes = { ...nodes, [stair.id]: stair, @@ -248,7 +276,13 @@ export const StairTool: React.FC = () => { const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null) const currentLevelId = useViewer((state) => state.selection.levelId) - const previewGeometry = useMemo(() => createStairPreviewGeometry(), []) + const previewRise = useScene((state) => + currentLevelId ? getLevelFloorToFloorHeight(currentLevelId, state.nodes) : DEFAULT_LEVEL_HEIGHT, + ) + const previewRiseRef = useRef(previewRise) + previewRiseRef.current = previewRise + const previewGeometry = useMemo(() => createStairPreviewGeometry(previewRise), [previewRise]) + useEffect(() => () => previewGeometry.dispose(), [previewGeometry]) useEffect(() => { if (!currentLevelId) return @@ -261,11 +295,18 @@ export const StairTool: React.FC = () => { // Reset rotation when tool activates rotationRef.current = 0 useStairBuildPreview.getState().reset() - if (previewRef.current) previewRef.current.rotation.y = 0 + if (previewRef.current) { + previewRef.current.rotation.y = 0 + previewRef.current.scale.y = 1 + } lastCanonicalPositionRef.current = null supportSurfaceRef.current = null - const buildPreviewScene = (position: [number, number, number], rotation: number) => { + const buildPreviewScene = ( + position: [number, number, number], + rotation: number, + supportSurface: PointerSupportSurface | null, + ) => { const nodes = useScene.getState().nodes const placementLevelId = resolveStairPlacementLevelId( nodes, @@ -280,15 +321,19 @@ export const StairTool: React.FC = () => { nodes, }) const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId - const segment = createDefaultStairSegment() + const seed = createSeedStairSegment(getLevelFloorToFloorHeight(placementLevelId, nodes)) const stair = createDefaultStairNode({ name: 'Staircase Preview', levelId: placementLevelId, nextLevelId, position, rotation, - segmentId: segment.id, + segmentId: seed.id, }) + const segment = { + ...seed, + height: resolvePlacedStairRise(nodes, placementLevelId, stair, supportSurface), + } const previewNodes = { ...nodes, ...(destinationPlan?.createdLevel @@ -298,7 +343,7 @@ export const StairTool: React.FC = () => { [segment.id]: { ...segment, parentId: stair.id }, } as Record - return { placementLevelId, previewNodes, stair } + return { placementLevelId, previewNodes, stair, rise: segment.height } } // The preview rebuild (full-scene copy + destination-level resolution + @@ -319,7 +364,7 @@ export const StairTool: React.FC = () => { if (key === lastPreviewKey) return lastPreviewKey = key useStairBuildPreview.getState().setPreview([position[0], position[2]], rotation) - const preview = buildPreviewScene(position, rotation) + const preview = buildPreviewScene(position, rotation, supportSurface) const frozenPatch = preview && supportSurface?.sourceNodeId ? resolveFrozenFloorPlacementPatch(preview.stair, preview.previewNodes, { @@ -355,6 +400,9 @@ export const StairTool: React.FC = () => { if (previewRef.current) { previewRef.current.position.set(...visualPosition) previewRef.current.rotation.y = rotation + // The ghost geometry is built for the storey height; squash it to the + // rise the placed flight will get on this surface. + previewRef.current.scale.y = preview ? preview.rise / previewRiseRef.current : 1 } // Forward-facing triangle (editor-side overlay). The run ascends along @@ -388,7 +436,7 @@ export const StairTool: React.FC = () => { z: number, rotation: number, ): ReturnType | null => { - const preview = buildPreviewScene([x, 0, z], rotation) + const preview = buildPreviewScene([x, 0, z], rotation, supportSurfaceRef.current) const moving = preview ? movingAlignmentAnchors(preview.stair, preview.previewNodes, x, z, rotation) : [] diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index dcc3ba134f..9822ac6ab3 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -12,6 +12,7 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { type ComponentType, lazy, Suspense, useMemo } from 'react' +import { useRegisteredToolEnabled } from '../../hooks/use-registered-tool-enabled' import { siteBoundaryHandlesEnabled } from '../../lib/site-boundary' import useEditor, { type Phase, type Tool } from '../../store/use-editor' import useInteractionScope, { @@ -102,6 +103,7 @@ export const ToolManager: React.FC = () => { const phase = useEditor((state) => state.phase) const mode = useEditor((state) => state.mode) const tool = useEditor((state) => state.tool) + const registeredToolEnabled = useRegisteredToolEnabled(tool) const movingNode = useMovingNode() const registryToolOwnsPlacement = useInteractionScope( (state) => state.scope.kind === 'placing' && state.scope.driver === 'registry-tool', @@ -142,6 +144,7 @@ export const ToolManager: React.FC = () => { const selectedIds = useViewer((state) => state.selection.selectedIds) const buildingId = useViewer((state) => state.selection.buildingId) const activeLevelId = useViewer((state) => state.selection.levelId) + const unit = useViewer((state) => state.unit) const setSelection = useViewer((state) => state.setSelection) const nodes = useScene((state) => state.nodes) const registrySceneApi = useMemo(() => createSceneApi(useScene), []) @@ -151,8 +154,9 @@ export const ToolManager: React.FC = () => { isCameraDragging: () => useViewer.getState().cameraDragging, sceneApi: registrySceneApi, selectNode: (nodeId: AnyNodeId) => setSelection({ selectedIds: [nodeId] }), + unit, }), - [activeLevelId, registrySceneApi, setSelection], + [activeLevelId, registrySceneApi, setSelection, unit], ) // Building transform for the local group β€” all building-relative tools live inside this group @@ -236,7 +240,7 @@ export const ToolManager: React.FC = () => { !showCeilingBoundaryEditor // Show build tools when in build mode - const showBuildTool = mode === 'build' && tool !== null + const showBuildTool = mode === 'build' && tool !== null && registeredToolEnabled // A move initiated from the 2D floor-plan (orange move-dot) is owned end-to- // end by `FloorplanRegistryMoveOverlay`, which marks the origin `'2d'` at diff --git a/packages/editor/src/components/ui/controls/metric-control.tsx b/packages/editor/src/components/ui/controls/metric-control.tsx index e48e398802..797ea8e835 100644 --- a/packages/editor/src/components/ui/controls/metric-control.tsx +++ b/packages/editor/src/components/ui/controls/metric-control.tsx @@ -2,11 +2,7 @@ import { useScene } from '@pascal-app/core' import { useCallback, useEffect, useRef, useState } from 'react' -import { - lingoUnitSpec, - measurementHint, - parseMeasurement, -} from '../../../lib/measurement-parser' +import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser' import { useLinearDisplay } from '../../../lib/use-linear-display' import { cn } from '../../../lib/utils' @@ -31,8 +27,8 @@ export function MetricControl({ onCommit, min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY, - precision = 2, - step = 1, + precision: storedPrecision = 2, + step: storedStep = 1, className, unit = '', restoreOnCommit = true, @@ -40,9 +36,12 @@ export function MetricControl({ const { isImperial, displayUnit, + parseUnit, + precision, + step, toDisplay: toDisplayValue, toStored: toStoredValue, - } = useLinearDisplay(unit, precision) + } = useLinearDisplay(unit, storedPrecision, storedStep) const clamp = useCallback( (val: number) => { @@ -110,7 +109,14 @@ export function MetricControl({ container.addEventListener('wheel', handleWheel, { passive: false }) return () => container.removeEventListener('wheel', handleWheel) - }, [isEditing, step, clamp, applyCommittedValue, toStoredValue, roundStoredValueForDisplayPrecision]) + }, [ + isEditing, + step, + clamp, + applyCommittedValue, + toStoredValue, + roundStoredValueForDisplayPrecision, + ]) useEffect(() => { if (!isHovered || isEditing) return @@ -226,7 +232,7 @@ export function MetricControl({ const spec = lingoUnitSpec(unit) let stored = spec ? parseMeasurement(inputValue, spec, { - bareUnit: isImperial ? 'ft' : spec.unitId, + bareUnit: parseUnit ?? spec.unitId, system: isImperial ? 'us' : 'metric', }) : null @@ -244,6 +250,7 @@ export function MetricControl({ inputValue, unit, isImperial, + parseUnit, applyCommittedValue, clamp, toStoredValue, @@ -256,9 +263,9 @@ export function MetricControl({ const hint = isEditing && spec ? measurementHint(inputValue, spec, { - bareUnit: isImperial ? 'ft' : spec.unitId, + bareUnit: parseUnit ?? spec.unitId, system: isImperial ? 'us' : 'metric', - displayUnit: isImperial ? 'ft' : spec.unitId, + displayUnit: parseUnit ?? spec.unitId, precision, clamp, }) @@ -287,7 +294,16 @@ export function MetricControl({ setInputValue(toDisplayValue(newV).toFixed(precision)) } }, - [submitValue, value, toDisplayValue, precision, step, clamp, applyCommittedValue, toStoredValue], + [ + submitValue, + value, + toDisplayValue, + precision, + step, + clamp, + applyCommittedValue, + toStoredValue, + ], ) return ( diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index a3c5d06d70..e89b84f17f 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -2,11 +2,7 @@ import { useScene } from '@pascal-app/core' import { useCallback, useEffect, useRef, useState } from 'react' -import { - lingoUnitSpec, - measurementHint, - parseMeasurement, -} from '../../../lib/measurement-parser' +import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser' import { useLinearDisplay } from '../../../lib/use-linear-display' import { cn } from '../../../lib/utils' @@ -60,19 +56,16 @@ export function SliderControl({ onCommit, min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY, - precision = 0, - step = 1, + precision: storedPrecision = 0, + step: storedStep = 1, className, unit = '', restoreOnCommit = true, mixed = false, }: SliderControlProps) { - // Display/storage conversion so the value honors the metric/imperial toggle. - // `value`, `onChange`, `onCommit`, `min`/`max`/`clamp` are always in the - // stored unit (meters for `unit === 'm'`); the step, drag deltas, text field - // and rendered number are in the DISPLAY unit (feet when imperial). For - // metric and non-length units these conversions are the identity. - const { isImperial, displayUnit, toDisplay, toStored } = useLinearDisplay(unit, precision) + // Values and bounds stay in meters; gestures and input use the displayed unit. + const { isImperial, displayUnit, parseUnit, precision, step, toDisplay, toStored } = + useLinearDisplay(unit, storedPrecision, storedStep) const [isEditing, setIsEditing] = useState(false) const [isDragging, setIsDragging] = useState(false) @@ -106,7 +99,9 @@ export function SliderControl({ (storedValue: number, displayDelta: number, displayStep: number) => clamp( toStored( - Number.parseFloat((toDisplay(storedValue) + displayDelta).toFixed(stepPrecision(displayStep))), + Number.parseFloat( + (toDisplay(storedValue) + displayDelta).toFixed(stepPrecision(displayStep)), + ), ), ), [clamp, toDisplay, toStored], @@ -231,7 +226,7 @@ export function SliderControl({ const spec = lingoUnitSpec(unit) let stored = spec ? parseMeasurement(inputValue, spec, { - bareUnit: isImperial ? 'ft' : spec.unitId, + bareUnit: parseUnit ?? spec.unitId, system: isImperial ? 'us' : 'metric', }) : null @@ -248,15 +243,27 @@ export function SliderControl({ onCommit?.(nextValue) } setIsEditing(false) - }, [inputValue, unit, isImperial, onChange, onCommit, clamp, precision, value, toDisplay, toStored]) + }, [ + inputValue, + unit, + isImperial, + parseUnit, + onChange, + onCommit, + clamp, + precision, + value, + toDisplay, + toStored, + ]) const spec = lingoUnitSpec(unit) const hint = isEditing && spec ? measurementHint(inputValue, spec, { - bareUnit: isImperial ? 'ft' : spec.unitId, + bareUnit: parseUnit ?? spec.unitId, system: isImperial ? 'us' : 'metric', - displayUnit: isImperial ? 'ft' : spec.unitId, + displayUnit: parseUnit ?? spec.unitId, precision, clamp, }) diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index 7e88c4af06..de3eaec260 100644 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -145,11 +145,14 @@ function LevelRow({ const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) const [isEditing, setIsEditing] = useState(false) const updateNode = useScene((s) => s.updateNode) - const { isImperial, toDisplay, displayUnit } = useLinearDisplay('m', 2) + const { isImperial, toDisplay, displayUnit, precision: displayPrecision } = useLinearDisplay('m', 2) const storeyHeight = getStoredLevelHeight(level) - // toFixed(2) + strip one trailing zero: "2.50" β†’ "2.5", "2.75" stays. - const storeyHeightLabel = `${toDisplay(storeyHeight).toFixed(2).replace(/0$/, '')} ${displayUnit}` + // Decimal units keep the compact readout; integer millimeters must retain trailing zeroes. + const formattedStoreyHeight = toDisplay(storeyHeight).toFixed(displayPrecision) + const storeyHeightLabel = `${ + displayPrecision > 0 ? formattedStoreyHeight.replace(/0$/, '') : formattedStoreyHeight + } ${displayUnit}` // Same rule as the site panel and command palette: the ordinal-0 ground // floor is the vertical model's zero anchor and must never be deletable. const canDeleteLevel = level.level !== 0 @@ -244,7 +247,7 @@ function LevelRow({ precision={3} step={0.1} unit="m" - value={Math.round(storeyHeight * 1000) / 1000} + value={storeyHeight} />
{heightPresets.map((preset) => ( diff --git a/packages/editor/src/components/ui/panels/multi-parametric-inspector.tsx b/packages/editor/src/components/ui/panels/multi-parametric-inspector.tsx index 7246aab6f4..bab0b2ace8 100644 --- a/packages/editor/src/components/ui/panels/multi-parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/multi-parametric-inspector.tsx @@ -254,7 +254,7 @@ function MultiVec3Field({ restoreOnCommit={false} step={0.05} unit="m" - value={Math.round(axisValue * 100) / 100} + value={axisValue} /> ) })} diff --git a/packages/editor/src/components/ui/panels/parametric-field-control.tsx b/packages/editor/src/components/ui/panels/parametric-field-control.tsx index 4defaeeccf..4dc18ee7ee 100644 --- a/packages/editor/src/components/ui/panels/parametric-field-control.tsx +++ b/packages/editor/src/components/ui/panels/parametric-field-control.tsx @@ -159,7 +159,7 @@ export function ParametricFieldControl({ restoreOnCommit={!onCommit} step={0.05} unit="m" - value={Math.round(axisValue * 100) / 100} + value={axisValue} /> ) })} diff --git a/packages/editor/src/components/ui/panels/reference-panel.tsx b/packages/editor/src/components/ui/panels/reference-panel.tsx index 86da5ccd89..e18df87827 100644 --- a/packages/editor/src/components/ui/panels/reference-panel.tsx +++ b/packages/editor/src/components/ui/panels/reference-panel.tsx @@ -376,7 +376,7 @@ export function ReferencePanel() { precision={2} step={0.1} unit="m" - value={Math.round(node.position[0] * 100) / 100} + value={node.position[0]} /> diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index 3baefe5a6f..1a9130df44 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -1,23 +1,41 @@ import { clearSceneHistory, emitter, + isNodeKindEnabled, + nodeRegistry, + useRegistryVersion, useScene, type ParsedBuildJson, validateBuildJson, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { useViewer, viewerPresentationRegistry } from '@pascal-app/viewer' import { TreeView, VisualJson } from '@visual-json/react' -import { Camera, Check, Copy, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react' +import { + Camera, + Check, + ChevronDown, + Copy, + Download, + Map as MapIcon, + Save, + Trash2, + Upload, +} from 'lucide-react' import { type KeyboardEvent, type SyntheticEvent, useCallback, useEffect, + useId, useMemo, useRef, useState, + useSyncExternalStore, } from 'react' -import { exportFloorplanPdf } from '../../../../../lib/floorplan/floorplan-export' +import { + exportFloorplanPdf, + type FloorplanExportScope, +} from '../../../../../lib/floorplan/floorplan-export' import { Button } from './../../../../../components/ui/primitives/button' import { Dialog, @@ -57,6 +75,21 @@ type SceneGraphValue = { detachedNodes?: SceneGraphNode[] } +const MODEL_EXPORT_FORMATS = [ + { format: 'glb', label: 'GLB' }, + { format: 'usdz', label: 'USDZ' }, + { format: 'stl', label: 'STL' }, + { format: 'obj', label: 'OBJ' }, +] as const + +type ModelExportFormat = (typeof MODEL_EXPORT_FORMATS)[number]['format'] + +type ExportableNodeType = { + type: string + label: string + supportsGeometryOnly: boolean +} + const isSceneNode = (value: unknown): value is SceneNode => { return ( typeof value === 'object' && @@ -193,6 +226,7 @@ export function SettingsPanel({ const rootNodeIds = useScene((state) => state.rootNodeIds) const installedPlugins = useScene((state) => state.installedPlugins) const materials = useScene((state) => state.materials) + const collections = useScene((state) => state.collections) const setScene = useScene((state) => state.setScene) const clearScene = useScene((state) => state.clearScene) const resetSelection = useViewer((state) => state.resetSelection) @@ -200,11 +234,65 @@ export function SettingsPanel({ const shadows = useViewer((state) => state.shadows) const setPhase = useEditor((state) => state.setPhase) const floorplanMode = useFloorplanMode((state) => state.mode) + const registryVersion = useRegistryVersion() + const visibleOnlySwitchId = useId() + const includeNodeTypeIdPrefix = useId() + const includePresentationIdPrefix = useId() const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false) const [exportOnlyVisible, setExportOnlyVisible] = useState(true) + const [excludedNodeTypes, setExcludedNodeTypes] = useState([]) + const [includedPresentationIds, setIncludedPresentationIds] = useState([]) + const [activeModelExport, setActiveModelExport] = useState(null) + const [modelExportError, setModelExportError] = useState(null) + const [modelExportWarning, setModelExportWarning] = useState(null) + const [activeFloorplanExport, setActiveFloorplanExport] = useState( + null, + ) + const [floorplanExportError, setFloorplanExportError] = useState(null) const [pendingImport, setPendingImport] = useState(null) - const [projectIdCopyState, setProjectIdCopyState] = useState<'idle' | 'copied' | 'error'>( - 'idle', + const [projectIdCopyState, setProjectIdCopyState] = useState<'idle' | 'copied' | 'error'>('idle') + const exportableNodeTypes = useMemo(() => { + void registryVersion + const uniqueTypes = new Set(Object.values(nodes).map((node) => node.type)) + const options: ExportableNodeType[] = [] + + for (const type of uniqueTypes) { + const definition = nodeRegistry.get(type) + if ( + !( + definition?.bakeGeometry || + definition?.bakeGeometryAsync || + definition?.bake === 'replace' + ) || + !isNodeKindEnabled(type, installedPlugins) + ) { + continue + } + options.push({ + type, + label: definition.presentation?.label ?? type, + supportsGeometryOnly: !definition.bakeGeometryAsync || Boolean(definition.bakeGeometry), + }) + } + + return options.sort((a, b) => { + const labelOrder = a.label.localeCompare(b.label) + return labelOrder === 0 ? a.type.localeCompare(b.type) : labelOrder + }) + }, [installedPlugins, nodes, registryVersion]) + const registeredPresentations = useSyncExternalStore( + viewerPresentationRegistry.subscribe, + viewerPresentationRegistry.getSnapshot, + viewerPresentationRegistry.getSnapshot, + ) + const exportablePresentations = useMemo( + () => + registeredPresentations.filter( + (contribution) => + contribution.staticExport && + (!contribution.pluginId || installedPlugins.includes(contribution.pluginId)), + ), + [installedPlugins, registeredPresentations], ) const sceneGraphValue = useMemo( () => buildSceneGraphValue(nodes as Record, rootNodeIds), @@ -236,7 +324,7 @@ export function SettingsPanel({ // Materials ride along: nodes reference them by `scene:` slot // refs, so a save without the table produces a file whose custom // finishes revert to defaults on the very Load Build path below. - const sceneData = { nodes, rootNodeIds, installedPlugins, materials } + const sceneData = { nodes, rootNodeIds, installedPlugins, materials, collections } const json = JSON.stringify(sceneData, null, 2) const blob = new Blob([json], { type: 'application/json' }) const url = URL.createObjectURL(blob) @@ -302,6 +390,7 @@ export function SettingsPanel({ // pointed at a material that no longer existed β€” custom finishes // silently reverted to defaults on import. materials: parsed.materials, + collections: parsed.collections, installedPlugins: parsed.installedPlugins ?? currentScene.installedPlugins, hasExplicitPluginInstallState: parsed.installedPlugins !== undefined || currentScene.hasExplicitPluginInstallState, @@ -358,8 +447,71 @@ export function SettingsPanel({ await onVisibilityChange?.(field, value) } + const handleNodeTypeInclusion = useCallback((type: string, included: boolean) => { + setExcludedNodeTypes((current) => { + const isExcluded = current.includes(type) + if (included) { + return isExcluded ? current.filter((excludedType) => excludedType !== type) : current + } + return isExcluded ? current : [...current, type] + }) + }, []) + const handlePresentationInclusion = useCallback((id: string, included: boolean) => { + setIncludedPresentationIds((current) => { + const isIncluded = current.includes(id) + if (included) return isIncluded ? current : [...current, id] + return isIncluded ? current.filter((includedId) => includedId !== id) : current + }) + }, []) + + const handleModelExport = async (format: ModelExportFormat, label: string) => { + if (!modelExport || activeModelExport) return + + setActiveModelExport(format) + setModelExportError(null) + setModelExportWarning(null) + try { + const artifact = await modelExport(format, { + onlyVisible: exportOnlyVisible, + excludedNodeTypes, + includedPresentationIds: + format === 'glb' || format === 'usdz' + ? includedPresentationIds.filter((id) => + exportablePresentations.some((contribution) => contribution.id === id), + ) + : [], + }) + if (!artifact) { + throw new Error('Model export did not produce a file') + } + if (artifact.warnings?.length) setModelExportWarning(artifact.warnings.join(' ')) + } catch (error) { + setModelExportError( + error instanceof Error ? error.message : `Couldn’t export ${label}. Try again.`, + ) + } finally { + setActiveModelExport(null) + } + } + + const handleFloorplanExport = async (scope: FloorplanExportScope) => { + if (activeFloorplanExport) return + + setActiveFloorplanExport(scope) + setFloorplanExportError(null) + try { + await exportFloorplanPdf(scope) + } catch (error) { + setFloorplanExportError( + `Couldn’t export the floor plan. ${error instanceof Error ? error.message : 'Try again.'}`, + ) + } finally { + setActiveFloorplanExport(null) + } + } + return ( -
+
{projectId && (
@@ -406,6 +558,7 @@ export function SettingsPanel({
handleVisibilityChange('isPrivate', !checked)} /> @@ -416,6 +569,7 @@ export function SettingsPanel({
Visible to public viewers
handleVisibilityChange('showScansPublic', checked)} /> @@ -426,6 +580,7 @@ export function SettingsPanel({
Visible to public viewers
handleVisibilityChange('showGuidesPublic', checked)} /> @@ -436,6 +591,7 @@ export function SettingsPanel({
Cast shadows from lights
useViewer.getState().setShadows(checked)} /> @@ -449,39 +605,139 @@ export function SettingsPanel({
3D model
-
-
-
Visible nodes only
-
- Exclude hidden furniture and other hidden scene nodes +
{ + // Keep Space available to the disclosure and switches, not canvas panning. + if (event.code === 'Space') event.stopPropagation() + }} + > + + Export options + +
+
+
+ +
+ Exclude hidden furniture and other hidden scene nodes +
+
+
+ +
+ Include in file +

+ Choose which procedural content is baked into model files. GLB and USDZ use the + textured portable path; STL and OBJ remain geometry-only. +

+ {exportableNodeTypes.length > 0 ? ( +
+ {exportableNodeTypes.map(({ type, label, supportsGeometryOnly }, index) => { + const switchId = `${includeNodeTypeIdPrefix}-${index}` + return ( +
+ + handleNodeTypeInclusion(type, included)} + /> +
+ ) + })} +
+ ) : ( +

+ No optional procedural content is present. +

+ )} +

+ Viewer surroundings are excluded unless selected separately below. +

+
+ {exportablePresentations.length > 0 ? ( +
+ Viewer surroundings +

+ Optional static surroundings are included only in GLB and USDZ. +

+
+ {exportablePresentations.map((contribution, index) => { + const switchId = `${includePresentationIdPrefix}-${index}` + const label = contribution.staticExport!.label + return ( +
+ + + handlePresentationInclusion(contribution.id, included) + } + /> +
+ ) + })} +
+
+ ) : null}
- -
- - - + + + {MODEL_EXPORT_FORMATS.map(({ format, label }) => { + const isActive = activeModelExport === format + return ( + + ) + })} + + {activeModelExport ? ( +

+ Preparing {activeModelExport.toUpperCase()} file… +

+ ) : null} + {modelExportError ? ( +

+ {modelExportError} +

+ ) : null} + {modelExportWarning ? ( +

+ Warning: {modelExportWarning} +

+ ) : null}
@@ -492,21 +748,35 @@ export function SettingsPanel({ {floorplanMode === 'default' ? 'Default mode' : 'Expert mode'}
+ {activeFloorplanExport ? ( +

+ Preparing floor-plan PDF… +

+ ) : null} + {floorplanExportError ? ( +

+ {floorplanExportError} +

+ ) : null}
diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx index b81d279ff7..ccc4b031a6 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx @@ -59,6 +59,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ { keys: ['Delete / Backspace'], action: 'Delete selected objects' }, { keys: ['Cmd/Ctrl', 'Z'], action: 'Undo' }, { keys: ['Cmd/Ctrl', 'Shift', 'Z'], action: 'Redo' }, + { keys: ['Cmd/Ctrl', 'S'], action: 'Save' }, ], }, { diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index 7520477e95..7723f770b0 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -50,7 +50,7 @@ import { metersToLinearUnit, squareMetersToAreaUnit, } from './../../../../../lib/measurements' -import { createLocalGuideImage } from './../../../../../lib/local-guide-image' +import { createLocalGuideImage, createLocalScan } from './../../../../../lib/local-guide-image' import { editorHostTreeChildrenRegistry } from './../../../../../lib/host-tree-children' import { cn } from './../../../../../lib/utils' import useEditor from './../../../../../store/use-editor' @@ -497,6 +497,7 @@ const LevelReferences = memo(function LevelReferences({ const deleteNode = useScene((s) => s.deleteNode) const setSelection = useViewer((s) => s.setSelection) const setShowGuides = useViewer((s) => s.setShowGuides) + const setShowScans = useViewer((s) => s.setShowScans) const references = useScene( useShallow((s) => Object.values(s.nodes).filter( @@ -566,6 +567,23 @@ const LevelReferences = memo(function LevelReferences({ return } + if (!onUploadAsset) { + useUploadStore.getState().startUpload(levelId, 'scan', file.name) + useUploadStore.getState().setStatus(levelId, 'uploading') + + try { + const { scan, url } = await createLocalScan({ createNode, file, levelId }) + setShowScans(true) + setSelectedReferenceId(scan.id) + setSelection({ selectedIds: [], zoneId: null }) + useUploadStore.getState().setResult(levelId, url) + window.setTimeout(() => useUploadStore.getState().clearUpload(levelId), 600) + } catch { + useUploadStore.getState().setError(levelId, 'Could not add that scan.') + } + return + } + if (!projectId) { useUploadStore.getState().startUpload(levelId, 'scan', file.name) useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.') @@ -573,7 +591,7 @@ const LevelReferences = memo(function LevelReferences({ } clearUpload(levelId) - onUploadAsset?.(projectId, levelId, file, type) + onUploadAsset(projectId, levelId, file, type) } const handleDelete = async (nodeId: string, e: React.MouseEvent) => { @@ -949,7 +967,7 @@ const LevelItem = memo(function LevelItem({ precision={2} step={0.05} unit="m" - value={Math.round((level.baseElevation ?? 0) * 100) / 100} + value={(level.baseElevation ?? 0)} /> } { +}: UseAutoSaveOptions): { + isLoadingSceneRef: MutableRefObject + saveNow: () => void +} { const saveTimeoutRef = useRef(undefined) const isSavingRef = useRef(false) // Starts TRUE: the scene is "loading" from mount until the Editor's load @@ -339,5 +342,24 @@ export function useAutoSave({ setSaveStatus('saved') }, [isVersionPreviewMode, setSaveStatus]) - return { isLoadingSceneRef } + // Imperative flush for the save shortcut: drop the debounce and write now, + // through the same `executeSave` so the wipe guard and status callbacks stay + // in the loop. A write already in flight only arms the follow-up. + const saveNow = useCallback(() => { + if (isLoadingSceneRef.current) return + + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current) + saveTimeoutRef.current = undefined + } + + if (isSavingRef.current) { + pendingSaveRef.current = true + return + } + + executeSaveRef.current?.() + }, []) + + return { isLoadingSceneRef, saveNow } } diff --git a/packages/editor/src/hooks/use-keyboard.test.ts b/packages/editor/src/hooks/use-keyboard.test.ts index 9df30e4fa8..477ae772c5 100644 --- a/packages/editor/src/hooks/use-keyboard.test.ts +++ b/packages/editor/src/hooks/use-keyboard.test.ts @@ -80,11 +80,21 @@ beforeEach(() => { afterEach(() => { useInteractionScope.getState().end() useEditor.getState().armToolMode({ mode: 'select' }) + useEditor.setState({ selectedItem: null }) clearSceneHistory() }) describe('rotation shortcut ownership', () => { + test('does not reserve R and T for an armed item tool without a placement item', () => { + useEditor.getState().armToolMode({ mode: 'build', tool: 'item' }) + + expect(isToolOwnedRotation()).toBe(false) + }) + test('leaves R and T to the active item placement tool', () => { + useEditor.getState().setSelectedItem({ + asset: { id: 'asset:test' }, + } as never) useEditor.getState().armToolMode({ mode: 'build', tool: 'item' }) expect(isToolOwnedRotation()).toBe(true) diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 094a28cd39..e72cc369e1 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -190,7 +190,11 @@ export const isToolOwnedRotation = () => { (editor.tool === 'door' || editor.tool === 'window' || editor.tool === 'roof' || - editor.tool === 'item' || + // The item tool is mounted for the build mode, but it only owns R/T + // when a catalog item is actually selected and a placement draft can + // exist. Without this check, selecting an existing item in the 2D plan + // while the item tool is armed silently drops the global rotate key. + (editor.tool === 'item' && editor.selectedItem !== null) || editor.tool === 'lean-to-extension') ) } diff --git a/packages/editor/src/hooks/use-registered-tool-enabled.ts b/packages/editor/src/hooks/use-registered-tool-enabled.ts new file mode 100644 index 0000000000..619db30455 --- /dev/null +++ b/packages/editor/src/hooks/use-registered-tool-enabled.ts @@ -0,0 +1,20 @@ +'use client' + +import { isNodeKindEnabled, useScene } from '@pascal-app/core' +import { useEffect } from 'react' +import useEditor, { type Tool } from '../store/use-editor' + +export function useRegisteredToolEnabled(tool: Tool | null): boolean { + const installedPlugins = useScene((state) => state.installedPlugins) + const enabled = tool === null || isNodeKindEnabled(tool, installedPlugins) + + useEffect(() => { + if (enabled || tool === null) return + + // The render gate has already unmounted the tool, so its own cleanup cancels + // any draft before clearing the stale selection prevents reinstall remounts. + if (useEditor.getState().tool === tool) useEditor.getState().setTool(null) + }, [enabled, tool]) + + return enabled +} diff --git a/packages/editor/src/hooks/use-save-shortcut.ts b/packages/editor/src/hooks/use-save-shortcut.ts new file mode 100644 index 0000000000..ad738c52ed --- /dev/null +++ b/packages/editor/src/hooks/use-save-shortcut.ts @@ -0,0 +1,33 @@ +'use client' + +import { useEffect, useRef } from 'react' + +/** + * Claims Cmd/Ctrl+S for the app's save. + * + * Capture phase and ungated on purpose: the browser's "Save page" dialog must + * never appear anywhere in the editor β€” including first-person, studio mode and + * while focus sits in an input, where people still expect the chord to save. + * `e.code` keeps it on the physical S key across keyboard layouts. + */ +export function useSaveShortcut(onSave: () => void) { + const onSaveRef = useRef(onSave) + + useEffect(() => { + onSaveRef.current = onSave + }, [onSave]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return + if (e.code !== 'KeyS') return + + e.preventDefault() + e.stopPropagation() + onSaveRef.current() + } + + window.addEventListener('keydown', handleKeyDown, true) + return () => window.removeEventListener('keydown', handleKeyDown, true) + }, []) +} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 7f94d73da6..29afbb15e8 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -436,7 +436,12 @@ export { commitFreshPlacementSubtree, createFreshPlacementSubtree, } from './lib/fresh-planar-placement' -export { exportSceneToGlb } from './lib/glb-export' +export { exportSceneToGlb, type GlbExportOptions } from './lib/glb-export' +export { + type EditorGridEvent, + type GridEventScreenProjection, + getGridEventScreenProjection, +} from './lib/grid-event-presentation' export { getHistoryCommandState, type HistoryCommandDelegate, @@ -515,6 +520,12 @@ export { metersToLinearUnit, squareMetersToAreaUnit, } from './lib/measurements' +export type { + ModelExport, + ModelExportArtifact, + ModelExportFormat, + ModelExportOptions, +} from './lib/model-export' export { consumePlacementDragRelease } from './lib/placement-drag-release' export { addFreshPlacementMetadata, @@ -533,6 +544,8 @@ export { editorHostPanelRegistry, registerEditorHostPanel, } from './lib/plugin-panels' +export { configureManifoldRuntime } from './lib/print-shell-compiler-manifold-worker' +export type { ManifoldRuntimeOptions } from './lib/print-shell-compiler-protocol' export { createQuickMeasurementPointerScheduler, quickMeasurementContext, @@ -588,6 +601,7 @@ export { resolveFlattenTarget, sculptFieldForSite, } from './lib/terrain-sculpt' +export { exportSceneToUsdz, type UsdzExportOptions } from './lib/usdz-export' // `cn` (twMerge + clsx) β€” used by kind-owned panels in `@pascal-app/ // nodes` so they don't need their own copy / their own tailwind-merge // dependency. diff --git a/packages/editor/src/lib/elevation-guides.test.ts b/packages/editor/src/lib/elevation-guides.test.ts index 2aaa832cfa..8523a5fc61 100644 --- a/packages/editor/src/lib/elevation-guides.test.ts +++ b/packages/editor/src/lib/elevation-guides.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'bun:test' +import { afterEach, describe, expect, test } from 'bun:test' import { type AnyNode, CeilingNode, LevelNode, SlabNode } from '@pascal-app/core' import useElevationGuides from '../store/use-elevation-guides' import { @@ -43,6 +43,10 @@ function structuralScene() { } describe('elevation guides', () => { + afterEach(() => { + useElevationGuides.setState({ guide: null }) + }) + test('collects the level, slab faces, and ceiling plane on the source level', () => { const { level, nodes, slab } = structuralScene() const targets = collectElevationSnapTargets( diff --git a/packages/editor/src/lib/floorplan/floorplan-export.test.ts b/packages/editor/src/lib/floorplan/floorplan-export.test.ts index f8fb37237c..7f00ae63c3 100644 --- a/packages/editor/src/lib/floorplan/floorplan-export.test.ts +++ b/packages/editor/src/lib/floorplan/floorplan-export.test.ts @@ -1,13 +1,30 @@ -import { describe, expect, test } from 'bun:test' -import type { FloorplanGeometry, NodeCategory } from '@pascal-app/core' +import { afterEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeDefinition, + BuildingNode, + type FloorplanGeometry, + type GeometryContext, + LevelNode, + loadPlugin, + type NodeCategory, + nodeRegistry, + registerNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import PDFDocument from 'pdfkit' +import { z } from 'zod' import { splitFloorplanOverlay } from '../../components/editor-2d/renderers/floorplan-registry-layer' import { DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } from './annotation-visibility' import { + collectFloorplanGeometry, + collectFloorplanSchedules, filterFloorplanExportOverlay, fitPlanToBox, isFloorplanExportAnnotationGeometry, isFloorplanNodeInExportScope, partitionFloorplanExportOverlay, + resolveExportLevels, resolveFloorplanExportAnnotationVisibility, resolveFloorplanExportNodeGeometry, resolveFloorplanExportPlacement, @@ -20,6 +37,63 @@ import { rotateFloorplanExportBounds, } from './floorplan-export' import { floorplanGeometryMetadata } from './floorplan-extension' +import { FloorplanPdfDocument } from './floorplan-pdfkit-document' +import { renderFloorplanGeometryToPdfKit } from './floorplan-pdfkit-renderer' + +type GroupGeometry = Extract +type GroupTransform = NonNullable + +function flattenGeometry(geometry: FloorplanGeometry | null): FloorplanGeometry[] { + if (!geometry) return [] + if (geometry.kind !== 'group') return [geometry] + return [geometry, ...geometry.children.flatMap(flattenGeometry)] +} + +function applyGeometryTransforms( + point: readonly [number, number], + transforms: readonly GroupTransform[], +): [number, number] { + let x = point[0] + let y = point[1] + for (let index = transforms.length - 1; index >= 0; index -= 1) { + const transform = transforms[index]! + if (transform.rotate !== undefined) { + const cos = Math.cos(transform.rotate) + const sin = Math.sin(transform.rotate) + const rotatedX = x * cos - y * sin + y = x * sin + y * cos + x = rotatedX + } + if (transform.translate) { + x += transform.translate[0] + y += transform.translate[1] + } + } + return [x, y] +} + +function projectedGeometryPoint( + geometry: FloorplanGeometry, + target: 'circle' | 'image', + transforms: readonly GroupTransform[] = [], +): [number, number] | null { + if (geometry.kind === target) { + const point = + geometry.kind === 'circle' + ? ([geometry.cx, geometry.cy] as const) + : geometry.kind === 'image' + ? geometry.center + : null + return point ? applyGeometryTransforms(point, transforms) : null + } + if (geometry.kind !== 'group') return null + const nestedTransforms = geometry.transform ? [...transforms, geometry.transform] : transforms + for (const child of geometry.children) { + const point = projectedGeometryPoint(child, target, nestedTransforms) + if (point) return point + } + return null +} describe('filterFloorplanExportOverlay', () => { test('preserves annotation metadata while splitting geometry passes', () => { @@ -354,3 +428,426 @@ describe('isFloorplanNodeInExportScope', () => { expect(isFloorplanNodeInExportScope(undefined, 'structure')).toBe(false) }) }) + +describe('collectFloorplanSchedules', () => { + test('omits non-structure schedule contributors under structure scope', () => { + const restoreRegistry = nodeRegistry._snapshot() + const structureKind = 'test:structure-schedule' + const siteKind = 'test:site-schedule' + const levelId = 'level_schedules' as AnyNode['id'] + const structureNodeId = 'structure_scheduled' as AnyNode['id'] + const siteNodeId = 'site_scheduled' as AnyNode['id'] + + const scheduleFor = (title: string) => ({ + id: title.toLowerCase(), + title, + columns: [{ key: 'id', label: 'ID' }], + rows: [{ id: 'row', cells: { id: '1' } }], + }) + + try { + nodeRegistry._reset() + registerNode({ + kind: structureKind, + schemaVersion: 1, + schema: z.object({ type: z.literal(structureKind) }) as never, + category: 'structure', + defaults: () => ({}) as never, + capabilities: {}, + extensions: { + 'pascal:editor/floorplan': { + schedule: () => scheduleFor('Doors'), + }, + }, + } as AnyNodeDefinition) + registerNode({ + kind: siteKind, + schemaVersion: 1, + schema: z.object({ type: z.literal(siteKind) }) as never, + category: 'site', + defaults: () => ({}) as never, + capabilities: {}, + extensions: { + 'pascal:editor/floorplan': { + schedule: () => scheduleFor('Rooms'), + }, + }, + } as AnyNodeDefinition) + + const nodes = { + [levelId]: { + id: levelId, + type: 'level', + visible: true, + children: [structureNodeId, siteNodeId], + }, + [structureNodeId]: { + id: structureNodeId, + type: structureKind, + visible: true, + }, + [siteNodeId]: { + id: siteNodeId, + type: siteKind, + visible: true, + }, + } as unknown as Record + + const full = collectFloorplanSchedules(nodes, levelId, 'metric', 'full') + expect(full.map((schedule) => schedule.title).sort()).toEqual(['Doors', 'Rooms']) + + const structure = collectFloorplanSchedules(nodes, levelId, 'metric', 'structure') + expect(structure.map((schedule) => schedule.title)).toEqual(['Doors']) + } finally { + restoreRegistry() + } + }) +}) + +describe('collectFloorplanGeometry', () => { + test('collects the active Site once below architecture with semantic context and projection', async () => { + const restoreRegistry = nodeRegistry._snapshot() + const activeSiteId = 'site_active' + const otherSiteId = 'site_other' + const enabledPluginId = 'test:site-pdf-enabled' + const disabledPluginId = 'test:site-pdf-disabled' + const enabledKind = 'test:site-pdf-overlay' + const disabledKind = 'test:site-pdf-disabled-overlay' + const architectureKind = 'test:level-architecture' + const semanticChildId = 'site_overlay_detail' + const referencedNodeId = 'site_reference' + const inlinePng = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=' + + const siteDefinition = ( + kind: string, + floorplan: (node: Record, context: GeometryContext) => FloorplanGeometry, + ): AnyNodeDefinition => + ({ + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as never, + category: 'utility', + defaults: () => ({}) as never, + capabilities: {}, + floorplanScope: 'site', + floorplan, + }) as AnyNodeDefinition + + const enabledDefinition = siteDefinition(enabledKind, (siteOverlay, context) => ({ + kind: 'group', + children: [ + { + kind: 'rect', + x: 10, + y: 3, + width: context.children[0]?.id === semanticChildId ? 3 : -3, + height: context.siblings.some(({ id }) => id === 'site_overlay_hidden') ? 4 : -4, + fill: + siteOverlay.id === 'site_overlay' && + context.parent?.id === activeSiteId && + context.resolve(referencedNodeId)?.id === referencedNodeId + ? '#102030' + : '#ff0000', + }, + { + kind: 'path', + d: 'M0,0H4V4H0ZM1,1H3V3H1Z', + fill: '#3f6b2f', + fillRule: 'evenodd', + }, + { + kind: 'image', + url: inlinePng, + center: [10, 3], + width: 1, + height: 1, + }, + { kind: 'circle', cx: 12, cy: 5, r: 0.5 }, + ], + })) + + try { + nodeRegistry._reset() + registerNode({ + kind: architectureKind, + schemaVersion: 1, + schema: z.object({ type: z.literal(architectureKind) }) as never, + category: 'structure', + defaults: () => ({}) as never, + capabilities: {}, + floorplan: () => ({ + kind: 'polygon', + points: [ + [0, 0], + [4, 0], + [4, 4], + ], + }), + } as AnyNodeDefinition) + await loadPlugin({ + id: enabledPluginId, + apiVersion: 1, + nodes: [enabledDefinition], + }) + await loadPlugin({ + id: disabledPluginId, + apiVersion: 1, + nodes: [siteDefinition(disabledKind, () => ({ kind: 'circle', cx: 0, cy: 0, r: 1 }))], + }) + + const activeSite = { + id: activeSiteId, + type: 'site', + parentId: null, + visible: true, + children: [ + 'building_active', + 'site_overlay', + 'site_overlay_hidden', + 'site_overlay_disabled', + ], + } as unknown as AnyNode + const activeBuilding = { + id: 'building_active', + type: 'building', + parentId: activeSiteId, + children: ['level_active', 'level_upper'], + position: [10, 0, 5], + rotation: [0, Math.PI / 2, 0], + } as unknown as AnyNode + const activeLevel = { + id: 'level_active', + type: 'level', + parentId: activeBuilding.id, + children: ['level_architecture'], + } as unknown as AnyNode + const upperLevel = { + id: 'level_upper', + type: 'level', + parentId: activeBuilding.id, + children: ['level_upper_architecture'], + } as unknown as AnyNode + const nodes = { + [activeSite.id]: activeSite, + [activeBuilding.id]: activeBuilding, + [activeLevel.id]: activeLevel, + [upperLevel.id]: upperLevel, + level_upper_architecture: { + id: 'level_upper_architecture', + type: architectureKind, + parentId: upperLevel.id, + visible: true, + } as unknown as AnyNode, + level_architecture: { + id: 'level_architecture', + type: architectureKind, + parentId: activeLevel.id, + visible: true, + } as unknown as AnyNode, + site_overlay: { + id: 'site_overlay', + type: enabledKind, + parentId: null, + children: [semanticChildId], + visible: true, + } as unknown as AnyNode, + [semanticChildId]: { + id: semanticChildId, + type: 'test:site-detail', + parentId: 'site_overlay', + } as unknown as AnyNode, + [referencedNodeId]: { + id: referencedNodeId, + type: 'test:site-reference', + parentId: activeSiteId, + } as unknown as AnyNode, + site_overlay_hidden: { + id: 'site_overlay_hidden', + type: enabledKind, + parentId: activeSiteId, + visible: false, + } as unknown as AnyNode, + site_overlay_disabled: { + id: 'site_overlay_disabled', + type: disabledKind, + parentId: activeSiteId, + visible: true, + } as unknown as AnyNode, + [otherSiteId]: { + id: otherSiteId, + type: 'site', + parentId: null, + children: ['site_overlay_other'], + } as unknown as AnyNode, + site_overlay_other: { + id: 'site_overlay_other', + type: enabledKind, + parentId: otherSiteId, + visible: true, + } as unknown as AnyNode, + } + + const full = collectFloorplanGeometry( + nodes, + activeLevel.id, + 'full', + 'metric', + 'meters', + DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, + 'floor-plan', + 'finished-faces', + [enabledPluginId], + ) + + expect(full.map(({ id }) => id)).toEqual(['site_overlay', 'level_architecture']) + const siteModel = full[0]?.model + if (!siteModel) throw new Error('Expected Site geometry') + const siteParts = flattenGeometry(siteModel) + expect(siteParts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'rect', + width: 3, + height: 4, + fill: '#102030', + }), + expect.objectContaining({ kind: 'path', fillRule: 'evenodd' }), + expect.objectContaining({ kind: 'image', url: inlinePng }), + ]), + ) + expect(projectedGeometryPoint(siteModel, 'image')).toEqual([ + expect.closeTo(2), + expect.closeTo(0), + ]) + expect(projectedGeometryPoint(siteModel, 'circle')).toEqual([ + expect.closeTo(0), + expect.closeTo(2), + ]) + + const rawPdf = new PDFDocument({ autoFirstPage: false, compress: false }) + const chunks: Buffer[] = [] + rawPdf.on('data', (chunk: Buffer) => chunks.push(chunk)) + const completedPdf = Promise.withResolvers() + rawPdf.on('end', () => completedPdf.resolve(Buffer.concat(chunks).toString('latin1'))) + const pdf = new FloorplanPdfDocument(rawPdf, [200, 200]) + pdf.addPage() + for (const { model } of full) { + if (!model) continue + await renderFloorplanGeometryToPdfKit(pdf, model, { + annotationLayer: false, + placement: { x: 20, y: 20, width: 100, height: 100 }, + rotationDeg: 0, + viewport: { x: -4, y: -4, width: 20, height: 20 }, + }) + } + rawPdf.end() + const renderedPdf = await completedPdf.promise + expect(renderedPdf).toMatch(/f\*/) + expect(renderedPdf).toContain('/Subtype /Image') + + const upper = collectFloorplanGeometry( + nodes, + upperLevel.id, + 'full', + 'metric', + 'meters', + DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, + 'floor-plan', + 'finished-faces', + [enabledPluginId], + ) + expect(upper.map(({ id }) => id)).toEqual(['site_overlay', 'level_upper_architecture']) + + const hiddenSiteNodes = { + ...nodes, + [activeSite.id]: { ...activeSite, visible: false } as AnyNode, + } + const hiddenSite = collectFloorplanGeometry( + hiddenSiteNodes, + activeLevel.id, + 'full', + 'metric', + 'meters', + DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, + 'floor-plan', + 'finished-faces', + [enabledPluginId], + ) + expect(hiddenSite.map(({ id }) => id)).toEqual(['level_architecture']) + + const structure = collectFloorplanGeometry( + nodes, + activeLevel.id, + 'structure', + 'metric', + 'meters', + DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, + 'floor-plan', + 'finished-faces', + [enabledPluginId], + ) + expect(structure.map(({ id }) => id)).toEqual(['level_architecture']) + } finally { + restoreRegistry() + } + }) +}) + +describe('resolveExportLevels', () => { + const ground = LevelNode.parse({ id: 'level_ground', parentId: 'building_a', level: 0 }) + const upper = LevelNode.parse({ id: 'level_upper', parentId: 'building_a', level: 1 }) + const roof = LevelNode.parse({ + id: 'level_roof', + parentId: 'building_a', + level: 2, + metadata: { role: 'roof', referenceLevelId: upper.id }, + }) + const attic = LevelNode.parse({ + id: 'level_attic', + parentId: 'building_a', + level: 3, + metadata: { role: 'attic' }, + }) + const building = BuildingNode.parse({ + id: 'building_a', + children: [ground.id, upper.id, roof.id, attic.id], + }) + const nodes: Record = Object.fromEntries( + [building, ground, upper, roof, attic].map((node) => [node.id, node]), + ) + + // The viewer store is a process-wide singleton, so an earlier test file can + // leak a selection into these tests; restore it instead of leaving ours. + const previousSelection = useViewer.getState().selection + + const selectLevel = (levelId: string | null) => { + useViewer.setState({ + selection: { ...previousSelection, buildingId: building.id, levelId }, + } as never) + } + + afterEach(() => { + useViewer.setState({ selection: previousSelection } as never) + }) + + test('skips a dedicated roof support level', () => { + selectLevel(ground.id) + + expect(resolveExportLevels(nodes)).toEqual([ + { id: ground.id, label: 'Level 0' }, + { id: upper.id, label: 'Level 1' }, + { id: attic.id, label: 'Level 3' }, + ]) + }) + + test('skips the roof level when it is the selected level', () => { + selectLevel(roof.id) + + expect(resolveExportLevels(nodes)).toEqual([ + { id: ground.id, label: 'Level 0' }, + { id: upper.id, label: 'Level 1' }, + { id: attic.id, label: 'Level 3' }, + ]) + }) +}) diff --git a/packages/editor/src/lib/floorplan/floorplan-export.tsx b/packages/editor/src/lib/floorplan/floorplan-export.tsx index 2dde3775b2..61478e5d84 100644 --- a/packages/editor/src/lib/floorplan/floorplan-export.tsx +++ b/packages/editor/src/lib/floorplan/floorplan-export.tsx @@ -7,6 +7,7 @@ import { type FloorplanGeometry, type FloorplanPalette, type FloorplanPoint, + isNodeKindEnabled, type LiveNodeOverrides, type NodeCategory, nodeRegistry, @@ -21,10 +22,13 @@ import { resolveSvgAnnotationCollisions } from '../../components/editor-2d/rende import { FloorplanGeometryRenderer } from '../../components/editor-2d/renderers/floorplan-geometry-renderer' import { buildContext, + collectDirectFloorplanScopeNodes, collectFloorplanLinkedLevelNodes, floorplanLayerRank, getFloorplanLevelData, + isFloorplanHierarchyVisible, isFloorplanNodeVisible, + siteToFloorplanTransform, splitFloorplanOverlay, } from '../../components/editor-2d/renderers/floorplan-registry-layer' import useDrawingView, { DRAWING_TYPE_OPTIONS } from '../../store/use-drawing-view' @@ -146,12 +150,27 @@ type ExportGeometry = { annotations: FloorplanGeometry | null } +type ExportGeometryContext = { + children: AnyNode[] + siblings: AnyNode[] + parent: AnyNode + outputTransform: { translate: FloorplanPoint; rotate: number } +} + +type ExportGeometryEntry = { + id: AnyNodeId + node: AnyNode + parentOverride?: AnyNode + context?: ExportGeometryContext + scopeRank?: number +} + export type FloorplanPageLayout = { planBox: { x: number; y: number; width: number; height: number } } export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise { - const nodes = useScene.getState().nodes + const { nodes, installedPlugins } = useScene.getState() const viewer = useViewer.getState() const unit = viewer.unit const metricNotation = viewer.metricNotation @@ -198,8 +217,9 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise, levelId: AnyNodeId, unit: 'metric' | 'imperial', + scope: FloorplanExportScope = 'full', ): FloorplanSchedule[] { const siblingsByType = new Map() const visit = (id: AnyNodeId) => { @@ -304,6 +325,9 @@ export function collectFloorplanSchedules( for (const [kind, definition] of nodeRegistry.entries()) { const scheduleContribution = getFloorplanNodeExtension(definition)?.schedule if (!scheduleContribution) continue + // Same scope gate as geometry: a structure-only PDF must not list rooms + // or other site-category contributors whose plan geometry was excluded. + if (!isFloorplanNodeInExportScope(definition, scope)) continue const siblings = siblingsByType.get(kind) ?? [] const schedule = scheduleContribution({ siblings, nodes, levelId, unit }) if (schedule && schedule.rows.length > 0) schedules.push(schedule) @@ -736,7 +760,7 @@ function applyFloorplanViewport( mounted.svg.insertBefore(background, mounted.svg.firstChild) } -function collectFloorplanGeometry( +export function collectFloorplanGeometry( nodes: Record, levelId: AnyNodeId, scope: FloorplanExportScope, @@ -745,10 +769,11 @@ function collectFloorplanGeometry( annotationVisibility: FloorplanAnnotationVisibility, drawingType: ConstructionDrawingType, wallDimensionReference: FloorplanWallDimensionReference, + installedPlugins: readonly string[], ): ExportGeometry[] { const noLiveOverrides = new Map() const levelNodeIdsByType = new Map() - const entries: { id: AnyNodeId; node: AnyNode; parentOverride?: AnyNode }[] = [] + const entries: ExportGeometryEntry[] = [] const visit = (id: AnyNodeId) => { const node = nodes[id] @@ -773,28 +798,74 @@ function collectFloorplanGeometry( visit(levelId) const activeLevelNode = nodes[levelId] + const collectedIds = new Set(entries.map((entry) => entry.id)) if (activeLevelNode) { - const collectedIds = new Set(entries.map((entry) => entry.id)) for (const linked of collectFloorplanLinkedLevelNodes(nodes, levelId, collectedIds)) { const definition = nodeRegistry.get(linked.node.type) if (isFloorplanNodeVisible(linked.node) && isFloorplanNodeInExportScope(definition, scope)) { const drawingNode = resolveNodeForDrawingType(linked.node, nodes, drawingType) if (drawingNode) { entries.push({ id: linked.id, node: drawingNode, parentOverride: activeLevelNode }) + collectedIds.add(linked.id) } } } } - // Document order is paint order β€” sort the same way the live layer does so - // zones sit under walls/slabs/furniture rather than on top of them. - entries.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type)) + const buildingId = resolveBuildingForLevel(levelId, nodes as Record) + const buildingNode = buildingId ? nodes[buildingId] : undefined + const siteNode = + buildingNode?.type === 'building' && buildingNode.parentId + ? nodes[buildingNode.parentId as AnyNodeId] + : undefined + if (siteNode?.type === 'site' && buildingNode?.type === 'building') { + const siteScopedNodes = collectDirectFloorplanScopeNodes(nodes, siteNode, 'site') + const outputTransform = siteToFloorplanTransform( + buildingNode.position, + buildingNode.rotation[1], + ) + for (const node of siteScopedNodes) { + if (collectedIds.has(node.id) || !isNodeKindEnabled(node.type, installedPlugins)) continue + const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType) + if (!drawingNode) continue + const definition = nodeRegistry.get(drawingNode.type) + if ( + !definition?.floorplan || + !isFloorplanNodeInExportScope(definition, scope) || + !isFloorplanHierarchyVisible(drawingNode, nodes, noLiveOverrides, siteNode.id) + ) { + continue + } + const childIds = 'children' in node ? node.children : undefined + const children = Array.isArray(childIds) + ? childIds.map((childId) => nodes[childId]).filter((child): child is AnyNode => !!child) + : [] + const siblings = siteScopedNodes.filter( + (candidate) => candidate.id !== node.id && candidate.type === node.type, + ) + entries.push({ + id: node.id, + node: drawingNode, + context: { children, siblings, parent: siteNode, outputTransform }, + scopeRank: -1, + }) + collectedIds.add(node.id) + } + } + + // Document order is paint order. Site context sits below level + // architecture, then each scope retains the live layer's z-order. + entries.sort( + (a, b) => + (a.scopeRank ?? 0) - (b.scopeRank ?? 0) || + floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type), + ) // One-shot per-type cache for `computeFloorplanLevelData`; value type is // module-private to the registry layer, so let it infer. const levelDataCache = new Map() const out: ExportGeometry[] = [] - for (const { id, node, parentOverride } of entries) { + for (const { id, node, parentOverride, context } of entries) { const builder = nodeRegistry.get(node.type)?.floorplan if (!builder) continue const levelData = getFloorplanLevelData( @@ -815,9 +886,25 @@ function collectFloorplanGeometry( ), levelData, ) - const ctx = parentOverride ? { ...baseContext, parent: parentOverride } : baseContext - const geometry = builder(node, ctx) - if (!geometry) continue + const ctx = context + ? { + ...baseContext, + children: context.children, + siblings: context.siblings, + parent: context.parent, + } + : parentOverride + ? { ...baseContext, parent: parentOverride } + : baseContext + const builtGeometry = builder(node, ctx) + if (!builtGeometry) continue + const geometry = context + ? { + kind: 'group' as const, + children: [builtGeometry], + transform: context.outputTransform, + } + : builtGeometry const visibleGeometry = filterFloorplanAnnotationGeometry(geometry, annotationVisibility) if (!visibleGeometry) continue const { base, overlay } = splitFloorplanOverlay(visibleGeometry) @@ -938,9 +1025,10 @@ function combineGeometryList( * Levels to export, ordered bottom-to-top. The active building (the building * owning the selected level, or the first one found) contributes all of its * level children; if there is no building wrapper we fall back to the single - * resolved level. + * resolved level. Roof support levels are excluded: they are not occupied + * stories, so they do not get a floor-plan page. */ -function resolveExportLevels(nodes: Record): ExportLevel[] { +export function resolveExportLevels(nodes: Record): ExportLevel[] { const selected = useViewer.getState().selection.levelId as AnyNodeId | null | undefined const activeLevelId = selected && nodes[selected] ? selected : firstLevelId(nodes) if (!activeLevelId) return [] @@ -955,6 +1043,7 @@ function resolveExportLevels(nodes: Record): ExportLevel[] { levelNodes = node ? [node] : [] } + levelNodes = levelNodes.filter((n) => n.metadata.role !== 'roof') levelNodes.sort((a, b) => levelIndexOf(a) - levelIndexOf(b)) return levelNodes.map((n) => ({ id: n.id as AnyNodeId, label: levelLabelOf(n) })) } diff --git a/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts b/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts index c325d0db82..f62a8502dd 100644 --- a/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +++ b/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts @@ -85,6 +85,32 @@ describe('renderFloorplanGeometryToPdfKit', () => { expect([...new Set(baseFonts)]).toEqual(['Courier']) expect([...new Set(fontSizes)]).toEqual(['1.6']) }) + test('uses even-odd fill for compound plugin paths', async () => { + const geometry = { + kind: 'path', + d: 'M0,0H4V4H0ZM1,1H3V3H1Z', + fill: '#3f6b2f', + fillRule: 'evenodd', + } satisfies FloorplanGeometry + + const pdf = await renderTestPdf(geometry) + + expect(pdf).toMatch(/f\*/) + }) + + test('writes a data-url PNG image without resolving it as an asset', async () => { + const geometry = { + kind: 'image', + url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + center: [2, 2], + width: 1, + height: 1, + } satisfies FloorplanGeometry + + const pdf = await renderTestPdf(geometry) + + expect(pdf).toContain('/Subtype /Image') + }) }) async function renderTestPdf(geometry: FloorplanGeometry, rotationDeg = 0): Promise { diff --git a/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.ts b/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.ts index a92f7e349b..4fb0cfe3a8 100644 --- a/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.ts +++ b/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.ts @@ -191,6 +191,7 @@ function paintStyledGeometry( const opacity = geometry.opacity ?? 1 const fillOpacity = (geometry.fillOpacity ?? 1) * opacity const strokeOpacity = (geometry.strokeOpacity ?? 1) * opacity + const fillRule = geometry.fillRule === 'evenodd' ? 'even-odd' : 'non-zero' if (fill) raw.fillColor(fill).fillOpacity(fillOpacity) if (stroke) { @@ -206,8 +207,8 @@ function paintStyledGeometry( ) } - if (fill && stroke) raw.fillAndStroke(fill, stroke) - else if (fill) raw.fill(fill) + if (fill && stroke) raw.fillAndStroke(fill, stroke, fillRule) + else if (fill) raw.fill(fill, fillRule) else if (stroke) raw.stroke(stroke) } @@ -620,29 +621,36 @@ async function drawImage( doc: FloorplanPdfDocument, geometry: Extract, ): Promise { - try { - const url = await loadAssetUrl(geometry.url) - if (!url) return - const response = await fetch(url) - if (!response.ok) return - const dataUrl = await blobToDataUrl(await response.blob()) - const raw = doc.raw - raw.save().translate(geometry.center[0], geometry.center[1]) - if (geometry.rotation) raw.rotate((geometry.rotation * 180) / Math.PI) - raw.opacity(geometry.opacity ?? 1) - const options = - geometry.preserveAspectRatio === 'none' - ? { width: geometry.width, height: geometry.height } - : { - fit: [geometry.width, geometry.height] as [number, number], - align: 'center' as const, - valign: 'center' as const, - } - raw.image(dataUrl, -geometry.width / 2, -geometry.height / 2, options) - raw.restore() - } catch { - return + const source = geometry.url.startsWith('data:') ? geometry.url : await loadAssetUrl(geometry.url) + if (!source) { + throw new Error('[floorplan-export] Could not resolve a floorplan image') + } + + let dataUrl = source + if (!source.startsWith('data:')) { + const response = await fetch(source) + if (!response.ok) { + throw new Error( + `[floorplan-export] Could not load a floorplan image (HTTP ${response.status})`, + ) + } + dataUrl = await blobToDataUrl(await response.blob()) } + + const raw = doc.raw + raw.save().translate(geometry.center[0], geometry.center[1]) + if (geometry.rotation) raw.rotate((geometry.rotation * 180) / Math.PI) + raw.opacity(geometry.opacity ?? 1) + const options = + geometry.preserveAspectRatio === 'none' + ? { width: geometry.width, height: geometry.height } + : { + fit: [geometry.width, geometry.height] as [number, number], + align: 'center' as const, + valign: 'center' as const, + } + raw.image(dataUrl, -geometry.width / 2, -geometry.height / 2, options) + raw.restore() } function blobToDataUrl(blob: Blob): Promise { diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index 5afc8d4c76..c33bead4c0 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -1,11 +1,32 @@ import { afterEach, describe, expect, test } from 'bun:test' -import { type AnyNode, DoorNode, registerNode, sceneRegistry } from '@pascal-app/core' -import { buildDoorPreviewMesh } from '@pascal-app/viewer' +import { + type AnyNode, + type AnyNodeDefinition, + DoorNode, + type GeometryContext, + loadPlugin, + nodeRegistry, + registerNode, + SiteNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { + buildDoorPreviewMesh, + markViewerPresentationTextureBorrowed, + type ViewerPresentationContribution, + viewerPresentationRegistry, +} from '@pascal-app/viewer' import * as THREE from 'three' import type { GLTFWriter } from 'three/examples/jsm/exporters/GLTFExporter.js' import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js' import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' -import { prepareSceneForExport, writeTextureReferenceExtras } from './glb-export' +import { MeshStandardNodeMaterial } from 'three/webgpu' +import { + prepareSceneForExport, + prepareSceneForExportAsync, + writeTextureReferenceExtras, +} from './glb-export' // The reference module reads the storage origin lazily on first use, so // setting the env here (before any validation call) pins it for the file. @@ -16,25 +37,15 @@ afterEach(() => { sceneRegistry.clear() }) -function nodeMaterial(overrides: Record = {}) { - // Duck-typed stand-in for the viewer's MeshStandard/LambertNodeMaterial: - // the exporter keys off `isNodeMaterial` and reads plain PBR props. - return { - isNodeMaterial: true, - name: 'painted', - color: new THREE.Color('#cc3300'), +function nodeMaterial(overrides: Record = {}): THREE.Material { + const material = new MeshStandardNodeMaterial({ + color: '#cc3300', roughness: 0.3, metalness: 0.7, - transparent: false, - opacity: 1, - side: THREE.FrontSide, - alphaTest: 0, - depthWrite: true, - depthTest: true, - vertexColors: false, - toneMapped: true, - ...overrides, - } as unknown as THREE.Material + }) + material.name = 'painted' + Object.assign(material, overrides) + return material } function meshWithNodeMaterial(material: THREE.Material): THREE.Mesh { @@ -88,6 +99,317 @@ describe('prepareSceneForExport', () => { expect(material.color.getHexString()).toBe('cc3300') }) + test('replaces only the cloned registered subtree with bake-only geometry', () => { + const restoreRegistry = nodeRegistry._snapshot() + try { + let receivedParentId: string | null | undefined + registerNode({ + kind: 'test:bake-geometry', + schemaVersion: 1, + schema: DoorNode, + category: 'utility', + defaults: () => ({}) as never, + capabilities: {}, + bake: 'replace', + bakeGeometry: (_node, context) => { + receivedParentId = context.parent?.id + const group = new THREE.Group() + const instances = new THREE.InstancedMesh( + new THREE.BoxGeometry(0.1, 1, 0.1), + new THREE.MeshStandardMaterial({ color: '#228833' }), + 1, + ) + instances.name = 'baked-instance' + instances.setMatrixAt(0, new THREE.Matrix4().makeTranslation(4, 0, 2)) + group.add(instances) + return group + }, + } as AnyNodeDefinition) + + const root = new THREE.Group() + const liveGroup = new THREE.Group() + liveGroup.position.set(2, 0, 3) + const liveMesh = meshWithNodeMaterial(nodeMaterial()) + liveMesh.name = 'live-procedural-candidate' + liveGroup.add(liveMesh) + const before = meshWithNodeMaterial(nodeMaterial()) + before.name = 'before-bake-replacement' + const after = meshWithNodeMaterial(nodeMaterial()) + after.name = 'after-bake-replacement' + root.add(before, liveGroup, after) + + const siteId = 'site_bake' + const grassId = 'grass_bake' + sceneRegistry.nodes.set(grassId, liveGroup) + const nodes = { + [siteId]: { + object: 'node', + id: siteId, + type: 'site', + parentId: null, + children: [grassId], + } as unknown as AnyNode, + [grassId]: { + object: 'node', + id: grassId, + type: 'test:bake-geometry', + parentId: siteId, + visible: true, + } as unknown as AnyNode, + } + + const { scene } = prepareSceneForExport(root, nodes) + const exported = scene.getObjectByName(grassId) + const baked = exported?.getObjectByName('baked-instance') + + expect(receivedParentId).toBe(siteId) + expect(liveGroup.getObjectByName('live-procedural-candidate')).toBe(liveMesh) + expect(scene.getObjectByName('live-procedural-candidate')).toBeUndefined() + expect(exported?.position.toArray()).toEqual([2, 0, 3]) + expect(scene.children.map((child) => child.name)).toEqual([ + 'before-bake-replacement', + grassId, + 'after-bake-replacement', + ]) + expect((baked as THREE.InstancedMesh | undefined)?.isInstancedMesh).toBe(true) + expect((baked as THREE.InstancedMesh | undefined)?.count).toBe(1) + expect(((baked as THREE.InstancedMesh).material as THREE.Material).isMaterial).toBe(true) + } finally { + restoreRegistry() + } + }) + + test.each([ + 'sync', + 'portable-sync', + 'portable-async', + ] as const)('bakes Site bounds and same-kind sibling reservations through %s hooks', async (mode) => { + const restoreRegistry = nodeRegistry._snapshot() + try { + const kind = 'test:site-context-bake' + const nodeId = 'site_context_bake' + const site = SiteNode.parse({ + polygon: { + type: 'polygon', + points: [ + [0, 0], + [12, 0], + [12, 8], + [0, 8], + ], + }, + children: [nodeId, 'declared_sibling', 'linked_sibling', 'other_kind'], + }) + const otherSite = SiteNode.parse({ + polygon: { + type: 'polygon', + points: [ + [0, 0], + [30, 0], + [30, 4], + [0, 4], + ], + }, + children: ['other_site_sibling'], + }) + const buildGeometry = (context: GeometryContext) => { + const points = (context.parent as SiteNode | null)?.polygon?.points ?? [[0, 0]] + const xs = points.map(([x]) => x) + const zs = points.map(([, z]) => z) + const reservedWidth = context.siblings.reduce( + (width, sibling) => width + Number(sibling.metadata?.reservedWidth ?? 0), + 0, + ) + return new THREE.Mesh( + new THREE.BoxGeometry( + Math.max(...xs) - Math.min(...xs) - reservedWidth, + 1, + Math.max(...zs) - Math.min(...zs), + ), + new THREE.MeshStandardMaterial(), + ) + } + registerNode({ + kind, + schemaVersion: 1, + schema: DoorNode, + category: 'furnish', + defaults: () => ({}) as never, + capabilities: {}, + bake: 'replace', + ...(mode === 'portable-async' + ? { bakeGeometryAsync: async (_node, context) => buildGeometry(context) } + : { bakeGeometry: (_node, context) => buildGeometry(context) }), + } as AnyNodeDefinition) + const nodes = { + [site.id]: site, + [otherSite.id]: otherSite, + [nodeId]: { + id: nodeId, + type: kind, + parentId: null, + visible: true, + metadata: { reservedWidth: 100 }, + }, + declared_sibling: { + id: 'declared_sibling', + type: kind, + parentId: null, + metadata: { reservedWidth: 2 }, + }, + pointer_sibling: { + id: 'pointer_sibling', + type: kind, + parentId: site.id, + metadata: { reservedWidth: 3 }, + }, + linked_sibling: { + id: 'linked_sibling', + type: kind, + parentId: site.id, + metadata: { reservedWidth: 1 }, + }, + other_kind: { + id: 'other_kind', + type: 'test:other-kind', + parentId: site.id, + metadata: { reservedWidth: 100 }, + }, + other_site_sibling: { + id: 'other_site_sibling', + type: kind, + parentId: otherSite.id, + metadata: { reservedWidth: 4 }, + }, + } as unknown as Record + const root = new THREE.Group() + const source = new THREE.Group() + root.add(source) + sceneRegistry.nodes.set(nodeId, source) + const prepare = mode === 'sync' ? prepareSceneForExport : prepareSceneForExportAsync + const exportedSize = async () => { + const prepared = await prepare(root, nodes) + try { + const exported = prepared.scene.getObjectByName(nodeId)! + return new THREE.Box3().setFromObject(exported).getSize(new THREE.Vector3()).toArray() + } finally { + prepared.dispose() + } + } + + expect(await exportedSize()).toEqual([6, 1, 8]) + nodes[nodeId]!.parentId = otherSite.id + expect(await exportedSize()).toEqual([26, 1, 4]) + nodes[nodeId]!.parentId = null + site.children = site.children.filter((id) => id !== nodeId) + expect(await exportedSize()).toEqual([0, 1, 0]) + } finally { + restoreRegistry() + } + }) + + test('selective exports omit a procedural kind without changing the live scene or later exports', () => { + const restoreRegistry = nodeRegistry._snapshot() + try { + const kind = 'test:optional-ground-cover' + registerNode({ + kind, + schemaVersion: 1, + schema: DoorNode, + category: 'furnish', + defaults: () => ({}) as never, + capabilities: {}, + bake: 'replace', + bakeGeometry: () => + new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshStandardMaterial({ color: '#228833' }), + ), + } as AnyNodeDefinition) + const root = new THREE.Group() + const building = meshWithNodeMaterial(nodeMaterial()) + const grass = new THREE.Group() + const water = meshWithNodeMaterial(nodeMaterial()) + root.add(building, grass, water) + const nodes: Record = {} + for (const [id, type, object] of [ + ['building_export', 'building', building], + ['grass_export', kind, grass], + ['water_export', 'test:water', water], + ] as const) { + sceneRegistry.nodes.set(id, object) + nodes[id] = { id, type, visible: true } as unknown as AnyNode + } + + const complete = prepareSceneForExport(root, nodes) + const selected = prepareSceneForExport(root, nodes, { + excludedNodeTypes: [kind], + onlyVisible: false, + }) + const stl = new STLExporter() + expect(stl.parse(complete.scene, { binary: true }).getUint32(80, true)).toBe(36) + expect(stl.parse(selected.scene, { binary: true }).getUint32(80, true)).toBe(24) + const obj = new OBJExporter().parse(selected.scene) + expect(obj).not.toContain('grass_export') + expect(obj).toContain('building_export') + expect(obj).toContain('water_export') + expect(selected.scene.getObjectByName('grass_export')).toBeUndefined() + expect(root.children).toEqual([building, grass, water]) + expect(grass.visible).toBe(true) + expect(nodes.grass_export?.visible).toBe(true) + const later = prepareSceneForExport(root, nodes) + expect(stl.parse(later.scene, { binary: true }).getUint32(80, true)).toBe(36) + } finally { + restoreRegistry() + } + }) + + test('excluding a parent skips descendant bake failures and animation tracks', () => { + const restoreRegistry = nodeRegistry._snapshot() + try { + registerNode({ + kind: 'test:unavailable-bake', + schemaVersion: 1, + schema: DoorNode, + category: 'furnish', + defaults: () => ({}) as never, + capabilities: {}, + bakeGeometry: () => { + throw new Error('Procedural content unavailable') + }, + } as AnyNodeDefinition) + const root = new THREE.Group() + const excluded = new THREE.Group() + const procedural = new THREE.Group() + const door = new THREE.Group() + const leaf = meshWithNodeMaterial(nodeMaterial()) + leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 } + door.add(leaf) + excluded.add(procedural, door) + root.add(excluded, meshWithNodeMaterial(nodeMaterial())) + const nodes: Record = {} + for (const [id, type, object, parentId] of [ + ['procedural_child', 'test:unavailable-bake', procedural, 'excluded_parent'], + ['door_child', 'door', door, 'excluded_parent'], + ['excluded_parent', 'test:optional-parent', excluded, null], + ] as const) { + sceneRegistry.nodes.set(id, object) + nodes[id] = { id, type, parentId, visible: true } as unknown as AnyNode + } + + expect(() => prepareSceneForExport(root, nodes)).toThrow('Procedural content unavailable') + const selected = prepareSceneForExport(root, nodes, { + excludedNodeTypes: ['test:optional-parent'], + }) + expect(new STLExporter().parse(selected.scene, { binary: true }).getUint32(80, true)).toBe(12) + expect(selected.animations).toEqual([]) + expect(selected.scene.getObjectByName('door_child')).toBeUndefined() + expect(excluded.children).toEqual([procedural, door]) + } finally { + restoreRegistry() + } + }) + test('shared NodeMaterial instances convert to a single shared material', () => { const root = new THREE.Group() const shared = nodeMaterial() @@ -145,7 +467,11 @@ describe('prepareSceneForExport', () => { expect(placeholder.flipY).toBe(stamped.flipY) expect(placeholder.colorSpace).toBe(stamped.colorSpace) expect(placeholder.userData.pascalTextureRef).toEqual(stamped.userData.pascalTextureRef) - expect(material.normalMap).toBe(unstamped) + expect(material.normalMap).toBeInstanceOf(THREE.DataTexture) + expect(Array.from((material.normalMap as THREE.DataTexture).image.data as Uint8Array)).toEqual([ + 128, 128, 255, 255, + ]) + expect(material.normalMap?.userData.pascalTextureRef).toBeUndefined() const sharedMaterial = (scene.children[1] as THREE.Mesh).material as THREE.MeshStandardMaterial expect(sharedMaterial.map).toBe(placeholder) }) @@ -240,6 +566,64 @@ describe('prepareSceneForExport', () => { expect(animations).toHaveLength(0) }) + test('inherits hidden Site visibility for detached declared children and their descendants', async () => { + const restoreRegistry = nodeRegistry._snapshot() + try { + const kind = 'test:detached-site-visibility' + const childId = 'detached_site_child' + const descendantId = 'detached_site_descendant' + const explicitId = 'explicit_site_child' + const unownedId = 'unowned_site_child' + const hiddenSite = SiteNode.parse({ + visible: false, + children: [childId, explicitId], + }) + const visibleSite = SiteNode.parse({ visible: true }) + registerNode({ + kind, + schemaVersion: 1, + schema: DoorNode, + category: 'furnish', + defaults: () => ({}) as never, + capabilities: {}, + bakeGeometryAsync: async () => + new THREE.Mesh(new THREE.BoxGeometry(2, 1, 3), new THREE.MeshStandardMaterial()), + } as AnyNodeDefinition) + const nodes = { + [hiddenSite.id]: hiddenSite, + [visibleSite.id]: visibleSite, + [childId]: { id: childId, type: kind, parentId: null, visible: true }, + [descendantId]: { id: descendantId, type: kind, parentId: childId, visible: true }, + [explicitId]: { id: explicitId, type: kind, parentId: visibleSite.id, visible: true }, + [unownedId]: { id: unownedId, type: kind, parentId: null, visible: true }, + } as unknown as Record + const allIds = [childId, descendantId, explicitId, unownedId] + const root = new THREE.Group() + for (const id of [hiddenSite.id, visibleSite.id, ...allIds]) { + const object = new THREE.Group() + root.add(object) + sceneRegistry.nodes.set(id, object) + } + const exportedIds = async (onlyVisible?: boolean) => { + const prepared = await prepareSceneForExportAsync(root, nodes, { onlyVisible }) + try { + return allIds.filter((id) => prepared.scene.getObjectByName(id) !== undefined) + } finally { + prepared.dispose() + } + } + + expect(await exportedIds()).toEqual([explicitId, unownedId]) + expect(await exportedIds(false)).toEqual(allIds) + nodes[hiddenSite.id] = { ...hiddenSite, visible: true } + expect(await exportedIds()).toEqual(allIds) + nodes[hiddenSite.id] = hiddenSite + expect(await exportedIds()).toEqual([explicitId, unownedId]) + } finally { + restoreRegistry() + } + }) + test('can include hidden scene nodes when visible-only export is disabled', () => { const root = new THREE.Group() const itemGroup = new THREE.Group() @@ -671,4 +1055,650 @@ describe('prepareSceneForExport', () => { expect(panel!.quaternion.angleTo(new THREE.Quaternion())).toBeLessThan(1e-4) } }) + + describe('requireSynchronousBake', () => { + test('rejects a retained async-only kind instead of returning its proxy', () => { + const restoreRegistry = nodeRegistry._snapshot() + try { + const kind = 'test:async-only-synchronous-export' + const nodeId = 'async_only_synchronous_export' + registerNode({ + kind, + schemaVersion: 1, + schema: DoorNode, + category: 'furnish', + defaults: () => ({}) as never, + capabilities: {}, + bake: 'replace', + bakeGeometryAsync: async () => + new THREE.Mesh(new THREE.BoxGeometry(4, 5, 6), new THREE.MeshStandardMaterial()), + } as AnyNodeDefinition) + + const proxyGeometry = new THREE.BoxGeometry(0.25, 0.5, 0.75) + const proxyMaterial = new THREE.MeshStandardMaterial() + let sourceGeometryDisposals = 0 + let sourceMaterialDisposals = 0 + proxyGeometry.addEventListener('dispose', () => { + sourceGeometryDisposals += 1 + }) + proxyMaterial.addEventListener('dispose', () => { + sourceMaterialDisposals += 1 + }) + const proxy = new THREE.Mesh(proxyGeometry, proxyMaterial) + proxy.name = 'async-only-proxy' + const source = new THREE.Group() + source.add(proxy) + const root = new THREE.Group() + root.add(source) + sceneRegistry.nodes.set(nodeId, source) + const nodes = { + [nodeId]: { id: nodeId, type: kind, visible: true }, + } as unknown as Record + + const compatible = prepareSceneForExport(root, nodes) + expect(compatible.scene.getObjectByName('async-only-proxy')).toBeDefined() + compatible.dispose() + + let rejection: unknown + try { + prepareSceneForExport(root, nodes, { requireSynchronousBake: true }) + } catch (error) { + rejection = error + } + + expect(rejection).toBeInstanceOf(Error) + const message = rejection instanceof Error ? rejection.message : '' + expect(message).toContain(kind) + expect(message).toMatch(/\bGLB\b/) + expect(message).toMatch(/\bUSDZ\b/) + expect(message).toMatch(/exclud/i) + expect(sourceGeometryDisposals).toBe(0) + expect(sourceMaterialDisposals).toBe(0) + expect(root.children).toEqual([source]) + expect(source.children).toEqual([proxy]) + } finally { + restoreRegistry() + } + }) + + test('allows a valid model when every async-only kind is pruned', async () => { + const restoreRegistry = nodeRegistry._snapshot() + const { installedPlugins, hasExplicitPluginInstallState } = useScene.getState() + try { + const excludedKind = 'test:excluded-synchronous-export' + const hiddenKind = 'test:hidden-synchronous-export' + const descendantKind = 'test:descendant-synchronous-export' + const disabledKind = 'test:disabled-synchronous-export' + const disabledPluginId = 'test:disabled-synchronous-export-plugin' + const asyncOnlyDefinition = (kind: string): AnyNodeDefinition => + ({ + kind, + schemaVersion: 1, + schema: DoorNode, + category: 'furnish', + defaults: () => ({}) as never, + capabilities: {}, + bake: 'replace', + bakeGeometryAsync: async () => { + throw new Error(`Pruned async-only builder ran for ${kind}`) + }, + }) as AnyNodeDefinition + + for (const kind of [excludedKind, hiddenKind, descendantKind]) { + registerNode(asyncOnlyDefinition(kind)) + } + await loadPlugin({ + id: disabledPluginId, + apiVersion: 1, + nodes: [asyncOnlyDefinition(disabledKind)], + }) + useScene.getState().setInstalledPlugins([], { explicit: true }) + + const valid = new THREE.Mesh( + new THREE.BoxGeometry(2, 3, 4), + new THREE.MeshStandardMaterial(), + ) + valid.name = 'valid-model' + const excluded = new THREE.Group() + const hidden = new THREE.Group() + const disabled = new THREE.Group() + const excludedParent = new THREE.Group() + const descendant = new THREE.Group() + excludedParent.add(descendant) + const root = new THREE.Group() + root.add(valid, excluded, hidden, disabled, excludedParent) + + const excludedId = 'excluded_async_only' + const hiddenId = 'hidden_async_only' + const disabledId = 'disabled_async_only' + const excludedParentId = 'excluded_async_only_parent' + const descendantId = 'descendant_async_only' + sceneRegistry.nodes.set(excludedId, excluded) + sceneRegistry.nodes.set(hiddenId, hidden) + sceneRegistry.nodes.set(disabledId, disabled) + sceneRegistry.nodes.set(excludedParentId, excludedParent) + sceneRegistry.nodes.set(descendantId, descendant) + const nodes = { + [excludedId]: { id: excludedId, type: excludedKind, visible: true }, + [hiddenId]: { id: hiddenId, type: hiddenKind, visible: false }, + [disabledId]: { id: disabledId, type: disabledKind, visible: true }, + [excludedParentId]: { + id: excludedParentId, + type: 'test:excluded-synchronous-export-parent', + visible: true, + children: [descendantId], + }, + [descendantId]: { + id: descendantId, + type: descendantKind, + parentId: excludedParentId, + visible: true, + }, + } as unknown as Record + + const prepared = prepareSceneForExport(root, nodes, { + excludedNodeTypes: [excludedKind, 'test:excluded-synchronous-export-parent'], + requireSynchronousBake: true, + }) + + const validModel = prepared.scene.getObjectByName('valid-model') + expect(validModel).toBeDefined() + const size = new THREE.Box3().setFromObject(validModel!).getSize(new THREE.Vector3()) + expect(size.toArray()).toEqual([2, 3, 4]) + for (const id of [excludedId, hiddenId, disabledId, excludedParentId, descendantId]) { + expect(prepared.scene.getObjectByName(id)).toBeUndefined() + } + prepared.dispose() + } finally { + useScene + .getState() + .setInstalledPlugins(installedPlugins, { explicit: hasExplicitPluginInstallState }) + restoreRegistry() + } + }) + + test('uses synchronous geometry when a retained kind provides both hooks', () => { + const restoreRegistry = nodeRegistry._snapshot() + try { + const kind = 'test:dual-hook-synchronous-export' + const nodeId = 'dual_hook_synchronous_export' + registerNode({ + kind, + schemaVersion: 1, + schema: DoorNode, + category: 'furnish', + defaults: () => ({}) as never, + capabilities: {}, + bake: 'replace', + bakeGeometry: () => + new THREE.Mesh(new THREE.BoxGeometry(2, 3, 4), new THREE.MeshStandardMaterial()), + bakeGeometryAsync: async () => + new THREE.Mesh(new THREE.BoxGeometry(8, 8, 8), new THREE.MeshStandardMaterial()), + } as AnyNodeDefinition) + + const source = new THREE.Group() + source.add( + new THREE.Mesh(new THREE.BoxGeometry(0.25, 0.5, 0.75), new THREE.MeshStandardMaterial()), + ) + const root = new THREE.Group() + root.add(source) + sceneRegistry.nodes.set(nodeId, source) + const nodes = { + [nodeId]: { id: nodeId, type: kind, visible: true }, + } as unknown as Record + + const prepared = prepareSceneForExport(root, nodes, { + requireSynchronousBake: true, + }) + + const exported = prepared.scene.getObjectByName(nodeId) + expect(exported).toBeDefined() + const size = new THREE.Box3().setFromObject(exported!).getSize(new THREE.Vector3()) + expect(size.toArray()).toEqual([2, 3, 4]) + expect(root.children).toEqual([source]) + prepared.dispose() + } finally { + restoreRegistry() + } + }) + }) + + test('awaits async bake geometry with full semantic context after selection', async () => { + const restoreRegistry = nodeRegistry._snapshot() + try { + const retainedKind = 'test:async-context-bake' + let asyncCalls = 0 + let syncCalls = 0 + registerNode({ + kind: retainedKind, + schemaVersion: 1, + schema: DoorNode, + category: 'furnish', + defaults: () => ({}) as never, + capabilities: {}, + bake: 'replace', + bakeGeometry: () => { + syncCalls += 1 + throw new Error('sync builder must not run when async geometry is available') + }, + bakeGeometryAsync: async (_node, context) => { + asyncCalls += 1 + await Promise.resolve() + const obstacle = context.resolve<{ metadata?: { acceptanceWidth?: number } }>( + 'door_obstacle_async_context', + ) + return new THREE.Mesh( + new THREE.BoxGeometry(obstacle?.metadata?.acceptanceWidth ?? 0, 1, 1), + new THREE.MeshStandardMaterial(), + ) + }, + } as AnyNodeDefinition) + const root = new THREE.Group() + const source = new THREE.Group() + const obstacle = new THREE.Group() + root.add(source, obstacle) + sceneRegistry.nodes.set('async_context_node', source) + sceneRegistry.nodes.set('door_obstacle_async_context', obstacle) + const nodes = { + async_context_node: { + id: 'async_context_node', + type: retainedKind, + visible: true, + }, + door_obstacle_async_context: { + id: 'door_obstacle_async_context', + type: 'test:excluded-obstacle', + visible: true, + metadata: { acceptanceWidth: 2 }, + }, + } as unknown as Record + + const prepared = await prepareSceneForExportAsync(root, nodes, { + excludedNodeTypes: ['test:excluded-obstacle'], + }) + const baked = prepared.scene.children.find((child) => child instanceof THREE.Mesh) + expect(asyncCalls).toBe(1) + expect(syncCalls).toBe(0) + expect(baked).toBeInstanceOf(THREE.Mesh) + if (baked instanceof THREE.Mesh) { + baked.geometry.computeBoundingBox() + expect(baked.geometry.boundingBox?.getSize(new THREE.Vector3()).x).toBeCloseTo(2) + } + expect(prepared.animations).toEqual([]) + expect(root.children).toEqual([source, obstacle]) + prepared.dispose() + } finally { + restoreRegistry() + } + }) + + test('does not invoke async builders for excluded or invisible nodes', async () => { + const restoreRegistry = nodeRegistry._snapshot() + try { + let calls = 0 + for (const kind of ['test:excluded-async-bake', 'test:hidden-async-bake']) { + registerNode({ + kind, + schemaVersion: 1, + schema: DoorNode, + category: 'furnish', + defaults: () => ({}) as never, + capabilities: {}, + bake: 'replace', + bakeGeometryAsync: async () => { + calls += 1 + throw new Error('unselected async builder ran') + }, + } as AnyNodeDefinition) + } + const root = new THREE.Group() + const excluded = new THREE.Group() + const hidden = new THREE.Group() + const retained = meshWithNodeMaterial(nodeMaterial()) + root.add(excluded, hidden, retained) + sceneRegistry.nodes.set('excluded_async_node', excluded) + sceneRegistry.nodes.set('hidden_async_node', hidden) + const nodes = { + excluded_async_node: { + id: 'excluded_async_node', + type: 'test:excluded-async-bake', + visible: true, + }, + hidden_async_node: { + id: 'hidden_async_node', + type: 'test:hidden-async-bake', + visible: false, + }, + } as unknown as Record + + const prepared = await prepareSceneForExportAsync(root, nodes, { + excludedNodeTypes: ['test:excluded-async-bake'], + onlyVisible: true, + }) + expect(calls).toBe(0) + expect(prepared.scene.children).toHaveLength(1) + prepared.dispose() + } finally { + restoreRegistry() + } + }) + + test('disposes only export-owned resources when a later async builder fails', async () => { + const restoreRegistry = nodeRegistry._snapshot() + try { + const ownedGeometry = new THREE.BoxGeometry() + const ownedMaterial = new THREE.MeshStandardMaterial() + const sourceGeometry = new THREE.BoxGeometry() + const sourceMaterial = new THREE.MeshStandardMaterial() + let ownedGeometryDisposals = 0 + let ownedMaterialDisposals = 0 + let sourceGeometryDisposals = 0 + let sourceMaterialDisposals = 0 + ownedGeometry.addEventListener('dispose', () => { + ownedGeometryDisposals += 1 + }) + ownedMaterial.addEventListener('dispose', () => { + ownedMaterialDisposals += 1 + }) + sourceGeometry.addEventListener('dispose', () => { + sourceGeometryDisposals += 1 + }) + sourceMaterial.addEventListener('dispose', () => { + sourceMaterialDisposals += 1 + }) + registerNode({ + kind: 'test:first-owned-async-bake', + schemaVersion: 1, + schema: DoorNode, + category: 'furnish', + defaults: () => ({}) as never, + capabilities: {}, + bake: 'replace', + bakeGeometryAsync: async () => new THREE.Mesh(ownedGeometry, ownedMaterial), + } as AnyNodeDefinition) + registerNode({ + kind: 'test:later-failing-async-bake', + schemaVersion: 1, + schema: DoorNode, + category: 'furnish', + defaults: () => ({}) as never, + capabilities: {}, + bake: 'replace', + bakeGeometryAsync: async () => { + throw new Error('acceptance injected async failure') + }, + } as AnyNodeDefinition) + const root = new THREE.Group() + const first = new THREE.Group() + const failing = new THREE.Group() + const source = new THREE.Mesh(sourceGeometry, sourceMaterial) + root.add(first, failing, source) + sceneRegistry.nodes.set('first_owned_async_node', first) + sceneRegistry.nodes.set('later_failing_async_node', failing) + const nodes = { + first_owned_async_node: { + id: 'first_owned_async_node', + type: 'test:first-owned-async-bake', + visible: true, + }, + later_failing_async_node: { + id: 'later_failing_async_node', + type: 'test:later-failing-async-bake', + visible: true, + }, + } as unknown as Record + + await expect(prepareSceneForExportAsync(root, nodes)).rejects.toThrow( + 'acceptance injected async failure', + ) + expect(ownedGeometryDisposals).toBe(1) + expect(ownedMaterialDisposals).toBe(1) + expect(sourceGeometryDisposals).toBe(0) + expect(sourceMaterialDisposals).toBe(0) + expect(root.children).toEqual([first, failing, source]) + } finally { + restoreRegistry() + } + }) + + test('includes a registered static presentation only when explicitly selected', async () => { + const priorPresentations = viewerPresentationRegistry.getSnapshot() + viewerPresentationRegistry.reset() + try { + const contribution: ViewerPresentationContribution = { + id: 'test:static-export-presentation', + component: async () => ({ default: () => null }), + staticExport: { + label: 'Acceptance surroundings', + build: async ({ nodes, onlyVisible, excludedNodeTypes }) => { + const semantic = nodes.semantic_context_node as + | { metadata?: { acceptanceDimensions?: [number, number, number] } } + | undefined + const dimensions = semantic?.metadata?.acceptanceDimensions ?? [0, 0, 0] + return new THREE.Mesh( + new THREE.BoxGeometry( + dimensions[0], + onlyVisible ? 0 : dimensions[1], + excludedNodeTypes.includes('test:excluded-context') ? dimensions[2] : 0, + ), + new THREE.MeshStandardMaterial({ color: '#4a6b3d' }), + ) + }, + }, + } + viewerPresentationRegistry.register(contribution) + const root = new THREE.Group() + root.add(meshWithNodeMaterial(nodeMaterial())) + const nodes = { + semantic_context_node: { + id: 'semantic_context_node', + type: 'test:semantic-context', + visible: true, + metadata: { acceptanceDimensions: [3, 1, 2] }, + }, + } as unknown as Record + + const defaultArtifact = await prepareSceneForExportAsync(root, nodes, { + onlyVisible: false, + }) + expect( + defaultArtifact.scene.getObjectByProperty('name', 'test:static-export-presentation'), + ).toBeUndefined() + defaultArtifact.dispose() + + const selectedArtifact = await prepareSceneForExportAsync(root, nodes, { + excludedNodeTypes: ['test:excluded-context'], + includedPresentationIds: [contribution.id], + onlyVisible: false, + }) + const presentation = selectedArtifact.scene.getObjectByProperty('name', contribution.id) + expect(presentation?.userData).toMatchObject({ + label: 'Acceptance surroundings', + pascalPresentationId: contribution.id, + }) + const bounds = new THREE.Box3().setFromObject(presentation!) + expect(bounds.getSize(new THREE.Vector3()).toArray()).toEqual([3, 1, 2]) + selectedArtifact.dispose() + } finally { + viewerPresentationRegistry.reset() + for (const contribution of priorPresentations) { + viewerPresentationRegistry.register(contribution) + } + } + }) + + test('preserves marked borrowed presentation textures and disposes owned maps', async () => { + await withCanvasCapture(async (canvasPixels) => { + const priorPresentations = viewerPresentationRegistry.getSnapshot() + viewerPresentationRegistry.reset() + const borrowed = new THREE.DataTexture(new Uint8Array([20, 40, 60, 255]), 1, 1) + const owned = new THREE.DataTexture(new Uint8Array([80, 100, 120, 255]), 1, 1) + const borrowedOnFailure = new THREE.DataTexture(new Uint8Array([140, 160, 180, 255]), 1, 1) + const ownedOnFailure = new THREE.DataTexture(new Uint8Array([200, 220, 240, 255]), 1, 1) + markViewerPresentationTextureBorrowed(borrowed) + markViewerPresentationTextureBorrowed(borrowedOnFailure) + const disposals = { + borrowed: 0, + owned: 0, + borrowedOnFailure: 0, + ownedOnFailure: 0, + } + for (const [texture, key] of [ + [borrowed, 'borrowed'], + [owned, 'owned'], + [borrowedOnFailure, 'borrowedOnFailure'], + [ownedOnFailure, 'ownedOnFailure'], + ] as const) { + texture.addEventListener('dispose', () => { + disposals[key] += 1 + }) + } + const textureContribution = ( + id: string, + texture: THREE.Texture, + ): ViewerPresentationContribution => ({ + id, + component: async () => ({ default: () => null }), + staticExport: { + label: id, + build: () => + new THREE.Mesh( + new THREE.PlaneGeometry(1, 1), + new THREE.MeshStandardMaterial({ map: texture }), + ), + }, + }) + try { + viewerPresentationRegistry.register( + textureContribution('test:borrowed-presentation-texture', borrowed), + ) + viewerPresentationRegistry.register( + textureContribution('test:owned-presentation-texture', owned), + ) + const success = await prepareSceneForExportAsync( + new THREE.Group(), + {}, + { + includedPresentationIds: [ + 'test:borrowed-presentation-texture', + 'test:owned-presentation-texture', + ], + }, + ) + const generatedTextures: THREE.Texture[] = [] + success.scene.traverse((object) => { + if (!(object as THREE.Mesh).isMesh) return + const mesh = object as THREE.Mesh + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + for (const material of materials) { + const map = (material as THREE.MeshStandardMaterial).map + if (map) generatedTextures.push(map) + } + }) + expect(generatedTextures).toHaveLength(2) + expect( + generatedTextures.every((texture) => (texture as THREE.CanvasTexture).isCanvasTexture), + ).toBe(true) + expect(generatedTextures.map(canvasPixels).sort()).toEqual( + [ + [20, 40, 60, 255], + [80, 100, 120, 255], + ].sort(), + ) + let generatedTextureDisposals = 0 + for (const texture of generatedTextures) { + texture.addEventListener('dispose', () => { + generatedTextureDisposals += 1 + }) + } + success.dispose() + expect(generatedTextureDisposals).toBe(2) + expect(disposals).toMatchObject({ borrowed: 0, owned: 1 }) + expect(Array.from(borrowed.image.data as Uint8Array)).toEqual([20, 40, 60, 255]) + + viewerPresentationRegistry.register( + textureContribution('test:borrowed-presentation-texture-failure', borrowedOnFailure), + ) + viewerPresentationRegistry.register( + textureContribution('test:owned-presentation-texture-failure', ownedOnFailure), + ) + viewerPresentationRegistry.register({ + id: 'test:failing-presentation-after-textures', + component: async () => ({ default: () => null }), + staticExport: { + label: 'Injected failure after texture ownership', + build: () => { + throw new Error('acceptance presentation texture failure') + }, + }, + }) + await expect( + prepareSceneForExportAsync( + new THREE.Group(), + {}, + { + includedPresentationIds: [ + 'test:borrowed-presentation-texture-failure', + 'test:owned-presentation-texture-failure', + 'test:failing-presentation-after-textures', + ], + }, + ), + ).rejects.toThrow('acceptance presentation texture failure') + expect(disposals).toEqual({ + borrowed: 0, + owned: 1, + borrowedOnFailure: 0, + ownedOnFailure: 1, + }) + expect(Array.from(borrowedOnFailure.image.data as Uint8Array)).toEqual([140, 160, 180, 255]) + } finally { + viewerPresentationRegistry.reset() + for (const contribution of priorPresentations) { + viewerPresentationRegistry.register(contribution) + } + } + }) + }) }) + +async function withCanvasCapture( + run: (pixels: (texture: THREE.Texture) => number[]) => Promise, +): Promise { + const globals = globalThis as unknown as { document?: Document } + const previousDocument = globals.document + const pixelsByCanvas = new WeakMap() + globals.document = { + createElement: (tagName: string) => { + if (tagName !== 'canvas') throw new Error(`Unexpected element request: ${tagName}`) + const canvas = { + width: 0, + height: 0, + getContext: () => ({ + createImageData: (width: number, height: number) => + ({ + colorSpace: 'srgb', + data: new Uint8ClampedArray(width * height * 4), + height, + width, + }) as ImageData, + putImageData: (image: ImageData) => { + pixelsByCanvas.set(canvas, new Uint8ClampedArray(image.data)) + }, + }), + } as unknown as HTMLCanvasElement + return canvas + }, + } as unknown as Document + + try { + await run((texture) => { + const pixels = pixelsByCanvas.get(texture.image as HTMLCanvasElement) + if (!pixels) throw new Error('Generated canvas texture has no captured pixels') + return Array.from(pixels) + }) + } finally { + if (previousDocument) globals.document = previousDocument + else delete globals.document + } +} diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index 72461f3e5c..36faa8bcd6 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -1,23 +1,32 @@ import { type AnyNode, + type AnyNodeId, bakePolicyOf, type DoorNode, emitter, + findLevelAncestorId, + type GeometryContext, getLevelDisplayName, + isNodeKindEnabled, isOperationDoorType, itemClipRegistry, type LevelNode, + levelBaseElevationAt, nodeRegistry, sceneRegistry, + useScene, type WindowNode, type ZoneNode, } from '@pascal-app/core' import { getPascalTextureRef, + isViewerPresentationTextureBorrowed, poseDoorMovingParts, poseWindowMovingParts, SCENE_LAYER, snapLevelsToTruePositions, + type ViewerPresentationContribution, + viewerPresentationRegistry, } from '@pascal-app/viewer' import type { Object3D } from 'three' import * as THREE from 'three' @@ -27,6 +36,11 @@ import { type GLTFWriter, } from 'three/examples/jsm/exporters/GLTFExporter.js' import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js' +import { + disposeExportResources, + normalizePortableScene, + normalizeViewerArtifactMaterials, +} from './portable-export' /** * Two TRS samples (closed vs open) differing by less than this are treated as @@ -44,11 +58,23 @@ type SwingLeafMarker = { axis: 'y'; openRotationY: number } export type GlbExport = { scene: THREE.Object3D animations: THREE.AnimationClip[] + warnings: string[] + dispose: () => void } export type GlbExportOptions = { textures?: 'embed' | 'reference' onlyVisible?: boolean + /** Omit these node kinds and their rendered subtrees before baking. */ + excludedNodeTypes?: readonly string[] + /** Selected static viewer-presentation contributions; omitted means none. */ + includedPresentationIds?: readonly string[] + /** Portable downloads are static; the baked viewer retains internal clips. */ + purpose?: 'portable' | 'viewer' + /** Called for actual lossy portable conversions discovered during preparation. */ + onWarning?: (warning: string) => void + /** Reject retained node kinds whose export geometry can only be baked asynchronously. */ + requireSynchronousBake?: boolean } /** Resolve after the next couple of animation frames, giving React/R3F time to @@ -118,114 +144,473 @@ export async function exportSceneToGlb( options: GlbExportOptions = {}, ): Promise { const textureMode = options.textures ?? 'embed' + const prepared = await preparePortableSceneFromViewer(sceneGroup, nodes, options) + for (const warning of prepared.warnings) options.onWarning?.(warning) + try { + return await serializePreparedSceneToGlb(prepared, { + textures: textureMode, + onlyVisible: options.onlyVisible, + }) + } finally { + prepared.dispose() + } +} +/** + * Capture the live renderer synchronously, restore editor presentation, then do + * async offscreen material/presentation work against only the owned clone. + */ +export async function preparePortableSceneFromViewer( + sceneGroup: Object3D, + nodes: Record, + options: GlbExportOptions = {}, +): Promise { emitter.emit('thumbnail:before-capture', undefined) - // Snap levels to their true stacked positions (like thumbnail capture) so the - // export always reflects the clean stacked building, regardless of the live - // levelMode (exploded/solo) or an unsettled level lerp that could otherwise - // bake a level at a stray offset. const restoreLevels = snapLevelsToTruePositions() - let prepared: ReturnType + let preparation: SceneExportPreparation try { - prepared = prepareSceneForExport(sceneGroup, nodes, options) + preparation = startSceneExportPreparation(sceneGroup, nodes, { + ...options, + purpose: options.purpose ?? 'portable', + }) } finally { restoreLevels() emitter.emit('thumbnail:after-capture', undefined) } - const { scene: exportScene, animations } = prepared + return completeSceneExportPreparation(preparation) +} +export function serializePreparedSceneToGlb( + prepared: GlbExport, + options: Pick = {}, +): Promise { const exporter = new GLTFExporter() - if (textureMode === 'reference') exporter.register(textureReferencePlugin) - // Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read - // those directly. WebGPUTextureUtils blits each one to RGBA on its own - // offscreen renderer (passing the live renderer would resize/draw over the - // editor canvas), letting the exporter embed standard textures. + if ((options.textures ?? 'embed') === 'reference') { + exporter.register(textureReferencePlugin) + } exporter.setTextureUtils(WebGPUTextureUtils) return new Promise((resolve, reject) => { exporter.parse( - exportScene, + prepared.scene, (gltf) => { resolve(gltf as ArrayBuffer) }, (error) => { reject(error) }, - { binary: true, animations, onlyVisible: options.onlyVisible ?? true }, + { + binary: true, + animations: prepared.animations, + onlyVisible: options.onlyVisible ?? true, + }, ) }) } +type RegistryEntry = readonly [id: string, original: THREE.Object3D] + +type SelectedPresentation = { + contribution: ViewerPresentationContribution + configuration: unknown +} + +type SceneExportPreparation = { + scene: THREE.Object3D + nodes: Record + options: GlbExportOptions + registryEntries: RegistryEntry[] + cloneByOriginal: Map + builders: Map< + string, + { + sync: ((node: AnyNode, ctx: GeometryContext) => THREE.Object3D) | undefined + async: ((node: AnyNode, ctx: GeometryContext) => Promise) | undefined + } + > + geometryContext: Pick + presentations: SelectedPresentation[] +} + /** - * Build an engine-agnostic export tree from the live scene graph. The result is - * a standalone three.js scene plus glTF animation clips, ready for - * `GLTFExporter` β€” it carries no Pascal runtime dependency. - * - * - Clones the source so live objects are never mutated. - * - Converts WebGPU NodeMaterials to classic glTF-standard materials. - * `GLTFExporter` only recognises `isMeshStandardMaterial` / - * `isMeshBasicMaterial`; the viewer's `MeshStandard/LambertNodeMaterial` set - * `isNodeMaterial` instead, so without this every surface exports as a blank - * default material. - * - Bakes open motions into glTF animation clips via kind-owned registry - * hooks, plus the legacy door/window build-once + pose-at-t primitives - * (`pascalSwingLeaf` for doors, `poseWindowMovingParts` for windows). - * - Stamps `name` + `extras` identity from `sceneRegistry` so selection/hover - * survive the bake with no in-memory registry, and strips all other userData - * so editor/runtime ephemera never leak into glTF extras. + * Build the legacy synchronous artifact used by geometry-only/print exports. + * The default remains viewer-purpose so existing internal baked-viewer clips + * are unchanged; portable downloads use the async entry point below. */ export function prepareSceneForExport( source: THREE.Object3D, nodes: Record, options: GlbExportOptions = {}, ): GlbExport { - const scene = source.clone(true) - const cloneByOriginal = pairClones(source, scene) - - // Kinds with `def.bake === 'strip'` (scans/LiDAR, guides/floorplan) are heavy - // reference assets stored elsewhere and aren't part of the compiled building. - // Drop them from the artifact entirely β€” `/viewer` re-adds them from the scene - // graph, gated by the project's public-visibility flags, so they never bloat - // the shared GLB nor slip past those flags into a static public file. - // (`'replace'` kinds are *kept*: static for portability, the viewer swaps them - // for a live render.) - for (const [id, original] of sceneRegistry.nodes) { + const preparation = startSceneExportPreparation(source, nodes, { + ...options, + purpose: options.purpose ?? 'viewer', + }) + try { + replaceBakeGeometrySync(preparation) + return finishSceneExportPreparation(preparation) + } catch (error) { + disposeExportResources(preparation.scene) + throw error + } +} + +/** Capture once, await async bake hooks, then normalize a static portable tree. */ +export async function prepareSceneForExportAsync( + source: THREE.Object3D, + nodes: Record, + options: GlbExportOptions = {}, +): Promise { + const preparation = startSceneExportPreparation(source, nodes, { + ...options, + purpose: options.purpose ?? 'portable', + }) + return completeSceneExportPreparation(preparation) +} + +function startSceneExportPreparation( + source: THREE.Object3D, + inputNodes: Record, + options: GlbExportOptions, +): SceneExportPreparation { + const nodes = structuredClone(inputNodes) + const registryEntries = Array.from(sceneRegistry.nodes.entries()) + const excludedNodeTypes = new Set(options.excludedNodeTypes) + const excludedObjects = new Set() + const builders: SceneExportPreparation['builders'] = new Map() + const sceneState = useScene.getState() + + for (const [id, original] of registryEntries) { const node = nodes[id] - if (node && bakePolicyOf(node.type) === 'strip') { - cloneByOriginal.get(original)?.removeFromParent() + if (!node) continue + const installedPlugins = sceneState.hasExplicitPluginInstallState + ? sceneState.installedPlugins + : undefined + if (!isNodeKindEnabled(node.type, installedPlugins)) { + excludedObjects.add(original) + continue + } + const definition = nodeRegistry.get(node.type) + builders.set(id, { + sync: definition?.bakeGeometry as + | ((node: AnyNode, ctx: GeometryContext) => THREE.Object3D) + | undefined, + async: definition?.bakeGeometryAsync as + | ((node: AnyNode, ctx: GeometryContext) => Promise) + | undefined, + }) + if (bakePolicyOf(node.type) === 'strip' || excludedNodeTypes.has(node.type)) { + excludedObjects.add(original) } } + const selectedIds = new Set(options.includedPresentationIds ?? []) + const registeredPresentations = viewerPresentationRegistry.getSnapshot() + const presentations: SelectedPresentation[] = [] + for (const id of selectedIds) { + const contribution = registeredPresentations.find((entry) => entry.id === id) + if (!contribution?.staticExport) { + throw new Error(`Static viewer presentation "${id}" is not registered`) + } + if (contribution.pluginId && !sceneState.installedPlugins.includes(contribution.pluginId)) { + throw new Error(`Static viewer presentation "${id}" belongs to an uninstalled plugin`) + } + presentations.push({ + contribution, + configuration: contribution.configuration?.getSnapshot(), + }) + } + + const cloneByOriginal = new Map() + const scene = cloneSceneForExport(source, excludedObjects, cloneByOriginal) + cloneSkinnedSkeletons(cloneByOriginal) if (options.onlyVisible ?? true) { - pruneHiddenSceneNodes(cloneByOriginal, nodes) + pruneHiddenSceneNodes(cloneByOriginal, nodes, registryEntries) } - // Object3Ds that carry node identity β€” never strip these even when they sit on - // a non-scene layer. Some are metadata-only: a zone's visible fill/wall meshes - // are stripped, but its identity node stays to carry the polygon that /viewer - // reconstructs the room from. + return { + scene, + nodes, + options, + registryEntries, + cloneByOriginal, + builders, + geometryContext: { + levelData: undefined, + materials: structuredClone(sceneState.materials), + }, + presentations, + } +} + +async function completeSceneExportPreparation( + preparation: SceneExportPreparation, +): Promise { + try { + await replaceBakeGeometryAsync(preparation) + await appendSelectedPresentations(preparation) + const prepared = finishSceneExportPreparation(preparation) + prepared.warnings.push( + ...(preparation.options.purpose === 'viewer' + ? normalizeViewerArtifactMaterials(prepared.scene) + : normalizePortableScene(prepared.scene)), + ) + return prepared + } catch (error) { + disposeExportResources(preparation.scene) + throw error + } +} + +function finishSceneExportPreparation(preparation: SceneExportPreparation): GlbExport { + const { scene, cloneByOriginal, nodes, options, registryEntries } = preparation const identityNodes = new Set() - for (const original of sceneRegistry.nodes.values()) { + for (const [, original] of registryEntries) { const clone = cloneByOriginal.get(original) if (clone) identityNodes.add(clone) } pruneNonRenderableMeshes(scene, identityNodes) sanitizeMaterialGroups(scene, identityNodes) - convertMaterials(scene, options.textures ?? 'embed') + convertMaterials(scene, options.textures ?? 'embed', options.purpose ?? 'viewer') const retainedCloneByOriginal = retainedClones(scene, cloneByOriginal) - const { clips, clipNamesByNode } = bakeAnimationClips(retainedCloneByOriginal, nodes) + const animation = + options.purpose === 'viewer' + ? bakeAnimationClips(retainedCloneByOriginal, nodes, registryEntries) + : { clips: [], clipNamesByNode: new Map() } + stampIdentity(scene, retainedCloneByOriginal, nodes, animation.clipNamesByNode, registryEntries) + + let disposed = false + return { + scene, + animations: animation.clips, + warnings: [], + dispose: () => { + if (disposed) return + disposed = true + disposeExportResources(scene) + }, + } +} + +function replaceBakeGeometrySync(preparation: SceneExportPreparation): void { + for (const [id, original] of preparation.registryEntries) { + const node = preparation.nodes[id] + const builders = preparation.builders.get(id) + if (!node || !builders) continue + const cloned = preparation.cloneByOriginal.get(original) + if (!cloned || !isDescendantOf(cloned, preparation.scene)) continue + if (preparation.options.requireSynchronousBake && builders.async && !builders.sync) { + throw new Error( + `Node kind "${node.type}" can only bake geometry asynchronously. Choose GLB/USDZ or exclude it from the export.`, + ) + } + if (!builders.sync) continue + replaceBakedNode( + preparation, + id, + original, + builders.sync( + node, + buildBakeGeometryContext(node, preparation.nodes, preparation.geometryContext), + ), + 'bakeGeometry', + ) + } +} + +async function replaceBakeGeometryAsync(preparation: SceneExportPreparation): Promise { + for (const [id, original] of preparation.registryEntries) { + const node = preparation.nodes[id] + const builders = preparation.builders.get(id) + if (!node || (!builders?.async && !builders?.sync)) continue + const cloned = preparation.cloneByOriginal.get(original) + if (!cloned || !isDescendantOf(cloned, preparation.scene)) continue + const context = buildBakeGeometryContext(node, preparation.nodes, preparation.geometryContext) + const replacement = builders.async + ? await builders.async(node, context) + : builders.sync!(node, context) + replaceBakedNode( + preparation, + id, + original, + replacement, + builders.async ? 'bakeGeometryAsync' : 'bakeGeometry', + ) + } +} + +function replaceBakedNode( + preparation: SceneExportPreparation, + id: string, + original: THREE.Object3D, + replacement: THREE.Object3D, + hook: 'bakeGeometry' | 'bakeGeometryAsync', +): void { + const cloned = preparation.cloneByOriginal.get(original) + if (!cloned || !isDescendantOf(cloned, preparation.scene)) return + const parent = cloned.parent + if (!parent) throw new Error(`Cannot replace root export geometry for node ${id}`) + if (replacement === cloned || replacement.parent) { + throw new Error( + `${hook} for ${preparation.nodes[id]?.type} must return a new detached Object3D`, + ) + } + + const siblingIndex = parent.children.indexOf(cloned) + replacement.position.copy(cloned.position) + replacement.quaternion.copy(cloned.quaternion) + replacement.scale.copy(cloned.scale) + replacement.matrix.copy(cloned.matrix) + replacement.matrixAutoUpdate = cloned.matrixAutoUpdate + replacement.visible = cloned.visible + replacement.layers.mask = cloned.layers.mask + replacement.renderOrder = cloned.renderOrder + parent.remove(cloned) + parent.add(replacement) + const appendedIndex = parent.children.indexOf(replacement) + + parent.children.splice(appendedIndex, 1) + parent.children.splice(siblingIndex, 0, replacement) + preparation.cloneByOriginal.set(original, replacement) +} +function ownBorrowedPresentationTextures(root: THREE.Object3D): void { + const ownedTextures = new Map() + root.traverse((object) => { + const renderable = object as THREE.Mesh + if (!renderable.material) return + const materials = Array.isArray(renderable.material) + ? renderable.material + : [renderable.material] + for (const material of materials) { + const textured = material as THREE.Material & Record + for (const slot of REFERENCE_MAP_SLOTS) { + const sourceTexture = textured[slot] + if ( + !(sourceTexture instanceof THREE.Texture) || + !isViewerPresentationTextureBorrowed(sourceTexture) + ) { + continue + } + let ownedTexture = ownedTextures.get(sourceTexture) + if (!ownedTexture) { + ownedTexture = sourceTexture.clone() + ownedTexture.userData = structuredClone(sourceTexture.userData) + ownedTexture.needsUpdate = true + ownedTextures.set(sourceTexture, ownedTexture) + } + textured[slot] = ownedTexture + } + } + }) +} + +async function appendSelectedPresentations(preparation: SceneExportPreparation): Promise { + for (const { contribution, configuration } of preparation.presentations) { + const staticExport = contribution.staticExport! + const built = await staticExport.build({ + nodes: preparation.nodes, + configuration, + onlyVisible: preparation.options.onlyVisible ?? true, + excludedNodeTypes: preparation.options.excludedNodeTypes ?? [], + }) + if (!built) continue + if (built.parent) { + throw new Error(`Static viewer presentation "${contribution.id}" returned an attached root`) + } + ownBorrowedPresentationTextures(built) + const wrapper = new THREE.Group() + wrapper.name = contribution.id + wrapper.userData = { + pascalPresentationId: contribution.id, + label: staticExport.label, + } + wrapper.add(built) + preparation.scene.add(wrapper) + } +} - stampIdentity(scene, retainedCloneByOriginal, nodes, clipNamesByNode) +function buildBakeGeometryContext( + node: AnyNode, + nodes: Record, + base: Pick, +): GeometryContext { + const resolve = (id: AnyNodeId): N | undefined => nodes[id] as N | undefined + const childIds = Array.isArray((node as { children?: AnyNodeId[] }).children) + ? (node as { children: AnyNodeId[] }).children + : [] + const children = childIds + .map((id) => nodes[id]) + .filter((child): child is AnyNode => child !== undefined) + const allNodes = node.parentId ? undefined : Object.values(nodes) + const parent = node.parentId + ? (nodes[node.parentId] ?? null) + : (allNodes?.find( + (candidate) => + candidate.type === 'site' && + 'children' in candidate && + Array.isArray(candidate.children) && + candidate.children.includes(node.id), + ) ?? null) + const siblingIds = + parent && Array.isArray((parent as { children?: AnyNodeId[] }).children) + ? (parent as { children: AnyNodeId[] }).children + : [] + let siblings: AnyNode[] + if (parent?.type === 'site') { + const declaredChildren = new Set(siblingIds) + siblings = (allNodes ?? Object.values(nodes)).filter( + (candidate) => + candidate.id !== node.id && + candidate.type === node.type && + (candidate.parentId === parent.id || declaredChildren.has(candidate.id)), + ) + } else { + siblings = siblingIds + .filter((id) => id !== node.id) + .map((id) => nodes[id]) + .filter((sibling): sibling is AnyNode => sibling?.type === node.type) + } + const levelId = findLevelAncestorId(node.id, nodes) + const levelBaseAt = (x: number, z: number) => + levelId ? levelBaseElevationAt(nodes, levelId, x, z) : 0 - return { scene, animations: clips } + return { + resolve, + children, + siblings, + parent, + levelBaseAt, + levelData: base.levelData, + materials: base.materials, + } +} + +function isDescendantOf(object: THREE.Object3D, ancestor: THREE.Object3D): boolean { + let current: THREE.Object3D | null = object + while (current) { + if (current === ancestor) return true + current = current.parent + } + return false } function pruneHiddenSceneNodes( cloneByOriginal: Map, nodes: Record, + registryEntries: readonly RegistryEntry[], ) { const visibility = new Map() + const declaredSiteParents = new Map() + for (const node of Object.values(nodes)) { + if (node.type !== 'site' || !('children' in node) || !Array.isArray(node.children)) continue + for (const childId of node.children) { + const child = nodes[childId] + if (child && !child.parentId && !declaredSiteParents.has(childId)) { + declaredSiteParents.set(childId, node.id) + } + } + } const isVisible = (id: string, path: Set): boolean => { const cached = visibility.get(id) @@ -237,19 +622,20 @@ function pruneHiddenSceneNodes( visibility.set(id, false) return false } - if (!node.parentId || path.has(id)) { + const parentId = node.parentId || declaredSiteParents.get(id) + if (!parentId || path.has(id)) { visibility.set(id, true) return true } path.add(id) - const visible = isVisible(node.parentId, path) + const visible = isVisible(parentId, path) path.delete(id) visibility.set(id, visible) return visible } - for (const [id, original] of sceneRegistry.nodes) { + for (const [id, original] of registryEntries) { if (isVisible(id, new Set())) continue cloneByOriginal.get(original)?.removeFromParent() } @@ -264,26 +650,100 @@ function retainedClones( return new Map(Array.from(cloneByOriginal.entries()).filter(([, clone]) => retained.has(clone))) } -/** - * Pair each original Object3D with its clone. `clone(true)` builds children in - * source order, so parallel pre-order traversals line up 1:1 β€” this is how we - * map `sceneRegistry`'s live refs onto the export tree without mutating either. - */ -function pairClones( +type ResourceCloneCache = { + geometries: Map + materials: Map + textures: Map +} + +/** Skip excluded subtrees before cloning large procedural instance buffers. */ +function cloneSceneForExport( source: THREE.Object3D, - clone: THREE.Object3D, -): Map { - const originals: THREE.Object3D[] = [] - const clones: THREE.Object3D[] = [] - source.traverse((object) => originals.push(object)) - clone.traverse((object) => clones.push(object)) - - const map = new Map() - for (let i = 0; i < originals.length; i++) { - const target = clones[i] - if (target) map.set(originals[i]!, target) + excludedObjects: Set, + cloneByOriginal: Map, + cache: ResourceCloneCache = { + geometries: new Map(), + materials: new Map(), + textures: new Map(), + }, +): THREE.Object3D { + if (excludedObjects.has(source)) return new THREE.Group() + + const clone = source.clone(false) + clone.userData = structuredClone(source.userData) + const renderable = source as THREE.Mesh + const renderableClone = clone as THREE.Mesh + if (renderable.geometry) { + let geometry = cache.geometries.get(renderable.geometry) + if (!geometry) { + geometry = renderable.geometry.clone() + cache.geometries.set(renderable.geometry, geometry) + } + renderableClone.geometry = geometry + } + if (renderable.material) { + const cloneMaterial = (material: THREE.Material): THREE.Material => { + let result = cache.materials.get(material) + if (result) return result + result = material.clone() + const textured = result as THREE.Material & Record + for (const slot of REFERENCE_MAP_SLOTS) { + const texture = textured[slot] + if (!(texture instanceof THREE.Texture)) continue + let textureClone = cache.textures.get(texture) + if (!textureClone) { + textureClone = texture.clone() + textureClone.userData = structuredClone(texture.userData) + textureClone.needsUpdate = true + cache.textures.set(texture, textureClone) + } + textured[slot] = textureClone + } + cache.materials.set(material, result) + return result + } + if (Array.isArray(renderable.material)) { + const materialSlots = renderable.material as Array + renderableClone.material = materialSlots.map((material) => + material ? cloneMaterial(material) : material, + ) as THREE.Material[] + } else { + renderableClone.material = cloneMaterial(renderable.material) + } + } + + cloneByOriginal.set(source, clone) + for (const child of source.children) { + if (!excludedObjects.has(child)) { + clone.add(cloneSceneForExport(child, excludedObjects, cloneByOriginal, cache)) + } + } + return clone +} + +function cloneSkinnedSkeletons(cloneByOriginal: Map): void { + for (const [original, cloned] of cloneByOriginal) { + const source = original as THREE.SkinnedMesh + const target = cloned as THREE.SkinnedMesh + if (!source.isSkinnedMesh) continue + const bones = source.skeleton.bones.map((bone) => { + const clonedBone = cloneByOriginal.get(bone) + if (!(clonedBone as THREE.Bone | undefined)?.isBone) { + throw new Error( + `Skinned mesh "${source.name}" references a bone outside its export subtree`, + ) + } + return clonedBone as THREE.Bone + }) + target.bindMode = source.bindMode + target.bind( + new THREE.Skeleton( + bones, + source.skeleton.boneInverses.map((inverse) => inverse.clone()), + ), + source.bindMatrix.clone(), + ) } - return map } // A single empty geometry shared by every container mesh we neutralise below β€” @@ -342,8 +802,8 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set 0) { - renderable.geometry = EMPTY_GEOMETRY - renderable.material = PLACEHOLDER_MATERIAL + renderable.geometry = EMPTY_GEOMETRY.clone() + renderable.material = PLACEHOLDER_MATERIAL.clone() } else { toRemove.push(object) } @@ -361,12 +821,12 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set m == null) ) { - renderable.material = renderable.material.map((m) => m ?? PLACEHOLDER_MATERIAL) + renderable.material = renderable.material.map((m) => m ?? PLACEHOLDER_MATERIAL.clone()) } const mesh = object as THREE.Mesh if (!mesh.isMesh || isRenderableMesh(mesh)) return if (mesh.children.length > 0) { - mesh.geometry = EMPTY_GEOMETRY + mesh.geometry = EMPTY_GEOMETRY.clone() } else { toRemove.push(mesh) } @@ -389,8 +849,8 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set) { const toRemove: THREE.Object3D[] = [] @@ -410,15 +870,15 @@ function sanitizeMaterialGroups(root: THREE.Object3D, identityNodes: Set 0 || identityNodes.has(mesh)) { - mesh.geometry = EMPTY_GEOMETRY - mesh.material = PLACEHOLDER_MATERIAL + mesh.geometry = EMPTY_GEOMETRY.clone() + mesh.material = PLACEHOLDER_MATERIAL.clone() } else { toRemove.push(mesh) } return } - // Only the group list needs repair β€” share the attribute/index refs - // instead of geometry.clone(), which deep-copies every vertex buffer. + // Only the group list needs repair; reuse export-owned attributes rather + // than duplicating every vertex buffer. if (validGroups.length !== groups.length) { const geometry = new THREE.BufferGeometry() geometry.index = mesh.geometry.index @@ -431,7 +891,7 @@ function sanitizeMaterialGroups(root: THREE.Object3D, identityNodes: Set ({ ...g })) mesh.geometry = geometry } - mesh.material = materials.map((m) => m ?? PLACEHOLDER_MATERIAL) + mesh.material = materials.map((m) => m ?? PLACEHOLDER_MATERIAL.clone()) }) for (const object of toRemove) { object.removeFromParent() @@ -481,29 +941,44 @@ const REFERENCE_MAP_SLOTS = [ 'anisotropyMap', ] as const -function convertMaterials(root: THREE.Object3D, textureMode: 'embed' | 'reference') { +function convertMaterials( + root: THREE.Object3D, + textureMode: 'embed' | 'reference', + purpose: 'portable' | 'viewer', +) { const cache = new Map() const placeholderCache = new Map() root.traverse((object) => { const mesh = object as THREE.Mesh if (!mesh.isMesh) return const material = mesh.material - if (Array.isArray(material)) { - mesh.material = material.map((m) => convertMaterial(m, cache, textureMode, placeholderCache)) - return - } - // glTF has no BackSide β€” GLTFExporter renders the *front* face for any - // non-DoubleSide material, which inverts a BackSide surface (e.g. the - // ceiling underside, meant to be seen from the room). Flip the mesh winding - // so the intended face shows with the FrontSide material convertMaterial - // produces. Per-mesh geometry clone keeps shared geometry untouched. + const materialArray = Array.isArray(material) ? material : [material] if ( - (material as { isNodeMaterial?: boolean }).isNodeMaterial && - material.side === THREE.BackSide + purpose === 'viewer' && + materialArray.length === 1 && + (materialArray[0] as { isNodeMaterial?: boolean }).isNodeMaterial && + materialArray[0]!.side === THREE.BackSide ) { mesh.geometry = flipGeometryWinding(mesh.geometry) } - mesh.material = convertMaterial(material, cache, textureMode, placeholderCache) + const converted = materialArray.map((entry) => + convertMaterial(entry, cache, textureMode, placeholderCache, purpose), + ) + const color = mesh.geometry.getAttribute('color') + let hasVertexAlpha = false + if (color?.itemSize === 4) { + for (let index = 0; index < color.count; index++) { + if (color.getW(index) < 1) { + hasVertexAlpha = true + break + } + } + } + for (const entry of converted) { + const textured = entry as THREE.Material & { alphaMap?: THREE.Texture | null } + if (hasVertexAlpha || textured.alphaMap) entry.transparent = true + } + mesh.material = Array.isArray(material) ? converted : converted[0]! }) } @@ -555,6 +1030,7 @@ function convertMaterial( cache: Map, textureMode: 'embed' | 'reference', placeholderCache: Map, + purpose: 'portable' | 'viewer', ): THREE.Material { const isNodeMaterial = (material as { isNodeMaterial?: boolean }).isNodeMaterial === true if (!isNodeMaterial) { @@ -589,7 +1065,8 @@ function convertMaterial( target.opacity = material.opacity // BackSide is flipped to FrontSide (with the mesh winding reversed in // convertMaterials) because glTF has no back-face-only mode. - target.side = material.side === THREE.BackSide ? THREE.FrontSide : material.side + target.side = + purpose === 'viewer' && material.side === THREE.BackSide ? THREE.FrontSide : material.side target.alphaTest = material.alphaTest target.depthWrite = material.depthWrite target.depthTest = material.depthTest @@ -695,11 +1172,12 @@ function createReferencePlaceholder(texture: THREE.Texture): THREE.Texture { function bakeAnimationClips( cloneByOriginal: Map, nodes: Record, + registryEntries: readonly RegistryEntry[], ): { clips: THREE.AnimationClip[]; clipNamesByNode: Map } { const clips: THREE.AnimationClip[] = [] const clipNamesByNode = new Map() - for (const [id, original] of sceneRegistry.nodes) { + for (const [id, original] of registryEntries) { const node = nodes[id] const target = cloneByOriginal.get(original) if (!node || !target) continue @@ -1052,12 +1530,16 @@ function stampIdentity( cloneByOriginal: Map, nodes: Record, clipNamesByNode: Map, + registryEntries: readonly RegistryEntry[], ) { scene.traverse((object) => { - object.userData = {} + const presentationId = object.userData.pascalPresentationId + const label = object.userData.label + object.userData = + typeof presentationId === 'string' ? { pascalPresentationId: presentationId, label } : {} }) - for (const [id, original] of sceneRegistry.nodes) { + for (const [id, original] of registryEntries) { const node = nodes[id] const target = cloneByOriginal.get(original) if (!node || !target) continue diff --git a/packages/editor/src/lib/grid-event-presentation.ts b/packages/editor/src/lib/grid-event-presentation.ts new file mode 100644 index 0000000000..679a596b5b --- /dev/null +++ b/packages/editor/src/lib/grid-event-presentation.ts @@ -0,0 +1,16 @@ +import type { GridEvent } from '@pascal-app/core' + +export type GridEventScreenProjection = { + pointer: [number, number] + localToScreen: [number, number, number, number, number, number] +} + +export type EditorGridEvent = GridEvent & { + screenProjection?: GridEventScreenProjection +} + +export function getGridEventScreenProjection( + event: GridEvent, +): GridEventScreenProjection | undefined { + return (event as EditorGridEvent).screenProjection +} diff --git a/packages/editor/src/lib/history.test.ts b/packages/editor/src/lib/history.test.ts index f356459afb..2f362a6c5b 100644 --- a/packages/editor/src/lib/history.test.ts +++ b/packages/editor/src/lib/history.test.ts @@ -1,194 +1,542 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' -import { - type AnyNode, - type AnyNodeId, - BuildingNode, - clearSceneHistory, - emitter, - LevelNode, - nodeRegistry, - useScene, -} from '@pascal-app/core' -import useInteractionScope from '../store/use-interaction-scope' -import { - getHistoryCommandState, - installHistoryCommandDelegate, - runRedo, - runUndo, - shouldCancelDraftOnHistoryJump, - subscribeHistoryCommandState, -} from './history' - -type RafFn = (cb: (time: number) => void) => number -;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (cb) => { - cb(0) - return 0 +import { describe, expect, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +function runSourceHistoryTest(body: string) { + const cache = join(import.meta.dir, '.turbo') + mkdirSync(cache, { recursive: true }) + const directory = mkdtempSync(join(cache, 'history-')) + const probe = join(directory, 'probe.ts') + try { + writeFileSync( + probe, + ` + import assert from 'node:assert/strict' + import { mock } from 'bun:test' + import { fileURLToPath, pathToFileURL } from 'node:url' + const editorConsumer = ${JSON.stringify(resolve(import.meta.dir, 'history.ts'))} + const coreConsumer = ${JSON.stringify(resolve(import.meta.dir, '../../../core/src/index.ts'))} + const viewerConsumer = ${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/wall/wall-system.tsx'))} + const nodesConsumer = ${JSON.stringify(resolve(import.meta.dir, '../../../nodes/src/shared/node-batch/system.tsx'))} + const peerConsumers = [editorConsumer, coreConsumer, viewerConsumer, nodesConsumer] + // Resolve only from declared consumers; isolated installs cannot see sibling dependencies. + const sharedConsumers = [ + ['@pascal-app/core', [editorConsumer, viewerConsumer, nodesConsumer]], + ['@pascal-app/viewer', [editorConsumer, nodesConsumer]], + ['react', peerConsumers], + ['three', peerConsumers], + ['@react-three/fiber', peerConsumers], + ] + const sharedPaths = new Map( + sharedConsumers.map(([specifier, consumers]) => [ + specifier, + [...new Set(consumers.map(consumer => fileURLToPath(import.meta.resolve(specifier, pathToFileURL(consumer).href))))], + ]), + ) + function mockShared(specifier, factory) { + for (const path of sharedPaths.get(specifier)) mock.module(path, factory) + } + async function importShared(specifier) { + const module = await import(sharedPaths.get(specifier)[0]) + mockShared(specifier, () => module) + return module + } + await importShared('react') + await importShared('three') + await importShared('@react-three/fiber') + globalThis.requestAnimationFrame = callback => { callback(0); return 0 } + globalThis.cancelAnimationFrame = () => {} + const core = await import(${JSON.stringify(resolve(import.meta.dir, '../../..', 'core/src/index.ts'))}) + mockShared('@pascal-app/core', () => core) + await importShared('@pascal-app/viewer') + const { useScene: scene, clearSceneHistory, useLiveTransforms: transforms, useLiveNodeOverrides: overrides } = core + const { runUndo, runRedo, installHistoryCommandDelegate, getHistoryCommandState, shouldCancelDraftOnHistoryJump, subscribeHistoryCommandState } = await import(${JSON.stringify(resolve(import.meta.dir, 'history.ts'))}) + const { default: useInteractionScope } = await import(${JSON.stringify(resolve(import.meta.dir, '../store/use-interaction-scope.ts'))}) + const level = core.LevelNode.parse({ id: 'level_history_source', children: ['wall_history_source', 'wall_remote_source', 'slab_history_source'] }) + const wall = core.WallNode.parse({ id: 'wall_history_source', parentId: level.id, start: [0,0], end: [4,0] }) + const remote = core.WallNode.parse({ id: 'wall_remote_source', parentId: level.id, start: [20,0], end: [24,0] }) + const opening = core.DoorNode.parse({ id: 'door_history_source', parentId: wall.id, wallId: wall.id }) + const slab = core.SlabNode.parse({ id: 'slab_history_source', parentId: level.id, polygon: [[0,0],[4,0],[4,4],[0,4]] }) + const baseline = Object.fromEntries([level, { ...wall, children: [opening.id] }, remote, opening, slab].map(node => [node.id, node])) + scene.setState({ nodes: baseline, dirtyNodes: new Set(), readOnly: false, materials: {}, collections: {}, rootNodeIds: [level.id] }) + clearSceneHistory() + const flush = async () => { await Promise.resolve(); await Promise.resolve() } + const clean = () => scene.getState().dirtyNodes.clear() + const dirty = id => scene.getState().dirtyNodes.has(id) + const edit = (id, patch) => scene.getState().updateNode(id, patch) + ${body} + `, + ) + const result = Bun.spawnSync([process.execPath, probe], { stdout: 'pipe', stderr: 'pipe' }) + expect({ code: result.exitCode, stderr: result.stderr.toString() }).toEqual({ + code: 0, + stderr: '', + }) + } finally { + rmSync(directory, { recursive: true, force: true }) + } } -;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= - () => {} -const BUILDING_ID = 'building_history_controller' as AnyNodeId -const LEVEL_ID = 'level_history_controller' as AnyNodeId -let disposeController = () => {} -let restoreRegistry = () => {} +describe('standalone history source invalidation', () => { + test('actual undo/redo keeps unchanged nodes clean and restores exact wall data on repeated jumps', () => { + runSourceHistoryTest(` + edit(wall.id, { start: [0,2], end: [4,2] }) + const moved = scene.getState().nodes + for (let i = 0; i < 3; i++) { + clean(); runUndo(); await flush() + assert.equal(scene.getState().nodes[wall.id], baseline[wall.id]) + assert(dirty(wall.id)); assert(!dirty(remote.id)); assert(!dirty(slab.id)) + clean(); runRedo(); await flush() + assert.equal(scene.getState().nodes[wall.id], moved[wall.id]) + assert(dirty(wall.id)); assert(!dirty(remote.id)); assert(!dirty(slab.id)) + } + `) + }) -function levelNumber(): number { - return (useScene.getState().nodes[LEVEL_ID] as { level: number }).level -} + test('unchanged transform/override targets and hosted parents restore after clear, including stair holes', () => { + runSourceHistoryTest(` + edit(level.id, { name: 'Changed' }) + transforms.getState().set(remote.id, { position: [1,0,0], rotation: 0 }) + overrides.getState().set(opening.id, { width: 2 }) + const controller = core.createSurfaceOpeningPreviewController() + controller.apply([{ id: slab.id, data: { holes: [[[1,1],[2,1],[2,2]]] } }]) + clean(); runUndo(); await flush() + for (const id of [remote.id, opening.id, wall.id, slab.id]) assert(dirty(id), id) + assert.equal(scene.getState().nodes[remote.id], baseline[remote.id]) + assert.equal(transforms.getState().transforms.size, 0) + assert.equal(overrides.getState().overrides.size, 0) + controller.clear() + `) + }) -describe('editor history controller', () => { - beforeEach(() => { - disposeController() - disposeController = () => {} - restoreRegistry() - restoreRegistry = nodeRegistry._snapshot() - useInteractionScope.getState().end() - const level = LevelNode.parse({ - id: LEVEL_ID, - parentId: BUILDING_ID, - children: [], - level: 0, - }) - const building = BuildingNode.parse({ - id: BUILDING_ID, - parentId: null, - children: [LEVEL_ID], - }) - useScene.setState({ - nodes: { [BUILDING_ID]: building, [LEVEL_ID]: level }, - rootNodeIds: [BUILDING_ID], - dirtyNodes: new Set(), - collections: {}, - materials: {}, - readOnly: false, - } as never) - clearSceneHistory() - useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial) - }) - - afterEach(() => { - disposeController() - disposeController = () => {} - restoreRegistry() - restoreRegistry = () => {} - useInteractionScope.getState().end() - }) - - test('cancels history jumps only when the drafted kind opts in', () => { - const onCancel = mock(() => {}) - emitter.on('tool:cancel', onCancel) - try { - useInteractionScope.getState().begin({ kind: 'drafting', tool: 'plain-draft' }) - expect(shouldCancelDraftOnHistoryJump()).toBe(false) + test.each([ + true, + false, + ])('discarded preview neighbours rebuild after undo/redo (joined: %s)', (joined) => { + runSourceHistoryTest(` + const react = await importShared('react') + mockShared('react', () => ({ ...react, useEffect: () => {} })) + const frames = [] + const fiber = await importShared('@react-three/fiber') + mockShared('@react-three/fiber', () => ({ ...fiber, useFrame: frame => frames.push(frame) })) + const selector = store => Object.assign(fn => fn(store.getState()), store) + mockShared('@pascal-app/core', () => ({ ...core, useScene: selector(scene), useLiveNodeOverrides: selector(overrides) })) + const { Mesh } = await importShared('three') + const { WallSystem, getPendingWallRebuildCount } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/wall/wall-system.tsx'))}) + const neighbor = { ...remote, start: [8,0], end: [8,4] } + scene.setState({ nodes: { ...baseline, [level.id]: { ...level, children: [wall.id, neighbor.id] }, [neighbor.id]: neighbor } }) + clearSceneHistory() + const a = new Mesh(), b = new Mesh() + core.sceneRegistry.nodes.set(wall.id, a) + core.sceneRegistry.nodes.set(neighbor.id, b) + let now = 0 + performance.now = () => now + WallSystem() + const frame = () => { now += 100; frames[0]() } + scene.getState().markDirty(wall.id); scene.getState().markDirty(neighbor.id) + frame(); frame(); clean() + const canonical = Array.from(b.geometry.getAttribute('position').array) + edit(level.id, { name: 'Unrelated edit' }) + for (const jump of [runUndo, runRedo]) { + overrides.getState().set(wall.id, { start: [4,0], end: [${joined ? 8 : 6},0] }) + scene.getState().markDirty(wall.id) + frame(); frame() + assert.equal(getPendingWallRebuildCount(), 0) + const preview = Array.from(b.geometry.getAttribute('position').array) + ${joined ? 'assert.notDeepEqual(preview, canonical)' : 'assert.deepEqual(preview, canonical)'} + clean(); jump(); await flush() + assert(dirty(wall.id)) + assert.equal(dirty(neighbor.id), ${joined}) + assert.equal(overrides.getState().overrides.size, 0) + frame(); frame() + assert.deepEqual(Array.from(b.geometry.getAttribute('position').array), canonical) + } + `) + }) - nodeRegistry._register({ - kind: 'registered-draft', - schemaVersion: 1, - drafting: { cancelOnHistoryJump: true }, - } as never) - useInteractionScope.getState().begin({ kind: 'drafting', tool: 'registered-draft' }) - expect(shouldCancelDraftOnHistoryJump()).toBe(true) + test('opening reparent dirties old and new walls on undo and redo', () => { + runSourceHistoryTest(` + edit(opening.id, { parentId: remote.id, wallId: remote.id }) + for (const jump of [runUndo, runRedo]) { + clean(); jump(); await flush() + for (const id of [wall.id, remote.id, opening.id]) assert(dirty(id), id) + } + `) + }) - runUndo() - expect(onCancel).toHaveBeenCalledTimes(1) - } finally { - emitter.off('tool:cancel', onCancel) - } - }) - - test('delegates undo and redo while a host delegate is installed', () => { - const undo = mock(() => ({ kind: 'applied', persistence: 'queued' }) as const) - const redo = mock(() => ({ kind: 'empty' }) as const) - disposeController = installHistoryCommandDelegate({ - getState: () => ({ - canRedo: false, - canUndo: true, - mode: 'collaborative', - status: 'syncing', - }), - redo, - subscribe: () => () => {}, - undo, - }) + test('delete/restore of a subtree leaves no deleted dirty ids or live entries', () => { + runSourceHistoryTest(` + scene.getState().deleteNode(wall.id) + clean(); runUndo(); await flush() + assert.equal(scene.getState().nodes[opening.id], baseline[opening.id]) + assert(dirty(wall.id)); assert(dirty(opening.id)) + transforms.getState().set(opening.id, { position: [1,0,0], rotation: 0 }) + scene.getState().markDirty(opening.id) + runRedo(); await flush() + assert(!scene.getState().nodes[wall.id]); assert(!scene.getState().nodes[opening.id]) + assert(!dirty(wall.id)); assert(!dirty(opening.id)) + assert.equal(transforms.getState().transforms.size, 0) + `) + }) - expect(runUndo()).toEqual({ kind: 'applied', persistence: 'queued' }) - expect(runRedo()).toEqual({ kind: 'empty' }) - expect(getHistoryCommandState()).toEqual({ - canRedo: false, - canUndo: true, - mode: 'collaborative', - status: 'syncing', - }) + test('synchronous jumps preserve intermediate wall layouts until their microtasks flush', () => { + runSourceHistoryTest(` + edit(wall.id, { start: [16,0], end: [20,0] }) + runUndo(); await flush(); clean() + runRedo(); runUndo(); await flush() + assert(dirty(remote.id)) + assert.equal(scene.getState().nodes[wall.id], baseline[wall.id]) + `) + }) - expect(undo).toHaveBeenCalledTimes(1) - expect(redo).toHaveBeenCalledTimes(1) - expect(levelNumber()).toBe(1) - expect(useScene.temporal.getState().pastStates).toHaveLength(1) - }) - - test('falls back to standalone Zundo undo and redo when no controller is installed', () => { - expect(runUndo()).toEqual({ kind: 'applied', persistence: 'local' }) - expect(levelNumber()).toBe(0) - expect(useScene.temporal.getState().futureStates).toHaveLength(1) - - expect(runRedo()).toEqual({ kind: 'applied', persistence: 'local' }) - expect(levelNumber()).toBe(1) - expect(useScene.temporal.getState().pastStates).toHaveLength(1) - }) - - test('an older cleanup cannot uninstall a newer controller', () => { - const firstUndo = mock(() => {}) - const delegate = (undo: () => void) => ({ - getState: () => ({ - canRedo: false, - canUndo: true, - mode: 'collaborative' as const, - status: 'ready' as const, - }), - redo: () => ({ kind: 'empty' as const }), - subscribe: () => () => {}, - undo: () => { - undo() - return { kind: 'applied' as const, persistence: 'queued' as const } - }, - }) - const stopFirst = installHistoryCommandDelegate(delegate(firstUndo)) - const secondUndo = mock(() => {}) - disposeController = installHistoryCommandDelegate(delegate(secondUndo)) + test('empty commands and collaborative delegates retain ownership of live previews and dirtiness', () => { + runSourceHistoryTest(` + transforms.getState().set(remote.id, { position: [1,0,0], rotation: 0 }) + overrides.getState().set(opening.id, { width: 2 }) + clean() + assert.equal(runUndo().kind, 'empty'); assert.equal(runRedo().kind, 'empty') + edit(wall.id, { thickness: 0.4 }); clean() + const changed = scene.getState().nodes + let undo = 0, redo = 0 + const stop = installHistoryCommandDelegate({ + getState: () => ({ canUndo: true, canRedo: true, mode: 'collaborative', status: 'ready' }), + subscribe: () => () => {}, + undo: () => { undo++; return { kind: 'applied', persistence: 'queued' } }, + redo: () => { redo++; return { kind: 'empty' } }, + }) + runUndo(); runRedo(); await flush(); stop() + assert.equal(undo, 1); assert.equal(redo, 1) + assert.equal(scene.getState().nodes, changed) + assert.equal(scene.getState().dirtyNodes.size, 0) + assert.equal(transforms.getState().transforms.size, 1) + assert.equal(overrides.getState().overrides.size, 1) + `) + }) + test('one-wall undo releases only its openings and neighbour openings from the real batch store', () => { + runSourceHistoryTest(` + const { Group, Mesh, MeshBasicMaterial, BoxGeometry } = await importShared('three') + const viewer = await importShared('@pascal-app/viewer') + const { captureChangedNodes, runBatchFrame, resetNodeBatchState } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../nodes/src/shared/node-batch/system.tsx'))}) + const root = new Group() + core.sceneRegistry.nodes.set(level.id, root) + core.sceneRegistry.byType.level.add(level.id) + const material = new MeshBasicMaterial() + const meshes = [] + const walls = [wall, { ...wall, id: 'wall_neighbor', start: [4,0], end: [4,4] }, remote, { ...remote, id: 'wall_far', start: [30,0], end: [34,0] }] + const nodes = { [level.id]: level } + walls.forEach((host, i) => { + const door = core.DoorNode.parse({ id: 'door_batch_' + i, parentId: host.id }) + nodes[host.id] = { ...host, children: [door.id] } + nodes[door.id] = door + const mesh = new Mesh(new BoxGeometry(), material) + meshes.push(mesh); root.add(mesh) + core.sceneRegistry.nodes.set(door.id, mesh) + core.sceneRegistry.byType.door.add(door.id) + }) + scene.setState({ nodes }); clearSceneHistory() + edit(wall.id, { start: [0,2], end: [4,2] }); clean() + viewer.useViewer.setState({ externalSelectedIds: [], previewSelectedIds: [], hoveredId: null, selection: { ...viewer.useViewer.getState().selection, selectedIds: [], levelId: null } }) + let now = 0 + performance.now = () => now + const wake = { current: null } + const frame = () => runBatchFrame(() => {}, wake) + frame(); now += 181; frame() + assert(meshes.every(mesh => !mesh.layers.isEnabled(viewer.SCENE_LAYER))) + const batch = root.children.find(child => child.name === 'item-batch') + assert.equal(batch.instanceCount, 4) + runUndo(); await flush() + captureChangedNodes(); clean(); frame() + assert.deepEqual(meshes.map(mesh => mesh.layers.isEnabled(viewer.SCENE_LAYER)), [true, true, false, false]) + assert.equal(batch.instanceCount, 2) + now += 181; frame() + assert.equal(batch.instanceCount, 4) + assert(meshes.every(mesh => !mesh.layers.isEnabled(viewer.SCENE_LAYER))) + resetNodeBatchState(); if (wake.current) clearTimeout(wake.current) + `) + }) - stopFirst() - runUndo() + test('endpoint undo/redo releases exactly both endpoint neighbours and their hosted children', () => { + runSourceHistoryTest(` + const { Group, Mesh, MeshBasicMaterial, BoxGeometry } = await importShared('three') + const viewer = await importShared('@pascal-app/viewer') + const { captureChangedNodes, runBatchFrame, resetNodeBatchState } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../nodes/src/shared/node-batch/system.tsx'))}) + const root = new Group() + core.sceneRegistry.nodes.set(level.id, root) + core.sceneRegistry.byType.level.add(level.id) + const material = new MeshBasicMaterial() + const meshes = [] + const walls = [wall, { ...wall, id: 'wall_start', start: [0,0], end: [0,4] }, { ...wall, id: 'wall_old', start: [4,0], end: [4,4] }, { ...wall, id: 'wall_new', start: [6,1], end: [6,4] }, { ...wall, id: 'wall_beyond', start: [6,4], end: [8,4] }, remote, { ...wall, id: 'wall_interior', start: [1,1], end: [2,1] }] + const nodes = { [level.id]: level, [slab.id]: slab } + walls.forEach((host, i) => { + const door = core.DoorNode.parse({ id: 'door_batch_' + i, parentId: host.id }) + nodes[host.id] = { ...host, children: [door.id] } + nodes[door.id] = door + const mesh = new Mesh(new BoxGeometry(), material) + meshes.push(mesh); root.add(mesh) + core.sceneRegistry.nodes.set(door.id, mesh) + core.sceneRegistry.byType.door.add(door.id) + }) + scene.setState({ nodes }); clearSceneHistory() + const stopSpatial = core.initSpatialGridSync() + edit(wall.id, { end: [6,1] }); clean() + viewer.useViewer.setState({ externalSelectedIds: [], previewSelectedIds: [], hoveredId: null, selection: { ...viewer.useViewer.getState().selection, selectedIds: [], levelId: null } }) + let now = 0 + performance.now = () => now + const wake = { current: null } + const frame = () => runBatchFrame(() => {}, wake) + frame(); now += 181; frame() + assert(meshes.every(mesh => !mesh.layers.isEnabled(viewer.SCENE_LAYER))) + const batch = root.children.find(child => child.name === 'item-batch') + assert.equal(batch.instanceCount, 7) + for (const jump of [runUndo, runRedo]) { + clean(); jump(); await flush() + assert.deepEqual([...scene.getState().dirtyNodes].sort(), [level.id, ...walls.slice(0,4).map(node => node.id)].sort()) + captureChangedNodes(); clean(); frame() + assert.deepEqual(meshes.map(mesh => mesh.layers.isEnabled(viewer.SCENE_LAYER)), [true, true, true, true, false, false, false]) + assert.equal(batch.instanceCount, 3) + now += 181; frame() + assert.equal(batch.instanceCount, 7) + assert(meshes.every(mesh => !mesh.layers.isEnabled(viewer.SCENE_LAYER))) + } + stopSpatial(); core.spatialGridManager.clear(); resetNodeBatchState(); if (wake.current) clearTimeout(wake.current) + `) + }) - expect(firstUndo).toHaveBeenCalledTimes(0) - expect(secondUndo).toHaveBeenCalledTimes(1) + test('all four item supports transfer through undo and redo without touching unrelated hosts', () => { + runSourceHistoryTest(` + const ceiling = core.CeilingNode.parse({ id: 'ceiling_transfer', parentId: level.id, polygon: slab.polygon }) + const deck = { ...slab, elevation: 1 } + const asset = { id: 'transfer', name: 'transfer', category: 'test', thumbnail: '', src: '/test.glb' } + const supports = [ + { parentId: level.id, supportSlabId: core.GROUND_SUPPORT_ID, asset }, + { parentId: wall.id, supportSlabId: undefined, asset: { ...asset, attachTo: 'wall-side' } }, + { parentId: ceiling.id, supportSlabId: undefined, asset: { ...asset, attachTo: 'ceiling' } }, + { parentId: level.id, supportSlabId: deck.id, asset }, + ] + for (let from = 0; from < supports.length; from++) { + for (let to = from + 1; to < supports.length; to++) { + const item = core.ItemNode.parse({ id: 'item_transfer', ...supports[from] }) + scene.setState({ nodes: { ...baseline, [ceiling.id]: ceiling, [deck.id]: deck, [item.id]: item } }) + clearSceneHistory() + edit(item.id, { ...supports[to], position: [2,0,2] }) + const moved = scene.getState().nodes[item.id] + for (const [jump, expected] of [[runUndo, item], [runRedo, moved]]) { + clean(); jump(); await flush() + assert.equal(scene.getState().nodes[item.id], expected) + assert.deepEqual([...scene.getState().dirtyNodes].sort(), [...new Set([item.id, level.id, supports[from].parentId, supports[to].parentId])].sort()) + assert(!dirty(remote.id)); assert(!dirty(deck.id)) + } + } + } + `) }) - test('publishes delegate state changes and restores standalone availability on teardown', () => { - const listeners = new Set<() => void>() - const observed: string[] = [] - const unsubscribe = subscribeHistoryCommandState(() => { - observed.push(getHistoryCommandState().mode) - }) - disposeController = installHistoryCommandDelegate({ - getState: () => ({ - canRedo: false, - canUndo: true, - mode: 'collaborative', - status: 'offline', - }), - redo: () => ({ kind: 'empty' }), - subscribe: (listener) => { - listeners.add(listener) - return () => listeners.delete(listener) - }, - undo: () => ({ kind: 'applied', persistence: 'queued' }), - }) + test('undo and redo re-mark hosted opening proxies and wall-side offsets for real frame rebuilds', () => { + runSourceHistoryTest(` + const react = await importShared('react') + mockShared('react', () => ({ ...react, useEffect: () => {}, useRef: current => ({ current }) })) + const frames = [] + const fiber = await importShared('@react-three/fiber') + mockShared('@react-three/fiber', () => ({ ...fiber, useFrame: frame => frames.push(frame) })) + const selector = store => Object.assign(fn => fn(store.getState()), store) + mockShared('@pascal-app/core', () => ({ ...core, useScene: selector(scene), useLiveNodeOverrides: selector(overrides) })) + const viewer = await importShared('@pascal-app/viewer') + mock.module(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/store/use-viewer.ts'))}, () => ({ default: selector(viewer.useViewer) })) + const { Mesh } = await importShared('three') + const { DoorSystem } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/door/door-system.tsx'))}) + const { WindowSystem } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/window/window-system.tsx'))}) + const { ItemSystem } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/item/item-system.tsx'))}) + const window = core.WindowNode.parse({ id: 'window_thickness', parentId: wall.id }) + const item = core.ItemNode.parse({ id: 'item_thickness', parentId: wall.id, side: 'front', asset: { id: 'test', name: 'test', category: 'test', thumbnail: '', src: '/test.glb', attachTo: 'wall-side' } }) + const children = [opening, window, item] + scene.setState({ nodes: { ...baseline, [wall.id]: { ...wall, children: children.map(node => node.id) }, [window.id]: window, [item.id]: item } }) + const meshes = children.map(node => { const mesh = new Mesh(); mesh.userData.itemModelSettled = true; core.sceneRegistry.nodes.set(node.id, mesh); return mesh }) + clearSceneHistory() + DoorSystem(); WindowSystem(); ItemSystem() + const frame = () => frames.forEach(frame => frame()) + const depths = () => meshes.slice(0, 2).map(mesh => mesh.getObjectByName('cutout').geometry.parameters.depth) + const check = thickness => { + assert.deepEqual(depths(), [thickness + 0.08, thickness + 0.08]) + assert.equal(meshes[2].position.z, thickness / 2) + } + children.forEach(node => scene.getState().markDirty(node.id)); frame(); check(core.getWallThickness(wall)) + edit(wall.id, { thickness: 0.6 }) + children.forEach(node => scene.getState().markDirty(node.id)); frame(); check(0.6) + for (const [jump, thickness] of [[runUndo, core.getWallThickness(wall)], [runRedo, 0.6]]) { + clean(); jump(); await flush() + children.forEach(node => assert(dirty(node.id), node.id)) + assert(!dirty(remote.id)); assert(!dirty(slab.id)) + frame(); check(thickness) + children.forEach(node => assert(!dirty(node.id), node.id)) + } + `) + }) + + test('undo removes a reconciliation-created side-effect wall and its auto surfaces in one step', () => { + runSourceHistoryTest(` + const upper = core.LevelNode.parse({ id: 'level_unrelated_side_effect', level: 1 }) + const walls = [wall, { ...wall, id: 'wall_east', start: [4,0], end: [4,4] }, { ...wall, id: 'wall_north', start: [4,4], end: [0,4] }] + const closing = core.WallNode.parse({ id: 'wall_closing', parentId: level.id, start: [0,4], end: [0,0] }) + const sideEffect = core.WallNode.parse({ id: 'wall_derived', parentId: level.id, start: [4,4], end: [6,4] }) + scene.setState({ nodes: Object.fromEntries([{ ...level, children: walls.map(node => node.id) }, upper, { ...remote, parentId: upper.id }, ...walls].map(node => [node.id, node])) }) + const editor = { spaces: {}, setSpaces: spaces => { editor.spaces = spaces } } + let created = false + const stop = core.initSpaceDetectionSync(scene, { getState: () => editor }, { + onTopologyReconcile: () => { + if (created) return + created = true + scene.getState().createNode(sideEffect, level.id) + }, + }) + clearSceneHistory() + scene.getState().createNode(closing, level.id) + await flush() + assert(created); assert(scene.getState().nodes[sideEffect.id]) + const surfaces = Object.values(scene.getState().nodes).filter(node => node.type === 'slab' || node.type === 'ceiling') + assert(surfaces.length > 0) + assert.equal(scene.temporal.getState().pastStates.length, 1) + clean(); runUndo(); await flush() + for (const node of [closing, sideEffect, ...surfaces]) { + assert(!scene.getState().nodes[node.id], node.id) + assert(!dirty(node.id), node.id) + } + assert(dirty('wall_north')); assert(!dirty(remote.id)) + stop() + `) + }) - for (const listener of listeners) listener() - disposeController() - disposeController = () => {} - unsubscribe() + test('mounted slab and space subscriptions run on temporal writes without swallowing the wall diff', () => { + runSourceHistoryTest(` + const react = await importShared('react') + const effects = [] + mockShared('react', () => ({ ...react, useEffect: effect => effects.push(effect) })) + const { default: SlabSystems } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../nodes/src/slab/system.tsx'))}) + SlabSystems() + const stopSlabs = effects[0]() + let publications = 0 + const editor = { spaces: {}, setSpaces: spaces => { editor.spaces = spaces; publications++ } } + const stopSpaces = core.initSpaceDetectionSync(scene, { getState: () => editor }) + edit(wall.id, { start: [0,2], end: [4,2] }) + clean(); const previousPublications = publications + runUndo() + assert(dirty(slab.id)) + assert(publications > previousPublications) + await flush() + assert(dirty(wall.id)) + assert(!dirty(remote.id)) + stopSpaces(); stopSlabs() + `) + }) + + test('stair preview cleanup captures holes republished during the first clear', () => { + runSourceHistoryTest(` + edit(level.id, { name: 'Changed' }) + transforms.getState().set(wall.id, { position: [1,0,0], rotation: 0 }) + let published = false + const stop = overrides.subscribe(state => { + if (published || state.overrides.size || !transforms.getState().transforms.size) return + published = true + overrides.getState().set(slab.id, { holes: [[[1,1],[2,1],[2,2]]] }) + }) + clean(); runUndo(); await flush(); stop() + assert(published) + assert.equal(overrides.getState().overrides.size, 0) + assert(dirty(slab.id)) + `) + }) + test('the wall geometry harness restores positions, normals, UVs and opening cutouts after undo', () => { + runSourceHistoryTest(` + const { Mesh } = await importShared('three') + const { generateExtrudedWall } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/wall/wall-system.tsx'))}) + core.sceneRegistry.nodes.set(wall.id, new Mesh()) + const geometry = () => { + const nodes = scene.getState().nodes + const currentWall = nodes[wall.id] + const children = currentWall.children.map(id => nodes[id]).filter(Boolean) + const mesh = generateExtrudedWall(currentWall, children, core.calculateLevelMiters([currentWall])) + const result = Object.fromEntries(['position', 'normal', 'uv'].map(name => [name, Array.from(mesh.getAttribute(name).array)])) + mesh.dispose(); return result + } + const canonical = geometry() + for (const [id, patch] of [ + [wall.id, { thickness: 0.4 }], [wall.id, { end: [7,2] }], + [wall.id, { curveOffset: 0.7 }], [opening.id, { position: [2,1,0], width: 1.5 }], + ]) { + edit(id, patch) + assert.notDeepEqual(geometry(), canonical) + clean(); runUndo(); await flush() + assert(dirty(wall.id)) + assert.deepEqual(geometry(), canonical) + } + `) + }) + + test('the mounted stair subscription restores derived flight heights after a temporal level write', () => { + runSourceHistoryTest(` + const react = await importShared('react') + const effects = [] + mockShared('react', () => ({ ...react, useEffect: effect => effects.push(effect), useRef: current => ({ current }) })) + const segment = core.StairSegmentNode.parse({ id: 'sseg_history', parentId: 'stair_history', height: 2.5 }) + const stair = core.StairNode.parse({ id: 'stair_history', parentId: level.id, children: [segment.id] }) + scene.setState({ nodes: { ...baseline, [level.id]: { ...level, height: 2.5, children: [stair.id] }, [stair.id]: stair, [segment.id]: segment } }) + const { StairOpeningSystem } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../core/src/systems/stair/stair-opening-system.tsx'))}) + StairOpeningSystem() + const stop = effects[0]() + await flush(); clearSceneHistory() + edit(level.id, { height: 4 }); await flush() + assert.equal(scene.getState().nodes[segment.id].height, 4) + clean(); runUndo(); await flush() + assert.equal(scene.getState().nodes[segment.id].height, 2.5) + assert(dirty(segment.id)); assert(!dirty(remote.id)) + stop() + `) + }) +}) + +describe('editor history controller', () => { + test('draft cancellation follows the registered kind', () => { + runSourceHistoryTest(` + let cancelled = 0 + core.emitter.on('tool:cancel', () => cancelled++) + useInteractionScope.getState().begin({ kind: 'drafting', tool: 'plain-draft' }) + assert.equal(shouldCancelDraftOnHistoryJump(), false) + core.nodeRegistry._register({ kind: 'registered-draft', schemaVersion: 1, drafting: { cancelOnHistoryJump: true } }) + useInteractionScope.getState().begin({ kind: 'drafting', tool: 'registered-draft' }) + assert.equal(shouldCancelDraftOnHistoryJump(), true) + edit(level.id, { level: 1 }); runUndo() + assert.equal(cancelled, 1) + `) + }) - expect(observed).toEqual(['collaborative', 'collaborative', 'standalone']) + test('delegates publish availability and an older cleanup cannot uninstall the current delegate', () => { + runSourceHistoryTest(` + edit(level.id, { level: 1 }) + const observed = [] + const unsubscribe = subscribeHistoryCommandState(() => observed.push(getHistoryCommandState().mode)) + const listeners = new Set() + let firstCalls = 0, secondCalls = 0 + const delegate = undo => ({ + getState: () => ({ canRedo: false, canUndo: true, mode: 'collaborative', status: 'syncing' }), + redo: () => ({ kind: 'empty' }), + subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener) }, + undo, + }) + const stopFirst = installHistoryCommandDelegate(delegate(() => { firstCalls++; return { kind: 'empty' } })) + const stopSecond = installHistoryCommandDelegate(delegate(() => { secondCalls++; return { kind: 'applied', persistence: 'queued' } })) + stopFirst() + assert.deepEqual(runUndo(), { kind: 'applied', persistence: 'queued' }) + assert.deepEqual(runRedo(), { kind: 'empty' }) + assert.equal(firstCalls, 0); assert.equal(secondCalls, 1) + assert.equal(scene.getState().nodes[level.id].level, 1) + assert.equal(scene.temporal.getState().pastStates.length, 1) + assert.deepEqual(getHistoryCommandState(), { canRedo: false, canUndo: true, mode: 'collaborative', status: 'syncing' }) + for (const listener of listeners) listener() + stopSecond(); unsubscribe() + assert.deepEqual(observed, ['collaborative', 'collaborative', 'collaborative', 'standalone']) + assert.deepEqual(runUndo(), { kind: 'applied', persistence: 'local' }) + assert.equal(scene.getState().nodes[level.id].level, 0) + assert.deepEqual(runRedo(), { kind: 'applied', persistence: 'local' }) + assert.equal(scene.getState().nodes[level.id].level, 1) + `) }) }) diff --git a/packages/editor/src/lib/history.ts b/packages/editor/src/lib/history.ts index 846d0f8410..d14108888d 100644 --- a/packages/editor/src/lib/history.ts +++ b/packages/editor/src/lib/history.ts @@ -1,4 +1,12 @@ -import { emitter, useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + emitter, + getHistoryDirtyNodeIds, + useLiveNodeOverrides, + useLiveTransforms, + useScene, +} from '@pascal-app/core' import { markPerfAction } from '@pascal-app/viewer' import useInteractionScope from '../store/use-interaction-scope' import { registeredDraftingConfig } from './interaction/registered-drafting' @@ -64,13 +72,55 @@ function notifyHistoryCommandListeners() { for (const listener of [...historyCommandListeners]) listener() } -function refreshSceneAfterHistoryJump() { +function capturePreviewLayout() { + const overrides = useLiveNodeOverrides.getState().overrides + if (overrides.size === 0) return null + const nodes = { ...useScene.getState().nodes } + for (const [id, values] of overrides) { + const node = nodes[id as AnyNodeId] + if (node) nodes[node.id] = { ...node, ...values } as AnyNode + } + return nodes +} + +function refreshSceneAfterHistoryJump(previewLayout: Record | null) { + const target = useScene.getState().nodes + const previewDirty = previewLayout + ? getHistoryDirtyNodeIds(previewLayout, target) + : new Set() + const previewIds = new Set([ + ...useLiveTransforms.getState().transforms.keys(), + ...useLiveNodeOverrides.getState().overrides.keys(), + ]) + const currentPreviewLayout = capturePreviewLayout() + if (currentPreviewLayout) { + for (const id of getHistoryDirtyNodeIds(currentPreviewLayout, target)) previewDirty.add(id) + } useLiveNodeOverrides.getState().clearAll() useLiveTransforms.getState().clearAll() + // Clearing overrides can republish stair holes while a live transform still + // exists. Capture that final publication before clearing it too. + const remainingOverrides = useLiveNodeOverrides.getState().overrides + if (remainingOverrides.size > 0) { + for (const id of remainingOverrides.keys()) previewIds.add(id) + const remainingLayout = capturePreviewLayout() + if (remainingLayout) { + for (const id of getHistoryDirtyNodeIds(remainingLayout, target)) previewDirty.add(id) + } + useLiveNodeOverrides.getState().clearAll() + } const state = useScene.getState() - for (const node of Object.values(state.nodes)) { + for (const id of previewDirty) { + if (state.nodes[id]) state.markDirty(id) + } + for (const id of previewIds) { + const node = state.nodes[id as AnyNodeId] + if (!node) continue state.markDirty(node.id) + if (node.parentId && state.nodes[node.parentId as AnyNodeId]) { + state.markDirty(node.parentId as AnyNodeId) + } } } @@ -90,8 +140,9 @@ export function runUndo(): HistoryCommandResult { } if (useScene.temporal.getState().pastStates.length === 0) return { kind: 'empty' } markPerfAction('undo') + const previewLayout = capturePreviewLayout() useScene.temporal.getState().undo() - refreshSceneAfterHistoryJump() + refreshSceneAfterHistoryJump(previewLayout) return { kind: 'applied', persistence: 'local' } } @@ -104,8 +155,9 @@ export function runRedo(): HistoryCommandResult { } if (useScene.temporal.getState().futureStates.length === 0) return { kind: 'empty' } markPerfAction('redo') + const previewLayout = capturePreviewLayout() useScene.temporal.getState().redo() - refreshSceneAfterHistoryJump() + refreshSceneAfterHistoryJump(previewLayout) return { kind: 'applied', persistence: 'local' } } diff --git a/packages/editor/src/lib/linear-display.test.ts b/packages/editor/src/lib/linear-display.test.ts new file mode 100644 index 0000000000..d0e8d7a1ab --- /dev/null +++ b/packages/editor/src/lib/linear-display.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test' +import { getLinearDisplay } from './linear-display' + +describe('linear property control units', () => { + test('keeps meter storage while displaying and accepting millimeters', () => { + const display = getLinearDisplay('m', 'metric', 'millimeters', 2, 0.05) + expect(display.displayUnit).toBe('mm') + expect(display.parseUnit).toBe('mm') + expect(display.toDisplay(2.5)).toBe(2500) + expect(display.toStored(1800)).toBe(1.8) + expect(display.toStored(-250)).toBe(-0.25) + expect(display.precision).toBe(0) + expect(display.step).toBe(50) + expect(display.toStored(display.step)).toBe(0.05) + }) + + test('retains sub-millimeter precision when the field supports it', () => { + const display = getLinearDisplay('m', 'metric', 'millimeters', 4, 0.0001) + expect(display.precision).toBe(1) + expect(display.step).toBe(0.1) + expect(display.toDisplay(0.0125)).toBe(12.5) + expect(display.roundStored(0.01254)).toBe(0.0125) + }) + + test('imperial preference takes precedence over the saved metric notation', () => { + const display = getLinearDisplay('m', 'imperial', 'millimeters', 2, 0.05) + expect(display.displayUnit).toBe('ft') + expect(display.parseUnit).toBe('ft') + expect(display.toDisplay(0.9144)).toBeCloseTo(3, 10) + expect(display.toStored(7)).toBeCloseTo(2.1336, 10) + expect(display.precision).toBe(2) + expect(display.step).toBe(0.05) + }) + + test('switching notation round-trips the same stored dimensions', () => { + for (const stored of [0, 0.01, 0.125, 0.9, 2.1, -0.25]) { + for (const [unit, notation] of [ + ['metric', 'meters'], + ['metric', 'millimeters'], + ['imperial', 'meters'], + ] as const) { + const display = getLinearDisplay('m', unit, notation, 2, 0.01) + expect(display.toStored(display.toDisplay(stored))).toBeCloseTo(stored, 12) + } + } + }) + + test('leaves non-meter fields and their gesture steps unchanged', () => { + for (const unit of ['Β°', 'rad', '%', '', 'in', 'cm']) { + const display = getLinearDisplay(unit, 'metric', 'millimeters', 2, 0.05) + expect(display.displayUnit).toBe(unit) + expect(display.parseUnit).toBeUndefined() + expect(display.toDisplay(12.5)).toBe(12.5) + expect(display.toStored(12.5)).toBe(12.5) + expect(display.precision).toBe(2) + expect(display.step).toBe(0.05) + } + }) +}) diff --git a/packages/editor/src/lib/linear-display.ts b/packages/editor/src/lib/linear-display.ts new file mode 100644 index 0000000000..a2fd50e352 --- /dev/null +++ b/packages/editor/src/lib/linear-display.ts @@ -0,0 +1,39 @@ +import { + getLinearUnitLabel, + type LinearUnit, + linearUnitToMeters, + type MetricNotation, + metersToLinearUnit, +} from './measurements' + +export function getLinearDisplay( + unit: string, + viewerUnit: LinearUnit, + metricNotation: MetricNotation, + precision: number, + step: number, +) { + const isImperial = unit === 'm' && viewerUnit === 'imperial' + const isMillimeters = unit === 'm' && viewerUnit === 'metric' && metricNotation === 'millimeters' + const displayUnit = isImperial ? getLinearUnitLabel('imperial') : isMillimeters ? 'mm' : unit + const displayPrecision = isMillimeters ? Math.max(0, precision - 3) : precision + const displayStep = isMillimeters ? step * 1000 : step + const parseUnit = isImperial ? 'ft' : isMillimeters ? 'mm' : undefined + const toDisplay = (stored: number) => + isImperial ? metersToLinearUnit(stored, 'imperial') : isMillimeters ? stored * 1000 : stored + const toStored = (display: number) => + isImperial ? linearUnitToMeters(display, 'imperial') : isMillimeters ? display / 1000 : display + const roundStored = (stored: number) => + toStored(Number.parseFloat(toDisplay(stored).toFixed(displayPrecision))) + + return { + isImperial, + displayUnit, + parseUnit, + precision: displayPrecision, + step: displayStep, + toDisplay, + toStored, + roundStored, + } +} diff --git a/packages/editor/src/lib/local-guide-image.test.ts b/packages/editor/src/lib/local-guide-image.test.ts new file mode 100644 index 0000000000..cc95488815 --- /dev/null +++ b/packages/editor/src/lib/local-guide-image.test.ts @@ -0,0 +1,24 @@ +import { expect, mock, test } from 'bun:test' +import * as core from '@pascal-app/core' + +const saveAsset = mock(async () => 'asset://stored-scan') + +mock.module('@pascal-app/core', () => ({ ...core, saveAsset })) + +const { createLocalScan } = await import('./local-guide-image') + +test('createLocalScan stores and attaches a scan asset to its level', async () => { + const createNode = mock() + const file = new File(['scan data'], 'living-room.glb', { type: 'model/gltf-binary' }) + + const { scan, url } = await createLocalScan({ createNode, file, levelId: 'level_ground' }) + + expect(saveAsset).toHaveBeenCalledWith(file) + expect(scan).toMatchObject({ + name: 'living-room', + type: 'scan', + url: 'asset://stored-scan', + }) + expect(url).toBe('asset://stored-scan') + expect(createNode).toHaveBeenCalledWith(scan, 'level_ground') +}) diff --git a/packages/editor/src/lib/local-guide-image.ts b/packages/editor/src/lib/local-guide-image.ts index 7dfe177511..c5111de3c7 100644 --- a/packages/editor/src/lib/local-guide-image.ts +++ b/packages/editor/src/lib/local-guide-image.ts @@ -2,13 +2,23 @@ import { type AnyNodeId, GuideNode, type GuideNode as GuideNodeType, + ScanNode, + type ScanNode as ScanNodeType, saveAsset, } from '@pascal-app/core' export function getGuideImageName(filename: string) { + return getAssetName(filename, 'Guide image') +} + +export function getScanName(filename: string) { + return getAssetName(filename, 'Scan') +} + +function getAssetName(filename: string, fallback: string) { const trimmed = filename.trim() if (!trimmed) { - return 'Guide image' + return fallback } const dotIndex = trimmed.lastIndexOf('.') @@ -40,3 +50,26 @@ export async function createLocalGuideImage({ createNode(guide, levelId as AnyNodeId) return guide } + +export async function createLocalScan({ + createNode, + file, + levelId, +}: { + createNode: (node: ScanNodeType, parentId: AnyNodeId) => void + file: File + levelId: string +}) { + const assetUrl = await saveAsset(file) + const scan = ScanNode.parse({ + name: getScanName(file.name), + url: assetUrl, + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: 1, + opacity: 100, + }) + + createNode(scan, levelId as AnyNodeId) + return { scan, url: assetUrl } +} diff --git a/packages/editor/src/lib/local-project-presentation-persistence.test.ts b/packages/editor/src/lib/local-project-presentation-persistence.test.ts new file mode 100644 index 0000000000..fc57b92595 --- /dev/null +++ b/packages/editor/src/lib/local-project-presentation-persistence.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, test } from 'bun:test' +import type { + ViewerPresentationConfiguration, + ViewerPresentationContribution, +} from '@pascal-app/viewer' +import { + createLocalProjectPresentationPersistence, + getLocalProjectPresentationStorageKey, +} from './local-project-presentation-persistence' + +const CONTRIBUTION_ID = 'test:environment:presentation' + +type TestSnapshot = { + version: 1 + value: string +} + +class MemoryStorage { + readonly values = new Map() + + getItem(key: string): string | null { + return this.values.get(key) ?? null + } + + setItem(key: string, value: string): void { + this.values.set(key, value) + } +} + +class TestConfiguration implements ViewerPresentationConfiguration { + private readonly listeners = new Set<() => void>() + value = 'default' + + getSnapshot = (): TestSnapshot => ({ version: 1, value: this.value }) + + restore = (snapshot: unknown): void => { + if ( + typeof snapshot !== 'object' || + snapshot === null || + !('version' in snapshot) || + snapshot.version !== 1 || + !('value' in snapshot) || + typeof snapshot.value !== 'string' + ) { + throw new Error('Invalid test presentation snapshot') + } + this.value = snapshot.value + } + + reset = (): void => { + this.value = 'default' + } + + subscribe = (onChange: () => void): (() => void) => { + this.listeners.add(onChange) + return () => this.listeners.delete(onChange) + } + + setValue(value: string): void { + this.value = value + for (const listener of this.listeners) listener() + } +} + +class TestRegistry { + private readonly listeners = new Set<() => void>() + private contributions: ViewerPresentationContribution[] = [] + + getSnapshot = (): readonly ViewerPresentationContribution[] => this.contributions + + subscribe = (onChange: () => void): (() => void) => { + this.listeners.add(onChange) + return () => this.listeners.delete(onChange) + } + + register(configuration: ViewerPresentationConfiguration): void { + this.contributions = [ + { + id: CONTRIBUTION_ID, + component: async () => ({ default: () => null }), + configuration, + }, + ] + for (const listener of this.listeners) listener() + } + + uninstall(): void { + this.contributions = [] + for (const listener of this.listeners) listener() + } +} + +function readStoredValue(storage: MemoryStorage, projectId: string): string { + const raw = storage.getItem(getLocalProjectPresentationStorageKey(projectId)) + if (!raw) throw new Error(`Missing presentation sidecar for ${projectId}`) + const sidecar = JSON.parse(raw) as { + contributions: Record + } + const snapshot = sidecar.contributions[CONTRIBUTION_ID] + if (!snapshot) throw new Error(`Missing ${CONTRIBUTION_ID} snapshot`) + return snapshot.value +} + +describe('local project presentation persistence', () => { + test('persists anonymous settings when assigning a new first project id', () => { + const storage = new MemoryStorage() + const registry = new TestRegistry() + const configuration = new TestConfiguration() + registry.register(configuration) + const persistence = createLocalProjectPresentationPersistence({ + registry, + storage, + pageHideTarget: null, + flushDelayMs: 60_000, + }) + + persistence.switchProject(null) + configuration.setValue('anonymous atmosphere') + persistence.switchProject('first-project') + + expect(configuration.value).toBe('anonymous atmosphere') + persistence.flush() + expect(readStoredValue(storage, 'first-project')).toBe('anonymous atmosphere') + persistence.dispose() + + const reloadedRegistry = new TestRegistry() + const reloadedConfiguration = new TestConfiguration() + reloadedRegistry.register(reloadedConfiguration) + const reloadedPersistence = createLocalProjectPresentationPersistence({ + registry: reloadedRegistry, + storage, + pageHideTarget: null, + }) + reloadedPersistence.switchProject('first-project') + + expect(reloadedConfiguration.value).toBe('anonymous atmosphere') + reloadedPersistence.dispose() + }) + + test('restores a stored project instead of overwriting it with anonymous settings', () => { + const storage = new MemoryStorage() + const storedRegistry = new TestRegistry() + const storedConfiguration = new TestConfiguration() + storedRegistry.register(storedConfiguration) + const storedPersistence = createLocalProjectPresentationPersistence({ + registry: storedRegistry, + storage, + pageHideTarget: null, + flushDelayMs: 60_000, + }) + storedPersistence.switchProject('existing-project') + storedConfiguration.setValue('stored atmosphere') + storedPersistence.flush() + storedPersistence.dispose() + + const registry = new TestRegistry() + const configuration = new TestConfiguration() + registry.register(configuration) + const persistence = createLocalProjectPresentationPersistence({ + registry, + storage, + pageHideTarget: null, + flushDelayMs: 60_000, + }) + persistence.switchProject(null) + configuration.setValue('anonymous atmosphere') + persistence.switchProject('existing-project') + + expect(configuration.value).toBe('stored atmosphere') + persistence.flush() + expect(readStoredValue(storage, 'existing-project')).toBe('stored atmosphere') + persistence.dispose() + }) + + test('flushes the old project before restoring defaults or the next project', () => { + const storage = new MemoryStorage() + const registry = new TestRegistry() + const configuration = new TestConfiguration() + registry.register(configuration) + const persistence = createLocalProjectPresentationPersistence({ + registry, + storage, + pageHideTarget: null, + flushDelayMs: 60_000, + }) + + persistence.switchProject('project-a') + configuration.setValue('project A atmosphere') + persistence.switchProject('project-b') + + expect(readStoredValue(storage, 'project-a')).toBe('project A atmosphere') + expect(configuration.value).toBe('default') + + configuration.setValue('project B atmosphere') + persistence.switchProject('project-a') + + expect(readStoredValue(storage, 'project-b')).toBe('project B atmosphere') + expect(configuration.value).toBe('project A atmosphere') + persistence.dispose() + }) + + test('restores a project after reload and flushes pending changes on dispose', () => { + const storage = new MemoryStorage() + const firstRegistry = new TestRegistry() + const firstConfiguration = new TestConfiguration() + firstRegistry.register(firstConfiguration) + const firstPersistence = createLocalProjectPresentationPersistence({ + registry: firstRegistry, + storage, + pageHideTarget: null, + flushDelayMs: 60_000, + }) + + firstPersistence.switchProject('reloadable') + firstConfiguration.setValue('saved sky') + firstPersistence.dispose() + + const reloadedRegistry = new TestRegistry() + const reloadedConfiguration = new TestConfiguration() + reloadedRegistry.register(reloadedConfiguration) + const reloadedPersistence = createLocalProjectPresentationPersistence({ + registry: reloadedRegistry, + storage, + pageHideTarget: null, + }) + reloadedPersistence.switchProject('reloadable') + + expect(reloadedConfiguration.value).toBe('saved sky') + reloadedPersistence.dispose() + }) + + test('recovers corrupted presentation data without touching scene storage', () => { + const storage = new MemoryStorage() + const sceneStorageKey = 'pascal-editor-scene' + const sceneData = '{"nodes":{"site":{"type":"site"}}}' + storage.setItem(sceneStorageKey, sceneData) + storage.setItem(getLocalProjectPresentationStorageKey('corrupted'), '{not-json') + + const registry = new TestRegistry() + const configuration = new TestConfiguration() + configuration.value = 'leaked from another project' + registry.register(configuration) + const persistence = createLocalProjectPresentationPersistence({ + registry, + storage, + pageHideTarget: null, + flushDelayMs: 60_000, + }) + + persistence.switchProject('corrupted') + expect(configuration.value).toBe('default') + persistence.flush() + + expect(readStoredValue(storage, 'corrupted')).toBe('default') + expect(storage.getItem(sceneStorageKey)).toBe(sceneData) + persistence.dispose() + }) + + test('retains configuration while a contribution is unregistered and registered again', () => { + const storage = new MemoryStorage() + const registry = new TestRegistry() + const firstConfiguration = new TestConfiguration() + registry.register(firstConfiguration) + const persistence = createLocalProjectPresentationPersistence({ + registry, + storage, + pageHideTarget: null, + flushDelayMs: 60_000, + }) + + persistence.switchProject('plugin-cycle') + firstConfiguration.setValue('retained atmosphere') + registry.uninstall() + + const reinstalledConfiguration = new TestConfiguration() + registry.register(reinstalledConfiguration) + + expect(reinstalledConfiguration.value).toBe('retained atmosphere') + persistence.dispose() + }) + + test('flushes pending changes on pagehide', () => { + const storage = new MemoryStorage() + const registry = new TestRegistry() + const configuration = new TestConfiguration() + const pageHideTarget = new EventTarget() + registry.register(configuration) + const persistence = createLocalProjectPresentationPersistence({ + registry, + storage, + pageHideTarget, + flushDelayMs: 60_000, + }) + + persistence.switchProject('pagehide') + configuration.setValue('last slider value') + pageHideTarget.dispatchEvent(new Event('pagehide')) + + expect(readStoredValue(storage, 'pagehide')).toBe('last slider value') + persistence.dispose() + }) +}) diff --git a/packages/editor/src/lib/local-project-presentation-persistence.ts b/packages/editor/src/lib/local-project-presentation-persistence.ts new file mode 100644 index 0000000000..bb72611064 --- /dev/null +++ b/packages/editor/src/lib/local-project-presentation-persistence.ts @@ -0,0 +1,335 @@ +'use client' + +import { type ViewerPresentationContribution, viewerPresentationRegistry } from '@pascal-app/viewer' +import { z } from 'zod' + +export const LOCAL_PROJECT_PRESENTATION_STORAGE_KEY_PREFIX = 'pascal:project-presentation:v1:' +const LOCAL_PROJECT_PRESENTATION_VERSION = 1 as const +const DEFAULT_FLUSH_DELAY_MS = 250 + +type PresentationConfiguration = NonNullable + +type PresentationRegistry = { + getSnapshot: () => readonly ViewerPresentationContribution[] + subscribe: (onChange: () => void) => () => void +} + +type PresentationStorage = { + getItem: (key: string) => string | null + setItem: (key: string, value: string) => void +} + +type PageHideTarget = Pick + +const LocalProjectPresentationSidecarSchema = z + .object({ + version: z.literal(LOCAL_PROJECT_PRESENTATION_VERSION), + projectId: z.string(), + contributions: z.record(z.string(), z.unknown()), + }) + .strict() +type LocalProjectPresentationSidecar = z.infer + +type StoredSidecar = { + contributions: Record + malformed: boolean + source: 'missing' | 'stored' | 'unavailable' +} + +export type LocalProjectPresentationPersistence = { + switchProject: (projectId: string | null) => void + flush: () => void + dispose: () => void +} + +export type LocalProjectPresentationPersistenceOptions = { + registry?: PresentationRegistry + storage?: PresentationStorage | null + pageHideTarget?: PageHideTarget | null + flushDelayMs?: number +} + +export function getLocalProjectPresentationStorageKey(projectId: string): string { + return `${LOCAL_PROJECT_PRESENTATION_STORAGE_KEY_PREFIX}${encodeURIComponent(projectId)}` +} + +function emptyContributions(): Record { + return Object.create(null) as Record +} + +function getBrowserStorage(): PresentationStorage | null { + if (typeof window === 'undefined') return null + try { + return window.localStorage + } catch { + return null + } +} + +function readSidecar(storage: PresentationStorage | null, projectId: string): StoredSidecar { + if (!storage) { + return { + contributions: emptyContributions(), + malformed: false, + source: 'unavailable', + } + } + + let raw: string | null + try { + raw = storage.getItem(getLocalProjectPresentationStorageKey(projectId)) + } catch { + return { + contributions: emptyContributions(), + malformed: false, + source: 'unavailable', + } + } + if (raw === null) { + return { + contributions: emptyContributions(), + malformed: false, + source: 'missing', + } + } + + try { + const parsed = LocalProjectPresentationSidecarSchema.safeParse(JSON.parse(raw)) + if (!parsed.success || parsed.data.projectId !== projectId) { + return { + contributions: emptyContributions(), + malformed: true, + source: 'stored', + } + } + return { + contributions: Object.assign(emptyContributions(), parsed.data.contributions), + malformed: false, + source: 'stored', + } + } catch { + return { + contributions: emptyContributions(), + malformed: true, + source: 'stored', + } + } +} + +class LocalProjectPresentationPersistenceImpl implements LocalProjectPresentationPersistence { + private projectId: string | null = null + private projectInitialized = false + private contributions = emptyContributions() + private readonly configurations = new Map< + string, + { configuration: PresentationConfiguration; unsubscribe: () => void } + >() + private readonly initializedConfigurationIds = new Set() + private readonly registry: PresentationRegistry + private readonly storage: PresentationStorage | null + private readonly pageHideTarget: PageHideTarget | null + private readonly flushDelayMs: number + private readonly unsubscribeRegistry: () => void + private flushTimer: ReturnType | undefined + private dirty = false + private suppressWrites = false + private disposed = false + + constructor(options: LocalProjectPresentationPersistenceOptions) { + this.registry = options.registry ?? viewerPresentationRegistry + this.storage = options.storage === undefined ? getBrowserStorage() : options.storage + this.pageHideTarget = + options.pageHideTarget === undefined + ? typeof window === 'undefined' + ? null + : window + : options.pageHideTarget + this.flushDelayMs = options.flushDelayMs ?? DEFAULT_FLUSH_DELAY_MS + this.unsubscribeRegistry = this.registry.subscribe(this.reconcileRegistry) + this.pageHideTarget?.addEventListener('pagehide', this.flushOnPageHide) + this.reconcileRegistry() + } + + switchProject(projectId: string | null): void { + const nextProjectId = projectId && projectId.length > 0 ? projectId : null + if (this.projectInitialized && nextProjectId === this.projectId) return + + const assigningAnonymousPresentation = + this.projectInitialized && this.projectId === null && nextProjectId !== null + + this.flush() + this.projectId = nextProjectId + this.projectInitialized = true + this.initializedConfigurationIds.clear() + + const stored = nextProjectId + ? readSidecar(this.storage, nextProjectId) + : { + contributions: emptyContributions(), + malformed: false, + source: 'missing' as const, + } + this.contributions = stored.contributions + + if (assigningAnonymousPresentation && stored.source === 'missing') { + let capturedConfiguration = false + for (const [id, entry] of this.configurations) { + this.initializedConfigurationIds.add(id) + try { + this.contributions[id] = entry.configuration.getSnapshot() + capturedConfiguration = true + } catch { + // Leave the live configuration intact when an optional adapter cannot export. + } + } + if (capturedConfiguration) this.markDirty() + return + } + + let recoveredInvalidConfiguration = stored.malformed + for (const [id, entry] of this.configurations) { + if (!this.restoreConfiguration(id, entry.configuration)) { + recoveredInvalidConfiguration = true + } + } + + if (recoveredInvalidConfiguration) this.markDirty() + } + + flush = (): void => { + if (this.flushTimer !== undefined) { + clearTimeout(this.flushTimer) + this.flushTimer = undefined + } + const projectId = this.projectId + if (!this.dirty || !projectId || !this.storage) return + + const contributions = Object.assign(emptyContributions(), this.contributions) + for (const [id, entry] of this.configurations) { + if (!this.initializedConfigurationIds.has(id)) continue + try { + contributions[id] = entry.configuration.getSnapshot() + } catch { + // Retain the last valid snapshot when one contribution cannot export. + } + } + + const sidecar: LocalProjectPresentationSidecar = { + version: LOCAL_PROJECT_PRESENTATION_VERSION, + projectId, + contributions, + } + try { + this.storage.setItem( + getLocalProjectPresentationStorageKey(projectId), + JSON.stringify(sidecar), + ) + this.contributions = contributions + this.dirty = false + } catch { + // Storage denial, quota exhaustion, and non-serializable plugin data are non-fatal. + } + } + + dispose(): void { + if (this.disposed) return + this.disposed = true + this.flush() + this.unsubscribeRegistry() + this.pageHideTarget?.removeEventListener('pagehide', this.flushOnPageHide) + for (const entry of this.configurations.values()) entry.unsubscribe() + this.configurations.clear() + this.initializedConfigurationIds.clear() + } + + private readonly flushOnPageHide = (): void => { + this.flush() + } + + private readonly reconcileRegistry = (): void => { + if (this.disposed) return + + const current = new Map() + for (const contribution of this.registry.getSnapshot()) { + if (contribution.configuration) { + current.set(contribution.id, contribution.configuration) + } + } + + for (const [id, entry] of this.configurations) { + if (current.get(id) === entry.configuration) continue + if (this.initializedConfigurationIds.has(id)) { + this.captureConfiguration(id, entry.configuration) + } + entry.unsubscribe() + this.configurations.delete(id) + this.initializedConfigurationIds.delete(id) + } + + for (const [id, configuration] of current) { + if (this.configurations.has(id)) continue + + let unsubscribe: () => void = () => undefined + try { + unsubscribe = configuration.subscribe(() => { + if (!this.suppressWrites) this.markDirty() + }) + } catch { + // A broken optional adapter must not affect the editor or other contributions. + } + this.configurations.set(id, { configuration, unsubscribe }) + + if (this.projectInitialized && !this.restoreConfiguration(id, configuration)) { + this.markDirty() + } + } + } + + private restoreConfiguration(id: string, configuration: PresentationConfiguration): boolean { + let valid = true + this.suppressWrites = true + try { + if (Object.hasOwn(this.contributions, id)) { + try { + configuration.restore(this.contributions[id]) + } catch { + valid = false + delete this.contributions[id] + try { + configuration.reset() + } catch {} + } + } else { + try { + configuration.reset() + } catch {} + } + } finally { + this.suppressWrites = false + this.initializedConfigurationIds.add(id) + } + return valid + } + + private captureConfiguration(id: string, configuration: PresentationConfiguration): void { + try { + this.contributions[id] = configuration.getSnapshot() + this.markDirty() + } catch { + // Preserve the prior snapshot if the retiring adapter cannot export. + } + } + + private markDirty(): void { + if (!this.projectId || this.suppressWrites || this.disposed) return + this.dirty = true + if (this.flushTimer !== undefined) clearTimeout(this.flushTimer) + this.flushTimer = setTimeout(this.flush, this.flushDelayMs) + } +} + +export function createLocalProjectPresentationPersistence( + options: LocalProjectPresentationPersistenceOptions = {}, +): LocalProjectPresentationPersistence { + return new LocalProjectPresentationPersistenceImpl(options) +} diff --git a/packages/editor/src/lib/model-export.ts b/packages/editor/src/lib/model-export.ts index efdf19ffde..7ddcc980e3 100644 --- a/packages/editor/src/lib/model-export.ts +++ b/packages/editor/src/lib/model-export.ts @@ -1,7 +1,11 @@ -export type ModelExportFormat = 'glb' | 'stl' | 'obj' | 'print-stl' | 'print-3mf' +import type { GlbExportOptions } from './glb-export' -export type ModelExportOptions = { - onlyVisible?: boolean +export type ModelExportFormat = 'glb' | 'usdz' | 'stl' | 'obj' | 'print-stl' | 'print-3mf' + +export type ModelExportOptions = Pick< + GlbExportOptions, + 'onlyVisible' | 'excludedNodeTypes' | 'includedPresentationIds' +> & { download?: boolean printScale?: number printScope?: 'whole' | 'levels' @@ -16,6 +20,7 @@ export type ModelExportArtifact = { blob: Blob filename: string metadata?: unknown + warnings?: readonly string[] } export type ModelExport = ( diff --git a/packages/editor/src/lib/planar-cursor-placement.test.ts b/packages/editor/src/lib/planar-cursor-placement.test.ts index f49c533985..8089ff77c1 100644 --- a/packages/editor/src/lib/planar-cursor-placement.test.ts +++ b/packages/editor/src/lib/planar-cursor-placement.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' import { + offsetPlanPositionByLocalCenter, resolvePlanarCursorPosition, resolvePrioritizedPlanarCursorPosition, } from './planar-cursor-placement' @@ -7,6 +8,83 @@ import { const snapHalf = (value: number) => Math.round(value / 0.5) * 0.5 describe('resolvePlanarCursorPosition', () => { + test('absolute mode puts the unrotated footprint centre at the snapped cursor', () => { + const result = resolvePlanarCursorPosition({ + cursor: [4.24, 2.26], + original: [0, 0], + anchor: null, + mode: 'absolute', + localCenter: [1.3, 0.5, 0.2], + snap: snapHalf, + }) + + expect(result.point).toEqual([2.7, 2.3]) + expect(result.anchor).toBeNull() + }) + + test('absolute mode snaps the centre before deriving the rotated origin', () => { + const proposals: [number, number][] = [] + const localCenter: [number, number, number] = [1.3, 0.5, 0.2] + const result = resolvePlanarCursorPosition({ + cursor: [4.24, 2.26], + original: [0, 0], + anchor: null, + mode: 'absolute', + localCenter, + rotationY: Math.PI / 2, + snapPoint: (point) => { + proposals.push(point) + return [snapHalf(point[0]), snapHalf(point[1])] + }, + }) + + expect(proposals).toEqual([[4.24, 2.26]]) + expect(result.point[0]).toBeCloseTo(3.8) + expect(result.point[1]).toBeCloseTo(3.8) + const centre = offsetPlanPositionByLocalCenter( + [result.point[0], 0, result.point[1]], + localCenter, + Math.PI / 2, + ) + expect(centre[0]).toBeCloseTo(4) + expect(centre[2]).toBeCloseTo(2.5) + }) + + test('absolute mode follows the unsnapped cursor at an oblique rotation', () => { + const localCenter: [number, number, number] = [1.5, 0.5, -0.3] + const result = resolvePlanarCursorPosition({ + cursor: [-2.13, 6.27], + original: [0, 0], + anchor: null, + mode: 'absolute', + localCenter, + rotationY: -Math.PI / 4, + }) + const centre = offsetPlanPositionByLocalCenter( + [result.point[0], 0, result.point[1]], + localCenter, + -Math.PI / 4, + ) + + expect(centre[0]).toBeCloseTo(-2.13) + expect(centre[2]).toBeCloseTo(6.27) + }) + + test('relative mode ignores the footprint centre and rotation', () => { + const result = resolvePlanarCursorPosition({ + cursor: [4.9, 5.2], + original: [10, 20], + anchor: [4.1, 6.1], + mode: 'relative', + localCenter: [1.3, 0.5, 0.2], + rotationY: Math.PI / 2, + snap: snapHalf, + }) + + expect(result.point).toEqual([11, 19]) + expect(result.anchor).toEqual([4.1, 6.1]) + }) + test('absolute mode places the point directly at the snapped cursor', () => { const result = resolvePlanarCursorPosition({ cursor: [1.24, -2.26], @@ -103,6 +181,46 @@ describe('resolvePlanarCursorPosition', () => { }) describe('resolvePrioritizedPlanarCursorPosition', () => { + test('attachment receives the corrected raw origin and returns the final origin', () => { + const proposals: [number, number][] = [] + const result = resolvePrioritizedPlanarCursorPosition({ + cursor: [4.24, 2.26], + original: [0, 0], + anchor: null, + mode: 'absolute', + localCenter: [1.3, 0.5, 0.2], + rotationY: Math.PI / 2, + snapPoint: () => { + throw new Error('Grid snapping must not run after attachment') + }, + resolveAttachment: (proposal) => { + proposals.push(proposal) + return [3, 5] + }, + }) + + expect(proposals).toHaveLength(1) + expect(proposals[0]![0]).toBeCloseTo(4.04) + expect(proposals[0]![1]).toBeCloseTo(3.56) + expect(result.point).toEqual([3, 5]) + expect(result.attachmentSnapped).toBe(true) + }) + + test('snaps the footprint centre when attachment declines the corrected origin', () => { + const result = resolvePrioritizedPlanarCursorPosition({ + cursor: [4.24, 2.26], + original: [0, 0], + anchor: null, + mode: 'absolute', + localCenter: [1.3, 0.5, 0.2], + snapPoint: ([x, z]) => [snapHalf(x), snapHalf(z)], + resolveAttachment: () => null, + }) + + expect(result.point).toEqual([2.7, 2.3]) + expect(result.attachmentSnapped).toBe(false) + }) + test('wall attachment receives the raw proposal and wins over grid snapping', () => { const attachmentProposals: [number, number][] = [] const result = resolvePrioritizedPlanarCursorPosition({ diff --git a/packages/editor/src/lib/planar-cursor-placement.ts b/packages/editor/src/lib/planar-cursor-placement.ts index 2bd61203f4..df1a077aa5 100644 --- a/packages/editor/src/lib/planar-cursor-placement.ts +++ b/packages/editor/src/lib/planar-cursor-placement.ts @@ -7,6 +7,8 @@ type ResolvePlanarCursorPositionArgs = { original: PlanarPoint anchor: PlanarPoint | null mode: PlanarCursorPlacementMode + localCenter?: [number, number, number] + rotationY?: number snap?: (value: number) => number snapPoint?: (point: PlanarPoint) => PlanarPoint } @@ -26,18 +28,42 @@ type ResolvePrioritizedPlanarCursorPositionResult = ResolvePlanarCursorPositionR const identity = (value: number) => value +export function offsetPlanPositionByLocalCenter( + position: [number, number, number], + center: [number, number, number], + rotationY: number, +): [number, number, number] { + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + return [ + position[0] + center[0] * cos + center[2] * sin, + position[1] + center[1], + position[2] - center[0] * sin + center[2] * cos, + ] +} + export function resolvePlanarCursorPosition({ cursor, original, anchor, mode, + localCenter, + rotationY = 0, snap = identity, snapPoint, }: ResolvePlanarCursorPositionArgs): ResolvePlanarCursorPositionResult { if (mode === 'absolute') { const proposal: PlanarPoint = [cursor[0], cursor[1]] + const snapped: PlanarPoint = snapPoint?.(proposal) ?? [snap(cursor[0]), snap(cursor[1])] + const origin: [number, number, number] = localCenter + ? offsetPlanPositionByLocalCenter( + [snapped[0], 0, snapped[1]], + [-localCenter[0], 0, -localCenter[2]], + rotationY, + ) + : [snapped[0], 0, snapped[1]] return { - point: snapPoint?.(proposal) ?? [snap(cursor[0]), snap(cursor[1])], + point: [origin[0], origin[2]], anchor, } } diff --git a/packages/editor/src/lib/portable-export.test.ts b/packages/editor/src/lib/portable-export.test.ts new file mode 100644 index 0000000000..9640744a4b --- /dev/null +++ b/packages/editor/src/lib/portable-export.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, test } from 'bun:test' +import { strFromU8, unzipSync } from 'fflate' +import { + Box3, + BoxGeometry, + BufferGeometry, + Color, + DoubleSide, + Float32BufferAttribute, + FrontSide, + Group, + InstancedBufferAttribute, + InstancedMesh, + Matrix4, + Mesh, + MeshStandardMaterial, + Quaternion, + Vector3, +} from 'three' +import { USDZExporter } from 'three/examples/jsm/exporters/USDZExporter.js' +import { + createUsdzScene, + expandInstancedMeshes, + freezeDeformedMeshes, + normalizePortableScene, +} from './portable-export' + +describe('portable vertex-color boundary', () => { + test('clips only over-bright channels while preserving ordinary linear colors', () => { + withCanvasCapture((capture) => { + const colors = new Float32Array([0.25, 0.5, 0.75, 1, 1.5, 0.4, 0.2, 0.5, 0.1, 0.2, 0.3, 0]) + const sourceValues = Array.from(colors) + const geometry = triangleGeometry(colors) + const material = new MeshStandardMaterial({ vertexColors: true }) + const mesh = new Mesh(geometry, material) + const root = new Group() + root.add(mesh) + + const warnings = normalizePortableScene(root) + + expect(warnings).toHaveLength(1) + expect(warnings[0]).toMatch(/color.*clip/i) + expect(Array.from(colors)).toEqual(sourceValues) + expect(portableColorAt(mesh, 0, capture)).toEqual([ + encodeSrgbByte(0.25), + encodeSrgbByte(0.5), + encodeSrgbByte(0.75), + 255, + ]) + expect(portableColorAt(mesh, 1, capture)).toEqual([ + 255, + encodeSrgbByte(0.4), + encodeSrgbByte(0.2), + 128, + ]) + expect(portableColorAt(mesh, 2, capture)).toEqual([ + encodeSrgbByte(0.1), + encodeSrgbByte(0.2), + encodeSrgbByte(0.3), + 0, + ]) + const portableMaterial = mesh.material as MeshStandardMaterial + expect(portableMaterial.emissive.getHex()).toBe(0) + expect(portableMaterial.emissiveMap).toBeNull() + }) + }) + + test('does not warn when every channel is already portable', () => { + withCanvasCapture(() => { + const geometry = triangleGeometry( + new Float32Array([0, 0.25, 0.5, 1, 0.75, 1, 0.125, 1, 0.3, 0.4, 0.6, 1]), + ) + const mesh = new Mesh(geometry, new MeshStandardMaterial({ vertexColors: true })) + const root = new Group() + root.add(mesh) + + expect(normalizePortableScene(root)).toEqual([]) + }) + }) +}) + +describe('portable geometry normalization', () => { + test('preserves two transformed instance populations without changing source data', () => { + const sourceMaterial = new MeshStandardMaterial({ color: '#808080' }) + const sourceGeometry = new BoxGeometry(1, 2, 3) + const shaderInstancePayload = new InstancedBufferAttribute(new Float32Array(32), 16) + sourceGeometry.setAttribute('shaderInstancePayload', shaderInstancePayload) + const instances = new InstancedMesh(sourceGeometry, sourceMaterial, 2) + instances.name = 'asymmetric-instance' + instances.position.set(10, 0, -2) + const firstMatrix = new Matrix4().compose( + new Vector3(2, 0, 0), + new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI / 2), + new Vector3(1, 2, 1), + ) + const reflectedMatrix = new Matrix4().compose( + new Vector3(-3, 1, 4), + new Quaternion(), + new Vector3(-2, 1, 0.5), + ) + instances.setMatrixAt(0, firstMatrix) + instances.setMatrixAt(1, reflectedMatrix) + instances.setColorAt(0, new Color('#ff0000')) + instances.setColorAt(1, new Color('#0000ff')) + const root = new Group() + root.add(instances) + + expandInstancedMeshes(root) + root.updateMatrixWorld(true) + + const firstPopulation = new Box3().makeEmpty() + const reflectedPopulation = new Box3().makeEmpty() + let triangleCount = 0 + let firstOutputTint: Color | null = null + let reflectedOutputTint: Color | null = null + root.traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + const positions = mesh.geometry.getAttribute('position') + triangleCount += (mesh.geometry.getIndex()?.count ?? positions.count) / 3 + for (const attribute of Object.values(mesh.geometry.attributes)) { + expect((attribute as InstancedBufferAttribute).isInstancedBufferAttribute).not.toBe(true) + expect(attribute.count).toBe(positions.count) + } + for (let index = 0; index < positions.count; index += 1) { + const world = new Vector3() + .fromBufferAttribute(positions, index) + .applyMatrix4(mesh.matrixWorld) + if (world.x < 9) { + reflectedPopulation.expandByPoint(world) + reflectedOutputTint ??= portableLinearTintAt(mesh, index) + } else { + firstPopulation.expandByPoint(world) + firstOutputTint ??= portableLinearTintAt(mesh, index) + } + } + }) + expect(triangleCount).toBe(24) + expectVector(firstPopulation.getCenter(new Vector3()), new Vector3(12, 0, -2)) + expectVector(firstPopulation.getSize(new Vector3()), new Vector3(3, 4, 1)) + expectVector(reflectedPopulation.getCenter(new Vector3()), new Vector3(7, 1, 2)) + expectVector(reflectedPopulation.getSize(new Vector3()), new Vector3(2, 2, 1.5)) + expect(firstOutputTint).not.toBeNull() + expect(reflectedOutputTint).not.toBeNull() + expectColor(firstOutputTint!, sourceMaterial.color.clone().multiply(new Color('#ff0000'))) + expectColor(reflectedOutputTint!, sourceMaterial.color.clone().multiply(new Color('#0000ff'))) + const retainedMatrix = new Matrix4() + instances.getMatrixAt(1, retainedMatrix) + expect(retainedMatrix.equals(reflectedMatrix)).toBe(true) + const retainedColor = new Color() + expect(instances.geometry.getAttribute('shaderInstancePayload')).toBe(shaderInstancePayload) + instances.getColorAt(1, retainedColor) + expect(retainedColor.equals(new Color('#0000ff'))).toBe(true) + }) + + test('freezes a settled morph pose into static vertex positions without mutating its source', () => { + const geometry = new BufferGeometry() + const sourcePositions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) + const morphDelta = new Float32Array([0, 0, 0, 0, 0, 0, 0, 2, 0]) + geometry.setAttribute('position', new Float32BufferAttribute(sourcePositions, 3)) + geometry.morphAttributes.position = [new Float32BufferAttribute(morphDelta, 3)] + geometry.morphTargetsRelative = true + const mesh = new Mesh(geometry, new MeshStandardMaterial()) + mesh.updateMorphTargets() + mesh.morphTargetInfluences![0] = 0.25 + const root = new Group() + root.add(mesh) + + freezeDeformedMeshes(root) + + const frozen = mesh.geometry.getAttribute('position') + expect([frozen.getX(2), frozen.getY(2), frozen.getZ(2)]).toEqual([0, 1.5, 0]) + expect(mesh.geometry.morphAttributes.position).toBeUndefined() + expect(mesh.morphTargetInfluences).toBeUndefined() + expect(Array.from(sourcePositions)).toEqual([0, 0, 0, 1, 0, 0, 0, 1, 0]) + expect(Array.from(morphDelta)).toEqual([0, 0, 0, 0, 0, 0, 0, 2, 0]) + }) + + test('bakes reflected USDZ transforms with outward-consistent winding and normals', () => { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 1], 3)) + geometry.computeVertexNormals() + const source = new Mesh(geometry, new MeshStandardMaterial()) + source.name = 'reflected-triangle' + source.position.set(2, -1, 3) + source.scale.set(-2, 1, 0.5) + const root = new Group() + root.add(source) + + const portable = createUsdzScene(root) + const reflected = portable.getObjectByName('reflected-triangle') as Mesh + const positions = reflected.geometry.getAttribute('position') + const normals = reflected.geometry.getAttribute('normal') + const first = new Vector3().fromBufferAttribute(positions, 0) + const second = new Vector3().fromBufferAttribute(positions, 1) + const third = new Vector3().fromBufferAttribute(positions, 2) + const faceNormal = second.clone().sub(first).cross(third.clone().sub(first)).normalize() + const vertexNormal = new Vector3().fromBufferAttribute(normals, 0).normalize() + + expect(faceNormal.dot(vertexNormal)).toBeGreaterThan(0.999) + expectVector(reflected.position, new Vector3()) + expectVector(reflected.scale, new Vector3(1, 1, 1)) + }) + + test('emits every referenced geometry member for a one-element material array', async () => { + const root = new Group() + const mesh = new Mesh(new BoxGeometry(1, 1, 1), [new MeshStandardMaterial()]) + root.add(mesh) + + const archive = await new USDZExporter().parseAsync(createUsdzScene(root)) + const files = unzipSync(archive) + const modelFile = files['model.usda'] + expect(modelFile).toBeDefined() + const model = strFromU8(modelFile!) + const geometryReferences = Array.from( + model.matchAll(/@\.\/(geometries\/[^@]+)@/g), + (match) => match[1]!, + ) + + expect(geometryReferences).toHaveLength(1) + for (const reference of geometryReferences) { + expect(files[reference]).toBeDefined() + } + }) + + test('retains RGBA and back-face visibility without prescribing GLB representation', () => { + withCanvasCapture((capture) => { + const geometry = triangleGeometry(new Float32Array([1, 0, 0, 1, 0, 1, 0, 0.5, 0, 0, 1, 0])) + geometry.setAttribute('normal', new Float32BufferAttribute([0, 0, 1, 0, 0, 1, 0, 0, 1], 3)) + const mesh = new Mesh( + geometry, + new MeshStandardMaterial({ side: DoubleSide, vertexColors: true }), + ) + const root = new Group() + root.add(mesh) + + expect(normalizePortableScene(root)).toEqual([]) + + expect(portableColorAt(mesh, 0, capture)).toEqual([255, 0, 0, 255]) + expect(portableColorAt(mesh, 1, capture)).toEqual([0, 255, 0, 128]) + expect(portableColorAt(mesh, 2, capture)).toEqual([0, 0, 255, 0]) + const material = mesh.material as MeshStandardMaterial + expect(material.transparent).toBe(true) + if (material.side === FrontSide) { + expect(mesh.geometry.getAttribute('position').count).toBe(6) + expect(mesh.geometry.getAttribute('normal').getZ(0)).toBe(1) + expect(mesh.geometry.getAttribute('normal').getZ(3)).toBe(-1) + } else { + expect(material.side).toBe(DoubleSide) + expect(mesh.geometry.getAttribute('position').count).toBe(3) + } + }) + }) +}) + +function expectVector(actual: Vector3, expected: Vector3): void { + expect(actual.x).toBeCloseTo(expected.x, 6) + expect(actual.y).toBeCloseTo(expected.y, 6) + expect(actual.z).toBeCloseTo(expected.z, 6) +} + +function triangleGeometry(colors: Float32Array): BufferGeometry { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)) + geometry.setAttribute('color', new Float32BufferAttribute(colors, 4)) + return geometry +} + +function portableLinearTintAt(mesh: Mesh, vertexIndex: number): Color { + const material = ( + Array.isArray(mesh.material) ? mesh.material[0] : mesh.material + ) as MeshStandardMaterial + const tint = material.color.clone() + const color = mesh.geometry.getAttribute('color') + if (color) { + tint.multiply( + new Color(color.getX(vertexIndex), color.getY(vertexIndex), color.getZ(vertexIndex)), + ) + } + return tint +} + +function expectColor(actual: Color, expected: Color): void { + expect(actual.r).toBeCloseTo(expected.r, 6) + expect(actual.g).toBeCloseTo(expected.g, 6) + expect(actual.b).toBeCloseTo(expected.b, 6) +} + +function portableColorAt(mesh: Mesh, vertexIndex: number, capture: () => ImageData): number[] { + const colors = mesh.geometry.getAttribute('color') + if (colors) { + return [ + encodeSrgbByte(colors.getX(vertexIndex)), + encodeSrgbByte(colors.getY(vertexIndex)), + encodeSrgbByte(colors.getZ(vertexIndex)), + Math.round((colors.itemSize >= 4 ? colors.getW(vertexIndex) : 1) * 255), + ] + } + const material = mesh.material as MeshStandardMaterial + if (!material.map) throw new Error('Portable color has no material-connected carrier') + const channel = material.map.channel ?? 0 + const uvName = channel === 0 ? 'uv' : `uv${channel}` + const uvs = mesh.geometry.getAttribute(uvName) + if (!uvs) throw new Error(`Portable color map has no geometry ${uvName} coordinates`) + const image = capture() + const u = Math.max(0, Math.min(1, uvs.getX(vertexIndex))) + const rawV = Math.max(0, Math.min(1, uvs.getY(vertexIndex))) + const v = material.map.flipY ? 1 - rawV : rawV + return pixel( + image.data, + image.width, + Math.min(image.width - 1, Math.floor(u * image.width)), + Math.min(image.height - 1, Math.floor(v * image.height)), + ) +} + +function encodeSrgbByte(linear: number): number { + const clipped = Math.max(0, Math.min(1, linear)) + const encoded = clipped <= 0.0031308 ? clipped * 12.92 : 1.055 * clipped ** (1 / 2.4) - 0.055 + return Math.round(encoded * 255) +} + +function pixel(pixels: Uint8ClampedArray, width: number, x: number, y: number): number[] { + const offset = (y * width + x) * 4 + return Array.from(pixels.subarray(offset, offset + 4)) +} + +function withCanvasCapture(run: (capture: () => ImageData) => void): void { + const globals = globalThis as unknown as { document?: Document } + const previousDocument = globals.document + let captured: ImageData | null = null + const canvas = { + width: 0, + height: 0, + getContext: () => ({ + createImageData: (width: number, height: number) => { + captured = { + colorSpace: 'srgb', + data: new Uint8ClampedArray(width * height * 4), + height, + width, + } as ImageData + return captured + }, + putImageData: (image: ImageData) => { + captured = image + }, + }), + } as unknown as HTMLCanvasElement + globals.document = { + createElement: (tagName: string) => { + if (tagName !== 'canvas') throw new Error(`Unexpected element request: ${tagName}`) + return canvas + }, + } as unknown as Document + + try { + run(() => { + if (!captured) throw new Error('Portable normalization did not emit atlas pixels') + return captured + }) + } finally { + if (previousDocument) globals.document = previousDocument + else delete globals.document + } +} diff --git a/packages/editor/src/lib/portable-export.ts b/packages/editor/src/lib/portable-export.ts new file mode 100644 index 0000000000..96f8490cd4 --- /dev/null +++ b/packages/editor/src/lib/portable-export.ts @@ -0,0 +1,969 @@ +import * as THREE from 'three' +import { StaticGeometryGenerator } from 'three-mesh-bvh' + +const VERTEX_COLOR_UV_CHANNEL = 0 +const VERTEX_COLOR_TILE_SIZE = 8 +const MAX_VERTEX_COLOR_ATLAS_SIZE = 2048 + +const MATERIAL_TEXTURE_SLOTS = [ + 'map', + 'normalMap', + 'roughnessMap', + 'metalnessMap', + 'aoMap', + 'emissiveMap', + 'alphaMap', + 'lightMap', + 'bumpMap', + 'displacementMap', + 'clearcoatMap', + 'clearcoatNormalMap', + 'clearcoatRoughnessMap', + 'iridescenceMap', + 'iridescenceThicknessMap', + 'transmissionMap', + 'thicknessMap', + 'specularIntensityMap', + 'specularColorMap', + 'sheenRoughnessMap', + 'sheenColorMap', + 'anisotropyMap', +] as const + +type TexturedMaterial = THREE.Material & Record +type DisposableResource = THREE.BufferGeometry | THREE.Material | THREE.Texture + +function uvAttributeName(channel: number): string { + return channel === 0 ? 'uv' : `uv${channel}` +} + +const discardedResources = new WeakMap>() + +function rememberDiscarded(root: THREE.Object3D, resource: DisposableResource): void { + let resources = discardedResources.get(root) + if (!resources) { + resources = new Set() + discardedResources.set(root, resources) + } + resources.add(resource) +} + +function materialsOf(mesh: THREE.Mesh): THREE.Material[] { + return Array.isArray(mesh.material) ? mesh.material : [mesh.material] +} + +function copyObjectState(source: THREE.Object3D, target: THREE.Object3D): void { + target.name = source.name + target.up.copy(source.up) + target.position.copy(source.position) + target.quaternion.copy(source.quaternion) + target.scale.copy(source.scale) + target.matrix.copy(source.matrix) + target.matrixAutoUpdate = source.matrixAutoUpdate + target.visible = source.visible + target.layers.mask = source.layers.mask + target.renderOrder = source.renderOrder + target.frustumCulled = source.frustumCulled + target.userData = structuredClone(source.userData) +} + +function replaceObject(source: THREE.Object3D, replacement: THREE.Object3D): void { + const parent = source.parent + if (!parent) throw new Error(`Cannot replace detached export object "${source.name}"`) + const index = parent.children.indexOf(source) + parent.remove(source) + parent.add(replacement) + const appended = parent.children.indexOf(replacement) + parent.children.splice(appended, 1) + parent.children.splice(index, 0, replacement) +} + +function tintMaterial(material: THREE.Material, tint: THREE.Color | null): THREE.Material { + if (!tint) return material + const clone = material.clone() as TexturedMaterial + const color = clone.color + if (!(color instanceof THREE.Color)) { + throw new Error(`Instance-colored material "${material.name}" has no portable color factor`) + } + color.multiply(tint) + return clone +} + +function tintMaterials( + material: THREE.Material | THREE.Material[], + tint: THREE.Color | null, +): THREE.Material | THREE.Material[] { + return Array.isArray(material) + ? material.map((entry) => tintMaterial(entry, tint)) + : tintMaterial(material, tint) +} +type InstancedGeometryAttribute = [string, THREE.InstancedBufferAttribute] + +function geometryForInstance( + source: THREE.BufferGeometry, + instancedAttributes: readonly InstancedGeometryAttribute[], + instanceIndex: number, +): THREE.BufferGeometry { + if (instancedAttributes.length === 0) return source + + const vertexCount = source.getAttribute('position')?.count + if (vertexCount === undefined) { + throw new Error('Instanced geometry has no position attribute') + } + const geometry = source.clone() + for (const [name, attribute] of instancedAttributes) { + const valueIndex = Math.floor(instanceIndex / attribute.meshPerAttribute) + if (valueIndex >= attribute.count) { + geometry.dispose() + throw new Error(`Instanced attribute "${name}" has no value for instance ${instanceIndex}`) + } + const ArrayType = attribute.array.constructor as new (length: number) => typeof attribute.array + const values = new ArrayType(vertexCount * attribute.itemSize) + const sourceOffset = valueIndex * attribute.itemSize + for (let vertex = 0; vertex < vertexCount; vertex++) { + const targetOffset = vertex * attribute.itemSize + for (let component = 0; component < attribute.itemSize; component++) { + values[targetOffset + component] = attribute.array[sourceOffset + component]! + } + } + const resolved = new THREE.BufferAttribute(values, attribute.itemSize, attribute.normalized) + resolved.name = attribute.name + resolved.setUsage(attribute.usage) + resolved.gpuType = attribute.gpuType + geometry.setAttribute(name, resolved) + } + return geometry +} + +/** Replace every InstancedMesh with ordinary meshes in the same local hierarchy. */ +export function expandInstancedMeshes(root: THREE.Object3D): void { + const instances: THREE.InstancedMesh[] = [] + root.traverse((object) => { + if ((object as THREE.InstancedMesh).isInstancedMesh) { + instances.push(object as THREE.InstancedMesh) + } + }) + + const matrix = new THREE.Matrix4() + const tint = new THREE.Color() + for (const source of instances) { + const wrapper = new THREE.Group() + copyObjectState(source, wrapper) + const instancedAttributes = Object.entries(source.geometry.attributes).filter( + ([, attribute]) => (attribute as THREE.InstancedBufferAttribute).isInstancedBufferAttribute, + ) as InstancedGeometryAttribute[] + + for (let index = 0; index < source.count; index++) { + const color = source.instanceColor ? source.getColorAt(index, tint).clone() : null + const geometry = geometryForInstance(source.geometry, instancedAttributes, index) + const mesh = new THREE.Mesh(geometry, tintMaterials(source.material, color)) + mesh.name = source.name ? `${source.name}_${index + 1}` : `instance_${index + 1}` + mesh.castShadow = source.castShadow + mesh.receiveShadow = source.receiveShadow + mesh.frustumCulled = source.frustumCulled + source.getMatrixAt(index, matrix) + matrix.decompose(mesh.position, mesh.quaternion, mesh.scale) + + if (source.morphTexture) { + source.getMorphAt(index, mesh) + } + wrapper.add(mesh) + } + if (instancedAttributes.length > 0) rememberDiscarded(root, source.geometry) + + for (const child of [...source.children]) wrapper.add(child) + replaceObject(source, wrapper) + } +} + +function bakeDeformedGeometry(mesh: THREE.Mesh): THREE.BufferGeometry { + const geometry = mesh.geometry + const generator = new StaticGeometryGenerator(mesh) + generator.applyWorldTransforms = false + generator.useGroups = false + generator.attributes = Object.keys(geometry.attributes).filter( + (name) => name !== 'skinIndex' && name !== 'skinWeight', + ) + + const savedMatrixWorld = mesh.matrixWorld.clone() + mesh.matrixWorld.identity() + try { + const baked = generator.generate() + baked.groups = geometry.groups.map((group) => ({ ...group })) + baked.setDrawRange(geometry.drawRange.start, geometry.drawRange.count) + return baked + } finally { + mesh.matrixWorld.copy(savedMatrixWorld) + } +} + +/** Freeze current morph and skin deformation into ordinary vertex buffers. */ +export function freezeDeformedMeshes(root: THREE.Object3D): void { + const meshes: THREE.Mesh[] = [] + root.traverse((object) => { + if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh) + }) + + for (const mesh of meshes) { + const skinnedMesh = mesh as THREE.SkinnedMesh + const hasMorphTargets = Object.values(mesh.geometry.morphAttributes).some( + (attributes) => attributes.length > 0, + ) + if (!(skinnedMesh.isSkinnedMesh || hasMorphTargets)) continue + + const originalGeometry = mesh.geometry + mesh.geometry = bakeDeformedGeometry(mesh) + mesh.geometry.morphAttributes = {} + mesh.morphTargetDictionary = undefined + mesh.morphTargetInfluences = undefined + if (skinnedMesh.isSkinnedMesh) { + const ordinaryMesh = mesh as unknown as { isSkinnedMesh: boolean } + ordinaryMesh.isSkinnedMesh = false + } + rememberDiscarded(root, originalGeometry) + } +} + +function canvas2d( + width: number, + height: number, +): { + canvas: HTMLCanvasElement + context: CanvasRenderingContext2D +} { + if (typeof document === 'undefined') { + throw new Error('Portable texture baking requires a browser canvas') + } + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d', { willReadFrequently: true }) + if (!context) throw new Error('Portable texture baking could not create a 2D canvas') + return { canvas, context } +} + +function srgbByte(linear: number): number { + const value = Math.max(0, Math.min(1, linear)) + const encoded = value <= 0.0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055 + return Math.round(encoded * 255) +} + +function colorComponent( + attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute, + index: number, + component: number, +): number { + if (component === 0) return attribute.getX(index) + if (component === 1) return attribute.getY(index) + if (component === 2) return attribute.getZ(index) + return attribute.itemSize >= 4 ? attribute.getW(index) : 1 +} + +function buildVertexColorAtlas( + geometry: THREE.BufferGeometry, + materials: readonly THREE.Material[], + relocatedUvChannel?: number, +): { + geometry: THREE.BufferGeometry + texture: THREE.CanvasTexture + hasAlpha: boolean + clampedHdr: boolean +} { + const expanded = geometry.index ? geometry.toNonIndexed() : geometry.clone() + if (relocatedUvChannel !== undefined) { + const sourceUv = expanded.getAttribute('uv') + if (!sourceUv) { + expanded.dispose() + throw new Error('Textured vertex-color geometry has no UV coordinates to preserve') + } + expanded.setAttribute(uvAttributeName(relocatedUvChannel), sourceUv.clone()) + } + const color = expanded.getAttribute('color') + const position = expanded.getAttribute('position') + if (!color || !position || position.count === 0 || position.count % 3 !== 0) { + expanded.dispose() + throw new Error('Vertex-color baking requires triangle geometry with a color attribute') + } + + const faceCount = position.count / 3 + const tilesPerRow = Math.ceil(Math.sqrt(faceCount)) + const atlasSize = THREE.MathUtils.ceilPowerOfTwo(tilesPerRow * VERTEX_COLOR_TILE_SIZE) + if (atlasSize > MAX_VERTEX_COLOR_ATLAS_SIZE) { + expanded.dispose() + throw new Error( + `Vertex-color atlas requires ${atlasSize}px, exceeding the ${MAX_VERTEX_COLOR_ATLAS_SIZE}px export limit`, + ) + } + + const { canvas, context } = canvas2d(atlasSize, atlasSize) + const image = context.createImageData(atlasSize, atlasSize) + + const uv = new Float32Array(position.count * 2) + let hasAlpha = false + let clampedHdr = false + for (let index = 0; index < color.count && !clampedHdr; index++) { + const triangleOffset = index - (index % 3) + const materialIndex = materialIndexForTriangle(expanded, triangleOffset) + if (!materials[materialIndex]?.vertexColors) continue + const red = colorComponent(color, index, 0) + const green = colorComponent(color, index, 1) + const blue = colorComponent(color, index, 2) + clampedHdr = red < 0 || red > 1 || green < 0 || green > 1 || blue < 0 || blue > 1 + } + + for (let face = 0; face < faceCount; face++) { + const tileX = (face % tilesPerRow) * VERTEX_COLOR_TILE_SIZE + const tileY = Math.floor(face / tilesPerRow) * VERTEX_COLOR_TILE_SIZE + const vertices = [face * 3, face * 3 + 1, face * 3 + 2] + const colors = vertices.map((vertex) => [ + colorComponent(color, vertex, 0), + colorComponent(color, vertex, 1), + colorComponent(color, vertex, 2), + colorComponent(color, vertex, 3), + ]) + if ( + materials[materialIndexForTriangle(expanded, face * 3)]?.vertexColors && + colors.some((entry) => entry[3]! < 1) + ) { + hasAlpha = true + } + + const inset = 0.5 + const span = VERTEX_COLOR_TILE_SIZE - 1 + const atlasUvs = [ + [tileX + inset, tileY + inset], + [tileX + inset + span, tileY + inset], + [tileX + inset, tileY + inset + span], + ] + for (let corner = 0; corner < 3; corner++) { + uv[(face * 3 + corner) * 2] = atlasUvs[corner]![0]! / atlasSize + uv[(face * 3 + corner) * 2 + 1] = 1 - atlasUvs[corner]![1]! / atlasSize + } + + for (let y = 0; y < VERTEX_COLOR_TILE_SIZE; y++) { + for (let x = 0; x < VERTEX_COLOR_TILE_SIZE; x++) { + let b = x / span + let c = y / span + if (b + c > 1) { + const sum = b + c + b /= sum + c /= sum + } + const a = 1 - b - c + const pixel = ((tileY + y) * atlasSize + tileX + x) * 4 + image.data[pixel] = srgbByte(colors[0]![0]! * a + colors[1]![0]! * b + colors[2]![0]! * c) + image.data[pixel + 1] = srgbByte( + colors[0]![1]! * a + colors[1]![1]! * b + colors[2]![1]! * c, + ) + image.data[pixel + 2] = srgbByte( + colors[0]![2]! * a + colors[1]![2]! * b + colors[2]![2]! * c, + ) + image.data[pixel + 3] = Math.round( + 255 * + Math.max(0, Math.min(1, colors[0]![3]! * a + colors[1]![3]! * b + colors[2]![3]! * c)), + ) + } + } + } + // Dilate each row's terminal tile through all unused power-of-two cells. + // Importers may generate their own mip chain (USDZ in particular), so sampler + // flags cannot prevent transparent-black padding from bleeding into Grass. + for (let y = 0; y < atlasSize; y++) { + const tileRow = Math.floor(y / VERTEX_COLOR_TILE_SIZE) + for (let x = 0; x < atlasSize; x++) { + const tileColumn = Math.floor(x / VERTEX_COLOR_TILE_SIZE) + const tileIndex = tileRow * tilesPerRow + tileColumn + if (tileColumn < tilesPerRow && tileIndex < faceCount) continue + const sourceFace = + tileRow * tilesPerRow < faceCount + ? Math.min((tileRow + 1) * tilesPerRow - 1, faceCount - 1) + : faceCount - 1 + const sourceX = + (sourceFace % tilesPerRow) * VERTEX_COLOR_TILE_SIZE + (x % VERTEX_COLOR_TILE_SIZE) + const sourceY = + Math.floor(sourceFace / tilesPerRow) * VERTEX_COLOR_TILE_SIZE + (y % VERTEX_COLOR_TILE_SIZE) + const sourcePixel = (sourceY * atlasSize + sourceX) * 4 + const targetPixel = (y * atlasSize + x) * 4 + image.data[targetPixel] = image.data[sourcePixel]! + image.data[targetPixel + 1] = image.data[sourcePixel + 1]! + image.data[targetPixel + 2] = image.data[sourcePixel + 2]! + image.data[targetPixel + 3] = image.data[sourcePixel + 3]! + } + } + + context.putImageData(image, 0, 0) + expanded.setAttribute(uvAttributeName(VERTEX_COLOR_UV_CHANNEL), new THREE.BufferAttribute(uv, 2)) + expanded.deleteAttribute('color') + const texture = new THREE.CanvasTexture(canvas) + texture.name = 'pascal_vertex_color_atlas' + texture.channel = VERTEX_COLOR_UV_CHANNEL + texture.colorSpace = THREE.SRGBColorSpace + texture.flipY = true + texture.needsUpdate = true + return { geometry: expanded, texture, hasAlpha, clampedHdr } +} +function hasDeformedGeometry(mesh: THREE.Mesh): boolean { + const skinnedMesh = mesh as THREE.SkinnedMesh + return ( + skinnedMesh.isSkinnedMesh || + Object.values(mesh.geometry.morphAttributes).some((attributes) => attributes.length > 0) + ) +} + +function bakeVertexColors(root: THREE.Object3D, preserveDeformations = false): boolean { + const meshes: THREE.Mesh[] = [] + root.traverse((object) => { + if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh) + }) + let clampedHdr = false + + for (const mesh of meshes) { + if (preserveDeformations && hasDeformedGeometry(mesh)) continue + const color = mesh.geometry.getAttribute('color') + if (!color) continue + const materials = materialsOf(mesh) + if (!materials.some((material) => material.vertexColors)) { + const originalGeometry = mesh.geometry + mesh.geometry = originalGeometry.clone() + mesh.geometry.deleteAttribute('color') + rememberDiscarded(root, originalGeometry) + continue + } + const occupiedUvChannels = new Set() + for (let channel = 1; channel <= 3; channel++) { + if (mesh.geometry.getAttribute(uvAttributeName(channel))) occupiedUvChannels.add(channel) + } + const channelZeroTextures = new Set() + for (const material of materials) { + const textured = material as TexturedMaterial + if (material.vertexColors && textured.map instanceof THREE.Texture) { + throw new Error( + `Material "${material.name}" combines a diffuse map with varying vertex colors; portable atlas composition is unavailable`, + ) + } + for (const slot of MATERIAL_TEXTURE_SLOTS) { + const texture = textured[slot] + if (!(texture instanceof THREE.Texture)) continue + if (texture.channel === 0) channelZeroTextures.add(texture) + else occupiedUvChannels.add(texture.channel) + } + } + + const relocatedUvChannel = + channelZeroTextures.size === 0 + ? undefined + : [1, 2, 3].find((channel) => !occupiedUvChannels.has(channel)) + if (channelZeroTextures.size > 0 && relocatedUvChannel === undefined) { + throw new Error( + `Vertex-color geometry "${mesh.name}" has no free portable UV channel for its PBR maps`, + ) + } + + const originalGeometry = mesh.geometry + const baked = buildVertexColorAtlas(originalGeometry, materials, relocatedUvChannel) + mesh.geometry = baked.geometry + if (baked.clampedHdr) mesh.userData.pascalHdrVertexColor = 'clamped-to-portable-range' + clampedHdr ||= baked.clampedHdr + const relocatedTextures = new Map() + const convertedMaterials = new Map() + const converted = materials.map((material) => { + const cached = convertedMaterials.get(material) + if (cached) return cached + if (!material.vertexColors && relocatedUvChannel === undefined) return material + const clone = material.clone() as TexturedMaterial + if (relocatedUvChannel !== undefined) { + for (const slot of MATERIAL_TEXTURE_SLOTS) { + const sourceTexture = clone[slot] + if (!(sourceTexture instanceof THREE.Texture) || sourceTexture.channel !== 0) continue + let relocatedTexture = relocatedTextures.get(sourceTexture) + if (!relocatedTexture) { + relocatedTexture = sourceTexture.clone() + relocatedTexture.channel = relocatedUvChannel + relocatedTexture.needsUpdate = true + relocatedTextures.set(sourceTexture, relocatedTexture) + rememberDiscarded(root, sourceTexture) + } + clone[slot] = relocatedTexture + } + } + if (material.vertexColors) { + clone.vertexColors = false + clone.map = baked.texture + if (baked.hasAlpha) clone.transparent = true + } + convertedMaterials.set(material, clone) + return clone + }) + mesh.material = Array.isArray(mesh.material) ? converted : converted[0]! + rememberDiscarded(root, originalGeometry) + for (const material of materials) rememberDiscarded(root, material) + } + return clampedHdr +} + +function sourcePixels(texture: THREE.Texture): { + width: number + height: number + data: Uint8ClampedArray +} { + const image = texture.image as { + width?: number + height?: number + data?: ArrayLike + } | null + const width = image?.width + const height = image?.height + if (!(width && height)) throw new Error(`Texture "${texture.name}" has no readable dimensions`) + + if (image?.data) { + if ( + !(image.data instanceof Uint8Array || image.data instanceof Uint8ClampedArray) || + image.data.length !== width * height * 4 + ) { + throw new Error(`Texture "${texture.name}" is not an 8-bit RGBA texture`) + } + return { width, height, data: Uint8ClampedArray.from(image.data) } + } + + const { context } = canvas2d(width, height) + try { + context.drawImage(texture.image as CanvasImageSource, 0, 0, width, height) + } catch { + throw new Error(`Texture "${texture.name}" cannot be read for portable export`) + } + return { width, height, data: context.getImageData(0, 0, width, height).data } +} + +function cloneTextureSettings(source: THREE.Texture, target: THREE.Texture): void { + target.name = source.name + target.mapping = source.mapping + target.channel = source.channel + target.wrapS = source.wrapS + target.wrapT = source.wrapT + target.magFilter = source.magFilter + target.minFilter = source.minFilter + target.anisotropy = source.anisotropy + target.offset.copy(source.offset) + target.repeat.copy(source.repeat) + target.center.copy(source.center) + target.rotation = source.rotation + target.matrixAutoUpdate = source.matrixAutoUpdate + target.matrix.copy(source.matrix) + target.generateMipmaps = source.generateMipmaps + target.premultiplyAlpha = source.premultiplyAlpha + target.flipY = source.flipY + target.unpackAlignment = source.unpackAlignment + target.colorSpace = THREE.NoColorSpace + target.userData = structuredClone(source.userData) + target.needsUpdate = true +} +function materializeDataTextures(root: THREE.Object3D): void { + const converted = new Map() + root.traverse((object) => { + if (!(object as THREE.Mesh).isMesh) return + for (const material of materialsOf(object as THREE.Mesh)) { + const textured = material as TexturedMaterial + for (const slot of MATERIAL_TEXTURE_SLOTS) { + const source = textured[slot] + if (!(source instanceof THREE.Texture) || !(source as THREE.DataTexture).isDataTexture) + continue + let texture = converted.get(source) + if (!texture) { + if (source.format !== THREE.RGBAFormat || source.type !== THREE.UnsignedByteType) { + throw new Error(`Data texture "${source.name}" is not portable 8-bit RGBA`) + } + const pixels = sourcePixels(source) + const { canvas, context } = canvas2d(pixels.width, pixels.height) + const image = context.createImageData(pixels.width, pixels.height) + image.data.set(pixels.data) + context.putImageData(image, 0, 0) + texture = new THREE.CanvasTexture(canvas) + cloneTextureSettings(source, texture) + texture.colorSpace = source.colorSpace + converted.set(source, texture) + rememberDiscarded(root, source) + } + textured[slot] = texture + } + } + }) +} + +function canonicalizeAlphaMaps(root: THREE.Object3D): void { + const materials = new Set() + root.traverse((object) => { + if (!(object as THREE.Mesh).isMesh) return + for (const material of materialsOf(object as THREE.Mesh)) materials.add(material) + }) + + for (const material of materials) { + const standard = material as THREE.MeshStandardMaterial + if (!standard.alphaMap) continue + const alphaMap = standard.alphaMap + const map = standard.map + alphaMap.updateMatrix() + map?.updateMatrix() + if ( + map && + (map.channel !== alphaMap.channel || + map.flipY !== alphaMap.flipY || + !map.matrix.equals(alphaMap.matrix)) + ) { + throw new Error( + `Material "${material.name}" uses incompatible diffuse and alpha texture coordinates`, + ) + } + + const alpha = sourcePixels(alphaMap) + const color = map ? sourcePixels(map) : null + if (color && (color.width !== alpha.width || color.height !== alpha.height)) { + throw new Error(`Material "${material.name}" uses mismatched diffuse and alpha map sizes`) + } + const { canvas, context } = canvas2d(alpha.width, alpha.height) + const output = context.createImageData(alpha.width, alpha.height) + for (let index = 0; index < alpha.data.length; index += 4) { + output.data[index] = color?.data[index] ?? 255 + output.data[index + 1] = color?.data[index + 1] ?? 255 + output.data[index + 2] = color?.data[index + 2] ?? 255 + output.data[index + 3] = Math.round( + ((color?.data[index + 3] ?? 255) * alpha.data[index + 1]!) / 255, + ) + } + context.putImageData(output, 0, 0) + const texture = new THREE.CanvasTexture(canvas) + cloneTextureSettings(map ?? alphaMap, texture) + texture.colorSpace = map?.colorSpace ?? THREE.SRGBColorSpace + texture.name = map?.name || alphaMap.name + standard.map = texture + standard.alphaMap = null + standard.transparent = true + rememberDiscarded(root, alphaMap) + if (map) rememberDiscarded(root, map) + } +} + +function canonicalNormalTexture(source: THREE.Texture, scale: THREE.Vector2): THREE.CanvasTexture { + const compressed = source as THREE.CompressedTexture + if (compressed.isCompressedTexture) { + throw new Error(`Compressed normal map "${source.name}" must be baked before portable export`) + } + const pixels = sourcePixels(source) + const { canvas, context } = canvas2d(pixels.width, pixels.height) + const output = context.createImageData(pixels.width, pixels.height) + for (let index = 0; index < pixels.data.length; index += 4) { + let x = (pixels.data[index]! / 255) * 2 - 1 + let y = (pixels.data[index + 1]! / 255) * 2 - 1 + let z = (pixels.data[index + 2]! / 255) * 2 - 1 + x *= scale.x + y *= scale.y + const inverseLength = 1 / Math.max(Math.hypot(x, y, z), 1e-8) + x *= inverseLength + y *= inverseLength + z *= inverseLength + output.data[index] = Math.round((x * 0.5 + 0.5) * 255) + output.data[index + 1] = Math.round((y * 0.5 + 0.5) * 255) + output.data[index + 2] = Math.round((z * 0.5 + 0.5) * 255) + output.data[index + 3] = pixels.data[index + 3]! + } + context.putImageData(output, 0, 0) + const texture = new THREE.CanvasTexture(canvas) + cloneTextureSettings(source, texture) + return texture +} + +function canonicalizeNormalMaps(root: THREE.Object3D): void { + const materials = new Set() + root.traverse((object) => { + if (!(object as THREE.Mesh).isMesh) return + for (const material of materialsOf(object as THREE.Mesh)) materials.add(material) + }) + + const cache = new Map>() + for (const material of materials) { + const standard = material as THREE.MeshStandardMaterial + if (!standard.normalMap || !standard.normalScale) continue + if (standard.normalScale.x === 1 && standard.normalScale.y === 1) continue + const source = standard.normalMap + const key = `${standard.normalScale.x}:${standard.normalScale.y}` + let variants = cache.get(source) + if (!variants) { + variants = new Map() + cache.set(source, variants) + } + let canonical = variants.get(key) + if (!canonical) { + canonical = canonicalNormalTexture(source, standard.normalScale) + variants.set(key, canonical) + } + rememberDiscarded(root, source) + standard.normalMap = canonical + standard.normalScale.set(1, 1) + } +} + +function materialIndexForTriangle(geometry: THREE.BufferGeometry, triangleOffset: number): number { + for (const group of geometry.groups) { + if (triangleOffset >= group.start && triangleOffset < group.start + group.count) { + return group.materialIndex ?? 0 + } + } + return 0 +} + +function expandPortableSidedGeometry( + geometry: THREE.BufferGeometry, + materials: THREE.Material[], +): THREE.BufferGeometry | null { + if (materials.every((material) => material.side === THREE.FrontSide)) return null + const source = geometry.index ? geometry.toNonIndexed() : geometry.clone() + const position = source.getAttribute('position') + if (!position || position.count % 3 !== 0) { + source.dispose() + throw new Error('Portable sidedness export requires triangle geometry') + } + + const triangles: Array<{ offset: number; materialIndex: number; reverse: boolean }> = [] + for (let offset = 0; offset < position.count; offset += 3) { + const materialIndex = materialIndexForTriangle(source, offset) + const side = materials[materialIndex]?.side ?? THREE.FrontSide + if (side !== THREE.BackSide) triangles.push({ offset, materialIndex, reverse: false }) + if (side !== THREE.FrontSide) triangles.push({ offset, materialIndex, reverse: true }) + } + + const output = new THREE.BufferGeometry() + for (const [name, attribute] of Object.entries(source.attributes)) { + const ArrayType = attribute.array.constructor as new (length: number) => typeof attribute.array + const values = new ArrayType(triangles.length * 3 * attribute.itemSize) + const target = new THREE.BufferAttribute(values, attribute.itemSize, attribute.normalized) + for (let triangle = 0; triangle < triangles.length; triangle++) { + const entry = triangles[triangle]! + const order = entry.reverse ? [0, 2, 1] : [0, 1, 2] + for (let corner = 0; corner < 3; corner++) { + const sourceIndex = entry.offset + order[corner]! + const targetIndex = triangle * 3 + corner + for (let component = 0; component < attribute.itemSize; component++) { + let value = attribute.getComponent(sourceIndex, component) + if (entry.reverse && name === 'normal') value = -value + if (entry.reverse && name === 'tangent' && component === 3) value = -value + target.setComponent(targetIndex, component, value) + } + } + } + output.setAttribute(name, target) + } + + let groupStart = 0 + let groupMaterial = triangles[0]?.materialIndex ?? 0 + for (let index = 1; index <= triangles.length; index++) { + const materialIndex = triangles[index]?.materialIndex + if (index < triangles.length && materialIndex === groupMaterial) continue + output.addGroup(groupStart * 3, (index - groupStart) * 3, groupMaterial) + groupStart = index + groupMaterial = materialIndex ?? 0 + } + source.dispose() + return output +} + +function bakeDoubleSidedMeshes(root: THREE.Object3D): void { + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + const materials = materialsOf(mesh) + const geometry = expandPortableSidedGeometry(mesh.geometry, materials) + if (!geometry) return + const original = mesh.geometry + mesh.geometry = geometry + const converted = materials.map((material) => { + if (material.side === THREE.FrontSide) return material + const clone = material.clone() + clone.side = THREE.FrontSide + rememberDiscarded(root, material) + return clone + }) + mesh.material = Array.isArray(mesh.material) ? converted : converted[0]! + rememberDiscarded(root, original) + }) +} +const PORTABLE_COLOR_PROPERTIES = [ + 'color', + 'emissive', + 'sheenColor', + 'specularColor', + 'attenuationColor', +] as const + +function clampPortableMaterialColors(root: THREE.Object3D): boolean { + const materials = new Set() + root.traverse((object) => { + if (!(object as THREE.Mesh).isMesh) return + for (const material of materialsOf(object as THREE.Mesh)) materials.add(material) + }) + + let clipped = false + for (const material of materials) { + const properties = material as unknown as Record + for (const property of PORTABLE_COLOR_PROPERTIES) { + const color = properties[property] as THREE.Color | undefined + if (!color?.isColor) continue + if (color.r < 0 || color.r > 1 || color.g < 0 || color.g > 1 || color.b < 0 || color.b > 1) { + clipped = true + color.r = THREE.MathUtils.clamp(color.r, 0, 1) + color.g = THREE.MathUtils.clamp(color.g, 0, 1) + color.b = THREE.MathUtils.clamp(color.b, 0, 1) + } + } + } + return clipped +} + +export function normalizePortableScene(root: THREE.Object3D): string[] { + expandInstancedMeshes(root) + root.updateMatrixWorld(true) + freezeDeformedMeshes(root) + const clampedMaterialColors = clampPortableMaterialColors(root) + materializeDataTextures(root) + const clampedVertexColors = bakeVertexColors(root) + const clampedHdr = clampedMaterialColors || clampedVertexColors + canonicalizeAlphaMaps(root) + canonicalizeNormalMaps(root) + bakeDoubleSidedMeshes(root) + root.updateMatrixWorld(true) + return clampedHdr + ? [ + 'Some rendered colors were outside the portable range (including colors brighter than the portable range) and were clipped to 0–1; the brightest areas may lose contrast.', + ] + : [] +} +/** + * Canonicalize baked static material details without freezing authored item + * deformation that existing saved-viewer animation clips still target. + */ +export function normalizeViewerArtifactMaterials(root: THREE.Object3D): string[] { + const clampedMaterialColors = clampPortableMaterialColors(root) + materializeDataTextures(root) + const clampedVertexColors = bakeVertexColors(root, true) + canonicalizeAlphaMaps(root) + canonicalizeNormalMaps(root) + return clampedMaterialColors || clampedVertexColors + ? [ + 'Some rendered colors were outside the portable range (including colors brighter than the portable range) and were clipped to 0–1; the brightest areas may lose contrast.', + ] + : [] +} + +function cloneMaterialForUsdz(material: THREE.Material): THREE.Material { + const clone = material.clone() + clone.side = THREE.FrontSide + return clone +} + +function reverseWindingPreservingNormals(geometry: THREE.BufferGeometry): void { + const index = geometry.getIndex() + if (index) { + for (let offset = 0; offset < index.count; offset += 3) { + const first = index.getX(offset) + index.setX(offset, index.getX(offset + 2)) + index.setX(offset + 2, first) + } + index.needsUpdate = true + return + } + + for (const attribute of Object.values(geometry.attributes)) { + for (let offset = 0; offset < attribute.count; offset += 3) { + for (let component = 0; component < attribute.itemSize; component++) { + const first = attribute.getComponent(offset, component) + attribute.setComponent(offset, component, attribute.getComponent(offset + 2, component)) + attribute.setComponent(offset + 2, component, first) + } + } + attribute.needsUpdate = true + } +} + +/** Make reflected static geometry agree with exported normals after world transforms. */ +export function fixReflectedMeshWinding(root: THREE.Object3D): void { + root.updateMatrixWorld(true) + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh || mesh.matrixWorld.determinant() >= 0) return + const original = mesh.geometry + mesh.geometry = original.clone() + reverseWindingPreservingNormals(mesh.geometry) + rememberDiscarded(root, original) + }) +} + +/** + * Build a USDZ-only clone with world transforms baked into geometry. This + * removes unsupported negative-scale transforms without changing the shared + * prepared artifact or its identity hierarchy. Empty mesh containers become + * groups because USDZ requires vertex positions for every mesh primitive. + */ +export function createUsdzScene(source: THREE.Object3D): THREE.Object3D { + source.updateMatrixWorld(true) + + const cloneObject = (object: THREE.Object3D): THREE.Object3D => { + let clone: THREE.Object3D + const mesh = object as THREE.Mesh + if (mesh.isMesh && (mesh.geometry.getAttribute('position')?.count ?? 0) > 0) { + const geometry = mesh.geometry.clone() + geometry.applyMatrix4(mesh.matrixWorld) + if (mesh.matrixWorld.determinant() < 0) reverseWindingPreservingNormals(geometry) + const material = Array.isArray(mesh.material) + ? mesh.material.length === 1 + ? cloneMaterialForUsdz(mesh.material[0]!) + : mesh.material.map(cloneMaterialForUsdz) + : cloneMaterialForUsdz(mesh.material) + clone = new THREE.Mesh(geometry, material) + } else { + clone = new THREE.Group() + } + + clone.name = object.name + clone.visible = object.visible + clone.layers.mask = object.layers.mask + clone.renderOrder = object.renderOrder + clone.userData = structuredClone(object.userData) + for (const child of object.children) clone.add(cloneObject(child)) + return clone + } + + return cloneObject(source) +} + +/** Dispose geometry, materials, and export-owned texture handles exactly once. */ +export function disposeExportResources( + root: THREE.Object3D, + options: { textures?: boolean } = {}, +): void { + const geometries = new Set() + const materials = new Set() + const textures = new Set() + root.traverse((object) => { + const renderable = object as THREE.Mesh + if (renderable.geometry) geometries.add(renderable.geometry) + if (!renderable.material) return + for (const material of materialsOf(renderable)) { + materials.add(material) + if (options.textures === false) continue + const textured = material as TexturedMaterial + for (const slot of MATERIAL_TEXTURE_SLOTS) { + const texture = textured[slot] + if (texture instanceof THREE.Texture) textures.add(texture) + } + } + }) + + for (const resource of discardedResources.get(root) ?? []) { + if (resource instanceof THREE.Texture) textures.add(resource) + else if (resource instanceof THREE.Material) materials.add(resource) + else geometries.add(resource) + } + discardedResources.delete(root) + + for (const geometry of geometries) geometry.dispose() + for (const material of materials) material.dispose() + for (const texture of textures) texture.dispose() +} diff --git a/packages/editor/src/lib/print-shell-compiler-manifold-core.ts b/packages/editor/src/lib/print-shell-compiler-manifold-core.ts index 082dcb835d..013cd2e269 100644 --- a/packages/editor/src/lib/print-shell-compiler-manifold-core.ts +++ b/packages/editor/src/lib/print-shell-compiler-manifold-core.ts @@ -1,6 +1,10 @@ -import ManifoldModule, { type Manifold as ManifoldSolid, type ManifoldToplevel } from 'manifold-3d' +import type { Manifold as ManifoldSolid, ManifoldToplevel } from 'manifold-3d' import type { PrintShellCompileDiagnostic } from './print-shell-compiler-baseline' -import type { ManifoldCompileOutput, ManifoldMeshData } from './print-shell-compiler-protocol' +import type { + ManifoldCompileOutput, + ManifoldMeshData, + ManifoldRuntimeOptions, +} from './print-shell-compiler-protocol' let modulePromise: Promise | null = null const MANIFOLD_OUTPUT_WELD_EPSILON_METERS = 2e-5 @@ -8,14 +12,59 @@ const COLLINEAR_SEAM_CROSS_LENGTH_SQ = 1e-20 type Triangle = [number, number, number] -async function getManifoldModule(wasmUrl?: string): Promise { - modulePromise ??= ManifoldModule(wasmUrl ? { locateFile: () => wasmUrl } : undefined).then( - (module) => { +type ManifoldFactory = (config?: { + locateFile?: (path: string) => string +}) => Promise + +// manifold-3d's emscripten glue awaits import('node:module') behind a Node +// check. The branch never executes in a browser, but webpack refuses to +// *build* a graph that can reach it, so a static specifier here poisons every +// external bundler that compiles this package's source (#715). The factory is +// therefore loaded through an import() no bundler can trace: the bare +// specifier resolves wherever node-style resolution exists at runtime (bun +// tests, dev servers, bundlers that inline it anyway), and the version-pinned +// CDN copy covers bundled browser builds that left the specifier unresolved β€” +// emscripten then locates manifold.wasm relative to the glue's own URL. Hosts +// that can't reach the CDN (offline, CSP) pass their own URLs through +// configureManifoldRuntime. +const MANIFOLD_VERSION = '3.5.1' +const FALLBACK_MODULE_URL = `https://cdn.jsdelivr.net/npm/manifold-3d@${MANIFOLD_VERSION}/manifold.js` + +function importUntraced(specifier: string): Promise<{ default: ManifoldFactory }> { + return import(/* webpackIgnore: true */ /* @vite-ignore */ specifier) +} + +async function loadManifoldFactory(moduleUrl?: string): Promise { + const specifiers = moduleUrl ? [moduleUrl] : ['manifold-3d', FALLBACK_MODULE_URL] + let lastError: unknown + for (const specifier of specifiers) { + try { + return (await importUntraced(specifier)).default + } catch (error) { + lastError = error + } + } + throw lastError instanceof Error ? lastError : new Error('Failed to load manifold-3d.') +} + +async function getManifoldModule(runtime?: ManifoldRuntimeOptions): Promise { + modulePromise ??= loadManifoldFactory(runtime?.moduleUrl) + .then((factory) => { + const wasmUrl = runtime?.wasmUrl + return factory(wasmUrl ? { locateFile: () => wasmUrl } : undefined) + }) + .then((module) => { module.setup() return module - }, - ) - return modulePromise + }) + try { + return await modulePromise + } catch (error) { + // A transient load failure (offline, blocked CDN) must not poison every + // later compile attempt with the cached rejection. + modulePromise = null + throw error + } } function manifoldMesh( @@ -211,7 +260,7 @@ function elapsed(startedAt: number): number { export async function compileManifoldMeshData( meshes: ManifoldMeshData[], - wasmUrl?: string, + runtime?: ManifoldRuntimeOptions, ): Promise { const startedAt = performance.now() const sourceNodeIds = Array.from(new Set(meshes.map((mesh) => mesh.nodeId))).sort() @@ -238,7 +287,7 @@ export async function compileManifoldMeshData( } try { - const module = await getManifoldModule(wasmUrl) + const module = await getManifoldModule(runtime) for (const mesh of meshes) { try { solids.push(new module.Manifold(manifoldMesh(module, mesh))) diff --git a/packages/editor/src/lib/print-shell-compiler-manifold-worker.ts b/packages/editor/src/lib/print-shell-compiler-manifold-worker.ts index f4ddb4c855..d274f3a60f 100644 --- a/packages/editor/src/lib/print-shell-compiler-manifold-worker.ts +++ b/packages/editor/src/lib/print-shell-compiler-manifold-worker.ts @@ -16,12 +16,25 @@ import { import type { ManifoldCompileOutput, ManifoldMeshData, + ManifoldRuntimeOptions, ManifoldWorkerRequest, ManifoldWorkerResponse, } from './print-shell-compiler-protocol' const WORKER_TIMEOUT_MS = 60_000 +let manifoldRuntime: ManifoldRuntimeOptions | undefined + +/** + * Overrides where the print-export worker loads the manifold-3d module and + * wasm from. See the loader in print-shell-compiler-manifold-core.ts for the + * default resolution order; hosts with restrictive networks should call this + * with self-hosted asset URLs before the first print export. + */ +export function configureManifoldRuntime(options: ManifoldRuntimeOptions | undefined): void { + manifoldRuntime = options +} + export type ManifoldCompileRunner = (meshes: ManifoldMeshData[]) => Promise export type SemanticManifoldCompileOptions = SemanticPrintCompileOptions & { @@ -76,7 +89,7 @@ export const runManifoldWorker: ManifoldCompileRunner = (meshes) => { const activeWorker = getWorker() const id = nextRequestId nextRequestId += 1 - const request: ManifoldWorkerRequest = { id, meshes } + const request: ManifoldWorkerRequest = { id, meshes, runtime: manifoldRuntime } const transfer = meshes.flatMap((mesh) => [ mesh.positions.buffer as ArrayBuffer, mesh.indices.buffer as ArrayBuffer, diff --git a/packages/editor/src/lib/print-shell-compiler-manifold.worker.ts b/packages/editor/src/lib/print-shell-compiler-manifold.worker.ts index 49b2739691..20f0118cca 100644 --- a/packages/editor/src/lib/print-shell-compiler-manifold.worker.ts +++ b/packages/editor/src/lib/print-shell-compiler-manifold.worker.ts @@ -10,7 +10,7 @@ const workerScope = self as unknown as { } workerScope.addEventListener('message', async (event) => { - const output = await compileManifoldMeshData(event.data.meshes) + const output = await compileManifoldMeshData(event.data.meshes, event.data.runtime) const response: ManifoldWorkerResponse = { id: event.data.id, ...output } const transfer: Transferable[] = [] if (response.status === 'compiled') { diff --git a/packages/editor/src/lib/print-shell-compiler-protocol.ts b/packages/editor/src/lib/print-shell-compiler-protocol.ts index 8554f1e2e8..6907ba62b7 100644 --- a/packages/editor/src/lib/print-shell-compiler-protocol.ts +++ b/packages/editor/src/lib/print-shell-compiler-protocol.ts @@ -22,9 +22,17 @@ export type ManifoldCompileOutput = durationMs: number } +export type ManifoldRuntimeOptions = { + /** URL of the manifold-3d emscripten glue module. Defaults to the pinned CDN copy. */ + moduleUrl?: string + /** URL of manifold.wasm. Defaults to resolving relative to the glue module. */ + wasmUrl?: string +} + export type ManifoldWorkerRequest = { id: number meshes: ManifoldMeshData[] + runtime?: ManifoldRuntimeOptions } export type ManifoldWorkerResponse = ManifoldCompileOutput & { id: number } diff --git a/packages/editor/src/lib/usdz-export.ts b/packages/editor/src/lib/usdz-export.ts new file mode 100644 index 0000000000..4577d33aba --- /dev/null +++ b/packages/editor/src/lib/usdz-export.ts @@ -0,0 +1,38 @@ +import type { AnyNode } from '@pascal-app/core' +import type { Object3D } from 'three' +import { USDZExporter } from 'three/examples/jsm/exporters/USDZExporter.js' +import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js' +import { type GlbExportOptions, preparePortableSceneFromViewer } from './glb-export' +import { createUsdzScene, disposeExportResources } from './portable-export' + +export type UsdzExportOptions = Pick< + GlbExportOptions, + 'excludedNodeTypes' | 'includedPresentationIds' | 'onlyVisible' | 'onWarning' +> + +/** Export a native, self-contained USDZ with no glTF conversion fallback. */ +export async function exportSceneToUsdz( + sceneGroup: Object3D, + nodes: Record, + options: UsdzExportOptions = {}, +): Promise> { + const prepared = await preparePortableSceneFromViewer(sceneGroup, nodes, { + ...options, + textures: 'embed', + }) + for (const warning of prepared.warnings) options.onWarning?.(warning) + + let scene: Object3D | null = null + try { + scene = createUsdzScene(prepared.scene) + const exporter = new USDZExporter() + exporter.textureUtils = WebGPUTextureUtils + return await exporter.parseAsync(scene, { + onlyVisible: options.onlyVisible ?? true, + quickLookCompatible: true, + }) + } finally { + if (scene) disposeExportResources(scene, { textures: false }) + prepared.dispose() + } +} diff --git a/packages/editor/src/lib/use-linear-display.ts b/packages/editor/src/lib/use-linear-display.ts index 6578ea30a3..f7512903df 100644 --- a/packages/editor/src/lib/use-linear-display.ts +++ b/packages/editor/src/lib/use-linear-display.ts @@ -1,40 +1,16 @@ 'use client' import { useViewer } from '@pascal-app/viewer' -import { useCallback } from 'react' -import { getLinearUnitLabel, linearUnitToMeters, metersToLinearUnit } from './measurements' +import { useMemo } from 'react' +import { getLinearDisplay } from './linear-display' -/** - * Shared display/storage conversion for numeric property controls so that - * every length input honors the metric/imperial toggle identically. - * - * Values are always STORED in the field's own unit (meters for `unit === 'm'`). - * When the viewer preference is imperial AND the field is a meter length, the - * value is DISPLAYED (and edited) in feet; otherwise the conversions are the - * identity, so metric fields and non-length units (`'Β°'`, `'%'`, `'in'`, `''`, - * …) behave exactly as before. - * - * Used by both `SliderControl` and `MetricControl` β€” keep the two in sync via - * this single source of truth. - */ -export function useLinearDisplay(unit: string, precision: number) { +/** Shared display/input conversion; values and bounds stay in the stored unit. */ +export function useLinearDisplay(unit: string, precision: number, step = 1) { const viewerUnit = useViewer((state) => state.unit) - const isImperial = viewerUnit === 'imperial' && unit === 'm' - const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit + const metricNotation = useViewer((state) => state.metricNotation) - const toDisplay = useCallback( - (stored: number) => (isImperial ? metersToLinearUnit(stored, 'imperial') : stored), - [isImperial], + return useMemo( + () => getLinearDisplay(unit, viewerUnit, metricNotation, precision, step), + [unit, viewerUnit, metricNotation, precision, step], ) - const toStored = useCallback( - (display: number) => (isImperial ? linearUnitToMeters(display, 'imperial') : display), - [isImperial], - ) - // Round a stored value so it lands on a clean number of DISPLAY-unit digits. - const roundStored = useCallback( - (stored: number) => toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))), - [toDisplay, toStored, precision], - ) - - return { isImperial, displayUnit, toDisplay, toStored, roundStored } } diff --git a/packages/editor/src/store/tool-mode.test.ts b/packages/editor/src/store/tool-mode.test.ts index abe47f87be..e21dc120ef 100644 --- a/packages/editor/src/store/tool-mode.test.ts +++ b/packages/editor/src/store/tool-mode.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import useEditor, { normalizePersistedEditorUiState } from './use-editor' function resetToolMode() { @@ -8,6 +8,7 @@ function resetToolMode() { useEditor.getState().setActivePaintMaterial(null) } +beforeEach(resetToolMode) afterEach(resetToolMode) describe('ToolMode transition', () => { diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 338d872f07..c64d6ef15c 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -116,6 +116,10 @@ export type FirstPersonMovementMode = 'walk' | 'drone' export const CAPTURE_FOV_MIN = 15 export const CAPTURE_FOV_MAX = 110 +function clampCaptureFov(fov: number): number { + return Math.min(Math.max(Math.round(fov), CAPTURE_FOV_MIN), CAPTURE_FOV_MAX) +} + export type Phase = 'site' | 'structure' | 'furnish' /** @@ -1485,14 +1489,12 @@ const useEditor = create()( captureFovBaseline: null, setCaptureFov: (fov) => set({ - captureFov: Math.min(Math.max(Math.round(fov), CAPTURE_FOV_MIN), CAPTURE_FOV_MAX), + captureFov: clampCaptureFov(fov), }), - armCaptureFov: (fov) => - set( - fov === null - ? { captureFov: null, captureFovBaseline: null } - : { captureFov: fov, captureFovBaseline: fov }, - ), + armCaptureFov: (fov) => { + const captureFov = fov === null ? null : clampCaptureFov(fov) + set({ captureFov, captureFovBaseline: captureFov }) + }, captureShutterHold: false, setCaptureShutterHold: (hold) => set({ captureShutterHold: hold }), workspaceMode: 'edit' as WorkspaceMode, diff --git a/packages/editor/src/store/use-interaction-scope.test.ts b/packages/editor/src/store/use-interaction-scope.test.ts index 073aa2699e..62686cd617 100644 --- a/packages/editor/src/store/use-interaction-scope.test.ts +++ b/packages/editor/src/store/use-interaction-scope.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import type { AnyNode } from '@pascal-app/core' import { type ActiveInteractionScope, @@ -21,6 +21,7 @@ const mockNode = (id: string, type: string): AnyNode => ({ id, type }) as unknow function reset() { useInteractionScope.getState().end() } +beforeEach(reset) afterEach(reset) describe('use-interaction-scope state machine', () => { diff --git a/packages/ifc-converter/README.md b/packages/ifc-converter/README.md index aa71177c8e..85909d7288 100644 --- a/packages/ifc-converter/README.md +++ b/packages/ifc-converter/README.md @@ -5,3 +5,10 @@ IFC bytes, returns `{ nodes, rootNodeIds, stats }` shaped against `@pascal-app/core` schemas. No DOM, no React. The UI lives in `apps/ifc-converter`. + +`IFCBEAM` and `IFCBEAMSTANDARDCASE` are imported as editable `block` nodes. +Their triangulated IFC geometry preserves cross-sections, rotations, and slopes; +positions are relative to the containing storey. IFC IDs, names, properties, +and material metadata are retained. Beams without renderable geometry are +reported in the conversion log. Imported beams use the block editor rather than +dedicated parametric beam controls. diff --git a/packages/ifc-converter/package.json b/packages/ifc-converter/package.json index 277ec83ac1..40d37adcaa 100644 --- a/packages/ifc-converter/package.json +++ b/packages/ifc-converter/package.json @@ -1,7 +1,7 @@ { "name": "@pascal-app/ifc-converter", - "version": "1.0.0-beta.5", - "description": "IFC β†’ Pascal scene-graph conversion. Pure logic β€” no DOM, no React.", + "version": "1.0.0", + "description": "IFC \u2192 Pascal scene-graph conversion. Pure logic \u2014 no DOM, no React.", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -23,7 +23,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@pascal-app/core": "*", + "@pascal-app/core": "^1.0.0", "nanoid": "^5.1.6", "web-ifc": "^0.0.77" }, diff --git a/packages/ifc-converter/src/beam-geometry.ts b/packages/ifc-converter/src/beam-geometry.ts new file mode 100644 index 0000000000..59ed77326e --- /dev/null +++ b/packages/ifc-converter/src/beam-geometry.ts @@ -0,0 +1,100 @@ +import { type BlockTopology, blockUndirectedEdgeKey } from '@pascal-app/core' +import type { IfcAPI } from 'web-ifc' + +type Point = [number, number, number] + +export function extractBeamGeometry( + ifcApi: IfcAPI, + modelID: number, + expressID: number, + options: { origin: number[]; unitFactor: number; swapYZ: boolean; levelElevation: number }, +): { position: Point; topology: BlockTopology } | null { + const mesh = ifcApi.GetFlatMesh(modelID, expressID) + const topology: BlockTopology = { vertices: [], edges: [], faces: [] } + const vertexByPosition = new Map() + const edgeKeys = new Set() + + try { + for (let g = 0; g < mesh.geometries.size(); g++) { + const placed = mesh.geometries.get(g) + const m = placed.flatTransformation + const geometry = ifcApi.GetGeometry(modelID, placed.geometryExpressID) + try { + const vertices = ifcApi.GetVertexArray( + geometry.GetVertexData(), + geometry.GetVertexDataSize(), + ) + const indices = ifcApi.GetIndexArray(geometry.GetIndexData(), geometry.GetIndexDataSize()) + const vertexIds: string[] = [] + for (let v = 0; v < vertices.length; v += 6) { + const x = vertices[v] + const y = vertices[v + 1] + const z = vertices[v + 2] + const wx = m[0] * x + m[4] * y + m[8] * z + m[12] + const wy = m[1] * x + m[5] * y + m[9] * z + m[13] + const wz = m[2] * x + m[6] * y + m[10] * z + m[14] + // web-ifc meshes are already in meters and use (X, Z, -Y). + // Undo that basis before applying the converter's origin and axis preset. + const sx = wx - options.origin[0] * options.unitFactor + const sy = -wz - options.origin[1] * options.unitFactor + const sz = wy - options.origin[2] * options.unitFactor - options.levelElevation + const position: Point = options.swapYZ ? [sx, sz, sy] : [sx, sy, sz] + if (!position.every(Number.isFinite)) throw new Error('Non-finite beam vertex') + const key = position.join(',') + let id = vertexByPosition.get(key) + if (!id) { + id = `v${topology.vertices.length}` + topology.vertices.push({ id, position }) + vertexByPosition.set(key, id) + } + vertexIds.push(id) + } + + const determinant = + m[0] * (m[5] * m[10] - m[9] * m[6]) - + m[4] * (m[1] * m[10] - m[9] * m[2]) + + m[8] * (m[1] * m[6] - m[5] * m[2]) + // Baking a reflection into the vertices also reverses the outward face winding. + const reverseWinding = determinant < 0 !== options.swapYZ + for (let i = 0; i + 2 < indices.length; i += 3) { + const ids = [vertexIds[indices[i]], vertexIds[indices[i + 1]], vertexIds[indices[i + 2]]] + if (ids.some((id) => id === undefined)) throw new Error('Invalid beam triangle index') + if (new Set(ids).size < 3) continue + if (reverseWinding) ids.reverse() + topology.faces.push({ + id: `f${topology.faces.length}`, + vertexIds: ids, + materialSlot: 'body', + }) + for (let j = 0; j < 3; j++) { + const a = ids[j] + const b = ids[(j + 1) % 3] + const key = blockUndirectedEdgeKey(a, b) + if (edgeKeys.has(key)) continue + edgeKeys.add(key) + topology.edges.push({ id: `e${topology.edges.length}`, vertexIds: [a, b] }) + } + } + } finally { + geometry.delete() + } + } + } finally { + mesh.delete?.() + } + + if (topology.faces.length === 0) return null + const min: Point = [Infinity, Infinity, Infinity] + const max: Point = [-Infinity, -Infinity, -Infinity] + for (const vertex of topology.vertices) { + for (let axis = 0; axis < 3; axis++) { + min[axis] = Math.min(min[axis], vertex.position[axis]) + max[axis] = Math.max(max[axis], vertex.position[axis]) + } + } + const position: Point = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2] + for (const vertex of topology.vertices) { + vertex.position = vertex.position.map((value, axis) => value - position[axis]) as Point + } + return { position, topology } +} diff --git a/packages/ifc-converter/src/index.ts b/packages/ifc-converter/src/index.ts index 09c3e4e91f..ba871c06b4 100644 --- a/packages/ifc-converter/src/index.ts +++ b/packages/ifc-converter/src/index.ts @@ -1,11 +1,13 @@ import { type AnyNode, type AnyNodeId, + BlockNode, BuildingNode, ColumnNode, DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS, DoorNode, + GROUND_SUPPORT_ID, LevelNode, RoofNode, SiteNode, @@ -16,6 +18,7 @@ import { } from '@pascal-app/core' import { customAlphabet } from 'nanoid' import * as WebIFC from 'web-ifc' +import { extractBeamGeometry } from './beam-geometry' import { type IfcConversionSimplificationOptions, simplifyConvertedSceneGraph } from './cleanup' export type { @@ -1845,32 +1848,56 @@ export async function convertIfcToPascal( } } - // Beams: skipped for now β€” Pascal has no `beam` node type yet. When it - // lands in @pascal-app/core, restore the IFCBEAM β†’ BeamNode mapping - // (axis polyline β†’ start/end [x,y,z], profile XDim/YDim β†’ width/depth, - // extrusion depth β†’ axis length). Reference implementation lives in - // git history of this file. We still walk the entities to log how - // many beams the IFC contained so the conversion summary is accurate. + progress('Processing beams...', 85) + let convertedBeamCount = 0 let skippedBeamCount = 0 - const beamTypes = [WebIFC.IFCBEAM] - try { - beamTypes.push(WebIFC.IFCBEAMSTANDARDCASE) - } catch { - /* not in all versions */ - } - for (const beamType of beamTypes) { - try { - const beams = ifcApi.GetLineIDsWithType(modelID, beamType) - skippedBeamCount += beams.size() - } catch { - /* type not present in this file */ + for (const beamType of [WebIFC.IFCBEAM, WebIFC.IFCBEAMSTANDARDCASE]) { + const beams = ifcApi.GetLineIDsWithType(modelID, beamType) + for (let i = 0; i < beams.size(); i++) { + const beamExpressID = beams.get(i) + if (expressIdToNodeId.has(beamExpressID)) continue + try { + const beam = ifcApi.GetLine(modelID, beamExpressID) + const storeyExpressID = findStoreyForElement(beamExpressID) + const parentExpressID = storeyExpressID ?? parentMap.get(beamExpressID) + const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : undefined + const parent = parentNodeId ? nodes[parentNodeId] : undefined + const levelElevation = parent?.type === 'level' ? Number(meta(parent).elevation ?? 0) : 0 + const geometry = extractBeamGeometry(ifcApi, modelID, beamExpressID, { + origin: originOffset, + unitFactor, + swapYZ: opts.swapYZ, + levelElevation, + }) + if (!geometry) throw new Error('No renderable beam geometry') + + const beamNode = tryParse(BlockNode, 'beam', { + name: beam.Name?.value || `Beam ${i + 1}`, + parentId: parentNodeId ?? null, + ...geometry, + // IFC already places the beam vertically; overlapping slabs must not lift it again. + supportSlabId: GROUND_SUPPORT_ID, + metadata: buildMetadata({ + ifcType: beamType === WebIFC.IFCBEAM ? 'IFCBEAM' : 'IFCBEAMSTANDARDCASE', + expressID: beamExpressID, + globalId: beam.GlobalId?.value, + predefinedType: beam.PredefinedType?.value, + }), + }) + nodes[beamNode.id] = beamNode + expressIdToNodeId.set(beamExpressID, beamNode.id) + if (parent && 'children' in parent) { + ;(parent.children as string[]).push(beamNode.id) + } else { + rootNodeIds.push(beamNode.id) + } + convertedBeamCount++ + } catch (error) { + skippedBeamCount++ + console.warn(`[IFCβ†’Pascal] Could not convert beam #${beamExpressID}:`, error) + } } } - if (skippedBeamCount > 0) { - console.warn( - `[IFCβ†’Pascal] Skipped ${skippedBeamCount} beam${skippedBeamCount === 1 ? '' : 's'} β€” Pascal has no beam node yet.`, - ) - } // Items: skipped for now β€” Pascal's ItemNode requires a full `asset` // (catalog reference with id/src/dimensions/etc.) that the converter @@ -2092,6 +2119,7 @@ export async function convertIfcToPascal( stairs: Object.values(nodes).filter((n) => n.type === 'stair').length, roofs: Object.values(nodes).filter((n) => n.type === 'roof').length, columns: Object.values(nodes).filter((n) => n.type === 'column').length, + beams: convertedBeamCount, skippedBeams: skippedBeamCount, skippedItems: skippedItemCount, }) diff --git a/packages/ifc-converter/tests/beams.test.ts b/packages/ifc-converter/tests/beams.test.ts new file mode 100644 index 0000000000..aff382cb00 --- /dev/null +++ b/packages/ifc-converter/tests/beams.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { AnyNode, type BlockNode, BlockTopology } from '@pascal-app/core' +import * as WebIFC from 'web-ifc' +import { convertIfcToPascal, type PascalSceneGraph } from '../src' + +const fixture = new URL('./fixtures/beams.ifc', import.meta.url) +const duplex = new URL( + '../../../apps/ifc-converter/public/test-ifc-files/01-duplex.ifc', + import.meta.url, +) +const originalSetWasmPath = WebIFC.IfcAPI.prototype.SetWasmPath +const originalGetLineIDsWithType = WebIFC.IfcAPI.prototype.GetLineIDsWithType + +function beams(graph: PascalSceneGraph) { + return Object.values(graph.nodes).filter( + (node): node is BlockNode => + node.type === 'block' && String(node.metadata.ifcType).startsWith('IFCBEAM'), + ) +} + +function bounds(node: BlockNode) { + const points = node.topology.vertices.map((vertex) => + vertex.position.map((value, axis) => value + node.position[axis]!), + ) + return [ + [0, 1, 2].map((axis) => Math.min(...points.map((point) => point[axis]!))), + [0, 1, 2].map((axis) => Math.max(...points.map((point) => point[axis]!))), + ] +} + +function signedVolume(node: BlockNode) { + const vertices = new Map(node.topology.vertices.map((vertex) => [vertex.id, vertex.position])) + return node.topology.faces.reduce((volume, face) => { + const [a, b, c] = face.vertexIds.map((id) => vertices.get(id)!) + return ( + volume + + (a![0] * (b![1] * c![2] - b![2] * c![1]) + + a![1] * (b![2] * c![0] - b![0] * c![2]) + + a![2] * (b![0] * c![1] - b![1] * c![0])) / + 6 + ) + }, 0) +} + +function expectBounds(node: BlockNode, expected: number[][]) { + const actual = bounds(node) + for (let side = 0; side < 2; side++) { + for (let axis = 0; axis < 3; axis++) { + expect(actual[side]![axis]).toBeCloseTo(expected[side]![axis]!, 5) + } + } +} + +function assertAttached(graph: PascalSceneGraph, node: BlockNode) { + const parent = node.parentId ? graph.nodes[node.parentId] : undefined + expect(parent?.type).toBe('level') + expect(node.metadata.levelId).toBe(parent?.id) + if (parent && 'children' in parent) { + expect(parent.children.filter((id) => id === node.id)).toHaveLength(1) + } + expect(BlockTopology.safeParse(node.topology).success).toBe(true) + expect(AnyNode.parse(JSON.parse(JSON.stringify(node)))).toEqual(node) + expect(node.supportSlabId).toBe('ground') +} + +describe('IFC beam import', () => { + const spies: { mockRestore: () => void }[] = [] + + beforeEach(() => { + const wasmPath = `${dirname(fileURLToPath(import.meta.resolve('web-ifc')))}/` + spies.push( + spyOn(WebIFC.IfcAPI.prototype, 'SetWasmPath').mockImplementation(function ( + this: WebIFC.IfcAPI, + ) { + originalSetWasmPath.call(this, wasmPath, true) + }), + ) + }) + + afterEach(() => { + for (const spy of spies.splice(0).reverse()) spy.mockRestore() + }) + + for (const simplify of [false, true]) { + it(`preserves all eight duplex I-beams with simplify=${simplify}`, async () => { + const graph = await convertIfcToPascal(await Bun.file(duplex).bytes(), undefined, { + simplify, + }) + const imported = beams(graph) + expect(imported.map((node) => node.metadata.expressID).sort()).toEqual([ + 28785, 28839, 28883, 28927, 28971, 29015, 29059, 29103, + ]) + for (const node of imported) { + assertAttached(graph, node) + expect(node.topology.faces.length).toBeGreaterThan(12) + expect(signedVolume(node)).toBeGreaterThan(0) + } + const first = imported.find((node) => node.metadata.expressID === 28785)! + const level = graph.nodes[first.parentId!] + const elevation = Number(level?.metadata.elevation) + expectBounds(first, [ + [4.2985, 2.797 - elevation, -17.4213], + [4.5015, 3.1 - elevation, -10], + ]) + expect(first.metadata.globalId).toBe('2OrWItJ6zAwBNp0OUxK_l8') + expect(first.metadata.material).toBe('Metal - Steel - 345 MPa') + }) + } + + for (const swapYZ of [true, false]) { + it(`converts millimeters, nested placements, slopes and storey offsets with swapYZ=${swapYZ}`, async () => { + const graph = await convertIfcToPascal(await Bun.file(fixture).bytes(), undefined, { swapYZ }) + const imported = beams(graph) + expect(imported).toHaveLength(2) + const horizontal = imported.find((node) => node.metadata.expressID === 100)! + const sloped = imported.find((node) => node.metadata.expressID === 130)! + const expected = [ + [7.8, 2.35, 21], + [8.2, 2.65, 25], + ] + expectBounds(horizontal, swapYZ ? expected : expected.map(([x, y, z]) => [x!, z!, y!])) + const halfDepth = 0.15 / Math.SQRT2 + const rise = 4 / Math.SQRT2 + const slopedExpected = [ + [7.8, 2.5 - halfDepth, 23 - halfDepth], + [8.2, 2.5 + rise + halfDepth, 23 + rise + halfDepth], + ] + expectBounds( + sloped, + swapYZ ? slopedExpected : slopedExpected.map(([x, y, z]) => [x!, z!, y!]), + ) + expect(sloped.metadata.ifcType).toBe('IFCBEAMSTANDARDCASE') + expect(horizontal.name).toBe('Horizontal beam') + expect(horizontal.metadata.properties).toEqual({ Pset_BeamCommon: { Reference: 'B1' } }) + for (const node of imported) { + assertAttached(graph, node) + expect(node.metadata.material).toBe('Steel') + expect(signedVolume(node)).toBeCloseTo(0.4 * 0.3 * 4, 5) + } + }) + } + + it('emits each beam once when type queries return repeated IDs', async () => { + spies.push( + spyOn(WebIFC.IfcAPI.prototype, 'GetLineIDsWithType').mockImplementation(function ( + this: WebIFC.IfcAPI, + modelID, + type, + inherited, + ) { + const ids = originalGetLineIDsWithType.call(this, modelID, type, inherited) + if (type !== WebIFC.IFCBEAM && type !== WebIFC.IFCBEAMSTANDARDCASE) return ids + const repeated = Array.from({ length: ids.size() * 2 }, (_, i) => ids.get(i % ids.size())) + return { + size: () => repeated.length, + get: (i: number) => repeated[i]!, + [Symbol.iterator]: () => repeated.values(), + } + }), + ) + const graph = await convertIfcToPascal(await Bun.file(fixture).bytes(), undefined, { + simplify: false, + }) + expect(beams(graph)).toHaveLength(2) + for (const node of beams(graph)) assertAttached(graph, node) + }) + + it('preserves all placed parts of a mapped representation', async () => { + const source = await Bun.file(fixture).text() + const mapped = source.replace('#92,#97,', '#92,#309,').replace( + 'ENDSEC;\nEND-ISO', + ` +#300=IFCREPRESENTATIONMAP(#6,#96); +#301=IFCCARTESIANPOINT((1000.,0.,0.)); +#302=IFCCARTESIANTRANSFORMATIONOPERATOR3D(#4,#5,#301,1.,#3); +#303=IFCMAPPEDITEM(#300,#302); +#304=IFCCARTESIANPOINT((6000.,0.,0.)); +#305=IFCCARTESIANTRANSFORMATIONOPERATOR3D(#4,#5,#304,1.,#3); +#306=IFCMAPPEDITEM(#300,#305); +#308=IFCSHAPEREPRESENTATION(#8,'Body','MappedRepresentation',(#303,#306)); +#309=IFCPRODUCTDEFINITIONSHAPE($,$,(#308)); +ENDSEC; +END-ISO`, + ) + const graph = await convertIfcToPascal(new TextEncoder().encode(mapped)) + const node = beams(graph).find((candidate) => candidate.metadata.expressID === 100)! + assertAttached(graph, node) + expect(node.topology.faces).toHaveLength(24) + expectBounds(node, [ + [7.8, 2.35, 22], + [8.2, 2.65, 31], + ]) + expect(signedVolume(node)).toBeCloseTo(2 * 0.4 * 0.3 * 4, 5) + }) + + it('retains uncontained beams as reachable roots', async () => { + const source = (await Bun.file(fixture).text()).replace(/^#42=.*\n/m, '') + const graph = await convertIfcToPascal(new TextEncoder().encode(source)) + const node = beams(graph).find((candidate) => candidate.metadata.expressID === 100)! + expect(node.parentId).toBeNull() + expect(graph.rootNodeIds).toContain(node.id) + expectBounds(node, [ + [7.8, 8.35, 21], + [8.2, 8.65, 25], + ]) + }) + + it('reports a missing representation and continues importing other beams', async () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + spies.push(warn) + const data = (await Bun.file(fixture).text()).replace('#92,#97,', '#92,$,') + const graph = await convertIfcToPascal(new TextEncoder().encode(data)) + expect(beams(graph).map((node) => node.metadata.expressID)).toEqual([130]) + expect(warn.mock.calls.some(([message]) => String(message).includes('beam #100'))).toBe(true) + }) +}) diff --git a/packages/ifc-converter/tests/fixtures/beams.ifc b/packages/ifc-converter/tests/fixtures/beams.ifc new file mode 100644 index 0000000000..d3950bdda1 --- /dev/null +++ b/packages/ifc-converter/tests/fixtures/beams.ifc @@ -0,0 +1,61 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('Beam import regression fixture'),'2;1'); +FILE_NAME('beams.ifc','2026-09-11T00:00:00',('Pascal'),('Pascal'),'','',''); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROJECT('0000000000000000000001',$,'Beam fixture',$,$,$,$,(#8),#9); +#2=IFCCARTESIANPOINT((0.,0.,0.)); +#3=IFCDIRECTION((0.,0.,1.)); +#4=IFCDIRECTION((1.,0.,0.)); +#5=IFCDIRECTION((0.,1.,0.)); +#6=IFCAXIS2PLACEMENT3D(#2,#3,#4); +#8=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,0.00001,#6,$); +#9=IFCUNITASSIGNMENT((#10)); +#10=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#11=IFCCARTESIANPOINT((1000000.,2000000.,3000.)); +#12=IFCAXIS2PLACEMENT3D(#11,#3,#4); +#13=IFCLOCALPLACEMENT($,#12); +#14=IFCSITE('0000000000000000000002',$,'Site',$,$,#13,$,$,.ELEMENT.,$,$,$,$,$); +#15=IFCRELAGGREGATES('0000000000000000000003',$,$,$,#1,(#14)); +#20=IFCCARTESIANPOINT((10000.,20000.,0.)); +#21=IFCAXIS2PLACEMENT3D(#20,#3,#5); +#22=IFCLOCALPLACEMENT(#13,#21); +#23=IFCBUILDING('0000000000000000000004',$,'Rotated building',$,$,#22,$,$,.ELEMENT.,$,$,$); +#24=IFCRELAGGREGATES('0000000000000000000005',$,$,$,#14,(#23)); +#30=IFCCARTESIANPOINT((0.,0.,6000.)); +#31=IFCAXIS2PLACEMENT3D(#30,#3,#4); +#32=IFCLOCALPLACEMENT(#22,#31); +#33=IFCBUILDINGSTOREY('0000000000000000000006',$,'Upper storey',$,$,#32,$,$,.ELEMENT.,6000.); +#34=IFCRELAGGREGATES('0000000000000000000007',$,$,$,#23,(#33)); +#40=IFCELEMENTASSEMBLY('0000000000000000000008',$,'Structural assembly',$,$,#32,$,$,$,.BEAM_GRID.); +#41=IFCRELCONTAINEDINSPATIALSTRUCTURE('0000000000000000000009',$,$,$,(#40,#130),#33); +#42=IFCRELAGGREGATES('0000000000000000000010',$,$,$,#40,(#100)); +#85=IFCCARTESIANPOINT((0.,0.)); +#86=IFCAXIS2PLACEMENT2D(#85,$); +#90=IFCCARTESIANPOINT((1000.,2000.,2500.)); +#91=IFCAXIS2PLACEMENT3D(#90,#3,#4); +#92=IFCLOCALPLACEMENT(#32,#91); +#93=IFCRECTANGLEPROFILEDEF(.AREA.,'400 x 300',#86,400.,300.); +#94=IFCAXIS2PLACEMENT3D(#2,#4,#5); +#95=IFCEXTRUDEDAREASOLID(#93,#94,#3,4000.); +#96=IFCSHAPEREPRESENTATION(#8,'Body','SweptSolid',(#95)); +#97=IFCPRODUCTDEFINITIONSHAPE($,$,(#96)); +#100=IFCBEAM('0000000000000000000011',$,'Horizontal beam',$,$,#92,#97,'B1',.BEAM.); +#120=IFCCARTESIANPOINT((3000.,2000.,2500.)); +#121=IFCAXIS2PLACEMENT3D(#120,#3,#4); +#122=IFCLOCALPLACEMENT(#32,#121); +#123=IFCDIRECTION((1.,0.,1.)); +#124=IFCAXIS2PLACEMENT3D(#2,#123,#5); +#125=IFCEXTRUDEDAREASOLID(#93,#124,#3,4000.); +#126=IFCSHAPEREPRESENTATION(#8,'Body','SweptSolid',(#125)); +#127=IFCPRODUCTDEFINITIONSHAPE($,$,(#126)); +#130=IFCBEAMSTANDARDCASE('0000000000000000000012',$,'Sloped beam',$,$,#122,#127,'B2',.BEAM.); +#200=IFCMATERIAL('Steel',$,$); +#201=IFCRELASSOCIATESMATERIAL('0000000000000000000013',$,$,$,(#100,#130),#200); +#202=IFCPROPERTYSINGLEVALUE('Reference',$,IFCLABEL('B1'),$); +#203=IFCPROPERTYSET('0000000000000000000014',$,'Pset_BeamCommon',$,(#202)); +#204=IFCRELDEFINESBYPROPERTIES('0000000000000000000015',$,$,$,(#100),#203); +ENDSEC; +END-ISO-10303-21; diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 9adaa851a2..43cee58090 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to `@pascal-app/mcp` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Tool schemas in `tools/list` now declare the JSON Schema 2020-12 dialect + instead of the SDK default `draft-07`, so clients that enforce 2020-12 no + longer reject every tool call. + ## [0.1.0] - 2026-04-18 ### Added diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 1331dea070..0292ffd5fd 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -334,7 +334,7 @@ captured by Zundo's temporal middleware as a single undoable step. | `add_door` | Add a door to a wall using parametric placement. | `{ wallId, t, width?, height?, hingesSide?, swingDirection? }` | `{ doorId, localX }` | | `add_window` | Add a window to a wall using parametric placement and sill height. | `{ wallId, t, width?, height?, sillHeight? }` | `{ windowId, localX, sillHeight }` | | `furnish_room` | Place realistic furniture for a room type inside a polygon. | `{ levelId, roomType, polygon, doorWallIndex? }` | `{ placed, itemIds, skipped }` | -| `apply_patch` | Batched create/update/delete/move, validated and dry-run before commit. | `{ patches: Patch[] }` | `{ applied: number }` | +| `apply_patch` | Batched create/update/delete/move, validated and dry-run before commit. Batch-first is the default: send all create/update/delete ops for a build step in one atomic call (stable order, later ops may reference earlier created ids); do not loop one-op calls. | `{ patches: Patch[] }` | `{ applied: number }` | | `create_level` | Add a new level to a building. | `{ buildingId, elevation, height, label? }` | `{ levelId }` | | `create_wall` | Add a wall to a level. | `{ levelId, start, end, thickness?, height? }` | `{ wallId }` | | `place_item` | Place a catalog item on a level/slab/zone, ceiling, wall, or site. Slab/zone targets resolve to the parent level so floor items render and validate. | `{ catalogItemId, targetNodeId, position, rotation? }` | `{ itemId, status }` | diff --git a/packages/mcp/package.json b/packages/mcp/package.json index a3e0dcb9ba..9f35700d53 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@pascal-app/mcp", - "version": "1.0.0-beta.6", + "version": "1.0.0", "description": "Model Context Protocol server for Pascal 3D editor", "type": "module", "main": "./dist/index.js", @@ -55,7 +55,7 @@ "prepublishOnly": "bun run build && bun test" }, "peerDependencies": { - "@pascal-app/core": "^1.0.0-beta.5" + "@pascal-app/core": "^1.0.0" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", @@ -63,7 +63,7 @@ "zod": ">=4.5.4 <4.6" }, "devDependencies": { - "@pascal-app/core": "^1.0.0-beta.5", + "@pascal-app/core": "^1.0.0", "@pascal/typescript-config": "*", "@types/node": "^22.19.20", "typescript": "6.0.3" diff --git a/packages/mcp/src/prompts/from-brief.ts b/packages/mcp/src/prompts/from-brief.ts index eec0a223fe..2062930100 100644 --- a/packages/mcp/src/prompts/from-brief.ts +++ b/packages/mcp/src/prompts/from-brief.ts @@ -6,6 +6,7 @@ import { SCENE_DESIGN_GUIDANCE } from './scene-guidance' const PREAMBLE = [ 'You are a Pascal 3D scene designer.', 'You have access to semantic scene tools and the lower-level `apply_patch` tool. Prefer semantic construction/room/opening/furnishing tools for architectural work, and use `apply_patch` for bulk graph edits that need exact control.', + 'One patch per phase: when a phase needs many graph edits, send all of them in a single `apply_patch` call in stable order (later ops may reference ids created by earlier ops) instead of looping one-op calls; a single call is atomic and pays the snapshot cost once.', 'Bind an active scene before mutating anything. Call `create_project` for a new project, `list_scenes` then `load_scene` for an existing one, or `create_house_from_brief` to create and load a starter in one step. Without a bound scene, mutations apply in memory only β€” they are not persisted and never appear in the browser.', 'Build incrementally with visible progress. Starting from an empty scene, first create/load a Site and Building, then create occupied Levels and `create_story_shell` once per story before detailed rooms, openings, furniture, a dedicated roof level via `create_roof`, and landscaping.', 'Semantic tools update the browser-visible draft. Call `save_scene` with `saveMode: "checkpoint"` only for meaningful milestones, then call `verify_scene` and `get_project_status`, and return the final `editorUrl`.', diff --git a/packages/mcp/src/resources/agent-guide.ts b/packages/mcp/src/resources/agent-guide.ts index 6fa5dec066..2ceec36de1 100644 --- a/packages/mcp/src/resources/agent-guide.ts +++ b/packages/mcp/src/resources/agent-guide.ts @@ -34,6 +34,7 @@ export const AGENT_GUIDE = [ '', '- Prefer semantic tools over raw graph patches.', '- Do not hand-write node graphs unless no semantic tool exists.', + '- When you do use `apply_patch` for bulk graph edits, batch-first is the default: one call containing all create/update/delete ops for the phase, in stable order so later ops can reference ids created earlier. A single call is atomic (all or nothing); do not loop one-op `apply_patch` calls.', '- For rooms, use `create_room` -> `add_door` -> `add_window` -> `furnish_room`.', '- `furnish_room` skips or nudges poses that block door clear zones or overlap other items; `verify_scene` and `check_collisions` report remaining issues.', '- Between adjacent rooms, prefer one shared wall (or only cut openings that line up). Leave ~0.65 m clear on both sides of each door; do not stack furniture footprints.', diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 0a3aba19b9..b07b747c69 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -5,6 +5,7 @@ import { registerPrompts } from './prompts' import { registerResources } from './resources' import type { SceneStore } from './storage/types' import { registerTools } from './tools' +import { normalizeToolSchemaDialect } from './tools/normalize-schema-dialect' import { registerVisionTools } from './tools/vision' import { version } from './version' @@ -41,6 +42,7 @@ export function createPascalMcpServer(opts: CreatePascalMcpServerOptions): McpSe registerVisionTools(server, operations) registerResources(server, operations) registerPrompts(server, operations) + normalizeToolSchemaDialect(server) return server } diff --git a/packages/mcp/src/tools/apply-patch-batch-first.test.ts b/packages/mcp/src/tools/apply-patch-batch-first.test.ts new file mode 100644 index 0000000000..883dbefa49 --- /dev/null +++ b/packages/mcp/src/tools/apply-patch-batch-first.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { SceneBridge } from '../bridge/scene-bridge' +import { buildFromBriefPrompt } from '../prompts/from-brief' +import { AGENT_GUIDE } from '../resources/agent-guide' +import { registerApplyPatch } from './apply-patch' + +describe('apply_patch batch-first guidance', () => { + let client: Client + + beforeEach(async () => { + const bridge = new SceneBridge() + bridge.setScene({}, []) + bridge.loadDefault() + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerApplyPatch(server, bridge) + const [srvT, cliT] = InMemoryTransport.createLinkedPair() + client = new Client({ name: 'test-client', version: '0.0.0' }) + await Promise.all([server.connect(srvT), client.connect(cliT)]) + }) + + test('tool description states batch-first as the default', async () => { + const { tools } = await client.listTools() + const applyPatch = tools.find((tool) => tool.name === 'apply_patch') + expect(applyPatch).toBeDefined() + const description = applyPatch!.description ?? '' + expect(description.toLowerCase()).toContain('batch-first') + expect(description.toLowerCase()).toContain('do not loop one-op') + expect(description.toLowerCase()).toContain('validated before any are applied') + }) + + test('agent guide tells agents to batch apply_patch per phase', () => { + expect(AGENT_GUIDE).toContain('batch-first is the default') + expect(AGENT_GUIDE).toContain('do not loop one-op `apply_patch` calls') + }) + + test('from_brief preamble says one patch per phase', () => { + const text = buildFromBriefPrompt({ brief: 'Studio loft' }) + expect(text).toContain('One patch per phase') + expect(text).toContain('single `apply_patch` call') + expect(text.toLowerCase()).toContain('instead of looping one-op') + }) + + test('MCP README apply_patch row documents batch-first default', () => { + const readme = readFileSync(join(import.meta.dir, '../../README.md'), 'utf8') + expect(readme).toContain('Batch-first is the default') + expect(readme).toContain('do not loop one-op calls') + }) +}) diff --git a/packages/mcp/src/tools/apply-patch.ts b/packages/mcp/src/tools/apply-patch.ts index 9a73cf2dd8..e520812645 100644 --- a/packages/mcp/src/tools/apply-patch.ts +++ b/packages/mcp/src/tools/apply-patch.ts @@ -25,7 +25,7 @@ export function registerApplyPatch(server: McpServer, bridge: SceneOperations): { title: 'Apply patch', description: - 'Apply a batch of create/update/delete operations atomically. All patches are validated before any are applied; the entire batch forms a single undo step.', + 'Apply a batch of create/update/delete operations atomically. All patches are validated before any are applied; the entire batch forms a single undo step. Batch-first is the default: prefer one apply_patch call containing all create/update/delete ops for a build step, in stable order so later ops can reference ids created by earlier ops. A single call is atomic (all or nothing) and pays the snapshot and save cost once; do not loop one-op calls.', inputSchema: applyPatchInput, outputSchema: applyPatchOutput, annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, diff --git a/packages/mcp/src/tools/normalize-schema-dialect.ts b/packages/mcp/src/tools/normalize-schema-dialect.ts new file mode 100644 index 0000000000..b41dbc68bb --- /dev/null +++ b/packages/mcp/src/tools/normalize-schema-dialect.ts @@ -0,0 +1,35 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' + +const DIALECT_2020_12 = 'https://json-schema.org/draft/2020-12/schema' + +type RequestHandler = (request: unknown, extra: unknown) => Promise + +type HandlerRegistry = { + _requestHandlers: Map +} + +function retargetDialect(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) retargetDialect(item) + return + } + if (value && typeof value === 'object') { + const record = value as Record + if (typeof record.$schema === 'string') record.$schema = DIALECT_2020_12 + for (const key of Object.keys(record)) retargetDialect(record[key]) + } +} + +export function normalizeToolSchemaDialect(server: McpServer): void { + const registry = server.server as unknown as HandlerRegistry + const original = registry._requestHandlers.get('tools/list') + if (!original) return + + server.server.removeRequestHandler('tools/list') + server.server.setRequestHandler(ListToolsRequestSchema, async (request, extra) => { + const result = (await original(request, extra)) as { tools?: unknown } + retargetDialect(result.tools) + return result + }) +} diff --git a/packages/mcp/src/tools/schema-dialect.test.ts b/packages/mcp/src/tools/schema-dialect.test.ts new file mode 100644 index 0000000000..8c9181e9b3 --- /dev/null +++ b/packages/mcp/src/tools/schema-dialect.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { SceneBridge } from '../bridge/scene-bridge' +import { createPascalMcpServer } from '../server' + +const DIALECT_2020_12 = 'https://json-schema.org/draft/2020-12/schema' + +async function listRawSchemas() { + const bridge = new SceneBridge() + bridge.setScene({}, []) + bridge.loadDefault() + + const server = createPascalMcpServer({ bridge }) + const [srvT, cliT] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'schema-dialect', version: '0.0.0' }) + await Promise.all([server.connect(srvT), client.connect(cliT)]) + const { tools } = await client.listTools() + + const dialects = new Set() + for (const tool of tools) { + const input = tool.inputSchema as Record | undefined + const output = tool.outputSchema as Record | undefined + if (typeof input?.$schema === 'string') dialects.add(input.$schema) + if (typeof output?.$schema === 'string') dialects.add(output.$schema) + } + return dialects +} + +describe('tools/list schema dialect', () => { + test('every declared $schema is JSON Schema 2020-12', async () => { + const dialects = await listRawSchemas() + + expect(dialects.size).toBeGreaterThan(0) + expect([...dialects]).toEqual([DIALECT_2020_12]) + }) +}) diff --git a/packages/nodes/bench/slab-dependency-tracker.ts b/packages/nodes/bench/slab-dependency-tracker.ts new file mode 100644 index 0000000000..0c6bc0217e --- /dev/null +++ b/packages/nodes/bench/slab-dependency-tracker.ts @@ -0,0 +1,138 @@ +import { + AnyNode, + type AnyNodeId, + getLinkedWallUpdates, + type SlabNode, + type WallNode, +} from '@pascal-app/core' +import fixture from '../../core/src/store/fixtures/maxi-8x-endpoint.json' +import { createSlabDependencyTracker } from '../src/slab/dependency-tracker' + +// Preserve the origin/main signature and dirty-selection work for a like-for-like baseline. +function levelSlabContextSignatures(nodes: Record): Map { + const partsByLevel = new Map() + + const push = (levelId: string, part: string) => { + const parts = partsByLevel.get(levelId) + if (parts) parts.push(part) + else partsByLevel.set(levelId, [part]) + } + + for (const node of Object.values(nodes)) { + const levelId = node.parentId + if (!levelId) continue + if (node.type === 'wall') { + const wall = node as WallNode + push( + levelId, + `w|${wall.id}|${wall.start[0]},${wall.start[1]}|${wall.end[0]},${wall.end[1]}|${wall.thickness ?? ''}|${wall.curveOffset ?? ''}`, + ) + } else if (node.type === 'slab') { + const slab = node as SlabNode + // Elevation is a seam input: an unequal-elevation seam projects to + // the lower side's wall face, so a height change reshapes siblings. + push( + levelId, + `s|${slab.id}|${slab.elevation ?? ''}|${slab.polygon.map(([x, z]) => `${x},${z}`).join(';')}`, + ) + } + } + + for (const node of Object.values(nodes)) { + if (node.type !== 'building') continue + const transform = `${node.position.join(',')}|${node.rotation.join(',')}` + for (const childId of node.children) { + const child = nodes[childId] + if (child?.type === 'level') push(child.id, `b|${node.id}|${transform}`) + } + } + + const signatures = new Map() + for (const [levelId, parts] of partsByLevel.entries()) { + signatures.set(levelId, parts.sort().join('||')) + } + return signatures +} + +function oldTracker(initial: Record) { + let previous = levelSlabContextSignatures(initial) + return (nodes: Record) => { + const current = levelSlabContextSignatures(nodes) + const dirty: AnyNodeId[] = [] + for (const [levelId, signature] of current) { + if (previous.get(levelId) === signature) continue + for (const node of Object.values(nodes)) { + if (node.type === 'slab' && node.parentId === levelId) dirty.push(node.id) + } + } + previous = current + return dirty + } +} + +const initial: Record = Object.fromEntries( + fixture.nodes.map((raw) => { + const node = AnyNode.parse(raw) + return [node.id, node] + }), +) +const wall = initial[fixture.updates[0]!.id] as WallNode +const nextStart: [number, number] = [wall.start[0], wall.start[1] + 0.4] +const nextEnd: [number, number] = [wall.end[0], wall.end[1] + 0.4] +const moved = { ...initial, [wall.id]: { ...wall, start: nextStart, end: nextEnd } } +const linked = Object.values(initial).filter( + (node): node is WallNode => + node.type === 'wall' && node.id !== wall.id && node.parentId === wall.parentId, +) +for (const update of getLinkedWallUpdates( + linked.map((wall) => ({ wall })), + wall.start, + wall.end, + nextStart, + nextEnd, +)) { + const original = initial[update.id] as WallNode + if (update.start === original.start && update.end === original.end) continue + moved[update.id] = { ...original, start: update.start, end: update.end } +} + +const iterations = 1000 +function measure(create: typeof oldTracker) { + const update = create(moved) + for (let i = 0; i < 100; i++) { + update(initial) + update(moved) + } + let elapsed = 0 + let marks = 0 + for (let i = 0; i < iterations; i++) { + const start = performance.now() + marks += update(initial).length + elapsed += performance.now() - start + update(moved) + } + return { msPerWrite: elapsed / iterations, marksPerWrite: marks / iterations } +} +const runs = Array.from({ length: 7 }, (_, i) => { + const order = + i % 2 ? [createSlabDependencyTracker, oldTracker] : [oldTracker, createSlabDependencyTracker] + return Object.fromEntries( + order.map((create) => [create === oldTracker ? 'old' : 'new', measure(create)]), + ) +}) +for (const key of ['old', 'new']) { + const sorted = runs.map((run) => run[key]!).sort((a, b) => a.msPerWrite - b.msPerWrite) + console.log(key, JSON.stringify(sorted[3])) +} +console.log( + 'Fixture:', + fixture.levelId, + 'walls:', + Object.values(initial).filter((node) => node.type === 'wall').length, + 'slabs:', + Object.values(initial).filter((node) => node.type === 'slab').length, + 'wall body undo:', + wall.id, + 'changed walls:', + Object.keys(moved).filter((id) => moved[id] !== initial[id]).length, +) diff --git a/packages/nodes/bunfig.toml b/packages/nodes/bunfig.toml new file mode 100644 index 0000000000..eec7d338da --- /dev/null +++ b/packages/nodes/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-preload-three.ts"] + +[test] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/packages/nodes/package.json b/packages/nodes/package.json index 699131dd7b..d989c0c3f6 100644 --- a/packages/nodes/package.json +++ b/packages/nodes/package.json @@ -1,7 +1,7 @@ { "name": "@pascal-app/nodes", - "version": "1.0.0-beta.5", - "description": "Built-in node bundles for the Pascal 3D editor β€” one folder per kind, exported as `builtinPlugin`", + "version": "1.0.0", + "description": "Built-in node bundles for the Pascal 3D editor \u2014 one folder per kind, exported as `builtinPlugin`", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -23,24 +23,26 @@ "prepublishOnly": "bun run build && bun test" }, "peerDependencies": { - "@pascal-app/core": "^1.0.0-beta.5", - "@pascal-app/editor": "^1.0.0-beta.5", - "@pascal-app/viewer": "^1.0.0-beta.5", + "@pascal-app/core": "^1.0.0", + "@pascal-app/editor": "^1.0.0", + "@pascal-app/viewer": "^1.0.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", "lucide-react": "^1", "react": "^18 || ^19", - "three": "^0.185", + "react-dom": "^18 || ^19", + "three": "^0.186", "zustand": "^5" }, "devDependencies": { - "@pascal-app/core": "^1.0.0-beta.5", - "@pascal-app/editor": "^1.0.0-beta.5", - "@pascal-app/viewer": "^1.0.0-beta.5", + "@pascal-app/core": "^1.0.0", + "@pascal-app/editor": "^1.0.0", + "@pascal-app/viewer": "^1.0.0", "@pascal/typescript-config": "*", "@types/bun": "^1.3.0", "@types/node": "^22.19.12", "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.3", "@types/three": "^0.184.0", "typescript": "6.0.3" }, diff --git a/packages/nodes/src/block/selection.tsx b/packages/nodes/src/block/selection.tsx index 87d420ebfe..7d326ce0b5 100644 --- a/packages/nodes/src/block/selection.tsx +++ b/packages/nodes/src/block/selection.tsx @@ -3453,7 +3453,7 @@ function BlockEditor({ } else if (actions.hasSelection) { actions.beginKeyboardTransformModal('rotate') } - } else if (key === 's') { + } else if (key === 's' && !(event.ctrlKey || event.metaKey)) { if (actions.hasSelection) { if (!actions.beginUniformScaleModal()) { playBlockSfx('tool-select') diff --git a/packages/nodes/src/box-vent/panel.tsx b/packages/nodes/src/box-vent/panel.tsx index 537496152f..bb6a757eda 100644 --- a/packages/nodes/src/box-vent/panel.tsx +++ b/packages/nodes/src/box-vent/panel.tsx @@ -193,7 +193,7 @@ export default function BoxVentPanel() { restoreOnCommit={false} step={0.01} unit="m" - value={Math.round(node.width * 100) / 100} + value={node.width} /> {/* Hood Overhang is `cap`-only β€” the dome shape rolls down to the body footprint without a flange skirt, and `box` doesn't @@ -234,7 +234,7 @@ export default function BoxVentPanel() { restoreOnCommit={false} step={0.005} unit="m" - value={Math.round((node.hoodOverhang ?? 0) * 1000) / 1000} + value={node.hoodOverhang ?? 0} /> )} {node.style === 'box' && ( @@ -249,7 +249,7 @@ export default function BoxVentPanel() { restoreOnCommit={false} step={0.005} unit="m" - value={Math.round((node.baseInset ?? 0.06) * 1000) / 1000} + value={node.baseInset ?? 0.06} /> )} @@ -292,7 +292,7 @@ export default function BoxVentPanel() { restoreOnCommit={false} step={0.005} unit="m" - value={Math.round((node.capHeight ?? 0.07) * 1000) / 1000} + value={node.capHeight ?? 0.07} /> )} @@ -369,7 +369,7 @@ export default function BoxVentPanel() { restoreOnCommit={false} step={0.05} unit="m" - value={Math.round((node.position[0] ?? 0) * 100) / 100} + value={node.position[0] ?? 0} /> )} diff --git a/packages/nodes/src/chimney/panel.tsx b/packages/nodes/src/chimney/panel.tsx index d706dbd5e8..d97af04fe7 100644 --- a/packages/nodes/src/chimney/panel.tsx +++ b/packages/nodes/src/chimney/panel.tsx @@ -377,7 +377,7 @@ export default function ChimneyPanel() { restoreOnCommit={false} step={0.05} unit="m" - value={Math.round(node.width * 100) / 100} + value={node.width} /> {(node.bodyShape ?? 'square') !== 'round' && ( )} {(node.bodyShape ?? 'square') !== 'round' && ( )} @@ -444,7 +444,7 @@ export default function ChimneyPanel() { restoreOnCommit={false} step={0.1} unit="m" - value={Math.round(node.heightAboveRidge * 100) / 100} + value={node.heightAboveRidge} /> @@ -478,7 +478,7 @@ export default function ChimneyPanel() { restoreOnCommit={false} step={0.05} unit="m" - value={Math.round(worldX_now * 100) / 100} + value={worldX_now} /> )} @@ -620,7 +620,7 @@ export default function ChimneyPanel() { restoreOnCommit={false} step={0.05} unit="m" - value={Math.round((node.shoulderHeight ?? 0.5) * 100) / 100} + value={node.shoulderHeight ?? 0.5} /> )} @@ -673,7 +673,7 @@ export default function ChimneyPanel() { restoreOnCommit={false} step={0.01} unit="m" - value={Math.round((node.flueDiameter ?? 0.22) * 100) / 100} + value={node.flueDiameter ?? 0.22} /> {(node.flueCount ?? 1) > 1 && ( )} @@ -741,7 +741,7 @@ export default function ChimneyPanel() { restoreOnCommit={false} step={0.01} unit="m" - value={Math.round((node.bandHeight ?? 0.1) * 100) / 100} + value={node.bandHeight ?? 0.1} /> )} @@ -804,7 +804,7 @@ export default function ChimneyPanel() { restoreOnCommit={false} step={0.05} unit="m" - value={Math.round((node.cricketLength ?? 0.6) * 100) / 100} + value={node.cricketLength ?? 0.6} /> )} @@ -846,7 +846,7 @@ export default function ChimneyPanel() { restoreOnCommit={false} step={0.005} unit="m" - value={Math.round((node.panelDepth ?? 0.03) * 1000) / 1000} + value={node.panelDepth ?? 0.03} /> )} diff --git a/packages/nodes/src/cupola/panel.tsx b/packages/nodes/src/cupola/panel.tsx index 2d5913a341..bc2aead4db 100644 --- a/packages/nodes/src/cupola/panel.tsx +++ b/packages/nodes/src/cupola/panel.tsx @@ -173,7 +173,7 @@ export default function CupolaPanel() { restoreOnCommit={false} step={0.05} unit="m" - value={Math.round(node.width * 100) / 100} + value={node.width} /> @@ -216,7 +216,7 @@ export default function CupolaPanel() { restoreOnCommit={false} step={0.05} unit="m" - value={Math.round((node.position[0] ?? 0) * 100) / 100} + value={node.position[0] ?? 0} /> {showFlipSide && (
@@ -726,7 +726,7 @@ export default function DoorPanel() { restoreOnCommit={false} step={0.05} unit="m" - value={Math.round(node.width * 100) / 100} + value={node.width} /> @@ -799,7 +799,7 @@ export default function DoorPanel() { precision={2} step={0.05} unit="m" - value={Math.round(cornerRadius * 100) / 100} + value={cornerRadius} /> ) : ( <> @@ -817,7 +817,7 @@ export default function DoorPanel() { precision={2} step={0.05} unit="m" - value={Math.round((openingTopRadii[index as number] ?? 0) * 100) / 100} + value={openingTopRadii[index as number] ?? 0} /> ))} @@ -831,7 +831,7 @@ export default function DoorPanel() { precision={3} step={0.005} unit="m" - value={Math.round(openingRevealRadius * 1000) / 1000} + value={openingRevealRadius} /> )} @@ -845,7 +845,7 @@ export default function DoorPanel() { restoreOnCommit={false} step={0.05} unit="m" - value={Math.round(archHeight * 100) / 100} + value={archHeight} /> )} @@ -896,7 +896,7 @@ export default function DoorPanel() { precision={2} step={0.05} unit="m" - value={Math.round(cornerRadius * 100) / 100} + value={cornerRadius} /> ) : ( <> @@ -914,7 +914,7 @@ export default function DoorPanel() { precision={2} step={0.05} unit="m" - value={Math.round((openingTopRadii[index as number] ?? 0) * 100) / 100} + value={openingTopRadii[index as number] ?? 0} /> ))} @@ -928,7 +928,7 @@ export default function DoorPanel() { precision={3} step={0.005} unit="m" - value={Math.round(openingRevealRadius * 1000) / 1000} + value={openingRevealRadius} /> )} @@ -942,7 +942,7 @@ export default function DoorPanel() { restoreOnCommit={false} step={0.05} unit="m" - value={Math.round(archHeight * 100) / 100} + value={archHeight} /> )} @@ -960,7 +960,7 @@ export default function DoorPanel() { precision={3} step={0.01} unit="m" - value={Math.round(node.frameThickness * 1000) / 1000} + value={node.frameThickness} /> )} @@ -985,7 +985,7 @@ export default function DoorPanel() { precision={3} step={0.005} unit="m" - value={Math.round(node.contentPadding[0] * 1000) / 1000} + value={node.contentPadding[0]} /> )} @@ -1052,7 +1052,7 @@ export default function DoorPanel() { precision={3} step={0.005} unit="m" - value={Math.round(node.thresholdHeight * 1000) / 1000} + value={node.thresholdHeight} />
)} @@ -1078,7 +1078,7 @@ export default function DoorPanel() { precision={2} step={0.05} unit="m" - value={Math.round(node.handleHeight * 100) / 100} + value={node.handleHeight} /> {supportsHandleSide && (
@@ -1122,7 +1122,7 @@ export default function DoorPanel() { precision={2} step={0.05} unit="m" - value={Math.round(node.panicBarHeight * 100) / 100} + value={node.panicBarHeight} />
)} @@ -1211,7 +1211,7 @@ export default function DoorPanel() { precision={3} step={0.005} unit="m" - value={Math.round(seg.dividerThickness * 1000) / 1000} + value={seg.dividerThickness} /> )} @@ -1231,7 +1231,7 @@ export default function DoorPanel() { precision={3} step={0.005} unit="m" - value={Math.round(seg.panelInset * 1000) / 1000} + value={seg.panelInset} /> )} diff --git a/packages/nodes/src/dormer/panel-position-section.tsx b/packages/nodes/src/dormer/panel-position-section.tsx index 545d55f15f..a2f95ee440 100644 --- a/packages/nodes/src/dormer/panel-position-section.tsx +++ b/packages/nodes/src/dormer/panel-position-section.tsx @@ -180,7 +180,7 @@ export function DormerPositionSection({ restoreOnCommit={false} step={0.05} unit="m" - value={Math.round(worldX_now * 100) / 100} + value={worldX_now} /> diff --git a/packages/nodes/src/duct-segment/continuation.test.ts b/packages/nodes/src/duct-segment/continuation.test.ts index 7f4973b827..0787cd3629 100644 --- a/packages/nodes/src/duct-segment/continuation.test.ts +++ b/packages/nodes/src/duct-segment/continuation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test' import { type AnyNode, DuctFittingNode, nodeRegistry, registerNode } from '@pascal-app/core' import { ductFittingDefinition } from '../duct-fitting/definition' import { getDuctFittingPorts } from '../duct-fitting/ports' +import { createDuctRunEndCap } from '../shared/automatic-run-end-cap' import { ductContinuationHandlePlan, ductContinuationHandlePoint, @@ -69,6 +70,28 @@ describe('duct continuation', () => { ).toBeNull() }) + test('continues through an end cap so the draw commit can replace it', () => { + const restoreRegistry = nodeRegistry._snapshot() + try { + registerNode(ductSegmentDefinition) + registerNode(ductFittingDefinition) + const duct = makeDuct() + const cap = createDuctRunEndCap(duct)! + const nodes = { [duct.id]: duct, [cap.id]: cap } as Record + + const handle = ductContinuationHandlePlan(duct, 'end', nodes) + expect(handle?.fittingId).toBe(cap.id) + const seed = resolveDuctContinuationSeed( + { continuation: { nodeId: duct.id, endpoint: 'end', fittingId: cap.id } }, + nodes, + ) + expect(seed?.port.position).toEqual(duct.path.at(-1)) + expect(seed?.promotedFitting).toBeUndefined() + } finally { + restoreRegistry() + } + }) + test('moves the action from an occupied run end to an elbow branch and seeds a tee', () => { const restoreRegistry = nodeRegistry._snapshot() try { diff --git a/packages/nodes/src/duct-segment/continuation.ts b/packages/nodes/src/duct-segment/continuation.ts index 75cfa6ca17..79c26a31a7 100644 --- a/packages/nodes/src/duct-segment/continuation.ts +++ b/packages/nodes/src/duct-segment/continuation.ts @@ -120,6 +120,7 @@ export function resolveDuctContinuationSeed( if (fitting?.type !== 'duct-fitting') return null const fittingPort = findMatedScenePorts(port, nodes).find((mate) => mate.nodeId === fitting.id) if (!fittingPort) return null + if (fitting.fittingType === 'end-cap') return { duct: node, port, body: null } const promotion = fitting.fittingType === 'elbow' ? planDuctElbowBranchPromotion(fitting, fittingPort.id) diff --git a/packages/nodes/src/duct-segment/definition.ts b/packages/nodes/src/duct-segment/definition.ts index 2de2c002a9..1a61566b34 100644 --- a/packages/nodes/src/duct-segment/definition.ts +++ b/packages/nodes/src/duct-segment/definition.ts @@ -2,6 +2,7 @@ import { type AnyNode, type NodeDefinition, useScene } from '@pascal-app/core' import { ductBodyPaint, ductBodySlots } from '../shared/duct-body-paint' import { createPathPointMoveAffordance } from '../shared/path-point-affordance' import { createSegmentMoveAffordance } from '../shared/path-segment-affordance' +import { createRunHangerToolHint } from '../shared/run-hanger-mode' import { ductBranchAffordance, ductContinuationAffordance } from './continuation' import { buildDuctSegmentFloorplan } from './floorplan' import { buildDuctSegmentGeometry, ductPortDiameterIn } from './geometry' @@ -179,7 +180,8 @@ export const ductSegmentDefinition: NodeDefinition = { { key: 'Alt + drag', label: 'Go vertical ↕, click to place' }, { key: '[ / ]', label: 'Duct diameter down / up' }, { key: 'Q', label: 'Round / rectangular / oval' }, - { key: 'Esc', label: 'Cancel start point' }, + createRunHangerToolHint('duct-segment'), + { key: 'Esc', label: 'Exit drawing' }, ], presentation: { diff --git a/packages/nodes/src/duct-segment/draw-plan.test.ts b/packages/nodes/src/duct-segment/draw-plan.test.ts index bc81e2feb9..5e95021141 100644 --- a/packages/nodes/src/duct-segment/draw-plan.test.ts +++ b/packages/nodes/src/duct-segment/draw-plan.test.ts @@ -28,6 +28,7 @@ test('a short existing run cannot silently lose its required elbow', () => { null, null, profile, + useScene.getState().nodes, ) expect(plan?.validationMessage).toBeTruthy() } finally { @@ -36,9 +37,10 @@ test('a short existing run cannot silently lose its required elbow', () => { }) test('a free run remains drawable', () => { - const plan = planDuctDraw([0, 0, 0], [2, 0, 0], null, null, null, null, profile) + const plan = planDuctDraw([0, 0, 0], [2, 0, 0], null, null, null, null, profile, {}) expect(plan?.validationMessage).toBeNull() expect(plan?.ducts).toHaveLength(1) + expect(plan?.fittings.map((fitting) => fitting.fittingType)).toEqual(['end-cap', 'end-cap']) }) test('a short branch reports failure instead of omitting its tee', () => { @@ -59,6 +61,7 @@ test('a short branch reports failure instead of omitting its tee', () => { null, null, profile, + useScene.getState().nodes, ) expect(plan?.validationMessage).toBeTruthy() expect(plan?.ducts).toHaveLength(0) diff --git a/packages/nodes/src/duct-segment/selection.tsx b/packages/nodes/src/duct-segment/selection.tsx index 8e1ce9dc2b..8ea6ea0ac8 100644 --- a/packages/nodes/src/duct-segment/selection.tsx +++ b/packages/nodes/src/duct-segment/selection.tsx @@ -48,6 +48,7 @@ import { withAutoOffsetTag, withoutAutoOffsetTag, } from '../shared/auto-offset-tag' +import { planRunEndCapFollowUpdates } from '../shared/automatic-run-end-cap' import { detectFittingEndpoint, type FittingEndpoint, @@ -399,11 +400,27 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj duct.wallAttachment && wall?.type === 'wall' ? refreshWallRunAttachment(path, duct.wallAttachment, wall) : duct.wallAttachment + const withEndCapFollow = ( + path: Point[], + updates: { id: AnyNodeId; data: Partial }[], + ) => { + if (drag.index !== 0 && drag.index !== drag.initialPath.length - 1) return updates + const endpoint = drag.index === 0 ? 'start' : 'end' + const nextDuct = { ...duct, path } as DuctSegmentNode + const capUpdates = planRunEndCapFollowUpdates( + duct, + nextDuct, + endpoint, + useScene.getState().nodes, + ) + const capIds = new Set(capUpdates.map((update) => update.id)) + return [...updates.filter((update) => !capIds.has(update.id)), ...capUpdates] + } if (!detached && drag.fittingEndpoint) { const plan = planFittingEndpointReaim(drag.fittingEndpoint, drag.index, next) // Out of the fitting's buildable range β€” hold this frame. if (!plan) return null - return [ + return withEndCapFollow(plan.path, [ { id: duct.id as AnyNodeId, data: { path: plan.path, wallAttachment: attachmentFor(plan.path) }, @@ -425,13 +442,14 @@ const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Obj }, ] : []), - ] + ]) } const path = drag.initialPath.map((p, i) => (i === drag.index ? next : p)) as Point[] - return [ + const updates = [ { id: duct.id as AnyNodeId, data: { path, wallAttachment: attachmentFor(path) } }, ...(detached ? [] : connectivityUpdatesForPath(drag.connectivity, path)), ] + return detached ? updates : withEndCapFollow(path, updates) } /** World-space position of a local path point. */ diff --git a/packages/nodes/src/duct-segment/tool.tsx b/packages/nodes/src/duct-segment/tool.tsx index c9888f877e..12691ff135 100644 --- a/packages/nodes/src/duct-segment/tool.tsx +++ b/packages/nodes/src/duct-segment/tool.tsx @@ -1,8 +1,13 @@ 'use client' -import { type AnyNode, type DuctFittingNode, DuctSegmentNode, useScene } from '@pascal-app/core' -import { EDITOR_LAYER, triggerSFX, useEditor, usePathDraftPreview } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' +import { type AnyNode, type DuctFittingNode, DuctSegmentNode } from '@pascal-app/core' +import { + EDITOR_LAYER, + triggerSFX, + useEditor, + usePathDraftPreview, + useRegistryToolContext, +} from '@pascal-app/editor' import { useEffect, useMemo, useRef, useState } from 'react' import { Euler, type Group, Vector3 } from 'three' import { getDuctFittingPorts } from '../duct-fitting/ports' @@ -12,6 +17,11 @@ import { planElbowRealign, planTeeAtRunBody, } from '../shared/auto-fitting' +import { + createDuctRunEndCap, + findMatedRunEndCapIds, + isRunEndCapPort, +} from '../shared/automatic-run-end-cap' import { ConnectionFeedback } from '../shared/connection-feedback' import { createRunWallAttachment, type RunSurfaceTarget } from '../shared/distribution-run-contract' import { @@ -28,13 +38,13 @@ import { DuctSegmentGhost, FittingGhost } from '../shared/mep-ghost' import { collectScenePorts, DUCT_PORT_SYSTEMS, - findNearestPort3D, findNearestRunBody3D, findRunBodyCrossingSurface, type RunBodyHit, type ScenePort, } from '../shared/ports' -import { RunHangerPreview, RunHangerToggle } from '../shared/run-hanger-controls' +import { RunHangerPreview } from '../shared/run-hanger-controls' +import { useRunHangerMode } from '../shared/run-hanger-mode' import { currentDuctContinuationSeed, ductEndpointPort } from './continuation' import { ductSegmentDefinition } from './definition' import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry' @@ -67,18 +77,13 @@ import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry' * vertical mouse motion drives Y. Click commits the riser segment. * - **[ / ]** step the duct diameter through nominal US sizes; the * ghost preview and the committed node both use it. - * - Esc clears an anchored start point. + * - Esc exits drawing. */ /** * Nominal US round-duct sizes (inches): 4"–10" in 1" steps, 12"+ in 2" * steps β€” matches what flex and rigid round actually ship in. */ const DUCT_DIAMETERS_IN = [4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18, 20] as const -/** Snap radius (meters) for joining onto an existing duct's start/end. */ -const ENDPOINT_SNAP_RADIUS_M = 0.5 -/** Snap radius (meters) for tapping the SIDE of an existing run β€” a tee - * is minted there. Tighter than the port radius so run ends keep - * priority near their last stretch. */ const BODY_SNAP_RADIUS_M = 0.35 /** Angle step (radians) for the XZ angle lock β€” 45Β°. */ @@ -92,9 +97,12 @@ const BODY_SNAP_RADIUS_M = 0.35 * there). Null when the port doesn't carry a rect orientation. Shared * by the ghost preview and the commit so what you see is what lands. */ -function continuityRollFrom(port: ScenePort | null, newDir: Vector3): number | null { +function continuityRollFrom( + port: ScenePort | null, + newDir: Vector3, + nodes: Readonly>, +): number | null { if (!port) return null - const nodes = useScene.getState().nodes const owner = nodes[port.nodeId] if (owner?.type === 'duct-fitting' && ['reducer', 'transition'].includes(owner.fittingType)) { const width = new Vector3(0, 0, 1).applyEuler(new Euler(...owner.rotation)) @@ -157,16 +165,19 @@ function continuityRollForRun( startPort: ScenePort | null, endPort: ScenePort | null, dir: Vector3, + nodes: Readonly>, ): number { - return continuityRollFrom(startPort, dir) ?? continuityRollFrom(endPort, dir) ?? 0 + return continuityRollFrom(startPort, dir, nodes) ?? continuityRollFrom(endPort, dir, nodes) ?? 0 } -function findNearbyPort(point: [number, number, number]): ScenePort | null { - const ports = collectScenePorts({ +function getConnectionPorts( + levelId: AnyNode['id'] | null, + nodes: Readonly>, +): ScenePort[] { + return collectScenePorts({ systems: DUCT_PORT_SYSTEMS, - levelId: useViewer.getState().selection.levelId ?? undefined, - }) - return findNearestPort3D(point, ports, ENDPOINT_SNAP_RADIUS_M) + levelId: levelId ?? undefined, + }).filter((port) => !isRunEndCapPort(port, nodes)) } /** Cross-section shared by the drawn run and its fitting preview. */ @@ -183,8 +194,11 @@ type DraftProfile = { * run / fitting collar keeps its diameter. Equipment and terminal * collars are round at the port's advertised size. */ -function inheritProfile(port: ScenePort): DraftProfile | null { - const owner = useScene.getState().nodes[port.nodeId] +function inheritProfile( + port: ScenePort, + nodes: Readonly>, +): DraftProfile | null { + const owner = nodes[port.nodeId] if (!owner) return null if (owner.type === 'duct-segment' || owner.type === 'duct-fitting') { return { @@ -232,17 +246,19 @@ type DuctDrawPlan = { ducts: DuctSegmentNode[] tails: DuctSegmentNode[] updates: { id: AnyNode['id']; data: Partial }[] + delete: AnyNode['id'][] } const elbowPlanFor = ( port: ScenePort | null, awayDir: [number, number, number], profile: DraftProfile, + nodes: Readonly>, ) => { if (!port) return null - const owner = useScene.getState().nodes[port.nodeId] + const owner = nodes[port.nodeId] if (owner?.type !== 'duct-segment') return null - const source = inheritProfile(port) ?? profile + const source = inheritProfile(port, nodes) ?? profile const plan = planElbowAtPort(port, awayDir, source) if (!plan) return null // Trim the run's snapped endpoint back to the elbow's inlet collar. @@ -264,9 +280,13 @@ const elbowPlanFor = ( } } -const realignPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => { +const realignPlanFor = ( + port: ScenePort | null, + awayDir: [number, number, number], + nodes: Readonly>, +) => { if (!port) return null - const owner = useScene.getState().nodes[port.nodeId] + const owner = nodes[port.nodeId] if (owner?.type !== 'duct-fitting') return null return planElbowRealign(owner, port.id, awayDir) } @@ -277,8 +297,8 @@ const realignPlanFor = (port: ScenePort | null, awayDir: [number, number, number * tap), decide every node the commit creates / updates β€” auto-inserted * elbows / tees / crosses, the drawn run (split in two when it crosses a * trunk), trunk tails, and trim / realign updates. Reads the live scene - * graph but mutates nothing, so the live preview can call it each frame - * to ghost the fittings before the commit applies the identical plan. + * graph snapshot but mutates nothing, so the live preview can call it each + * frame to ghost the fittings before the commit applies the identical plan. */ export function planDuctDraw( start: [number, number, number], @@ -288,9 +308,10 @@ export function planDuctDraw( endPort: ScenePort | null, endBody: RunBodyHit | null, profile: DraftProfile, + nodes: Readonly>, surface?: RunSurfaceTarget | null, autoHangers = false, - toolDefaults = useEditor.getState().toolDefaults['duct-segment'] ?? {}, + toolDefaults: Partial = {}, hangerStyle: 'single' | 'double' = 'single', ): DuctDrawPlan | null { const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2]) @@ -301,26 +322,27 @@ export function planDuctDraw( (end[2] - start[2]) / length, ] - const startPlan = elbowPlanFor(startPort, dir, profile) - const endPlan = elbowPlanFor(endPort, [-dir[0], -dir[1], -dir[2]], profile) + const startPlan = elbowPlanFor(startPort, dir, profile, nodes) + const endPlan = elbowPlanFor(endPort, [-dir[0], -dir[1], -dir[2]], profile, nodes) const invalidPlan = (): DuctDrawPlan => ({ validationMessage: FITTING_CLEARANCE_MESSAGE, fittings: [], ducts: [], tails: [], updates: [], + delete: [], }) if (startPlan?.hasClearance === false || endPlan?.hasClearance === false) return invalidPlan() - const startRealign = startPlan ? null : realignPlanFor(startPort, dir) - const endRealign = endPlan ? null : realignPlanFor(endPort, [-dir[0], -dir[1], -dir[2]]) + const startRealign = startPlan ? null : realignPlanFor(startPort, dir, nodes) + const endRealign = endPlan ? null : realignPlanFor(endPort, [-dir[0], -dir[1], -dir[2]], nodes) const trunkBody = startPlan ? null : startBody - const trunkOwner = trunkBody ? useScene.getState().nodes[trunkBody.nodeId] : null + const trunkOwner = trunkBody ? nodes[trunkBody.nodeId] : null const teePlan = trunkBody && trunkOwner?.type === 'duct-segment' ? planTeeAtRunBody(trunkOwner, trunkBody, dir, profile) : null const endTrunkBody = endPlan || endRealign ? null : endBody - const endTrunkOwner = endTrunkBody ? useScene.getState().nodes[endTrunkBody.nodeId] : null + const endTrunkOwner = endTrunkBody ? nodes[endTrunkBody.nodeId] : null const endTeePlan = endTrunkBody && endTrunkOwner?.type === 'duct-segment' ? planTeeAtRunBody(endTrunkOwner, endTrunkBody, [-dir[0], -dir[1], -dir[2]], profile) @@ -331,12 +353,12 @@ export function planDuctDraw( away: [number, number, number], ) => { if (!port) return null - const source = inheritProfile(port) + const source = inheritProfile(port, nodes) if (!source) return null const axis = new Vector3(...away) if (!corner && axis.dot(new Vector3(...port.direction).normalize()) < 0.9999) return null - const owner = useScene.getState().nodes[port.nodeId] - const roll = continuityRollFrom(port, axis) ?? 0 + const owner = nodes[port.nodeId] + const roll = continuityRollFrom(port, axis, nodes) ?? 0 const width = rectSectionAxes(axis, roll).width if (owner?.type === 'duct-fitting' && !corner) { width.set(0, 0, 1).applyEuler(new Euler(...owner.rotation)) @@ -354,7 +376,7 @@ export function planDuctDraw( [startPort, startAdapter], [endPort, endAdapter], ] as const) { - const source = port ? inheritProfile(port) : null + const source = port ? inheritProfile(port, nodes) : null if (source && !ductProfilesMatch(source, profile) && !adapter) { return { ...invalidPlan(), @@ -383,7 +405,7 @@ export function planDuctDraw( const crossHit = surface ? findRunBodyCrossingSurface(start, end, BODY_SNAP_RADIUS_M, surface) : null - const crossOwner = crossHit ? useScene.getState().nodes[crossHit.nodeId] : null + const crossOwner = crossHit ? nodes[crossHit.nodeId] : null const crossTappedElsewhere = crossHit?.nodeId === trunkBody?.nodeId || crossHit?.nodeId === endTrunkBody?.nodeId const cross = @@ -410,7 +432,7 @@ export function planDuctDraw( let roll = 0 if (profile.shape !== 'round') { const newDir = new Vector3(...dir) - roll = continuityRollForRun(startPort, endPort, newDir) + roll = continuityRollForRun(startPort, endPort, newDir, nodes) } const defaults = ductSegmentDefinition.defaults() @@ -458,38 +480,37 @@ export function planDuctDraw( ...(cross ? [cross.trunkUpdate as { id: AnyNode['id']; data: Partial }] : []), ...realigns.map((p) => p.update as { id: AnyNode['id']; data: Partial }), ] + const deleteIds = Array.from( + new Set([ + ...findMatedRunEndCapIds(startPort, nodes, 'duct-fitting'), + ...findMatedRunEndCapIds(endPort, nodes, 'duct-fitting'), + ]), + ) + const firstDuct = ducts[0] + const nextDuct = ducts.at(-1) + const startEndCap = + !startPort && !startBody && firstDuct ? createDuctRunEndCap(firstDuct, 'start') : null + const nextEndCap = !endPort && !endBody && nextDuct ? createDuctRunEndCap(nextDuct) : null + if (startEndCap) fittings.push(startEndCap) + if (nextEndCap) fittings.push(nextEndCap) - return { validationMessage: null, fittings, ducts, tails, updates } + return { validationMessage: null, fittings, ducts, tails, updates, delete: deleteIds } } const DuctSegmentTool = () => { - const activeLevelId = useViewer((state) => state.selection.levelId) - const unit = useViewer((state) => state.unit) + const { activeLevelId, sceneApi, unit } = useRegistryToolContext() const cursorRef = useRef(null) const continuationSeedRef = useRef(currentDuctContinuationSeed()) const continuationSeed = continuationSeedRef.current const hangerDefaults = useEditor((state) => state.toolDefaults['duct-segment']) - const autoHangers = Boolean( - hangerDefaults?.autoHangers ?? continuationSeed?.duct.autoHangers ?? false, + const initialAutoHangersRef = useRef( + Boolean(hangerDefaults?.autoHangers ?? continuationSeed?.duct.autoHangers ?? false), ) - const setAutoHangers = (enabled: boolean) => { - const editor = useEditor.getState() - editor.setToolDefaults('duct-segment', { - ...editor.toolDefaults['duct-segment'], - autoHangers: enabled, - }) - } + const autoHangers = useRunHangerMode((state) => state.enabled['duct-segment']) const hangerStyle = (hangerDefaults?.hangerStyle ?? continuationSeed?.duct.hangerStyle) === 'double' ? 'double' : 'single' - const setHangerStyle = (style: 'single' | 'double') => { - const editor = useEditor.getState() - editor.setToolDefaults('duct-segment', { - ...editor.toolDefaults['duct-segment'], - hangerStyle: style, - }) - } const hangerStyleRef = useRef<'single' | 'double'>(hangerStyle) hangerStyleRef.current = hangerStyle const autoHangersRef = useRef(autoHangers) @@ -509,6 +530,11 @@ const DuctSegmentTool = () => { }) const profileRef = useRef(profile) profileRef.current = profile + useEffect(() => { + const mode = useRunHangerMode.getState() + mode.setEnabled('duct-segment', initialAutoHangersRef.current) + return () => mode.setEnabled('duct-segment', false) + }, []) const run = useDistributionRunTool({ active: !!activeLevelId, levelId: activeLevelId, @@ -523,7 +549,7 @@ const DuctSegmentTool = () => { initialConnection: continuationSeed ? { port: continuationSeed.port, body: continuationSeed.body } : null, - findPort: findNearbyPort, + getPorts: () => getConnectionPorts(activeLevelId, sceneApi.nodes()), findBody: (point) => findNearestRunBody3D(point, BODY_SNAP_RADIUS_M, { levelId: activeLevelId ?? undefined }), surfaceClearance: (surface) => @@ -537,7 +563,7 @@ const DuctSegmentTool = () => { minimumSegmentLength: 0.08, inheritFromConnection: ({ port }) => { if (!port) return - const inherited = inheritProfile(port) + const inherited = inheritProfile(port, sceneApi.nodes()) if (inherited) setProfile(inherited) }, commit: ({ start, end, startConnection, endConnection, surfaceTarget }) => { @@ -551,9 +577,10 @@ const DuctSegmentTool = () => { endConnection.port, endConnection.body, profileRef.current, + sceneApi.nodes(), surfaceTarget, autoHangersRef.current, - undefined, + hangerDefaults, hangerStyleRef.current, ) if (!plan || plan.validationMessage) return null @@ -575,7 +602,8 @@ const DuctSegmentTool = () => { } const ducts = plan.ducts.map(attachDuct) const tails = plan.tails - useScene.getState().applyNodeChanges({ + if (!sceneApi.applyChanges) throw new Error('Registry SceneApi must support atomic changes') + sceneApi.applyChanges({ create: [ ...plan.fittings.map((node) => ({ node, parentId: activeLevelId })), ...tails.map((node) => ({ node, parentId: activeLevelId })), @@ -601,6 +629,7 @@ const DuctSegmentTool = () => { : []), ...plan.updates, ], + delete: plan.delete, }) pendingPromotionRef.current = null const nextDuct = plan.ducts.at(-1) @@ -633,6 +662,10 @@ const DuctSegmentTool = () => { shape: current.shape === 'round' ? 'rect' : current.shape === 'rect' ? 'oval' : 'round', })) triggerSFX('sfx:grid-snap') + } else if (event.key === 'h' || event.key === 'H') { + event.preventDefault() + useRunHangerMode.getState().toggle('duct-segment') + triggerSFX('sfx:grid-snap') } }, }) @@ -647,6 +680,7 @@ const DuctSegmentTool = () => { run.endConnection.port, run.endConnection.body, profile, + sceneApi.nodes(), run.surfaceTarget, autoHangers, hangerDefaults, @@ -663,6 +697,7 @@ const DuctSegmentTool = () => { run.startConnection, run.endConnection, run.surfaceTarget, + sceneApi, ]) const ghostFittings = useMemo(() => previewPlan?.fittings ?? [], [previewPlan]) @@ -701,19 +736,6 @@ const DuctSegmentTool = () => { }} /> 0 - ? 'Ceiling top' - : 'Ceiling underside' - : run.surfaceTarget?.kind === 'floor' - ? 'Floor' - : run.surfaceTarget - ? 'Surface' - : 'Free space' - } altActive={run.altActive} cursor={run.cursor} cursorRef={cursorRef} @@ -724,16 +746,9 @@ const DuctSegmentTool = () => { onDirectionSelect={run.onDirectionSelect} validationMessage={previewPlan?.validationMessage ?? run.validationMessage} snapTarget={run.snapTarget} + snapScreen={run.snapScreen} start={run.start} startDirection={run.startConnection.port?.direction ?? null} - status={ - - } unit={unit} /> {run.start && ( diff --git a/packages/nodes/src/elevator/panel.tsx b/packages/nodes/src/elevator/panel.tsx index f716e1b3c9..49964283c9 100644 --- a/packages/nodes/src/elevator/panel.tsx +++ b/packages/nodes/src/elevator/panel.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, type ElevatorNode, ElevatorNode as ElevatorNodeSchema, + getLevelDisplayName, type LevelNode, requestElevatorLevel, useInteractive, @@ -596,7 +597,7 @@ export default function ElevatorPanel() { > {levels.map((level) => ( ))} @@ -613,7 +614,7 @@ export default function ElevatorPanel() { > {levels.map((level) => ( ))} @@ -631,7 +632,7 @@ export default function ElevatorPanel() { > {defaultLevelOptions.map((level) => ( ))} @@ -810,9 +811,7 @@ export default function ElevatorPanel() { className="flex items-center justify-between gap-2 rounded-lg border border-border/45 bg-[#2C2C2E] px-2.5 py-2" key={level.id} > - - {level.name || `Level ${level.level}`} - + {getLevelDisplayName(level)}
- {enabled && ( - - )} - - ) -} - export function RunHangerPreview({ run, levelId }: { run: SupportedRun; levelId: AnyNodeId }) { const nodes = useScene.getState().nodes const geometry = useMemo(() => { diff --git a/packages/nodes/src/shared/run-hanger-mode.test.ts b/packages/nodes/src/shared/run-hanger-mode.test.ts new file mode 100644 index 0000000000..d094f85a54 --- /dev/null +++ b/packages/nodes/src/shared/run-hanger-mode.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { ductSegmentDefinition } from '../duct-segment/definition' +import { pipeSegmentDefinition } from '../pipe-segment/definition' +import { createRunHangerToolHint, useRunHangerMode } from './run-hanger-mode' + +beforeEach(() => { + useRunHangerMode.setState({ + enabled: { + 'duct-segment': false, + 'pipe-segment': false, + }, + }) +}) + +describe('run hanger mode', () => { + test('toggles each run tool independently', () => { + const ductHint = createRunHangerToolHint('duct-segment') + const pipeHint = createRunHangerToolHint('pipe-segment') + + expect(ductHint.chip?.value()).toBe('off') + expect(pipeHint.chip?.value()).toBe('off') + + ductHint.chip?.cycle() + + expect(ductHint.chip?.value()).toBe('on') + expect(pipeHint.chip?.value()).toBe('off') + }) + + test('registers the live H shortcut on duct and DWV drawing tools', () => { + for (const definition of [ductSegmentDefinition, pipeSegmentDefinition]) { + const hint = definition.toolHints?.find((candidate) => candidate.key === 'H') + expect(hint?.chip?.labels).toEqual({ + off: 'Auto hangers: Off', + on: 'Auto hangers: On', + }) + } + }) + + test('notifies the contextual helper when the active value changes', () => { + const hint = createRunHangerToolHint('pipe-segment') + let changes = 0 + const unsubscribe = hint.chip?.subscribe(() => { + changes += 1 + }) + + useRunHangerMode.getState().toggle('duct-segment') + useRunHangerMode.getState().toggle('pipe-segment') + unsubscribe?.() + + expect(changes).toBe(1) + }) +}) diff --git a/packages/nodes/src/shared/run-hanger-mode.ts b/packages/nodes/src/shared/run-hanger-mode.ts new file mode 100644 index 0000000000..f40bedd3e2 --- /dev/null +++ b/packages/nodes/src/shared/run-hanger-mode.ts @@ -0,0 +1,45 @@ +import type { ToolHint } from '@pascal-app/core' +import { create } from 'zustand' + +export type RunHangerTool = 'duct-segment' | 'pipe-segment' + +type RunHangerModeState = { + enabled: Record + setEnabled: (tool: RunHangerTool, enabled: boolean) => void + toggle: (tool: RunHangerTool) => void +} + +export const useRunHangerMode = create((set) => ({ + enabled: { + 'duct-segment': false, + 'pipe-segment': false, + }, + setEnabled: (tool, enabled) => + set((state) => ({ enabled: { ...state.enabled, [tool]: enabled } })), + toggle: (tool) => + set((state) => ({ enabled: { ...state.enabled, [tool]: !state.enabled[tool] } })), +})) + +export function createRunHangerToolHint(tool: RunHangerTool): ToolHint { + return { + key: 'H', + label: 'Auto hangers', + chip: { + subscribe: (onChange) => + useRunHangerMode.subscribe((state, previous) => { + if (state.enabled[tool] !== previous.enabled[tool]) onChange() + }), + value: () => (useRunHangerMode.getState().enabled[tool] ? 'on' : 'off'), + cycle: () => useRunHangerMode.getState().toggle(tool), + labels: { + off: 'Auto hangers: Off', + on: 'Auto hangers: On', + }, + icons: { + off: 'lucide:toggle-left', + on: 'lucide:toggle-right', + }, + tooltip: 'Auto hangers - click or press H to toggle', + }, + } +} diff --git a/packages/nodes/src/shared/run-port-snap.test.ts b/packages/nodes/src/shared/run-port-snap.test.ts new file mode 100644 index 0000000000..f2f7cac083 --- /dev/null +++ b/packages/nodes/src/shared/run-port-snap.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from 'bun:test' +import { PerspectiveCamera, Vector3 } from 'three' +import type { ScenePort } from './ports' +import { findScreenPort } from './run-port-snap' + +const low: ScenePort = { + nodeId: 'pipe-segment_low', + id: 'end', + position: [0, 0, 0], + direction: [1, 0, 0], +} +const high: ScenePort = { + nodeId: 'duct-segment_high', + id: 'start', + position: [0, 3, 0], + direction: [1, 0, 0], +} +const camera = new PerspectiveCamera(50, 1, 0.1, 100) +camera.position.set(0, 2, 10) +camera.lookAt(0, 2, 0) +camera.updateMatrixWorld() +const project = (point: ScenePort['position']) => { + const projected = new Vector3(...point).project(camera) + if (projected.z < -1 || projected.z > 1) return null + return { x: (projected.x + 1) * 400, y: (1 - projected.y) * 400, depth: projected.z } +} + +test('picks an elevated socket under the pointer independently of the drawing plane', () => { + const screen = project(high.position)! + const hit = findScreenPort([low, high], [screen.x + 5, screen.y - 3], project, null) + expect(hit?.port).toBe(high) + expect(hit?.port.position).toEqual([0, 3, 0]) +}) + +test('uses a pixel threshold rather than attracting distant sockets', () => { + const screen = project(high.position)! + expect(findScreenPort([high], [screen.x + 17, screen.y], project, null)).toBeNull() +}) + +test('excludes the source before ranking and keeps other sockets on that item eligible', () => { + const other = { ...high, id: 'end' } + const screen = project(high.position)! + expect(findScreenPort([high, other], [screen.x, screen.y], project, high)?.port).toBe(other) +}) + +test('ignores occluded, clipped and invalid projections', () => { + const behind = { ...high, position: [0, 2, 20] as const } + expect(findScreenPort([behind], [400, 400], project, null)).toBeNull() + expect(findScreenPort([high], [400, 400], () => null, null)).toBeNull() + expect(findScreenPort([high], [NaN, NaN], project, null)).toBeNull() +}) + +test('chooses the front socket when projected points coincide', () => { + const far = { ...high, id: 'far' } + const near = { ...high, position: [0, 3, 1] as const } + expect( + findScreenPort( + [far, near], + [10, 10], + (point) => ({ + x: 10, + y: 10, + depth: point[2] === 1 ? 1 : 5, + }), + null, + )?.port, + ).toBe(near) +}) + +test('supports a rotated and zoomed floorplan projection while retaining socket elevation', () => { + const planProject = (point: ScenePort['position']) => ({ + x: -point[2] * 40 + 300, + y: point[0] * 40 + 200, + depth: 0, + }) + expect(findScreenPort([high], [302, 203], planProject, null)?.port.position[1]).toBe(3) +}) diff --git a/packages/nodes/src/shared/run-port-snap.ts b/packages/nodes/src/shared/run-port-snap.ts new file mode 100644 index 0000000000..f3d3d269a8 --- /dev/null +++ b/packages/nodes/src/shared/run-port-snap.ts @@ -0,0 +1,25 @@ +import type { ScenePort } from './ports' + +export type PortScreenPoint = { x: number; y: number; depth: number } + +export function findScreenPort( + ports: readonly ScenePort[], + pointer: readonly [number, number], + project: (point: ScenePort['position']) => PortScreenPoint | null, + source: Pick | null, + radius = 16, +): { port: ScenePort; screen: PortScreenPoint } | null { + let best: { port: ScenePort; screen: PortScreenPoint } | null = null + let distance = radius + for (const port of ports) { + if (source?.nodeId === port.nodeId && source.id === port.id) continue + const screen = project(port.position) + if (!screen) continue + const nextDistance = Math.hypot(screen.x - pointer[0], screen.y - pointer[1]) + if (!Number.isFinite(nextDistance) || nextDistance > distance) continue + if (best && nextDistance === distance && screen.depth >= best.screen.depth) continue + best = { port, screen } + distance = nextDistance + } + return best +} diff --git a/packages/nodes/src/shared/run-screen-direction.test.ts b/packages/nodes/src/shared/run-screen-direction.test.ts new file mode 100644 index 0000000000..940c41e738 --- /dev/null +++ b/packages/nodes/src/shared/run-screen-direction.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from 'bun:test' +import { chooseScreenDirection, screenDirectionScore } from './run-screen-direction' + +test('picks the visually closest guide including diagonals', () => { + const pointer = [75, -60] as const + const scores = [ + [100, 0], + [70, -70], + [0, -100], + ].map((tip) => screenDirectionScore([0, 0], tip as [number, number], pointer)) + expect(chooseScreenDirection(scores, -1)).toBe(1) +}) + +test('small jitter keeps the guide but deliberate movement switches', () => { + expect(chooseScreenDirection([12, 10], 0)).toBe(0) + expect(chooseScreenDirection([20, 10], 0)).toBe(1) +}) + +test('camera-facing and backwards guides do not capture the cursor', () => { + expect(screenDirectionScore([0, 0], [0.1, 0.2], [50, 50])).toBe(Infinity) + expect(screenDirectionScore([0, 0], [-100, 0], [50, 0])).toBe(Infinity) + expect(chooseScreenDirection([Infinity, Infinity], 0)).toBe(-1) +}) diff --git a/packages/nodes/src/shared/run-screen-direction.ts b/packages/nodes/src/shared/run-screen-direction.ts new file mode 100644 index 0000000000..e658c00278 --- /dev/null +++ b/packages/nodes/src/shared/run-screen-direction.ts @@ -0,0 +1,30 @@ +type Point2 = readonly [number, number] + +export function screenDirectionScore(origin: Point2, tip: Point2, pointer: Point2): number { + const dx = tip[0] - origin[0], + dy = tip[1] - origin[1] + const length = Math.hypot(dx, dy) + if (length < 2) return Infinity + const px = pointer[0] - origin[0], + py = pointer[1] - origin[1] + const distance = Math.hypot(px, py) + if (px * dx + py * dy <= 0 || distance < 4) return Infinity + return ( + Math.hypot(px / distance - dx / length, py / distance - dy / length) * Math.min(distance, 100) + ) +} + +export function chooseScreenDirection(scores: readonly number[], previous: number): number { + let best = -1 + for (let i = 0; i < scores.length; i++) { + if (Number.isFinite(scores[i]) && (best < 0 || scores[i]! < scores[best]!)) best = i + } + if ( + best >= 0 && + previous >= 0 && + Number.isFinite(scores[previous]) && + scores[previous]! <= scores[best]! + 4 + ) + return previous + return best +} diff --git a/packages/nodes/src/shared/system-check-panel.tsx b/packages/nodes/src/shared/system-check-panel.tsx index f5aa1980bc..a55609ee74 100644 --- a/packages/nodes/src/shared/system-check-panel.tsx +++ b/packages/nodes/src/shared/system-check-panel.tsx @@ -8,9 +8,30 @@ import { summarizeSystemFor, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useMemo, useState } from 'react' +import { + AlertTriangle, + CheckCircle2, + ChevronDown, + ChevronRight, + Crosshair, + ShieldCheck, + XCircle, +} from 'lucide-react' +import { useId, useMemo, useState } from 'react' import { checkDistributionSystems } from './system-checks' +const CATEGORIES = [ + { id: 'all', label: 'All findings' }, + { id: 'open-end', label: 'Open ends' }, + { id: 'connection-mismatch', label: 'Connections' }, + { id: 'disconnected-branch', label: 'Separate branches' }, + { id: 'possible-intersection', label: 'Intersections' }, + { id: 'drainage', label: 'Drainage' }, + { id: 'unsupported-hanger', label: 'Hangers' }, +] as const +const categoryFor = (code: string) => + ['slope-too-flat', 'slope-too-steep', 'trap-arm-too-long'].includes(code) ? 'drainage' : code + export default function SystemCheckPanel({ nodeId, nodes, @@ -18,6 +39,7 @@ export default function SystemCheckPanel({ nodeId: AnyNodeId nodes: Record }) { + const contentId = useId() const [open, setOpen] = useState(false) const [filter, setFilter] = useState('all') const summary = useMemo( @@ -25,6 +47,11 @@ export default function SystemCheckPanel({ [nodeId, nodes, open], ) const findings = useMemo(() => (open ? checkDistributionSystems(nodes) : []), [nodes, open]) + const visibleFindings = findings.filter( + (finding) => filter === 'all' || categoryFor(finding.code) === filter, + ) + const errorCount = findings.filter((finding) => finding.severity === 'error').length + const warningCount = findings.length - errorCount const reveal = (id: AnyNodeId) => { const node = nodes[id] if (!node) return @@ -48,86 +75,167 @@ export default function SystemCheckPanel({ emitter.emit('selection:find-node', node) } return ( -
+
{open && ( - <> - {summary && ( -

- Selected system: {summary.runCount} runs Β· {summary.runLengthM.toFixed(2)} m -

- )} -

- Checks cover all distribution systems. Open ends and separate branches may be - intentional. Intersection warnings use bounding boxes; inspect openings and clearances. -

- -
    - {findings - .filter( - (finding) => - filter === 'all' || - finding.code === filter || - (filter === 'drainage' && - ['slope-too-flat', 'slope-too-steep', 'trap-arm-too-long'].includes( - finding.code, - )), - ) - .map((finding, index) => ( -
  • - - {finding.nodeIds.map((id) => ( - - ))} -
  • +
    +
    + All systems + {summary && ( + + Selected: {summary.runCount} {summary.runCount === 1 ? 'run' : 'runs'} Β·{' '} + {summary.runLengthM.toFixed(1)} m + + )} +
    +
    +
    +
    +
    +
    +
    +
    + +
    + {visibleFindings.length ? ( +
      + {visibleFindings.map((finding, index) => { + const error = finding.severity === 'error' + const Icon = error ? XCircle : AlertTriangle + const label = + CATEGORIES.find((category) => category.id === categoryFor(finding.code))?.label ?? + 'System finding' + return ( +
    • +
      +
      +

      + {finding.message} +

      +
      + {finding.nodeIds.map((id) => ( + + ))} +
      +
    • + ) + })} +
    + ) : ( +
    +
    )} - +
    + About these checks +

    + Open ends and separate branches may be intentional. Intersection checks use bounding + boxes; inspect openings and clearances. +

    +
    +
    )}
) diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 21c58c622d..440ab835af 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -18,11 +18,21 @@ import { horizonHazeColor, NodeRenderer, useNodeEvents, + useSceneAtmosphere, + useSceneGroundReplacement, useViewer, } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import { BufferAttribute, BufferGeometry, type Group, Path, Shape, ShapeGeometry } from 'three' -import { cameraPosition, color, float, mix, positionWorld, smoothstep, vec2 } from 'three/tsl' +import { + cameraPosition, + color, + mix, + positionWorld, + smoothstep, + float as tslFloat, + vec2, +} from 'three/tsl' import { MeshLambertNodeMaterial } from 'three/webgpu' import { getRecessedSlabGroundHoles } from './recessed-slab-ground-holes' import { @@ -127,6 +137,8 @@ function addSlabHoles( export const SiteRenderer = ({ node }: { node: SiteNode }) => { const ref = useRef(null!) + const atmosphere = useSceneAtmosphere() + const groundReplaced = useSceneGroundReplacement() useRegistry(node.id, 'site', ref) @@ -191,40 +203,44 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { return material }, [bgColor]) - // Presentation horizon: a large ground disc under the lot, in the same - // theme ground colour, fading radially into the theme background so the - // scene sits on an "infinite" plane that dissolves into the sky instead of - // a hard-edged plate floating on the backdrop. Never pickable. + // Presentation horizon: a large ground disc under the lot, fading into the + // active fog radiance (or the theme backdrop when there is no atmosphere) so + // the scene sits on an "infinite" plane instead of a hard-edged plate. Never pickable. const horizonMaterial = useMemo(() => { - if (!fadeBounds) return null + if (!fadeBounds || groundReplaced) return null const material = new MeshLambertNodeMaterial({ color: bgColor }) const center = vec2(fadeBounds.cx, fadeBounds.cz) const dist = positionWorld.xz.sub(center).length() - const fade = smoothstep(float(fadeBounds.radius * 1.05), float(fadeBounds.radius * 5), dist) + const fade = smoothstep( + tslFloat(fadeBounds.radius * 1.05), + tslFloat(fadeBounds.radius * 5), + dist, + ) // Contact vignette: a soft darkening that hugs the lot so the parcel // reads as sitting on the ground instead of floating on an even field. // The linear cut competes with the tone mapper's shoulder β€” bright themes // (studio's key light runs at intensity 4) compress a fixed 15% to almost // nothing β€” so the strength scales with the theme's strongest light. const vignetteStrength = Math.min(0.45, 0.13 * maxLightIntensity) - const halo = float(1) - .sub(smoothstep(float(fadeBounds.radius * 0.95), float(fadeBounds.radius * 2.6), dist)) + const halo = tslFloat(1) + .sub(smoothstep(tslFloat(fadeBounds.radius * 0.95), tslFloat(fadeBounds.radius * 2.6), dist)) .mul(vignetteStrength) - const haloFactor = float(1).sub(halo) + const haloFactor = tslFloat(1).sub(halo) material.colorNode = mix(color(bgColor), color('#000000'), fade).mul(haloFactor) - // Dissolve, not tint: the albedo (lighting response, incl. shadows) fades - // to black while an emissive term fades up to the backdrop gradient β€” the - // exact formula the post pipeline composites (viewer lib/backdrop.ts), - // evaluated with this fragment's view direction, so the far end is - // literally the backdrop (incl. the horizon haze) from any camera pose. - const viewDirY = positionWorld.sub(cameraPosition).normalize().y - const backdrop = backdropGradient({ - dirY: viewDirY, - background: color(backgroundColor), - haze: color(horizonHazeColor(skyColor, appearance)), - sky: color(skyColor), - skyDeep: color(deepSkyColor(skyColor)), - }) + // Dissolve, not tint: albedo fades to black while emissive fades up to the + // exact active horizon source, evaluated with this fragment's world-space + // view direction. fogRadiance excludes celestial discs and stars so they + // cannot leave bright spots around the ground seam. + const viewDir = positionWorld.sub(cameraPosition).normalize() + const backdrop = atmosphere + ? atmosphere.fogRadiance(viewDir) + : backdropGradient({ + dirY: viewDir.y, + background: color(backgroundColor), + haze: color(horizonHazeColor(skyColor, appearance)), + sky: color(skyColor), + skyDeep: color(deepSkyColor(skyColor)), + }) // The halo also scales the in-band emissive: the dissolve starts at 1.05R, // so without it the (bright) backdrop dilutes the vignette exactly where // it should read. halo is 0 past 2.6R while the dissolve completes at 5R, @@ -238,7 +254,16 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { material.polygonOffsetFactor = 2 material.polygonOffsetUnits = 2 return material - }, [bgColor, backgroundColor, skyColor, appearance, maxLightIntensity, fadeBounds]) + }, [ + atmosphere, + bgColor, + backgroundColor, + skyColor, + appearance, + maxLightIntensity, + fadeBounds, + groundReplaced, + ]) // Cache computed polygons to keep the selector stable across unrelated store updates. const slabPolygonsCache = useRef<[number, number][][]>([]) @@ -271,7 +296,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { // // biome-ignore lint/correctness/useExhaustiveDependencies: `terrainKey` is the grid signature the footprint is a function of; depending on the field itself would rebuild an 800 m disc every dab. const horizonGeometry = useMemo(() => { - if (!fadeBounds) return null + if (!fadeBounds || groundReplaced) return null const radius = Math.max(fadeBounds.radius * 8, 400) const shape = new Shape() const segments = 64 @@ -284,8 +309,9 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { const holes = terrainGrid ? [...slabPolygons, terrainFootprint(terrainGrid)] : slabPolygons addSlabHoles(shape, holes, fadeBounds.cx, fadeBounds.cz) return new ShapeGeometry(shape) - }, [fadeBounds, slabPolygons, terrainKey]) + }, [fadeBounds, slabPolygons, terrainKey, groundReplaced]) useEffect(() => () => horizonGeometry?.dispose(), [horizonGeometry]) + useEffect(() => () => horizonMaterial?.dispose(), [horizonMaterial]) // Boundary line geometry, subdivided against the terrain grid when there is one. // diff --git a/packages/nodes/src/site/terrain-geometry.test.ts b/packages/nodes/src/site/terrain-geometry.test.ts index 81ecef056a..d94f4e883c 100644 --- a/packages/nodes/src/site/terrain-geometry.test.ts +++ b/packages/nodes/src/site/terrain-geometry.test.ts @@ -3,6 +3,7 @@ import { applyHeightPatch, createTerrainField, flattenPatch, + type HeightPatch, type TerrainField, } from '@pascal-app/core' import { @@ -208,6 +209,128 @@ describe('updateTerrainMesh β€” the dirty-rect path', () => { }) }) +describe('updateTerrainMesh β€” local brush acceptance', () => { + // The established full builder is the oracle, not the dirty updater's algorithm. + // Non-binary spacing/origin also exercise rounding in world-space normal sampling. + test.each([ + ['interior', 5, 3, 2, 3], + ['top edge', 4, 0, 3, 1], + ['bottom edge', 4, 8, 3, 1], + ['left edge', 0, 3, 1, 3], + ['right edge', 12, 3, 1, 3], + ['clipped top-left', -2, -1, 4, 3], + ['clipped bottom-right', 11, 7, 4, 4], + ['full-width strip', 0, 4, 13, 1], + ['full-height strip', 6, 0, 1, 9], + ] as const)('matches the full builder at %s', (_name, col0, row0, cols, rows) => { + const before: TerrainField = { + ...createTerrainField({ cols: 13, rows: 9, origin: [-11.17, 3.29], spacing: 0.3 }), + heights: Int16Array.from({ length: 13 * 9 }, (_, i) => { + const c = i % 13 + const r = Math.floor(i / 13) + return ((c * 71 + r * 113 + c * r * 19) % 601) - 300 + }), + } + const patch: HeightPatch = { + col0, + row0, + cols, + rows, + heights: Int16Array.from({ length: cols * rows }, (_, i) => (i % 2 ? -1703 : 2309) + i), + } + const originalHeights = before.heights.slice() + const originalPatch = patch.heights.slice() + const after = applyHeightPatch(before, patch) + const mesh = buildTerrainMesh(before) + const references = { ...mesh } + const full = buildTerrainMesh(after) + + updateTerrainMesh(after, mesh, patch) + + expect(mesh.positions).toEqual(full.positions) + for (let i = 0; i < full.normals.length; i++) { + expect(mesh.normals[i]).toBeCloseTo(full.normals[i]!, 6) + } + expect(mesh.uvs).toEqual(full.uvs) + expect(mesh.indices).toEqual(full.indices) + for (const name of ['positions', 'normals', 'uvs', 'indices'] as const) { + expect(mesh[name]).toBe(references[name]) + } + expect(before.heights).toEqual(originalHeights) + expect(patch.heights).toEqual(originalPatch) + expect(after.heights).not.toBe(before.heights) + }) + + test('does not rewrite XZ, UVs, clean Y values, or normals outside the one-cell halo', () => { + const before = rampField(0.3, 17, 11, 0.5) + const patch: HeightPatch = { + col0: 7, + row0: 4, + cols: 2, + rows: 2, + heights: new Int16Array([300, -400, 500, -600]), + } + const after = applyHeightPatch(before, patch) + const mesh = buildTerrainMesh(before) + const expected = buildTerrainMesh(after) + // Distinct, finite sentinels on forbidden destinations expose redundant writes + // of the old value, without requiring a particular loop or typed-array API. + for (let r = 0; r < before.rows; r++) { + for (let c = 0; c < before.cols; c++) { + const i = r * before.cols + c + const dirty = c >= 7 && c <= 8 && r >= 4 && r <= 5 + const halo = c >= 6 && c <= 9 && r >= 3 && r <= 6 + for (let axis = 0; axis < 3; axis++) { + const index = i * 3 + axis + if (axis !== 1 || !dirty) { + mesh.positions[index] = expected.positions[index] = 10000 + index + } + if (!halo) mesh.normals[index] = expected.normals[index] = -10000 - index + } + } + } + mesh.uvs.fill(12345) + expected.uvs.fill(12345) + const references = { ...mesh } + + updateTerrainMesh(after, mesh, patch) + + expect(mesh.positions).toEqual(expected.positions) + expect(mesh.normals).toEqual(expected.normals) + expect(mesh.uvs).toEqual(expected.uvs) + expect(mesh.indices).toEqual(expected.indices) + for (const name of ['positions', 'normals', 'uvs', 'indices'] as const) { + expect(mesh[name]).toBe(references[name]) + } + }) + + test.each([ + ['left', -2, 3], + ['right', 9, 3], + ['above', 3, -2], + ['below', 3, 9], + ['diagonally outside', 9, 9], + ] as const)('does not rewrite buffers for a patch wholly %s', (_name, col0, row0) => { + const field = createTerrainField({ cols: 9, rows: 9 }) + const mesh = buildTerrainMesh(field) + mesh.positions.fill(12345) + mesh.normals.fill(-12345) + mesh.uvs.fill(6789) + const expected = { + positions: mesh.positions.slice(), + normals: mesh.normals.slice(), + uvs: mesh.uvs.slice(), + indices: mesh.indices.slice(), + } + const patch: HeightPatch = { col0, row0, cols: 2, rows: 2, heights: new Int16Array(4) } + + updateTerrainMesh(field, mesh, patch) + + expect(mesh).toEqual(expected) + expect(patchUpdateRange(field, patch, 3)).toBeNull() + }) +}) + describe('buildTerrainSkirt β€” the horizon seam', () => { /** Top/bottom Y of the ring position at index `i`. */ function span(positions: Float32Array, i: number): { top: number; bottom: number } { diff --git a/packages/nodes/src/site/terrain-geometry.ts b/packages/nodes/src/site/terrain-geometry.ts index ef1048c4e5..75d782d076 100644 --- a/packages/nodes/src/site/terrain-geometry.ts +++ b/packages/nodes/src/site/terrain-geometry.ts @@ -19,7 +19,7 @@ import { type HeightPatch, heightAtSample, normalAt, type TerrainField } from '@ * (~1.5 ms at 257Β²), `computeVertexNormals()` rewrites the *whole* normal * attribute, which silently defeats the dirty-rect partial upload that makes * sculpting cheap. `normalAt` central-differences the field instead, so a - * patch touches only the rows it changed. + * patch touches only its footprint and the neighbouring normals. * - **Vertices are one-per-sample and shared between triangles**, so a partial * upload is a contiguous row range. Duplicating verts per-triangle for flat * shading would triple memory and scatter the dirty range. @@ -303,9 +303,18 @@ export function patchUpdateRange( patch: HeightPatch, itemSize: number, ): { start: number; count: number } | null { + if ( + patch.cols <= 0 || + patch.rows <= 0 || + patch.col0 >= field.cols || + patch.row0 >= field.rows || + patch.col0 + patch.cols <= 0 || + patch.row0 + patch.rows <= 0 + ) { + return null + } const firstRow = Math.max(0, patch.row0 - 1) const lastRow = Math.min(field.rows - 1, patch.row0 + patch.rows) - if (lastRow < firstRow) return null const startVertex = firstRow * field.cols const endVertex = lastRow * field.cols + (field.cols - 1) @@ -328,12 +337,33 @@ export function updateTerrainMesh( buffers: TerrainMeshBuffers, patch: HeightPatch, ): void { - const firstRow = Math.max(0, patch.row0 - 1) - const lastRow = Math.min(field.rows - 1, patch.row0 + patch.rows) - for (let r = firstRow; r <= lastRow; r++) { - for (let c = 0; c < field.cols; c++) { - const i = r * field.cols + c - writeVertex(field, buffers.positions, buffers.normals, buffers.uvs, c, r, i) + const col0 = Math.max(0, patch.col0) + const row0 = Math.max(0, patch.row0) + const col1 = Math.min(field.cols, patch.col0 + patch.cols) + const row1 = Math.min(field.rows, patch.row0 + patch.rows) + if (col0 >= col1 || row0 >= row1) return + + for (let r = row0; r < row1; r++) { + for (let c = col0; c < col1; c++) { + buffers.positions[(r * field.cols + c) * 3 + 1] = heightAtSample(field, c, r) + } + } + + // Uploads may span whole rows, but CPU work only needs the changed samples + // and the one-cell halo used by central-difference normals. + const firstCol = Math.max(0, col0 - 1) + const firstRow = Math.max(0, row0 - 1) + const lastCol = Math.min(field.cols, col1 + 1) + const lastRow = Math.min(field.rows, row1 + 1) + for (let r = firstRow; r < lastRow; r++) { + const z = field.origin[1] + r * field.spacing + for (let c = firstCol; c < lastCol; c++) { + const x = field.origin[0] + c * field.spacing + const [nx, ny, nz] = normalAt(field, x, z) + const i = (r * field.cols + c) * 3 + buffers.normals[i] = nx + buffers.normals[i + 1] = ny + buffers.normals[i + 2] = nz } } } diff --git a/packages/nodes/src/site/terrain-mesh.test.ts b/packages/nodes/src/site/terrain-mesh.test.ts index 963b50627e..9dd3b45581 100644 --- a/packages/nodes/src/site/terrain-mesh.test.ts +++ b/packages/nodes/src/site/terrain-mesh.test.ts @@ -3,10 +3,11 @@ import { applyHeightPatch, createTerrainField, flattenPatch, + type HeightPatch, type TerrainField, } from '@pascal-app/core' -import type { BufferAttribute } from 'three' -import { buildTerrainMesh } from './terrain-geometry' +import type { BufferAttribute, BufferGeometry } from 'three' +import { buildTerrainMesh, buildTerrainSkirt } from './terrain-geometry' import { applyTerrainPatch, createTerrainGeometry, @@ -19,26 +20,6 @@ function attr(geometry: { getAttribute: (n: string) => unknown }, name: string): } describe('createTerrainGeometry', () => { - test('wires all four attributes with the right item sizes', () => { - const field = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) - const target = createTerrainGeometry(field) - expect(attr(target.geometry, 'position').itemSize).toBe(3) - expect(attr(target.geometry, 'normal').itemSize).toBe(3) - expect(attr(target.geometry, 'uv').itemSize).toBe(2) - expect(target.geometry.getIndex()).not.toBeNull() - expect(target.geometry.getIndex()?.count).toBe(8 * 8 * 6) - disposeTerrainGeometry(target) - }) - - test('the attributes are backed by the same arrays as the CPU buffers', () => { - // The dirty-rect path mutates `buffers` in place and expects the GPU-side - // attribute to see it. If these ever diverge, patches would silently no-op. - const target = createTerrainGeometry(createTerrainField({ cols: 5, rows: 5, spacing: 1 })) - expect(attr(target.geometry, 'position').array).toBe(target.buffers.positions) - expect(attr(target.geometry, 'normal').array).toBe(target.buffers.normals) - disposeTerrainGeometry(target) - }) - test('bounds cover the field extent without calling computeBoundingSphere', () => { const field: TerrainField = { ...createTerrainField({ cols: 5, rows: 5, spacing: 2 }), @@ -104,19 +85,35 @@ describe('applyTerrainPatch', () => { disposeTerrainGeometry(target) }) - test('does not accumulate ranges across a stroke of many dabs', () => { - // The failure this guards: 60 dabs/second each appending a range would make - // every subsequent frame re-upload all of them. + test('uploads every dab when multiple terrain patches precede one frame', () => { let field = createTerrainField({ cols: 33, rows: 33, spacing: 0.5 }) const target = createTerrainGeometry(field) - for (let i = 0; i < 12; i++) { - const patch = flattenPatch(field, { minX: i, minZ: 2, maxX: i + 1, maxZ: 3 }, i * 0.1) - if (!patch) continue - field = applyHeightPatch(field, patch) - applyTerrainPatch(target, field, patch) + const uploadedPositions = target.buffers.positions.slice() + const uploadedNormals = target.buffers.normals.slice() + try { + for (const [x, z, height] of [ + [1, 1, 2], + [10, 12, -3], + ] as const) { + const patch = flattenPatch(field, { minX: x, minZ: z, maxX: x + 1, maxZ: z + 1 }, height)! + field = applyHeightPatch(field, patch) + applyTerrainPatch(target, field, patch) + } + for (const [name, uploaded] of [ + ['position', uploadedPositions], + ['normal', uploadedNormals], + ] as const) { + const attribute = attr(target.geometry, name) + for (const { start, count } of attribute.updateRanges) { + uploaded.set((attribute.array as Float32Array).subarray(start, start + count), start) + } + } + const expected = buildTerrainMesh(field) + expect(Array.from(uploadedPositions)).toEqual(Array.from(expected.positions)) + expect(Array.from(uploadedNormals)).toEqual(Array.from(expected.normals)) + } finally { + disposeTerrainGeometry(target) } - expect(attr(target.geometry, 'position').updateRanges).toHaveLength(1) - disposeTerrainGeometry(target) }) test('the patched buffers match a full rebuild', () => { @@ -162,6 +159,219 @@ describe('applyTerrainPatch', () => { }) }) +describe('applyTerrainPatch β€” conservative bounds and locality', () => { + function expectEnclosed(geometry: BufferGeometry, positions: Float32Array): void { + const sphere = geometry.boundingSphere + expect(sphere).not.toBeNull() + expect(Number.isFinite(sphere!.radius)).toBe(true) + for (let i = 0; i < positions.length; i += 3) { + const distance = Math.hypot( + positions[i]! - sphere!.center.x, + positions[i + 1]! - sphere!.center.y, + positions[i + 2]! - sphere!.center.z, + ) + // All fixtures stay below 400 m: 0.1 mm covers Float32 vertex rounding, + // not a stale bound. Tightness is deliberately not an acceptance criterion. + expect(distance).toBeLessThanOrEqual(sphere!.radius + 0.0001) + } + } + + test.each([ + -1200, 900, + ])('surface and skirt stay enclosed through new and removed extremes (initial height %i)', (initialHeight) => { + let field: TerrainField = { + ...createTerrainField({ cols: 9, rows: 7, origin: [-3.17, 2.29], spacing: 0.3 }), + heights: new Int16Array(9 * 7).fill(initialHeight), + } + const target = createTerrainGeometry(field) + const surfaceReferences = { ...target.buffers } + const skirtReferences = { ...target.skirt.buffers } + const positionAttribute = attr(target.geometry, 'position') + const normalAttribute = attr(target.geometry, 'normal') + try { + expectEnclosed(target.geometry, target.buffers.positions) + expectEnclosed(target.skirt.geometry, target.skirt.buffers.positions) + for (const [col0, row0, height] of [ + [4, 3, 32767], + [8, 6, -32768], + [4, 3, 0], + [8, 6, 0], + [0, 0, 31000], + [0, 0, 0], + [8, 0, -32000], + [8, 0, 0], + ] as const) { + const patch: HeightPatch = { + col0, + row0, + cols: 1, + rows: 1, + heights: new Int16Array([height]), + } + const previous = field + const previousHeights = field.heights.slice() + field = applyHeightPatch(field, patch) + const expectedHeights = field.heights.slice() + applyTerrainPatch(target, field, patch) + + const surface = buildTerrainMesh(field) + const skirt = buildTerrainSkirt(field) + expect(target.buffers.positions).toEqual(surface.positions) + for (let i = 0; i < surface.normals.length; i++) { + expect(target.buffers.normals[i]).toBeCloseTo(surface.normals[i]!, 6) + } + expect(target.skirt.buffers.positions).toEqual(skirt.positions) + expectEnclosed(target.geometry, surface.positions) + expectEnclosed(target.skirt.geometry, skirt.positions) + expect(previous.heights).toEqual(previousHeights) + expect(field.heights).toEqual(expectedHeights) + expect(field.heights).not.toBe(previous.heights) + for (const name of ['positions', 'normals', 'uvs', 'indices'] as const) { + expect(target.buffers[name]).toBe(surfaceReferences[name]) + } + for (const name of ['positions', 'normals', 'indices'] as const) { + expect(target.skirt.buffers[name]).toBe(skirtReferences[name]) + } + expect(attr(target.geometry, 'position')).toBe(positionAttribute) + expect(attr(target.geometry, 'normal')).toBe(normalAttribute) + } + } finally { + disposeTerrainGeometry(target) + } + }) + + test.each([ + ['top-left', -1, -1], + ['bottom-right', 7, 5], + ] as const)('clipped %s patches preserve the surface, skirt, and safe bounds', (_name, col0, row0) => { + const before = createTerrainField({ cols: 9, rows: 7, origin: [-3.17, 2.29], spacing: 0.3 }) + const patch: HeightPatch = { + col0, + row0, + cols: 3, + rows: 3, + heights: Int16Array.from({ length: 9 }, (_, i) => (i % 2 ? -32768 : 32767)), + } + const after = applyHeightPatch(before, patch) + const target = createTerrainGeometry(before) + try { + applyTerrainPatch(target, after, patch) + const surface = buildTerrainMesh(after) + const skirt = buildTerrainSkirt(after) + expect(target.buffers.positions).toEqual(surface.positions) + for (let i = 0; i < surface.normals.length; i++) { + expect(target.buffers.normals[i]).toBeCloseTo(surface.normals[i]!, 6) + } + expect(target.skirt.buffers.positions).toEqual(skirt.positions) + expectEnclosed(target.geometry, surface.positions) + expectEnclosed(target.skirt.geometry, skirt.positions) + } finally { + disposeTerrainGeometry(target) + } + }) + + test.each([65, 257])('a tiny interior patch does not scan a %i-square heightfield', (size) => { + const before = createTerrainField({ cols: size, rows: size, spacing: 0.5 }) + const center = Math.floor(size / 2) + const patch: HeightPatch = { + col0: center, + row0: center, + cols: 1, + rows: 1, + heights: new Int16Array([-3000]), + } + const after = applyHeightPatch(before, patch) + const target = createTerrainGeometry(before) + const visited = new Set() + // Observe accesses, not elapsed time. Native bulk reads count the entire view; + // subarray narrows that view. Geometry creation and immutable copying are excluded. + function observe(samples: Int16Array, offset = 0): Int16Array { + return new Proxy(samples, { + get(array, key) { + if (typeof key === 'string' && /^\d+$/.test(key)) visited.add(offset + Number(key)) + if (key === 'subarray') { + return (begin?: number, end?: number) => { + const view = array.subarray(begin, end) + return observe( + view, + offset + (view.byteOffset - array.byteOffset) / array.BYTES_PER_ELEMENT, + ) + } + } + const value = Reflect.get(array, key, array) + if (typeof value !== 'function') return value + return (...args: unknown[]) => { + for (let i = 0; i < array.length; i++) visited.add(offset + i) + return Reflect.apply(value, array, args) + } + }, + }) + } + try { + applyTerrainPatch(target, { ...after, heights: observe(after.heights) }, patch) + // A generous 9x9 neighborhood allows different local normal stencils and + // repeated passes, but rejects full rows and full-field extrema scans. + expect(visited.size).toBeLessThanOrEqual(81) + for (const i of visited) { + expect(Math.abs((i % size) - center)).toBeLessThanOrEqual(4) + expect(Math.abs(Math.floor(i / size) - center)).toBeLessThanOrEqual(4) + } + expect(target.buffers.positions).toEqual(buildTerrainMesh(after).positions) + expectEnclosed(target.geometry, target.buffers.positions) + expectEnclosed(target.skirt.geometry, target.skirt.buffers.positions) + } finally { + disposeTerrainGeometry(target) + } + }) + + test.each([ + ['left', -2, 3], + ['right', 9, 3], + ['above', 3, -2], + ['below', 3, 9], + ['diagonally outside', 9, 9], + ] as const)('a patch wholly %s preserves pending uploads and bounds', (_name, col0, row0) => { + const before = createTerrainField({ cols: 9, rows: 9 }) + const pending: HeightPatch = { + col0: 3, + row0: 3, + cols: 1, + rows: 1, + heights: new Int16Array([1200]), + } + const field = applyHeightPatch(before, pending) + const target = createTerrainGeometry(before) + try { + applyTerrainPatch(target, field, pending) + const geometries = [target.geometry, target.skirt.geometry] + const snapshot = () => + geometries.map((geometry) => ({ + sphere: geometry.boundingSphere!.clone(), + attributes: Object.entries(geometry.attributes).map(([name]) => { + const attribute = attr(geometry, name) + return { + name, + version: attribute.version, + ranges: attribute.updateRanges.map((range) => ({ ...range })), + values: attribute.array.slice(), + } + }), + })) + const expected = snapshot() + applyTerrainPatch(target, field, { + col0, + row0, + cols: 2, + rows: 2, + heights: new Int16Array(4).fill(-32000), + }) + expect(snapshot()).toEqual(expected) + } finally { + disposeTerrainGeometry(target) + } + }) +}) + describe('needsRebuild', () => { test('false for the field it was built from', () => { const field = createTerrainField({ cols: 17, rows: 17, spacing: 0.5 }) diff --git a/packages/nodes/src/site/terrain-mesh.ts b/packages/nodes/src/site/terrain-mesh.ts index 11666b93db..250473785a 100644 --- a/packages/nodes/src/site/terrain-mesh.ts +++ b/packages/nodes/src/site/terrain-mesh.ts @@ -1,5 +1,5 @@ import type { HeightPatch, TerrainField } from '@pascal-app/core' -import { BufferAttribute, BufferGeometry, Sphere, Vector3 } from 'three' +import { BufferAttribute, BufferGeometry, DynamicDrawUsage, Sphere } from 'three' import { buildTerrainMesh, buildTerrainSkirt, @@ -32,6 +32,8 @@ import { export type TerrainGeometry = { readonly geometry: BufferGeometry readonly buffers: TerrainMeshBuffers + /** Expands during live edits; a committed-field rebuild tightens it again. */ + readonly heightBounds: { minY: number; maxY: number } /** The edge curtain that closes the field against the horizon disc. */ readonly skirt: { readonly geometry: BufferGeometry; readonly buffers: TerrainSkirtBuffers } } @@ -39,23 +41,41 @@ export type TerrainGeometry = { export function createTerrainGeometry(field: TerrainField): TerrainGeometry { const buffers = buildTerrainMesh(field) const geometry = new BufferGeometry() - geometry.setAttribute('position', new BufferAttribute(buffers.positions, 3)) - geometry.setAttribute('normal', new BufferAttribute(buffers.normals, 3)) + geometry.setAttribute( + 'position', + new BufferAttribute(buffers.positions, 3).setUsage(DynamicDrawUsage), + ) + geometry.setAttribute( + 'normal', + new BufferAttribute(buffers.normals, 3).setUsage(DynamicDrawUsage), + ) geometry.setAttribute('uv', new BufferAttribute(buffers.uvs, 2)) geometry.setIndex(new BufferAttribute(buffers.indices, 1)) - setTerrainBounds(geometry, field) + const span = heightSpan(field) + setTerrainBounds(geometry, field, span) // A separate geometry, not extra vertices on the surface: the surface's dirty // range is a row span over a `cols * rows` layout, and appending a perimeter ring // to it would break that indexing for a saving of one draw call. const skirtBuffers = buildTerrainSkirt(field) const skirtGeometry = new BufferGeometry() - skirtGeometry.setAttribute('position', new BufferAttribute(skirtBuffers.positions, 3)) - skirtGeometry.setAttribute('normal', new BufferAttribute(skirtBuffers.normals, 3)) + skirtGeometry.setAttribute( + 'position', + new BufferAttribute(skirtBuffers.positions, 3).setUsage(DynamicDrawUsage), + ) + skirtGeometry.setAttribute( + 'normal', + new BufferAttribute(skirtBuffers.normals, 3).setUsage(DynamicDrawUsage), + ) skirtGeometry.setIndex(new BufferAttribute(skirtBuffers.indices, 1)) - setSkirtBounds(skirtGeometry, field) + setSkirtBounds(skirtGeometry, field, span) - return { geometry, buffers, skirt: { geometry: skirtGeometry, buffers: skirtBuffers } } + return { + geometry, + buffers, + heightBounds: span, + skirt: { geometry: skirtGeometry, buffers: skirtBuffers }, + } } /** @@ -71,31 +91,50 @@ export function applyTerrainPatch( field: TerrainField, patch: HeightPatch, ): void { - updateTerrainMesh(field, target.buffers, patch) - - // The skirt goes up unconditionally, before the range bail: a patch that leaves - // the surface's dirty span empty can still have moved a boundary sample, and a - // stale skirt is a hole at the property line. - updateTerrainSkirt(field, target.skirt.buffers) - for (const name of ['position', 'normal'] as const) { - const attribute = target.skirt.geometry.getAttribute(name) as BufferAttribute - attribute.needsUpdate = true - } - setSkirtBounds(target.skirt.geometry, field) - const range = patchUpdateRange(field, patch, 3) if (!range) return - + updateTerrainMesh(field, target.buffers, patch) + if ( + patch.col0 <= 0 || + patch.row0 <= 0 || + patch.col0 + patch.cols >= field.cols || + patch.row0 + patch.rows >= field.rows + ) { + updateTerrainSkirt(field, target.skirt.buffers) + for (const name of ['position', 'normal'] as const) { + const attribute = target.skirt.geometry.getAttribute(name) as BufferAttribute + attribute.needsUpdate = true + } + } for (const name of ['position', 'normal'] as const) { const attribute = target.geometry.getAttribute(name) as BufferAttribute - // `clearUpdateRanges` first: ranges accumulate, so a stroke dabbing 60 times - // a second would otherwise grow an unbounded list that all gets re-uploaded. + let start = range.start + let end = start + range.count + // Only the renderer knows when an upload happened. Keep all dabs since then. + for (const pending of attribute.updateRanges) { + start = Math.min(start, pending.start) + end = Math.max(end, pending.start + pending.count) + } attribute.clearUpdateRanges() - attribute.addUpdateRange(range.start, range.count) + attribute.addUpdateRange(start, end - start) attribute.needsUpdate = true } - - setTerrainBounds(target.geometry, field) + // Keep bounds conservative while brushing: removing an old extreme need not + // rescan the field, while a new extreme must become visible immediately. + const span = target.heightBounds + const col0 = Math.max(0, patch.col0) + const row0 = Math.max(0, patch.row0) + const col1 = Math.min(field.cols, patch.col0 + patch.cols) + const row1 = Math.min(field.rows, patch.row0 + patch.rows) + for (let row = row0; row < row1; row++) { + for (let col = col0; col < col1; col++) { + const height = (field.heights[row * field.cols + col] ?? 0) * field.step + span.minY = Math.min(span.minY, height) + span.maxY = Math.max(span.maxY, height) + } + } + setTerrainBounds(target.geometry, field, span) + setSkirtBounds(target.skirt.geometry, field, span) } /** @@ -103,23 +142,28 @@ export function applyTerrainPatch( * * The computed version walks every vertex twice and allocates; the field's * horizontal extent is known in O(1) from `origin`/`spacing`/`cols`/`rows`, and - * only the height range needs a scan. Skipping bounds entirely is not an option β€” + * the height range is scanned once, then expanded from patches. Skipping bounds + * entirely is not an option β€” * a stale bounding sphere gets a raised hill frustum-culled while it is still on * screen, which reads as terrain flickering out at certain camera angles. */ -function setTerrainBounds(geometry: BufferGeometry, field: TerrainField): void { - const { minY, maxY } = heightSpan(field) +function setTerrainBounds( + geometry: BufferGeometry, + field: TerrainField, + { minY, maxY }: { minY: number; maxY: number }, +): void { const minX = field.origin[0] const minZ = field.origin[1] const maxX = minX + (field.cols - 1) * field.spacing const maxZ = minZ + (field.rows - 1) * field.spacing - const center = new Vector3((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2) - const radius = Math.hypot((maxX - minX) / 2, (maxY - minY) / 2, (maxZ - minZ) / 2) - - geometry.boundingSphere = new Sphere(center, radius) + geometry.boundingSphere ??= new Sphere() + const sphere = geometry.boundingSphere + sphere.center.set((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2) + sphere.radius = Math.hypot((maxX - minX) / 2, (maxY - minY) / 2, (maxZ - minZ) / 2) if (geometry.boundingBox) { - geometry.boundingBox.set(new Vector3(minX, minY, minZ), new Vector3(maxX, maxY, maxZ)) + geometry.boundingBox.min.set(minX, minY, minZ) + geometry.boundingBox.max.set(maxX, maxY, maxZ) } } @@ -131,8 +175,11 @@ function setTerrainBounds(geometry: BufferGeometry, field: TerrainField): void { * avoid, one geometry over. The vertical span brackets the horizon plane for the * same reason the geometry does. */ -function setSkirtBounds(geometry: BufferGeometry, field: TerrainField): void { - const { minY, maxY } = heightSpan(field) +function setSkirtBounds( + geometry: BufferGeometry, + field: TerrainField, + { minY, maxY }: { minY: number; maxY: number }, +): void { const low = Math.min(minY, HORIZON_PLANE_Y) - 1 const high = Math.max(maxY, HORIZON_PLANE_Y) @@ -141,9 +188,10 @@ function setSkirtBounds(geometry: BufferGeometry, field: TerrainField): void { const maxX = minX + (field.cols - 1) * field.spacing const maxZ = minZ + (field.rows - 1) * field.spacing - const center = new Vector3((minX + maxX) / 2, (low + high) / 2, (minZ + maxZ) / 2) - const radius = Math.hypot((maxX - minX) / 2, (high - low) / 2, (maxZ - minZ) / 2) - geometry.boundingSphere = new Sphere(center, radius) + geometry.boundingSphere ??= new Sphere() + const sphere = geometry.boundingSphere + sphere.center.set((minX + maxX) / 2, (low + high) / 2, (minZ + maxZ) / 2) + sphere.radius = Math.hypot((maxX - minX) / 2, (high - low) / 2, (maxZ - minZ) / 2) } /** Height span in metres, always including the datum so flat ground has a box. */ diff --git a/packages/nodes/src/skylight/panel.tsx b/packages/nodes/src/skylight/panel.tsx index 8630dfba9b..8ab9ce7dd5 100644 --- a/packages/nodes/src/skylight/panel.tsx +++ b/packages/nodes/src/skylight/panel.tsx @@ -297,7 +297,7 @@ export default function SkylightPanel() { restoreOnCommit={false} step={0.001} unit="m" - value={Math.round((node.glassThickness ?? 0.01) * 1000) / 1000} + value={node.glassThickness ?? 0.01} /> {activeSkylightType === 'lantern' && ( <> @@ -311,7 +311,7 @@ export default function SkylightPanel() { restoreOnCommit={false} step={0.01} unit="m" - value={Math.round((node.lanternHeight ?? 0.25) * 1000) / 1000} + value={node.lanternHeight ?? 0.25} /> )} @@ -419,7 +419,7 @@ export default function SkylightPanel() { restoreOnCommit={false} step={0.005} unit="m" - value={Math.round((node.trackWidth ?? 0.045) * 1000) / 1000} + value={node.trackWidth ?? 0.045} /> )} @@ -436,7 +436,7 @@ export default function SkylightPanel() { restoreOnCommit={false} step={0.05} unit="m" - value={Math.round(node.width * 100) / 100} + value={node.width} /> @@ -463,7 +463,7 @@ export default function SkylightPanel() { restoreOnCommit={false} step={0.005} unit="m" - value={Math.round((node.frameThickness ?? 0.05) * 1000) / 1000} + value={node.frameThickness ?? 0.05} /> @@ -511,7 +511,7 @@ export default function SkylightPanel() { restoreOnCommit={false} step={0.005} unit="m" - value={Math.round((node.curbHeight ?? 0.1) * 1000) / 1000} + value={node.curbHeight ?? 0.1} /> )} @@ -531,7 +531,7 @@ export default function SkylightPanel() { restoreOnCommit={false} step={0.05} unit="m" - value={Math.round(worldX_now * 100) / 100} + value={worldX_now} /> [x + 40, z]), + }) + const wall = WallNode.parse({ + id: 'wall_tracker', + parentId: level.id, + start: [0, 0], + end: [4, 0], + thickness: 0.2, + }) + const nodes: Record = Object.fromEntries( + [building, level, slab, remote, wall].map((node) => [node.id, node]), + ) + return { nodes, building, level, slab, remote, wall } +} + +describe('slab dependency tracker', () => { + test('marks only the adopted slab for wall edits and restoration', () => { + const { nodes, wall, slab } = fixture() + const changed = createSlabDependencyTracker(nodes) + const next = { ...nodes, [wall.id]: { ...wall, thickness: 0.4 } } + expect(changed(next)).toEqual([slab.id]) + expect(changed(nodes)).toEqual([slab.id]) + expect(changed({ ...nodes })).toEqual([]) + }) + + test('ignores wall changes that do not change any rendered polygon', () => { + const { nodes, wall } = fixture() + const changed = createSlabDependencyTracker(nodes) + expect(changed({ ...nodes, [wall.id]: { ...wall, height: 3 } })).toEqual([]) + const remote = WallNode.parse({ + id: 'wall_remote', + parentId: wall.parentId, + start: [80, 0], + end: [84, 0], + }) + expect(changed({ ...nodes, [remote.id]: remote })).toEqual([]) + }) + + test('tracks sibling creation, floating classification, elevation and deletion', () => { + const { nodes, level, slab, wall } = fixture() + const sibling = SlabNode.parse({ + id: 'slab_sibling', + parentId: slab.parentId, + polygon: [ + [0, -4], + [4, -4], + [4, 0], + [0, 0], + ], + elevation: 0.2, + thickness: 0.05, + }) + const changed = createSlabDependencyTracker(nodes) + const withSibling = { + ...nodes, + [level.id]: { ...level, children: [...level.children, sibling.id] }, + } + expect(changed({ ...withSibling, [sibling.id]: sibling })).toEqual([sibling.id]) + const grounded = { ...sibling, thickness: 0.2 } + const joined = { ...withSibling, [sibling.id]: grounded } + expect(changed(joined).sort()).toEqual([slab.id, sibling.id].sort()) + expect(changed({ ...joined, [sibling.id]: { ...grounded, elevation: 0.05 } }).sort()).toEqual( + [slab.id, sibling.id].sort(), + ) + expect(changed(nodes)).toEqual([slab.id]) + expect(wall.thickness).toBe(0.2) + }) + + test('recessed floating siblings become seam partners', () => { + const { nodes, level, slab } = fixture() + const sibling = SlabNode.parse({ + id: 'slab_pool', + parentId: slab.parentId, + polygon: [ + [0, -4], + [4, -4], + [4, 0], + [0, 0], + ], + elevation: 0.2, + }) + const next = { + ...nodes, + [sibling.id]: sibling, + [level.id]: { ...level, children: [...level.children, sibling.id] }, + } + const changed = createSlabDependencyTracker(next) + expect(changed({ ...next, [sibling.id]: { ...sibling, recessed: true } }).sort()).toEqual( + [slab.id, sibling.id].sort(), + ) + }) + + test('curved adopted bands change the local slab only', () => { + const { nodes, slab, wall } = fixture() + const changed = createSlabDependencyTracker(nodes) + expect(changed({ ...nodes, [wall.id]: { ...wall, curveOffset: 0.3 } })).toEqual([slab.id]) + }) + + test('building transforms invalidate terrain fills only', () => { + const { nodes, building, slab } = fixture() + const initial = { ...nodes, [slab.id]: { ...slab, fillToTerrain: true } } + const changed = createSlabDependencyTracker(initial) + expect( + changed({ + ...initial, + [building.id]: { ...building, position: [1, 0, 2] as [number, number, number] }, + }), + ).toEqual([slab.id]) + }) +}) + +test('adopts the first tied wall in level.children order and invalidates reordered membership', () => { + const { nodes, level, wall, slab } = fixture() + const second = WallNode.parse({ ...wall, id: 'wall_second', thickness: 0.4 }) + const initial = { + ...nodes, + [second.id]: second, + [level.id]: { ...level, children: [second.id, ...level.children] }, + } + const changed = createSlabDependencyTracker(initial) + const next = { ...initial, [second.id]: { ...second, thickness: 0.6 } } + expect(changed(next)).toEqual([slab.id]) + expect( + changed({ ...next, [level.id]: { ...level, children: [...level.children, second.id] } }), + ).toEqual([slab.id]) + expect( + changed({ + ...next, + [level.id]: { ...level, children: level.children.filter((id) => id !== wall.id) }, + }), + ).toEqual([slab.id]) +}) + +test('Maxi tracker context matches the renderer membership, order and polygons', () => { + const nodes: Record = Object.fromEntries( + maxi.nodes.map((raw) => { + const node = AnyNode.parse(raw) + return [node.id, node] + }), + ) + const level = nodes[maxi.levelId] as LevelNode + const context = slabPolygonContextForLevel(level, (id) => nodes[id]) + expect(context.walls).toHaveLength(312) + expect(context.siblingSlabs).toHaveLength(72) + const prepared = prepareSlabPolygonContext(context) + for (const slab of context.siblingSlabs) { + const renderer = slabPolygonContextFromGeometry({ + parent: level, + resolve: (id) => nodes[id], + siblings: level.children + .map((id) => nodes[id]) + .filter((node) => node?.type === 'slab' && node.id !== slab.id), + } as GeometryContext) + const tracker = { + walls: context.walls, + siblingSlabs: context.siblingSlabs.filter((node) => node.id !== slab.id), + } + expect(getRenderableSlabPolygon(slab, scopeSlabPolygonContext(slab, prepared))).toEqual( + getRenderableSlabPolygon(slab, renderer), + ) + expect(tracker).toEqual(renderer) + expect(getRenderableSlabPolygon(slab, tracker)).toEqual( + getRenderableSlabPolygon(slab, renderer), + ) + } +}) + +test('unchanged input references skip serialization and equal-value replacements stay clean', () => { + const { nodes, wall, slab } = fixture() + const changed = createSlabDependencyTracker(nodes) + const stringify = spyOn(JSON, 'stringify') + try { + const dirty = changed({ + ...nodes, + unrelated: { ...nodes[slab.id], id: 'ceiling_unrelated', type: 'ceiling' } as AnyNode, + }) + expect(stringify).not.toHaveBeenCalled() + expect(dirty).toEqual([]) + } finally { + stringify.mockRestore() + } + const replaced = Object.fromEntries( + Object.entries(nodes).map(([id, node]) => [id, structuredClone(node)]), + ) + expect(changed(replaced)).toEqual([]) + expect(changed({ ...replaced, [wall.id]: { ...wall, thickness: 0.4 } })).toEqual([slab.id]) +}) + +test('new adoption, deletion and reparenting match the unfiltered renderer', () => { + const { nodes, level, slab, wall } = fixture() + const otherLevel = LevelNode.parse({ id: 'level_other', children: [] }) + let previous = { ...nodes, [otherLevel.id]: otherLevel } + const changed = createSlabDependencyTracker(previous) + const rendered = (snapshot: Record, slab: SlabNode) => { + const siblings = Object.values(snapshot).filter( + (node): node is SlabNode => node.type === 'slab' && node.parentId === slab.parentId, + ) + const context = slabPolygonContextForLevel( + snapshot[slab.parentId!] ?? null, + (id) => snapshot[id], + siblings, + ) + return getRenderableSlabPolygon(slab, { + ...context, + siblingSlabs: context.siblingSlabs.filter((node) => node.id !== slab.id), + }) + } + const verify = (next: Record) => { + const expected = Object.values(next) + .filter((node): node is SlabNode => node.type === 'slab') + .filter((node) => { + const before = previous[node.id] + return ( + before?.type !== 'slab' || + JSON.stringify(rendered(previous, before)) !== JSON.stringify(rendered(next, node)) + ) + }) + .map((node) => node.id) + .sort() + expect(changed(next).sort()).toEqual(expected) + previous = next + } + verify({ ...previous, [wall.id]: { ...wall, start: [0, 20], end: [4, 20] } }) + verify({ ...previous, [wall.id]: { ...wall, thickness: 0.7, curveOffset: 0.2 } }) + const { [wall.id]: _deleted, ...withoutWall } = previous + verify(withoutWall) + verify({ ...previous, [wall.id]: wall }) + verify({ + ...previous, + [slab.id]: { ...slab, parentId: otherLevel.id }, + [level.id]: { ...level, children: level.children.filter((id) => id !== slab.id) }, + [otherLevel.id]: { ...otherLevel, children: [slab.id] }, + }) +}) diff --git a/packages/nodes/src/slab/dependency-tracker.ts b/packages/nodes/src/slab/dependency-tracker.ts new file mode 100644 index 0000000000..563d732721 --- /dev/null +++ b/packages/nodes/src/slab/dependency-tracker.ts @@ -0,0 +1,142 @@ +import { + type AnyNode, + type AnyNodeId, + getRenderableSlabPolygon, + prepareSlabPolygonContext, + type SlabNode, + type SlabPolygonContext, + scopeSlabPolygonContext, + slabPolygonContextChanges, + slabPolygonContextForLevel, +} from '@pascal-app/core' + +type LevelContext = { slabs: SlabNode[] } +type CachedLevel = { + prepared: ReturnType + transform: string + slabs: Map + references: { + level: AnyNode | undefined + building: AnyNode | undefined + slabs: SlabNode[] + context: SlabPolygonContext + } +} + +function sameReferences(left: T[], right: T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +export function createSlabDependencyTracker(initialNodes: Record) { + let previous = new Map() + const nodeInputs = new WeakMap() + const sign = (node: AnyNode): string => { + let value = nodeInputs.get(node) + if (value !== undefined) return value + value = JSON.stringify( + node.type === 'wall' + ? [node.id, node.start, node.end, node.thickness, node.curveOffset] + : node.type === 'slab' + ? [ + node.id, + node.polygon, + node.elevation, + node.thickness, + node.recessed, + node.fillToTerrain, + ] + : null, + ) + nodeInputs.set(node, value) + return value + } + + const update = (nodes: Record): AnyNodeId[] => { + const levels = new Map() + for (const node of Object.values(nodes)) { + if (!node.parentId || node.type !== 'slab') continue + let context = levels.get(node.parentId) + if (!context) { + context = { slabs: [] } + levels.set(node.parentId, context) + } + context.slabs.push(node) + } + + const current = new Map() + const dirty: AnyNodeId[] = [] + for (const [levelId, context] of levels) { + if (context.slabs.length === 0) continue + const level = nodes[levelId] + const polygonContext = slabPolygonContextForLevel( + level ?? null, + (id) => nodes[id], + context.slabs, + ) + const building = level?.parentId ? nodes[level.parentId] : undefined + const cached = previous.get(levelId) + const references = { level, building, slabs: context.slabs, context: polygonContext } + if ( + cached && + cached.references.level === level && + cached.references.building === building && + sameReferences(cached.references.slabs, context.slabs) && + sameReferences(cached.references.context.walls, polygonContext.walls) && + sameReferences(cached.references.context.siblingSlabs, polygonContext.siblingSlabs) + ) { + current.set(levelId, cached) + continue + } + const transform = + building?.type === 'building' ? [building.id, building.position, building.rotation] : null + const transformSignature = JSON.stringify(transform) + const sameValues = (left: T[], right: T[]) => + left.length === right.length && + left.every((node, index) => node === right[index] || sign(node) === sign(right[index]!)) + if ( + cached && + cached.transform === transformSignature && + sameValues(cached.references.context.walls, polygonContext.walls) && + sameValues(cached.references.slabs, context.slabs) && + sameValues(cached.references.context.siblingSlabs, polygonContext.siblingSlabs) + ) { + current.set(levelId, { ...cached, references }) + continue + } + const slabs: CachedLevel['slabs'] = new Map() + const prepared = prepareSlabPolygonContext(polygonContext, cached?.prepared) + const affected = cached ? slabPolygonContextChanges(cached.prepared, prepared) : () => true + for (const slab of context.slabs) { + const previousSlab = cached?.slabs.get(slab.id) + if ( + previousSlab && + (previousSlab.node === slab || sign(previousSlab.node) === sign(slab)) && + (!slab.fillToTerrain || slab.recessed || cached?.transform === transformSignature) && + !affected(slab) + ) { + slabs.set(slab.id, previousSlab) + continue + } + const local = scopeSlabPolygonContext(slab, prepared) + const polygon = getRenderableSlabPolygon(slab, local) + // Compare the derived result: remote walls and seams can change the + // level context without changing this slab's geometry. + const signature = JSON.stringify([ + polygon, + slab.elevation, + slab.thickness, + slab.recessed, + slab.fillToTerrain && !slab.recessed ? transform : null, + ]) + slabs.set(slab.id, { node: slab, signature }) + if (previousSlab?.signature !== signature) dirty.push(slab.id) + } + current.set(levelId, { prepared, transform: transformSignature, slabs, references }) + } + previous = current + return dirty + } + + update(initialNodes) + return update +} diff --git a/packages/nodes/src/slab/panel.tsx b/packages/nodes/src/slab/panel.tsx index 22a2eefc13..6ad6f04964 100644 --- a/packages/nodes/src/slab/panel.tsx +++ b/packages/nodes/src/slab/panel.tsx @@ -289,7 +289,7 @@ export function SlabPanel() { precision={3} step={0.01} unit="m" - value={Math.round(node.elevation * 1000) / 1000} + value={node.elevation} /> {node.recessed ? ( @@ -312,7 +312,7 @@ export function SlabPanel() { precision={2} step={0.01} unit="m" - value={Math.round(getSlabRecessDepth(node) * 100) / 100} + value={getSlabRecessDepth(node)} /> ) : ( )} diff --git a/packages/nodes/src/slab/system.tsx b/packages/nodes/src/slab/system.tsx index 0169530c15..d2aec6ac1e 100644 --- a/packages/nodes/src/slab/system.tsx +++ b/packages/nodes/src/slab/system.tsx @@ -1,85 +1,15 @@ 'use client' -import { - type AnyNode, - type AnyNodeId, - type SlabNode, - useScene, - type WallNode, -} from '@pascal-app/core' +import { useScene } from '@pascal-app/core' import { useEffect } from 'react' - -/** - * Slab dependency tracker. The renderable slab polygon derives from level - * context β€” wall centerlines/thickness (exterior flush offsets) and sibling - * slab polygons (interior centerline seams) β€” none of which lives on the slab - * node itself. Store updates only dirty the node that changed, so a wall - * thickness edit, a neighbour slab add/remove/reshape, or a building transform - * would leave stale slab meshes. Watch a per-level signature of those inputs - * and dirty every slab on a level whose signature moved; `GeometrySystem` then - * rebuilds them through `def.geometry` as usual. - */ - -function levelSlabContextSignatures(nodes: Record): Map { - const partsByLevel = new Map() - - const push = (levelId: string, part: string) => { - const parts = partsByLevel.get(levelId) - if (parts) parts.push(part) - else partsByLevel.set(levelId, [part]) - } - - for (const node of Object.values(nodes)) { - const levelId = node.parentId - if (!levelId) continue - if (node.type === 'wall') { - const wall = node as WallNode - push( - levelId, - `w|${wall.id}|${wall.start[0]},${wall.start[1]}|${wall.end[0]},${wall.end[1]}|${wall.thickness ?? ''}|${wall.curveOffset ?? ''}`, - ) - } else if (node.type === 'slab') { - const slab = node as SlabNode - // Elevation is a seam input: an unequal-elevation seam projects to - // the lower side's wall face, so a height change reshapes siblings. - push( - levelId, - `s|${slab.id}|${slab.elevation ?? ''}|${slab.polygon.map(([x, z]) => `${x},${z}`).join(';')}`, - ) - } - } - - for (const node of Object.values(nodes)) { - if (node.type !== 'building') continue - const transform = `${node.position.join(',')}|${node.rotation.join(',')}` - for (const childId of node.children) { - const child = nodes[childId] - if (child?.type === 'level') push(child.id, `b|${node.id}|${transform}`) - } - } - - const signatures = new Map() - for (const [levelId, parts] of partsByLevel.entries()) { - signatures.set(levelId, parts.sort().join('||')) - } - return signatures -} +import { createSlabDependencyTracker } from './dependency-tracker' const SlabSystems = () => { useEffect(() => { - let previous = levelSlabContextSignatures(useScene.getState().nodes) - - return useScene.subscribe((state) => { - const current = levelSlabContextSignatures(state.nodes) - for (const [levelId, signature] of current.entries()) { - if (previous.get(levelId) === signature) continue - for (const node of Object.values(state.nodes)) { - if (node.type === 'slab' && node.parentId === levelId) { - state.markDirty(node.id as AnyNodeId) - } - } - } - previous = current + const changedSlabs = createSlabDependencyTracker(useScene.getState().nodes) + return useScene.subscribe((state, previous) => { + if (state.nodes === previous.nodes) return + for (const id of changedSlabs(state.nodes)) state.markDirty(id) }) }, []) diff --git a/packages/nodes/src/solar-panel/panel.tsx b/packages/nodes/src/solar-panel/panel.tsx index aa7d7c913d..37aee8e087 100644 --- a/packages/nodes/src/solar-panel/panel.tsx +++ b/packages/nodes/src/solar-panel/panel.tsx @@ -255,7 +255,7 @@ export default function SolarPanelPanel() { restoreOnCommit={false} step={0.005} unit="m" - value={Math.round(num(node.gapX, 0.02) * 1000) / 1000} + value={num(node.gapX, 0.02)} /> @@ -286,7 +286,7 @@ export default function SolarPanelPanel() { restoreOnCommit={false} step={0.05} unit="m" - value={Math.round(num(node.panelWidth, 1) * 100) / 100} + value={num(node.panelWidth, 1)} /> @@ -313,7 +313,7 @@ export default function SolarPanelPanel() { restoreOnCommit={false} step={0.005} unit="m" - value={Math.round(num(node.frameThickness, 0.04) * 1000) / 1000} + value={num(node.frameThickness, 0.04)} /> @@ -362,7 +362,7 @@ export default function SolarPanelPanel() { restoreOnCommit={false} step={0.005} unit="m" - value={Math.round(num(node.standoffHeight, 0.05) * 1000) / 1000} + value={num(node.standoffHeight, 0.05)} /> diff --git a/packages/nodes/src/spawn/panel.tsx b/packages/nodes/src/spawn/panel.tsx index de8e49d093..bfdcba9e6d 100644 --- a/packages/nodes/src/spawn/panel.tsx +++ b/packages/nodes/src/spawn/panel.tsx @@ -111,7 +111,7 @@ export default function SpawnPanel() { precision={2} step={0.01} unit="m" - value={Math.round(node.position[0] * 100) / 100} + value={node.position[0]} /> diff --git a/packages/nodes/src/stair-segment/panel.tsx b/packages/nodes/src/stair-segment/panel.tsx index daad0fa4b2..6ccbf20375 100644 --- a/packages/nodes/src/stair-segment/panel.tsx +++ b/packages/nodes/src/stair-segment/panel.tsx @@ -4,6 +4,9 @@ import { type AnyNode, type AnyNodeId, type AttachmentSide, + DEFAULT_LEVEL_HEIGHT, + resolveStairTotalRise, + runAsSingleSceneHistoryStep, type StairSegmentNode, StairSegmentNode as StairSegmentNodeSchema, type StairSegmentType, @@ -67,6 +70,48 @@ export default function StairSegmentPanel() { setSelection({ selectedIds: [] }) }, [setSelection]) + // A follows-level stair would hand the edited height straight back to + // `syncStairRises`, so a flight edit also pins the parent to the new total: + // the stair becomes Custom rise, exactly as editing Rise on its own panel. + const parentFollowsLevel = useScene((s) => { + const parent = node?.parentId ? s.nodes[node.parentId as AnyNodeId] : undefined + return parent?.type === 'stair' && parent.totalRise == null + }) + const handleFlightHeightChange = useCallback( + (height: number) => { + if (!node) return + const sceneNodes = useScene.getState().nodes + const parent = node.parentId ? sceneNodes[node.parentId as AnyNodeId] : undefined + if (parent?.type !== 'stair') { + handleUpdate({ height }) + return + } + const totalRise = parent.children.reduce((sum, childId) => { + const child = sceneNodes[childId as AnyNodeId] + if (child?.type !== 'stair-segment') return sum + return sum + (child.id === node.id ? height : child.height) + }, 0) + runAsSingleSceneHistoryStep(useScene, () => { + useScene.getState().updateNodes([ + { id: node.id as AnyNodeId, data: { height } }, + { id: parent.id as AnyNodeId, data: { totalRise } }, + ]) + }) + }, + [node, handleUpdate], + ) + + // Turning a landing back into a flight seeds the rise the parent stair + // resolves β€” a fixed 2.5 m stops halfway up a tall storey, and for a + // follows-mode stair it is what `syncStairRises` would converge to anyway. + const resolveParentStairRise = useCallback(() => { + const sceneNodes = useScene.getState().nodes + const parent = node?.parentId ? sceneNodes[node.parentId as AnyNodeId] : undefined + return parent?.type === 'stair' + ? resolveStairTotalRise(parent, sceneNodes) + : DEFAULT_LEVEL_HEIGHT + }, [node]) + const handleBack = useCallback(() => { if (node?.parentId) { setSelection({ selectedIds: [node.parentId] }) @@ -136,7 +181,7 @@ export default function StairSegmentPanel() { updates.stepCount = 0 updates.length = 1.0 } else { - updates.height = 2.5 + updates.height = resolveParentStairRise() updates.stepCount = 10 updates.length = 3.0 } @@ -166,7 +211,7 @@ export default function StairSegmentPanel() { precision={2} step={0.1} unit="m" - value={Math.round(node.width * 100) / 100} + value={node.width} /> {node.segmentType === 'stair' && ( <> @@ -184,12 +229,17 @@ export default function StairSegmentPanel() { label="Height" max={1000} min={0.5} - onChange={(v) => handleUpdate({ height: v })} + onChange={handleFlightHeightChange} precision={2} step={0.1} unit="m" - value={Math.round(node.height * 100) / 100} + value={node.height} /> + {parentFollowsLevel && ( +
+ Editing switches the stair to Custom rise +
+ )} )}
@@ -237,7 +287,7 @@ export default function StairSegmentPanel() { precision={2} step={0.05} unit="m" - value={Math.round(node.position[0] * 100) / 100} + value={node.position[0]} /> s.selection.selectedIds[0]) const selectedCount = useViewer((s) => s.selection.selectedIds.length) + const unit = useViewer((s) => s.unit) + const metricNotation = useViewer((s) => s.metricNotation) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) const createNode = useScene((s) => s.createNode) @@ -154,6 +159,18 @@ export default function StairPanel() { [handleUpdate], ) + const handleStairTypeChange = useCallback( + (value: StairType) => { + if (!node) return + const change = getStairTypeChange(node, value, useScene.getState().nodes) + runAsSingleSceneHistoryStep(useScene, () => { + updateNode(node.id as AnyNode['id'], change.updates) + if (change.segment) createNode(change.segment, node.id as AnyNodeId) + }) + }, + [node, updateNode, createNode], + ) + const handleDestinationChange = useCallback( (value: string) => { if (!node) return @@ -255,7 +272,7 @@ export default function StairPanel() { const resolvedToLevelId = resolveStairToLevelId(nodes, node, resolvedFromLevelId, levels) const deckNode = node.deckSlabId ? nodes[node.deckSlabId as AnyNodeId] : undefined const attachedDeck = deckNode?.type === 'slab' ? deckNode : undefined - const resolvedRise = Math.round(resolveStairTotalRise(node, nodes) * 100) / 100 + const resolvedRise = resolveStairTotalRise(node, nodes) return ( - handleUpdate( - value === 'spiral' && node.stairType !== 'spiral' - ? { - stairType: value, - sweepAngle: DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE, - position: [node.position[0], 0, node.position[2]], - } - : { stairType: value }, - ) - } + onChange={handleStairTypeChange} options={STAIR_TYPE_OPTIONS} value={node.stairType ?? 'straight'} /> @@ -303,7 +310,7 @@ export default function StairPanel() { > {levels.map((level) => ( ))} @@ -320,7 +327,7 @@ export default function StairPanel() { > {levels.map((level) => ( ))} {candidateDecks.map((deck) => ( @@ -331,41 +338,39 @@ export default function StairPanel() { - {attachedDeck ? ( -
-
- Rise +
+
+ Rise +
+ + handleUpdate( + value === 'custom' ? { totalRise: resolvedRise } : { totalRise: undefined }, + ) + } + options={[ + { label: attachedDeck ? 'Follows deck' : 'Follows level', value: 'follows' }, + { label: 'Custom rise', value: 'custom' }, + ]} + value={node.totalRise == null ? 'follows' : 'custom'} + /> + {node.totalRise == null ? ( +
+ Currently {formatLinearMeasurement(resolvedRise, unit, metricNotation)}
- - handleUpdate( - value === 'custom' ? { totalRise: resolvedRise } : { totalRise: undefined }, - ) - } - options={[ - { label: 'Follows deck', value: 'follows' }, - { label: 'Custom rise', value: 'custom' }, - ]} - value={node.totalRise == null ? 'follows' : 'custom'} + ) : ( + handleUpdate({ totalRise: value })} + precision={2} + step={0.05} + unit="m" + value={resolvedRise} /> - {node.totalRise == null ? ( -
- Currently {resolvedRise} m -
- ) : ( - handleUpdate({ totalRise: value })} - precision={2} - step={0.05} - unit="m" - value={resolvedRise} - /> - )} -
- ) : null} + )} +
{attachedDeck ? null : ( <> @@ -384,7 +389,7 @@ export default function StairPanel() { precision={2} step={0.01} unit="m" - value={Math.round((node.openingOffset ?? 0) * 100) / 100} + value={node.openingOffset ?? 0} /> ) : null} @@ -411,7 +416,7 @@ export default function StairPanel() { precision={2} step={0.05} unit="m" - value={Math.round((node.topLandingDepth ?? 0.9) * 100) / 100} + value={node.topLandingDepth ?? 0.9} /> )} @@ -459,17 +464,7 @@ export default function StairPanel() { precision={2} step={0.05} unit="m" - value={Math.round((node.width ?? 1) * 100) / 100} - /> - handleUpdate({ totalRise: value })} - precision={2} - step={0.05} - unit="m" - value={Math.round(resolveStairTotalRise(node, nodes) * 100) / 100} + value={node.width ?? 1} /> )} )} diff --git a/packages/nodes/src/stair/stair-type.test.ts b/packages/nodes/src/stair/stair-type.test.ts new file mode 100644 index 0000000000..b340707a62 --- /dev/null +++ b/packages/nodes/src/stair/stair-type.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'bun:test' +import { type AnyNode, LevelNode, StairNode, StairSegmentNode } from '@pascal-app/core' +import { getStairTypeChange } from './stair-type' + +const LEVEL_ID = 'level_5o2tes0jyuiupp2f' +const STAIR_ID = 'stair_7yfdirs1t2iqslvi' + +function buildScene(overrides: Record = {}, segmentHeights: number[] = []) { + const segments = segmentHeights.map((height, index) => + StairSegmentNode.parse({ + id: `sseg_${index}`, + type: 'stair-segment', + segmentType: 'stair', + height, + parentId: STAIR_ID, + }), + ) + const stair = StairNode.parse({ + id: STAIR_ID, + type: 'stair', + parentId: LEVEL_ID, + position: [9.5, 0, -3.5], + stairType: 'curved', + width: 0.9, + stepCount: 16, + thickness: 0.16, + fillToFloor: false, + totalRise: 3.81, + children: segments.map((segment) => segment.id), + ...overrides, + }) + const level = LevelNode.parse({ + id: LEVEL_ID, + type: 'level', + level: 0, + height: 3.5, + children: [STAIR_ID], + }) + const nodes: Record = { [level.id]: level, [stair.id]: stair } + for (const segment of segments) nodes[segment.id] = segment + return { nodes, stair } +} + +describe('getStairTypeChange', () => { + it('materializes a flight when a segment-less stair becomes straight', () => { + const { nodes, stair } = buildScene() + const change = getStairTypeChange(stair, 'straight', nodes) + + expect(change.updates.stairType).toBe('straight') + expect(change.segment?.type).toBe('stair-segment') + expect(change.segment?.segmentType).toBe('stair') + expect(change.segment?.width).toBe(0.9) + expect(change.segment?.stepCount).toBe(16) + expect(change.segment?.thickness).toBe(0.16) + expect(change.segment?.fillToFloor).toBe(false) + // Rise from the stair, run from the `StairSegmentNode` schema default. + expect(change.segment?.height).toBe(3.81) + expect(change.segment?.length).toBe(3) + }) + + it('follows the storey height when the stair has no explicit rise', () => { + const { nodes, stair } = buildScene({ totalRise: undefined }) + const change = getStairTypeChange(stair, 'straight', nodes) + + expect(change.segment?.height).toBe(3.5) + }) + + it('leaves an existing flight alone', () => { + const { nodes, stair } = buildScene({ stairType: 'curved' }, [2.5]) + const change = getStairTypeChange(stair, 'straight', nodes) + + expect(change.updates.stairType).toBe('straight') + expect(change.segment).toBeNull() + }) + + it('keeps the segments when a straight stair becomes curved', () => { + const { nodes, stair } = buildScene({ stairType: 'straight' }, [2.5]) + const change = getStairTypeChange(stair, 'curved', nodes) + + expect(change.updates).toEqual({ stairType: 'curved' }) + expect(change.segment).toBeNull() + expect(nodes[STAIR_ID]).toBe(stair) + }) + + it('seeds the sweep and drops the Y offset when switching to spiral', () => { + const { nodes, stair } = buildScene() + const change = getStairTypeChange(stair, 'spiral', nodes) + + expect(change.updates.stairType).toBe('spiral') + expect(change.updates.sweepAngle).toBeCloseTo((400 * Math.PI) / 180, 10) + expect(change.updates.position).toEqual([9.5, 0, -3.5]) + expect(change.segment).toBeNull() + }) +}) diff --git a/packages/nodes/src/stair/stair-type.ts b/packages/nodes/src/stair/stair-type.ts new file mode 100644 index 0000000000..718fd8a815 --- /dev/null +++ b/packages/nodes/src/stair/stair-type.ts @@ -0,0 +1,47 @@ +import { + type AnyNode, + type AnyNodeId, + createStairFlightFromStair, + type StairNode, + type StairSegmentNode, + type StairType, +} from '@pascal-app/core' +import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '@pascal-app/editor' + +export type StairTypeChange = { + updates: Partial + /** A flight to create under the stair, or null when the stair already has segments. */ + segment: StairSegmentNode | null +} + +/** + * Computes the stair patch for a type switch. + * + * Straight stairs are drawn from their `stair-segment` children while curved + * and spiral stairs are drawn parametrically from the stair's own fields, so a + * stair that reaches `straight` without segments has nothing to draw at all. + * Switching to straight therefore materializes the flight the stair already + * describes. Switching away keeps the segments β€” they simply go unused until + * the stair comes back, which makes the round trip lossless. + */ +export function getStairTypeChange( + stair: StairNode, + nextType: StairType, + nodes: Record, +): StairTypeChange { + const updates: Partial = + nextType === 'spiral' && stair.stairType !== 'spiral' + ? { + stairType: nextType, + sweepAngle: DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE, + position: [stair.position[0], 0, stair.position[2]], + } + : { stairType: nextType } + + if (nextType !== 'straight') return { updates, segment: null } + + const hasSegment = (stair.children ?? []).some( + (childId) => nodes[childId as AnyNodeId]?.type === 'stair-segment', + ) + return { updates, segment: hasSegment ? null : createStairFlightFromStair(stair, nodes) } +} diff --git a/packages/nodes/src/turbine-vent/panel.tsx b/packages/nodes/src/turbine-vent/panel.tsx index fc23df3142..50418742d4 100644 --- a/packages/nodes/src/turbine-vent/panel.tsx +++ b/packages/nodes/src/turbine-vent/panel.tsx @@ -189,7 +189,7 @@ export default function TurbineVentPanel() { restoreOnCommit={false} step={0.01} unit="m" - value={Math.round(node.diameter * 100) / 100} + value={node.diameter} /> entry.node.type === 'roof-segment')?.node expect(wallDefinition.quickActions).toBeUndefined() - expect(roof).toMatchObject({ position: [0, 3, 0] }) + expect(roof).toMatchObject({ position: [0, 3, 0], support: { kind: 'walls' } }) expect(segment).toMatchObject({ roofType: 'conical', width: 4, @@ -190,7 +190,7 @@ test('curved wall roof builder parents the roof to the active level', () => { const createdRoof = created.find((entry) => entry.node.type === 'roof') expect(createdRoof?.parentId).toBe(activeLevel.id) - expect(createdRoof?.node).toMatchObject({ position: [0, 0, 0] }) + expect(createdRoof?.node).toMatchObject({ position: [0, 0, 0], support: { kind: 'walls' } }) }) test('curved wall roof builder reuses its existing hosted roof', () => { @@ -242,7 +242,7 @@ test('curved wall roof builder reuses its existing hosted roof', () => { expect(created).toHaveLength(0) }) -test('curved wall roof builder clamps a lower-floor wall to the active floor', () => { +test('curved wall roof builder follows a lower-floor wall top below the active floor', () => { const sourceLevel = { object: 'node', id: 'level_source', @@ -280,7 +280,7 @@ test('curved wall roof builder clamps a lower-floor wall to the active floor', ( createConicalRoofSectorAboveWall(wall, nodes, sceneApi, activeLevel.id as AnyNodeId) const createdRoof = created.find((entry) => entry.node.type === 'roof') - expect(createdRoof?.node).toMatchObject({ position: [0, 0, 0] }) + expect(createdRoof?.node).toMatchObject({ position: [0, -2, 0] }) }) test('curved wall roof builder rejects walls more than one level below', () => { diff --git a/packages/nodes/src/wall/wall-batch-system.test.ts b/packages/nodes/src/wall/wall-batch-system.test.ts index 8e1c7cb6bb..f5694aac20 100644 --- a/packages/nodes/src/wall/wall-batch-system.test.ts +++ b/packages/nodes/src/wall/wall-batch-system.test.ts @@ -1,5 +1,6 @@ import { afterAll, afterEach, describe, expect, spyOn, test } from 'bun:test' import { sceneRegistry, useScene } from '@pascal-app/core' +import * as viewerExports from '@pascal-app/viewer' import { SCENE_LAYER, useViewer } from '@pascal-app/viewer' import { BufferGeometry, @@ -264,3 +265,27 @@ test('merged wall batches are stripped from GLB exports', () => { const { batch } = setupBatchedLevel() expect(batch.userData.pascalExport).toBe('strip') }) + +test('wall batches keep waiting for pending neighbours even after the dirty census is clean', () => { + const { root, walls } = setupBatchedLevel() + let pending = 1 + const queue = spyOn(viewerExports, 'getPendingWallRebuildCount').mockImplementation(() => pending) + try { + useViewer.setState({ wallMode: 'down' }) + runFrame() + useViewer.setState({ wallMode: 'up' }) + nowMs = 200 + runFrame() + nowMs = 500 + runFrame() + expect(useScene.getState().dirtyNodes.size).toBe(0) + expect(walls.every((wall) => wall.layers.isEnabled(SCENE_LAYER))).toBe(true) + pending = 0 + nowMs += 181 + runFrame() + expect(root.children.some((child) => child.name === 'wall-batch')).toBe(true) + expect(walls.every((wall) => !wall.layers.isEnabled(SCENE_LAYER))).toBe(true) + } finally { + queue.mockRestore() + } +}) diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index e496329205..4ec23c4e83 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -535,7 +535,7 @@ export default function WindowPanel() { precision={2} step={0.1} unit="m" - value={Math.round(node.position[0] * 100) / 100} + value={node.position[0]} /> {showFlipSide && (
@@ -570,7 +570,7 @@ export default function WindowPanel() { restoreOnCommit={false} step={0.1} unit="m" - value={Math.round(node.width * 100) / 100} + value={node.width} /> @@ -631,7 +631,7 @@ export default function WindowPanel() { precision={2} step={0.05} unit="m" - value={Math.round(cornerRadius * 100) / 100} + value={cornerRadius} /> ) : ( <> @@ -651,7 +651,7 @@ export default function WindowPanel() { precision={2} step={0.05} unit="m" - value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100} + value={openingCornerRadii[index as number] ?? 0} /> ))} @@ -665,7 +665,7 @@ export default function WindowPanel() { precision={3} step={0.005} unit="m" - value={Math.round(openingRevealRadius * 1000) / 1000} + value={openingRevealRadius} />
)} @@ -680,7 +680,7 @@ export default function WindowPanel() { restoreOnCommit={false} step={0.05} unit="m" - value={Math.round(archHeight * 100) / 100} + value={archHeight} />
)} @@ -722,7 +722,7 @@ export default function WindowPanel() { precision={2} step={0.05} unit="m" - value={Math.round(cornerRadius * 100) / 100} + value={cornerRadius} /> ) : ( <> @@ -742,7 +742,7 @@ export default function WindowPanel() { precision={2} step={0.05} unit="m" - value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100} + value={openingCornerRadii[index as number] ?? 0} /> ))} @@ -756,7 +756,7 @@ export default function WindowPanel() { precision={3} step={0.005} unit="m" - value={Math.round(openingRevealRadius * 1000) / 1000} + value={openingRevealRadius} /> )} @@ -771,7 +771,7 @@ export default function WindowPanel() { restoreOnCommit={false} step={0.05} unit="m" - value={Math.round(archHeight * 100) / 100} + value={archHeight} /> )} @@ -789,7 +789,7 @@ export default function WindowPanel() { precision={3} step={0.01} unit="m" - value={Math.round(node.frameThickness * 1000) / 1000} + value={node.frameThickness} />
)} @@ -857,7 +857,7 @@ export default function WindowPanel() { precision={3} step={0.01} unit="m" - value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000} + value={node.columnDividerThickness ?? 0.03} /> @@ -890,7 +890,7 @@ export default function WindowPanel() { precision={3} step={0.01} unit="m" - value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000} + value={node.rowDividerThickness ?? 0.03} /> @@ -914,7 +914,7 @@ export default function WindowPanel() { precision={3} step={0.01} unit="m" - value={Math.round(node.sillDepth * 1000) / 1000} + value={node.sillDepth} /> )} diff --git a/packages/viewer/README.md b/packages/viewer/README.md index 7ebe4fe8cd..f9f34263a6 100644 --- a/packages/viewer/README.md +++ b/packages/viewer/README.md @@ -120,6 +120,61 @@ floor-plan pan/zoom/rotation, and the compass synchronize through transient subs navigation does not require a React render per frame. Set `showCompass={false}` or `showSwitcher={false}` when the host supplies its own controls. +## Capture Sessions + +`@pascal-app/viewer/capture` holds the optional capture runtime and its reference layers. Mount +`CaptureRuntime` as a child of `Viewer` and provide a source resolver. The host owns access control +and transport; the runtime owns source lifecycle, scan-node placement, layer visibility, and +reference renderers for RoomPlan models, device trajectories, and PLY/live point clouds. The +session contracts it consumes live in `@pascal-app/core/capture`. + +```tsx +import { createHttpCaptureSource } from '@pascal-app/core/capture' +import { Viewer } from '@pascal-app/viewer' +import { CaptureRuntime } from '@pascal-app/viewer/capture' + +function CaptureViewer() { + return ( + + reportCaptureError(error, context)} + resolveSource={(locator) => createHttpCaptureSource(locator, { credentials: 'include' })} + retryKey={retryVersion} + /> + + ) +} +``` + +Unknown streams remain in the descriptor and can be rendered by passing a custom renderer keyed by +stream role or kind. A live transport implements `CaptureSource.subscribe()`; no particular +WebSocket, WebRTC, or collaboration backend is required. + +`CaptureRuntime` keeps telemetry host-neutral: pass `onError` to report source or per-stream +failures in the host, then increment `retryKey` to reload every affected session. Direct +`useCaptureSource()` consumers can call its `retry()` function instead. + +Hosts can pass `defaultLayerVisibility` to keep expensive optional layers disabled until a user +enables them. Persisted values in the scan node's `layers` map always override those host defaults; +without host defaults, every available layer remains visible for backwards compatibility. Hidden +sessions and layers are unmounted rather than only made visually transparent, so they stop +raycasting, artifact work, animation, and live packet subscriptions while disabled. + +### Local surface previews + +`@pascal-app/viewer/capture/preview` exports `createSurfaceMeshGeometry` and `createClayMatcap` +without importing the React viewer runtime, so a capture client can render a locally saved surface +immediately, before its archive is uploaded. The geometry decoder uses the shared +`@pascal-app/core/capture` validator, including the native 20,000-face budget, byte lengths, and +index bounds. It returns `null` for invalid input. The host owns the returned geometry and matcap +texture and must dispose them on teardown. + +Direct `CaptureStreamLayer` consumers can pass +`meshPresentation={{ previewMaterial: 'clay', dollhouse: true }}`. Clay replaces preliminary vertex +colors; dollhouse enables front-face rendering for surface previews and room models, revealing +inward-facing room surfaces from outside. It changes per-instance materials, not geometry or +loader-cached materials. Omitting these options preserves the existing presentation. + ## Viewer State ```typescript diff --git a/packages/viewer/bunfig.toml b/packages/viewer/bunfig.toml new file mode 100644 index 0000000000..eec7d338da --- /dev/null +++ b/packages/viewer/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-preload-three.ts"] + +[test] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 4097c35b9a..60101c943f 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -1,6 +1,6 @@ { "name": "@pascal-app/viewer", - "version": "1.0.0-beta.5", + "version": "1.0.0", "description": "3D viewer component for Pascal building editor", "type": "module", "main": "./dist/index.js", @@ -10,6 +10,16 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" + }, + "./capture": { + "types": "./dist/capture/index.d.ts", + "import": "./dist/capture/index.js", + "default": "./dist/capture/index.js" + }, + "./capture/preview": { + "types": "./dist/capture/preview.d.ts", + "import": "./dist/capture/preview.js", + "default": "./dist/capture/preview.js" } }, "files": [ @@ -23,12 +33,12 @@ "prepublishOnly": "npm run build" }, "peerDependencies": { - "@pascal-app/core": "^1.0.0-beta.5", + "@pascal-app/core": "^1.0.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", - "three": "^0.185" + "three": "^0.186" }, "dependencies": { "three-bvh-csg": "^0.0.18", @@ -36,8 +46,9 @@ "zustand": "^5" }, "devDependencies": { - "@pascal-app/core": "^1.0.0-beta.5", + "@pascal-app/core": "^1.0.0", "@pascal/typescript-config": "*", + "@react-three/test-renderer": "^9.1.0", "@types/node": "^22", "@types/react": "^19.2.2", "@types/react-dom": "19.2.2", diff --git a/packages/capture-viewer/src/asset-url.ts b/packages/viewer/src/capture/asset-url.ts similarity index 100% rename from packages/capture-viewer/src/asset-url.ts rename to packages/viewer/src/capture/asset-url.ts diff --git a/packages/capture-viewer/src/capture-runtime.tsx b/packages/viewer/src/capture/capture-runtime.tsx similarity index 92% rename from packages/capture-viewer/src/capture-runtime.tsx rename to packages/viewer/src/capture/capture-runtime.tsx index 31cb22c909..c5efb7b194 100644 --- a/packages/capture-viewer/src/capture-runtime.tsx +++ b/packages/viewer/src/capture/capture-runtime.tsx @@ -1,5 +1,6 @@ 'use client' +import { type ScanNode, sceneRegistry, useScene } from '@pascal-app/core' import { type CaptureArtifactReference, CaptureArtifactReferenceSchema, @@ -12,9 +13,7 @@ import { type CaptureStreamPacket, captureLayerKey, DeviceMotionTrajectorySchema, -} from '@pascal-app/capture-protocol' -import { type ScanNode, sceneRegistry, useScene } from '@pascal-app/core' -import { ErrorBoundary, useNodeEvents, useViewer } from '@pascal-app/viewer' +} from '@pascal-app/core/capture' import { createPortal, useFrame } from '@react-three/fiber' import { type ComponentType, @@ -27,7 +26,9 @@ import { useState, } from 'react' import type { Object3D } from 'three' -import { rewriteLoopbackAssetUrl } from './asset-url' +import { ErrorBoundary } from '../components/error-boundary' +import { useNodeEvents } from '../hooks/use-node-events' +import useViewer from '../store/use-viewer' import { resolveCaptureFrameMatrix } from './frame' import { isCaptureSessionVisible, isCaptureStreamVisible } from './layer-visibility' import { CaptureDeviceMotionLayer } from './layers/device-motion-layer' @@ -43,6 +44,7 @@ import { streamHydratesJsonPayload, } from './stream-rendering' import { parseDeviceTrajectoryPackets, parseDeviceTrajectoryPayload } from './trajectory' +import { useJsonArtifactPayload } from './use-json-artifact' export type CaptureMeshPresentation = { dollhouse?: boolean @@ -356,34 +358,6 @@ export function CaptureStreamLayer({ ) } -function useJsonArtifactPayload(url: string | null): unknown { - const [error, setError] = useState(null) - const [payload, setPayload] = useState(null) - - useEffect(() => { - setError(null) - setPayload(null) - if (!url) return - const abort = new AbortController() - void fetch(rewriteLoopbackAssetUrl(url), { signal: abort.signal }) - .then(async (response) => { - if (!response.ok) throw new Error(`Could not load ${url}: ${response.status}`) - return (await response.json()) as unknown - }) - .then((data) => { - if (!abort.signal.aborted) setPayload(data) - }) - .catch((cause: unknown) => { - if (abort.signal.aborted) return - setError(cause instanceof Error ? cause : new Error(`Could not load ${url}.`)) - }) - return () => abort.abort() - }, [url]) - - if (error) throw error - return payload -} - function useResolvedArtifact( source: CaptureSource, artifact: CaptureArtifactReference | undefined, diff --git a/packages/capture-viewer/src/frame.test.ts b/packages/viewer/src/capture/frame.test.ts similarity index 94% rename from packages/capture-viewer/src/frame.test.ts rename to packages/viewer/src/capture/frame.test.ts index 2dfe47e19a..c4462f28d5 100644 --- a/packages/capture-viewer/src/frame.test.ts +++ b/packages/viewer/src/capture/frame.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import type { CaptureSessionDescriptor } from '@pascal-app/capture-protocol' +import type { CaptureSessionDescriptor } from '@pascal-app/core/capture' import { Vector3 } from 'three' import { resolveCaptureFrameMatrix } from './frame' diff --git a/packages/capture-viewer/src/frame.ts b/packages/viewer/src/capture/frame.ts similarity index 90% rename from packages/capture-viewer/src/frame.ts rename to packages/viewer/src/capture/frame.ts index 992c40e136..00b1b47b4e 100644 --- a/packages/capture-viewer/src/frame.ts +++ b/packages/viewer/src/capture/frame.ts @@ -1,4 +1,4 @@ -import type { CaptureSessionDescriptor } from '@pascal-app/capture-protocol' +import type { CaptureSessionDescriptor } from '@pascal-app/core/capture' import { Matrix4 } from 'three' export function resolveCaptureFrameMatrix( diff --git a/packages/capture-viewer/src/index.ts b/packages/viewer/src/capture/index.ts similarity index 100% rename from packages/capture-viewer/src/index.ts rename to packages/viewer/src/capture/index.ts diff --git a/packages/capture-viewer/src/layer-visibility.test.ts b/packages/viewer/src/capture/layer-visibility.test.ts similarity index 100% rename from packages/capture-viewer/src/layer-visibility.test.ts rename to packages/viewer/src/capture/layer-visibility.test.ts diff --git a/packages/capture-viewer/src/layer-visibility.ts b/packages/viewer/src/capture/layer-visibility.ts similarity index 84% rename from packages/capture-viewer/src/layer-visibility.ts rename to packages/viewer/src/capture/layer-visibility.ts index 5524e7fd86..9065e90985 100644 --- a/packages/capture-viewer/src/layer-visibility.ts +++ b/packages/viewer/src/capture/layer-visibility.ts @@ -1,5 +1,5 @@ -import type { CaptureStreamDescriptor } from '@pascal-app/capture-protocol' -import { captureLayerKey } from '@pascal-app/capture-protocol' +import type { CaptureStreamDescriptor } from '@pascal-app/core/capture' +import { captureLayerKey } from '@pascal-app/core/capture' const EMPTY_LAYER_VISIBILITY: Readonly> = {} diff --git a/packages/capture-viewer/src/layers/clay-matcap.ts b/packages/viewer/src/capture/layers/clay-matcap.ts similarity index 100% rename from packages/capture-viewer/src/layers/clay-matcap.ts rename to packages/viewer/src/capture/layers/clay-matcap.ts diff --git a/packages/capture-viewer/src/layers/device-motion-layer.tsx b/packages/viewer/src/capture/layers/device-motion-layer.tsx similarity index 87% rename from packages/capture-viewer/src/layers/device-motion-layer.tsx rename to packages/viewer/src/capture/layers/device-motion-layer.tsx index 723456b1d9..3357fed294 100644 --- a/packages/capture-viewer/src/layers/device-motion-layer.tsx +++ b/packages/viewer/src/capture/layers/device-motion-layer.tsx @@ -57,13 +57,7 @@ export function CaptureDeviceMotionLayer({ return ( {trajectorySegments.map(({ points, segment }) => ( - + ))} @@ -88,11 +82,11 @@ function CameraFrustum({ lineWidth }: { lineWidth: number }) { ].flat() return ( - - + + - - + + ) @@ -101,13 +95,11 @@ function CameraFrustum({ lineWidth }: { lineWidth: number }) { function CaptureLine({ color, lineWidth, - opacity = 1, points, segments = false, }: { color: string lineWidth: number - opacity?: number points: readonly [number, number, number][] segments?: boolean }) { @@ -117,17 +109,15 @@ function CaptureLine({ ) const material = new LineBasicMaterial({ color, - depthWrite: opacity >= 1, linewidth: lineWidth, - opacity, - transparent: opacity < 1, + toneMapped: false, }) const object = segments ? new LineSegments(geometry, material) : new ThreeLine(geometry, material) object.frustumCulled = false return object - }, [color, lineWidth, opacity, points, segments]) + }, [color, lineWidth, points, segments]) useEffect( () => () => { diff --git a/packages/capture-viewer/src/layers/point-cloud-layer.tsx b/packages/viewer/src/capture/layers/point-cloud-layer.tsx similarity index 98% rename from packages/capture-viewer/src/layers/point-cloud-layer.tsx rename to packages/viewer/src/capture/layers/point-cloud-layer.tsx index 34274604c4..9a09a0cc00 100644 --- a/packages/capture-viewer/src/layers/point-cloud-layer.tsx +++ b/packages/viewer/src/capture/layers/point-cloud-layer.tsx @@ -1,6 +1,6 @@ 'use client' -import type { CaptureStreamPacket } from '@pascal-app/capture-protocol' +import type { CaptureStreamPacket } from '@pascal-app/core/capture' import { useLoader } from '@react-three/fiber' import { useEffect, useMemo } from 'react' import { BufferGeometry, Float32BufferAttribute } from 'three' diff --git a/packages/capture-viewer/src/layers/room-model-layer.tsx b/packages/viewer/src/capture/layers/room-model-layer.tsx similarity index 98% rename from packages/capture-viewer/src/layers/room-model-layer.tsx rename to packages/viewer/src/capture/layers/room-model-layer.tsx index 9f4621b9bb..e679096275 100644 --- a/packages/capture-viewer/src/layers/room-model-layer.tsx +++ b/packages/viewer/src/capture/layers/room-model-layer.tsx @@ -1,10 +1,10 @@ 'use client' -import { useGLTFKTX2 } from '@pascal-app/viewer' import { useLoader } from '@react-three/fiber' import { useEffect, useMemo } from 'react' import { DoubleSide, FrontSide, type Material, type Mesh, type Object3D } from 'three' import { USDLoader } from 'three/addons/loaders/USDLoader.js' +import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2' import { rewriteLoopbackAssetUrl } from '../asset-url' import type { CaptureModelFormat } from '../stream-rendering' diff --git a/packages/capture-viewer/src/layers/surface-mesh-data.ts b/packages/viewer/src/capture/layers/surface-mesh-data.ts similarity index 97% rename from packages/capture-viewer/src/layers/surface-mesh-data.ts rename to packages/viewer/src/capture/layers/surface-mesh-data.ts index 70141ed9ee..44256a9f41 100644 --- a/packages/capture-viewer/src/layers/surface-mesh-data.ts +++ b/packages/viewer/src/capture/layers/surface-mesh-data.ts @@ -1,4 +1,4 @@ -import { SurfaceMeshPayloadSchema } from '@pascal-app/capture-protocol' +import { SurfaceMeshPayloadSchema } from '@pascal-app/core/capture' import { BufferGeometry, Float32BufferAttribute, Uint16BufferAttribute } from 'three' export type SurfaceMeshData = { diff --git a/packages/capture-viewer/src/layers/surface-mesh-layer.tsx b/packages/viewer/src/capture/layers/surface-mesh-layer.tsx similarity index 100% rename from packages/capture-viewer/src/layers/surface-mesh-layer.tsx rename to packages/viewer/src/capture/layers/surface-mesh-layer.tsx diff --git a/packages/capture-viewer/src/point-cloud-layer.test.ts b/packages/viewer/src/capture/point-cloud-layer.test.ts similarity index 94% rename from packages/capture-viewer/src/point-cloud-layer.test.ts rename to packages/viewer/src/capture/point-cloud-layer.test.ts index bb47013797..f534e25ebd 100644 --- a/packages/capture-viewer/src/point-cloud-layer.test.ts +++ b/packages/viewer/src/capture/point-cloud-layer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import type { CaptureStreamPacket } from '@pascal-app/capture-protocol' +import type { CaptureStreamPacket } from '@pascal-app/core/capture' import { buildPointCloudData, buildPointCloudPayloadData } from './layers/point-cloud-layer' function packet(sequence: number, positions: number[], colors?: number[]): CaptureStreamPacket { diff --git a/packages/capture-viewer/src/preview.ts b/packages/viewer/src/capture/preview.ts similarity index 100% rename from packages/capture-viewer/src/preview.ts rename to packages/viewer/src/capture/preview.ts diff --git a/packages/capture-viewer/src/source-state.test.ts b/packages/viewer/src/capture/source-state.test.ts similarity index 98% rename from packages/capture-viewer/src/source-state.test.ts rename to packages/viewer/src/capture/source-state.test.ts index f2ba53acf1..35a189af7d 100644 --- a/packages/capture-viewer/src/source-state.test.ts +++ b/packages/viewer/src/capture/source-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import type { CaptureStreamPacket } from '@pascal-app/capture-protocol' +import type { CaptureStreamPacket } from '@pascal-app/core/capture' import { appendCapturePacket, captureSubscriptionStreamIds, diff --git a/packages/capture-viewer/src/source-state.ts b/packages/viewer/src/capture/source-state.ts similarity index 99% rename from packages/capture-viewer/src/source-state.ts rename to packages/viewer/src/capture/source-state.ts index 434040771e..a76b93874c 100644 --- a/packages/capture-viewer/src/source-state.ts +++ b/packages/viewer/src/capture/source-state.ts @@ -5,7 +5,7 @@ import type { CaptureSourceResolver, CaptureStreamDescriptor, CaptureStreamPacket, -} from '@pascal-app/capture-protocol' +} from '@pascal-app/core/capture' import { useCallback, useEffect, useState } from 'react' export type CaptureSourceState = { diff --git a/packages/capture-viewer/src/stream-rendering.test.ts b/packages/viewer/src/capture/stream-rendering.test.ts similarity index 100% rename from packages/capture-viewer/src/stream-rendering.test.ts rename to packages/viewer/src/capture/stream-rendering.test.ts diff --git a/packages/capture-viewer/src/stream-rendering.ts b/packages/viewer/src/capture/stream-rendering.ts similarity index 98% rename from packages/capture-viewer/src/stream-rendering.ts rename to packages/viewer/src/capture/stream-rendering.ts index 2fcfea7aeb..663ccd6ce8 100644 --- a/packages/capture-viewer/src/stream-rendering.ts +++ b/packages/viewer/src/capture/stream-rendering.ts @@ -5,7 +5,7 @@ import { DeviceMotionTrajectorySchema, PointCloudPayloadSchema, SurfaceMeshPayloadSchema, -} from '@pascal-app/capture-protocol' +} from '@pascal-app/core/capture' const GLB_MEDIA_TYPES = new Set(['model/gltf-binary', 'model/gltf+json']) const USDZ_MEDIA_TYPES = new Set(['model/vnd.usdz+zip']) diff --git a/packages/capture-viewer/src/surface-mesh-layer.test.ts b/packages/viewer/src/capture/surface-mesh-layer.test.ts similarity index 100% rename from packages/capture-viewer/src/surface-mesh-layer.test.ts rename to packages/viewer/src/capture/surface-mesh-layer.test.ts diff --git a/packages/capture-viewer/src/trajectory.test.ts b/packages/viewer/src/capture/trajectory.test.ts similarity index 100% rename from packages/capture-viewer/src/trajectory.test.ts rename to packages/viewer/src/capture/trajectory.test.ts diff --git a/packages/capture-viewer/src/trajectory.ts b/packages/viewer/src/capture/trajectory.ts similarity index 98% rename from packages/capture-viewer/src/trajectory.ts rename to packages/viewer/src/capture/trajectory.ts index 688f2e6828..0d883b2907 100644 --- a/packages/capture-viewer/src/trajectory.ts +++ b/packages/viewer/src/capture/trajectory.ts @@ -2,7 +2,7 @@ import { DeviceMotionSampleSchema, type DeviceMotionTrajectoryPayload, DeviceMotionTrajectorySchema, -} from '@pascal-app/capture-protocol' +} from '@pascal-app/core/capture' import { Matrix4, Quaternion, Vector3 } from 'three' export type DeviceTrajectoryPose = { diff --git a/packages/viewer/src/capture/use-json-artifact.test.tsx b/packages/viewer/src/capture/use-json-artifact.test.tsx new file mode 100644 index 0000000000..bab99555b0 --- /dev/null +++ b/packages/viewer/src/capture/use-json-artifact.test.tsx @@ -0,0 +1,70 @@ +import { afterEach, expect, mock, spyOn, test } from 'bun:test' +import { create } from '@react-three/test-renderer' +import { Suspense } from 'react' +import { FileLoader } from 'three' +import { ErrorBoundary } from '../components/error-boundary' +import { useJsonArtifactPayload } from './use-json-artifact' + +afterEach(() => { + mock.restore() +}) + +function Preview({ url }: { url: string }) { + const payload = useJsonArtifactPayload(url) + return payload ? : null +} + +test('a failed artifact can load after resetting its error boundary without reloading the page', async () => { + spyOn(console, 'error').mockImplementation(() => {}) + spyOn(globalThis, 'reportError').mockImplementation(() => {}) + const load = spyOn(FileLoader.prototype, 'load') + const failure = new Error('Temporarily unavailable') + const payload = { positions: [1, 2, 3] } + load.mockImplementationOnce((_url, _loaded, _progress, failed) => { + queueMicrotask(() => failed?.(failure)) + }) + load.mockImplementation((_url, loaded) => { + queueMicrotask(() => loaded?.(payload)) + }) + const view = (retryKey: number) => ( + } resetKey={retryKey}> + + + + + ) + const renderer = await create(view(0)) + try { + expect(renderer.scene.findAllByProps({ name: 'failed' })).toHaveLength(1) + expect(load).toHaveBeenCalledTimes(1) + await renderer.update(view(1)) + expect(renderer.scene.findAllByProps({ name: 'failed' })).toHaveLength(0) + expect(renderer.scene.findAllByProps({ name: 'loaded' })).toHaveLength(1) + expect(load).toHaveBeenCalledTimes(2) + } finally { + await renderer.unmount() + } +}) + +test('returning to a successful artifact reuses its parsed payload', async () => { + const load = spyOn(FileLoader.prototype, 'load') + const payload = { positions: [1, 2, 3] } + load.mockImplementation((_url, loaded) => { + queueMicrotask(() => loaded?.(payload)) + }) + const view = ( + + + + ) + const renderer = await create(view) + try { + expect(renderer.scene.findByProps({ name: 'loaded' }).props.userData.payload).toBe(payload) + await renderer.update() + await renderer.update(view) + expect(renderer.scene.findByProps({ name: 'loaded' }).props.userData.payload).toBe(payload) + expect(load).toHaveBeenCalledTimes(1) + } finally { + await renderer.unmount() + } +}) diff --git a/packages/viewer/src/capture/use-json-artifact.ts b/packages/viewer/src/capture/use-json-artifact.ts new file mode 100644 index 0000000000..dc3c806126 --- /dev/null +++ b/packages/viewer/src/capture/use-json-artifact.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react' +import { FileLoader } from 'three' +import { rewriteLoopbackAssetUrl } from './asset-url' + +const requests = new Map>() + +function loadJsonArtifact(url: string): Promise { + const cached = requests.get(url) + if (cached) return cached + const request = new FileLoader().setResponseType('json').loadAsync(url) + requests.set(url, request) + // Keep successful downloads across view switches, but let a remount retry failures. + void request.catch(() => requests.delete(url)) + return request +} + +export function useJsonArtifactPayload(url: string | null): unknown { + const resolvedUrl = url ? rewriteLoopbackAssetUrl(url) : null + const [result, setResult] = useState<{ + url: string + payload?: unknown + error?: Error + } | null>(null) + + useEffect(() => { + if (!resolvedUrl) return + let active = true + void loadJsonArtifact(resolvedUrl).then( + (payload) => { + if (active) setResult({ url: resolvedUrl, payload }) + }, + (cause: unknown) => { + if (active) { + setResult({ + url: resolvedUrl, + error: cause instanceof Error ? cause : new Error('Could not load capture data.'), + }) + } + }, + ) + return () => { + active = false + } + }, [resolvedUrl]) + + if (!result || result.url !== resolvedUrl) return null + if (result.error) throw result.error + return result.payload ?? null +} diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index e2e604dca0..4d19c1c196 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -599,7 +599,9 @@ export function GlbScene({ const targetY = baseY + (exploded ? index * EXPLODED_GAP : 0) // Snap (not lerp) in walkthrough so the first-person collider, built from // these world positions, matches the stacked building immediately. - node.position.y = walkthroughMode ? targetY : lerp(node.position.y, targetY, delta * 12) + node.position.y = walkthroughMode + ? targetY + : lerp(node.position.y, targetY, Math.min(1, delta * 12)) // Solo: hidden levels above the soloed one keep casting shadows // (shadow-caster-only); below-levels can't block the sun, so plain-hide. const hidden = diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 6dd57a2723..e03f8b7d0e 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -3,6 +3,7 @@ import { type AnyNodeId, nodeRegistry, + RoofElevationSystem, StairOpeningSystem, sceneRegistry, useScene, @@ -30,6 +31,7 @@ import useViewer, { type RenderContext } from '../../store/use-viewer' import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevation-system' import { GeometrySystem } from '../../systems/geometry/geometry-system' import { PerfActionSettleSystem } from '../../systems/perf-action-settle/perf-action-settle-system' +import { subscribeWallBuildInteractions } from '../../systems/wall/wall-build-lifecycle' import { ErrorBoundary } from '../error-boundary' import { SceneRenderer } from '../renderers/scene-renderer' import { BATCH_SPIKE_ENABLED, BatchedMeshSpike } from './batched-mesh-spike' @@ -40,6 +42,7 @@ import { PerfPanel } from './perf-panel' import { PointerRaycastLayers } from './pointer-raycast-layers' import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing' import { RegisteredSystems } from './registered-systems' +import { useSceneAtmosphere } from './scene-atmosphere' import { SceneBvh } from './scene-bvh' import { SelectionManager } from './selection-manager' import { UnsupportedGpuViewerFallback } from './unsupported-gpu-fallback' @@ -217,13 +220,20 @@ function GPUDeviceWatcher() { function ToneMappingExposure() { const sceneTheme = useViewer((state) => state.sceneTheme) + const atmosphere = useSceneAtmosphere() const gl = useThree((state) => state.gl) const invalidate = useThree((state) => state.invalidate) useEffect(() => { + if (atmosphere) return gl.toneMappingExposure = getSceneTheme(sceneTheme).toneMappingExposure invalidate() - }, [gl, invalidate, sceneTheme]) + }, [atmosphere, gl, invalidate, sceneTheme]) + + useFrame(() => { + if (!atmosphere) return + gl.toneMappingExposure = atmosphere.exposure + }, -1) return null } @@ -526,6 +536,7 @@ const Viewer = forwardRef(function Viewer( a camera transform that defeats position:fixed (see perf-panel.tsx). */} {(perf || PERF_OVERLAY_ENABLED) && } (function Viewer( {/* Automated stair opening sync β€” updates slab/ceiling cutouts whenever stairs, slabs, or levels change. */} + {/* Mounts systems contributed by registry-backed kinds. Each kind's `def.system` is loaded via lazy() and rendered here, ordered by `system.priority`. */} diff --git a/packages/viewer/src/components/viewer/lights.tsx b/packages/viewer/src/components/viewer/lights.tsx index 53624d4fd4..7a143c818c 100644 --- a/packages/viewer/src/components/viewer/lights.tsx +++ b/packages/viewer/src/components/viewer/lights.tsx @@ -11,6 +11,7 @@ import * as THREE from 'three/webgpu' import { SHADOW_ONLY_LAYER } from '../../lib/layers' import { getSceneTheme } from '../../lib/scene-themes' import useViewer from '../../store/use-viewer' +import { useSceneAtmosphere } from './scene-atmosphere' // Diagnostic toggle: `?disable=shadows` skips the shadow-map render pass // (which doubles draw calls for every shadow-casting mesh) so you can @@ -78,6 +79,11 @@ export function Lights() { const sceneTheme = useViewer((state) => state.sceneTheme) const theme = getSceneTheme(sceneTheme) const shadows = useViewer((state) => state.shadows) + const atmosphere = useSceneAtmosphere() + const lightSlots = useMemo( + () => Array.from({ length: atmosphere ? 2 : theme.lights.length }, (_, index) => index), + [atmosphere, theme.lights.length], + ) const lightRefs = useRef>([]) const shadowCamera = useRef(null) @@ -87,7 +93,7 @@ export function Lights() { // Building bounds the shadow frustum is fit to, recomputed on an interval. const shadowFocus = useRef(new THREE.Vector3()) // sphere centre const shadowRadius = useRef(SHADOW_FALLBACK_RADIUS) // sphere radius - const shadowDir = useRef(new THREE.Vector3()) // scratch: per-light direction + const shadowDir = useRef(new THREE.Vector3()) // scratch: key-light direction const boundsBox = useRef(new THREE.Box3()) // scratch: union AABB const boundsSphere = useRef(new THREE.Sphere()) // scratch: fitted sphere const lastBoundsTime = useRef(-1) // last refresh timestamp (-1 = never) @@ -109,32 +115,38 @@ export function Lights() { ) useFrame((state, delta) => { - // clamp delta to avoid huge jumps on tab switch + // Clamp delta to avoid huge jumps on tab switch. const dt = Math.min(delta, 0.1) * 4 - // Fit each shadow-casting light's frustum to the BUILDING geometry rather - // than the camera. We refresh the union bounds on an interval (cheap enough, - // and bounds only change while editing), fit a sphere, and size + place the - // ortho shadow camera so the building (plus a margin) is fully covered from - // the light's direction. The light DIRECTION stays exactly as the theme - // specifies; only its position/distance and the frustum extents change. + // Atmosphere directions are stable mutable vectors. Keep both light + // resources mounted and move them imperatively so time/angle changes never + // rebuild materials or shadow resources. + if (atmosphere) { + for (let index = 0; index < 2; index++) { + const light = lightRefs.current[index] + if (!light) continue + const direction = index === 0 ? atmosphere.sunDirection : atmosphere.moonDirection + light.position.copy(direction).multiplyScalar(100) + light.target.position.set(0, 0, 0) + light.target.updateMatrixWorld() + } + } + + // Fit the single key-light shadow frustum to the BUILDING geometry rather + // than the camera. The source controls its direction; only its distance and + // frustum extents are derived here. if (shadows) { const now = state.clock.elapsedTime if (now - lastBoundsTime.current >= BOUNDS_REFRESH_INTERVAL) { lastBoundsTime.current = now const box = boundsBox.current.makeEmpty() for (const [id, obj] of sceneRegistry.nodes) { - if (SHADOW_EXCLUDED_TYPES.some((t) => sceneRegistry.byType[t]!.has(id))) continue + if (SHADOW_EXCLUDED_TYPES.some((type) => sceneRegistry.byType[type]!.has(id))) continue box.expandByObject(obj) } box.getBoundingSphere(boundsSphere.current) const center = boundsSphere.current.center const radius = boundsSphere.current.radius - // Empty scene OR a node with a NaN position/geometry poisoning the union - // box: fall back to the origin with a default radius. The directional - // light's position is derived from `focus`, so a single non-finite mesh - // must NOT be allowed to make `focus`/`radius` NaN β€” that breaks every - // shadow-casting light's position and renders the whole scene black. const finiteBounds = !box.isEmpty() && Number.isFinite(center.x) && @@ -150,33 +162,30 @@ export function Lights() { } } - const focus = shadowFocus.current - // Ortho half-extent: the building sphere plus a proportional margin. - const size = shadowRadius.current * SHADOW_MARGIN_SCALE + SHADOW_MARGIN - // Park the light just outside the sphere so the near plane stays positive - // and the whole building fits between near and far along the light axis. - const distance = size + SHADOW_BACKOFF - const near = SHADOW_BACKOFF - const far = distance + size - - for (let index = 0; index < theme.lights.length; index++) { - const config = theme.lights[index] - const light = lightRefs.current[index] - if (!(config?.castShadow && light)) continue - const [ox, oy, oz] = config.position - const dir = shadowDir.current.set(ox, oy, oz) - if (dir.lengthSq() === 0) dir.set(0, 1, 0) - dir.normalize().multiplyScalar(distance) - light.position.set(focus.x + dir.x, focus.y + dir.y, focus.z + dir.z) + const light = lightRefs.current[0] + const themeKey = theme.lights[0] + const castsShadow = atmosphere ? true : Boolean(themeKey?.castShadow) + if (light && castsShadow) { + const focus = shadowFocus.current + const size = shadowRadius.current * SHADOW_MARGIN_SCALE + SHADOW_MARGIN + const distance = size + SHADOW_BACKOFF + const near = SHADOW_BACKOFF + const far = distance + size + if (atmosphere) { + shadowDir.current.copy(atmosphere.sunDirection) + } else if (themeKey) { + shadowDir.current.set(...themeKey.position) + } else { + shadowDir.current.set(0, 1, 0) + } + if (shadowDir.current.lengthSq() === 0) shadowDir.current.set(0, 1, 0) + shadowDir.current.normalize().multiplyScalar(distance) + light.position.copy(focus).add(shadowDir.current) light.target.position.copy(focus) light.target.updateMatrixWorld() - // Resize the ortho frustum to the fitted bounds. The shadow camera is - // the below. const cam = light.shadow?.camera as THREE.OrthographicCamera | undefined if (cam) { - // Shadow-caster-only geometry (hidden roofs/levels in cutaway views) - // is visible to the shadow pass alone β€” see lib/shadow-only.ts. cam.layers.enable(SHADOW_ONLY_LAYER) cam.left = -size cam.right = size @@ -186,10 +195,10 @@ export function Lights() { cam.far = far cam.updateProjectionMatrix() if (SHADOW_CAMERA_DEBUG) { - let helper = shadowHelpers.current[index] + let helper = shadowHelpers.current[0] if (!helper) { helper = new THREE.CameraHelper(cam) - shadowHelpers.current[index] = helper + shadowHelpers.current[0] = helper state.scene.add(helper) } helper.update() @@ -198,119 +207,132 @@ export function Lights() { } } - if (!initialized.current) { - for (let index = 0; index < theme.lights.length; index++) { - const config = theme.lights[index] - const light = lightRefs.current[index] - if (!(config && light)) continue - light.intensity = config.intensity - light.color.set(config.color) + for (const index of lightSlots) { + const light = lightRefs.current[index] + if (!light) continue + const config = theme.lights[index] + const intensity = atmosphere + ? index === 0 + ? atmosphere.sunIntensity + : atmosphere.moonIntensity + : (config?.intensity ?? 0) + const color = atmosphere + ? index === 0 + ? atmosphere.sunColor + : atmosphere.moonColor + : config?.color - if (config.castShadow && light.shadow) { - light.shadow.intensity = config.intensity <= 1 ? config.intensity : MAX_SHADOW_INTENSITY + if (atmosphere || !initialized.current) { + light.intensity = intensity + if (color) light.color.set(color) + } else { + light.intensity = THREE.MathUtils.lerp(light.intensity, intensity, dt) + let target = lightTargets.current[index] + if (!target) { + target = new THREE.Color() + lightTargets.current[index] = target } + if (color) target.set(color) + light.color.lerp(target, dt) } - if (hemiRef.current && theme.hemi) { - hemiRef.current.intensity = theme.hemi.intensity - hemiRef.current.color.set(theme.hemi.sky) - hemiRef.current.groundColor.set(theme.hemi.ground) - } - if (ambientRef.current) { - ambientRef.current.intensity = theme.ambient.intensity - ambientRef.current.color.set(theme.ambient.color) + + if (index === 0 && light.shadow?.intensity !== undefined) { + const shadowIntensity = intensity <= 1 ? intensity : MAX_SHADOW_INTENSITY + light.shadow.intensity = + atmosphere || !initialized.current + ? shadowIntensity + : THREE.MathUtils.lerp(light.shadow.intensity, shadowIntensity, dt) } - initialized.current = true - return } - for (let index = 0; index < theme.lights.length; index++) { - const config = theme.lights[index] - const light = lightRefs.current[index] - if (!(config && light)) continue - - light.intensity = THREE.MathUtils.lerp(light.intensity, config.intensity, dt) - let target = lightTargets.current[index] - if (!target) { - target = new THREE.Color() - lightTargets.current[index] = target + const hemiIntensity = atmosphere ? atmosphere.hemisphereIntensity : theme.hemi?.intensity + const hemiSky = atmosphere ? atmosphere.skyColor : theme.hemi?.sky + const hemiGround = atmosphere ? atmosphere.groundColor : theme.hemi?.ground + if (hemiRef.current && hemiIntensity !== undefined && hemiSky && hemiGround) { + if (atmosphere || !initialized.current) { + hemiRef.current.intensity = hemiIntensity + hemiRef.current.color.set(hemiSky) + hemiRef.current.groundColor.set(hemiGround) + } else { + hemiRef.current.intensity = THREE.MathUtils.lerp( + hemiRef.current.intensity, + hemiIntensity, + dt, + ) + targets.hemiSky.set(hemiSky) + hemiRef.current.color.lerp(targets.hemiSky, dt) + targets.hemiGround.set(hemiGround) + hemiRef.current.groundColor.lerp(targets.hemiGround, dt) } - target.set(config.color) - light.color.lerp(target, dt) + } - if (config.castShadow && light.shadow) { - if (light.shadow.intensity !== undefined) { - light.shadow.intensity = THREE.MathUtils.lerp( - light.shadow.intensity, - config.intensity <= 1 ? config.intensity : MAX_SHADOW_INTENSITY, - dt, - ) - } + if (ambientRef.current) { + const ambientIntensity = atmosphere ? atmosphere.ambientIntensity : theme.ambient.intensity + const ambientColor = atmosphere ? '#ffffff' : theme.ambient.color + if (atmosphere || !initialized.current) { + ambientRef.current.intensity = ambientIntensity + ambientRef.current.color.set(ambientColor) + } else { + ambientRef.current.intensity = THREE.MathUtils.lerp( + ambientRef.current.intensity, + ambientIntensity, + dt, + ) + targets.ambColor.set(ambientColor) + ambientRef.current.color.lerp(targets.ambColor, dt) } } - if (hemiRef.current && theme.hemi) { - hemiRef.current.intensity = THREE.MathUtils.lerp( - hemiRef.current.intensity, - theme.hemi.intensity, - dt, - ) - targets.hemiSky.set(theme.hemi.sky) - hemiRef.current.color.lerp(targets.hemiSky, dt) - targets.hemiGround.set(theme.hemi.ground) - hemiRef.current.groundColor.lerp(targets.hemiGround, dt) - } + initialized.current = true + }, -1) - if (ambientRef.current) { - ambientRef.current.intensity = THREE.MathUtils.lerp( - ambientRef.current.intensity, - theme.ambient.intensity, - dt, - ) - targets.ambColor.set(theme.ambient.color) - ambientRef.current.color.lerp(targets.ambColor, dt) - } - }) + const keyCastsShadow = + !SHADOWS_DISABLED && (atmosphere ? true : Boolean(theme.lights[0]?.castShadow)) return ( <> - {theme.lights.map((light, index) => ( - // The user-facing shadows toggle must NOT flip `castShadow` at runtime: - // three r184's WebGPU node cache keys builder state by castShadow, but - // evicts with the post-toggle key, so flipping off disposes the shadow - // map's GPU texture while the shadows-on cache entry (still referencing - // it) survives. Re-enabling then reuses that stale state and every - // submit fails ("Invalid CommandBuffer ... renderContext_N"). The - // toggle is applied via `renderer.shadowMap.enabled` (Canvas `shadows` - // prop in viewer/index.tsx), which round-trips without disposing. - { - lightRefs.current[index] = ref - }} - shadow-bias={SHADOW_DEPTH_BIAS} - shadow-mapSize={[1024, 1024]} - shadow-normalBias={SHADOW_NORMAL_BIAS} - shadow-radius={2} - > - {light.castShadow && !SHADOWS_DISABLED ? ( - - ) : null} - - ))} - - {theme.hemi ? : null} + {lightSlots.map((index) => { + const themeLight = theme.lights[index] + const direction = + atmosphere && index === 0 + ? atmosphere.sunDirection + : atmosphere && index === 1 + ? atmosphere.moonDirection + : null + return ( + { + lightRefs.current[index] = light + }} + shadow-bias={SHADOW_DEPTH_BIAS} + shadow-mapSize={[1024, 1024]} + shadow-normalBias={SHADOW_NORMAL_BIAS} + shadow-radius={2} + > + {index === 0 && keyCastsShadow ? ( + + ) : null} + + ) + })} + {atmosphere || theme.hemi ? : null} ) diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 1fa7ff5502..8d76fc1da0 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -6,10 +6,10 @@ import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js' import { add, diffuseColor, - float, mix, mrt, normalView, + normalWorldGeometry, oscSine, output, pass, @@ -20,6 +20,7 @@ import { screenUV, smoothstep, time, + float as tslFloat, uniform, vec3, vec4, @@ -37,6 +38,7 @@ import { PostProcessingResources } from '../../lib/post-processing-resources' import { getSceneTheme } from '../../lib/scene-themes' import { packNormalToRGB, unpackRGBToNormal } from '../../lib/tsl-compat' import useViewer from '../../store/use-viewer' +import { useSceneAtmosphere } from './scene-atmosphere' // Scene-referred grade applied before the output tone mapping (AgX). AgX rolls // highlights off gently but reads flat on its own; a mild mid-gray-pivot @@ -62,7 +64,8 @@ export const SSGI_PARAMS = { useTemporalFiltering: false, } -// Diagnostic toggles for thermal A/B testing. Add `?disable=ao,denoise,outline,postFx` +// Diagnostic toggles for thermal A/B testing. Add +// `?disable=ao,denoise,outline,postFx` // to the URL (any subset) and reload to skip those passes. Each flag prevents // allocation + per-frame work for that stage, so device temperature deltas // across combos isolate which pass is the actual culprit. Picked up once at @@ -79,7 +82,12 @@ export const SSGI_PARAMS = { // per-frame vertex/draw cost dominates the whole capture. function readPerfDisableFlags() { if (typeof window === 'undefined') { - return { ao: false, denoise: false, outline: false, postFx: false } + return { + ao: false, + denoise: false, + outline: false, + postFx: false, + } } const raw = new URLSearchParams(window.location.search).get('disable') ?? '' const set = new Set( @@ -195,6 +203,11 @@ const PostProcessingPasses = ({ }) => { const { gl: renderer, invalidate, scene, camera, size } = useThree() const resourcesRef = useRef(null) + const atmosphere = useSceneAtmosphere() + const directSkyNode = useMemo( + () => (atmosphere ? atmosphere.skyRadiance(normalWorldGeometry) : null), + [atmosphere], + ) const hasPipelineErrorRef = useRef(false) const retryCountRef = useRef(0) const rebuildTimeoutRef = useRef | null>(null) @@ -284,6 +297,11 @@ const PostProcessingPasses = ({ setPipelineVersion((v) => v + 1) }, []) + const disposePipeline = useCallback(() => { + resourcesRef.current?.dispose() + resourcesRef.current = null + }, []) + // Reset retry state when project changes useEffect(() => { if (lastProjectIdRef.current === projectId) return @@ -338,8 +356,7 @@ const PostProcessingPasses = ({ if (width < 1 || height < 1) { skippedZeroSizeRef.current = true hasPipelineErrorRef.current = false - resourcesRef.current?.dispose() - resourcesRef.current = null + disposePipeline() return } @@ -355,21 +372,17 @@ const PostProcessingPasses = ({ // allocated every pass. if (disablePostFx || perfDisable.postFx) { hasPipelineErrorRef.current = false - resourcesRef.current?.dispose() - resourcesRef.current = null + disposePipeline() return } const ssgiEnabled = shading === 'rendered' && SSGI_PARAMS.enabled && !perfDisable.ao const denoiseEnabled = ssgiEnabled && !perfDisable.denoise const outlineEnabled = !perfDisable.outline const inkEnabled = edges !== 'off' - // The depth+normal MRT feeds both SSGI and the screen-space ink pass. const needsNormalMRT = ssgiEnabled || inkEnabled // Soft = thin (1px sample radius) + faint (50% opacity); strong = thick // (2px, ~2Γ— wider detected band) + solid (100%). The edge masks saturate, - // so radius+opacity are what actually separate the two modes β€” gain wouldn't. - // Same 1px line thickness for both (soft's thickness is the nice one); - // strong reads heavier purely by being fully solid vs soft's lighter 50%. + // so radius+opacity are what actually separate the two modes. const inkRadius = 1 const inkOpacity = inkOpacityOverride ?? (edges === 'strong' ? 1 : 0.5) @@ -448,11 +461,10 @@ const PostProcessingPasses = ({ contentAlpha, ) as unknown as ReturnType - // Depth + normal MRT β€” shared by SSGI (diffuse/normal) and the ink pass - // (depth/normal). Built whenever either is active. + // Scene depth is shared by SSGI, ink, and outlines. + // The normal MRT is only built when SSGI or ink needs it. const scenePassDepth = scenePass.getTextureNode('depth') let scenePassNormal: any = null - let sceneNormal: any = null if (needsNormalMRT) { scenePass.setMRT( mrt({ @@ -462,13 +474,18 @@ const PostProcessingPasses = ({ }), ) scenePassNormal = scenePass.getTextureNode('normal') + } + if (scenePassNormal) { const normalTexture = scenePass.getTexture('normal') normalTexture.type = UnsignedByteType - // Extract normal from color-encoded texture (SSGI consumes the node form) - sceneNormal = sample((uv) => unpackRGBToNormal(scenePassNormal.sample(uv))) } + // Extract normal from color-encoded texture (SSGI consumes the node form). + const normalNode = scenePassNormal + const sceneNormal = normalNode + ? sample((uv) => unpackRGBToNormal(normalNode.sample(uv))) + : null - if (ssgiEnabled) { + if (ssgiEnabled && scenePassDepth && sceneNormal) { const scenePassDiffuse = scenePass.getTextureNode('diffuseColor') const diffuseTexture = scenePass.getTexture('diffuseColor') diffuseTexture.type = UnsignedByteType @@ -495,7 +512,7 @@ const PostProcessingPasses = ({ if (denoiseEnabled) { // DenoiseNode only denoises RGB β€” alpha is passed through unchanged. // SSGI's AO is a single red channel, so we remap it into RGB before denoising. - const aoAsRgb = vec4(aoTexture.r, aoTexture.r, aoTexture.r, float(1)) + const aoAsRgb = vec4(aoTexture.r, aoTexture.r, aoTexture.r, tslFloat(1)) const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera) denoisePass.index.value = 0 denoisePass.radius.value = 4 @@ -511,11 +528,11 @@ const PostProcessingPasses = ({ // disc and the geometry↔sky depth cliff never grow an AO band β€” that // band read as a visible line along the horizon. const aoFarFade = smoothstep( - float(0.9994), - float(0.9998), + tslFloat(0.9994), + tslFloat(0.9998), scenePassDepth.sample(screenUV).r, ) - ao = mix(ao, float(1), aoFarFade) + ao = mix(ao, tslFloat(1), aoFarFade) // Composite: scene * AO + diffuse * GI sceneColor = vec4( @@ -527,7 +544,7 @@ const PostProcessingPasses = ({ // Screen-space ink outline (SketchUp look) β€” depth/normal edge detection // over the composited scene. Topology-agnostic, so it handles CSG-cut // walls cleanly. Applied before the selection outline + background mix. - if (inkEnabled) { + if (inkEnabled && scenePassDepth && scenePassNormal) { sceneColor = vec4( inkedEdges({ sceneRgb: sceneColor.rgb, @@ -535,7 +552,7 @@ const PostProcessingPasses = ({ normalTex: scenePassNormal, inkColor: inkColorUniform.current, radius: inkRadius, - opacity: float(inkOpacity).mul(inkOpacityScaleUniform.current), + opacity: tslFloat(inkOpacity).mul(inkOpacityScaleUniform.current), }), sceneColor.a, ) @@ -583,7 +600,7 @@ const PostProcessingPasses = ({ // Hovered: blue visible, yellow hidden, pulsing const pulsePeriod = uniform(3) const oscillating = oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) - const osc = mix(oscillating, float(1), hoverPulseMix) + const osc = mix(oscillating, tslFloat(1), hoverPulseMix) const hoverOutline = outlineNode.secondaryVisibleEdge .mul(hoverVisibleColor) .add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor)) @@ -607,37 +624,40 @@ const PostProcessingPasses = ({ // seamlessly exactly where the disc vanishes. const ndc = vec4( screenUV.x.mul(2).sub(1), - float(1).sub(screenUV.y).mul(2).sub(1), + tslFloat(1).sub(screenUV.y).mul(2).sub(1), 1, 1, ) as any const viewRay = (camProjInvUniform.current as any).mul(ndc) const worldDir = (camWorldUniform.current as any).mul(vec4(viewRay.xyz, 0)).xyz.normalize() - let bgGradient = backdropGradient({ - dirY: worldDir.y, - background: bgUniform.current, - haze: bgHazeUniform.current, - sky: bgSkyUniform.current, - skyDeep: bgSkyDeepUniform.current, - }) + let bgGradient = atmosphere + ? atmosphere.skyRadiance(worldDir) + : backdropGradient({ + dirY: worldDir.y, + background: bgUniform.current, + haze: bgHazeUniform.current, + sky: bgSkyUniform.current, + skyDeep: bgSkyDeepUniform.current, + }) if (shading === 'rendered') { bgGradient = gradeRgb(bgGradient) } - const composited = mix(bgGradient, compositeWithOutlines.rgb, contentAlpha) + const sceneComposite = compositeWithOutlines.rgb + const composited = mix(bgGradient, sceneComposite, contentAlpha) // Editor overlays painted on top by their own alpha β€” they never get inked, // AO'd, or outlined, and always read crisp regardless of scene depth. const withOverlay = mix(composited, overlayColor.rgb, overlayColor.a) let finalOutput: ReturnType | ReturnType = vec4( withOverlay, - float(1), + tslFloat(1), ) if (transparentBackground) { const overlayAlpha = overlayColor.a const alpha = overlayAlpha.add(visualAlpha.mul(overlayAlpha.oneMinus())) const straightRgb = overlayColor.rgb .mul(overlayAlpha) - .add(compositeWithOutlines.rgb.mul(visualAlpha).mul(overlayAlpha.oneMinus())) - .div(alpha.max(float(0.00001))) + .add(sceneComposite.mul(visualAlpha).mul(overlayAlpha.oneMinus())) + .div(alpha.max(tslFloat(0.00001))) finalOutput = premultiplyAlpha(renderOutput(vec4(straightRgb, alpha))) } @@ -647,7 +667,6 @@ const PostProcessingPasses = ({ renderPipeline.outputNode = finalOutput retryCountRef.current = 0 } catch (error) { - resources.dispose() hasPipelineErrorRef.current = true console.error( '[viewer/post-processing] Failed to set up post-processing pipeline. Rendering without post FX.', @@ -658,20 +677,19 @@ const PostProcessingPasses = ({ }, error, ) - resourcesRef.current = null + disposePipeline() } - return () => { - resources.dispose() - if (resourcesRef.current === resources) resourcesRef.current = null - } + return disposePipeline }, [ // NOTE: hoverHighlightMode intentionally excluded β€” the hover style is // pushed to uniforms in a separate effect, so a hover must NOT rebuild the // whole pipeline. The uniform refs below are stable (useMemo), so they // never trigger a rebuild either. + atmosphere, camera, disablePostFx, + disposePipeline, hoverHiddenColor, hoverPulseMix, hoverStrength, @@ -746,6 +764,8 @@ const PostProcessingPasses = ({ hasPipelineErrorRef.current || !resourcesRef.current?.pipeline ) { + const previousBackgroundNode = scene.backgroundNode + if (directSkyNode && !transparentBackground) scene.backgroundNode = directSkyNode try { const clearAlpha = transparentBackground ? 0 : 1 if ((renderer as any).setClearColor) { @@ -760,6 +780,8 @@ const PostProcessingPasses = ({ if (PERF_OVERLAY_ENABLED) recordFrameGpuTiming(renderer, submittedAt) } catch (fallbackError) { console.error('[viewer/post-processing] Fallback render failed.', fallbackError) + } finally { + scene.backgroundNode = previousBackgroundNode } return } @@ -783,8 +805,7 @@ const PostProcessingPasses = ({ rendererCtor: (renderer as any).constructor?.name, error, }) - resourcesRef.current?.dispose() - resourcesRef.current = null + disposePipeline() if (retryCountRef.current < MAX_PIPELINE_RETRIES) { // Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit diff --git a/packages/viewer/src/components/viewer/scene-atmosphere.tsx b/packages/viewer/src/components/viewer/scene-atmosphere.tsx new file mode 100644 index 0000000000..9e554fa41d --- /dev/null +++ b/packages/viewer/src/components/viewer/scene-atmosphere.tsx @@ -0,0 +1,146 @@ +'use client' + +import { useThree } from '@react-three/fiber' +import { useLayoutEffect, useMemo, useRef } from 'react' +import { cameraPosition, fog, positionWorld, reference, smoothstep } from 'three/tsl' +import type { Color, Node, Scene, Vector3 } from 'three/webgpu' +import { useStore } from 'zustand' +import { createStore, type StoreApi } from 'zustand/vanilla' + +export type SceneAtmosphereSource = { + skyRadiance(direction: Node<'vec3'>): Node<'vec3'> + reflectionRadiance(direction: Node<'vec3'>): Node<'vec3'> + fogRadiance(direction: Node<'vec3'>): Node<'vec3'> + environmentNode: Node<'vec3'> + sunDirection: Vector3 + sunColor: Color + sunIntensity: number + moonDirection: Vector3 + moonColor: Color + moonIntensity: number + skyColor: Color + groundColor: Color + hemisphereIntensity: number + ambientIntensity: number + exposure: number + fogStart: number + fogEnd: number +} + +type AtmosphereState = { + source: SceneAtmosphereSource | null +} + +type SceneNodeSnapshot = { + environmentNode: Node<'vec3'> | null | undefined + environmentIntensity: number + fogNode: Node | null | undefined +} + +type AtmosphereOwner = { + id: symbol + source: SceneAtmosphereSource + fogNode: Node<'vec4'> +} + +type SceneAtmosphereRegistry = { + base: SceneNodeSnapshot | null + owners: AtmosphereOwner[] + store: StoreApi +} + +const sceneAtmospheres = new WeakMap() + +function registryFor(scene: Scene): SceneAtmosphereRegistry { + let registry = sceneAtmospheres.get(scene) + if (!registry) { + registry = { + base: null, + owners: [], + store: createStore(() => ({ + source: null, + })), + } + sceneAtmospheres.set(scene, registry) + } + return registry +} + +function applyActiveOwner(scene: Scene, registry: SceneAtmosphereRegistry): void { + const active = registry.owners.at(-1) + if (active) { + scene.environmentNode = active.source.environmentNode + scene.environmentIntensity = 1 + scene.fogNode = active.fogNode + registry.store.setState({ source: active.source }) + return + } + + const base = registry.base + if (base) { + scene.environmentNode = base.environmentNode + scene.environmentIntensity = base.environmentIntensity + scene.fogNode = base.fogNode + } + registry.base = null + registry.store.setState({ source: null }) +} + +/** Returns the atmosphere currently owning this React Three Fiber scene. */ +export function useSceneAtmosphere(): SceneAtmosphereSource | null { + const scene = useThree((state) => state.scene) + const registry = useMemo(() => registryFor(scene), [scene]) + return useStore(registry.store, (state) => state.source) +} + +/** + * Installs a generic radiance source into the current scene. The source object is + * expected to remain stable while its colors, vectors, and numbers mutate. + */ +export function SceneAtmosphere({ source }: { source: SceneAtmosphereSource }) { + const scene = useThree((state) => state.scene) + const invalidate = useThree((state) => state.invalidate) + const ownerId = useRef(Symbol('scene-atmosphere')) + const registry = useMemo(() => registryFor(scene), [scene]) + const fogNode = useMemo(() => { + const offset = positionWorld.sub(cameraPosition) + const direction = offset.normalize() + const fogRange: Pick = source + const fogStart: Node<'float'> = reference<'float', typeof fogRange>( + 'fogStart', + 'float', + fogRange, + ) + const fogEnd: Node<'float'> = reference<'float', typeof fogRange>('fogEnd', 'float', fogRange) + const factor = smoothstep(fogStart, fogEnd, offset.length()) + return fog(source.fogRadiance(direction), factor) + }, [source]) + + useLayoutEffect(() => { + if (registry.owners.length === 0) { + registry.base = { + environmentNode: scene.environmentNode, + environmentIntensity: scene.environmentIntensity, + fogNode: scene.fogNode, + } + } + + const owner: AtmosphereOwner = { id: ownerId.current, source, fogNode } + registry.owners.push(owner) + applyActiveOwner(scene, registry) + invalidate() + + return () => { + const index = registry.owners.findIndex((entry) => entry.id === owner.id) + if (index < 0) return + const wasActive = index === registry.owners.length - 1 + registry.owners.splice(index, 1) + if (wasActive) { + applyActiveOwner(scene, registry) + invalidate() + } + } + }, [fogNode, invalidate, registry, scene, source]) + + return null +} diff --git a/packages/viewer/src/components/viewer/scene-environment.tsx b/packages/viewer/src/components/viewer/scene-environment.tsx index 23c332d4bb..08cdf4d2cd 100644 --- a/packages/viewer/src/components/viewer/scene-environment.tsx +++ b/packages/viewer/src/components/viewer/scene-environment.tsx @@ -1,10 +1,11 @@ 'use client' import { useThree } from '@react-three/fiber' -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three/webgpu' import { getSceneTheme } from '../../lib/scene-themes' import useViewer from '../../store/use-viewer' +import { useSceneAtmosphere } from './scene-atmosphere' /** * Scene IBL β€” a small procedural gradient sky (cool zenith β†’ warm horizon β†’ @@ -69,18 +70,30 @@ export function SceneEnvironment() { const scene = useThree((state) => state.scene) const texture = useMemo(buildGradientSky, []) const appearance = useViewer((state) => getSceneTheme(state.sceneTheme).appearance) + const atmosphere = useSceneAtmosphere() + const atmosphereRef = useRef(atmosphere) + atmosphereRef.current = atmosphere useEffect(() => { const prevEnvironment = scene.environment const prevIntensity = scene.environmentIntensity scene.environment = texture - scene.environmentIntensity = appearance === 'dark' ? ENV_INTENSITY_DARK : ENV_INTENSITY return () => { - scene.environment = prevEnvironment - scene.environmentIntensity = prevIntensity + if (scene.environment === texture) { + scene.environment = prevEnvironment + if (!atmosphereRef.current) scene.environmentIntensity = prevIntensity + } texture.dispose() } - }, [scene, texture, appearance]) + }, [scene, texture]) + + useEffect(() => { + // An atmosphere owns environmentIntensity while its environmentNode is + // active. The gradient remains installed as the fallback texture and takes + // over immediately when the atmosphere restores scene ownership. + if (atmosphere) return + scene.environmentIntensity = appearance === 'dark' ? ENV_INTENSITY_DARK : ENV_INTENSITY + }, [appearance, atmosphere, scene]) return null } diff --git a/packages/viewer/src/components/viewer/scene-ground-replacement.test.tsx b/packages/viewer/src/components/viewer/scene-ground-replacement.test.tsx new file mode 100644 index 0000000000..d54585ea53 --- /dev/null +++ b/packages/viewer/src/components/viewer/scene-ground-replacement.test.tsx @@ -0,0 +1,40 @@ +import { expect, test } from 'bun:test' +import { create } from '@react-three/test-renderer' +import { StrictMode } from 'react' +import { SceneGroundReplacement, useSceneGroundReplacement } from './scene-ground-replacement' + +function FallbackGround() { + return useSceneGroundReplacement() ? null : +} + +function Fixture({ owners }: { owners: number }) { + return ( + + + {Array.from({ length: owners }, (_, index) => ( + + ))} + + ) +} + +test('fallback ground is scene-local and returns only after the last replacement releases', async () => { + const replaced = await create() + const untouched = await create() + const groundCount = (renderer: typeof replaced) => + renderer.scene.findAllByProps({ name: 'fallback-ground' }).length + try { + expect(groundCount(replaced)).toBe(0) + expect(groundCount(untouched)).toBe(1) + await replaced.update() + expect(groundCount(replaced)).toBe(0) + await replaced.update() + expect(groundCount(replaced)).toBe(1) + await replaced.update() + expect(groundCount(replaced)).toBe(0) + expect(groundCount(untouched)).toBe(1) + } finally { + await replaced.unmount() + await untouched.unmount() + } +}) diff --git a/packages/viewer/src/components/viewer/scene-ground-replacement.tsx b/packages/viewer/src/components/viewer/scene-ground-replacement.tsx new file mode 100644 index 0000000000..04f480c0ac --- /dev/null +++ b/packages/viewer/src/components/viewer/scene-ground-replacement.tsx @@ -0,0 +1,42 @@ +'use client' + +import { useThree } from '@react-three/fiber' +import { useLayoutEffect, useMemo } from 'react' +import type { Scene } from 'three' +import { useStore } from 'zustand' +import { createStore, type StoreApi } from 'zustand/vanilla' + +type GroundState = { owners: number } +const sceneGrounds = new WeakMap>() + +function groundStore(scene: Scene): StoreApi { + let store = sceneGrounds.get(scene) + if (!store) { + store = createStore(() => ({ owners: 0 })) + sceneGrounds.set(scene, store) + } + return store +} + +/** Whether modeled exterior ground replaces this scene's fallback horizon disc. */ +export function useSceneGroundReplacement(): boolean { + const scene = useThree((state) => state.scene) + const store = useMemo(() => groundStore(scene), [scene]) + return useStore(store, (state) => state.owners > 0) +} + +/** Mount alongside replacement ground. The last release restores the fallback. */ +export function SceneGroundReplacement() { + const scene = useThree((state) => state.scene) + const invalidate = useThree((state) => state.invalidate) + const store = useMemo(() => groundStore(scene), [scene]) + useLayoutEffect(() => { + store.setState((state) => ({ owners: state.owners + 1 })) + invalidate() + return () => { + store.setState((state) => ({ owners: state.owners - 1 })) + invalidate() + } + }, [invalidate, store]) + return null +} diff --git a/packages/viewer/src/components/viewer/viewer-camera.tsx b/packages/viewer/src/components/viewer/viewer-camera.tsx index adb24e9ff3..121ee81c08 100644 --- a/packages/viewer/src/components/viewer/viewer-camera.tsx +++ b/packages/viewer/src/components/viewer/viewer-camera.tsx @@ -1,11 +1,14 @@ import { OrthographicCamera, PerspectiveCamera } from '@react-three/drei' import useViewer from '../../store/use-viewer' +import { useSceneGroundReplacement } from './scene-ground-replacement' export const ViewerCamera = () => { const cameraMode = useViewer((state) => state.cameraMode) + // Exterior ground can include a coarse ocean horizon beyond the local terrain. + const far = useSceneGroundReplacement() ? 20_000 : 1000 return cameraMode === 'perspective' ? ( - + ) : ( ) diff --git a/packages/viewer/src/components/viewer/viewer-presentations.test.tsx b/packages/viewer/src/components/viewer/viewer-presentations.test.tsx new file mode 100644 index 0000000000..1c1652e563 --- /dev/null +++ b/packages/viewer/src/components/viewer/viewer-presentations.test.tsx @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, expect, mock, spyOn, test } from 'bun:test' +import { useScene } from '@pascal-app/core' +import { act, create } from '@react-three/test-renderer' +import { + registerViewerPresentation, + ViewerPresentations, + viewerPresentationRegistry, +} from './viewer-presentations' + +beforeEach(() => { + viewerPresentationRegistry.reset() + useScene.setState({ installedPlugins: [] }) +}) + +afterEach(() => { + viewerPresentationRegistry.reset() + useScene.setState({ installedPlugins: [] }) +}) + +test('plugin install state independently mounts, releases, and remounts each viewer presentation', async () => { + registerViewerPresentation({ + id: 'host:grid', + component: async () => ({ default: () => }), + }) + registerViewerPresentation({ + id: 'acme:nature:presentation', + pluginId: 'acme:nature', + component: async () => ({ default: () => }), + }) + + const first = await create() + const second = await create() + const count = (renderer: typeof first, name: string) => + renderer.scene.findAllByProps({ name }).length + let firstUnmounted = false + + try { + expect(count(first, 'host-presentation')).toBe(1) + expect(count(second, 'host-presentation')).toBe(1) + expect(count(first, 'plugin-presentation')).toBe(0) + + await act(async () => { + useScene.getState().setInstalledPlugins(['acme:nature'], { explicit: true }) + }) + expect(count(first, 'plugin-presentation')).toBe(1) + expect(count(second, 'plugin-presentation')).toBe(1) + + await first.unmount() + firstUnmounted = true + expect(count(second, 'plugin-presentation')).toBe(1) + + await act(async () => { + useScene.getState().setInstalledPlugins([], { explicit: true }) + }) + expect(count(second, 'plugin-presentation')).toBe(0) + + await act(async () => { + useScene.getState().setInstalledPlugins(['acme:nature'], { explicit: true }) + }) + expect(count(second, 'plugin-presentation')).toBe(1) + } finally { + if (!firstUnmounted) await first.unmount() + await second.unmount() + } +}) + +test('a crashing lazy presentation does not remove healthy contributions', async () => { + const originalConsoleError = console.error + console.error = mock(() => {}) + const reportedErrors: unknown[] = [] + const report = spyOn(globalThis, 'reportError').mockImplementation((error) => { + reportedErrors.push(error) + }) + registerViewerPresentation({ + id: 'acme:healthy', + component: async () => ({ default: () => }), + }) + registerViewerPresentation({ + id: 'acme:broken', + component: async () => ({ + default: () => { + throw new Error('broken presentation') + }, + }), + }) + + try { + const renderer = await create() + try { + expect(renderer.scene.findAllByProps({ name: 'healthy-presentation' })).toHaveLength(1) + expect( + reportedErrors.some( + (error) => error instanceof Error && error.message === 'broken presentation', + ), + ).toBe(true) + } finally { + await renderer.unmount() + } + } finally { + console.error = originalConsoleError + report.mockRestore() + } +}) diff --git a/packages/viewer/src/components/viewer/viewer-presentations.tsx b/packages/viewer/src/components/viewer/viewer-presentations.tsx new file mode 100644 index 0000000000..f2757b01d5 --- /dev/null +++ b/packages/viewer/src/components/viewer/viewer-presentations.tsx @@ -0,0 +1,202 @@ +'use client' + +import { type AnyNode, type LazyComponent, useScene } from '@pascal-app/core' +import { type ComponentType, lazy, Suspense, useSyncExternalStore } from 'react' +import type { Object3D, Texture } from 'three' +import { ErrorBoundary } from '../error-boundary' + +export type ViewerPresentationConfiguration = { + /** Returns a detached, versioned snapshot suitable for host-owned persistence. */ + getSnapshot: () => unknown + /** Validates and applies a previously persisted snapshot. */ + restore: (snapshot: unknown) => void + /** Restores the contribution's initial presentation state. */ + reset: () => void + /** Notifies the host only when persisted presentation state changes. */ + subscribe: (onChange: () => void) => () => void +} +export type ViewerPresentationExportContext = { + /** Full semantic snapshot; output selection never removes generation context. */ + nodes: Readonly> + /** Detached contribution configuration captured once when export starts. */ + configuration: unknown + onlyVisible: boolean + excludedNodeTypes: readonly string[] +} + +export type ViewerPresentationStaticExport = { + label: string + build: (ctx: ViewerPresentationExportContext) => Object3D | null | Promise +} +const borrowedStaticExportTextures = new WeakSet() + +/** + * Marks a cached presentation texture handle as borrowed. Static export owns + * every returned resource by default; the host clones marked handles before + * attaching the contribution and never disposes the marked source handle. + */ +export function markViewerPresentationTextureBorrowed(texture: T): T { + borrowedStaticExportTextures.add(texture) + return texture +} + +export function isViewerPresentationTextureBorrowed(texture: Texture): boolean { + return borrowedStaticExportTextures.has(texture) +} + +export type ViewerPresentationContribution = { + /** Globally unique contribution id, conventionally `${pluginId}:presentation`. */ + id: string + /** Project installation gate. Omit only for host-owned, always-on presentation. */ + pluginId?: string + /** Lazy R3F subtree mounted as a sibling of the authored scene renderer. */ + component: LazyComponent + /** Optional host persistence seam; never stored in the semantic scene graph. */ + configuration?: ViewerPresentationConfiguration + /** + * Explicit opt-in static artifact contribution. The returned root must be + * detached and uses the presentation's existing world coordinates. + */ + staticExport?: ViewerPresentationStaticExport +} + +function isDevMode(): boolean { + try { + const meta = import.meta as { env?: { DEV?: boolean } } + if (typeof meta?.env?.DEV === 'boolean') return meta.env.DEV + } catch { + // import.meta unavailable in some CJS contexts β€” fall through. + } + if (typeof process !== 'undefined' && process.env?.NODE_ENV) { + return process.env.NODE_ENV !== 'production' + } + return false +} + +class ViewerPresentationRegistryImpl { + private readonly contributions = new Map() + private readonly listeners = new Set<() => void>() + private cached: ViewerPresentationContribution[] = [] + + subscribe = (onChange: () => void): (() => void) => { + this.listeners.add(onChange) + return () => { + this.listeners.delete(onChange) + } + } + + getSnapshot = (): ViewerPresentationContribution[] => this.cached + + reset(): void { + this.contributions.clear() + this.emit() + } + + register(contribution: ViewerPresentationContribution): void { + if (typeof contribution.id !== 'string' || contribution.id.length === 0) { + throw new Error('[viewer:presentations] contribution id must be a non-empty string') + } + if ( + contribution.pluginId !== undefined && + (typeof contribution.pluginId !== 'string' || contribution.pluginId.length === 0) + ) { + throw new Error('[viewer:presentations] plugin id must be a non-empty string when provided') + } + if (typeof contribution.component !== 'function') { + throw new Error('[viewer:presentations] component must be a lazy component loader') + } + if ( + contribution.configuration !== undefined && + (contribution.configuration === null || + typeof contribution.configuration !== 'object' || + typeof contribution.configuration.getSnapshot !== 'function' || + typeof contribution.configuration.restore !== 'function' || + typeof contribution.configuration.reset !== 'function' || + typeof contribution.configuration.subscribe !== 'function') + ) { + throw new Error( + '[viewer:presentations] configuration must implement getSnapshot, restore, reset, and subscribe', + ) + } + if ( + contribution.staticExport !== undefined && + (contribution.staticExport === null || + typeof contribution.staticExport !== 'object' || + typeof contribution.staticExport.label !== 'string' || + contribution.staticExport.label.length === 0 || + typeof contribution.staticExport.build !== 'function') + ) { + throw new Error( + '[viewer:presentations] staticExport must provide a non-empty label and build function', + ) + } + if (this.contributions.has(contribution.id)) { + if (isDevMode()) { + console.warn(`[viewer:presentations] re-registering "${contribution.id}" (HMR)`) + } else { + throw new Error( + `[viewer:presentations] duplicate id: "${contribution.id}" already registered`, + ) + } + } + this.contributions.set(contribution.id, contribution) + this.emit() + } + + private emit(): void { + this.cached = Array.from(this.contributions.values()) + for (const listener of this.listeners) listener() + } +} + +export const viewerPresentationRegistry = new ViewerPresentationRegistryImpl() + +export function registerViewerPresentation(contribution: ViewerPresentationContribution): void { + viewerPresentationRegistry.register(contribution) +} + +const lazyComponents = new WeakMap() + +function resolvePresentationComponent(loader: LazyComponent): ComponentType { + const cached = lazyComponents.get(loader) + if (cached) return cached + const component = lazy(loader) + lazyComponents.set(loader, component) + return component +} + +function RegisteredViewerPresentation({ + contribution, +}: { + contribution: ViewerPresentationContribution +}) { + const Component = resolvePresentationComponent(contribution.component) + return ( + + + + + + ) +} + +/** + * Mounts registered presentation-only R3F subtrees for the current project. + * Hosts place this once inside each Viewer they want to include presentation; + * semantic scene export remains isolated because the mount is a sibling of + * `scene-renderer`, not one of its authored descendants. + */ +export function ViewerPresentations() { + const contributions = useSyncExternalStore( + viewerPresentationRegistry.subscribe, + viewerPresentationRegistry.getSnapshot, + viewerPresentationRegistry.getSnapshot, + ) + const installedPlugins = useScene((state) => state.installedPlugins) + + return contributions.map((contribution) => + contribution.pluginId && !installedPlugins.includes(contribution.pluginId) ? null : ( + + ), + ) +} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 7346b7b3d2..d1dcc3036e 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -52,7 +52,27 @@ export { DEFAULT_HOVER_STYLES, SSGI_PARAMS, } from './components/viewer/post-processing' +export { + SceneAtmosphere, + type SceneAtmosphereSource, + useSceneAtmosphere, +} from './components/viewer/scene-atmosphere' export { SceneEnvironment } from './components/viewer/scene-environment' +export { + SceneGroundReplacement, + useSceneGroundReplacement, +} from './components/viewer/scene-ground-replacement' +export { + isViewerPresentationTextureBorrowed, + markViewerPresentationTextureBorrowed, + registerViewerPresentation, + type ViewerPresentationConfiguration, + type ViewerPresentationContribution, + type ViewerPresentationExportContext, + type ViewerPresentationStaticExport, + ViewerPresentations, + viewerPresentationRegistry, +} from './components/viewer/viewer-presentations' export { useAssetUrl } from './hooks/use-asset-url' export { useGLTFKTX2 } from './hooks/use-gltf-ktx2' export { useLibraryMaterialsVersion } from './hooks/use-library-materials-version' @@ -98,6 +118,7 @@ export { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, + SHADOW_ONLY_LAYER, setSurfaceRaycastLayers, ZONE_LAYER, } from './lib/layers' @@ -271,6 +292,7 @@ export { getVisibleWallMaterials } from './systems/wall/wall-materials' export { drainRebuiltWalls, getPendingWallRebuildCount, + isWallInitialBuildActive, WallSystem, } from './systems/wall/wall-system' export { diff --git a/packages/viewer/src/lib/perf-panel-store.ts b/packages/viewer/src/lib/perf-panel-store.ts index f39de7bae4..4678d5b435 100644 --- a/packages/viewer/src/lib/perf-panel-store.ts +++ b/packages/viewer/src/lib/perf-panel-store.ts @@ -74,13 +74,29 @@ export type PerfBatchStats = { joins?: number geometryReplacements?: number overflowRebuilds?: number + wallDrain?: { + initialBuildActive: boolean + wallsConsumedThisFrame: number + budgetExits: number + heavyExits: number + drainedExits: number + capExits: number + pendingNeighbours: number + firstBuilds: number + reinvalidationBuilds: number + neighbourEnqueues: number + } geometryBytesCopied?: number } let batchStats: PerfBatchStats = { items: 0, instances: 0, containers: 0 } -export function publishPerfBatchStats(stats: PerfBatchStats): void { - batchStats = stats +export function publishPerfBatchStats(stats: Partial): void { + batchStats = { ...batchStats, ...stats } +} + +export function publishPerfWallDrainStats(stats: NonNullable): void { + batchStats.wallDrain = stats } export function readPerfBatchStats(): PerfBatchStats { diff --git a/packages/viewer/src/lib/renderer-capability.ts b/packages/viewer/src/lib/renderer-capability.ts index a528fda857..0bf90e6967 100644 --- a/packages/viewer/src/lib/renderer-capability.ts +++ b/packages/viewer/src/lib/renderer-capability.ts @@ -152,7 +152,9 @@ export async function initializeGpuRenderer void - exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise) | null - setExportScene: (fn: ((format?: 'glb' | 'stl' | 'obj') => Promise) | null) => void + exportScene: ((format?: 'glb' | 'usdz' | 'stl' | 'obj') => Promise) | null + setExportScene: (fn: ((format?: 'glb' | 'usdz' | 'stl' | 'obj') => Promise) | null) => void } declare const useViewer: import('zustand').UseBoundStore> export default useViewer diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 9eb3b2584e..90edab7088 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -148,8 +148,8 @@ type ViewerState = { bumpGeometryRevision: () => void // Export functionality - exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise) | null - setExportScene: (fn: ((format?: 'glb' | 'stl' | 'obj') => Promise) | null) => void + exportScene: ((format?: 'glb' | 'usdz' | 'stl' | 'obj') => Promise) | null + setExportScene: (fn: ((format?: 'glb' | 'usdz' | 'stl' | 'obj') => Promise) | null) => void debugColors: boolean setDebugColors: (enabled: boolean) => void diff --git a/packages/viewer/src/systems/level/level-system.test.ts b/packages/viewer/src/systems/level/level-system.test.ts index ded9f6c4a4..e0f75dd1b4 100644 --- a/packages/viewer/src/systems/level/level-system.test.ts +++ b/packages/viewer/src/systems/level/level-system.test.ts @@ -1,55 +1,18 @@ -// @ts-expect-error β€” bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { afterEach, describe, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import type { AnyNode, AnyNodeId } from '@pascal-app/core' import { sceneRegistry, useScene } from '@pascal-app/core' -import type { Object3D } from 'three' - -// Only the two modules that need a renderer or a React context are mocked. -// `@pascal-app/core` is deliberately NOT mocked: mock.module replaces a module -// for the whole test process and Bun never restores it, so faking core here -// breaks the other viewer suites that run after this file. -type FrameCallback = (state: unknown, delta: number) => void -let frameCallback: FrameCallback | null = null - -// Read through a function so the value is not control-flow narrowed. The -// useFrame mock assigns frameCallback while LevelSystem() runs; TypeScript -// cannot see through that indirection, so reading the binding directly after -// `frameCallback = null` narrows it to `null` and types the call `never`. -function takeFrameCallback(): FrameCallback | null { - return frameCallback -} - -mock.module('@react-three/fiber', () => ({ - useFrame: (callback: FrameCallback) => { - frameCallback = callback - }, -})) +import { create } from '@react-three/test-renderer' +import { createElement } from 'react' +import { Object3D } from 'three' +import useViewer from '../../store/use-viewer' +import { LevelSystem } from './level-system' +import { snapLevelsToTruePositions } from './level-utils' -let viewerState = { - levelMode: 'stacked' as 'stacked' | 'exploded' | 'solo', - selection: { levelId: null as string | null }, -} +let previousViewerState = useViewer.getState() -mock.module('../../store/use-viewer', () => ({ - default: { - getState: () => viewerState, - }, -})) - -const [{ LevelSystem }, { snapLevelsToTruePositions }] = await Promise.all([ - import('./level-system'), - import('./level-utils'), -]) - -/** Stand-in for a level's Object3D β€” LevelSystem only touches these fields. */ -function fakeLevelObject(): Object3D { - return { - position: { y: -100 }, - visible: true, - layers: { mask: 0 }, - } as unknown as Object3D -} +beforeEach(() => { + previousViewerState = useViewer.getState() +}) function setupLevels(baseElevations: number[]) { const buildingId = 'building_base-elevation-system-test' @@ -81,7 +44,8 @@ function setupLevels(baseElevations: number[]) { useScene.setState({ nodes }) const objects = levels.map((level) => { - const object = fakeLevelObject() + const object = new Object3D() + object.position.y = -100 sceneRegistry.nodes.set(level.id, object) sceneRegistry.byType.level!.add(level.id) return object @@ -94,47 +58,52 @@ function setLevelMode( mode: 'stacked' | 'exploded' | 'solo', selectedLevelId: string | null = null, ) { - viewerState = { + useViewer.setState({ levelMode: mode, - selection: { levelId: selectedLevelId }, - } + selection: { ...useViewer.getState().selection, levelId: selectedLevelId }, + }) } -function updateLevelPresentation(delta: number) { - frameCallback = null - LevelSystem() - const callback = takeFrameCallback() - expect(callback).not.toBeNull() - callback?.({}, delta) +async function updateLevelPresentation(delta: number) { + const renderer = await create(createElement(LevelSystem)) + try { + await renderer.advanceFrames(1, delta) + } finally { + await renderer.unmount() + } } afterEach(() => { sceneRegistry.clear() useScene.setState({ nodes: {} as Record }) + useViewer.setState({ + levelMode: previousViewerState.levelMode, + selection: previousViewerState.selection, + }) }) describe('updateLevelPresentation', () => { - test('writes offset positions to the registry transform used by floorplan and selection', () => { + test('writes offset positions to the registry transform used by floorplan and selection', async () => { const { objects } = setupLevels([0, 1.25, 0]) setLevelMode('stacked') - updateLevelPresentation(1 / 12) + await updateLevelPresentation(1 / 12) expect(objects.map((object) => object.position.y)).toEqual([0, 3.75, 6.25]) }) - test('keeps offset-aware positions in exploded and solo modes', () => { + test('keeps offset-aware positions in exploded and solo modes', async () => { const { levels, objects } = setupLevels([1, 0.5]) setLevelMode('exploded') - updateLevelPresentation(1 / 12) + await updateLevelPresentation(1 / 12) expect(objects.map((object) => object.position.y)).toEqual([1, 9]) objects.forEach((object) => { object.position.y = -100 }) setLevelMode('solo', levels[1]!.id) - updateLevelPresentation(1 / 12) + await updateLevelPresentation(1 / 12) expect(objects.map((object) => object.position.y)).toEqual([1, 4]) expect(objects[0]!.visible).toBe(false) expect(objects[1]!.visible).toBe(true) diff --git a/packages/viewer/src/systems/roof/roof-system.test.ts b/packages/viewer/src/systems/roof/roof-system.test.ts index 910a445509..f167b04696 100644 --- a/packages/viewer/src/systems/roof/roof-system.test.ts +++ b/packages/viewer/src/systems/roof/roof-system.test.ts @@ -3,7 +3,70 @@ import { describe, expect, test } from 'bun:test' import { type AnyNode, RoofNode, RoofSegmentNode } from '@pascal-app/core' import * as THREE from 'three' -import { generateRoofSegmentGeometry } from './roof-system' +import { Evaluator, SUBTRACTION } from 'three-bvh-csg' +import { generateRoofSegmentGeometry, getRoofSegmentBrushes } from './roof-system' + +describe('roof system gable geometry', () => { + test('keeps a zero-height gable wall shell exactly on its base', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 0, + wallThickness: 0.1, + pitch: 40, + }) + const brushes = getRoofSegmentBrushes(segment) + expect(brushes).not.toBeNull() + if (!brushes) return + + const shell = new Evaluator().evaluate(brushes.wallBrush, brushes.innerBrush, SUBTRACTION) + try { + brushes.wallBrush.geometry.computeBoundingBox() + expect(brushes.wallBrush.geometry.boundingBox!.min.y).toBe(0) + expect(shell.geometry.getAttribute('position').count).toBeGreaterThan(0) + shell.geometry.computeBoundingBox() + expect(shell.geometry.boundingBox!.min.y).toBeCloseTo(0, 12) + } finally { + shell.geometry.dispose() + brushes.wallBrush.geometry.dispose() + brushes.innerBrush.geometry.dispose() + brushes.deckSlab.geometry.dispose() + brushes.shinSlab.geometry.dispose() + brushes.rakeBoards?.dispose() + } + }) + + test('lifts the inner cutter with the shell so a flat zero-height roof stays hollow', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'flat', + width: 8, + depth: 6, + wallHeight: 0, + wallThickness: 0.1, + pitch: 0, + }) + const brushes = getRoofSegmentBrushes(segment) + expect(brushes).not.toBeNull() + if (!brushes) return + try { + brushes.wallBrush.geometry.computeBoundingBox() + brushes.innerBrush.geometry.computeBoundingBox() + expect(brushes.wallBrush.geometry.boundingBox!.min.y).toBe(0) + expect(brushes.wallBrush.geometry.boundingBox!.max.y).toBeCloseTo(0.05, 6) + expect(brushes.innerBrush.geometry.boundingBox!.max.y).toBeCloseTo( + brushes.wallBrush.geometry.boundingBox!.max.y, + 6, + ) + } finally { + brushes.wallBrush.geometry.dispose() + brushes.innerBrush.geometry.dispose() + brushes.deckSlab.geometry.dispose() + brushes.shinSlab.geometry.dispose() + brushes.rakeBoards?.dispose() + } + }) +}) describe('roof system shed geometry', () => { function inspectShedGeometry(segment: RoofSegmentNode) { @@ -179,9 +242,11 @@ describe('roof system shed geometry', () => { shedInsetEndPanels: true, wallShell: 'omit', }) - const { geometry, roofSideX, sideInfillNormals, sideInfillX } = inspectShedGeometry(segment) + const { geometry, roofSideX, sideInfillNormals, sideInfillX, wallVertexYs } = + inspectShedGeometry(segment) expect(sideInfillNormals).toHaveLength(2) + expect(Math.min(...wallVertexYs)).toBeCloseTo(0.05, 5) expect(Math.max(...sideInfillX.map((x) => Math.abs(x)))).toBeCloseTo(infillHalfWidth, 5) expect(Math.max(...sideInfillX.map((x) => Math.abs(x)))).toBeGreaterThan(span / 2) expect(Math.max(...sideInfillX.map((x) => Math.abs(x)))).toBeLessThan(span / 2 + leftOverhang) diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 3c4b966c0a..e2d651e662 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -1380,7 +1380,10 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe const dV = Math.max(0.01, depth + 2 * wExt) const autoDrop = wExt * tanTheta - const whV = Math.max(0.01, wallHeight - autoDrop + vOffset) + // Floor every prism at 5 cm so CSG never sees a degenerate volume β€” by + // raising the top, never by sinking the base (the base is the wall top). + // One floor for all volumes keeps each cutter level with the shell it carves. + const whV = Math.max(0.05, wallHeight - autoDrop + vOffset) let rhV = activeRh if (activeRh > 0) { @@ -1388,8 +1391,6 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe if (roofType === 'shed') rhV = activeRh + 2 * autoDrop } - const safeBaseY = Math.min(baseY, whV - 0.05) - let structuralI = baseI if (isVoid) { structuralI += deckThickness @@ -1401,7 +1402,7 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe d: dV, wh: whV, rh: rhV, - baseY: safeBaseY, + baseY, insets: { dutchI: structuralI }, baseW: width, baseD: depth, @@ -3589,7 +3590,7 @@ function createShedInsetEndPanelGeometry(node: RoofSegmentNode): THREE.BufferGeo }) const wallOuterOffset = node.wallThickness / 2 const autoDrop = wallOuterOffset * tanTheta - const wh = Math.max(0.01, node.wallHeight - autoDrop) + const wh = Math.max(0.05, node.wallHeight - autoDrop) const rh = activeRh > 0 ? activeRh + 2 * autoDrop : activeRh const faces = getRoofModuleFaces({ diff --git a/packages/viewer/src/systems/stair/stair-system.tsx b/packages/viewer/src/systems/stair/stair-system.tsx index e63b75fe13..ef3ab9abba 100644 --- a/packages/viewer/src/systems/stair/stair-system.tsx +++ b/packages/viewer/src/systems/stair/stair-system.tsx @@ -1,6 +1,7 @@ import { type AnyNode, type AnyNodeId, + createStairFlightFromStair, getEffectiveNode, getFloorStackedPosition, type StairNode, @@ -319,18 +320,20 @@ function updateMergedStairGeometry( .filter((n): n is StairSegmentNode => n?.type === 'stair-segment') .map((n) => getEffectiveNode(n)) - if (segments.length === 0) { - replaceMeshGeometry(mergedMesh, createEmptyGeometry()) - return - } + // A straight stair with no segments has nothing to merge and would render as + // nothing at all β€” the state a stair authored as curved lands in the moment + // it is switched to straight. Draw the flight its own fields describe + // instead of vanishing; it is the same flight the panel materializes. + const bodySegments = + segments.length > 0 ? segments : [createStairFlightFromStair(stairNode, nodes)] // Compute chained transforms for segments - const transforms = computeSegmentTransforms(segments) + const transforms = computeSegmentTransforms(bodySegments) const geometries: THREE.BufferGeometry[] = [] - for (let i = 0; i < segments.length; i++) { - const segment = segments[i]! + for (let i = 0; i < bodySegments.length; i++) { + const segment = bodySegments[i]! const transform = transforms[i]! const absoluteHeight = transform.position[1] diff --git a/packages/viewer/src/systems/wall/level-miter-cache.ts b/packages/viewer/src/systems/wall/level-miter-cache.ts index 2f7dc57ed0..e63744445b 100644 --- a/packages/viewer/src/systems/wall/level-miter-cache.ts +++ b/packages/viewer/src/systems/wall/level-miter-cache.ts @@ -1,9 +1,8 @@ import { calculateLevelMiters, type WallMiterData, type WallNode } from '@pascal-app/core' -// A progressive rebuild drains 8 walls per frame, so a 1081-wall import takes -// ~136 frames. The miter solution does not change across those frames β€” nothing -// dirties the geometry in between β€” yet the naive code recomputed it every -// frame. Cache it, keyed on the exact wall data the miters depend on. +// Progressive rebuilds span frames (initial hydration uses an 8 ms budget; +// interactive bulk edits also cap at 8 walls). The miter solution is stable +// between input changes, so cache it by the exact data the miters depend on. // // The comparison is exact (no hashing): a stale hit would silently render wrong // joints, and 7 numeric compares Γ— N walls is microseconds β€” far cheaper than diff --git a/packages/viewer/src/systems/wall/wall-build-lifecycle.ts b/packages/viewer/src/systems/wall/wall-build-lifecycle.ts new file mode 100644 index 0000000000..430562eb3b --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-build-lifecycle.ts @@ -0,0 +1,77 @@ +import { useScene } from '@pascal-app/core' +import { PERF_OVERLAY_ENABLED } from '../../lib/gpu-perf' +import { type PerfBatchStats, publishPerfWallDrainStats } from '../../lib/perf-panel-store' +import { beginSpan, endSpan, type PerfSpanHandle } from '../../lib/perf-tracks' + +export const pendingAdjacentByLevel = new Map>() +let hydrationId: object | null = null +let hydrationToken: object | null = null +let initialBuildActive = false +let initialBuildSpan: PerfSpanHandle | null = null +export const initiallyBuiltWalls = new Set() +export const drainStats: NonNullable = { + initialBuildActive: false, + wallsConsumedThisFrame: 0, + budgetExits: 0, + heavyExits: 0, + drainedExits: 0, + capExits: 0, + pendingNeighbours: 0, + firstBuilds: 0, + reinvalidationBuilds: 0, + neighbourEnqueues: 0, +} + +export function publishWallDrainStats() { + drainStats.initialBuildActive = initialBuildActive + if (PERF_OVERLAY_ENABLED) publishPerfWallDrainStats(drainStats) +} + +export function endInitialBuild() { + if (!initialBuildActive) return + initialBuildActive = false + endSpan(initialBuildSpan) + initialBuildSpan = null + publishWallDrainStats() +} + +export function isWallInitialBuildActive(): boolean { + const state = useScene.getState() + if (state.hydrationId !== hydrationId) { + endInitialBuild() + hydrationId = state.hydrationId + initiallyBuiltWalls.clear() + pendingAdjacentByLevel.clear() + for (const key of Object.keys(drainStats) as (keyof typeof drainStats)[]) { + if (key !== 'initialBuildActive') drainStats[key] = 0 + } + publishWallDrainStats() + } + const token = state.hydrationToken + if (token !== hydrationToken) { + endInitialBuild() + hydrationToken = token + if (token) { + initialBuildActive = true + initialBuildSpan = beginSpan('wall-initial-build') + publishWallDrainStats() + } + } + return initialBuildActive +} + +useScene.subscribe(() => isWallInitialBuildActive()) + +export function subscribeWallBuildInteractions( + target: EventTarget | null, +): (() => void) | undefined { + if (!target) return + const interrupt = () => useScene.getState().invalidateHydration() + const events = ['pointerdown', 'pointermove', 'wheel'] + for (const event of events) + target.addEventListener(event, interrupt, { capture: true, passive: true }) + isWallInitialBuildActive() + return () => { + for (const event of events) target.removeEventListener(event, interrupt, true) + } +} diff --git a/packages/viewer/src/systems/wall/wall-progressive-budget.test.ts b/packages/viewer/src/systems/wall/wall-progressive-budget.test.ts index 98947b8c88..e972021200 100644 --- a/packages/viewer/src/systems/wall/wall-progressive-budget.test.ts +++ b/packages/viewer/src/systems/wall/wall-progressive-budget.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' import { type AnyNode, type AnyNodeId, @@ -69,3 +71,706 @@ describe('progressive wall budget', () => { } }) }) + +// Isolate source aliases from Bun's process-global mocks while live dists stay untouched. +test('initial wall drain lifecycle and scheduling against source packages', () => { + const sourcePath = (path: string) => + JSON.stringify(resolve(import.meta.dir, '../../../../..', path)) + const cache = join(import.meta.dir, '.turbo') + mkdirSync(cache, { recursive: true }) + const directory = mkdtempSync(join(cache, 'initial-build-')) + try { + const preload = join(directory, 'preload.ts') + writeFileSync( + preload, + ` + import { mock } from 'bun:test' + mock.module(${sourcePath('packages/viewer/src/lib/gpu-perf.ts')}, () => ({ PERF_OVERLAY_ENABLED: process.env.WALL_TEST_PERF !== 'off' })) + mock.module('@pascal-app/core', () => require(${sourcePath('packages/core/src/index.ts')})) + `, + ) + const probe = join(directory, 'probe.test.ts') + writeFileSync( + probe, + ` +import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test' +import { + type AnyNode, + initSpaceDetectionSync, + applySceneSnapshot, + applyScenePatch, + BuildingNode, + ElevatorNode, + StairNode, + StairSegmentNode, + SlabNode, + CeilingNode, + DoorNode, + LevelNode, + sceneRegistry, + useLiveNodeOverrides, + useLiveTransforms, + useScene, + WallNode, +} from '@pascal-app/core' +import { BoxGeometry, Mesh, MeshBasicMaterial } from 'three' +import { publishPerfBatchStats, readPerfBatchStats } from ${sourcePath('packages/viewer/src/lib/perf-panel-store.ts')} +import { + getPendingWallRebuildCount, + isWallInitialBuildActive, + runWallBuildFrame, +} from ${sourcePath('packages/viewer/src/systems/wall/wall-system.tsx')} +import { subscribeWallBuildInteractions } from ${sourcePath('packages/viewer/src/systems/wall/wall-build-lifecycle.ts')} +import { initializeElevatorOpeningSync } from ${sourcePath('packages/core/src/systems/elevator/elevator-opening-system.tsx')} +import { initializeStairOpeningSync } from ${sourcePath('packages/core/src/systems/stair/stair-opening-system.tsx')} +import { subscribePerfSamples } from ${sourcePath('packages/viewer/src/lib/perf-tracks.ts')} +import { WALL_PLACEHOLDER_SWEEP_INTERVAL } from ${sourcePath('packages/viewer/src/systems/wall/wall-placeholder-sweep.ts')} + +let now = 0 +let rebuildCost = 0 +let restoreClock: () => void +let restoreRaf: () => void +let unsubscribe: () => void +let canvas: EventTarget +const meshes: Mesh[] = [] +const rafs = new Map() +let nextRaf = 0 + +beforeEach(() => { + const request = globalThis.requestAnimationFrame + const cancel = globalThis.cancelAnimationFrame + rafs.clear() + globalThis.requestAnimationFrame = (callback) => { + rafs.set(++nextRaf, callback) + return nextRaf + } + globalThis.cancelAnimationFrame = (id) => { rafs.delete(id) } + restoreRaf = () => { + globalThis.requestAnimationFrame = request + globalThis.cancelAnimationFrame = cancel + } + now = 0 + rebuildCost = 0 + const clock = spyOn(performance, 'now').mockImplementation(() => now) + restoreClock = () => clock.mockRestore() + useScene.getState().unloadScene() + useScene.setState({ readOnly: false }) + sceneRegistry.clear() + canvas = new EventTarget() + unsubscribe = subscribeWallBuildInteractions(canvas) +}) + +afterEach(() => { + unsubscribe() + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + for (const mesh of meshes.splice(0)) { + mesh.geometry.dispose() + ;(mesh.material as MeshBasicMaterial).dispose() + } + sceneRegistry.clear() + useScene.getState().unloadScene() + restoreClock() + restoreRaf() +}) + +function register(wall: WallNode) { + const mesh = new Mesh(new BoxGeometry(), new MeshBasicMaterial()) + mesh.geometry.addEventListener('dispose', () => { + now += rebuildCost + }) + sceneRegistry.nodes.set(wall.id, mesh) + sceneRegistry.byType.wall.add(wall.id) + meshes.push(mesh) + return mesh +} + +function hydrate(count = 20, heavyIndex = -1, mountedCount = count) { + const level = LevelNode.parse({ height: 3 }) + const walls = Array.from({ length: count }, (_, index) => + WallNode.parse({ + parentId: level.id, + start: [index * 12, 0], + end: [(index + 1) * 12, 0], + height: 3, + }), + ) + const openings = + heavyIndex < 0 + ? [] + : Array.from({ length: 6 }, (_, index) => + DoorNode.parse({ + parentId: walls[heavyIndex]!.id, + position: [index * 1.5 + 1, 0, 0], + }), + ) + if (heavyIndex >= 0) walls[heavyIndex]!.children = openings.map((node) => node.id) + level.children = walls.map((wall) => wall.id) + useScene + .getState() + .setScene(Object.fromEntries([level, ...walls, ...openings].map((node) => [node.id, node])), [ + level.id, + ]) + for (const wall of walls.slice(0, mountedCount)) register(wall) + return walls +} + +const stats = () => readPerfBatchStats().wallDrain! + +function buildingWithOpenings() { + const building = BuildingNode.parse({}) + const ground = LevelNode.parse({ parentId: building.id, level: 0, height: 3 }) + const upper = LevelNode.parse({ parentId: building.id, level: 1, height: 3 }) + const slab = SlabNode.parse({ parentId: upper.id, polygon: [[0, 0], [10, 0], [10, 10], [0, 10]], holes: [] }) + const elevator = ElevatorNode.parse({ parentId: building.id, position: [2, 0, 2], fromLevelId: ground.id, toLevelId: upper.id }) + const stair = StairNode.parse({ parentId: ground.id, position: [5, 0, 5], fromLevelId: ground.id, toLevelId: upper.id, slabOpeningMode: 'destination' }) + const segment = StairSegmentNode.parse({ parentId: stair.id, height: 1 }) + stair.children = [segment.id] + const walls = Array.from({ length: 12 }, (_, index) => WallNode.parse({ parentId: ground.id, start: [index * 12, 0], end: [(index + 1) * 12, 0] })) + ground.children = [...walls.map(wall => wall.id), stair.id] + upper.children = [slab.id] + building.children = [ground.id, upper.id, elevator.id] + return { building, slab, segment, walls, nodes: Object.fromEntries([building, ground, upper, slab, elevator, stair, segment, ...walls].map(node => [node.id, node])) } +} + +test('elevator reconciliation finishes before publishing hydration and the first wall frame', async () => { + const scene = buildingWithOpenings() + const stop = initializeElevatorOpeningSync() + try { + useScene.getState().setScene(scene.nodes, [scene.building.id]) + expect((useScene.getState().nodes[scene.slab.id] as SlabNode).holes).toHaveLength(1) + await new Promise(resolve => queueMicrotask(resolve)) + expect(useScene.getState().hydrationToken).not.toBeNull() + for (const wall of scene.walls) register(wall) + expect(isWallInitialBuildActive()).toBe(true) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(12) + } finally { stop() } +}) + +test.each(['none', 'edit', 'host', 'remote', 'wheel'])('deferred stair normalization completes hydration unless interrupted by %s', async (interrupt) => { + const scene = buildingWithOpenings() + const stop = initializeStairOpeningSync() + try { + useScene.getState().setScene(scene.nodes, [scene.building.id]) + expect(useScene.getState().hydrationToken).toBeNull() + if (interrupt === 'edit') useScene.getState().updateNode(scene.walls[0]!.id, { height: 4 }) + if (interrupt === 'host') useScene.setState(state => ({ nodes: { ...state.nodes, [scene.walls[0]!.id]: { ...state.nodes[scene.walls[0]!.id], height: 4 } as AnyNode } })) + if (interrupt === 'remote') expect(applyScenePatch({ materialChanges: [], nodeUpdates: [{ id: scene.walls[0]!.id, data: { height: 4 }, removeFields: [] }] })).toBe(true) + if (interrupt === 'wheel') canvas.dispatchEvent(new Event('wheel')) + await new Promise(resolve => queueMicrotask(resolve)) + expect((useScene.getState().nodes[scene.segment.id] as StairSegmentNode).height).toBe(3) + expect((useScene.getState().nodes[scene.slab.id] as SlabNode).holes!.length).toBeGreaterThan(0) + expect(isWallInitialBuildActive()).toBe(interrupt === 'none') + for (const wall of scene.walls) register(wall) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(interrupt === 'none' ? 12 : 8) + } finally { stop() } +}) + +test('opening normalization belongs to hydration even when the systems mount after setScene', async () => { + const scene = buildingWithOpenings() + useScene.getState().setScene(scene.nodes, [scene.building.id]) + expect(useScene.getState().hydrationToken).toBeNull() + await new Promise(resolve => queueMicrotask(resolve)) + const token = useScene.getState().hydrationToken + expect(token).not.toBeNull() + expect((useScene.getState().nodes[scene.segment.id] as StairSegmentNode).height).toBe(3) + expect((useScene.getState().nodes[scene.slab.id] as SlabNode).holes!.length).toBe(2) + const stopStair = initializeStairOpeningSync() + const stopElevator = initializeElevatorOpeningSync() + try { + await new Promise(resolve => queueMicrotask(resolve)) + expect(useScene.getState().hydrationToken).toBe(token) + for (const wall of scene.walls) register(wall) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(12) + } finally { stopStair(); stopElevator() } +}) + +test('locked snapshot hydration does not defer derived writes beyond the mutation lock', async () => { + const scene = buildingWithOpenings() + useScene.setState({ readOnly: true }) + useScene.getState().setScene(scene.nodes, [scene.building.id]) + useScene.setState({ readOnly: false }) + const token = useScene.getState().hydrationToken + await new Promise(resolve => queueMicrotask(resolve)) + expect(useScene.getState().hydrationToken).toBe(token) + expect((useScene.getState().nodes[scene.segment.id] as StairSegmentNode).height).toBe(1) + expect((useScene.getState().nodes[scene.slab.id] as SlabNode).holes).toEqual([]) +}) + +test('replacing a hydration before its normalization runs cannot publish an obsolete token', async () => { + const first = buildingWithOpenings() + const second = buildingWithOpenings() + useScene.getState().setScene(first.nodes, [first.building.id]) + useScene.getState().setScene(second.nodes, [second.building.id]) + const identity = useScene.getState().hydrationId + await new Promise(resolve => queueMicrotask(resolve)) + expect(useScene.getState().hydrationToken).toBe(identity) + expect(useScene.getState().nodes[first.building.id]).toBeUndefined() + expect((useScene.getState().nodes[second.segment.id] as StairSegmentNode).height).toBe(3) +}) + +test('replacing an already-known level completes space reconciliation before issuing its token', () => { + const level = LevelNode.parse({ height: 3 }) + const points = [[0, 0], [12, 0], [12, 8], [0, 8]] + const walls = points.map((start, index) => WallNode.parse({ parentId: level.id, start, end: points[(index + 1) % 4] })) + const slab = SlabNode.parse({ parentId: level.id, polygon: points, autoFromWalls: true }) + level.children = [...walls.map(wall => wall.id), slab.id] + const nodes = Object.fromEntries([level, slab, ...walls].map(node => [node.id, node])) + useScene.getState().setScene(nodes, [level.id]) + const editorState = { spaces: {}, setSpaces(spaces: object) { this.spaces = spaces } } + const stop = initSpaceDetectionSync(useScene, { getState: () => editorState }) + try { + const next = { ...nodes, [walls[0]!.id]: { ...walls[0]!, end: [12, 1] }, [walls[1]!.id]: { ...walls[1]!, start: [12, 1] } } + useScene.getState().setScene(next as Record, [level.id]) + expect((useScene.getState().nodes[slab.id] as SlabNode).polygon).not.toEqual(slab.polygon) + expect(isWallInitialBuildActive()).toBe(true) + for (const wall of walls) register(wall) + runWallBuildFrame() + expect(stats().firstBuilds).toBe(4) + } finally { stop() } +}) + +test('snapshot clears stale live maps before its token is published', () => { + const walls = hydrate(12) + useLiveNodeOverrides.getState().set(walls[0]!.id, { height: 9 }) + useLiveTransforms.getState().set(walls[0]!.id, { position: [0, 2, 0], rotation: 0 }) + const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState() + useScene.temporal.getState().resume() + applySceneSnapshot({ nodes, rootNodeIds, collections, materials, installedPlugins }, { origin: 'load' }) + expect(useLiveNodeOverrides.getState().overrides.size).toBe(0) + expect(useLiveTransforms.getState().transforms.size).toBe(0) + expect(isWallInitialBuildActive()).toBe(true) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(12) +}) + +test('pre-consumer wheel revokes the owner token and cannot be revived on mount', () => { + hydrate(12) + canvas.dispatchEvent(new Event('wheel')) + expect(useScene.getState().hydrationToken).toBeNull() + unsubscribe() + unsubscribe = subscribeWallBuildInteractions(canvas)! + expect(isWallInitialBuildActive()).toBe(false) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(8) + expect(stats().firstBuilds).toBe(8) + expect(stats().reinvalidationBuilds).toBe(0) +}) + +test('reattaching mid-drain preserves the hydration counters and one continuous span', () => { + const spans: number[] = [] + const stop = subscribePerfSamples((track, ms) => { if (track === 'wall-initial-build') spans.push(ms) }) + try { + hydrate(12) + const token = useScene.getState().hydrationToken + rebuildCost = 4 + runWallBuildFrame() + expect(stats().firstBuilds).toBe(2) + unsubscribe() + now += 20 + unsubscribe = subscribeWallBuildInteractions(canvas)! + expect(useScene.getState().hydrationToken).toBe(token) + expect(stats().firstBuilds).toBe(2) + expect(stats().budgetExits).toBe(1) + expect(spans).toEqual([]) + rebuildCost = 0 + runWallBuildFrame() + expect(stats().firstBuilds).toBe(12) + expect(spans).toEqual([28]) + } finally { stop() } +}) + +test('an unregistered dirty wall loses privilege after the renderer grace period without being cleared', () => { + const walls = hydrate(1, -1, 0) + for (let i = 0; i < WALL_PLACEHOLDER_SWEEP_INTERVAL - 1; i++) runWallBuildFrame() + expect(isWallInitialBuildActive()).toBe(true) + runWallBuildFrame() + expect(isWallInitialBuildActive()).toBe(false) + expect(useScene.getState().hydrationToken).toBeNull() + expect(useScene.getState().dirtyNodes.has(walls[0]!.id)).toBe(true) + expect(stats().drainedExits).toBe(0) + register(walls[0]!) + runWallBuildFrame() + expect(stats().firstBuilds).toBe(1) + expect(useScene.getState().dirtyNodes.has(walls[0]!.id)).toBe(false) +}) + +test('a missing renderer does not strand pending neighbours', () => { + const walls = hydrate(4, -1, 3) + runWallBuildFrame() + useScene.getState().markDirty(walls[0]!.id) + runWallBuildFrame() + expect(getPendingWallRebuildCount()).toBe(1) + now += 80 + runWallBuildFrame() + expect(getPendingWallRebuildCount()).toBe(0) + expect(useScene.getState().dirtyNodes.has(walls[3]!.id)).toBe(true) +}) + +test('a hydration interrupted before publication still resets first-ever counters', async () => { + const scene = buildingWithOpenings() + useScene.getState().setScene(scene.nodes, [scene.building.id]) + await new Promise(resolve => queueMicrotask(resolve)) + for (const wall of scene.walls) register(wall) + runWallBuildFrame() + expect(stats().firstBuilds).toBe(12) + const stop = initializeStairOpeningSync() + try { + useScene.getState().setScene(scene.nodes, [scene.building.id]) + canvas.dispatchEvent(new Event('wheel')) + await new Promise(resolve => queueMicrotask(resolve)) + expect(useScene.getState().hydrationToken).toBeNull() + expect(stats().firstBuilds).toBe(0) + runWallBuildFrame() + expect(stats().firstBuilds).toBe(8) + expect(stats().reinvalidationBuilds).toBe(0) + } finally { stop() } +}) + +test('setScene starts initial build; more than eight cheap walls drain in one frame', () => { + hydrate() + expect(isWallInitialBuildActive()).toBe(true) + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + expect(isWallInitialBuildActive()).toBe(true) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(20) + expect(stats().firstBuilds).toBe(20) + expect(stats().neighbourEnqueues).toBe(0) + expect(stats().drainedExits).toBe(1) + expect(isWallInitialBuildActive()).toBe(false) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(0) + expect(stats().drainedExits).toBe(1) +}) + +test('checks the eight millisecond budget between walls and skips first-build neighbour invalidation across frames', () => { + hydrate(12) + rebuildCost = 4 + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(2) + expect(stats().budgetExits).toBe(1) + expect(isWallInitialBuildActive()).toBe(true) + rebuildCost = 0 + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(10) + expect(stats().firstBuilds).toBe(12) + expect(stats().reinvalidationBuilds).toBe(0) + expect(stats().neighbourEnqueues).toBe(0) + expect(getPendingWallRebuildCount()).toBe(0) + expect(isWallInitialBuildActive()).toBe(false) +}) + +test('a heavy wall gets its own frame even with budget left and cheap walls following it', () => { + hydrate(12, 1) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(1) + expect(stats().heavyExits).toBe(1) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(1) + expect(stats().heavyExits).toBe(2) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(10) + expect(isWallInitialBuildActive()).toBe(false) +}) + +test.each([ + 'edit', + 'pointerdown', + 'pointermove', + 'wheel', + 'override', + 'transform', +])('%s ends initial build immediately and restores the interactive cap', (interaction) => { + const walls = hydrate() + if (interaction === 'edit') useScene.getState().updateNode(walls[0]!.id, { height: 4 }) + else if (interaction === 'override') + useLiveNodeOverrides.getState().set(walls[0]!.id, { height: 4 } as Partial) + else if (interaction === 'transform') + useLiveTransforms.getState().set(walls[0]!.id, { position: [0, 1, 0], rotation: 0 }) + else canvas.dispatchEvent(new Event(interaction)) + expect(isWallInitialBuildActive()).toBe(false) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(8) + expect(stats().capExits).toBe(1) + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + expect(isWallInitialBuildActive()).toBe(false) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(8) + expect(stats().neighbourEnqueues).toBeGreaterThan(0) +}) + +test('opening completion can re-dirty a parent; initial build waits for pending neighbours after dirty drains', () => { + const walls = hydrate(3, -1, 2) + runWallBuildFrame() + expect(isWallInitialBuildActive()).toBe(true) + useScene.getState().markDirty(walls[0]!.id) + runWallBuildFrame() + expect(stats().reinvalidationBuilds).toBe(1) + expect(getPendingWallRebuildCount()).toBe(1) + register(walls[2]!) + runWallBuildFrame() + expect(isWallInitialBuildActive()).toBe(true) + expect(stats().firstBuilds).toBe(3) + now += 79 + runWallBuildFrame() + expect(getPendingWallRebuildCount()).toBe(1) + now += 1 + runWallBuildFrame() + expect(getPendingWallRebuildCount()).toBe(0) + expect(isWallInitialBuildActive()).toBe(false) +}) + +test('a late mount sees hydration, but cannot revive it after an edit', () => { + unsubscribe() + const walls = hydrate() + unsubscribe = subscribeWallBuildInteractions(canvas) + expect(isWallInitialBuildActive()).toBe(true) + unsubscribe() + useScene.getState().updateNode(walls[0]!.id, { height: 4 }) + unsubscribe = subscribeWallBuildInteractions(canvas) + expect(isWallInitialBuildActive()).toBe(false) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(8) +}) + +test('first builds across frames use the complete junction solution', () => { + const level = LevelNode.parse({ height: 3 }) + const walls = [ + WallNode.parse({ parentId: level.id, start: [0, 0], end: [12, 0], height: 3 }), + WallNode.parse({ parentId: level.id, start: [12, 0], end: [12, 8], height: 3 }), + WallNode.parse({ parentId: level.id, start: [12, 0], end: [20, -6], height: 3 }), + ] + level.children = walls.map((wall) => wall.id) + useScene.getState().setScene( + Object.fromEntries([level, ...walls].map((node) => [node.id, node])), [level.id], + ) + const built = walls.map(register) + rebuildCost = 8 + for (let index = 0; index < walls.length; index++) runWallBuildFrame() + expect(stats().firstBuilds).toBe(3) + expect(stats().reinvalidationBuilds).toBe(0) + expect(isWallInitialBuildActive()).toBe(false) + const geometrySnapshot = () => built.map(({ geometry }) => ({ + positions: Array.from(geometry.getAttribute('position').array), + normals: Array.from(geometry.getAttribute('normal').array), + uvs: Array.from(geometry.getAttribute('uv').array), + groups: geometry.groups, + })) + const initialGeometry = geometrySnapshot() + for (const wall of walls) useScene.getState().markDirty(wall.id) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(3) + expect(geometrySnapshot()).toEqual(initialGeometry) + expect(getPendingWallRebuildCount()).toBe(0) +}) + +test.each(['action', 'host', 'paused'])('first %s document write invalidates hydration in one notification', (write) => { + const walls = hydrate(3) + runWallBuildFrame() + const notifications: Array = [] + const stop = useScene.subscribe((state) => notifications.push(state.hydrationToken)) + try { + if (write === 'paused') useScene.temporal.getState().pause() + if (write === 'host') { + useScene.setState((state) => ({ + nodes: { ...state.nodes, [walls[0]!.id]: { ...state.nodes[walls[0]!.id], height: 4 } as AnyNode }, + })) + } else useScene.getState().updateNode(walls[0]!.id, { height: 4 }) + expect(notifications).toEqual([null]) + } finally { + stop() + useScene.temporal.getState().resume() + } +}) + +test.each([false, true])('a drained scene keeps its first endpoint edit local (token already invalid: %s)', (invalidated) => { + const level = LevelNode.parse({ height: 3 }) + const walls = Array.from({ length: 12 }, (_, room) => { + const x = room * 20 + const points = [[x, 0], [x + 12, 0], [x + 12, 8], [x, 8]] + return points.map((start, index) => WallNode.parse({ + parentId: level.id, start, end: points[(index + 1) % 4], height: 3, + })) + }).flat() + const surfaces = Array.from({ length: 12 }, (_, room) => { + const polygon = walls.slice(room * 4, room * 4 + 4).map((wall) => wall.start) + return [SlabNode.parse({ parentId: level.id, polygon, autoFromWalls: true }), CeilingNode.parse({ parentId: level.id, polygon, autoFromWalls: true })] + }).flat() + level.children = [...walls, ...surfaces].map((node) => node.id) + useScene.getState().setScene(Object.fromEntries([level, ...walls, ...surfaces].map((node) => [node.id, node])), [level.id]) + for (const wall of walls) register(wall) + const editorState = { spaces: {}, setSpaces(spaces: object) { this.spaces = spaces } } + const stopDetection = initSpaceDetectionSync(useScene, { getState: () => editorState }) + try { + runWallBuildFrame() + expect(isWallInitialBuildActive()).toBe(false) + expect(getPendingWallRebuildCount()).toBe(0) + if (invalidated) useScene.setState({ hydrationToken: null }) + useScene.getState().dirtyNodes.clear() + useScene.getState().updateNodes([ + { id: walls[0]!.id, data: { end: [12, 1] } }, + { id: walls[1]!.id, data: { start: [12, 1] } }, + ]) + for (const [id, callback] of rafs) { rafs.delete(id); callback(now) } + const dirtyWalls = [...useScene.getState().dirtyNodes].filter((id) => useScene.getState().nodes[id]?.type === 'wall') + expect(dirtyWalls.length).toBe(4) + const before = stats().reinvalidationBuilds + runWallBuildFrame() + now += 80 + runWallBuildFrame() + expect(stats().reinvalidationBuilds - before).toBe(4) + expect(getPendingWallRebuildCount()).toBe(0) + } finally { + stopDetection() + } +}) + +test('a new hydration resets counters and pending neighbours; node stats preserve wall counters', () => { + const walls = hydrate(3) + canvas.dispatchEvent(new Event('pointerdown')) + useScene.getState().dirtyNodes.clear() + useScene.getState().markDirty(walls[0]!.id) + runWallBuildFrame() + expect(getPendingWallRebuildCount()).toBe(1) + hydrate(12) + expect(getPendingWallRebuildCount()).toBe(0) + expect(stats().firstBuilds).toBe(0) + runWallBuildFrame() + publishPerfBatchStats({ items: 5, instances: 10, containers: 1 }) + expect(stats().firstBuilds).toBe(12) + expect(readPerfBatchStats().items).toBe(5) +}) + + `, + ) + const result = Bun.spawnSync( + [process.execPath, 'test', '--preload', preload, probe, '--randomize', '--seed=1'], + { stdout: 'pipe', stderr: 'pipe' }, + ) + expect({ + code: result.exitCode, + failures: result.exitCode ? result.stderr.toString() : '', + }).toEqual({ code: 0, failures: '' }) + const mountPreload = join(directory, 'mount-preload.ts') + writeFileSync( + mountPreload, + ` +import { mock } from 'bun:test' +const react = require('react') +mock.module('react', () => ({ ...react, default: react, useEffect: (effect) => { globalThis.wallCleanup = effect() } })) +mock.module('@react-three/fiber', () => ({ useFrame: (frame) => { globalThis.wallFrame = frame } })) +const core = require(${sourcePath('packages/core/src/index.ts')}) +const storeWithoutHooks = (store) => Object.assign((selector) => selector(store.getState()), store) +mock.module('@pascal-app/core', () => ({ ...core, useScene: storeWithoutHooks(core.useScene), useLiveNodeOverrides: storeWithoutHooks(core.useLiveNodeOverrides) })) +mock.module(${sourcePath('packages/viewer/src/lib/gpu-perf.ts')}, () => ({ PERF_OVERLAY_ENABLED: process.env.WALL_TEST_PERF !== 'off' })) +`, + ) + const mountProbe = join(directory, 'mount.test.ts') + writeFileSync( + mountProbe, + ` +import { expect, spyOn, test } from 'bun:test' +import { LevelNode, WallNode, useScene, useLiveNodeOverrides, useLiveTransforms, sceneRegistry } from '@pascal-app/core' +import { BoxGeometry, Mesh } from 'three' +import { subscribeWallBuildInteractions, isWallInitialBuildActive, drainStats } from ${sourcePath('packages/viewer/src/systems/wall/wall-build-lifecycle.ts')} +import * as perfStore from ${sourcePath('packages/viewer/src/lib/perf-panel-store.ts')} +import { subscribePerfSamples } from ${sourcePath('packages/viewer/src/lib/perf-tracks.ts')} + +test('canvas and live-state owner precede the lazy wall consumer; remount retains drain identity', async () => { + let now = 0 + const clock = spyOn(performance, 'now').mockImplementation(() => now) + const publish = spyOn(perfStore, 'publishPerfWallDrainStats') + const spans: number[] = [] + const stopSamples = subscribePerfSamples((track, ms) => { if (track === 'wall-initial-build') spans.push(ms) }) + const canvas = new EventTarget() + const stopInput = subscribeWallBuildInteractions(canvas)! + const level = LevelNode.parse({}) + const walls = Array.from({ length: 12 }, (_, i) => WallNode.parse({ parentId: level.id, start: [i * 12, 0], end: [(i + 1) * 12, 0] })) + level.children = walls.map(wall => wall.id) + const nodes = Object.fromEntries([level, ...walls].map(node => [node.id, node])) + const meshes = walls.map(wall => { + const mesh = new Mesh(new BoxGeometry()) + mesh.geometry.addEventListener('dispose', () => { now += 4 }) + sceneRegistry.nodes.set(wall.id, mesh) + sceneRegistry.byType.wall.add(wall.id) + return mesh + }) + let cleanup: (() => void) | undefined + try { + for (const interaction of ['wheel', 'override', 'transform']) { + useScene.getState().setScene(nodes, [level.id]) + if (interaction === 'wheel') canvas.dispatchEvent(new Event('wheel')) + if (interaction === 'override') { + useLiveNodeOverrides.getState().set(walls[0]!.id, { height: 5 }) + useLiveNodeOverrides.getState().clearAll() + } + if (interaction === 'transform') { + useLiveTransforms.getState().set(walls[0]!.id, { position: [0, 1, 0], rotation: 0 }) + useLiveTransforms.getState().clearAll() + } + expect(useScene.getState().hydrationToken).toBeNull() + } + const { WallSystem } = await import(${sourcePath('packages/viewer/src/systems/wall/wall-system.tsx')}) + WallSystem() + cleanup = globalThis.wallCleanup + expect(isWallInitialBuildActive()).toBe(false) + useScene.getState().setScene(nodes, [level.id]) + spans.length = 0 + const start = now + const token = useScene.getState().hydrationToken + globalThis.wallFrame() + expect(drainStats.firstBuilds).toBe(2) + expect(drainStats.budgetExits).toBe(1) + cleanup!() + now += 20 + WallSystem() + cleanup = globalThis.wallCleanup + expect(useScene.getState().hydrationToken).toBe(token) + expect(drainStats.firstBuilds).toBe(2) + expect(spans).toEqual([]) + for (let i = 0; i < 5; i++) globalThis.wallFrame() + expect(drainStats.firstBuilds).toBe(12) + expect(isWallInitialBuildActive()).toBe(false) + const perf = process.env.WALL_TEST_PERF !== 'off' + expect(spans).toEqual(perf ? [now - start] : []) + if (perf) { + const snapshot = perfStore.readPerfBatchStats().wallDrain + globalThis.wallFrame() + expect(perfStore.readPerfBatchStats().wallDrain).toBe(snapshot) + } else { + for (let i = 0; i < 40; i++) globalThis.wallFrame() + expect(publish).not.toHaveBeenCalled() + expect(perfStore.readPerfBatchStats().wallDrain).toBeUndefined() + } + } finally { + cleanup?.() + stopInput() + stopSamples() + publish.mockRestore() + clock.mockRestore() + useScene.getState().unloadScene() + sceneRegistry.clear() + for (const mesh of meshes) { mesh.geometry.dispose(); mesh.material.dispose() } + } +}) +`, + ) + for (const perf of ['on', 'off']) { + const mountResult = Bun.spawnSync( + [process.execPath, 'test', '--preload', mountPreload, mountProbe], + { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, WALL_TEST_PERF: perf } }, + ) + expect({ + code: mountResult.exitCode, + failures: mountResult.exitCode ? mountResult.stderr.toString() : '', + }).toEqual({ code: 0, failures: '' }) + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}, 10000) diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index aee5985660..ce152367fb 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -47,9 +47,18 @@ import { buildOpeningCutoutGeometry, getOpeningCutoutBottomPadding, } from './opening-cutout-geometry' +import { + drainStats, + endInitialBuild, + initiallyBuiltWalls, + isWallInitialBuildActive, + pendingAdjacentByLevel, + publishWallDrainStats, +} from './wall-build-lifecycle' import { sweepUnbuiltWalls, WALL_PLACEHOLDER_SWEEP_INTERVAL } from './wall-placeholder-sweep' import { notifyWallRebuilt } from './wall-rebuild-notifications' +export { isWallInitialBuildActive } from './wall-build-lifecycle' export { drainRebuiltWalls } from './wall-rebuild-notifications' // Reusable CSG evaluator for better performance @@ -607,19 +616,21 @@ const WALL_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WALL_REBUILDS_PER_FRAME const WALL_PROGRESSIVE_TIME_BUDGET_MS = 8 const HEAVY_WALL_OPENINGS = 6 let lastWallDirtyAtMs = 0 -const pendingAdjacentByLevel = new Map>() +let unmountedFrames = 0 +let stalledHydrationToken: object | null = null -export function shouldDeferWallRebuild( +function wallRebuildExitReason( wallId: string, nodes: Record, rebuiltThisFrame: number, elapsedMs: number, -): boolean { - if (rebuiltThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) return true - if (rebuiltThisFrame === 0) return false - if (elapsedMs >= WALL_PROGRESSIVE_TIME_BUDGET_MS) return true + initialBuild = false, +): 'cap' | 'budget' | 'heavy' | null { + if (!initialBuild && rebuiltThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) return 'cap' + if (rebuiltThisFrame === 0) return null + if (elapsedMs >= WALL_PROGRESSIVE_TIME_BUDGET_MS) return 'budget' const wall = nodes[wallId as AnyNodeId] - if (wall?.type !== 'wall') return false + if (wall?.type !== 'wall') return null let cutouts = 0 for (const childId of getEffectiveWall(wall).children ?? []) { const child = nodes[childId] @@ -632,214 +643,283 @@ export function shouldDeferWallRebuild( )?.geometry?.getAttribute('position')?.count) ) { cutouts++ - if (cutouts >= HEAVY_WALL_OPENINGS) return true + if (cutouts >= HEAVY_WALL_OPENINGS) return 'heavy' } } - return false + return null +} + +export function shouldDeferWallRebuild( + wallId: string, + nodes: Record, + rebuiltThisFrame: number, + elapsedMs: number, +): boolean { + return wallRebuildExitReason(wallId, nodes, rebuiltThisFrame, elapsedMs) !== null } /** Rebuilds this system still owes β€” neighbours deferred during a drag. */ export function getPendingWallRebuildCount(): number { - let count = 0 - for (const ids of pendingAdjacentByLevel.values()) { - count += ids.size - } - return count + return drainStats.pendingNeighbours } let placeholderSweepCountdown = WALL_PLACEHOLDER_SWEEP_INTERVAL export const WallSystem = () => { - // Subscribe so scene writes and override-only changes (no scene write) - // still re-run this component. The frame body reads the LIVE set via - // `useScene.getState()` β€” a closure over the subscribed value goes stale - // whenever the store REPLACES the set (scene load, plugin install) in the - // window before React commits the re-render, and marks added to the new - // set in that window would be invisible to the frame. useScene((state) => state.dirtyNodes) - const clearDirty = useScene((state) => state.clearDirty) useLiveNodeOverrides((s) => s.overrides) - - // The miter cache is module-level, so it outlives this mount. Editor - // teardown resets the other shared singletons; without the same reset here a - // remount in the same tab keeps every previous level's walls reachable. useEffect(() => () => clearLevelMiterCache(), []) + useFrame(runWallBuildFrame, 4) + return null +} - useFrame(() => { - // Self-heal: any registered wall still on its mount-time placeholder - // geometry with NO dirty mark gets re-marked, so a lost mark (system - // mounted late, suspense remount, mark consumed elsewhere) can never - // strand a wall as a degenerate point forever (QA f2 probe5/probe6 β€” - // scene loaded with the X-ray active never built any of its 24 walls). - placeholderSweepCountdown -= 1 - if (placeholderSweepCountdown <= 0) { - placeholderSweepCountdown = WALL_PLACEHOLDER_SWEEP_INTERVAL - const sceneState = useScene.getState() - sweepUnbuiltWalls({ - wallIds: sceneRegistry.byType.wall ?? [], - geometryOf: (wallId) => - (sceneRegistry.nodes.get(wallId) as THREE.Mesh | undefined)?.geometry ?? null, - isDirty: (wallId) => sceneState.dirtyNodes.has(wallId as AnyNodeId), - markDirty: (wallId) => sceneState.markDirty(wallId as AnyNodeId), - }) - } - - const dirtyNodes = useScene.getState().dirtyNodes - const hasDirty = dirtyNodes.size > 0 - const hasPending = pendingAdjacentByLevel.size > 0 - if (!hasDirty && !hasPending) return +export function runWallBuildFrame() { + const initialBuild = isWallInitialBuildActive() + const token = useScene.getState().hydrationToken + if (token !== stalledHydrationToken) { + unmountedFrames = 0 + stalledHydrationToken = token + } + drainStats.wallsConsumedThisFrame = 0 + try { + consumeWallBuildFrame(initialBuild) + } finally { + publishWallDrainStats() + } +} - const nodes = useScene.getState().nodes - const now = performance.now() +function consumeWallBuildFrame(initialBuild: boolean) { + const clearDirty = useScene.getState().clearDirty + // Self-heal: any registered wall still on its mount-time placeholder + // geometry with NO dirty mark gets re-marked, so a lost mark (system + // mounted late, suspense remount, mark consumed elsewhere) can never + // strand a wall as a degenerate point forever (QA f2 probe5/probe6 β€” + // scene loaded with the X-ray active never built any of its 24 walls). + placeholderSweepCountdown -= 1 + if (placeholderSweepCountdown <= 0) { + placeholderSweepCountdown = WALL_PLACEHOLDER_SWEEP_INTERVAL + const sceneState = useScene.getState() + sweepUnbuiltWalls({ + wallIds: sceneRegistry.byType.wall ?? [], + geometryOf: (wallId) => + (sceneRegistry.nodes.get(wallId) as THREE.Mesh | undefined)?.geometry ?? null, + isDirty: (wallId) => sceneState.dirtyNodes.has(wallId as AnyNodeId), + markDirty: (wallId) => sceneState.markDirty(wallId as AnyNodeId), + }) + } - // Collect dirty walls and their levels - const dirtyWallsByLevel = new Map>() - let dirtyWallCount = 0 + const dirtyNodes = useScene.getState().dirtyNodes + const hasDirty = dirtyNodes.size > 0 + const hasPending = pendingAdjacentByLevel.size > 0 + if (!hasDirty && !hasPending) { + endInitialBuild() + return + } - useFrameNb += 1 - if (hasDirty) { - dirtyNodes.forEach((id) => { - const node = nodes[id] - if (node?.type !== 'wall') return + const nodes = useScene.getState().nodes + const now = performance.now() + + // Collect dirty walls and their levels + const dirtyWallsByLevel = new Map>() + let dirtyWallCount = 0 + let unmountedWallCount = 0 + + useFrameNb += 1 + if (hasDirty) { + dirtyNodes.forEach((id) => { + const node = nodes[id] + if (node?.type !== 'wall') return + + dirtyWallCount += 1 + if (!sceneRegistry.nodes.has(id)) unmountedWallCount++ + const levelId = node.parentId + if (!levelId) return + + if (!dirtyWallsByLevel.has(levelId)) { + dirtyWallsByLevel.set(levelId, new Set()) + } + dirtyWallsByLevel.get(levelId)?.add(id) + }) + } - const levelId = node.parentId - if (!levelId) return + const hasDirtyWalls = dirtyWallCount > unmountedWallCount + if (hasDirtyWalls) { + lastWallDirtyAtMs = now + } - if (!dirtyWallsByLevel.has(levelId)) { - dirtyWallsByLevel.set(levelId, new Set()) - } - dirtyWallsByLevel.get(levelId)?.add(id) - dirtyWallCount += 1 - }) - } + const useProgressiveWallRebuilds = + initialBuild || dirtyWallCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD + let rebuiltWallsThisFrame = 0 + const rebuildFrameStartedAt = now + let deferWallRebuilds = false + let exitReason: 'cap' | 'budget' | 'heavy' | null = null - const hasDirtyWalls = dirtyWallsByLevel.size > 0 - if (hasDirtyWalls) { - lastWallDirtyAtMs = now + // Process each level that has dirty walls + for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) { + if ( + !initialBuild && + useProgressiveWallRebuilds && + rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME + ) { + exitReason = 'cap' + break } - - const useProgressiveWallRebuilds = dirtyWallCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD - let rebuiltWallsThisFrame = 0 - const rebuildFrameStartedAt = now - let deferWallRebuilds = false - - // Process each level that has dirty walls - for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) { - if (useProgressiveWallRebuilds && rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) { - break - } - - const levelWalls = getLevelWalls(levelId) - const miterData = timeSpan('wall-miter', () => getCachedLevelMiters(levelId, levelWalls)) - const rebuiltWallIds = new Set() - - // Update dirty walls β€” always, no throttling. The dragged wall must - // follow the cursor with full fidelity (cutouts and all). Large imports - // enter the progressive path so initial load can't lock the tab. - for (const wallId of dirtyWallIds) { - if ( - useProgressiveWallRebuilds && - shouldDeferWallRebuild( + const levelWalls = getLevelWalls(levelId) + const miterData = timeSpan('wall-miter', () => getCachedLevelMiters(levelId, levelWalls)) + const rebuiltWallIds = new Set() + + // Update dirty walls β€” always, no throttling. The dragged wall must + // follow the cursor with full fidelity (cutouts and all). Large imports + // enter the progressive path so initial load can't lock the tab. + for (const wallId of dirtyWallIds) { + exitReason = useProgressiveWallRebuilds + ? wallRebuildExitReason( wallId, nodes, rebuiltWallsThisFrame, performance.now() - rebuildFrameStartedAt, + initialBuild, ) - ) { + : null + if (exitReason) { + deferWallRebuilds = true + break + } + + const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh + if (mesh) { + timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData), { + properties: [['node', wallId]], + }) + clearDirty(wallId as AnyNodeId) + notifyWallRebuilt(wallId) + const firstBuild = !initiallyBuiltWalls.has(wallId) + if (firstBuild) { + initiallyBuiltWalls.add(wallId) + drainStats.firstBuilds++ + } else { + drainStats.reinvalidationBuilds++ + } + if (!initialBuild || !firstBuild) rebuiltWallIds.add(wallId) + rebuiltWallsThisFrame += 1 + drainStats.wallsConsumedThisFrame++ + if (initialBuild && wallRebuildExitReason(wallId, nodes, 1, 0, true) === 'heavy') { + exitReason = 'heavy' deferWallRebuilds = true break } - - const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh - if (mesh) { - timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData), { - properties: [['node', wallId]], - }) - clearDirty(wallId as AnyNodeId) - notifyWallRebuilt(wallId) - rebuiltWallIds.add(wallId) - rebuiltWallsThisFrame += 1 - } - // If mesh not found, keep it dirty for next frame } + // If mesh not found, keep it dirty for next frame + } - if (rebuiltWallIds.size === 0) { - if (deferWallRebuilds) break - continue - } + if (rebuiltWallIds.size === 0) { + if (deferWallRebuilds) break + continue + } - // Adjacent walls sharing junctions β€” *defer* during active drag - // (dirty arrived this frame), flush on the trailing edge. - const adjacentWallIds = getAdjacentWallIds(levelWalls, rebuiltWallIds) - let pending = pendingAdjacentByLevel.get(levelId) - if (!pending) { - pending = new Set() - pendingAdjacentByLevel.set(levelId, pending) - } - for (const wallId of adjacentWallIds) { - if (!dirtyWallIds.has(wallId)) { - pending.add(wallId) - } + // First builds use the same hydrated inputs as every queued neighbour. + // Only subsequent invalidations need the adjacency scan and trailing flush. + // Adjacent walls sharing junctions β€” *defer* during active drag + // (dirty arrived this frame), flush on the trailing edge. + const adjacentWallIds = getAdjacentWallIds(levelWalls, rebuiltWallIds) + let pending = pendingAdjacentByLevel.get(levelId) + if (!pending) { + pending = new Set() + pendingAdjacentByLevel.set(levelId, pending) + } + for (const wallId of adjacentWallIds) { + if (!dirtyWallIds.has(wallId) && !pending.has(wallId)) { + pending.add(wallId) + drainStats.pendingNeighbours++ + drainStats.neighbourEnqueues++ } - if (deferWallRebuilds) break } + if (pending.size === 0) pendingAdjacentByLevel.delete(levelId) + if (deferWallRebuilds) break + } - // Trailing-edge flush: if no new dirty marks for DRAG_FLUSH_MS, the - // drag has ended β€” rebuild the queued neighbors so corners snap into - // their correct miter joins. - const quiet = !hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS - if (quiet && pendingAdjacentByLevel.size > 0) { - const pendingCount = getPendingWallRebuildCount() - const useProgressiveAdjacentRebuilds = pendingCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD - let rebuiltAdjacentThisFrame = 0 - const adjacentFrameStartedAt = performance.now() - let deferAdjacentRebuilds = false - - for (const [levelId, pendingIds] of pendingAdjacentByLevel) { - if (pendingIds.size === 0) continue - const levelWalls = getLevelWalls(levelId) - const miterData = timeSpan('wall-miter', () => getCachedLevelMiters(levelId, levelWalls)) - for (const wallId of Array.from(pendingIds)) { - if ( - useProgressiveAdjacentRebuilds && - shouldDeferWallRebuild( + // Trailing-edge flush: if no new dirty marks for DRAG_FLUSH_MS, the + // drag has ended β€” rebuild the queued neighbors so corners snap into + // their correct miter joins. + const quiet = !hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS + if (quiet && pendingAdjacentByLevel.size > 0) { + const pendingCount = getPendingWallRebuildCount() + const useProgressiveAdjacentRebuilds = + initialBuild || pendingCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD + let rebuiltAdjacentThisFrame = 0 + const adjacentFrameStartedAt = performance.now() + let deferAdjacentRebuilds = false + + for (const [levelId, pendingIds] of pendingAdjacentByLevel) { + if (pendingIds.size === 0) continue + const levelWalls = getLevelWalls(levelId) + const miterData = timeSpan('wall-miter', () => getCachedLevelMiters(levelId, levelWalls)) + for (const wallId of Array.from(pendingIds)) { + exitReason = useProgressiveAdjacentRebuilds + ? wallRebuildExitReason( wallId, nodes, rebuiltAdjacentThisFrame, performance.now() - adjacentFrameStartedAt, + initialBuild, ) - ) { - deferAdjacentRebuilds = true - break - } - - const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh - if (mesh) { - timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData), { - properties: [['node', wallId]], - }) - notifyWallRebuilt(wallId) - } - pendingIds.delete(wallId) - rebuiltAdjacentThisFrame += 1 + : null + if (exitReason) { + deferAdjacentRebuilds = true + break } - if (pendingIds.size === 0) { - pendingAdjacentByLevel.delete(levelId) + const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh + if (mesh) { + timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData), { + properties: [['node', wallId]], + }) + notifyWallRebuilt(wallId) + drainStats.wallsConsumedThisFrame++ + if (initiallyBuiltWalls.has(wallId)) drainStats.reinvalidationBuilds++ + else { + initiallyBuiltWalls.add(wallId) + drainStats.firstBuilds++ + } } - - if ( - deferAdjacentRebuilds || - (useProgressiveAdjacentRebuilds && - rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) - ) { + pendingIds.delete(wallId) + drainStats.pendingNeighbours-- + rebuiltAdjacentThisFrame += 1 + if (initialBuild && wallRebuildExitReason(wallId, nodes, 1, 0, true) === 'heavy') { + exitReason = 'heavy' + deferAdjacentRebuilds = true break } } - } - }, 4) - return null + if (pendingIds.size === 0) { + pendingAdjacentByLevel.delete(levelId) + } + + if ( + deferAdjacentRebuilds || + (!initialBuild && + useProgressiveAdjacentRebuilds && + rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) + ) { + break + } + } + } + if (initialBuild && drainStats.wallsConsumedThisFrame === 0 && unmountedWallCount > 0) { + unmountedFrames++ + if (unmountedFrames >= WALL_PLACEHOLDER_SWEEP_INTERVAL) { + useScene.getState().invalidateHydration() + } + } else unmountedFrames = 0 + if (exitReason === 'budget') drainStats.budgetExits++ + else if (exitReason === 'heavy') drainStats.heavyExits++ + else if (exitReason === 'cap') drainStats.capExits++ + if (dirtyWallCount === rebuiltWallsThisFrame && drainStats.pendingNeighbours === 0) { + if (drainStats.wallsConsumedThisFrame > 0 || drainStats.initialBuildActive) + drainStats.drainedExits++ + endInitialBuild() + } } /** diff --git a/plugin-evals/README.md b/plugin-evals/README.md index bc405e697e..b669ecc03c 100644 --- a/plugin-evals/README.md +++ b/plugin-evals/README.md @@ -1,6 +1,6 @@ # Publishing evaluation fixtures -`publishing-cases.json` is the draft cross-skill review suite for a future OpenAI **With MCP** submission. It contains at least five positive cases with expected result shapes, plus three negative or refusal-boundary cases with explicit reasons the plugin must not complete the requested action. `tool-annotation-justifications.json` records the exact three required hint values and a non-empty justification for every hint on all 46 expected MCP tools. Repository policy tests check its exact inventory and shape, and the live MCP regression test checks its values against `tools/list`. +`publishing-cases.json` is the draft cross-skill review suite for a future OpenAI **With MCP** submission. It contains at least five positive cases with expected result shapes, plus three negative or refusal-boundary cases with explicit reasons the plugin must not complete the requested action. `tool-annotation-justifications.json` records the exact three required hint values and a non-empty justification for every hint on all 46 expected MCP tools. Repository policy tests check its exact inventory and shape, and the live MCP regression test checks its values against `tools/list`. That counted 46-tool inventory is the public package's; the hosted server's additional hosted-only Capture scan tools are documented separately and stay outside this packet. The suite remains blocked until Pascal confirms an authorized verified OpenAI publisher identity, completes portal-token domain verification, passes Scan Tools against the production hosted endpoint, provisions OAuth-compatible reviewer access, and names disposable fixtures that reviewers can use without internal context. Each standalone skill also bundles: diff --git a/plugin-evals/publishing-cases.json b/plugin-evals/publishing-cases.json index 4e6b88e3ba..c7b08e4218 100644 --- a/plugin-evals/publishing-cases.json +++ b/plugin-evals/publishing-cases.json @@ -18,7 +18,7 @@ "evidence": [ "The machine-readable packet records exact values and non-empty per-hint justifications for all 46 expected tools.", "The repository policy validator rejects missing, unexpected, duplicate, reordered, or wrongly classified tools and missing, blank, or extra hint justifications.", - "The live MCP tools/list regression test requires the packet's exact inventory and annotation values to match the registered server tools.", + "The live MCP tools/list regression test requires the packet's exact inventory and annotation values to match the registered @pascal-app/mcp package tools; the live hosted tools/list exposes those 46 reviewed package tools plus three hosted-only capture tools, list_captures, get_capture, and open_capture_as_project, which are outside this packet by design.", "The complete @pascal-app/mcp suite passed 361 tests with 1684 assertions, and the MCP dependency build passed." ], "limitations": "This is local submission preparation only. It does not establish production hosted-MCP behavior, a portal Scan Tools run or approval, domain verification, verified publisher identity, reviewer access, submission, review, publication, or listing." diff --git a/plugin-evals/release-notes.md b/plugin-evals/release-notes.md index 04b9546f73..58fb74cb99 100644 --- a/plugin-evals/release-notes.md +++ b/plugin-evals/release-notes.md @@ -1,6 +1,6 @@ # OpenAI submission release notes -Draft release notes for a future Pascal agent skills 0.1.7 **With MCP** submission. +Draft release notes for a future Pascal agent skills 0.1.8 **With MCP** submission. The plugin teaches ChatGPT and Codex to create, inspect, edit, validate, save, and hand off editable Pascal 3D scenes through a separately connected Pascal MCP server. It also includes a focused furniture-fit workflow that reports measured footprint evidence, unsupported checks, and one bounded next action without authorizing project changes or spending. diff --git a/plugin.json b/plugin.json index a8533aea8f..26656f63fb 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "pascal-agent-skills", - "version": "0.1.7", + "version": "0.1.8", "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.", "author": { "name": "Pascal", @@ -15,7 +15,7 @@ "extensions": { "com.openai": { "interface": { - "displayName": "Pascal agent skills", + "displayName": "Pascal", "shortDescription": "Build 3D scenes and check fit", "longDescription": "Use Pascal's MCP tools to work with editable building scenes, validate and save results, and produce bounded furniture footprint reports with explicit evidence, limitations, and blocker-aware next actions.", "developerName": "Pascal", @@ -26,7 +26,6 @@ "Assess furniture footprints" ], "websiteURL": "https://editor.pascal.app/docs/developers/mcp", - "supportURL": "https://github.com/pascalorg/editor/issues", "privacyPolicyURL": "https://editor.pascal.app/privacy", "termsOfServiceURL": "https://editor.pascal.app/terms", "defaultPrompt": [ @@ -35,7 +34,7 @@ ], "brandColor": "#171717", "composerIcon": "./assets/pascal-mark.svg", - "logo": "./assets/pascal-mark.svg" + "logo": "./assets/pascal-mark-plate.svg" } } } diff --git a/scripts/bun-preload-three.ts b/scripts/bun-preload-three.ts new file mode 100644 index 0000000000..c3cd40324b --- /dev/null +++ b/scripts/bun-preload-three.ts @@ -0,0 +1,21 @@ +import { resolveSync } from 'bun' + +// Preload React too: when R3F loads its CJS entry first, Bun can give our ESM +// hooks a second React instance and fail with an invalid hook call. +// three r186's CommonJS entry is `require('./three.module.js')`. Bun cannot +// require() an ES module that is still loading, and the R3F ecosystem (fiber, +// drei, maath, meshline, troika) ships CJS mains that require("three") while +// our sources import it as ESM β€” so a test file that imports both races and +// dies with "require() async module is unsupported". Evaluating three first +// turns the later require() into a cache hit. Resolve from the package under +// test, not from this file: with the isolated linker each package has its own +// link and this directory would walk up to a different copy. Packages that do +// not depend on a package have nothing to pre-evaluate. +process.noDeprecation = true +for (const packageName of ['react', 'three']) { + let packagePath: string | null = null + try { + packagePath = resolveSync(packageName, process.cwd()) + } catch {} + if (packagePath) await import(packagePath) +} diff --git a/scripts/claude-mcp-config-policy.test.ts b/scripts/claude-mcp-config-policy.test.ts index b6fe2e244b..42b5961386 100644 --- a/scripts/claude-mcp-config-policy.test.ts +++ b/scripts/claude-mcp-config-policy.test.ts @@ -1,14 +1,18 @@ import { describe, expect, test } from 'bun:test' import { readFileSync } from 'node:fs' import { join, resolve } from 'node:path' -import { validateClaudeMcpPolicy } from './claude-mcp-config-policy' +import { + hostedAuthorizationHeader, + hostedMcpUrl, + validateClaudeMcpPolicy, +} from './claude-mcp-config-policy' const repositoryRoot = resolve(import.meta.dir, '..') const canonicalConfig = JSON.parse( - readFileSync(join(repositoryRoot, '.mcp.json'), 'utf8'), + readFileSync(join(repositoryRoot, 'skills', '.mcp.json'), 'utf8'), ) as unknown const canonicalPlugin = JSON.parse( - readFileSync(join(repositoryRoot, '.claude-plugin', 'plugin.json'), 'utf8'), + readFileSync(join(repositoryRoot, 'skills', '.claude-plugin', 'plugin.json'), 'utf8'), ) as Record const marketplace = JSON.parse( readFileSync(join(repositoryRoot, '.claude-plugin', 'marketplace.json'), 'utf8'), @@ -21,45 +25,60 @@ const upgradeGuidancePaths = [ 'skills/pascal-3d/references/setup.md', 'skills/furniture-fit/references/setup.md', ] as const +const hostedGuidancePaths = [ + 'skills/pascal-3d/references/setup.md', + 'skills/furniture-fit/references/setup.md', +] as const + +const localServer = { type: 'stdio', command: 'pascal', args: ['mcp', 'connect'] } +const hostedServer = { + type: 'http', + url: hostedMcpUrl, + headers: { Authorization: hostedAuthorizationHeader }, +} + +function withServers(servers: Record): Record { + return { mcpServers: servers } +} + +const canonicalKeyOption = (canonicalPlugin.userConfig as Record) + .pascal_api_key as Record + +function pluginWithUserConfig(userConfig: unknown): Record { + return { ...canonicalPlugin, userConfig } +} describe('Claude plugin MCP configuration', () => { - test('uses the protected local connector configuration', () => { + test('accepts the local connector beside the hosted endpoint', () => { expect( validateClaudeMcpPolicy(canonicalConfig, canonicalPlugin, canonicalMarketplaceEntry), ).toEqual([]) }) test.each([ - ['another server', { ...canonicalConfig, mcpServers: { pascal: {}, other: {} } }], + ['a third server', withServers({ pascal: localServer, 'pascal-hosted': hostedServer, o: {} })], + ['a missing hosted server', withServers({ pascal: localServer })], + ['a missing local server', withServers({ 'pascal-hosted': hostedServer })], [ - 'a remote URL', - { mcpServers: { pascal: { type: 'http', url: 'https://editor.pascal.app/api/mcp' } } }, + 'a remote URL on the local server', + withServers({ + pascal: { type: 'http', url: 'https://editor.pascal.app/api/mcp' }, + 'pascal-hosted': hostedServer, + }), ], [ - 'request headers', - { - mcpServers: { - pascal: { - type: 'stdio', - command: 'pascal', - args: ['mcp', 'connect'], - headers: { Authorization: 'Bearer placeholder' }, - }, - }, - }, + 'request headers on the local server', + withServers({ + pascal: { ...localServer, headers: { Authorization: 'Bearer placeholder' } }, + 'pascal-hosted': hostedServer, + }), ], [ - 'environment credentials', - { - mcpServers: { - pascal: { - type: 'stdio', - command: 'pascal', - args: ['mcp', 'connect'], - env: { PASCAL_API_KEY: 'placeholder' }, - }, - }, - }, + 'environment credentials on the local server', + withServers({ + pascal: { ...localServer, env: { PASCAL_API_KEY: 'placeholder' } }, + 'pascal-hosted': hostedServer, + }), ], ])('rejects %s', (_label, config) => { expect( @@ -67,26 +86,66 @@ describe('Claude plugin MCP configuration', () => { ).toBeGreaterThan(0) }) + test.each([ + [ + 'a literal hosted credential', + { ...hostedServer, headers: { Authorization: 'Bearer pascal_live_placeholder' } }, + ], + [ + 'an unexpected hosted header', + { ...hostedServer, headers: { ...hostedServer.headers, 'X-Pascal-Org': 'acme' } }, + ], + ['a redirected hosted URL', { ...hostedServer, url: 'https://mcp.example.com/api/mcp' }], + ['a non-HTTP hosted transport', { ...hostedServer, type: 'sse' }], + ['a hosted download helper', { ...hostedServer, headersHelper: './fetch-headers.sh' }], + ])('rejects hosted %s', (_label, server) => { + expect( + validateClaudeMcpPolicy( + withServers({ pascal: localServer, 'pascal-hosted': server }), + canonicalPlugin, + canonicalMarketplaceEntry, + ).length, + ).toBeGreaterThan(0) + }) + test('rejects command or argument changes', () => { expect( validateClaudeMcpPolicy( - { - mcpServers: { - pascal: { type: 'stdio', command: 'npx', args: ['pascal', 'mcp', 'connect'] }, - }, - }, + withServers({ + pascal: { type: 'stdio', command: 'npx', args: ['pascal', 'mcp', 'connect'] }, + 'pascal-hosted': hostedServer, + }), canonicalPlugin, canonicalMarketplaceEntry, ), ).toEqual([ - '.mcp.json pascal server command must be pascal', - '.mcp.json pascal server args must be exactly ["mcp", "connect"]', + 'skills/.mcp.json pascal server command must be pascal', + 'skills/.mcp.json pascal server args must be exactly ["mcp", "connect"]', ]) }) test.each([ ['inline MCP servers', { ...canonicalPlugin, mcpServers: { remote: {} } }], - ['user configuration', { ...canonicalPlugin, userConfig: { apiKey: { sensitive: true } } }], + ['a missing hosted key option', pluginWithUserConfig(undefined)], + [ + 'an extra user configuration option', + pluginWithUserConfig({ + pascal_api_key: canonicalKeyOption, + pascal_password: { type: 'string', sensitive: true, required: false }, + }), + ], + [ + 'a plaintext hosted key option', + pluginWithUserConfig({ pascal_api_key: { ...canonicalKeyOption, sensitive: false } }), + ], + [ + 'a required hosted key option', + pluginWithUserConfig({ pascal_api_key: { ...canonicalKeyOption, required: true } }), + ], + [ + 'a default hosted credential', + pluginWithUserConfig({ pascal_api_key: { ...canonicalKeyOption, default: 'placeholder' } }), + ], ])('rejects plugin-manifest %s', (_label, pluginManifest) => { expect( validateClaudeMcpPolicy(canonicalConfig, pluginManifest, canonicalMarketplaceEntry).length, @@ -117,4 +176,13 @@ describe('Claude plugin MCP upgrade guidance', () => { expect(content).toContain('before reloading or restarting Claude Code') expect(content).toContain('one-active-agent-client-per-local-service requirement') }) + + test.each(hostedGuidancePaths)('%s documents the plugin hosted key path', (path) => { + const content = readFileSync(join(repositoryRoot, path), 'utf8') + expect(content).toContain('pascal-hosted') + expect(content).toContain( + 'claude plugin install pascal-agent-skills@pascal --config pascal_api_key=', + ) + expect(content).toContain('keychain') + }) }) diff --git a/scripts/claude-mcp-config-policy.ts b/scripts/claude-mcp-config-policy.ts index c97c40f51b..d4044580c3 100644 --- a/scripts/claude-mcp-config-policy.ts +++ b/scripts/claude-mcp-config-policy.ts @@ -11,6 +11,88 @@ function hasExactKeys(value: Record, expected: readonly string[ ) } +export const hostedMcpUrl = 'https://editor.pascal.app/api/mcp' +export const hostedApiKeyOption = 'pascal_api_key' +export const hostedAuthorizationHeader = `Bearer \${user_config.${hostedApiKeyOption}}` + +function validateLocalServer(server: unknown, failures: string[]): void { + if (!isRecord(server) || !hasExactKeys(server, ['type', 'command', 'args'])) { + failures.push( + 'skills/.mcp.json pascal server must contain only type, command, and args; remote or credential fields are not allowed', + ) + return + } + + if (server.type !== 'stdio') failures.push('skills/.mcp.json pascal server type must be stdio') + if (server.command !== 'pascal') + failures.push('skills/.mcp.json pascal server command must be pascal') + if ( + !Array.isArray(server.args) || + server.args.length !== 2 || + server.args[0] !== 'mcp' || + server.args[1] !== 'connect' + ) { + failures.push('skills/.mcp.json pascal server args must be exactly ["mcp", "connect"]') + } +} + +function validateHostedServer(server: unknown, failures: string[]): void { + if (!isRecord(server) || !hasExactKeys(server, ['type', 'url', 'headers'])) { + failures.push('skills/.mcp.json pascal-hosted server must contain only type, url, and headers') + return + } + + if (server.type !== 'http') + failures.push('skills/.mcp.json pascal-hosted server type must be http') + if (server.url !== hostedMcpUrl) + failures.push(`skills/.mcp.json pascal-hosted server url must be ${hostedMcpUrl}`) + + const headers = server.headers + if (!isRecord(headers) || !hasExactKeys(headers, ['Authorization'])) { + failures.push('skills/.mcp.json pascal-hosted server must send only an Authorization header') + return + } + // The header must stay a ${user_config.*} reference. A literal token here would publish a + // credential in the installed plugin source instead of resolving it from the host's secret store. + if (headers.Authorization !== hostedAuthorizationHeader) { + failures.push( + `skills/.mcp.json pascal-hosted Authorization header must be exactly "${hostedAuthorizationHeader}"`, + ) + } +} + +function validateUserConfig(userConfig: unknown, failures: string[]): void { + if (!isRecord(userConfig) || !hasExactKeys(userConfig, [hostedApiKeyOption])) { + failures.push( + `Claude plugin manifest must declare exactly one user configuration option named ${hostedApiKeyOption}`, + ) + return + } + + const option = userConfig[hostedApiKeyOption] + if (!isRecord(option)) { + failures.push(`Claude plugin manifest ${hostedApiKeyOption} option must be an object`) + return + } + + if (option.type !== 'string') { + failures.push(`Claude plugin manifest ${hostedApiKeyOption} type must be string`) + } + if (option.sensitive !== true) { + failures.push( + `Claude plugin manifest ${hostedApiKeyOption} must be sensitive so the key is stored outside settings.json`, + ) + } + if (option.required !== false) { + failures.push( + `Claude plugin manifest ${hostedApiKeyOption} must set required to false so the local connector works without a key`, + ) + } + if ('default' in option) { + failures.push(`Claude plugin manifest ${hostedApiKeyOption} must not ship a default credential`) + } +} + export function validateClaudeMcpPolicy( config: unknown, pluginManifest: unknown, @@ -18,32 +100,16 @@ export function validateClaudeMcpPolicy( ): string[] { const failures: string[] = [] if (!isRecord(config) || !hasExactKeys(config, ['mcpServers'])) { - return ['.mcp.json must contain only the mcpServers object'] + return ['skills/.mcp.json must contain only the mcpServers object'] } const servers = config.mcpServers - if (!isRecord(servers) || !hasExactKeys(servers, ['pascal'])) { - return ['.mcp.json must contain exactly one server named pascal'] - } - - const pascal = servers.pascal - if (!isRecord(pascal) || !hasExactKeys(pascal, ['type', 'command', 'args'])) { - failures.push( - '.mcp.json pascal server must contain only type, command, and args; remote or credential fields are not allowed', - ) - return failures + if (!isRecord(servers) || !hasExactKeys(servers, ['pascal', 'pascal-hosted'])) { + return ['skills/.mcp.json must declare exactly the pascal and pascal-hosted servers'] } - if (pascal.type !== 'stdio') failures.push('.mcp.json pascal server type must be stdio') - if (pascal.command !== 'pascal') failures.push('.mcp.json pascal server command must be pascal') - if ( - !Array.isArray(pascal.args) || - pascal.args.length !== 2 || - pascal.args[0] !== 'mcp' || - pascal.args[1] !== 'connect' - ) { - failures.push('.mcp.json pascal server args must be exactly ["mcp", "connect"]') - } + validateLocalServer(servers.pascal, failures) + validateHostedServer(servers['pascal-hosted'], failures) if (!isRecord(pluginManifest)) { failures.push('Claude plugin manifest must be an object') @@ -51,9 +117,7 @@ export function validateClaudeMcpPolicy( if ('mcpServers' in pluginManifest) { failures.push('Claude plugin manifest must not define inline MCP servers') } - if ('userConfig' in pluginManifest) { - failures.push('Claude plugin manifest must not request credentials or user configuration') - } + validateUserConfig(pluginManifest.userConfig, failures) } if (!isRecord(marketplaceEntry)) { diff --git a/scripts/clawhub-ignore-policy.ts b/scripts/clawhub-ignore-policy.ts index 9819cdd976..5083431a9e 100644 --- a/scripts/clawhub-ignore-policy.ts +++ b/scripts/clawhub-ignore-policy.ts @@ -14,6 +14,8 @@ export const clawHubRequiredIgnorePatterns = [ 'yarn.lock', ] as const +export const clawHubCanonicalIgnorePolicy = `${clawHubRequiredIgnorePatterns.join('\n')}\n` + export function validateClawHubIgnorePolicy( content: string, hasLegacyIgnoreFile = false, @@ -25,6 +27,10 @@ export function validateClawHubIgnorePolicy( const patternSet = new Set(patterns) const failures: string[] = [] + if (content !== clawHubCanonicalIgnorePolicy) { + failures.push('.clawhubignore must be byte-identical to the canonical ignore policy') + } + for (const pattern of clawHubRequiredIgnorePatterns) { if (!patternSet.has(pattern)) failures.push(`.clawhubignore is missing ${pattern}`) } diff --git a/scripts/path-containment.test.ts b/scripts/path-containment.test.ts new file mode 100644 index 0000000000..a4b26354a7 --- /dev/null +++ b/scripts/path-containment.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test' +import { isPathInside } from './path-containment' + +describe('isPathInside', () => { + test.each([ + ['windows separators', 'C:\\repo\\skills\\pascal-3d', 'C:\\repo\\skills\\pascal-3d\\a.md'], + ['posix separators', '/repo/skills/pascal-3d', '/repo/skills/pascal-3d/a.md'], + ['mixed separators', 'C:\\repo\\skills\\pascal-3d', 'C:/repo/skills/pascal-3d/a.md'], + ['nested directory', '/repo/skills/pascal-3d', '/repo/skills/pascal-3d/examples/a.md'], + ['drive root', 'C:\\', 'C:\\a.md'], + ['trailing separator on the parent', '/repo/skills/', '/repo/skills/a.md'], + ])('accepts a target inside the parent with %s', (_label, parent, target) => { + expect(isPathInside(parent, target)).toBe(true) + }) + + test.each([ + ['a sibling sharing the name prefix', '/repo/skills', '/repo/skills-extra/a.md'], + ['a parent directory', '/repo/skills/pascal-3d', '/repo/skills/a.md'], + ['an unrelated directory', '/repo/skills', '/repo/other/a.md'], + ['the parent itself', '/repo/skills', '/repo/skills'], + ['a traversal out of the parent', '/repo/skills', '/repo/other/../skills-evil/a.md'], + ['a different drive', 'C:\\repo\\skills', 'D:\\repo\\skills\\a.md'], + ])('rejects %s', (_label, parent, target) => { + expect(isPathInside(parent, target)).toBe(false) + }) +}) diff --git a/scripts/path-containment.ts b/scripts/path-containment.ts new file mode 100644 index 0000000000..f2afe9c8cb --- /dev/null +++ b/scripts/path-containment.ts @@ -0,0 +1,16 @@ +/** + * Containment check for paths produced by `resolve()` / `join()`. + * + * A `${parentDir}/` string prefix is not portable: `resolve()` returns + * backslash-separated paths on Windows, so the prefix never matches there and + * every file that is genuinely inside the directory is reported as outside. + * Comparing on a normalized separator keeps the check identical on every + * platform. + */ +function normalizeSeparators(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, '') +} + +export function isPathInside(parentDir: string, target: string): boolean { + return normalizeSeparators(target).startsWith(`${normalizeSeparators(parentDir)}/`) +} diff --git a/scripts/validate-skills.ts b/scripts/validate-skills.ts index 8e7127f62e..5f1538730e 100644 --- a/scripts/validate-skills.ts +++ b/scripts/validate-skills.ts @@ -2,9 +2,10 @@ import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node import { dirname, extname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { XMLParser, XMLValidator } from 'fast-xml-parser' -import { validateClaudeMcpPolicy } from './claude-mcp-config-policy' +import { hostedMcpUrl, validateClaudeMcpPolicy } from './claude-mcp-config-policy' import { validateClawHubIgnorePolicy } from './clawhub-ignore-policy' import { validateOpenAiToolAnnotationPacket } from './openai-tool-annotation-policy' +import { isPathInside } from './path-containment' import { collectSkillDiscoveryEntries, validatePublicSkillDiscoverySurface, @@ -12,9 +13,15 @@ import { const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const skillNames = ['pascal-3d', 'furniture-fit'] as const -const skillVersions = { 'pascal-3d': '0.1.0', 'furniture-fit': '0.1.3' } as const -const pluginVersion = '0.1.7' +const skillVersions = new Map() const portablePluginSchema = 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json' +const portableMcpSchema = 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json' +const cursorMcpConfigPath = './.cursor-plugin/mcp.json' +const cursorHostedApiKeyVariable = 'PASCAL_API_KEY' +// Cursor substitutes bare `${VAR}` plugin variables, explicitly not the shell `${env:...}` form: +// https://cursor.com/docs/reference/plugins#variables +const cursorHostedAuthorizationHeader = `Bearer \${${cursorHostedApiKeyVariable}}` +const cursorVariableKeywords = new Set(['type', 'title', 'description']) const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ const openAiListingLimits = { @@ -28,6 +35,22 @@ const openAiCapabilityLimit = 20 const openAiCapabilityLengthLimit = 120 const openAiListingUrlLimit = 1024 const openAiImageByteLimit = 5 * 1024 * 1024 +const openAiInterfaceFields = new Set([ + 'displayName', + 'shortDescription', + 'longDescription', + 'developerName', + 'category', + 'capabilities', + 'websiteURL', + 'privacyPolicyURL', + 'termsOfServiceURL', + 'defaultPrompt', + 'brandColor', + 'composerIcon', + 'logo', + 'screenshots', +]) const openAiCategories = new Set([ 'Productivity', 'Creativity', @@ -164,6 +187,17 @@ function parseJson(path: string): Record { } } +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` + if (typeof value === 'object' && value !== null) { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(',')}}` + } + return JSON.stringify(value) ?? 'null' +} + function frontmatter(content: string, path: string): Record { const match = content.match(/^---\n([\s\S]*?)\n---/) if (!match) { @@ -178,12 +212,61 @@ function frontmatter(content: string, path: string): Record { return fields } +function headingSlug(heading: string): string { + return heading + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[`*]/g, '') + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{N} _-]+/gu, '') + .replace(/ /g, '-') +} + +const headingSlugCache = new Map>() + +function markdownHeadingSlugs(path: string): Set { + const cached = headingSlugCache.get(path) + if (cached) return cached + const slugs = new Set() + const occurrences = new Map() + let insideFence = false + for (const line of readFileSync(path, 'utf8').split('\n')) { + if (/^\s*(?:```|~~~)/.test(line)) { + insideFence = !insideFence + continue + } + if (insideFence) continue + const heading = line.match(/^#{1,6}\s+(.+?)\s*$/) + if (!heading) continue + const slug = headingSlug(heading[1]!) + const seen = occurrences.get(slug) ?? 0 + occurrences.set(slug, seen + 1) + slugs.add(seen === 0 ? slug : `${slug}-${seen}`) + } + headingSlugCache.set(path, slugs) + return slugs +} + function validateLinks(content: string, path: string) { for (const match of content.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)) { const target = match[1]! - if (/^(?:https?:|mailto:|#)/.test(target)) continue - const file = resolve(dirname(path), target.split('#')[0]!) - if (!existsSync(file)) fail(`Broken link in ${relative(root, path)}: ${target}`) + if (/^(?:https?:|mailto:)/.test(target)) continue + const hash = target.indexOf('#') + const targetPath = hash === -1 ? target : target.slice(0, hash) + const fragment = hash === -1 ? '' : target.slice(hash + 1) + const file = targetPath ? resolve(dirname(path), targetPath) : path + if (!existsSync(file)) { + fail(`Broken link in ${relative(root, path)}: ${target}`) + continue + } + if (!fragment) continue + if (!file.endsWith('.md')) { + fail(`Link in ${relative(root, path)} anchors into a non-markdown file: ${target}`) + continue + } + if (!markdownHeadingSlugs(file).has(fragment)) { + fail(`Broken link fragment in ${relative(root, path)}: ${target}`) + } } } @@ -209,6 +292,8 @@ for (const discoveryFailure of validatePublicSkillDiscoverySurface( } for (const entry of readdirSync(join(root, 'skills'), { withFileTypes: true })) { + // skills/ is also the Claude plugin root, so its dot-entries carry plugin metadata, not bundles. + if (entry.name.startsWith('.')) continue if (entry.isDirectory() && !skillNames.includes(entry.name as (typeof skillNames)[number])) { fail(`OpenAI skills directory contains an unexpected non-skill directory: ${entry.name}`) } @@ -229,8 +314,9 @@ for (const skillName of skillNames) { const fields = frontmatter(content, skillFile) if (fields.name !== skillName) fail(`${skillName}: frontmatter name does not match directory`) if (!fields.description) fail(`${skillName}: description is required`) - if (!content.includes(`version: "${skillVersions[skillName]}"`)) - fail(`${skillName}: metadata version must be ${skillVersions[skillName]}`) + const skillVersion = content.match(/^ {2}version: "([^"]+)"$/m)?.[1] + if (skillVersion && semverPattern.test(skillVersion)) skillVersions.set(skillName, skillVersion) + else fail(`${skillName}: metadata version must be a quoted semantic version`) if (!/^ {2}source-reviewed: "\d{4}-\d{2}-\d{2}"$/m.test(content)) { fail(`${skillName}: an ISO source review date is required`) } @@ -305,7 +391,7 @@ for (const skillName of skillNames) { const target = match[1]! if (/^(?:https?:|mailto:|#)/.test(target)) continue const resolvedTarget = resolve(dirname(path), target.split('#')[0]!) - if (!resolvedTarget.startsWith(`${skillRoot}/`)) { + if (!isPathInside(skillRoot, resolvedTarget)) { fail(`${relative(root, path)} links outside its standalone skill bundle: ${target}`) } } @@ -320,6 +406,11 @@ for (const skillName of skillNames) { } } +for (const publicDoc of ['README.md', 'VALIDATION.md']) { + const docPath = join(root, 'skills', publicDoc) + validateLinks(read(docPath), docPath) +} + const furnitureSkill = read(join(root, 'skills', 'furniture-fit', 'SKILL.md')) const furnitureReport = read( join(root, 'skills', 'furniture-fit', 'references', 'report-template.md'), @@ -360,6 +451,68 @@ for (const entry of readdirSync(furnitureExamplesRoot, { withFileTypes: true })) } } +const furniturePrecheckExample = read( + join(furnitureExamplesRoot, 'no-sign-in-dimension-precheck.md'), +) +const furniturePrecheckUrlMatches = furniturePrecheckExample.match( + /https:\/\/editor\.pascal\.app\/tools\/furniture-fit\?[^\s]+/gu, +) +if (furniturePrecheckUrlMatches?.length !== 1) { + fail('furniture-fit no-sign-in pre-check example must contain exactly one canonical URL') +} else { + const precheckUrl = new URL(furniturePrecheckUrlMatches[0]!) + const allowedPrecheckKeys = [ + 'clearance', + 'entry', + 'itemDepth', + 'itemWidth', + 'roomDepth', + 'roomWidth', + 'shared', + 'unit', + ] + if ( + precheckUrl.origin !== 'https://editor.pascal.app' || + precheckUrl.pathname !== '/tools/furniture-fit' + ) { + fail('furniture-fit no-sign-in pre-check must use the canonical HTTPS calculator URL') + } + if ( + JSON.stringify([...precheckUrl.searchParams.keys()].sort()) !== + JSON.stringify(allowedPrecheckKeys) + ) { + fail('furniture-fit no-sign-in pre-check must use only the fixed query keys') + } + if ( + precheckUrl.searchParams.get('entry') !== 'agent_report' || + precheckUrl.searchParams.get('shared') !== '1' || + !['cm', 'in'].includes(precheckUrl.searchParams.get('unit') ?? '') + ) { + fail('furniture-fit no-sign-in pre-check must carry fixed attribution and a supported unit') + } + for (const key of ['roomWidth', 'roomDepth', 'itemWidth', 'itemDepth']) { + const value = Number(precheckUrl.searchParams.get(key)) + if (!(Number.isFinite(value) && value > 0 && value <= 1_000_000)) { + fail(`furniture-fit no-sign-in pre-check ${key} must be within the runtime bounds`) + } + } + const clearance = Number(precheckUrl.searchParams.get('clearance')) + if (!(Number.isFinite(clearance) && clearance >= 0 && clearance <= 1_000_000)) { + fail('furniture-fit no-sign-in pre-check clearance must be within the runtime bounds') + } +} + +for (const requiredBoundary of [ + 'Open dimension-only footprint pre-check', + 'entry=agent_report', + 'Opening the link sends the visible measurement query to `editor.pascal.app`', + 'Never put a project, revision, graph hash, node ID, address, person, account, workspace, credential, signed URL, `flow_id`, or arbitrary scene text in the URL.', +]) { + if (!furnitureSkill.includes(requiredBoundary)) { + fail(`furniture-fit skill is missing no-sign-in pre-check boundary: ${requiredBoundary}`) + } +} + type FurnitureDecisionContext = { has_passing_footprint?: unknown has_failing_requested_pose?: unknown @@ -676,33 +829,235 @@ for (const item of publishingCases) { if (positivePublishingCases < 5) fail('Publishing suite needs at least 5 positive cases') if (negativePublishingCases < 3) fail('Publishing suite needs at least 3 negative cases') -const claudePlugin = parseJson(join(root, '.claude-plugin', 'plugin.json')) +const claudePluginRoot = join(root, 'skills') +const claudePlugin = parseJson(join(claudePluginRoot, '.claude-plugin', 'plugin.json')) const claudeMarketplace = parseJson(join(root, '.claude-plugin', 'marketplace.json')) -const claudeMcpConfig = parseJson(join(root, '.mcp.json')) +const claudeMcpConfig = parseJson(join(claudePluginRoot, '.mcp.json')) +const portableMcpConfig = parseJson(join(root, 'mcp.json')) const portablePlugin = parseJson(join(root, 'plugin.json')) const codexPlugin = parseJson(join(root, '.codex-plugin', 'plugin.json')) const codexMarketplace = parseJson(join(root, '.agents', 'plugins', 'marketplace.json')) +const geminiExtension = parseJson(join(root, 'gemini-extension.json')) +const cursorPlugin = parseJson(join(root, '.cursor-plugin', 'plugin.json')) -for (const [label, manifest] of [ - ['Claude plugin', claudePlugin], - ['Codex plugin', codexPlugin], -] as const) { - if (manifest.name !== 'pascal-agent-skills') fail(`${label}: unexpected name`) - if (manifest.version !== pluginVersion) fail(`${label}: version must be ${pluginVersion}`) - if (typeof manifest.version !== 'string' || !semverPattern.test(manifest.version)) { - fail(`${label}: version must use semantic versioning`) +function firstMarketplaceEntry(marketplace: Record): Record { + const entry = Array.isArray(marketplace.plugins) ? marketplace.plugins[0] : undefined + return typeof entry === 'object' && entry !== null ? (entry as Record) : {} +} + +function cursorAuthorSubset(value: unknown): string { + if (typeof value !== 'object' || value === null) return 'missing' + const { name, email } = value as Record + return normalizedAuthor({ name, email }) +} + +function normalizedAuthor(value: unknown): string { + if (typeof value !== 'object' || value === null) return 'missing' + return JSON.stringify(Object.entries(value as Record).sort()) +} + +const pluginVersion = typeof portablePlugin.version === 'string' ? portablePlugin.version : '' +if (!semverPattern.test(pluginVersion)) { + fail('Root plugin.json version is the single source of truth and must use semantic versioning') +} + +const claudeMarketplaceEntry = firstMarketplaceEntry(claudeMarketplace) +const codexMarketplaceEntry = firstMarketplaceEntry(codexMarketplace) +const pluginDescriptors = [ + ['Root plugin manifest', portablePlugin], + ['Claude plugin manifest', claudePlugin], + ['Claude marketplace entry', claudeMarketplaceEntry], + ['Codex plugin manifest', codexPlugin], + ['Codex marketplace entry', codexMarketplaceEntry], + ['Cursor plugin manifest', cursorPlugin], +] as const + +for (const [label, descriptor] of pluginDescriptors) { + if (descriptor.name !== 'pascal-agent-skills') fail(`${label}: unexpected plugin name`) + if (descriptor.version !== pluginVersion) { + fail(`${label}: version must match the root plugin.json version ${pluginVersion}`) + } + if (descriptor.description !== portablePlugin.description) { + fail(`${label}: description must match the root plugin.json description`) + } + // Cursor's plugin schema allows only `name` and `email` under `author` + // (additionalProperties: false), so its manifest is compared on those two. + const expectedAuthor = + label === 'Cursor plugin manifest' + ? cursorAuthorSubset(portablePlugin.author) + : normalizedAuthor(portablePlugin.author) + if (normalizedAuthor(descriptor.author) !== expectedAuthor) { + fail(`${label}: author must match the root plugin.json author`) } } +if (cursorPlugin.displayName !== claudePlugin.displayName) { + fail('Cursor plugin displayName must match the Claude plugin displayName') +} +if ( + typeof cursorPlugin.category !== 'string' || + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(cursorPlugin.category) +) { + fail('Cursor plugin category must be a kebab-case marketplace category such as developer-tools') +} +for (const field of ['logo', 'skills', 'mcpServers']) { + const value = cursorPlugin[field] + if (typeof value !== 'string' || value.startsWith('/') || value.includes('..')) { + fail(`Cursor plugin ${field} must be a plugin-relative path`) + continue + } + const target = join(root, value) + if (!existsSync(target)) fail(`Cursor plugin ${field} must reference an existing path: ${value}`) + if (field === 'skills' && !lstatSync(target).isDirectory()) { + fail('Cursor plugin skills must point at the public skills directory') + } + if (field !== 'skills' && existsSync(target) && !lstatSync(target).isFile()) { + fail(`Cursor plugin ${field} must reference a file: ${value}`) + } +} +if (typeof cursorPlugin.logo === 'string' && existsSync(join(root, cursorPlugin.logo))) { + validateOpenAiSvg(join(root, cursorPlugin.logo), 'Cursor plugin logo') +} + if (portablePlugin.$schema !== portablePluginSchema) { fail(`Portable plugin must declare ${portablePluginSchema}`) } -if (portablePlugin.name !== codexPlugin.name) fail('Portable and Codex plugin names must match') -if (portablePlugin.version !== pluginVersion) { - fail(`Portable plugin version must be ${pluginVersion}`) +if (portableMcpConfig.$schema !== portableMcpSchema) { + fail(`Portable mcp.json must declare ${portableMcpSchema}`) +} +if (Object.keys(portableMcpConfig).sort().join(',') !== '$schema,mcpServers') { + fail('Portable mcp.json must contain only $schema and mcpServers') +} +const claudeMcpServers = (claudeMcpConfig.mcpServers ?? {}) as Record +const portableMcpServers = (portableMcpConfig.mcpServers ?? {}) as Record +if (Object.keys(portableMcpServers).sort().join(',') !== 'pascal') { + fail('Portable mcp.json must declare only the local pascal server') +} +if (canonicalJson(portableMcpServers.pascal) !== canonicalJson(claudeMcpServers.pascal)) { + fail('Portable mcp.json and skills/.mcp.json must declare an identical pascal server') +} +// The hosted server reads its key through ${user_config.*}, which only Claude Code substitutes, so +// it stays in the Claude plugin root instead of the portable Agent Plugins manifest. +if (Object.keys(claudeMcpServers).sort().join(',') !== 'pascal,pascal-hosted') { + fail('skills/.mcp.json must add only the Claude-specific pascal-hosted server') +} + +// Cursor needs its own copy for two reasons the portable file cannot satisfy: Agent Plugins 1.0.0 +// forbids credentials in `headers` and any expansion there (spec 7.2.3/9.2), and its only remote +// transport keyword is `streamable-http` while Cursor writes `http`. Cursor documents the custom +// `mcpServers` path for exactly this case. +if (cursorPlugin.mcpServers !== cursorMcpConfigPath) { + fail(`Cursor plugin mcpServers must point at ${cursorMcpConfigPath}`) +} +const cursorMcpConfig = parseJson(join(root, '.cursor-plugin', 'mcp.json')) +if (Object.keys(cursorMcpConfig).sort().join(',') !== 'mcpServers') { + fail('Cursor mcp.json must contain only the mcpServers object') +} +const cursorMcpServers = (cursorMcpConfig.mcpServers ?? {}) as Record +if (Object.keys(cursorMcpServers).sort().join(',') !== 'pascal,pascal-hosted') { + fail('Cursor mcp.json must declare exactly the pascal and pascal-hosted servers') +} +if (canonicalJson(cursorMcpServers.pascal) !== canonicalJson(portableMcpServers.pascal)) { + fail('Cursor mcp.json and the portable mcp.json must declare an identical pascal server') +} +const cursorHostedServer = cursorMcpServers['pascal-hosted'] as Record | undefined +if ( + canonicalJson(cursorHostedServer) !== + canonicalJson({ + type: 'http', + url: hostedMcpUrl, + headers: { Authorization: cursorHostedAuthorizationHeader }, + }) +) { + fail( + `Cursor mcp.json pascal-hosted must be exactly the http server at ${hostedMcpUrl} sending only "Authorization: ${cursorHostedAuthorizationHeader}"`, + ) +} + +const cursorVariables = (cursorPlugin.variables ?? {}) as Record +if (cursorVariables.type !== 'object') { + fail('Cursor plugin variables must be a JSON Schema object') +} +if ('required' in cursorVariables) { + // A required variable blocks the local no-account path: Cursor cannot enable the plugin until an + // admin supplies a value, so the bundled stdio server would be unreachable without a hosted key. + fail( + `Cursor plugin variables must not mark ${cursorHostedApiKeyVariable} required so a local-only install still loads`, + ) +} +const cursorVariableProperties = (cursorVariables.properties ?? {}) as Record +if (Object.keys(cursorVariableProperties).sort().join(',') !== cursorHostedApiKeyVariable) { + fail(`Cursor plugin variables must declare exactly ${cursorHostedApiKeyVariable}`) +} +const cursorApiKeyVariable = cursorVariableProperties[cursorHostedApiKeyVariable] as + | Record + | undefined +if (cursorApiKeyVariable) { + if (cursorApiKeyVariable.type !== 'string') { + fail(`Cursor plugin ${cursorHostedApiKeyVariable} type must be string`) + } + for (const field of ['title', 'description'] as const) { + if (typeof cursorApiKeyVariable[field] !== 'string' || !cursorApiKeyVariable[field]) { + fail(`Cursor plugin ${cursorHostedApiKeyVariable} must declare a ${field}`) + } + } + for (const keyword of Object.keys(cursorApiKeyVariable)) { + // Cursor accepts a fixed keyword set, and `default` would ship a placeholder credential. + if (!cursorVariableKeywords.has(keyword)) { + fail( + `Cursor plugin ${cursorHostedApiKeyVariable} declares an unsupported schema keyword: ${keyword}`, + ) + } + } +} +// Cursor requires every `${VAR}` used in plugin config to be declared in the manifest schema. +for (const placeholder of JSON.stringify(cursorMcpConfig).matchAll(/\$\{([^}]+)\}/g)) { + if (!(placeholder[1] in cursorVariableProperties)) { + fail(`Cursor mcp.json uses undeclared plugin variable \${${placeholder[1]}}`) + } +} + +const claudeUserConfig = (claudePlugin.userConfig ?? {}) as Record +const claudeHostedKeyOption = claudeUserConfig.pascal_api_key as Record | undefined +if (claudeHostedKeyOption?.sensitive !== true) { + fail('Claude plugin userConfig.pascal_api_key must set sensitive so the key never reaches a file') +} +if (claudeHostedKeyOption?.required !== false) { + fail('Claude plugin userConfig.pascal_api_key must set required to false for local-only installs') +} + +const portablePascalServer = portableMcpServers.pascal as Record | undefined +if (geminiExtension.name !== 'pascal') fail('Gemini CLI extension name must be pascal') +if (geminiExtension.version !== pluginVersion) { + fail(`Gemini CLI extension version must match the root plugin.json version ${pluginVersion}`) +} +if (geminiExtension.description !== portablePlugin.description) { + fail('Gemini CLI extension description must match the root plugin.json description') } -if (typeof portablePlugin.version !== 'string' || !semverPattern.test(portablePlugin.version)) { - fail('Portable plugin version must use semantic versioning') +const geminiContextFile = geminiExtension.contextFileName +if ( + typeof geminiContextFile !== 'string' || + !geminiContextFile || + geminiContextFile.startsWith('/') || + geminiContextFile.includes('..') +) { + fail('Gemini CLI extension contextFileName must be an extension-relative path') +} else if (!existsSync(join(root, geminiContextFile))) { + fail(`Gemini CLI extension contextFileName must point at an existing file: ${geminiContextFile}`) +} +const geminiServers = geminiExtension.mcpServers as Record | undefined +const geminiPascalServer = geminiServers?.pascal as Record | undefined +if (!geminiServers || Object.keys(geminiServers).join(',') !== 'pascal') { + fail('Gemini CLI extension must declare exactly one server named pascal') +} else if ( + // Gemini CLI's MCP server type field accepts only sse or http; stdio is inferred from command. + 'type' in (geminiPascalServer ?? {}) || + geminiPascalServer?.command !== portablePascalServer?.command || + canonicalJson(geminiPascalServer?.args) !== canonicalJson(portablePascalServer?.args) +) { + fail( + 'Gemini CLI extension pascal server must run the mcp.json command and args without a transport type', + ) } if ( typeof portablePlugin.name !== 'string' || @@ -730,9 +1085,6 @@ if ( } validateHttpsUrl(portableAuthor?.url, 'Portable plugin author URL', 2048) validateHttpsUrl(portablePlugin.homepage, 'Portable plugin homepage', 2048) -if (portablePlugin.description !== codexPlugin.description) { - fail('Portable and Codex plugin descriptions must match') -} const extensions = portablePlugin.extensions as Record | undefined const openAiExtension = extensions?.['com.openai'] as Record | undefined const portableInterface = openAiExtension?.interface as Record | undefined @@ -806,9 +1158,14 @@ if ( ) { fail('OpenAI category must use a supported final-directory value') } -for (const field of ['websiteURL', 'supportURL', 'privacyPolicyURL', 'termsOfServiceURL']) { +for (const field of ['websiteURL', 'privacyPolicyURL', 'termsOfServiceURL']) { validateHttpsUrl(portableInterface?.[field], `OpenAI ${field}`, openAiListingUrlLimit) } +for (const field of Object.keys(portableInterface ?? {})) { + if (!openAiInterfaceFields.has(field)) { + fail(`OpenAI interface declares a field OpenAI does not document: ${field}`) + } +} for (const field of ['composerIcon', 'logo']) { const value = portableInterface?.[field] if (typeof value !== 'string' || !value.startsWith('./')) { @@ -816,7 +1173,7 @@ for (const field of ['composerIcon', 'logo']) { continue } const asset = resolve(root, value) - if (!asset.startsWith(`${root}/`) || !existsSync(asset) || !lstatSync(asset).isFile()) { + if (!isPathInside(root, asset) || !existsSync(asset) || !lstatSync(asset).isFile()) { fail(`OpenAI ${field} must reference an existing file inside the plugin`) continue } @@ -835,7 +1192,6 @@ if (!Array.isArray(codexEntries) || codexEntries.length !== 1) { const entry = codexEntries[0] as Record const source = entry.source as Record | undefined const policy = entry.policy as Record | undefined - if (entry.name !== codexPlugin.name) fail('Codex marketplace plugin name must match its manifest') if (source?.source !== 'local' || source.path !== './') { fail('Codex marketplace must resolve the plugin from the repository root') } @@ -856,20 +1212,41 @@ if (!Array.isArray(marketplacePlugins) || marketplacePlugins.length !== 1) { for (const configFailure of validateClaudeMcpPolicy(claudeMcpConfig, claudePlugin, plugin)) { fail(configFailure) } - if (plugin.source !== './') fail('Claude marketplace plugin must use the repository root') - if (plugin.version !== pluginVersion) { - fail(`Claude marketplace plugin version must be ${pluginVersion}`) + // The plugin root must stay skills/: a repository-root source makes Claude Code cache the whole + // monorepo and run bun install against the root lockfile on every install. + if (plugin.source !== './skills') { + fail('Claude marketplace plugin must use the skills directory as its plugin root') } - const packagedSkills = plugin.skills - for (const skillName of skillNames) { - if (!Array.isArray(packagedSkills) || !packagedSkills.includes(`./skills/${skillName}`)) { - fail(`Claude marketplace is missing ${skillName}`) + // A listed skills array is the complete set Claude Code loads for the entry, so it must equal + // every packaged bundle; a new skills//SKILL.md is otherwise installed but never loaded. + const bundledSkillPaths = readdirSync(claudePluginRoot, { withFileTypes: true }) + .filter( + (entry) => entry.isDirectory() && existsSync(join(claudePluginRoot, entry.name, 'SKILL.md')), + ) + .map((entry) => `./${entry.name}`) + .sort() + for (const [label, descriptor] of [ + ['Claude marketplace', plugin], + ['Claude plugin manifest', claudePlugin], + ] as const) { + const declared = descriptor.skills + const declaredPaths = Array.isArray(declared) ? [...declared].map(String).sort() : [] + if (declaredPaths.join(',') !== bundledSkillPaths.join(',')) { + fail( + `${label} skills must list exactly the packaged bundles ${bundledSkillPaths.join(', ')}; found ${declaredPaths.join(', ') || 'none'}`, + ) } } } +const releaseNotes = read(join(root, 'plugin-evals', 'release-notes.md')) +if (!releaseNotes.includes(`Pascal agent skills ${pluginVersion} **With MCP** submission`)) { + fail(`OpenAI release notes must describe the ${pluginVersion} submission candidate`) +} + const readme = read(join(root, 'README.md')) for (const expected of [ + '[![Install with skills](https://skills.sh/b/pascalorg/editor)](https://skills.sh/pascalorg/editor)', 'npx skills add pascalorg/editor', '/plugin marketplace add pascalorg/editor', 'codex plugin marketplace add pascalorg/editor', @@ -886,5 +1263,5 @@ if (failures.length > 0) { } console.log( - `Validated ${skillNames.length} skills (${skillNames.map((name) => `${name}@${skillVersions[name]}`).join(', ')}) and portable, Codex, and Claude plugin manifests at ${pluginVersion}.`, + `Validated ${skillNames.length} skills (${skillNames.map((name) => `${name}@${skillVersions.get(name)}`).join(', ')}) and portable, Codex, Claude, Cursor, and Gemini CLI plugin manifests at ${pluginVersion}.`, ) diff --git a/server.json b/server.json index 9b5b451573..86beefc921 100644 --- a/server.json +++ b/server.json @@ -5,6 +5,26 @@ "description": "Create, inspect, validate, and save editable 3D building scenes with Pascal's hosted MCP server.", "version": "0.6.1", "websiteUrl": "https://editor.pascal.app/docs/developers/mcp", + "repository": { + "url": "https://github.com/pascalorg/editor", + "source": "github" + }, + "icons": [ + { + "src": "https://editor.pascal.app/icons/icon-512.png", + "mimeType": "image/png", + "sizes": [ + "512x512" + ] + }, + { + "src": "https://editor.pascal.app/icons/icon-1024.png", + "mimeType": "image/png", + "sizes": [ + "1024x1024" + ] + } + ], "remotes": [ { "type": "streamable-http", diff --git a/skills/.claude-plugin/plugin.json b/skills/.claude-plugin/plugin.json new file mode 100644 index 0000000000..3c6ffec9d6 --- /dev/null +++ b/skills/.claude-plugin/plugin.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin.json", + "name": "pascal-agent-skills", + "displayName": "Pascal", + "version": "0.1.8", + "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.", + "author": { + "name": "Pascal", + "email": "support@pascal.app", + "url": "https://pascal.app" + }, + "homepage": "https://editor.pascal.app/docs/developers/mcp", + "repository": "https://github.com/pascalorg/editor", + "license": "MIT", + "keywords": ["pascal", "3d", "architecture", "mcp", "furniture", "spatial"], + "userConfig": { + "pascal_api_key": { + "type": "string", + "title": "Pascal API key (hosted)", + "description": "Paste a key from editor.pascal.app Settings to work on your hosted projects and Capture scans; leave empty to use only the local Pascal CLI.", + "sensitive": true, + "required": false + } + }, + "skills": ["./pascal-3d", "./furniture-fit"] +} diff --git a/skills/.mcp.json b/skills/.mcp.json new file mode 100644 index 0000000000..01f779f616 --- /dev/null +++ b/skills/.mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "pascal": { + "type": "stdio", + "command": "pascal", + "args": ["mcp", "connect"] + }, + "pascal-hosted": { + "type": "http", + "url": "https://editor.pascal.app/api/mcp", + "headers": { + "Authorization": "Bearer ${user_config.pascal_api_key}" + } + } + } +} diff --git a/skills/README.md b/skills/README.md index 1665b3b7e4..304b3daf84 100644 --- a/skills/README.md +++ b/skills/README.md @@ -2,6 +2,19 @@ These public skills teach MCP-capable agents to use Pascal for editable building models and bounded spatial answers. +## Channel status + +Status on 2026-09-10. Installable and publicly listed are separate results. + +| Channel | Status | +| --- | --- | +| [skills.sh](https://skills.sh/pascalorg/editor) | Indexed automatically from this repository; installable, with install counts on that listing. | +| Claude Code plugin | Installable from this Git marketplace; not submitted to the Anthropic plugin directory. | +| Codex and Cursor Agent Plugin | Installable from this repository, including the root [`mcp.json`](../mcp.json) server; a Cursor-native [`.cursor-plugin/plugin.json`](../.cursor-plugin/plugin.json) carries the marketplace logo and category and adds an optional hosted key; not submitted to the OpenAI or Cursor marketplaces. | +| Gemini CLI extension | Root [`gemini-extension.json`](../gemini-extension.json) is present; installable from a release tag that carries it, and gallery listing waits on the `gemini-cli-extension` repository topic. | +| Official MCP Registry | `io.github.pascalorg/editor` 0.6.1 is published. | +| ClawHub and OpenClaw | Not published; waiting on an authorized publisher accepting the MIT-0 terms. | + ## Install with skills.sh List the available skills: @@ -26,6 +39,8 @@ npx skills add https://github.com/pascalorg/editor/tree/main/skills/furniture-fi Use `-g` for a user-wide installation or `-a claude-code -a codex` to choose hosts explicitly. +skills.sh indexes this repository automatically, so no submission step is involved; its listing at [skills.sh/pascalorg/editor](https://skills.sh/pascalorg/editor) also reports install counts from the `skills` CLI. + ## Install with OpenClaw After publication under Pascal's ClawHub publisher, use the owner-qualified registry references and verify their trust envelopes: @@ -55,18 +70,28 @@ codex plugin marketplace add pascalorg/editor codex plugin add pascal-agent-skills@pascal ``` -The Claude plugin installs the instructions from the canonical `skills/` directory and supplies one local stdio server that runs `pascal mcp connect`. Install and start the Pascal CLI first, and keep `pascal` on Claude Code's `PATH`. The bundled local connector needs no Pascal account or API key and does not upload projects automatically. Codex and individually installed skills still use the setup reference included in either skill. +The Claude plugin installs the instructions from the canonical `skills/` directory and supplies two servers: a local stdio server that runs `pascal mcp connect`, and a hosted `pascal-hosted` server for `https://editor.pascal.app/api/mcp` that prompts for an optional Pascal API key when the plugin is enabled and keeps it in the OS keychain. This `skills/` directory is itself the Claude plugin root, so an install copies only the two skill bundles and their plugin metadata rather than the repository. Install and start the Pascal CLI first, and keep `pascal` on Claude Code's `PATH`. The bundled local connector needs no Pascal account or API key and does not upload projects automatically; leaving the hosted key empty keeps the install local-only. Codex and individually installed skills still use the setup reference included in either skill. Claude Code 2.1.258 loads both the user-scoped `pascal` server created by `pascal mcp setup claude` and the plugin-provided server. Run `claude mcp remove --scope user pascal` before reloading or restarting Claude Code so only the plugin owns the connection lifecycle. Use `/mcp` to remove or disable any project- or local-scoped Pascal connection too. Leaving both connections active violates the one-active-agent-client-per-local-service requirement. For a hosted Pascal project, disable the plugin-provided local server in `/mcp`, then configure the hosted endpoint from the setup reference. Plugin installation alone never creates an account, uploads a project, or authorizes paid work. -The root [`plugin.json`](../plugin.json) is the portable Agent Plugins manifest used for OpenAI submission. The repository keeps `.codex-plugin/plugin.json` as a compatibility fallback and validates that both expose the same OpenAI listing metadata. Public-directory submission, review, and publication are separate external steps; a Git marketplace install does not make the plugin publicly listed in ChatGPT or Codex. +The root [`plugin.json`](../plugin.json) is the portable Agent Plugins manifest used for OpenAI submission, and the root [`mcp.json`](../mcp.json) is the MCP configuration path Codex reads; Claude Code reads the same local server from [`.mcp.json`](.mcp.json) in its `skills/` plugin root, next to [`.claude-plugin/plugin.json`](.claude-plugin/plugin.json), and Cursor reads it from [`.cursor-plugin/mcp.json`](../.cursor-plugin/mcp.json). Each host keeps its own copy of the hosted server because the credential placeholder is host-specific β€” `${user_config.pascal_api_key}` in Claude Code, the bare `${PASCAL_API_KEY}` plugin variable in Cursor β€” while the portable `mcp.json` stays local-only: Agent Plugins 1.0.0 forbids secrets and placeholder expansion in `headers`, and Codex strips a plugin-supplied `Authorization`, so Codex users register the hosted endpoint with `codex mcp add --bearer-token-env-var PASCAL_API_KEY`. The repository keeps `.codex-plugin/plugin.json` as a compatibility fallback and validates that both expose the same OpenAI listing metadata. Public-directory submission, review, and publication are separate external steps; a Git marketplace install does not make the plugin publicly listed in ChatGPT or Codex. -The npm `beta` CLI remains on the older runtime contract. For the read-only `check_collisions.candidate` capability used by the current furniture workflow, follow the checksum-verified [GitHub preview instructions](pascal-3d/references/setup.md#candidate-enabled-github-preview). The preview archive is published on GitHub, not npm. +`@pascal-app/cli` 1.0.0 on the npm `latest` tag carries the read-only `check_collisions.candidate` capability used by the current furniture workflow. Use one active agent client per local CLI service. Its standalone HTTP runtime shares active scene state; the hosted endpoint uses a separate session-isolated bridge. +## Install as a Gemini CLI extension + +The root [`gemini-extension.json`](../gemini-extension.json) declares the same `pascal mcp connect` server and loads this file as the extension context. Gemini CLI resolves a plain repository URL to the GitHub release marked **Latest**, which predates this manifest, so install from a release tag that contains it: + +```bash +gemini extensions install https://github.com/pascalorg/editor --ref +``` + +Gemini CLI copies the extension on install; run `gemini extensions update pascal` to pull later changes. + ## Included skills | Skill | Use it for | @@ -81,9 +106,9 @@ The `source-reviewed` date records a code and public-documentation review. The ` ## Validate the source package ```bash -bun test scripts/clawhub-ignore-policy.test.ts scripts/claude-mcp-config-policy.test.ts scripts/openai-tool-annotation-policy.test.ts scripts/public-skill-discovery-policy.test.ts -bun scripts/validate-skills.ts -claude plugin validate . --strict +bun run skills:validate ``` -The repository validator checks the exact two-skill public discovery surface, keeps contributor-only workflows internal, and checks frontmatter, bundled links, task and trigger fixtures, semantic furniture next-action decision cases, scoped ClawHub ignore policies without re-inclusion overrides, the exact credential-free Claude local MCP configuration, the publishing suite, the exact 46-tool OpenAI annotation and justification packet, portable and compatibility manifest consistency, OpenAI public-directory metadata limits, bundled branding assets, and accidental private-path or credential leakage. +Run `claude plugin validate . --strict` manually as well. It stays out of the script and out of CI because it needs the Claude Code CLI, which is not installed on every runner. + +The repository validator checks the exact two-skill public discovery surface, keeps contributor-only workflows internal, and checks frontmatter, semantic skill versions, bundled links and their heading anchors, task and trigger fixtures, semantic furniture next-action decision cases, scoped ClawHub ignore policies without re-inclusion overrides, the exact Claude local and hosted MCP configuration with the hosted key declared as an optional, sensitive user-configuration option, an identical local `pascal` server in the `mcp.json` files Codex and Cursor read, the Cursor hosted server and its optional, never-required `PASCAL_API_KEY` plugin variable with no undeclared placeholders, a Gemini CLI manifest that runs the same command at the same version, Claude marketplace and plugin skills lists that equal the packaged bundles exactly, the publishing suite, the exact 46-tool OpenAI annotation and justification packet, portable and compatibility manifest consistency with one plugin version, description, and author across every plugin descriptor, OpenAI public-directory metadata limits including its documented interface fields, bundled branding assets, and accidental private-path or credential leakage. diff --git a/skills/VALIDATION.md b/skills/VALIDATION.md index 41af09fc52..f78b56f1d1 100644 --- a/skills/VALIDATION.md +++ b/skills/VALIDATION.md @@ -1,10 +1,18 @@ # Skill package validation -Released package source: **0.1.7**. Latest released package source: **0.1.7**. `pascal-3d` skill metadata version: **0.1.0**. `furniture-fit` skill metadata version: **0.1.3**. Recorded September 9, 2026. +Released package source: **0.1.8**. Latest released package source: **0.1.8**. `pascal-3d` skill metadata version: **0.1.0**. `furniture-fit` skill metadata version: **0.1.4**. Recorded September 10, 2026. + +## Bundle 0.1.8 agent-report footprint pre-check release + +This release lets the `furniture-fit` skill include an optional no-sign-in Pascal calculator link when the user asks for it or explicitly authorizes sending the visible rectangular measurements to Pascal. The link carries only exact room, item, uniform room-boundary clearance, unit, `shared=1`, and `entry=agent_report` values. It is omitted for missing, inferred, ambiguous, irregular, directional, or over-limit inputs and whenever the requested conclusion depends on project evidence such as pose, existing objects, doors, height, delivery access, or other scene constraints. The report must disclose that opening the link sends its visible measurement query to Pascal and may retain it in browser history or service logs. + +The exact package source merged to public `main` at `cbaed2c0af51e8fe51e8217de410dae8a69c289f` and was published at `2026-09-10T06:14:36Z` as the immutable GitHub prerelease tag `pascal-agent-skills--v0.1.8`. The released `.mcp.json` SHA-256 is `e4042df42028e6f39cd3442d8896587f4f11a999bce828e8bee3a4bf1030d9ba`; the unchanged released `pascal-3d/SKILL.md` SHA-256 is `55d263977898bad4375093435050dc377d512ab1600640e96b8ac106613e6f05`; and the released `furniture-fit/SKILL.md` SHA-256 is `ca812bc0e196d0890183af559affd11665cd3678c22d58a274983e6f02b4090e`. A freshly downloaded and extracted tag source archive matched all three hashes and passed package validation and strict Claude plugin validation using the repository dependency tree. + +Public `quality` and `cli-smoke` CI passed before merge. The new positive and negative package evals cover consent, URL privacy, exact values, irregular rooms, directional clearance, scene-dependent conclusions, and numeric bounds. An independent Claude Fable 5.1 source review found no remaining P0, P1, or P2 implementation issues after corrections. A native Claude host cohort did not run because the local Claude OAuth session could not refresh, so the skill metadata remains `package-checks-only` and this release makes no native-agent behavior claim. It also does not establish hosted app deployment, marketplace listing, external installation, adoption, or retention. ## OpenAI MCP tool-annotation candidate -The unreleased submission-preparation candidate based on public `main` at `f107edabb68743290de4ba50b701c2e04869cabb` explicitly classifies every registered MCP tool with `readOnlyHint`, `destructiveHint`, and `openWorldHint`. `plugin-evals/tool-annotation-justifications.json` records the exact three values and a non-empty justification for every hint on all 46 tools. The independent repository policy validator requires the exact inventory, values, keys, ordering, and non-empty justifications; the live `tools/list` regression test requires the packet to match the registered server. The classifications distinguish 17 closed-world reads, two open-world image-analysis reads, 14 additive closed-world mutations, 12 destructive closed-world operations, and one destructive open-world photo-to-scene operation. +The unreleased submission-preparation candidate based on public `main` at `f107edabb68743290de4ba50b701c2e04869cabb` explicitly classifies every registered MCP tool with `readOnlyHint`, `destructiveHint`, and `openWorldHint`. `plugin-evals/tool-annotation-justifications.json` records the exact three values and a non-empty justification for every hint on all 46 tools. The independent repository policy validator requires the exact inventory, values, keys, ordering, and non-empty justifications; the live `tools/list` regression test requires the packet to match the registered server. The classifications distinguish 17 closed-world reads, two open-world image-analysis reads, 14 additive closed-world mutations, 12 destructive closed-world operations, and one destructive open-world photo-to-scene operation. This counted 46-tool inventory is the public package's; the hosted server additionally registers hosted-only Capture scan tools that are documented separately and stay outside this packet. The four focused package-policy files passed 38 tests with 60 assertions. The live annotation regression passed with 324 assertions, and the complete `packages/mcp` suite passed 361 tests with 1,684 assertions. The MCP dependency build, repository check across 2,100 files, package validator, and Claude Code 2.1.267 strict plugin validation also passed. These local checks establish source-level inventory, value, and justification completeness for the tested candidate. They do not establish behavior of the production hosted endpoint, a portal Scan Tools run or approval, domain verification, verified publisher identity, reviewer access, submission, review, publication, listing, or release. The publishing suite records each external prerequisite and remains blocked until they pass. @@ -80,7 +88,7 @@ A separate tool-free Claude Fable 5.1 source review first returned changes requi | Public skill release commit | `aa653f2f523f81f361ac20cb42b745faf7e46844` | | Candidate-enabled GitHub CLI archive | `814ffa8c6f6a5fced73bf909c616d9a78feff18fd61fd0b4b7d65e74fad5a33d` | -Manifest hashes are not Git commits or persisted scene identities. Native furniture fixtures used local SQLite storage and direct stdio MCP. The CLI's `mcp connect` command forwards to its managed HTTP service, a distinct transport path tested separately below. The npm `beta` tags still resolve to CLI `1.0.0-beta.1` and MCP `1.0.0-beta.6`; those older registry releases do not establish the candidate-enabled behavior documented here. +Manifest hashes are not Git commits or persisted scene identities. Native furniture fixtures used local SQLite storage and direct stdio MCP. The CLI's `mcp connect` command forwards to its managed HTTP service, a distinct transport path tested separately below. The npm `latest` tags resolve to CLI and MCP `1.0.0`, which carry the `check_collisions.candidate` behavior; the `beta` tags remain on the older CLI `1.0.0-beta.1` and MCP `1.0.0-beta.6` releases. ## Completed checks for bundle 0.1.1 @@ -105,14 +113,14 @@ Claude's three task pairs are a small diagnostic sample. Baseline and treatment ## Scope and remaining checks - Bundle 0.1.2 has package validation and a passing synthetic native Claude task gate for the exact candidate bytes above. The earlier 0.1.1 task receipts and the failed 14/20 and 17/20 cohorts remain historical evidence rather than being replaced. -- The save-and-disconnect precondition added to the preview setup references after merge `72d4451202e79bbbb22090016543874a7c2e9836` is a documentation-only safety correction. The historical installation digests above apply to that merge and do not claim byte identity for these revised setup references. +- The save-and-disconnect precondition added to the setup references after merge `72d4451202e79bbbb22090016543874a7c2e9836` is a documentation-only safety correction. The historical installation digests above apply to that merge and do not claim byte identity for these revised setup references. - The single-run D02 automatic-routing baseline failed and was not rerun. It recorded two semantic false activations and four budget-limit invalid observations: `96/100` observations were structurally valid, `94/96` valid observations were correct, and the frozen score remains `94/100` because invalid observations count as failures. Each skill's precision was `25/26`; the per-class and combined negative activation ceilings passed exactly with no headroom. Forty-six terminal cost receipts total `$6.7027235`; 54 early Skill captures have unknown cost rather than zero cost. Independent audit reproduced the score byte-for-byte and verified all 388 manifest entries without finding a material defect. There is no automatic-routing promotion or improvement claim because no prior-version comparison ran. - Five clean Claude marketplace installations of the final merged 0.1.2 bundle pass. These verify installation and byte identity only; they are not five additional native task runs and did not install or validate a Pascal CLI runtime. - Clean skills.sh and Codex Git marketplace installations also pass against the exact merge commit. These are package-install checks, not native task proof; neither installed nor validated a Pascal CLI or MCP runtime. - The foundation task proves one hosted-development journey, not all general construction, account claiming, or human handoff workflows. - Use one active agent client per local CLI service. The standalone HTTP bridge shares scene state across clients; concurrent independent client isolation is not supported. Hosted Community MCP uses a different session-isolated bridge. - Cursor Agent can list the configured MCP tools, but is signed out in the validation environment. A native Cursor task is not counted as passed. -- Production hosted MCP, newer npm CLI/MCP releases, official marketplace listing, external users, and retention require their own receipts. Public Git installation and a GitHub prerelease are not proof of npm publication, directory approval, or indexing. +- Production hosted MCP, official marketplace listing, external users, and retention require their own receipts. The native task evidence above ran against the GitHub prerelease archive, so the published npm `1.0.0` CLI carries an installation and command-surface check of its own but no native task cohort. Public Git installation and npm publication are not proof of directory approval or indexing. - Headless GLB export, delivery-route analysis, full door-swing geometry, and vertical clearance remain unsupported by the assessed layout tools. Run the package checks with `bun scripts/validate-skills.ts` and `claude plugin validate . --strict`. Runtime regression cases live in `packages/mcp/scripts/furniture-fit-journey.ts`. Each public `evals/` directory contains representative prompts and expectations; the private 20-case release gate is not distributed there. diff --git a/skills/furniture-fit/SKILL.md b/skills/furniture-fit/SKILL.md index a4e440b2c7..c2dc8da318 100644 --- a/skills/furniture-fit/SKILL.md +++ b/skills/furniture-fit/SKILL.md @@ -3,8 +3,8 @@ name: furniture-fit description: Assess whether furniture fits in a measured Pascal room or layout. Use this skill for sofa, table, bed, cabinet, appliance, staging, placement, collision, clearance, or rotated-footprint questions. Produce a tool-backed spatial report that distinguishes footprint fit from unsupported height, door-swing, assembly, and delivery-route claims, and return insufficient evidence when dimensions or scale are missing. compatibility: Requires a Pascal MCP connection for verified scene checks. Can still produce an input-gap report when the scene or measurements are unavailable. metadata: - version: "0.1.3" - source-reviewed: "2026-09-09" + version: "0.1.4" + source-reviewed: "2026-09-10" native-host-validation: "package-checks-only" openclaw: homepage: https://editor.pascal.app/docs/developers/mcp @@ -151,6 +151,14 @@ Use the exact report shape in [references/report-template.md](references/report- - one blocker-aware `nextAction` with its required input, exact available context, authority, and cost boundary; - the exact `editorUrl` returned by Pascal when a persistent project is involved. +When the user asks for a hosted link, or explicitly confirms that these measurements may be sent to Pascal, an eligible report can include an **Open dimension-only footprint pre-check** link. Eligibility requires exact positive dimensions no greater than `1,000,000` for one rectangular room footprint and one rectangular item footprint. Use the user's original `cm` or `in` values when they are exact; otherwise convert measured meter values to centimeters without rounding away meaningful precision. Use the user's explicit uniform room-boundary clearance when one was supplied. Item-to-item spacing from `check_collisions.minimumClearance` is a different constraint and must not be copied into this link. Use `clearance=0` only for a bare dimensional fit or when the user explicitly requested no added room-boundary clearance. Build only this fixed URL shape, with standard URL encoding: + +```text +https://editor.pascal.app/tools/furniture-fit?entry=agent_report&roomWidth=&roomDepth=&itemWidth=&itemDepth=&clearance=&unit=&shared=1 +``` + +The link recomputes only an empty axis-aligned rectangular footprint at 0Β° and 90Β° with uniform per-side room-boundary clearance. Label it as a separate dimension-only pre-check, not as the scene-backed verdict. Omit it when the room is irregular; dimensions are missing, ambiguous, inferred, or over the calculator limit; any directional or asymmetric clearance was requested; the user has not authorized sending private or local measurements to Pascal; or the requested conclusion depends on a tested position, existing objects, doors, height, delivery, or another scene-specific constraint. Opening the link sends the visible measurement query to `editor.pascal.app` and can leave it in browser history and service request logs. Never put a project, revision, graph hash, node ID, address, person, account, workspace, credential, signed URL, `flow_id`, or arbitrary scene text in the URL. Use `unavailable` plus the first reason when the link cannot represent the inputs safely. + Before sending the report, compare its numeric inputs and source IDs against both the user's constraint record and the actual tool output. Copy level, zone, item, candidate, and project IDs exactly; do not recreate them from memory. A missing requested check must be identified as incomplete, even when a narrower calculation passes. The examples are synthetic and illustrate correct claim boundaries: @@ -160,3 +168,4 @@ The examples are synthetic and illustrate correct claim boundaries: - [examples/all-tested-poses-fail.md](examples/all-tested-poses-fail.md) - [examples/insufficient-evidence.md](examples/insufficient-evidence.md) - [examples/unproven-height-metadata.md](examples/unproven-height-metadata.md) +- [examples/no-sign-in-dimension-precheck.md](examples/no-sign-in-dimension-precheck.md) diff --git a/skills/furniture-fit/evals/evals.json b/skills/furniture-fit/evals/evals.json index 1b7bcf7b18..ef68e9be2c 100644 --- a/skills/furniture-fit/evals/evals.json +++ b/skills/furniture-fit/evals/evals.json @@ -239,6 +239,68 @@ "Returns exactly one check_alternate_pose nextAction naming position [1.20, 0, 0.80] and 90 degrees.", "Labels the alternate proposed and unverified and requires fresh containment, collision, requested-clearance, and applicable door checks before a pass." ] + }, + { + "id": 13, + "prompt": "A user supplied an exact empty rectangular room 144 in wide by 120 in deep, a sofa footprint 84 in wide by 36 in deep, and 8 in of room-boundary clearance on every side. They explicitly ask for a Pascal link using these measurements. Return the bounded furniture-fit report and a human-openable pre-check.", + "expected_output": "Keeps the scene-backed report separate and includes the exact no-sign-in Pascal dimension-only pre-check URL using only the supplied inch values and fixed attribution keys.", + "files": [], + "semantic_case": "no-sign-in-dimension-precheck-link", + "expectations": [ + "Labels the link Open dimension-only footprint pre-check.", + "Uses exactly https://editor.pascal.app/tools/furniture-fit with roomWidth=144, roomDepth=120, itemWidth=84, itemDepth=36, clearance=8, unit=in, shared=1, and entry=agent_report.", + "States that the public calculator does not carry the scene-backed verdict or project-specific collision, door, height, delivery, or mesh evidence.", + "Discloses that opening the link sends the visible measurement query to Pascal and can retain it in browser history and service request logs.", + "Does not place project, revision, graph hash, node, identity, credential, address, signed URL, flow, prompt, or arbitrary scene data in the URL." + ] + }, + { + "id": 14, + "prompt": "The user authorizes sending the measurements to Pascal. The measured room is L-shaped inside a 400 cm by 350 cm bounding box, the exact sofa footprint is 210 cm by 95 cm, and the requested room-boundary clearance is 20 cm on every side. Include any safe human-openable pre-check in the report.", + "expected_output": "Omits the dimension-only calculator URL because the rectangular uniform-clearance calculator cannot represent the evidence, while retaining the scene-backed report.", + "files": [], + "semantic_case": "no-sign-in-dimension-precheck-unrepresentable", + "expectations": [ + "Marks Open dimension-only footprint pre-check unavailable and gives the first unrepresentable-input reason.", + "Does not flatten the irregular room, pose-specific obstacle, or unequal clearances into a misleading rectangular link.", + "Does not expose project, revision, graph hash, node, identity, credential, address, signed URL, flow, prompt, or arbitrary scene data." + ] + }, + { + "id": 15, + "prompt": "The user authorizes sending the measurements to Pascal. The exact rectangular room is 400 cm by 350 cm, the exact sofa footprint is 210 cm by 95 cm, and the user requests zero added room-boundary clearance. The requested scene pose overlaps a fixed kitchen island. The user asks whether that placement works and asks for a Pascal link.", + "expected_output": "Keeps the scene-backed collision failure and omits the empty-room calculator link because it would contradict the requested conclusion.", + "files": [], + "semantic_case": "dimension-precheck-scene-conflict", + "expectations": [ + "Marks Open dimension-only footprint pre-check unavailable because the requested conclusion depends on the fixed-island collision.", + "Does not produce a link whose empty-room verdict could contradict the scene-backed result.", + "Names the scene-dependent collision as the reason the narrower calculator is unavailable." + ] + }, + { + "id": 16, + "prompt": "The user authorizes sending the measurements to Pascal. The exact rectangular room is 400 cm by 350 cm and the exact sofa footprint is 210 cm by 95 cm, but the user requires 60 cm only in front of the sofa and 10 cm at the sides. They ask for a report and a Pascal link.", + "expected_output": "Omits the dimension-only link because the calculator cannot represent directional clearance without changing the request.", + "files": [], + "semantic_case": "dimension-precheck-asymmetric-clearance", + "expectations": [ + "Marks Open dimension-only footprint pre-check unavailable because the requested clearances are directional.", + "Does not replace the directional values with zero or with item-to-item minimum clearance.", + "Retains the exact directional constraint in the scene-backed report." + ] + }, + { + "id": 17, + "prompt": "A generated coordinate import reports a rectangular room width of 1000001 cm and otherwise exact dimensions. The user asks for a Pascal pre-check link.", + "expected_output": "Omits the link because the room width exceeds the calculator's accepted maximum instead of emitting a link that silently falls back to defaults.", + "files": [], + "semantic_case": "dimension-precheck-over-limit", + "expectations": [ + "Marks Open dimension-only footprint pre-check unavailable because one value exceeds 1000000.", + "Does not clamp, round, or replace the value with a default.", + "Does not emit a calculator URL." + ] } ] } diff --git a/skills/furniture-fit/examples/insufficient-evidence.md b/skills/furniture-fit/examples/insufficient-evidence.md index f3537b881e..69c70718b1 100644 --- a/skills/furniture-fit/examples/insufficient-evidence.md +++ b/skills/furniture-fit/examples/insufficient-evidence.md @@ -16,6 +16,8 @@ Stop there. Do not add conditional maximum-size, fit, height, route, or alternat Do not create a placeholder with guessed dimensions and report it as a verified fit. +**Open dimension-only footprint pre-check:** unavailable β€” the room and item footprints are not exact rectangular measurements. + ```yaml nextAction: kind: request_measurement diff --git a/skills/furniture-fit/examples/no-sign-in-dimension-precheck.md b/skills/furniture-fit/examples/no-sign-in-dimension-precheck.md new file mode 100644 index 0000000000..a8ec844e89 --- /dev/null +++ b/skills/furniture-fit/examples/no-sign-in-dimension-precheck.md @@ -0,0 +1,34 @@ +# Synthetic example: no-sign-in dimension pre-check + +## Inputs + +- Rectangular room: 400 cm wide Γ— 350 cm deep +- Rectangular sofa footprint: 210 cm wide Γ— 95 cm deep +- Uniform requested clearance: 20 cm on every side +- Exact source unit: centimeters +- The user explicitly asked for a Pascal link using these measurements +- No project, person, address, workspace, or private scene value is required for the pre-check + +## Report excerpt + +**Open dimension-only footprint pre-check:** https://editor.pascal.app/tools/furniture-fit?entry=agent_report&roomWidth=400&roomDepth=350&itemWidth=210&itemDepth=95&clearance=20&unit=cm&shared=1 + +This no-sign-in link sends the visible measurements to `editor.pascal.app` and may retain them in browser history and service request logs. It recomputes only the stated empty rectangular room and item footprints at 0Β° and 90Β° with 20 cm on every side. It does not carry or prove the report's project, position, collisions, existing-object spacing, doors, height, delivery route, detailed mesh, or scene-backed verdict. + +Do not add project IDs, revisions, graph hashes, node IDs, addresses, people, accounts, workspaces, credentials, signed URLs, flow IDs, or scene labels to the query string. + +```yaml +nextAction: + kind: check_related_item_or_pose + task: Check one other exact rectangular item footprint in the same measured room. + requiredInput: One exact item width and depth in centimeters. + context: + projectId: null + revision: null + graphHash: null + levelId: null + zoneId: null + itemId: null + authority: Read-only; no account or workspace changes, publication, save, or project mutation authorized. + cost: No rendering, generation, paid operation, or additional spending authorized. +``` diff --git a/skills/furniture-fit/references/report-template.md b/skills/furniture-fit/references/report-template.md index cd5d73b059..48d782573a 100644 --- a/skills/furniture-fit/references/report-template.md +++ b/skills/furniture-fit/references/report-template.md @@ -73,6 +73,15 @@ The next action is optional. Do not execute it, create or switch accounts/worksp - Changed node IDs: - Editor URL returned by Pascal: +## Open dimension-only footprint pre-check + +- URL: `https://editor.pascal.app/tools/furniture-fit?entry=agent_report&roomWidth=&roomDepth=&itemWidth=&itemDepth=&clearance=&unit=&shared=1` | unavailable +- Representation: exact rectangular room and item footprints at 0Β° and 90Β° with one uniform per-side room-boundary clearance; use zero when none was requested +- Difference from the report: this no-sign-in calculator assumes an empty rectangular room and does not carry the project, pose, collisions, doors, height, delivery route, detailed mesh, or scene-backed verdict +- Unavailable reason: first missing, private, or unrepresentable input | not applicable + +Include the URL only after the user asks for it or confirms that the measurements may be sent to Pascal. Every represented dimension must be exact, positive, no greater than `1,000,000`, and safe to disclose; clearance may be zero. Omit it for directional clearance or whenever scene-specific evidence changes the requested conclusion. Never reuse item-to-item spacing as room-boundary clearance. Opening the link sends its visible measurement query to `editor.pascal.app` and can retain it in browser history and service request logs. Keep its query keys fixed. Never add project, revision, graph hash, node, address, person, account, workspace, credential, signed URL, `flow_id`, or arbitrary scene values. + Use `footprint` in the verdict sentence. Never turn untested rows into an unqualified purchase, delivery, safety, or code-compliance assurance. An empty issue list with missing geometry is not a pass. State `not checked` or `insufficient evidence` and name the missing geometry. A read-only candidate is absent from `verify_scene`; do not borrow that tool's clean result for the candidate. diff --git a/skills/furniture-fit/references/setup.md b/skills/furniture-fit/references/setup.md index fb1f4434b1..8aff6d232f 100644 --- a/skills/furniture-fit/references/setup.md +++ b/skills/furniture-fit/references/setup.md @@ -6,39 +6,12 @@ Source and public-documentation review date: 2026-09-10. Native task results are Use the local path when the project should remain on the machine: -### Verified GitHub preview - -The npm `beta` tag currently resolves to `@pascal-app/cli@1.0.0-beta.1`, an older runtime that may not expose `check_collisions.candidate` or the hosted agent claim/status commands. For the candidate and hosted-agent paths verified with this skill, install the GitHub prerelease built from public commit `5dabbc3b56109c9f79dc8a378443a4c520d9ee0a`: - -```bash -PASCAL_PREVIEW_VERSION='1.0.0-beta.2.status.0' -PASCAL_PREVIEW_PREFIX="${XDG_DATA_HOME:-$HOME/.local/share}/pascal-preview" -PASCAL_PREVIEW_DOWNLOAD="$(mktemp -d)" -cd "$PASCAL_PREVIEW_DOWNLOAD" - -curl --fail --location --remote-name \ - "https://github.com/pascalorg/editor/releases/download/cli-v1.0.0-beta.2-status.0/pascal-app-cli-${PASCAL_PREVIEW_VERSION}.tgz" -curl --fail --location --remote-name \ - "https://github.com/pascalorg/editor/releases/download/cli-v1.0.0-beta.2-status.0/SHA256SUMS.txt" - -# macOS -shasum -a 256 -c SHA256SUMS.txt -# Linux: use `sha256sum -c SHA256SUMS.txt` instead. - -npm install --global --prefix "$PASCAL_PREVIEW_PREFIX" --ignore-scripts \ - "./pascal-app-cli-${PASCAL_PREVIEW_VERSION}.tgz" -export PATH="$PASCAL_PREVIEW_PREFIX/bin:$PATH" -pascal --version -``` - -Before activating the preview, save or otherwise persist every project with active work, then disconnect all editor and agent clients from this local service. `pascal update` may stop and restart the managed editor and MCP processes. Keeping the same `PASCAL_HOME` preserves persisted project data in that directory, but it does not preserve unsaved or unbound in-memory changes, undo history, or active client sessions. - ```bash -pascal update --version "$PASCAL_PREVIEW_VERSION" +npm install --global @pascal-app/cli pascal editor --no-open ``` -The expected archive SHA-256 is `15628baeeb174fb7786a1643db08f0554bf6d18afaaa3979f01922c5cd40019a`. The same-version `update` command installs and activates this CLI's bundled runtime, restarting an older running service when necessary. `pascal editor` alone reuses any healthy service, including an older one, so run `pascal update` when activating the preview. Keep the preview prefix on the agent host's `PATH` so its configured `pascal mcp connect` command resolves. The preview includes `pascal agent claim` for a prefilled 15-minute accountability handoff and `pascal agent status --json` for bounded credential and claim-state verification. This preview is not published on npm. +The npm package keeps the MCP service inside it and downloads the roughly 64 MB web editor runtime only when a command starts the editor, so `pascal mcp connect` needs no runtime download: an agent-only host can list, load, and save local scenes without one. Run `pascal editor` when a person needs the visual editor, and add `--runtime ` when the host has no network access. The Claude Code plugin supplies `pascal mcp connect` automatically. Keep `pascal` on the `PATH` used to launch Claude Code; the plugin does not install or start the Pascal editor. Claude Code 2.1.258 loads both the user-scoped `pascal` server created by `pascal mcp setup claude` and the plugin-provided server. Remove the manual entry with `claude mcp remove --scope user pascal` before reloading or restarting Claude Code. Use `/mcp` to remove or disable other manual Pascal connections. Leaving both connections active violates the one-active-agent-client-per-local-service requirement. If the intended project is hosted, disable the plugin-provided local server in `/mcp` before configuring the hosted connection below. @@ -58,7 +31,7 @@ Use only one active agent client with each local CLI service. The standalone HTT ## Existing hosted project -Create an API key in Pascal Settings for the same user or organization that owns the target project. Set `PASCAL_API_KEY` to that key without printing it. If you assign it in a shell command, avoid or remove that command from shell history. The hosted Streamable HTTP endpoint is: +Create an API key in Pascal Settings (`https://editor.pascal.app/settings`) for the same user or organization that owns the target project. Set `PASCAL_API_KEY` to that key without printing it. If you assign it in a shell command, avoid or remove that command from shell history. The hosted Streamable HTTP endpoint is: ```text https://editor.pascal.app/api/mcp @@ -77,8 +50,14 @@ codex mcp add pascal \ Codex stores the environment-variable name, not its value. Set `PASCAL_API_KEY` again in each new terminal before starting Codex, or supply it through the user's existing shell or secret-manager configuration. +Run this command even when the Codex plugin is installed. The plugin's portable `mcp.json` follows Agent Plugins 1.0.0, which forbids credentials and placeholder expansion in `headers` and reserves `Authorization` for the client, so a plugin cannot carry a hosted key. The plugin therefore supplies only the local `pascal` server, and `codex mcp add` owns the hosted connection. + Claude Code: +Plugin users set the key once in the configuration prompt shown when `pascal-agent-skills@pascal` is enabled. To add or change it later, reinstall with `claude plugin install pascal-agent-skills@pascal --config pascal_api_key=`, or open `/plugin` in a session and use its configure flow; there is no `claude plugin config` command. The hosted tools then load under the plugin's `pascal-hosted` server beside the local `pascal` server, and Claude Code keeps the key in the OS keychain, falling back to `~/.claude/.credentials.json`, rather than writing it into `settings.json` or any project file. + +Without the plugin, register the hosted endpoint manually: + ```bash : "${PASCAL_API_KEY:?Set PASCAL_API_KEY to the apiKey returned by Pascal}" && \ claude mcp add --scope user --transport http pascal https://editor.pascal.app/api/mcp \ @@ -100,6 +79,27 @@ openclaw mcp doctor pascal --probe The current OpenClaw static-header path stores the expanded key in its private MCP configuration and may warn about the literal credential during `doctor`. Do not commit or share that configuration. Remove the server with `openclaw mcp unset pascal` and rotate the Pascal key if the configuration is exposed. Installing this skill does not authorize a save, placement, account, upload, publication, or paid operation. +Cursor: + +Installing this repository as a Cursor plugin declares an optional `PASCAL_API_KEY` variable. A team admin sets its value in the Cursor dashboard under **Plugins** β†’ **Configure**, at install time or later; the repository holds only the `${PASCAL_API_KEY}` placeholder. With a value set, the plugin's `pascal-hosted` server reaches the hosted endpoint alongside the local `pascal` server. Leaving it unset keeps the install local-only: the local server still works and `pascal-hosted` fails with `401 Unauthorized` because the placeholder resolves to nothing. Disable `pascal-hosted` in Cursor's MCP settings to remove that failing entry. + +Cursor without the plugin, in `.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "pascal": { + "url": "https://editor.pascal.app/api/mcp", + "headers": { + "Authorization": "Bearer ${env:PASCAL_API_KEY}" + } + } + } +} +``` + +Cursor resolves `${env:PASCAL_API_KEY}` from the environment it starts in, so the file holds no key and `PASCAL_API_KEY` must be exported where Cursor is launched. The two Cursor syntaxes are not interchangeable: `${env:NAME}` reads the environment in a user or project `.cursor/mcp.json`, while a plugin's `mcp.json` uses the bare `${NAME}` plugin-variable form resolved from the dashboard. Interpolation syntax varies between clients; confirm that the chosen host supports this form before relying on it. + ## Separate autonomous workspace Only when the task explicitly authorizes creating separate private agent-owned work, register through `POST https://editor.pascal.app/api/auth/agent/register` with `name` and optional `purpose` and `agentClient`. Capture the returned key without printing it and store it securely. diff --git a/skills/pascal-3d/SKILL.md b/skills/pascal-3d/SKILL.md index 360f26de3b..96b0435e38 100644 --- a/skills/pascal-3d/SKILL.md +++ b/skills/pascal-3d/SKILL.md @@ -21,7 +21,7 @@ Use Pascal as the scene authority. Prefer its semantic tools and validation resu ## Start here -1. Check whether a Pascal MCP server is already connected. If it is, read `pascal://agent-guide` and inspect the available tools and their input schemas before changing anything. Installed and hosted releases can differ from this skill's source-review snapshot. +1. Check whether a Pascal MCP server is already connected. If it is, read `pascal://agent-guide` and inspect the available tools and their input schemas before changing anything. Installed and hosted releases can differ from this skill's source-review snapshot. When both the `pascal` and `pascal-hosted` servers are connected, use `pascal-hosted` for projects that live in the person's Pascal account, including Capture scans, and `pascal` for local work; never call both for the same task. 2. If Pascal is not connected, select the data boundary that matches the request: - **Local:** use the Pascal CLI for projects that should remain on this machine. - **Hosted existing account:** use an API key created by the same Pascal user or organization that owns the target project. @@ -46,6 +46,7 @@ If the task is a furniture or clearance assessment and the `furniture-fit` skill ### Read or create the right scene - Existing project: call `list_scenes` when available, select by exact ID or unambiguous name, then call `load_scene`. +- Room scan on the hosted server only: reach it with `list_captures`, then `get_capture`, then `open_capture_as_project` for a `processed` scan you have edit access to on the scan's own project; these tools do not exist on the local CLI, so never call them there. - New persistent project: call `create_project` before modeling. - Already active scene: call `get_project_status` and `get_scene` before editing. - If persistence tools are absent, explain that the connected server is an in-memory/custom runtime and do not promise a durable handoff. diff --git a/skills/pascal-3d/examples/local-project.md b/skills/pascal-3d/examples/local-project.md index 3cf7120d9b..214f34bb27 100644 --- a/skills/pascal-3d/examples/local-project.md +++ b/skills/pascal-3d/examples/local-project.md @@ -7,7 +7,7 @@ User request: Expected workflow: 1. Select the local CLI path; do not request an account or API key. -2. If needed, install with `npm install --global @pascal-app/cli@beta`, run `pascal editor --no-open`, then configure the active host with `pascal mcp setup `. Use one active agent client per local service; concurrent clients share active scene state. +2. If needed, install with `npm install --global @pascal-app/cli`, run `pascal editor --no-open`, then configure the active host with `pascal mcp setup `. Use one active agent client per local service; concurrent clients share active scene state. 3. Read `pascal://agent-guide`. 4. Call `create_project`, `create_room`, and `add_door` with meter values. 5. Call `validate_scene`, `verify_scene`, `save_scene` in draft mode, and `get_project_status`. diff --git a/skills/pascal-3d/references/setup.md b/skills/pascal-3d/references/setup.md index 7cccd5a085..c43788b841 100644 --- a/skills/pascal-3d/references/setup.md +++ b/skills/pascal-3d/references/setup.md @@ -8,46 +8,14 @@ Choose one path. Do not switch storage boundaries without the user's instruction Use local mode when the project should remain on the current machine. It requires Node.js 22.13 or newer and does not require a Pascal account or API key. -The npm beta is suitable for its published tool contract: +Install the published CLI: ```bash -npm install --global @pascal-app/cli@beta +npm install --global @pascal-app/cli pascal editor --no-open ``` -### Verified GitHub preview - -The npm `beta` tag currently resolves to `@pascal-app/cli@1.0.0-beta.1`, an older runtime that may not expose `check_collisions.candidate` or the hosted agent claim/status commands. For the candidate and hosted-agent paths verified with this skill, install the GitHub prerelease built from public commit `5dabbc3b56109c9f79dc8a378443a4c520d9ee0a`: - -```bash -PASCAL_PREVIEW_VERSION='1.0.0-beta.2.status.0' -PASCAL_PREVIEW_PREFIX="${XDG_DATA_HOME:-$HOME/.local/share}/pascal-preview" -PASCAL_PREVIEW_DOWNLOAD="$(mktemp -d)" -cd "$PASCAL_PREVIEW_DOWNLOAD" - -curl --fail --location --remote-name \ - "https://github.com/pascalorg/editor/releases/download/cli-v1.0.0-beta.2-status.0/pascal-app-cli-${PASCAL_PREVIEW_VERSION}.tgz" -curl --fail --location --remote-name \ - "https://github.com/pascalorg/editor/releases/download/cli-v1.0.0-beta.2-status.0/SHA256SUMS.txt" - -# macOS -shasum -a 256 -c SHA256SUMS.txt -# Linux: use `sha256sum -c SHA256SUMS.txt` instead. - -npm install --global --prefix "$PASCAL_PREVIEW_PREFIX" --ignore-scripts \ - "./pascal-app-cli-${PASCAL_PREVIEW_VERSION}.tgz" -export PATH="$PASCAL_PREVIEW_PREFIX/bin:$PATH" -pascal --version -``` - -Before activating the preview, save or otherwise persist every project with active work, then disconnect all editor and agent clients from this local service. `pascal update` may stop and restart the managed editor and MCP processes. Keeping the same `PASCAL_HOME` preserves persisted project data in that directory, but it does not preserve unsaved or unbound in-memory changes, undo history, or active client sessions. - -```bash -pascal update --version "$PASCAL_PREVIEW_VERSION" -pascal editor --no-open -``` - -The expected archive SHA-256 is `15628baeeb174fb7786a1643db08f0554bf6d18afaaa3979f01922c5cd40019a`. The same-version `update` command installs and activates this CLI's bundled runtime, restarting an older running service when necessary. `pascal editor` alone reuses any healthy service, including an older one, so run `pascal update` when activating the preview. Keep the preview prefix on the agent host's `PATH` so its configured `pascal mcp connect` command resolves. The preview includes `pascal agent claim` for a prefilled 15-minute accountability handoff and `pascal agent status --json` for bounded credential and claim-state verification. This preview is not published on npm. +The npm package keeps the MCP service inside it and downloads the roughly 64 MB web editor runtime only when a command starts the editor, so `pascal mcp connect` needs no runtime download: an agent-only host can list, load, and save local scenes without one. Run `pascal editor` when a person needs the visual editor, and add `--runtime ` when the host has no network access. The Claude Code plugin supplies this local connector automatically. Keep `pascal` on the `PATH` used to launch Claude Code; the plugin does not install or start the Pascal editor. Claude Code 2.1.258 loads both the user-scoped `pascal` server created by `pascal mcp setup claude` and the plugin-provided server. Remove the manual entry with `claude mcp remove --scope user pascal` before reloading or restarting Claude Code. Use `/mcp` to remove or disable other manual Pascal connections. Leaving both connections active violates the one-active-agent-client-per-local-service requirement. If the intended project is hosted, disable the plugin-provided local server in `/mcp` before configuring the hosted connection below. @@ -93,7 +61,7 @@ Use the hosted endpoint when the user wants the agent to work in a Pascal accoun https://editor.pascal.app/api/mcp ``` -The user creates an API key in Pascal Settings and chooses the intended personal or organization workspace. Set `PASCAL_API_KEY` to that key without printing it. If you assign it in a shell command, avoid or remove that command from shell history. +The user creates an API key in Pascal Settings (`https://editor.pascal.app/settings`) and chooses the intended personal or organization workspace. Set `PASCAL_API_KEY` to that key without printing it. If you assign it in a shell command, avoid or remove that command from shell history. Codex CLI: @@ -108,8 +76,14 @@ codex mcp add pascal \ Codex stores the environment-variable name, not its value. Set `PASCAL_API_KEY` again in each new terminal before starting Codex, or supply it through the user's existing shell or secret-manager configuration. +Run this command even when the Codex plugin is installed. The plugin's portable `mcp.json` follows Agent Plugins 1.0.0, which forbids credentials and placeholder expansion in `headers` and reserves `Authorization` for the client, so a plugin cannot carry a hosted key. The plugin therefore supplies only the local `pascal` server, and `codex mcp add` owns the hosted connection. + Claude Code: +Plugin users set the key once in the configuration prompt shown when `pascal-agent-skills@pascal` is enabled. To add or change it later, reinstall with `claude plugin install pascal-agent-skills@pascal --config pascal_api_key=`, or open `/plugin` in a session and use its configure flow; there is no `claude plugin config` command. The hosted tools then load under the plugin's `pascal-hosted` server beside the local `pascal` server, and Claude Code keeps the key in the OS keychain, falling back to `~/.claude/.credentials.json`, rather than writing it into `settings.json` or any project file. + +Without the plugin, register the hosted endpoint manually: + ```bash : "${PASCAL_API_KEY:?Set PASCAL_API_KEY to the apiKey returned by Pascal}" && \ claude mcp add --scope user --transport http pascal https://editor.pascal.app/api/mcp \ @@ -131,7 +105,11 @@ openclaw mcp doctor pascal --probe The current OpenClaw static-header path stores the expanded key in its private MCP configuration and may warn about the literal credential during `doctor`. Do not commit or share that configuration. Remove the server with `openclaw mcp unset pascal` and rotate the Pascal key if the configuration is exposed. Skill installation alone does not configure this connection or authorize an account, upload, save, publication, or paid operation. -For other JSON-based clients, prefer their supported environment-variable or secret interpolation rather than a literal key: +Cursor: + +Installing this repository as a Cursor plugin declares an optional `PASCAL_API_KEY` variable. A team admin sets its value in the Cursor dashboard under **Plugins** β†’ **Configure**, at install time or later; the repository holds only the `${PASCAL_API_KEY}` placeholder. With a value set, the plugin's `pascal-hosted` server reaches the hosted endpoint alongside the local `pascal` server. Leaving it unset keeps the install local-only: the local server still works and `pascal-hosted` fails with `401 Unauthorized` because the placeholder resolves to nothing. Disable `pascal-hosted` in Cursor's MCP settings to remove that failing entry. + +For Cursor without the plugin, and for other JSON-based clients, prefer their supported environment-variable or secret interpolation rather than a literal key. In `.cursor/mcp.json`: ```json { @@ -140,14 +118,14 @@ For other JSON-based clients, prefer their supported environment-variable or sec "type": "http", "url": "https://editor.pascal.app/api/mcp", "headers": { - "Authorization": "Bearer ${PASCAL_API_KEY}" + "Authorization": "Bearer ${env:PASCAL_API_KEY}" } } } } ``` -Client interpolation syntax varies. Confirm that the chosen host supports this form before relying on it. +Cursor resolves `${env:PASCAL_API_KEY}` from the environment it starts in, so the file holds no key and `PASCAL_API_KEY` must be exported where Cursor is launched. The two Cursor syntaxes are not interchangeable: `${env:NAME}` reads the environment in a user or project `.cursor/mcp.json`, while a plugin's `mcp.json` uses the bare `${NAME}` plugin-variable form resolved from the dashboard. Client interpolation syntax varies. Confirm that the chosen host supports this form before relying on it. ## Autonomous private work diff --git a/skills/pascal-3d/references/tool-workflows.md b/skills/pascal-3d/references/tool-workflows.md index 13e303cb6b..41f46bb356 100644 --- a/skills/pascal-3d/references/tool-workflows.md +++ b/skills/pascal-3d/references/tool-workflows.md @@ -16,6 +16,17 @@ Inspect the server's advertised tools because hosted and local releases may diff `get_scene` returns the full graph and is useful when a compact summary omits a field needed for a calculation, such as an item's scale. +## Open a room scan (hosted only) + +These three tools exist only on the hosted Pascal server. A local CLI connection does not advertise them, so inspect the advertised tools before assuming this path is available. + +1. `list_captures`, optionally narrowed by `projectId`, `status`, or `limit`. +2. `get_capture` with the `captureId`, adding `includeScanMetrics` when the answer needs scan quality numbers. +3. `open_capture_as_project` once the capture reports `processed`, to bind the owning project's persisted draft into the session. +4. Continue with the project workflows above. + +All three require edit access on the scan's own project; view access, including a public project owned by someone else, is refused as not found. The first two are read-only. `open_capture_as_project` creates nothing and is idempotent, but it carries the same non-read-only annotation as `get_project_status` because it changes the project the session is bound to. + ## Create an editable project 1. `create_project` @@ -50,5 +61,6 @@ Do not mutate just to make a report unless the user authorizes a temporary or sa - `place_item` uses catalog dimensions. If a catalog item is unavailable, its placeholder dimensions are not evidence for a real product. - `check_collisions` checks rotation-aware scaled item footprints using plan AABBs. Pass `minimumClearance` explicitly: zero reports overlap; a positive measurement also reports pairs closer than that gap. Inspect `status`, `checkedItems`, `skippedItems`, and `unsupportedChecks` before drawing a conclusion. - `verify_scene` adds practical issues, including item separation and rectangular door-access keep-outs. It does not model a door-leaf swing arc or a delivery route. +- No tool starts a room scan or clones a scan into a new project. Scans are created only by the Pascal iOS app, and `open_capture_as_project` opens the scan's existing owning project. When a requested deliverable is unsupported, return `partial` or `failed` with the tool status and the next supported action. Do not substitute an invented file, URL, or capability. diff --git a/wiki/architecture/capture-runtime.md b/wiki/architecture/capture-runtime.md index 8df4a6b66b..d62314737c 100644 --- a/wiki/architecture/capture-runtime.md +++ b/wiki/architecture/capture-runtime.md @@ -3,15 +3,18 @@ Capture data is an optional viewer extension, not a private Community renderer and not a second scene graph. -## Package boundaries - -- `@pascal-app/capture-protocol` owns versioned manifests, normalized stream descriptors, stable - session locators, incremental packet headers, and the `CaptureSource` interface. It has no React, - Three.js, authentication, database, or prescribed transport. -- `@pascal-app/capture-viewer` mounts inside `Viewer` through its existing children slot. It resolves - `scan.captureSession`, portals layers into that scan node's registered group, honors per-layer - visibility, composes declared local-to-parent coordinate frames into session space, and supplies - reference model, device-motion, point-cloud, and compact color-surface renderers. +## Ownership boundaries + +- `@pascal-app/core/capture` (`packages/core/src/capture/`) owns versioned manifests, normalized + stream descriptors, stable session locators, incremental packet headers, and the `CaptureSource` + interface. It has no React, Three.js, authentication, database, or prescribed transport, so it + stays inside core's pure-logic layer rule. +- `@pascal-app/viewer/capture` (`packages/viewer/src/capture/`) mounts inside `Viewer` through its + existing children slot. It resolves `scan.captureSession`, portals layers into that scan node's + registered group, honors per-layer visibility, composes declared local-to-parent coordinate frames + into session space, and supplies reference model, device-motion, point-cloud, and compact + color-surface renderers. `@pascal-app/viewer/capture/preview` exposes the matcap and surface-mesh + geometry builders on their own for capture clients that render a local preview without the runtime. - `@pascal-app/core` stores only the scene anchor: session locator, optional current mesh URL, placement, opacity, and an extensible visibility map. Raw samples and artifact inventories never enter scene JSON. diff --git a/wiki/architecture/node-definitions.md b/wiki/architecture/node-definitions.md index 67c8ecdedb..3de5524ce9 100644 --- a/wiki/architecture/node-definitions.md +++ b/wiki/architecture/node-definitions.md @@ -84,6 +84,82 @@ type GeometryContext = { For level-scoped batch data (wall mitering across an entire level), `ctx` can be extended with `ctx.levelData?.miters` in a future revision β€” decided alongside the wall migration (Phase 3 of the registry plan). +## Floor-plan scope + +`def.floorplan` is a pure `FloorplanGeometry` builder over the same +`GeometryContext` shape. `def.floorplanScope` controls discovery: + +| Scope | Persisted parent | Builder coordinates | `ctx.parent` | +|---|---|---|---| +| `'level'` (default) | active level subtree | building-local metres | semantic parent | +| `'building'` | active building | building-local metres | active level | +| `'site'` | active building's Site | site-local metres | real Site | + +The floor-plan layer applies the inverse active-building transform to +site-scoped output and paints that output below level architecture. A plugin +therefore keeps one semantic Site child while the same representation appears +from every level of every building on that Site. Scope discovery is +registry-driven; editor code must not name plugin kinds. + +`FloorplanStyle.fillRule` is the winding rule for compound contours. Use +`'evenodd'` when nested rings represent holes; both the interactive SVG +renderer and PDFKit export preserve it. `FloorplanImage.url` may also be an +inline `data:` URL, which PDF export passes directly to PDFKit rather than +through the asset resolver. + +## Export-only geometry + +`def.bakeGeometry(node, ctx)` replaces the registered node's cloned subtree +only inside `prepareSceneForExport()`. It exists for procedural runtime trees +whose live GPU representation is not a faithful portable artifactβ€”for example, +an instanced maximum population masked by a TSL material. + +The hook receives persisted scene data through `GeometryContext` and returns a +new detached, local-space `Object3D`. That return value is the complete static +snapshot for the node. It must use geometry and materials supported by +`GLTFExporter`; the exporter preserves the registered node's transform and +identity. The live editor tree is neither passed to the hook nor mutated. + +Use `bake: 'replace'` with `bakeGeometry` when the generic GLB should retain the +portable static snapshot while Pascal's baked viewer hides it and mounts +`bakeReplaceRenderer` for the richer live result. + +`def.bakeGeometryAsync(node, ctx)` is the asynchronous counterpart for material +baking and texture reads. Portable export awaits it once instead of invoking the +synchronous hook; synchronous geometry-only callers retain `bakeGeometry`. +Both return detached, local-space trees owned by the export artifact. Context +includes captured materials and level data as well as semantic node lookup. + +Model exports accept `excludedNodeTypes?: readonly string[]`. Matching registered +subtrees are omitted before cloning or invoking either builder. Filtering affects +output, not the complete semantic context available to retained builders. + +Settings β†’ Export β†’ **Include in file** discovers procedural kinds from +`bakeGeometry`, `bakeGeometryAsync`, or `bake: 'replace'`, including palette-hidden +kinds. Node filters apply to model downloads, not saved-viewer artifacts, print +profiles, scene JSON, or floor-plan PDFs. GLB and USDZ additionally accept +`includedPresentationIds` for explicitly selected static presentation builders; +live presentation subtrees remain outside `scene-renderer` and are never cloned. + +Portable GLB/USDZ outputs freeze instancing and deformation and normalize +material textures, vertex colors, sidedness, and reflected geometry. Saved-viewer +artifacts retain their authored animation clips. Preparation captures the source +synchronously, restores viewer state before asynchronous work, and returns an +owned artifact that callers must dispose after serialization or failure. + +## Selection presentation + +`capabilities.selectionHighlight` controls only the Editor's material-based +selection and hover presentation. It defaults to `true`, including for legacy +and unregistered kinds. Set it to `false` when a node must stay semantically +selected while its rendered subtree keeps plugin-authored materialsβ€”for +example, a paint layer whose NodeMaterial carries the result being edited. + +The selection manager and outliner query this capability through the registry, +including after late plugin registration. The capability does not change +selectability, inspector ownership, tool activation, keyboard behavior or +deletion policy. Host code must not special-case the opting-out kind. + ## Choosing the right combination ### `geometry` only diff --git a/wiki/architecture/plugin-authoring.md b/wiki/architecture/plugin-authoring.md index d089433024..8af6fa9c89 100644 --- a/wiki/architecture/plugin-authoring.md +++ b/wiki/architecture/plugin-authoring.md @@ -36,7 +36,7 @@ The same shape powers the built-in `pascal:core` plugin in `@pascal-app/nodes` ## What a `NodeDefinition` can contribute -A plugin's `nodes` array is the only meaningful contribution point in v1. Each entry is a `NodeDefinition` that the registry stamps with `kind`, `schemaVersion`, `schema`, and any combination of: +The core `Plugin` manifest owns semantic node definitions (and registry-backed inspector extensions); host UI and viewer-wide presentation remain separate exports. Each `nodes` entry is a `NodeDefinition` that the registry stamps with `kind`, `schemaVersion`, `schema`, and any combination of: - `defaults` β€” initial field values for new instances. - `capabilities` β€” `selectable` / `duplicable` / `deletable` / `surfaces` / `relations` flags consumed by the framework. @@ -198,6 +198,49 @@ Install/uninstall is a project-level visibility operation. Plugin code and node Host panels mount lazily inside an error boundary. Use host CSS variables, keep CSS scoped to the plugin, and do not write global styles. +## Viewer presentation contributions + +A plugin can also export a presentation-only R3F subtree separately from its +core manifest. Use this for scene-wide derived visuals such as atmosphere, +weather, or surroundings that are not authored nodes: + +```tsx +import type { ViewerPresentationContribution } from '@pascal-app/viewer' + +export const myPresentation: ViewerPresentationContribution = { + id: 'acme:landscape:presentation', + pluginId: 'acme:landscape', + component: () => import('./presentation'), +} +``` + +The application registers it during the same bootstrap pass as the plugin and +host panel: + +```ts +import { registerViewerPresentation } from '@pascal-app/viewer' + +registerViewerPresentation(myPresentation) +``` + +`@pascal-app/editor` mounts the public `` contribution +host once in both its edit and preview viewers. A host composing raw +`` mounts `` explicitly. Do not also put the +same contribution in `viewerSceneSlot`; that double-mounts it. + +When `pluginId` is present, the contribution is mounted only while that id is +in the project's `installedPlugins`. Registration remains session-add-only; +project uninstall releases the mounted subtree and reinstall creates a fresh +one. Lazy load and render failures are isolated per contribution. Contributions +must clean up Three.js resources on unmount and keep all scene-scoped ownership +per R3F `Scene`, so two Viewer instances cannot affect each other. + +Presentation is outside `scene-renderer` and therefore outside semantic model +export. It must not create authored nodes, `pascalId` values, selection/query +targets, or history entries. Registration also provides no persistence: +versioned plugin configuration belongs in a host-owned project sidecar, and +the host must call the plugin's public import/export functions explicitly. + ## Versioning `apiVersion: 1` covers the surface above. The host bumps the major when it removes or changes the shape of an existing field. New optional fields don't bump. The plan is to keep additions backwards-compatible as long as possible β€” the bump is the escape hatch, not the default. diff --git a/wiki/architecture/systems.md b/wiki/architecture/systems.md index 155994669e..a1e9668984 100644 --- a/wiki/architecture/systems.md +++ b/wiki/architecture/systems.md @@ -37,6 +37,63 @@ containers preserve source shadow flags. Selection (including external selection live transforms and each slot paint preview target release sources until settled. Level mode/selected-level changes re-offer sources rejected while shadow-only. +### Initial wall build + +`setScene` assigns a non-persisted hydration identity, then publishes its eligible +`hydrationToken` after synchronous reconciliation and hydration-owned deferred +normalization finish. Elevator openings and reconciliation of replaced levels run +inside the synchronous boundary; queued stair rise/opening normalization extends +that boundary through its microtask. The store owns these opening passes even if +their reactive systems mount after hydration, and honors the scene mutation lock. +Ordinary document writes cancel pending publication or invalidate an issued token atomically before subscribers run, +including paused, remote and undo/redo writes. History pausing alone grants no +exemption. Dirty marks alone do not invalidate it, so opening completion can still +re-dirty its parent wall. + +The canvas ref installs pointerdown, pointermove and wheel capture before lazy +systems mount. Live override/transform interruption belongs to the scene store; +nonempty maps cancel hydration even if cleared before the wall consumer mounts. +`applySceneSnapshot` clears stale live maps before starting the replacement. +The eager wall lifecycle owner observes tokens independently of `WallSystem`, so a +consumer remount retains the same span, counters, built-wall identities and pending +neighbours. A fresh hydration resets that state; an interruption cannot re-enter +for the same token. These hydration-scoped records are an exception to the usual +system-unmount cache cleanup rule; the consumer still clears its miter cache. + +Initial build ends on the first frame with no dirty walls and no pending +neighbours, or on interruption. If no walls rebuild for 30 consecutive frames +while dirty walls lack registered meshes (one placeholder-sweep interval), the +privilege is revoked. This bounded renderer grace period leaves their dirty marks +intact and does not report geometry completion; a later mount still rebuilds them. +Unavailable walls do not continually postpone the pending-neighbour quiet clock. +`isWallInitialBuildActive()` and `getPendingWallRebuildCount()` remain readable +without `?perf`. + +Initial build consumes walls under the existing **8 ms budget**, checked between +walls, without the interactive **8 walls/frame** cap. A wall with at least six +opening cutouts occupies its own frame. Each wall's first build during active +initial build skips adjacency scanning and neighbour re-invalidation because the +hydrated inputs are stable and its neighbours are queued for their own first +builds. Subsequent builds retain neighbour invalidation and the **80 ms** trailing +quiet window. Once initial build ends, the existing interactive scheduling applies +(progressive limits for queues larger than eight; small edits rebuild immediately). + +Only with `?perf`, `__pascalPerf.batchStats().wallDrain` publishes the active state, +this frame's consumption, cumulative budget/heavy/drained/cap exits, pending-neighbour +count, first builds, re-invalidation builds and unique neighbour enqueues. Publication +reuses one mutable stats object without allocating frame snapshots. Counters reset +on each hydration identity, including one interrupted before token publication. +`firstBuilds` counts the first-ever geometry build of each wall in that hydration, +even after interruption; `reinvalidationBuilds` counts later builds of those walls. +The `wall-initial-build` span starts at eligible token publication and ends at drain +completion or interruption, spanning consumer unmounts. Counters do not imply that +opening-system completion has drained: late opening builds can still re-dirty walls. + +The wall batch still waits for its pending-neighbour queue. Node batching retains +its global 180 ms quiet clock for now. Initial-drain batching is a follow-up: bounded +joins must preserve whole-wave `MIN_BATCH_ENTRIES` decisions and partial/leftover +membership, including candidates larger than one frame's allowance. + ### Viewer Systems β€” `packages/viewer/src/systems/` Access Three.js objects (via `useRegistry`) and manage rendering side-effects. @@ -105,6 +162,50 @@ Any optimization that scopes reconciliation to a subset of nodes or rooms must b equivalence with a full level scan. Representative create, update, delete, cascade, split, merge, and corridor-enclosure edits must produce the same spaces and surfaces as full reconciliation. +## Undo and redo invalidation + +Standalone history jumps clear live transforms and node overrides, including surface-hole +previews. Before a jump, the editor captures the effective layout by merging live overrides +onto committed nodes. Before clearing previews it runs the same pure dependency closure used +for committed history snapshots, with that effective layout as `before` and the committed +target as `after`: wall neighbours in either layout and hosted children on host dimension +changes must rebuild even when only the discarded preview connected them. Overrides published +during restoration/cleanup also contribute their closure before being cleared. Surviving live +transform targets and their parents receive restoration marks too. Empty commands preserve +previews, and collaborative delegates own their own refresh. + +Core diffs the before/after node snapshots in a microtask before paint. It marks changed nodes, +old and new parents, wall neighbours in both layouts (scoped to the wall's level), and hosted +doors/windows/items when wall thickness, height or curvature changes. Deletion retains its +conservative surviving-sibling refresh and removes marks for missing IDs. Both layouts are +captured per jump; reconciliation's history pause/resume notifications cannot replace them. +The cold-start fallback without a previous snapshot remains conservative. + +Temporal restoration writes to the scene store, so existing subscriptions still own spatial +index updates, slab context tracking, space detection, stair rise/openings, elevator openings, +and level-height dependents. Spatial sync also checks before/after rendered slab boundaries: +wall bands and sibling seams can change support even when the slab's stored polygon is unchanged. +Support invalidation tests the gained/lost rendered bands in both layouts, so objects on a +former boundary re-elevate while consumers in the unchanged interior stay clean. Each pass +groups affected-level walls, slabs and consumers once and caches each slab's rendered polygon +once per layout. Discovering changes still scans the snapshots; it does not scan the scene +again for each candidate slab. + +Standalone undo/redo scopes reconciliation candidates to every identity-changed node in the +current and target snapshots, including additions/removals and every step of a multi-step jump. +Changed site, building or level identities retain full-level reconciliation. The slab tracker +mirrors the renderer's context through `slabPolygonContextForLevel`, preserving `level.children` +membership and order for wall adoption and sibling seams. It signs each derived polygon, +elevation, thickness and recessed state, plus building transforms for terrain-filled slabs. +Unchanged input references skip serialization; changed levels share prepared wall bands and +sibling segments, and conservative bounds in both layouts limit polygon derivation. Direct +slab writes retain their existing invalidation. This is not a complete terrain-fill eligibility +signature: level base elevations, stack heights and building-to-site ancestry remain outside it. + +There is no routine whole-scene history refresh or batch reset. The existing priority-1 batch +snapshot releases affected sources (including dirty walls' openings); untouched members stay +batched, and affected members rejoin through the normal settle window. + ## Adding a New System 1. Decide the scope: diff --git a/wiki/architecture/vertical-model.md b/wiki/architecture/vertical-model.md index 521441d61d..bb475da808 100644 --- a/wiki/architecture/vertical-model.md +++ b/wiki/architecture/vertical-model.md @@ -2,7 +2,7 @@ *How buildings stack: stored level heights, plane-bound wall/ceiling tops, slab placement + thickness, support hosts, and the clamp rules that keep it all coherent.* -Applies to: anything that reads or writes vertical geometry β€” levels, walls, slabs, ceilings, stairs, fences, floor-placed items. +Applies to: anything that reads or writes vertical geometry β€” levels, walls, slabs, ceilings, roofs, stairs, fences, floor-placed items. The invariant, in one sentence: @@ -21,6 +21,7 @@ The invariant, in one sentence: | `level.baseElevation` | Additive offset from the computed stack position. It shifts this level and cumulatively shifts every higher level in the same building; negative offsets are valid. | Zero (the schema default). | | `wall.height` | Explicit body height (half wall, parapet, or a raised-support draft whose ghost height must remain invariant). Ground-hosted walls always resolve top = elected base + height, including below datum; other legacy sunken supports retain their absolute-top constraint. | **Plane-bound** (the default for ordinary datum placement): the top follows `getWallPlaneTop` β€” `min(level height, lowest covering-slab underside over the span)`. | | `ceiling.height` | Explicit custom height, write-clamped to the bound. | **Follows the level**: resolves live to `getCeilingClampBound` = `min(level height, covering underside) βˆ’ 0.01`. | +| `roof.support.kind` | `walls` follows the highest spatially matched wall top in the roof’s level frame; `level` keeps custom Y; `roof` retains its roof-surface attachment rule. Room and curved-wall creation write `walls`; free-drawn rectangles write `level` at Y 0. | Existing roofs remain custom (`level`); no load migration enables following. | | `slab.elevation` | The walking surface (top), level-local. | Default 0.05. | | `slab.thickness` | Grows **downward**: the solid occupies `[elevation βˆ’ thickness, elevation]`. | Default 0.05. | | `slab.recessed` | Recess intent: open shell whose floor is `elevation` and whose rim is `recessedRimElevation`. Excluded from "covering" queries and wall-face adoption. | Solid slab. | @@ -31,7 +32,7 @@ The invariant, in one sentence: | `fence.supportOffset` | Optional level-local delta from the fence's slab host or level plane. It translates the complete fence while preserving height. | Zero offset: the fence sits directly on its host or level plane. | | `wall.fillToTerrain` | Extends the wall downward from its authored base to the terrain with independently sampled left/right faces. The wall body height and top stay unchanged. | Fixed base with no terrain infill. | | `stair.deckSlabId` | Destination deck: rise follows `deck.elevation βˆ’ the stair's own elected base` live; cutout sync disabled while attached. | Destination is a level. | -| `stair.totalRise` | Explicit custom rise (wins over everything). | Follows: derived from the deck or the containing level; `syncStairRises` converges straight-stair segments to the resolved rise. | +| `stair.totalRise` | Explicit custom rise (wins over everything). | Follows: the deck's `elevation`, else the containing level's floor-to-floor height β€” each **minus the stair's own elected base**, so a slab under the stair shortens the rise the way it shortens a plane-bound wall; `syncStairRises` converges straight-stair segments to the resolved rise. | Two schema rules protect these semantics: @@ -49,6 +50,7 @@ Two schema rules protect these semantics: | `getCeilingClampBound`, `getCoveringSlabUndersideAt` | `services/storey.ts` | Ceiling bound; the cross-level covering query (level above, non-recessed slabs) | | `resolveCeilingHeight` | `services/level-height.ts` | A ceiling's effective height (explicit or follows) | | `resolveStairTotalRise`, `syncStairRises` | `systems/stair/stair-rise.ts` | Stair rise precedence + straight-flight convergence | +| `resolveRoofElevation`, `resolveRoofWallTopElevation` | `systems/roof/roof-elevation.ts` | Highest spatially matched wall top for `walls` support, including explicit heights and elected bases, converted to the roof's level frame | | `computeWallSlabSupport`, `getSlabSupportForItem`, `getSupportCandidatesForFootprint` | `systems/slab/slab-support.ts` + spatial-grid manager | Support election (rendered polygons, host-preferring, optional `maxElevation` cap) | | `resolveSlabPlacementElevation` | `systems/slab/slab-placement.ts` | Translates a solid slab's authored top/thickness interval onto a captured base plane; recessed slabs stay level-relative | | `getSlabBaseElevation`, `applySlabBaseElevationChange`, `applySlabThicknessChange` | `nodes/slab/elevation-limit.ts` | Separates whole-body underside placement from fixed-base thickness editing | @@ -134,6 +136,7 @@ Because community autosave only persists after the first post-load edit, the mig ## Gotchas +- **Only `support.kind: 'walls'` roofs follow walls.** The resolver projects the roof’s XZ centre onto its parent level’s lower neighbour, selected by `findLevelBelowId` from `getLevelElevations` in the same building stack (ordinals need not be consecutive). It uses the smallest enclosing room at that point and takes the highest resolved wall top without clamping to the storey. Conical roofs match curved walls by arc centre and radius against their transformed segment footprint, without a wall-ID binding. No matching enclosure or arc freezes Y and preserves follow intent, so redrawing walls resumes following. Negative level-local Y is valid: 2.5 m walls under a 3 m storey put the roof at βˆ’0.5 m. `RoofElevationSystem` re-derives Y only for following roofs on wall, slab, level, building, site, and roof edits, history-paused and one microtask after store updates so the spatial grid has settled. Settled updates publish a separate scene commit without adding an undo step; an outer gesture’s history pause retains commit ownership. The panel’s β€œFollows walls” choice enables this rule; β€œCustom” writes `level` and keeps Y. An explicit Y change exceeding 1e-4 m in the panel or 3D move handle switches to `level` in the same patch; XZ-only moves retain `walls` and re-resolve spatially. Undo/redo restores mode and Y together. Roof-surface attachments hide this mode control and retain their own rule. Schema version stays 3 and load never opts existing roofs into following. - **Ordinals are semantic.** `level < 0` renders "Basement N"; `level === 0` is the ground-floor lookup. Never renumber without the zero anchor. - **Boundary geometry.** Auto slabs derive polygons from wall centerlines, so wall/ceiling clamp samples sit exactly on polygon edges β€” always use the boundary-inclusive band-overlap helpers (`wallOverlapsSlabFootprint`, `slabCoversPoint`), never raw ray-cast point-in-polygon on those paths. - **Straight stairs build from stored segment heights**, not the resolved rise β€” any rise change must go through `syncStairRises` (applied by `StairOpeningSystem`, history-paused, one microtask after store updates so the spatial grid has settled). diff --git a/wiki/architecture/viewer-isolation.md b/wiki/architecture/viewer-isolation.md index d5f880280d..499fbde149 100644 --- a/wiki/architecture/viewer-isolation.md +++ b/wiki/architecture/viewer-isolation.md @@ -77,6 +77,64 @@ When an editor feature needs to live "inside" the canvas but must not pollute th This pattern lets the viewer stay ignorant of these components while they still have access to the R3F context. +## Plugin presentation contributions + +Presentation-only plugin content uses the viewer-owned registry rather than a +route-specific scene slot. The core `Plugin` manifest stays rendering-agnostic: +the host registers a separate contribution during bootstrap and mounts the +registry once inside each viewer that should show presentation. + +```tsx +import { + registerViewerPresentation, + Viewer, + ViewerPresentations, + type ViewerPresentationContribution, +} from '@pascal-app/viewer' + +const presentation: ViewerPresentationContribution = { + id: 'acme:landscape:presentation', + pluginId: 'acme:landscape', + component: () => import('./presentation'), +} + +registerViewerPresentation(presentation) + + + + +``` + +`ViewerPresentations` filters `pluginId` through the current project's +`installedPlugins`. Uninstalling a plugin therefore unmounts its contribution; +reinstalling remounts it without hot-removing the session's code or node +definitions. Every lazy contribution has its own Suspense and error boundary, +so a failed plugin does not take down the authored scene or its siblings. + +The mount is a sibling of `scene-renderer`, never one of its descendants. It +must not create semantic nodes, selection targets, history entries, or query +results. Authored model export remains rooted at `scene-renderer`. A registered +presentation may additionally provide a `staticExport` builder: GLB and USDZ +include that contribution only when explicitly selected. The builder derives +finite, export-owned geometry from its inputs rather than cloning the live +presentation's camera-dependent visibility or LOD. + +Raw `` embedders opt into registered presentation by mounting +``; snapshot inclusion remains an explicit host policy. +The reusable `` already mounts it in its normal and preview compositions. + +Registration does not persist plugin configuration. A plugin that exposes +versioned configuration export/import still needs its host to store that value +in a project sidecar and restore it before or after the viewer mounts. + +Static builders receive a complete semantic node snapshot, a detached +configuration captured when export starts, and output visibility/type filters. +They return a detached world-space root without creating scene nodes or history. +Returned geometry, materials, and textures belong to the artifact. Cached +presentation texture handles must be marked with +`markViewerPresentationTextureBorrowed`; the host clones those handles before +attaching the contribution and never disposes the borrowed source. + ## Checklist Before Adding Code to `packages/viewer` - [ ] Does this feature make sense in the read-only viewer route?