From e31c0f8a36afb7cee89635587328474029a27554 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 13:16:54 +0000 Subject: [PATCH 1/3] deps: bump @biomejs/biome to 2.5.15 and turbo to 2.11.8 --- bun.lock | 36 ++++++++++++++++++------------------ package.json | 4 ++-- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/bun.lock b/bun.lock index 42927214ff..7455b29f04 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,12 @@ "": { "name": "editor", "devDependencies": { - "@biomejs/biome": "^2.4.16", + "@biomejs/biome": "^2.5.15", "@typescript/native-preview": "7.0.0-dev.20260624.1", "@typescript/typescript6": "6.0.2", "dotenv-cli": "^11.0.0", "fast-xml-parser": "^5.11.2", - "turbo": "^2.9.17", + "turbo": "^2.11.8", "typescript": "npm:@typescript/typescript6@6.0.2", "ultracite": "^7.8.2", }, @@ -422,23 +422,23 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="], + "@biomejs/biome": ["@biomejs/biome@2.5.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.15", "@biomejs/cli-darwin-x64": "2.5.15", "@biomejs/cli-linux-arm64": "2.5.15", "@biomejs/cli-linux-arm64-musl": "2.5.15", "@biomejs/cli-linux-x64": "2.5.15", "@biomejs/cli-linux-x64-musl": "2.5.15", "@biomejs/cli-win32-arm64": "2.5.15", "@biomejs/cli-win32-x64": "2.5.15" }, "bin": { "biome": "bin/biome" } }, "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.15", "", { "os": "win32", "cpu": "x64" }, "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g=="], "@bufbuild/protobuf": ["@bufbuild/protobuf@2.15.0", "", {}, "sha512-DAheWUkVr/SJTWCc+lg9dhY0eN4SaWlf4+bG1KzHeXbnqt0AfB/NX0Z+VunGlM1ki1B4zVvye27MpKh/svySUA=="], @@ -972,17 +972,17 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "postcss": "^8.5.10", "tailwindcss": "4.3.0" } }, "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w=="], - "@turbo/darwin-64": ["@turbo/darwin-64@2.9.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-io5jn5RDeU+9YV78rWhwG++HD/OZ/Lxg1sg93+jDGKQNP3UDxY6RX2dmarbCILhNxNuAM8FH3WgGMY9E96Mf8w=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.11.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-u+6s4C2hskRYx6MkW2uT7BCW6sESCL/+5bjHDDWUfQfQliCSJ7lAHAL6RQaw0an9WU6nJvwYgT786mEWul2kDw=="], - "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-83YZTYmN2sxFWf2LTMOwqbOvR3qZMa/TSFwnB6BHVBbIWyoPPe+TAdSTd8KevEx8ml8KkycJ/9A70DFVReyUww=="], + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.11.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-y3qpPbclA4MuzAf/pSp6ivifp2mBCiLMHyf/bVq2eAJLjTnzev9JG5eqFhM0UvDGi0b3QKRNZi2HiavPnLbEbw=="], - "@turbo/linux-64": ["@turbo/linux-64@2.9.17", "", { "os": "linux", "cpu": "x64" }, "sha512-teKfwJg0zSC+C2ZSOsX3VnAJGVgcN+pgKNmnGWzcpXQ9eIkAQtYP+getrQ2f1Tw/ePudnreQhq8tVP8S73Vy6Q=="], + "@turbo/linux-64": ["@turbo/linux-64@2.11.8", "", { "os": [ "linux", "android", ], "cpu": "x64" }, "sha512-5Pm0icIkXPjk/HxhZD2AFMCyfH8RlITgtOksoSAdeLtXHAagzAqaAkdqG+6Ebd3eGPT1k3nJZWQD4wCLlIte5g=="], - "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-mqO36x2CNtJ9CCbEf5xOqH662tVSc1wB0mxh7dopBpgXg0fEifdzwX0IEAUW1WUAaNH986L7iEUUgw3HNqUWgg=="], + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.11.8", "", { "os": [ "linux", "android", ], "cpu": "arm64" }, "sha512-gh/Pcg7WVLS22SpoFqNxs/4y6feU/+Qa9nkCLTOHMTLjE3ADeqMjLFulq1LVCsWD2vjQccCnaZSUOLJ/lHzBRQ=="], - "@turbo/windows-64": ["@turbo/windows-64@2.9.17", "", { "os": "win32", "cpu": "x64" }, "sha512-jbyoNePufyMoSSrvVr+/mglcjmya/MOgoIrSHPr67iZ1VFgrlMQXHXtptR2lR48gi+86b1XBvsviJZ9A7zrydg=="], + "@turbo/windows-64": ["@turbo/windows-64@2.11.8", "", { "os": "win32", "cpu": "x64" }, "sha512-NnHtcWPE6PymlYsCKEuYditlY/nMeCEB3xyD4yc3cpqpxW7/4Xqdlqd5TpqvEpjS8CDazPHelGCQS/ghUIoZ6g=="], - "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-+ql0wYc99Y2AMvyHCcC/P+xtyV4nz522L+C9HDnyi7ryHXBGM+ZjBP28M7SLBGMDImgpN8sk2szpgbvreMeXVA=="], + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.11.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-o8UyM/VlYCvW5Enfo2O37Pe11p3wmnEYtJrohXA2SyOlehmo5KyXpptPRKrXOMNfdmoYEulo6Nhj0tIv2UB0hA=="], "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], @@ -2054,7 +2054,7 @@ "tunnel-rat": ["tunnel-rat@0.1.2", "", { "dependencies": { "zustand": "^4.3.2" } }, "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ=="], - "turbo": ["turbo@2.9.17", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.17", "@turbo/darwin-arm64": "2.9.17", "@turbo/linux-64": "2.9.17", "@turbo/linux-arm64": "2.9.17", "@turbo/windows-64": "2.9.17", "@turbo/windows-arm64": "2.9.17" }, "bin": { "turbo": "bin/turbo" } }, "sha512-91Q3KxfHJn7esFu2Ic6j9pkvQqWjncQCOp7r1gCKChRSb/+T/yIjsavAmbGLmFRKAzSjmWW/FMrcknmJ4hEOPA=="], + "turbo": ["turbo@2.11.8", "", { "optionalDependencies": { "@turbo/darwin-64": "2.11.8", "@turbo/darwin-arm64": "2.11.8", "@turbo/linux-64": "2.11.8", "@turbo/linux-arm64": "2.11.8", "@turbo/windows-64": "2.11.8", "@turbo/windows-arm64": "2.11.8" }, "bin": { "turbo": "bin/turbo" } }, "sha512-OwiFs6xNO5c2XtPgYCgCXuSlkur4b5T/OeAbOKzgsHh5mmJ9Sh7m041fz2JchrvyQv8hmae+1n07HfBmvDLZRA=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], diff --git a/package.json b/package.json index 75ff9bfdc9..1a576d3a6e 100644 --- a/package.json +++ b/package.json @@ -26,12 +26,12 @@ "release:major": "gh workflow run release.yml -f bump=major" }, "devDependencies": { - "@biomejs/biome": "^2.4.16", + "@biomejs/biome": "^2.5.15", "@typescript/native-preview": "7.0.0-dev.20260624.1", "@typescript/typescript6": "6.0.2", "dotenv-cli": "^11.0.0", "fast-xml-parser": "^5.11.2", - "turbo": "^2.9.17", + "turbo": "^2.11.8", "typescript": "npm:@typescript/typescript6@6.0.2", "ultracite": "^7.8.2" }, From a78edc5a7a4131323adb7f49bca97d2a5a53f60f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 13:17:44 +0000 Subject: [PATCH 2/3] style: apply Biome 2.5 formatter output --- apps/editor/lib/graph-schema.test.ts | 55 +- .../agent-operations/hosted-services.test.ts | 21 +- .../agent-operations/layout-clearance.test.ts | 137 ++- .../native-ceiling-paint.test.ts | 140 ++- .../structure/divide-zone-path.test.ts | 155 +-- .../structure/set-floor-foundation.test.ts | 76 +- .../wall-elevation-parity.test.ts | 64 +- .../src/lib/floor-footprint-creator.test.ts | 135 ++- .../core/src/lib/floor-footprint-name.test.ts | 29 +- packages/core/src/lib/floor-levels.test.ts | 17 +- .../core/src/lib/floor-plate-support.test.ts | 37 +- .../core/src/lib/level-footprints.test.ts | 88 +- packages/core/src/lib/own-floor.test.ts | 122 +-- .../src/lib/plate-riser-ownership.test.ts | 25 +- .../core/src/lib/structure-kernel.test.ts | 40 +- .../core/src/procedural-items/design.test.ts | 37 +- .../procedural-items/schema-import.test.ts | 62 +- .../core/src/schema/host-children.test.ts | 75 +- .../core/src/services/surface-hosting.test.ts | 73 +- .../actions/node-update-children.test.ts | 23 +- .../core/src/store/floor-opening-live.test.ts | 252 ++--- .../core/src/store/history-drafts.test.ts | 96 +- .../src/store/history-invalidation.test.ts | 191 ++-- .../store/use-scene-room-invariants.test.ts | 160 ++-- .../src/systems/wall/wall-topology.test.ts | 146 +-- .../exterior-threshold-migration.test.ts | 22 +- .../core/src/utils/floor-plate-corpus.test.ts | 340 +++---- .../core/src/utils/floor-plate-load.test.ts | 252 ++--- .../src/utils/floor-plate-migration.test.ts | 47 +- .../core/src/utils/frozen-floor-gate.test.ts | 221 ++--- .../utils/vertical-scene-migration.test.ts | 61 +- .../src/utils/wall-assembly-migration.test.ts | 55 +- .../components/editor/follow-pascal.test.tsx | 127 +-- .../editor/snapshot-capture.test.ts | 111 ++- .../item-surface-strategy-resolver.test.ts | 135 ++- .../shelf-surface-strategy-resolver.test.ts | 112 +-- .../tools/item/use-draft-node.test.tsx | 188 ++-- .../tools/registry/item-surface-move.test.ts | 125 ++- .../registry/item-surface-resolver.test.ts | 52 +- .../tools/wall/wall-drafting.test.ts | 115 +-- .../hooks/use-auto-save.integration.test.tsx | 75 +- .../src/hooks/use-ceiling-events.test.ts | 241 ++--- .../editor/src/hooks/use-grid-events.test.ts | 210 ++--- packages/editor/src/lib/glb-export.test.ts | 326 +++---- packages/editor/src/lib/history.test.ts | 12 +- .../editor/src/lib/measurement-kind.test.ts | 14 +- packages/geometry-script/src/compile.test.ts | 21 +- .../tools/scene-lifecycle/save-scene.test.ts | 69 +- .../src/box-vent/__tests__/geometry.test.ts | 29 +- .../src/cabinet/__tests__/geometry.test.ts | 485 +++++----- .../cabinet/__tests__/hosting-matrix.test.tsx | 251 ++--- .../src/cabinet/__tests__/hosting.test.tsx | 78 +- .../__tests__/panel-visibility.test.ts | 17 +- .../cabinet/__tests__/quick-actions.test.ts | 78 +- .../src/cabinet/__tests__/run-ops.test.ts | 380 ++++---- .../src/cabinet/__tests__/run-reflow.test.ts | 885 +++++++++--------- .../nodes/src/cabinet/__tests__/stack.test.ts | 175 ++-- .../src/cabinet/__tests__/surfaces.test.ts | 279 +++--- .../src/cabinet/__tests__/top-finish.test.ts | 73 +- .../__tests__/wall-depth-handles.test.ts | 480 +++++----- .../src/dormer/__tests__/geometry.test.ts | 32 +- .../src/item/floorplan-move-history.test.tsx | 234 ++--- .../src/item/wall-object-observations.test.ts | 32 +- .../src/lean-to-extension/assembly.test.ts | 72 +- .../lean-to-extension/canopy-joint.test.ts | 70 +- .../src/lean-to-extension/floorplan.test.ts | 80 +- .../lean-to-extension/joint-framing.test.ts | 14 +- .../mono-j-rendering.test.ts | 53 +- .../src/lean-to-extension/placement.test.ts | 127 ++- .../src/lean-to-extension/system.test.ts | 179 ++-- .../nodes/src/pipe-fitting/geometry.test.ts | 42 +- .../src/shared/accessory-catalog.test.ts | 37 +- .../src/shared/distribution-run-tool.test.tsx | 218 ++--- .../src/shared/node-batch/node-batch.test.ts | 349 ++++--- .../src/shared/opening-move-history.test.ts | 288 +++--- .../src/shared/pipe-vertical-offset.test.ts | 84 +- .../shared/surface-raycast-batching.test.ts | 54 +- .../nodes/src/shared/vertical-offset.test.ts | 101 +- .../shelf/__tests__/surface-regions.test.ts | 68 +- packages/nodes/src/site/terrain-mesh.test.ts | 170 ++-- .../__tests__/floor-support-hosting.test.tsx | 35 +- .../src/slab/__tests__/plate-paint.test.ts | 42 +- packages/nodes/src/wall/split-preview.test.ts | 45 +- packages/nodes/src/wall/treatments.test.ts | 123 ++- .../nodes/src/wall/wall-batch-system.test.ts | 18 +- .../viewer/registered-systems.test.tsx | 131 +-- 86 files changed, 5589 insertions(+), 5506 deletions(-) diff --git a/apps/editor/lib/graph-schema.test.ts b/apps/editor/lib/graph-schema.test.ts index 6aef42abf1..7260486630 100644 --- a/apps/editor/lib/graph-schema.test.ts +++ b/apps/editor/lib/graph-schema.test.ts @@ -195,33 +195,36 @@ test('reports malformed legacy wall assemblies as validation issues', () => { test.each([ ['long', 'a'.repeat(81), 'a'.repeat(121)], ['empty', '', ''], -])('preserves %s legacy text and every layer above the former F2 caps', (_, preset, cavityInsulation) => { - const wall = WallNode.parse({ id: 'wall_unboundedgraph', start: [0, 0], end: [4, 0] }) - const graph = buildGraph({ - [wall.id]: { - ...wall, - assembly: { - preset, - cavityInsulation, - exterior: { finish: 'stone', thickness: 5.001 }, - sheathing: { material: 'osb', thickness: 5.001 }, - framing: { kind: 'wood', depth: 5.001 }, - interior: { finish: 'drywall', thickness: 5.001 }, +])( + 'preserves %s legacy text and every layer above the former F2 caps', + (_, preset, cavityInsulation) => { + const wall = WallNode.parse({ id: 'wall_unboundedgraph', start: [0, 0], end: [4, 0] }) + const graph = buildGraph({ + [wall.id]: { + ...wall, + assembly: { + preset, + cavityInsulation, + exterior: { finish: 'stone', thickness: 5.001 }, + sheathing: { material: 'osb', thickness: 5.001 }, + framing: { kind: 'wood', depth: 5.001 }, + interior: { finish: 'drywall', thickness: 5.001 }, + }, }, - }, - }) - - const result = apiGraphSchema.parse(graph) - const assembly = WallNode.parse(result.nodes[wall.id]).assembly! - expect(assembly.presetId).toBe(preset) - expect(assembly.cavityInsulation).toBe(cavityInsulation) - expect(assembly.layers.map(({ id, thickness }) => [id, thickness])).toEqual([ - ['exterior', 5.001], - ['sheathing', 5.001], - ['framing', 5.001], - ['interior', 5.001], - ]) -}) + }) + + const result = apiGraphSchema.parse(graph) + const assembly = WallNode.parse(result.nodes[wall.id]).assembly! + expect(assembly.presetId).toBe(preset) + expect(assembly.cavityInsulation).toBe(cavityInsulation) + expect(assembly.layers.map(({ id, thickness }) => [id, thickness])).toEqual([ + ['exterior', 5.001], + ['sheathing', 5.001], + ['framing', 5.001], + ['interior', 5.001], + ]) + }, +) // Unnamespaced kinds are legitimate: `wiki/architecture/plugin-authoring.md` // requires plugin *ids* to look like `vendor:pack`, never kinds, and its worked diff --git a/packages/core/src/agent-operations/hosted-services.test.ts b/packages/core/src/agent-operations/hosted-services.test.ts index 62ef6d4226..e1d54e0c46 100644 --- a/packages/core/src/agent-operations/hosted-services.test.ts +++ b/packages/core/src/agent-operations/hosted-services.test.ts @@ -55,15 +55,18 @@ describe('hosted service operations', () => { address: 'An address', walletId: 'somebody_else', }, - ])('refuses invalid location, budget or undeclared input before host admission (%#)', async (input) => { - let called = false - await expect( - executeHostedServiceTool('pull_site_data', input, async () => { - called = true - }), - ).rejects.toBeInstanceOf(Error) - expect(called).toBe(false) - }) + ])( + 'refuses invalid location, budget or undeclared input before host admission (%#)', + async (input) => { + let called = false + await expect( + executeHostedServiceTool('pull_site_data', input, async () => { + called = true + }), + ).rejects.toBeInstanceOf(Error) + expect(called).toBe(false) + }, + ) test('reopening dispatches only the retained operation reference', async () => { let call: HostedServiceRequest | undefined diff --git a/packages/core/src/agent-operations/layout-clearance.test.ts b/packages/core/src/agent-operations/layout-clearance.test.ts index e487decc10..a46cc60307 100644 --- a/packages/core/src/agent-operations/layout-clearance.test.ts +++ b/packages/core/src/agent-operations/layout-clearance.test.ts @@ -279,34 +279,34 @@ describe('layout-clearance', () => { } }) - test.each([ - null, - 'level_missing', - ])('an underlay with unresolved parent %s remains an occupied footprint', (parentId) => { - const rug = ItemNode.parse({ - id: 'item_rug', - parentId, - position: [3, 0, 3], - asset: { - id: 'large-rug', - name: 'Large rug', - category: 'furniture', - thumbnail: '', - src: 'asset://large-rug', - dimensions: [4, 0.02, 4], - }, - }) - expect(collectOccupiedFootprints([rug])).toMatchObject([ - { + test.each([null, 'level_missing'])( + 'an underlay with unresolved parent %s remains an occupied footprint', + (parentId) => { + const rug = ItemNode.parse({ id: 'item_rug', - levelId: null, - aabb: { minX: 1, maxX: 5, minZ: 1, maxZ: 5 }, - }, - ]) - const level = LevelNode.parse({ id: 'level_floor' }) - const floorRug = ItemNode.parse({ ...rug, parentId: level.id }) - expect(collectOccupiedFootprints([level, floorRug])).toEqual([]) - }) + parentId, + position: [3, 0, 3], + asset: { + id: 'large-rug', + name: 'Large rug', + category: 'furniture', + thumbnail: '', + src: 'asset://large-rug', + dimensions: [4, 0.02, 4], + }, + }) + expect(collectOccupiedFootprints([rug])).toMatchObject([ + { + id: 'item_rug', + levelId: null, + aabb: { minX: 1, maxX: 5, minZ: 1, maxZ: 5 }, + }, + ]) + const level = LevelNode.parse({ id: 'level_floor' }) + const floorRug = ItemNode.parse({ ...rug, parentId: level.id }) + expect(collectOccupiedFootprints([level, floorRug])).toEqual([]) + }, + ) function bedRoom(blocker?: 'large-rug' | 'solid-cabinet' | 'thick-rug' | 'raised-rug') { const level = LevelNode.parse({ id: 'level_r' }) @@ -350,51 +350,50 @@ describe('layout-clearance', () => { return { nodes, catalog } } - test.each([ - undefined, - 'large-rug', - ] as const)('automatic bed fits against a wall with floor covering %s', (blocker) => { - const { nodes, catalog } = bedRoom(blocker) - const before = structuredClone(nodes) - const outcome = AGENT_OPERATIONS.place_in_room( - nodes, - { assetId: 'double-bed', zoneId: 'zone_r' }, - { catalog }, - ) - const after = applySceneChanges(nodes, outcome.changes!) - const bed = after[outcome.result.itemId as string] - expect(nodes).toEqual(before) - expect(outcome.result).toMatchObject({ ok: true }) - expect(bed).toMatchObject({ type: 'item', parentId: 'level_r' }) - if (bed?.type !== 'item') throw new Error('bed missing') - const footprint = itemNodePlanAabb(bed)! - expect([footprint.minX, footprint.minZ].every((value) => value >= 0)).toBe(true) - expect([footprint.maxX, footprint.maxZ].every((value) => value <= 6)).toBe(true) - expect( - Math.min(footprint.minX, footprint.minZ, 6 - footprint.maxX, 6 - footprint.maxZ), - ).toBeLessThanOrEqual(0.2) - expect(bed.position[1]).toBe(0) - if (blocker) { - expect(after.item_underlay).toEqual(before.item_underlay) - expect(aabbsOverlap(footprint, itemNodePlanAabb(after.item_underlay!)!)).toBe(true) - expect(findItemItemCollisions({ nodes: Object.values(after) })).toEqual([]) - } - }) - - test.each([ - 'solid-cabinet', - 'thick-rug', - 'raised-rug', - ] as const)('automatic bed remains blocked by %s', (blocker) => { - const { nodes, catalog } = bedRoom(blocker) - expect(() => - AGENT_OPERATIONS.place_in_room( + test.each([undefined, 'large-rug'] as const)( + 'automatic bed fits against a wall with floor covering %s', + (blocker) => { + const { nodes, catalog } = bedRoom(blocker) + const before = structuredClone(nodes) + const outcome = AGENT_OPERATIONS.place_in_room( nodes, { assetId: 'double-bed', zoneId: 'zone_r' }, { catalog }, - ), - ).toThrow(expect.objectContaining({ code: 'no_free_spot' })) - }) + ) + const after = applySceneChanges(nodes, outcome.changes!) + const bed = after[outcome.result.itemId as string] + expect(nodes).toEqual(before) + expect(outcome.result).toMatchObject({ ok: true }) + expect(bed).toMatchObject({ type: 'item', parentId: 'level_r' }) + if (bed?.type !== 'item') throw new Error('bed missing') + const footprint = itemNodePlanAabb(bed)! + expect([footprint.minX, footprint.minZ].every((value) => value >= 0)).toBe(true) + expect([footprint.maxX, footprint.maxZ].every((value) => value <= 6)).toBe(true) + expect( + Math.min(footprint.minX, footprint.minZ, 6 - footprint.maxX, 6 - footprint.maxZ), + ).toBeLessThanOrEqual(0.2) + expect(bed.position[1]).toBe(0) + if (blocker) { + expect(after.item_underlay).toEqual(before.item_underlay) + expect(aabbsOverlap(footprint, itemNodePlanAabb(after.item_underlay!)!)).toBe(true) + expect(findItemItemCollisions({ nodes: Object.values(after) })).toEqual([]) + } + }, + ) + + test.each(['solid-cabinet', 'thick-rug', 'raised-rug'] as const)( + 'automatic bed remains blocked by %s', + (blocker) => { + const { nodes, catalog } = bedRoom(blocker) + expect(() => + AGENT_OPERATIONS.place_in_room( + nodes, + { assetId: 'double-bed', zoneId: 'zone_r' }, + { catalog }, + ), + ).toThrow(expect.objectContaining({ code: 'no_free_spot' })) + }, + ) test('automatic bed over a rug still respects reserved clearance', () => { const { nodes, catalog } = bedRoom('large-rug') diff --git a/packages/core/src/agent-operations/native-ceiling-paint.test.ts b/packages/core/src/agent-operations/native-ceiling-paint.test.ts index 389e16f4d8..a5fbab862e 100644 --- a/packages/core/src/agent-operations/native-ceiling-paint.test.ts +++ b/packages/core/src/agent-operations/native-ceiling-paint.test.ts @@ -95,59 +95,59 @@ function freeze(value: T): T { } describe('native ceiling paint writes the renderable surface, not metadata or custom geometry', () => { - test.each([ - undefined, - 'surface', - ])('default/explicit surface role %s preserves scene identity and other surfaces', (role) => { - const { nodes, ceiling, wall, slab } = fixture() - const before = structuredClone(nodes) - freeze(nodes) - const result = paint( - nodes, - { - targets: [ceiling.id], - material: requestedFinish, - ...(role ? { role } : {}), - }, - context, - ) - const after = applySceneChanges(nodes, result.changes) - const painted = CeilingNode.parse(after[ceiling.id]) - expect(result.result).toMatchObject({ - ok: true, - painted: [{ id: ceiling.id, roles: ['surface'] }], - }) - expect(painted.slots).toEqual({ surface: requestedFinish, retained: requestedFinish }) - expect(withoutFinish(painted)).toEqual(withoutFinish(ceiling)) - expect(Object.keys(after)).toEqual(Object.keys(nodes)) - expect(after[wall.id]).toEqual(before[wall.id]) - expect(after[slab.id]).toEqual(before[slab.id]) - expect(after[context.activeLevelId]).toEqual(before[context.activeLevelId]) - expect(nodes).toEqual(before) - }) + test.each([undefined, 'surface'])( + 'default/explicit surface role %s preserves scene identity and other surfaces', + (role) => { + const { nodes, ceiling, wall, slab } = fixture() + const before = structuredClone(nodes) + freeze(nodes) + const result = paint( + nodes, + { + targets: [ceiling.id], + material: requestedFinish, + ...(role ? { role } : {}), + }, + context, + ) + const after = applySceneChanges(nodes, result.changes) + const painted = CeilingNode.parse(after[ceiling.id]) + expect(result.result).toMatchObject({ + ok: true, + painted: [{ id: ceiling.id, roles: ['surface'] }], + }) + expect(painted.slots).toEqual({ surface: requestedFinish, retained: requestedFinish }) + expect(withoutFinish(painted)).toEqual(withoutFinish(ceiling)) + expect(Object.keys(after)).toEqual(Object.keys(nodes)) + expect(after[wall.id]).toEqual(before[wall.id]) + expect(after[slab.id]).toEqual(before[slab.id]) + expect(after[context.activeLevelId]).toEqual(before[context.activeLevelId]) + expect(nodes).toEqual(before) + }, + ) - test.each([ - 'slot-and-legacy', - 'legacy-only', - ] as const)('erase %s matches UI slot removal while preserving legacy fields and regions', (mode) => { - const { nodes, ceiling } = fixture() - if (mode === 'legacy-only') ceiling.slots = { retained: requestedFinish } - const before = structuredClone(nodes) - freeze(nodes) - const result = paint(nodes, { targets: [ceiling.id], erase: true }, context) - const after = applySceneChanges(nodes, result.changes) - const erased = CeilingNode.parse(after[ceiling.id]) - expect(erased.slots?.surface).toBeUndefined() - expect(erased.slots?.retained).toBe(requestedFinish) - expect(erased.material).toEqual(ceiling.material) - expect(erased.materialPreset).toBe(ceiling.materialPreset) - expect(withoutFinish(erased)).toEqual(withoutFinish(ceiling)) - expect(Object.keys(after)).toEqual(Object.keys(nodes)) - for (const [id, node] of Object.entries(before)) { - if (id !== ceiling.id) expect(after[id]).toEqual(node) - } - expect(nodes).toEqual(before) - }) + test.each(['slot-and-legacy', 'legacy-only'] as const)( + 'erase %s matches UI slot removal while preserving legacy fields and regions', + (mode) => { + const { nodes, ceiling } = fixture() + if (mode === 'legacy-only') ceiling.slots = { retained: requestedFinish } + const before = structuredClone(nodes) + freeze(nodes) + const result = paint(nodes, { targets: [ceiling.id], erase: true }, context) + const after = applySceneChanges(nodes, result.changes) + const erased = CeilingNode.parse(after[ceiling.id]) + expect(erased.slots?.surface).toBeUndefined() + expect(erased.slots?.retained).toBe(requestedFinish) + expect(erased.material).toEqual(ceiling.material) + expect(erased.materialPreset).toBe(ceiling.materialPreset) + expect(withoutFinish(erased)).toEqual(withoutFinish(ceiling)) + expect(Object.keys(after)).toEqual(Object.keys(nodes)) + for (const [id, node] of Object.entries(before)) { + if (id !== ceiling.id) expect(after[id]).toEqual(node) + } + expect(nodes).toEqual(before) + }, + ) test.each([ { @@ -163,23 +163,21 @@ describe('native ceiling paint writes the renderable surface, not metadata or cu code: 'unknown_role', target: 'ceiling_finish', }, - ])('$name refuses atomically, including earlier valid targets', ({ - targets, - role, - code, - target, - }) => { - const { nodes } = fixture() - const before = structuredClone(nodes) - freeze(nodes) - let refusal: unknown - try { - paint(nodes, { targets, material: requestedFinish, ...(role ? { role } : {}) }, context) - } catch (error) { - refusal = error - } - expect(isAgentRefusal(refusal)).toBe(true) - expect(refusal).toMatchObject({ code, details: { target } }) - expect(nodes).toEqual(before) - }) + ])( + '$name refuses atomically, including earlier valid targets', + ({ targets, role, code, target }) => { + const { nodes } = fixture() + const before = structuredClone(nodes) + freeze(nodes) + let refusal: unknown + try { + paint(nodes, { targets, material: requestedFinish, ...(role ? { role } : {}) }, context) + } catch (error) { + refusal = error + } + expect(isAgentRefusal(refusal)).toBe(true) + expect(refusal).toMatchObject({ code, details: { target } }) + expect(nodes).toEqual(before) + }, + ) }) diff --git a/packages/core/src/commands/structure/divide-zone-path.test.ts b/packages/core/src/commands/structure/divide-zone-path.test.ts index 8602dae965..8c016b4ed7 100644 --- a/packages/core/src/commands/structure/divide-zone-path.test.ts +++ b/packages/core/src/commands/structure/divide-zone-path.test.ts @@ -82,38 +82,41 @@ test('L-shaped open path shares exact endpoints and splits wall T-vertices into for (const face of faces) expect(face.spans.filter((s) => s.kind === 'separator')).toHaveLength(2) }) -test.each([ - false, - true, -])('four-point island preserves outer identity, inherits finishes and keeps one unholed base and two platforms (reverse=%s)', (reverse) => { - const { zoneId, plan, separators, faces, result, mintId } = run( - reverse ? [...island].reverse() : island, - true, - ) - expect(plan.separatorIds).toHaveLength(4) - expect(separators.at(-1)!.end).toEqual(separators[0]!.start) - expect(faces).toHaveLength(2) - const outer = result.nodes[zoneId] as ZoneNode - const zones = Object.values(result.nodes).filter((n) => n.type === 'zone') - expect(zones).toHaveLength(2) - expect(outer.holes).toHaveLength(1) - expect(containsPoint([{ outer: outer.polygon, holes: outer.holes }], outer.seed!)).toBe(true) - for (const zone of zones) - expect(zone).toMatchObject({ floor: { finish: 'oak', elevation: 0.2 }, wallMaterial: 'paint' }) - const inner = zones.find((n) => n.id !== zoneId)! - expect(inner.holes).toEqual([]) - expect(inner.boundaryWallIds).toEqual([]) - expect(inner.boundarySeparatorIds.sort()).toEqual([...plan.separatorIds].sort()) - const plates = Object.values(result.nodes).filter((n) => n.type === 'slab') - expect(plates).toHaveLength(3) - const base = plates.find((plate) => plate.plateRole === 'base')! - expect(base.holes).toEqual([]) - expect(base.zoneIds?.sort()).toEqual(zones.map((z) => z.id).sort()) - const ceilings = Object.values(result.nodes).filter((n) => n.type === 'ceiling') - expect(ceilings).toHaveLength(2) - expect(ceilings.find((c) => c.zoneId === zoneId)!.holes).toHaveLength(1) - expect(reconcileSceneStructure({ nodes: result.nodes, mintId }).patches).toEqual([]) -}) +test.each([false, true])( + 'four-point island preserves outer identity, inherits finishes and keeps one unholed base and two platforms (reverse=%s)', + (reverse) => { + const { zoneId, plan, separators, faces, result, mintId } = run( + reverse ? [...island].reverse() : island, + true, + ) + expect(plan.separatorIds).toHaveLength(4) + expect(separators.at(-1)!.end).toEqual(separators[0]!.start) + expect(faces).toHaveLength(2) + const outer = result.nodes[zoneId] as ZoneNode + const zones = Object.values(result.nodes).filter((n) => n.type === 'zone') + expect(zones).toHaveLength(2) + expect(outer.holes).toHaveLength(1) + expect(containsPoint([{ outer: outer.polygon, holes: outer.holes }], outer.seed!)).toBe(true) + for (const zone of zones) + expect(zone).toMatchObject({ + floor: { finish: 'oak', elevation: 0.2 }, + wallMaterial: 'paint', + }) + const inner = zones.find((n) => n.id !== zoneId)! + expect(inner.holes).toEqual([]) + expect(inner.boundaryWallIds).toEqual([]) + expect(inner.boundarySeparatorIds.sort()).toEqual([...plan.separatorIds].sort()) + const plates = Object.values(result.nodes).filter((n) => n.type === 'slab') + expect(plates).toHaveLength(3) + const base = plates.find((plate) => plate.plateRole === 'base')! + expect(base.holes).toEqual([]) + expect(base.zoneIds?.sort()).toEqual(zones.map((z) => z.id).sort()) + const ceilings = Object.values(result.nodes).filter((n) => n.type === 'ceiling') + expect(ceilings).toHaveLength(2) + expect(ceilings.find((c) => c.zoneId === zoneId)!.holes).toHaveLength(1) + expect(reconcileSceneStructure({ nodes: result.nodes, mintId }).patches).toEqual([]) + }, +) test('minimum 0.25 m² island is a graph face', () => { expect( @@ -389,50 +392,52 @@ test('open and closed paths cannot cross or enclose an existing hole', () => { } }) -test.each([ - false, - true, -])('multi-separator Divide is one commit and one undo step (closed=%s)', (closed) => { - globalThis.requestAnimationFrame ??= (callback) => { - callback(0) - return 0 - } - globalThis.cancelAnimationFrame ??= () => {} - const previous = useScene.getState() - const fixture = setup() - useScene.setState({ - nodes: fixture.nodes, - rootNodeIds: [levelId], - readOnly: false, - materials: {}, - collections: {}, - }) - clearSceneHistory() - const stop = initSpaceDetectionSync(useScene, { - getState: () => ({ spaces: {}, setSpaces: () => {} }), - }) - const origins: string[] = [] - const unsubscribe = subscribeSceneCommits((commit) => origins.push(commit.origin)) - try { - const plan = divideZone(fixture.nodes, { ...fixture, path: closed ? island : elbow, closed }) - expect(plan.conflicts).toBeUndefined() - useScene.getState().applyNodeChanges(structureChangeBatch(plan.changes)) - const after = useScene.getState().nodes - expect(Object.values(after).filter((n) => n.type === 'separator')).toHaveLength(closed ? 4 : 2) - expect(Object.values(after).filter((n) => n.type === 'zone')).toHaveLength(2) - expect(useScene.temporal.getState().pastStates).toHaveLength(1) - expect(origins).toEqual(['local']) - useScene.temporal.getState().undo() - expect(useScene.getState().nodes).toEqual(fixture.nodes) - useScene.temporal.getState().redo() - expect(useScene.getState().nodes).toEqual(after) - } finally { - unsubscribe() - stop() - useScene.setState(previous) +test.each([false, true])( + 'multi-separator Divide is one commit and one undo step (closed=%s)', + (closed) => { + globalThis.requestAnimationFrame ??= (callback) => { + callback(0) + return 0 + } + globalThis.cancelAnimationFrame ??= () => {} + const previous = useScene.getState() + const fixture = setup() + useScene.setState({ + nodes: fixture.nodes, + rootNodeIds: [levelId], + readOnly: false, + materials: {}, + collections: {}, + }) clearSceneHistory() - } -}) + const stop = initSpaceDetectionSync(useScene, { + getState: () => ({ spaces: {}, setSpaces: () => {} }), + }) + const origins: string[] = [] + const unsubscribe = subscribeSceneCommits((commit) => origins.push(commit.origin)) + try { + const plan = divideZone(fixture.nodes, { ...fixture, path: closed ? island : elbow, closed }) + expect(plan.conflicts).toBeUndefined() + useScene.getState().applyNodeChanges(structureChangeBatch(plan.changes)) + const after = useScene.getState().nodes + expect(Object.values(after).filter((n) => n.type === 'separator')).toHaveLength( + closed ? 4 : 2, + ) + expect(Object.values(after).filter((n) => n.type === 'zone')).toHaveLength(2) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + expect(origins).toEqual(['local']) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes).toEqual(fixture.nodes) + useScene.temporal.getState().redo() + expect(useScene.getState().nodes).toEqual(after) + } finally { + unsubscribe() + stop() + useScene.setState(previous) + clearSceneHistory() + } + }, +) // divide_zone over the hosted MCP crashed with "Cannot // read properties of undefined (reading 'getState')". A host that brings its own runtime keeps its diff --git a/packages/core/src/commands/structure/set-floor-foundation.test.ts b/packages/core/src/commands/structure/set-floor-foundation.test.ts index 8e5285a84d..30092d2dc0 100644 --- a/packages/core/src/commands/structure/set-floor-foundation.test.ts +++ b/packages/core/src/commands/structure/set-floor-foundation.test.ts @@ -78,46 +78,46 @@ test('one footprint command moves its walls, opening and explicit room steps; sh }) }) -test.each([ - true, - false, -])('migration replaces level intent and fillToTerrain=%s once, manual slabs unchanged', (fillToTerrain) => { - const s = scene() - const plates = bases(s.nodes) - const input = { - ...s.nodes, - [s.level.id]: { ...s.nodes[s.level.id], floorElevation: 0.35 }, - } as Record - for (const plate of plates) - input[plate.id] = { - ...plate, - foundation: undefined, +test.each([true, false])( + 'migration replaces level intent and fillToTerrain=%s once, manual slabs unchanged', + (fillToTerrain) => { + const s = scene() + const plates = bases(s.nodes) + const input = { + ...s.nodes, + [s.level.id]: { ...s.nodes[s.level.id], floorElevation: 0.35 }, + } as Record + for (const plate of plates) + input[plate.id] = { + ...plate, + foundation: undefined, + fillToTerrain, + slots: { edge: 'library:stone' }, + } + const manual = { + ...plates[0]!, + id: 'slab_manual' as SlabNode['id'], + boundary: undefined, + autoFromWalls: false, + plateRole: undefined, fillToTerrain, - slots: { edge: 'library:stone' }, } - const manual = { - ...plates[0]!, - id: 'slab_manual' as SlabNode['id'], - boundary: undefined, - autoFromWalls: false, - plateRole: undefined, - fillToTerrain, - } - input[manual.id] = manual - const first = migrateFloorPlates(input).nodes as Record - expect(first[s.level.id]).not.toHaveProperty('floorElevation') - for (const plate of plates) { - expect(first[plate.id]).toMatchObject({ - floorHeight: 0.35, - foundation: { type: fillToTerrain ? 'solid' : 'none' }, - }) - expect(first[plate.id]).not.toHaveProperty('fillToTerrain') - if (fillToTerrain) - expect((first[plate.id] as SlabNode).foundation!.material).toBe('library:stone') - } - expect(first[manual.id]).toEqual(manual) - expect(migrateFloorPlates(first).nodes).toEqual(first) -}) + input[manual.id] = manual + const first = migrateFloorPlates(input).nodes as Record + expect(first[s.level.id]).not.toHaveProperty('floorElevation') + for (const plate of plates) { + expect(first[plate.id]).toMatchObject({ + floorHeight: 0.35, + foundation: { type: fillToTerrain ? 'solid' : 'none' }, + }) + expect(first[plate.id]).not.toHaveProperty('fillToTerrain') + if (fillToTerrain) + expect((first[plate.id] as SlabNode).foundation!.material).toBe('library:stone') + } + expect(first[manual.id]).toEqual(manual) + expect(migrateFloorPlates(first).nodes).toEqual(first) + }, +) test('a newly drawn shed gets thin construction without copying the house settings', () => { const s = scene() diff --git a/packages/core/src/hooks/spatial-grid/wall-elevation-parity.test.ts b/packages/core/src/hooks/spatial-grid/wall-elevation-parity.test.ts index 58b7809cda..c19997c369 100644 --- a/packages/core/src/hooks/spatial-grid/wall-elevation-parity.test.ts +++ b/packages/core/src/hooks/spatial-grid/wall-elevation-parity.test.ts @@ -2,36 +2,36 @@ import { expect, test } from 'bun:test' import { SlabNode, WallNode } from '../../schema' import { SpatialGridManager } from './spatial-grid-manager' -test.each([ - 'a', - 'b', -] as const)('wall elevation wrapper matches body support for justification %s', (justification) => { - const levelId = 'level_wall-elevation-parity' - const side = justification === 'a' ? 1 : -1 - const wall = WallNode.parse({ - parentId: levelId, - start: [0, 0], - end: [4, 0], - thickness: 0.2, - justification, - }) - const slab = SlabNode.parse({ - parentId: levelId, - elevation: 0.6, - polygon: [ - [-1, side * 0.15], - [5, side * 0.15], - [5, side], - [-1, side], - ], - }) - const manager = new SpatialGridManager() - manager.handleNodeCreated(wall, levelId) - manager.handleNodeCreated(slab, levelId) - const args = [levelId, wall.start, wall.end, 0, wall.thickness, wall.supportSlabId] as const +test.each(['a', 'b'] as const)( + 'wall elevation wrapper matches body support for justification %s', + (justification) => { + const levelId = 'level_wall-elevation-parity' + const side = justification === 'a' ? 1 : -1 + const wall = WallNode.parse({ + parentId: levelId, + start: [0, 0], + end: [4, 0], + thickness: 0.2, + justification, + }) + const slab = SlabNode.parse({ + parentId: levelId, + elevation: 0.6, + polygon: [ + [-1, side * 0.15], + [5, side * 0.15], + [5, side], + [-1, side], + ], + }) + const manager = new SpatialGridManager() + manager.handleNodeCreated(wall, levelId) + manager.handleNodeCreated(slab, levelId) + const args = [levelId, wall.start, wall.end, 0, wall.thickness, wall.supportSlabId] as const - expect(manager.getSlabElevationForWall(...args)).toBe(0) - const support = manager.getSlabSupportForWall(...args, undefined, 0, wall.justification) - expect(support.elevation).toBe(0.6) - expect(manager.getSlabElevationForWall(...args, wall.justification)).toBe(support.elevation) -}) + expect(manager.getSlabElevationForWall(...args)).toBe(0) + const support = manager.getSlabSupportForWall(...args, undefined, 0, wall.justification) + expect(support.elevation).toBe(0.6) + expect(manager.getSlabElevationForWall(...args, wall.justification)).toBe(support.elevation) + }, +) diff --git a/packages/core/src/lib/floor-footprint-creator.test.ts b/packages/core/src/lib/floor-footprint-creator.test.ts index b934cff7ed..e5aa10f07b 100644 --- a/packages/core/src/lib/floor-footprint-creator.test.ts +++ b/packages/core/src/lib/floor-footprint-creator.test.ts @@ -86,24 +86,22 @@ test('the creator leaving falls back; creating another floor never rejoins the o } }) -test.each([ - 'deleted', - 'other level', - 'no floor', - 'unnamed', -])('an unavailable creator (%s) uses the largest named room', (state) => { - const { nodes, creatorId, largerId } = fixture() - const creator = room(nodes, creatorId) - if (state === 'deleted') delete nodes[creatorId] - else - nodes[creatorId] = { - ...creator, - ...(state === 'other level' ? { parentId: 'level_elsewhere' } : {}), - ...(state === 'no floor' ? { hasFloor: false as const } : {}), - ...(state === 'unnamed' ? { name: '' } : {}), - } - expect(floorFootprintName(nodes, base(nodes, largerId))).toBe('Living room floor') -}) +test.each(['deleted', 'other level', 'no floor', 'unnamed'])( + 'an unavailable creator (%s) uses the largest named room', + (state) => { + const { nodes, creatorId, largerId } = fixture() + const creator = room(nodes, creatorId) + if (state === 'deleted') delete nodes[creatorId] + else + nodes[creatorId] = { + ...creator, + ...(state === 'other level' ? { parentId: 'level_elsewhere' } : {}), + ...(state === 'no floor' ? { hasFloor: false as const } : {}), + ...(state === 'unnamed' ? { name: '' } : {}), + } + expect(floorFootprintName(nodes, base(nodes, largerId))).toBe('Living room floor') + }, +) test('literal own has no creator and retains the largest-room fallback', () => { const { nodes, creatorId, largerId } = fixture() @@ -132,55 +130,54 @@ test('a disconnected room duplicate keeps the original creator rather than its o expect(roomFloorChoices(copied, plan.zoneId)[0]!.name).toBe('Lanai floor') }) -test.each([ - 'level', - 'scene', -])('%s clone remints keys with the cloned creator and stable plate IDs', (kind) => { - const { nodes, creatorId, largerId, key, levelId } = fixture() - const clonedNodes = - kind === 'level' - ? cloneLevelSubtree(nodes, levelId).clonedNodes - : Object.values(cloneSceneGraph({ nodes, rootNodeIds: [levelId] }).nodes) - let cloned = Object.fromEntries(clonedNodes.map((node) => [node.id, node])) - const creator = clonedNodes.find( - (node) => node.type === 'zone' && node.name === 'Lanai', - ) as ZoneNode - const larger = clonedNodes.find( - (node) => node.type === 'zone' && node.name === 'Living room', - ) as ZoneNode - expect(creator.id).not.toBe(creatorId) - expect(larger.id).not.toBe(largerId) - expect(creator.floor!.footprint).not.toBe(key) - expect(larger.floor!.footprint).toBe(creator.floor!.footprint) - expect(floorFootprintCreatorId(creator.floor!.footprint!)).toBe(creator.id) - const plateId = keyedFloorPlateId(creator.parentId!, creator.floor!.footprint!) - expect(cloned[plateId]?.type).toBe('slab') - cloned = reconcile(cloned) - expect(base(cloned, creator.id).id).toBe(plateId) - expect(floorFootprintName(cloned, base(cloned, larger.id))).toBe('Lanai floor') - cloned = intent(cloned, creator.id, { name: 'Cloned porch' }) - expect(floorFootprintName(cloned, base(cloned, larger.id))).toBe('Cloned porch floor') - expect(floorFootprintName(nodes, base(nodes, largerId))).toBe('Lanai floor') -}) +test.each(['level', 'scene'])( + '%s clone remints keys with the cloned creator and stable plate IDs', + (kind) => { + const { nodes, creatorId, largerId, key, levelId } = fixture() + const clonedNodes = + kind === 'level' + ? cloneLevelSubtree(nodes, levelId).clonedNodes + : Object.values(cloneSceneGraph({ nodes, rootNodeIds: [levelId] }).nodes) + let cloned = Object.fromEntries(clonedNodes.map((node) => [node.id, node])) + const creator = clonedNodes.find( + (node) => node.type === 'zone' && node.name === 'Lanai', + ) as ZoneNode + const larger = clonedNodes.find( + (node) => node.type === 'zone' && node.name === 'Living room', + ) as ZoneNode + expect(creator.id).not.toBe(creatorId) + expect(larger.id).not.toBe(largerId) + expect(creator.floor!.footprint).not.toBe(key) + expect(larger.floor!.footprint).toBe(creator.floor!.footprint) + expect(floorFootprintCreatorId(creator.floor!.footprint!)).toBe(creator.id) + const plateId = keyedFloorPlateId(creator.parentId!, creator.floor!.footprint!) + expect(cloned[plateId]?.type).toBe('slab') + cloned = reconcile(cloned) + expect(base(cloned, creator.id).id).toBe(plateId) + expect(floorFootprintName(cloned, base(cloned, larger.id))).toBe('Lanai floor') + cloned = intent(cloned, creator.id, { name: 'Cloned porch' }) + expect(floorFootprintName(cloned, base(cloned, larger.id))).toBe('Cloned porch floor') + expect(floorFootprintName(nodes, base(nodes, largerId))).toBe('Lanai floor') + }, +) -test.each([ - 'own', - 'departed', - 'deleted', -])('cloning %s creator history preserves fallback naming', (state) => { - const { nodes, creatorId, largerId, levelId } = fixture() - if (state === 'own') { - for (const id of [creatorId, largerId]) - nodes[id] = { ...room(nodes, id), floor: { footprint: 'own' } } - } else if (state === 'departed') - nodes[creatorId] = { ...room(nodes, creatorId), floor: undefined } - else delete nodes[creatorId] - const clonedNodes = Object.values(cloneSceneGraph({ nodes, rootNodeIds: [levelId] }).nodes) - const cloned = Object.fromEntries(clonedNodes.map((node) => [node.id, node])) - const larger = clonedNodes.find( - (node) => node.type === 'zone' && node.name === 'Living room', - ) as ZoneNode - expect(floorFootprintName(cloned, base(cloned, larger.id))).toBe('Living room floor') - if (state !== 'departed') - expect(floorFootprintCreatorId(larger.floor!.footprint!)).toBeUndefined() -}) +test.each(['own', 'departed', 'deleted'])( + 'cloning %s creator history preserves fallback naming', + (state) => { + const { nodes, creatorId, largerId, levelId } = fixture() + if (state === 'own') { + for (const id of [creatorId, largerId]) + nodes[id] = { ...room(nodes, id), floor: { footprint: 'own' } } + } else if (state === 'departed') + nodes[creatorId] = { ...room(nodes, creatorId), floor: undefined } + else delete nodes[creatorId] + const clonedNodes = Object.values(cloneSceneGraph({ nodes, rootNodeIds: [levelId] }).nodes) + const cloned = Object.fromEntries(clonedNodes.map((node) => [node.id, node])) + const larger = clonedNodes.find( + (node) => node.type === 'zone' && node.name === 'Living room', + ) as ZoneNode + expect(floorFootprintName(cloned, base(cloned, larger.id))).toBe('Living room floor') + if (state !== 'departed') + expect(floorFootprintCreatorId(larger.floor!.footprint!)).toBeUndefined() + }, +) diff --git a/packages/core/src/lib/floor-footprint-name.test.ts b/packages/core/src/lib/floor-footprint-name.test.ts index a325bcd172..ba7de3e483 100644 --- a/packages/core/src/lib/floor-footprint-name.test.ts +++ b/packages/core/src/lib/floor-footprint-name.test.ts @@ -17,22 +17,19 @@ const room = (id: string, polygon: [number, number][], name: string) => const scene = (...nodes: AnyNode[]) => Object.fromEntries(nodes.map((node) => [node.id, node])) -test.each([ - 'Room 3 Slab', - 'Kitchen slab', - 'Floor plate 12', - 'Slab 4', - 'Floor', -])('generated legacy name %s uses the shared footprint name', (name) => { - const base = plate('slab_base', square(0, 4), [], name) - expect(floorFootprintName(scene(base), base)).toBe('Floor area') - const shed = room('zone_shed', base.polygon, 'Shed') - const withShed = { ...base, zoneIds: [shed.id] } - expect(floorFootprintName(scene(withShed, shed), withShed)).toBe('Shed floor') - expect(floorFootprintName(scene(base), { ...base, name: 'Garden foundation' })).toBe( - 'Garden foundation', - ) -}) +test.each(['Room 3 Slab', 'Kitchen slab', 'Floor plate 12', 'Slab 4', 'Floor'])( + 'generated legacy name %s uses the shared footprint name', + (name) => { + const base = plate('slab_base', square(0, 4), [], name) + expect(floorFootprintName(scene(base), base)).toBe('Floor area') + const shed = room('zone_shed', base.polygon, 'Shed') + const withShed = { ...base, zoneIds: [shed.id] } + expect(floorFootprintName(scene(withShed, shed), withShed)).toBe('Shed floor') + expect(floorFootprintName(scene(base), { ...base, name: 'Garden foundation' })).toBe( + 'Garden foundation', + ) + }, +) test('footprints never read as levels: Shared floor, then the largest room, then Floor area', () => { const kitchen = room('zone_kitchen', square(0, 6), 'Kitchen') diff --git a/packages/core/src/lib/floor-levels.test.ts b/packages/core/src/lib/floor-levels.test.ts index 0525bb64be..8cd58fdf20 100644 --- a/packages/core/src/lib/floor-levels.test.ts +++ b/packages/core/src/lib/floor-levels.test.ts @@ -278,14 +278,15 @@ test('support offsets move W while plate-carried face datums stay on their own f expect(support.faceBottom.b[0]!.elevation).toBe(0.05) }) -test.each([ - 0.049, 0.051, -])('D13 exact millimetre floor %s has its own nonempty plate', (elevation) => { - const s = scene(elevation, 0.05, false) - const own = plates(s.nodes).find((plate) => plate.plateRole !== 'base')! - expect(own).toBeDefined() - expect(area([{ outer: own.polygon, holes: own.holes }])).toBeGreaterThan(1) -}) +test.each([0.049, 0.051])( + 'D13 exact millimetre floor %s has its own nonempty plate', + (elevation) => { + const s = scene(elevation, 0.05, false) + const own = plates(s.nodes).find((plate) => plate.plateRole !== 'base')! + expect(own).toBeDefined() + expect(area([{ outer: own.polygon, holes: own.holes }])).toBeGreaterThan(1) + }, +) test('D11 door resizing and wall lowering use the same floor feasibility check', () => { const s = scene(0.3) diff --git a/packages/core/src/lib/floor-plate-support.test.ts b/packages/core/src/lib/floor-plate-support.test.ts index 96184bf720..fe86eb0026 100644 --- a/packages/core/src/lib/floor-plate-support.test.ts +++ b/packages/core/src/lib/floor-plate-support.test.ts @@ -70,24 +70,25 @@ function fixture(highThickness = 0.45) { return { walls, slabs, plates, nodes: migrated } } -test.each([ - 0.05, 0.45, -])('base plate carries every wall regardless of legacy thickness %s', (thickness) => { - const { walls, plates, nodes } = fixture(thickness) - const baseHeight = plates.find((plate) => plate.plateRole === 'base')!.elevation - expect(baseHeight).toBe(0.05) - expect(plates.every((plate) => plate.floorHeight === undefined)).toBe(true) - for (const wall of walls) { - const actual = computeWallSlabSupport(wall, plates, walls, undefined, undefined, 0, nodes) - expect(actual.elevation).toBe(baseHeight) - expect(actual.baseSegments).toEqual([{ start: 0, end: 1, elevation: baseHeight }]) - } - const divider = computeWallSlabSupport(walls[4]!, plates, walls, undefined, undefined, 0, nodes) - expect(divider.faceDatum.a[0]!.elevation).toBe(0.05) - expect(divider.faceDatum.b[0]!.elevation).toBe(0.45) - for (const plate of plates) - expect(getRenderableSlabPolygon(plate, { walls, siblingSlabs: plates })).toBe(plate.polygon) -}) +test.each([0.05, 0.45])( + 'base plate carries every wall regardless of legacy thickness %s', + (thickness) => { + const { walls, plates, nodes } = fixture(thickness) + const baseHeight = plates.find((plate) => plate.plateRole === 'base')!.elevation + expect(baseHeight).toBe(0.05) + expect(plates.every((plate) => plate.floorHeight === undefined)).toBe(true) + for (const wall of walls) { + const actual = computeWallSlabSupport(wall, plates, walls, undefined, undefined, 0, nodes) + expect(actual.elevation).toBe(baseHeight) + expect(actual.baseSegments).toEqual([{ start: 0, end: 1, elevation: baseHeight }]) + } + const divider = computeWallSlabSupport(walls[4]!, plates, walls, undefined, undefined, 0, nodes) + expect(divider.faceDatum.a[0]!.elevation).toBe(0.05) + expect(divider.faceDatum.b[0]!.elevation).toBe(0.45) + for (const plate of plates) + expect(getRenderableSlabPolygon(plate, { walls, siblingSlabs: plates })).toBe(plate.polygon) + }, +) test('a zero-height exterior deck cannot pull a wall off the continuous base', () => { const { walls, plates, nodes } = fixture(0.05) diff --git a/packages/core/src/lib/level-footprints.test.ts b/packages/core/src/lib/level-footprints.test.ts index 23404eccbd..a4903e5930 100644 --- a/packages/core/src/lib/level-footprints.test.ts +++ b/packages/core/src/lib/level-footprints.test.ts @@ -466,50 +466,50 @@ describe('nested reference faces', () => { }) describe('curved boundary junctions and exposure caching', () => { - test.each([ - 'separator', - 'wall', - ] as const)('%s endpoint splits a curved wall at mid-span', (kind) => { - for (const arcT of [0.5, 0.37]) { - const walls = rectangle() - walls[0] = { ...walls[0]!, curveOffset: 0.5 } - const point = getWallCurveFrameAt(walls[0]!, arcT).point - const start: [number, number] = [point.x, point.y] - const end: [number, number] = [point.x, 3] - const divider = - kind === 'separator' - ? SeparatorNode.parse({ parentId: levelId, start, end }) - : wall('curve_divider', start, end) - const { index, level } = topology([...walls, divider]) - expect(level.rooms).toHaveLength(2) - const spans = index.spansForWall(levelId, walls[0]!.id) - const chordT = point.x / 4 - expect(spans).toHaveLength(2) - expect(spans[0]).toMatchObject({ face: 'a', t0: 0 }) - expect(spans[0]!.t1).toBeCloseTo(chordT, 12) - expect(spans[1]!.t0).toBeCloseTo(chordT, 12) - expect(spans[1]).toMatchObject({ face: 'a', t1: 1 }) - const dividerSpans = index.spansForWall(levelId, divider.id) - expect(dividerSpans).toHaveLength(2) - expect(new Set(dividerSpans.map((span) => span.roomId)).size).toBe(2) - expect(index.roomForWallHit(levelId, divider.id, 'a', 0.5)).toBe( - index.roomAtPoint(levelId, [1, 2]), - ) - expect(index.roomForWallHit(levelId, divider.id, 'b', 0.5)).toBe( - index.roomAtPoint(levelId, [3, 2]), - ) - const before = topology(walls).index - before.applyWallDelta( - levelId, - new Set([divider.id]), - scene(walls), - scene([...walls, divider]), - ) - expect( - before.getLevelTopology(levelId)!.rooms.map(({ holes, spans }) => ({ holes, spans })), - ).toEqual(level.rooms.map(({ holes, spans }) => ({ holes, spans }))) - } - }) + test.each(['separator', 'wall'] as const)( + '%s endpoint splits a curved wall at mid-span', + (kind) => { + for (const arcT of [0.5, 0.37]) { + const walls = rectangle() + walls[0] = { ...walls[0]!, curveOffset: 0.5 } + const point = getWallCurveFrameAt(walls[0]!, arcT).point + const start: [number, number] = [point.x, point.y] + const end: [number, number] = [point.x, 3] + const divider = + kind === 'separator' + ? SeparatorNode.parse({ parentId: levelId, start, end }) + : wall('curve_divider', start, end) + const { index, level } = topology([...walls, divider]) + expect(level.rooms).toHaveLength(2) + const spans = index.spansForWall(levelId, walls[0]!.id) + const chordT = point.x / 4 + expect(spans).toHaveLength(2) + expect(spans[0]).toMatchObject({ face: 'a', t0: 0 }) + expect(spans[0]!.t1).toBeCloseTo(chordT, 12) + expect(spans[1]!.t0).toBeCloseTo(chordT, 12) + expect(spans[1]).toMatchObject({ face: 'a', t1: 1 }) + const dividerSpans = index.spansForWall(levelId, divider.id) + expect(dividerSpans).toHaveLength(2) + expect(new Set(dividerSpans.map((span) => span.roomId)).size).toBe(2) + expect(index.roomForWallHit(levelId, divider.id, 'a', 0.5)).toBe( + index.roomAtPoint(levelId, [1, 2]), + ) + expect(index.roomForWallHit(levelId, divider.id, 'b', 0.5)).toBe( + index.roomAtPoint(levelId, [3, 2]), + ) + const before = topology(walls).index + before.applyWallDelta( + levelId, + new Set([divider.id]), + scene(walls), + scene([...walls, divider]), + ) + expect( + before.getLevelTopology(levelId)!.rooms.map(({ holes, spans }) => ({ holes, spans })), + ).toEqual(level.rooms.map(({ holes, spans }) => ({ holes, spans }))) + } + }, + ) test('exposure preparation is reused across plates and invalidated by topology revision', () => { const walls = rectangle() diff --git a/packages/core/src/lib/own-floor.test.ts b/packages/core/src/lib/own-floor.test.ts index 3a8ace441d..23408e6624 100644 --- a/packages/core/src/lib/own-floor.test.ts +++ b/packages/core/src/lib/own-floor.test.ts @@ -116,27 +116,27 @@ test('a shared room owns the whole wall even where another span borders an own r expect(containsPoint([shape(shared)], [6, 0])).toBe(true) }) -test.each([ - false, - true, -])('equal-height own/shared and own/own have no side seam (both=%s)', (both) => { - const s = scene(true, 0.05) - let nodes = toggle(s.nodes, s.zones[1]!.id, true) - if (both) nodes = toggle(nodes, s.zones[0]!.id, true) - const context = plateLevelContext(nodes[s.level.id]!, (id) => nodes[id]) - for (const plate of bases(nodes)) { - const sides = computePlateSurfacePartition(plate, context)!.sides - const seam = sides.filter( - (side) => - Math.abs(side.start[0] - 4) < 1e-6 && - Math.abs(side.end[0] - 4) < 1e-6 && - (side.start[1] + side.end[1]) / 2 > 0.1 && - (side.start[1] + side.end[1]) / 2 < 3.9, - ) - expect(seam.length).toBeGreaterThan(0) - expect(seam.every((side) => side.role === 'hidden')).toBe(true) - } -}) +test.each([false, true])( + 'equal-height own/shared and own/own have no side seam (both=%s)', + (both) => { + const s = scene(true, 0.05) + let nodes = toggle(s.nodes, s.zones[1]!.id, true) + if (both) nodes = toggle(nodes, s.zones[0]!.id, true) + const context = plateLevelContext(nodes[s.level.id]!, (id) => nodes[id]) + for (const plate of bases(nodes)) { + const sides = computePlateSurfacePartition(plate, context)!.sides + const seam = sides.filter( + (side) => + Math.abs(side.start[0] - 4) < 1e-6 && + Math.abs(side.end[0] - 4) < 1e-6 && + (side.start[1] + side.end[1]) / 2 > 0.1 && + (side.start[1] + side.end[1]) / 2 < 3.9, + ) + expect(seam.length).toBeGreaterThan(0) + expect(seam.every((side) => side.role === 'hidden')).toBe(true) + } + }, +) test('shared/own doorway keeps the higher landing, lower-face step, and doorway paint', () => { const s = scene() @@ -729,46 +729,46 @@ test('Wawa-shaped sunken Lanai round-trips exterior doors and windows through an expect(zone(attached, id).floor?.elevation).toBeCloseTo(-0.1) }) -test.each([ - undefined, - 2.5, -])('ceiling edge slivers do not carry the adjacent floor (height=%s)', (height) => { - const s = scene(false, -0.1) - const id = s.zones[1]!.id - const detached = toggle(s.nodes, id, true) - const ceiling = CeilingNode.parse({ - parentId: s.level.id, - height, - polygon: [ - [0, 0], - [4.12, 0], - [4.12, 4], - [0, 4], - ], - }) - const nodes = { ...detached, [ceiling.id]: ceiling } - const own = bases(nodes).find((p) => p.zoneIds?.includes(id))! - const shared = bases(nodes).find((p) => !p.zoneIds?.includes(id))! - expect(footprintSupportsNode(own, ceiling, nodes)).toBe(false) - expect(footprintSupportsNode(shared, ceiling, nodes)).toBe(true) - const plan = setRoomFloorConstruction(nodes, { zoneId: id, patch: { floorHeight: -0.05 } }) - expect(plan.conflicts ?? []).toEqual([]) - expect(plan.changes.some((p) => p.op === 'update' && p.id === ceiling.id)).toBe(false) - const spanning = { - ...ceiling, - polygon: [ - [0, 0], - [8, 0], - [8, 4], - [0, 4], - ] as [number, number][], - } - const refused = setRoomFloorConstruction( - { ...nodes, [ceiling.id]: spanning }, - { zoneId: id, patch: { floorHeight: -0.05 } }, - ) - expect(refused.conflicts?.[0]?.message).toContain('raise both or neither') -}) +test.each([undefined, 2.5])( + 'ceiling edge slivers do not carry the adjacent floor (height=%s)', + (height) => { + const s = scene(false, -0.1) + const id = s.zones[1]!.id + const detached = toggle(s.nodes, id, true) + const ceiling = CeilingNode.parse({ + parentId: s.level.id, + height, + polygon: [ + [0, 0], + [4.12, 0], + [4.12, 4], + [0, 4], + ], + }) + const nodes = { ...detached, [ceiling.id]: ceiling } + const own = bases(nodes).find((p) => p.zoneIds?.includes(id))! + const shared = bases(nodes).find((p) => !p.zoneIds?.includes(id))! + expect(footprintSupportsNode(own, ceiling, nodes)).toBe(false) + expect(footprintSupportsNode(shared, ceiling, nodes)).toBe(true) + const plan = setRoomFloorConstruction(nodes, { zoneId: id, patch: { floorHeight: -0.05 } }) + expect(plan.conflicts ?? []).toEqual([]) + expect(plan.changes.some((p) => p.op === 'update' && p.id === ceiling.id)).toBe(false) + const spanning = { + ...ceiling, + polygon: [ + [0, 0], + [8, 0], + [8, 4], + [0, 4], + ] as [number, number][], + } + const refused = setRoomFloorConstruction( + { ...nodes, [ceiling.id]: spanning }, + { zoneId: id, patch: { floorHeight: -0.05 } }, + ) + expect(refused.conflicts?.[0]?.message).toContain('raise both or neither') + }, +) test('keyed plates stay ground-bearing on their foundation; solid only while raised', () => { const s = scene(true, -0.1) diff --git a/packages/core/src/lib/plate-riser-ownership.test.ts b/packages/core/src/lib/plate-riser-ownership.test.ts index c3e0a0b16c..a7e7222c4d 100644 --- a/packages/core/src/lib/plate-riser-ownership.test.ts +++ b/packages/core/src/lib/plate-riser-ownership.test.ts @@ -47,16 +47,15 @@ function upperAndSunkRoom(between: Between) { return { upper: f.zones[0]!.id, sides } } -test.each([ - 'door', - 'wall', - 'separator', -] as const)('every step down into a sunken room across a %s is the upper room’s', (between) => { - const { upper, sides } = upperAndSunkRoom(between) - const risers = sides.filter((side) => side.role === 'riser') - // A plain wall covers its drop down to the sunken floor: nothing to paint. - expect(risers.length > 0).toBe(between !== 'wall') - expect(risers.every((side) => side.zoneId === upper)).toBe(true) - // Only the footprint's exterior band stays the plate's own. - expect(sides.filter((side) => side.role === 'edge').every((side) => !side.zoneId)).toBe(true) -}) +test.each(['door', 'wall', 'separator'] as const)( + 'every step down into a sunken room across a %s is the upper room’s', + (between) => { + const { upper, sides } = upperAndSunkRoom(between) + const risers = sides.filter((side) => side.role === 'riser') + // A plain wall covers its drop down to the sunken floor: nothing to paint. + expect(risers.length > 0).toBe(between !== 'wall') + expect(risers.every((side) => side.zoneId === upper)).toBe(true) + // Only the footprint's exterior band stays the plate's own. + expect(sides.filter((side) => side.role === 'edge').every((side) => !side.zoneId)).toBe(true) + }, +) diff --git a/packages/core/src/lib/structure-kernel.test.ts b/packages/core/src/lib/structure-kernel.test.ts index 23f7a563a0..ce965a547f 100644 --- a/packages/core/src/lib/structure-kernel.test.ts +++ b/packages/core/src/lib/structure-kernel.test.ts @@ -725,26 +725,26 @@ function plates(nodes: SceneNodes) { ) } -test.each([ - false, - true, -])('one full-footprint plate survives partition add/remove (separator=%s)', (separator) => { - const initial = run(fixture()).nodes - const plate = plates(initial)[0]! - expect(plate.polygon).toContainEqual([-0.1, -0.1]) - const divided = split(initial, 2, separator).nodes - expect(plates(divided)).toHaveLength(1) - expect(plates(divided)[0]).toMatchObject({ - id: plate.id, - zoneIds: zones(divided) - .map((zone) => zone.id) - .sort(), - }) - delete divided[separator ? 'separator_divider' : 'wall_divider'] - const merged = run(divided).nodes - expect(plates(merged).map((node) => node.id)).toEqual([plate.id]) - assertIdempotent(merged) -}) +test.each([false, true])( + 'one full-footprint plate survives partition add/remove (separator=%s)', + (separator) => { + const initial = run(fixture()).nodes + const plate = plates(initial)[0]! + expect(plate.polygon).toContainEqual([-0.1, -0.1]) + const divided = split(initial, 2, separator).nodes + expect(plates(divided)).toHaveLength(1) + expect(plates(divided)[0]).toMatchObject({ + id: plate.id, + zoneIds: zones(divided) + .map((zone) => zone.id) + .sort(), + }) + delete divided[separator ? 'separator_divider' : 'wall_divider'] + const merged = run(divided).nodes + expect(plates(merged).map((node) => node.id)).toEqual([plate.id]) + assertIdempotent(merged) + }, +) describe('ceiling outline', () => { function divided(divider: Partial) { diff --git a/packages/core/src/procedural-items/design.test.ts b/packages/core/src/procedural-items/design.test.ts index 6144bc4548..8a3486d757 100644 --- a/packages/core/src/procedural-items/design.test.ts +++ b/packages/core/src/procedural-items/design.test.ts @@ -59,23 +59,26 @@ describe('validateDesign on trial elements', () => { ['E1 condenser', condenserJson, 'floor', 4032, 5, 119, 25, [1.219, 1.076, 1.524]], ['E2 air handler', airHandlerJson, 'ceiling', 7200, 4, 30, 23, [2.125, 1.146, 2.21]], ['E5 louver', louverJson, 'wall', 252, 1, 21, 23, [0.634, 0.94, 0.076]], - ] as const)('%s is valid and measured like the trial build', (_, json, datum, triangles, groups, shapes, cases, dimensions) => { - const result = validateDesign(json) - expect(DesignValidationSchema.parse(result)).toEqual(result) - expect(result.valid).toBe(true) - expect(result.diagnostics).toEqual([]) - expect(result.sweep).toEqual({ cases, failed: 0, failures: [] }) - const m = result.measurements! - expect(m.triangles.actual).toBe(triangles) - expect(m.drawGroups).toHaveLength(groups) - expect(m.shapes).toBe(shapes) - for (const [i, value] of m.bounds.dimensions.entries()) - expect(value).toBeCloseTo(dimensions[i]!, 3) - expect(m.datum).toMatchObject({ kind: datum, gap: 0 }) - expect(m.datum.contact!.shapes).toBeGreaterThan(0) - expect(m.components.count).toBe(1) - expect(m.parts.reduce((sum, part) => sum + part.shapes, 0)).toBe(shapes) - }) + ] as const)( + '%s is valid and measured like the trial build', + (_, json, datum, triangles, groups, shapes, cases, dimensions) => { + const result = validateDesign(json) + expect(DesignValidationSchema.parse(result)).toEqual(result) + expect(result.valid).toBe(true) + expect(result.diagnostics).toEqual([]) + expect(result.sweep).toEqual({ cases, failed: 0, failures: [] }) + const m = result.measurements! + expect(m.triangles.actual).toBe(triangles) + expect(m.drawGroups).toHaveLength(groups) + expect(m.shapes).toBe(shapes) + for (const [i, value] of m.bounds.dimensions.entries()) + expect(value).toBeCloseTo(dimensions[i]!, 3) + expect(m.datum).toMatchObject({ kind: datum, gap: 0 }) + expect(m.datum.contact!.shapes).toBeGreaterThan(0) + expect(m.components.count).toBe(1) + expect(m.parts.reduce((sum, part) => sum + part.shapes, 0)).toBe(shapes) + }, + ) test('per-part bounds and instances follow the parameters', () => { const at = (slat_count: number) => diff --git a/packages/core/src/procedural-items/schema-import.test.ts b/packages/core/src/procedural-items/schema-import.test.ts index bd8c9aab1b..52f9874412 100644 --- a/packages/core/src/procedural-items/schema-import.test.ts +++ b/packages/core/src/procedural-items/schema-import.test.ts @@ -1,34 +1,34 @@ import { expect, test } from 'bun:test' -test.each([ - './node.ts', - '../schema/types.ts', -])('a clean browser bundle can initialize %s without loading scene runtime', async (entry) => { - const loaded = new Set() - const build = await Bun.build({ - entrypoints: [new URL(entry, import.meta.url).pathname], - target: 'browser', - plugins: [ - { - name: 'record-imports', - setup(builder) { - builder.onLoad({ filter: /\.[tj]sx?$/ }, ({ path }) => { - loaded.add(path) - return undefined - }) +test.each(['./node.ts', '../schema/types.ts'])( + 'a clean browser bundle can initialize %s without loading scene runtime', + async (entry) => { + const loaded = new Set() + const build = await Bun.build({ + entrypoints: [new URL(entry, import.meta.url).pathname], + target: 'browser', + plugins: [ + { + name: 'record-imports', + setup(builder) { + builder.onLoad({ filter: /\.[tj]sx?$/ }, ({ path }) => { + loaded.add(path) + return undefined + }) + }, }, - }, - ], - }) - expect(build.success).toBe(true) - const source = await build.outputs[0]!.text() - const result = Bun.spawnSync(['node', '--input-type=module'], { - stdin: Buffer.from(source), - stdout: 'pipe', - stderr: 'pipe', - }) - expect(result.stderr.toString()).toBe('') - expect(result.exitCode).toBe(0) - expect([...loaded].filter((path) => path.endsWith('/store/use-scene.ts'))).toEqual([]) - expect([...loaded].filter((path) => path.endsWith('/spatial-grid-manager.ts'))).toEqual([]) -}) + ], + }) + expect(build.success).toBe(true) + const source = await build.outputs[0]!.text() + const result = Bun.spawnSync(['node', '--input-type=module'], { + stdin: Buffer.from(source), + stdout: 'pipe', + stderr: 'pipe', + }) + expect(result.stderr.toString()).toBe('') + expect(result.exitCode).toBe(0) + expect([...loaded].filter((path) => path.endsWith('/store/use-scene.ts'))).toEqual([]) + expect([...loaded].filter((path) => path.endsWith('/spatial-grid-manager.ts'))).toEqual([]) + }, +) diff --git a/packages/core/src/schema/host-children.test.ts b/packages/core/src/schema/host-children.test.ts index 0addfdb773..f6263387e1 100644 --- a/packages/core/src/schema/host-children.test.ts +++ b/packages/core/src/schema/host-children.test.ts @@ -45,43 +45,44 @@ test('frozen pre-slice corpus covers every kind predating separators', () => { ).sort(), ) }) -test.each( - baseline, -)('pre-slice $type parses, saves and loads without changing existing fields', (saved) => { - const expected = saved.type === 'column' ? { ...saved, children: [] } : saved - const parsedExpected = - saved.type === 'zone' - ? { ...expected, boundarySeparatorIds: [], holes: [] } - : saved.type === 'door' || saved.type === 'window' - ? { ...expected, floorThresholdVersion: 1 } - : saved.type === 'fence' - ? { - ...expected, - children: [], - surfaceMode: 'auto', - transitionMode: 'slope', - transitionWidth: 0.8, - picketSpacing: 0.27, - patternDistribution: 'automatic', - patternAlignment: 'center', - patternCount: 4, - patternRemainder: 'leave', - picketWidth: 0.07, - picketTop: 'flat', - picketProfile: 'level', - picketTopClearance: 0.2, - picketVariation: 0.23, - picketRailProjection: 0.001, - picketRailCount: 2, - infillPlacement: 'center', - } - : expected - expect(JSON.parse(JSON.stringify(AnyNode.parse(saved)))).toEqual(parsedExpected) - // Loading fills the schema defaults a stored node leaves out, nothing else. - const graph = { [saved.id]: parsedExpected } - expect(load({ [saved.id]: saved }, saved.id)).toEqual(graph) - expect(load(graph, saved.id)).toEqual(graph) -}) +test.each(baseline)( + 'pre-slice $type parses, saves and loads without changing existing fields', + (saved) => { + const expected = saved.type === 'column' ? { ...saved, children: [] } : saved + const parsedExpected = + saved.type === 'zone' + ? { ...expected, boundarySeparatorIds: [], holes: [] } + : saved.type === 'door' || saved.type === 'window' + ? { ...expected, floorThresholdVersion: 1 } + : saved.type === 'fence' + ? { + ...expected, + children: [], + surfaceMode: 'auto', + transitionMode: 'slope', + transitionWidth: 0.8, + picketSpacing: 0.27, + patternDistribution: 'automatic', + patternAlignment: 'center', + patternCount: 4, + patternRemainder: 'leave', + picketWidth: 0.07, + picketTop: 'flat', + picketProfile: 'level', + picketTopClearance: 0.2, + picketVariation: 0.23, + picketRailProjection: 0.001, + picketRailCount: 2, + infillPlacement: 'center', + } + : expected + expect(JSON.parse(JSON.stringify(AnyNode.parse(saved)))).toEqual(parsedExpected) + // Loading fills the schema defaults a stored node leaves out, nothing else. + const graph = { [saved.id]: parsedExpected } + expect(load({ [saved.id]: saved }, saved.id)).toEqual(graph) + expect(load(graph, saved.id)).toEqual(graph) + }, +) describe.each([false, true])('host children with compiled parsers = %s', (compiled) => { test.each(hosts)('%s accepts every built-in child id and an undeclared plugin id', (kind) => { diff --git a/packages/core/src/services/surface-hosting.test.ts b/packages/core/src/services/surface-hosting.test.ts index 5c859cc2dd..d9cf0aea1b 100644 --- a/packages/core/src/services/surface-hosting.test.ts +++ b/packages/core/src/services/surface-hosting.test.ts @@ -553,43 +553,42 @@ describe('surface frame contract', () => { [0.3, 0.6, 0.2], [-0.35, -0.7, 0.25], [0, Math.PI / 2, 0], - ] as [ - number, - number, - number, - ][])('full surface rotation %j composes through query.ts', (rx, ry, rz) => { - const rotation: [number, number, number] = [rx, ry, rz] - const host = namedHost(rotation) - const surface = queryProceduralItem(host, {}).surfaces.find((s) => s.id === 'ledge')! - const local: [number, number, number] = [0.1, 0, 0.05] - const point = transformPoint(frame(surface.position, surface.rotation), local) - const result = resolveSurfacePlacement({ - host: host as unknown as AnyNode, - childKind: 'item', - childFootprint: { size: [0.2, 0.1, 0.2], rotationY: rotation[1], rotation }, - hit: { point, normalWorldY: surface.normal[1] }, - scene, - })! - expect(result).not.toBeNull() - expectVec(result.surfaceLocal!.position, local) - const child = ItemNode.parse({ - asset: { ...asset, dimensions: [0.2, 0.1, 0.2] }, - parentId: host.id, - position: result.surfaceLocal!.position, - rotation: result.surfaceLocal!.rotation, - }) - const { attached, nodes } = attachedScene(host, child) - expect(() => validateProceduralRelations(attached, nodes)).not.toThrow() - const actual = nodeLevelFrame(child.id, nodes) - const expected = composeFrames( - frame(host.position, host.rotation), - frame([...result.position], rotation), - ) - expectVec(actual.position, expected.position) - actual.axes.forEach((axis, i) => { - expectVec(axis, expected.axes[i]!) - }) - }) + ] as [number, number, number][])( + 'full surface rotation %j composes through query.ts', + (rx, ry, rz) => { + const rotation: [number, number, number] = [rx, ry, rz] + const host = namedHost(rotation) + const surface = queryProceduralItem(host, {}).surfaces.find((s) => s.id === 'ledge')! + const local: [number, number, number] = [0.1, 0, 0.05] + const point = transformPoint(frame(surface.position, surface.rotation), local) + const result = resolveSurfacePlacement({ + host: host as unknown as AnyNode, + childKind: 'item', + childFootprint: { size: [0.2, 0.1, 0.2], rotationY: rotation[1], rotation }, + hit: { point, normalWorldY: surface.normal[1] }, + scene, + })! + expect(result).not.toBeNull() + expectVec(result.surfaceLocal!.position, local) + const child = ItemNode.parse({ + asset: { ...asset, dimensions: [0.2, 0.1, 0.2] }, + parentId: host.id, + position: result.surfaceLocal!.position, + rotation: result.surfaceLocal!.rotation, + }) + const { attached, nodes } = attachedScene(host, child) + expect(() => validateProceduralRelations(attached, nodes)).not.toThrow() + const actual = nodeLevelFrame(child.id, nodes) + const expected = composeFrames( + frame(host.position, host.rotation), + frame([...result.position], rotation), + ) + expectVec(actual.position, expected.position) + actual.axes.forEach((axis, i) => { + expectVec(axis, expected.axes[i]!) + }) + }, + ) test('full child rotation and off-centre bounds agree with attachmentBounds and validation', () => { const host = namedHost([0, 0, 0]) diff --git a/packages/core/src/store/actions/node-update-children.test.ts b/packages/core/src/store/actions/node-update-children.test.ts index 333dbfd10f..85ca2668b2 100644 --- a/packages/core/src/store/actions/node-update-children.test.ts +++ b/packages/core/src/store/actions/node-update-children.test.ts @@ -61,17 +61,18 @@ function children(node: AnyNode) { for (const [writer, write] of Object.entries(writers)) { describe.each([false, true])(`${writer}, compiled parser = %s`, (enabled) => { - test.each([ - ...nodeFixtures(), - ])('%s keeps existing children when the patch omits them', (_kind, fixture) => { - enableCompiledNodeParsers(enabled) - const node = { ...fixture, children: [child.id] } as AnyNode - seed(node) - write(node, { name: 'Edited host' }) - expect(children(node)).toEqual([child.id]) - expect(useScene.getState().nodes[node.id]!.name).toBe('Edited host') - expect(useScene.getState().nodes[child.id]!.parentId).toBe(node.id) - }) + test.each([...nodeFixtures()])( + '%s keeps existing children when the patch omits them', + (_kind, fixture) => { + enableCompiledNodeParsers(enabled) + const node = { ...fixture, children: [child.id] } as AnyNode + seed(node) + write(node, { name: 'Edited host' }) + expect(children(node)).toEqual([child.id]) + expect(useScene.getState().nodes[node.id]!.name).toBe('Edited host') + expect(useScene.getState().nodes[child.id]!.parentId).toBe(node.id) + }, + ) }) } diff --git a/packages/core/src/store/floor-opening-live.test.ts b/packages/core/src/store/floor-opening-live.test.ts index 595a3e5fbc..5cad6a0524 100644 --- a/packages/core/src/store/floor-opening-live.test.ts +++ b/packages/core/src/store/floor-opening-live.test.ts @@ -20,115 +20,41 @@ const rect = (x0: number, z0: number, x1: number, z1: number): [number, number][ [x0, z1], ] -test.each([ - false, - true, -])('a live opening cuts both surfaces and undoes (paused: %s)', async (paused) => { - const frames = { - requestAnimationFrame: globalThis.requestAnimationFrame, - cancelAnimationFrame: globalThis.cancelAnimationFrame, - } - globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { - callback(0) - return 1 - }) as typeof requestAnimationFrame - globalThis.cancelAnimationFrame = () => {} - const building = BuildingNode.parse({ - id: 'building_live_opening', - children: ['level_live_0', 'level_live_1'], - }) - const ground = LevelNode.parse({ id: 'level_live_0', parentId: building.id, level: 0 }) - const upper = LevelNode.parse({ id: 'level_live_1', parentId: building.id, level: 1 }) - useScene.setState({ - nodes: { [building.id]: building, [ground.id]: ground, [upper.id]: upper }, - rootNodeIds: [building.id], - dirtyNodes: new Set(), - materials: {}, - collections: {}, - readOnly: false, - }) - clearSceneHistory() - const stop = initSpaceDetectionSync(useScene, { - getState: () => ({ spaces: {}, setSpaces: () => {} }), - }) - const solid = (levelId: string, type: 'slab' | 'ceiling') => - Object.values(useScene.getState().nodes) - .filter((node) => node.parentId === levelId && node.type === type && node.boundary === 'auto') - .reduce( - (sum, node) => - sum + - area( - difference(node.polygon, union(node.holes.map((hole) => ({ outer: hole, holes: [] })))), - ), - 0, - ) - try { - let upperRoomId = '' - for (const levelId of [ground.id, upper.id]) { - const plan = createZone(useScene.getState().nodes, { - levelId, - polygon: rect(0, 0, 8, 5), - enclose: true, - mintId: generateId, - name: 'Room', - }) - useScene.getState().applyNodeChanges(structureChangeBatch(plan.changes)) - if (levelId === upper.id) upperRoomId = plan.zoneId +test.each([false, true])( + 'a live opening cuts both surfaces and undoes (paused: %s)', + async (paused) => { + const frames = { + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, } - clearSceneHistory() - const beforePlate = solid(upper.id, 'slab') - const beforeCeiling = solid(ground.id, 'ceiling') - const plan = cutFloorOpening(useScene.getState().nodes, { - zoneId: upperRoomId, - polygon: rect(2, 1, 4, 2), - mintId: () => 'floor-opening_live_cut', + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callback(0) + return 1 + }) as typeof requestAnimationFrame + globalThis.cancelAnimationFrame = () => {} + const building = BuildingNode.parse({ + id: 'building_live_opening', + children: ['level_live_0', 'level_live_1'], }) - expect( - plan.changes.some( - (change) => change.op === 'update' && useScene.getState().nodes[change.id]?.type === 'slab', - ), - ).toBe(false) - if (paused) pauseSpaceDetection() - useScene.getState().applyNodeChanges(structureChangeBatch(plan.changes)) - if (paused) resumeSpaceDetection() - expect(beforePlate - solid(upper.id, 'slab')).toBeCloseTo(2) - expect(beforeCeiling - solid(ground.id, 'ceiling')).toBeCloseTo(2) - - useScene.temporal.getState().undo() - await Promise.resolve() - expect(solid(upper.id, 'slab')).toBeCloseTo(beforePlate) - expect(solid(ground.id, 'ceiling')).toBeCloseTo(beforeCeiling) - useScene.temporal.getState().redo() - await Promise.resolve() - expect(beforePlate - solid(upper.id, 'slab')).toBeCloseTo(2) - expect(beforeCeiling - solid(ground.id, 'ceiling')).toBeCloseTo(2) - - if (paused) pauseSpaceDetection() - useScene - .getState() - .updateNodes([{ id: 'floor-opening_live_cut', data: { polygon: rect(2, 1, 5, 2) } }]) - if (paused) resumeSpaceDetection() - expect(beforePlate - solid(upper.id, 'slab')).toBeCloseTo(3) - expect(beforeCeiling - solid(ground.id, 'ceiling')).toBeCloseTo(3) - - const removal = removeFloorOpening(useScene.getState().nodes, 'floor-opening_live_cut') - if (paused) pauseSpaceDetection() - useScene.getState().applyNodeChanges(structureChangeBatch(removal.changes)) - if (paused) resumeSpaceDetection() - expect(solid(upper.id, 'slab')).toBeCloseTo(beforePlate) - expect(solid(ground.id, 'ceiling')).toBeCloseTo(beforeCeiling) - - const mezzanine = createMezzanine(useScene.getState().nodes, { - hostZoneId: upperRoomId, - polygon: rect(1, 1, 7, 4), - elevation: 1.5, - mintId: generateId, + const ground = LevelNode.parse({ id: 'level_live_0', parentId: building.id, level: 0 }) + const upper = LevelNode.parse({ id: 'level_live_1', parentId: building.id, level: 1 }) + useScene.setState({ + nodes: { [building.id]: building, [ground.id]: ground, [upper.id]: upper }, + rootNodeIds: [building.id], + dirtyNodes: new Set(), + materials: {}, + collections: {}, + readOnly: false, + }) + clearSceneHistory() + const stop = initSpaceDetectionSync(useScene, { + getState: () => ({ spaces: {}, setSpaces: () => {} }), }) - expect(mezzanine.conflicts).toBeUndefined() - useScene.getState().applyNodeChanges(structureChangeBatch(mezzanine.changes)) - const mezzArea = () => + const solid = (levelId: string, type: 'slab' | 'ceiling') => Object.values(useScene.getState().nodes) - .filter((node) => node.type === 'slab' && node.zoneIds?.includes(mezzanine.zoneId)) + .filter( + (node) => node.parentId === levelId && node.type === type && node.boundary === 'auto', + ) .reduce( (sum, node) => sum + @@ -140,24 +66,104 @@ test.each([ ), 0, ) - const beforeMezz = mezzArea() - const hatch = cutFloorOpening(useScene.getState().nodes, { - zoneId: mezzanine.zoneId, - polygon: rect(2, 1.5, 4, 2.5), - mintId: () => 'floor-opening_live_hatch', - }) - if (paused) pauseSpaceDetection() - useScene.getState().applyNodeChanges(structureChangeBatch(hatch.changes)) - if (paused) resumeSpaceDetection() - expect(beforeMezz - mezzArea()).toBeCloseTo(2) - expect(solid(upper.id, 'slab') - mezzArea()).toBeCloseTo(beforePlate) - } finally { - if (paused) resumeSpaceDetection() - stop() - clearSceneHistory() - Object.assign(globalThis, frames) - } -}) + try { + let upperRoomId = '' + for (const levelId of [ground.id, upper.id]) { + const plan = createZone(useScene.getState().nodes, { + levelId, + polygon: rect(0, 0, 8, 5), + enclose: true, + mintId: generateId, + name: 'Room', + }) + useScene.getState().applyNodeChanges(structureChangeBatch(plan.changes)) + if (levelId === upper.id) upperRoomId = plan.zoneId + } + clearSceneHistory() + const beforePlate = solid(upper.id, 'slab') + const beforeCeiling = solid(ground.id, 'ceiling') + const plan = cutFloorOpening(useScene.getState().nodes, { + zoneId: upperRoomId, + polygon: rect(2, 1, 4, 2), + mintId: () => 'floor-opening_live_cut', + }) + expect( + plan.changes.some( + (change) => + change.op === 'update' && useScene.getState().nodes[change.id]?.type === 'slab', + ), + ).toBe(false) + if (paused) pauseSpaceDetection() + useScene.getState().applyNodeChanges(structureChangeBatch(plan.changes)) + if (paused) resumeSpaceDetection() + expect(beforePlate - solid(upper.id, 'slab')).toBeCloseTo(2) + expect(beforeCeiling - solid(ground.id, 'ceiling')).toBeCloseTo(2) + + useScene.temporal.getState().undo() + await Promise.resolve() + expect(solid(upper.id, 'slab')).toBeCloseTo(beforePlate) + expect(solid(ground.id, 'ceiling')).toBeCloseTo(beforeCeiling) + useScene.temporal.getState().redo() + await Promise.resolve() + expect(beforePlate - solid(upper.id, 'slab')).toBeCloseTo(2) + expect(beforeCeiling - solid(ground.id, 'ceiling')).toBeCloseTo(2) + + if (paused) pauseSpaceDetection() + useScene + .getState() + .updateNodes([{ id: 'floor-opening_live_cut', data: { polygon: rect(2, 1, 5, 2) } }]) + if (paused) resumeSpaceDetection() + expect(beforePlate - solid(upper.id, 'slab')).toBeCloseTo(3) + expect(beforeCeiling - solid(ground.id, 'ceiling')).toBeCloseTo(3) + + const removal = removeFloorOpening(useScene.getState().nodes, 'floor-opening_live_cut') + if (paused) pauseSpaceDetection() + useScene.getState().applyNodeChanges(structureChangeBatch(removal.changes)) + if (paused) resumeSpaceDetection() + expect(solid(upper.id, 'slab')).toBeCloseTo(beforePlate) + expect(solid(ground.id, 'ceiling')).toBeCloseTo(beforeCeiling) + + const mezzanine = createMezzanine(useScene.getState().nodes, { + hostZoneId: upperRoomId, + polygon: rect(1, 1, 7, 4), + elevation: 1.5, + mintId: generateId, + }) + expect(mezzanine.conflicts).toBeUndefined() + useScene.getState().applyNodeChanges(structureChangeBatch(mezzanine.changes)) + const mezzArea = () => + Object.values(useScene.getState().nodes) + .filter((node) => node.type === 'slab' && node.zoneIds?.includes(mezzanine.zoneId)) + .reduce( + (sum, node) => + sum + + area( + difference( + node.polygon, + union(node.holes.map((hole) => ({ outer: hole, holes: [] }))), + ), + ), + 0, + ) + const beforeMezz = mezzArea() + const hatch = cutFloorOpening(useScene.getState().nodes, { + zoneId: mezzanine.zoneId, + polygon: rect(2, 1.5, 4, 2.5), + mintId: () => 'floor-opening_live_hatch', + }) + if (paused) pauseSpaceDetection() + useScene.getState().applyNodeChanges(structureChangeBatch(hatch.changes)) + if (paused) resumeSpaceDetection() + expect(beforeMezz - mezzArea()).toBeCloseTo(2) + expect(solid(upper.id, 'slab') - mezzArea()).toBeCloseTo(beforePlate) + } finally { + if (paused) resumeSpaceDetection() + stop() + clearSceneHistory() + Object.assign(globalThis, frames) + } + }, +) test('stair edits own the live floor and ceiling cuts in one undo step', async () => { const previousFrame = globalThis.requestAnimationFrame diff --git a/packages/core/src/store/history-drafts.test.ts b/packages/core/src/store/history-drafts.test.ts index 0f0719759d..2d5ace967d 100644 --- a/packages/core/src/store/history-drafts.test.ts +++ b/packages/core/src/store/history-drafts.test.ts @@ -147,35 +147,35 @@ describe('scene history drafts', () => { expect(useScene.temporal.getState().isTracking).toBe(true) }) - test.each([ - true, - false, - ])('a co-holder never re-records carry metadata after restoration (metadata present=%s)', (present) => { - if (!present) { - const { metadata: _metadata, ...withoutMetadata } = node(itemId)! - useScene.setState({ - nodes: { ...useScene.getState().nodes, [itemId]: withoutMetadata }, - } as never) - clearSceneHistory() - } - const endMover = beginSceneHistoryDraft(itemId, node(itemId)!) - const endOverlay = beginSceneHistoryDraft(itemId, node(itemId)!) - runSceneHistoryDraftWrite(() => - useScene.getState().updateNode(itemId, { metadata: { isTransient: true } }), - ) - useScene.getState().updateNode(itemId, { metadata: { isTransient: true, tag: 'agent' } }) - runSceneHistoryDraftWrite(() => - useScene.getState().updateNodes(sceneHistoryDraftRevertUpdates([itemId]) as never), - ) - endMover() - useScene.getState().updateNode(wallId, { name: 'Foreign wall edit' }) - expect(useScene.temporal.getState().pastStates.at(-1)!.nodes![itemId]?.metadata).toEqual({ - tag: 'agent', - }) - endOverlay() - useScene.temporal.getState().undo() - expect(node(itemId)?.metadata).toEqual({ tag: 'agent' }) - }) + test.each([true, false])( + 'a co-holder never re-records carry metadata after restoration (metadata present=%s)', + (present) => { + if (!present) { + const { metadata: _metadata, ...withoutMetadata } = node(itemId)! + useScene.setState({ + nodes: { ...useScene.getState().nodes, [itemId]: withoutMetadata }, + } as never) + clearSceneHistory() + } + const endMover = beginSceneHistoryDraft(itemId, node(itemId)!) + const endOverlay = beginSceneHistoryDraft(itemId, node(itemId)!) + runSceneHistoryDraftWrite(() => + useScene.getState().updateNode(itemId, { metadata: { isTransient: true } }), + ) + useScene.getState().updateNode(itemId, { metadata: { isTransient: true, tag: 'agent' } }) + runSceneHistoryDraftWrite(() => + useScene.getState().updateNodes(sceneHistoryDraftRevertUpdates([itemId]) as never), + ) + endMover() + useScene.getState().updateNode(wallId, { name: 'Foreign wall edit' }) + expect(useScene.temporal.getState().pastStates.at(-1)!.nodes![itemId]?.metadata).toEqual({ + tag: 'agent', + }) + endOverlay() + useScene.temporal.getState().undo() + expect(node(itemId)?.metadata).toEqual({ tag: 'agent' }) + }, + ) test('foreign metadata remains undoable while carry metadata stays out of history', () => { const end = beginSceneHistoryDraft(itemId, node(itemId)!) @@ -192,27 +192,27 @@ describe('scene history drafts', () => { end() }) - test.each([ - false, - true, - ])('undoing a new host keeps the carried draft attached (created=%s)', (created) => { - const host = WallNode.parse({ parentId: levelId, start: [0, 2], end: [4, 2] }) - useScene.getState().createNode(host, levelId) - const carried = created ? ItemNode.parse({ parentId: levelId, asset: item.asset }) : item - const end = beginSceneHistoryDraft(carried.id as AnyNodeId, created ? null : node(itemId)!) - runSceneHistoryDraftWrite(() => { - if (created) useScene.getState().createNode(carried, levelId) - useScene.getState().updateNode(carried.id as AnyNodeId, { parentId: host.id }) - }) + test.each([false, true])( + 'undoing a new host keeps the carried draft attached (created=%s)', + (created) => { + const host = WallNode.parse({ parentId: levelId, start: [0, 2], end: [4, 2] }) + useScene.getState().createNode(host, levelId) + const carried = created ? ItemNode.parse({ parentId: levelId, asset: item.asset }) : item + const end = beginSceneHistoryDraft(carried.id as AnyNodeId, created ? null : node(itemId)!) + runSceneHistoryDraftWrite(() => { + if (created) useScene.getState().createNode(carried, levelId) + useScene.getState().updateNode(carried.id as AnyNodeId, { parentId: host.id }) + }) - useScene.temporal.getState().undo() + useScene.temporal.getState().undo() - expect(node(host.id as AnyNodeId)).toBeUndefined() - expect(node(carried.id as AnyNodeId)?.parentId).toBe(levelId) - expect(levelChildren()).toContain(carried.id) - expect(useScene.temporal.getState().futureStates).toHaveLength(1) - end() - }) + expect(node(host.id as AnyNodeId)).toBeUndefined() + expect(node(carried.id as AnyNodeId)?.parentId).toBe(levelId) + expect(levelChildren()).toContain(carried.id) + expect(useScene.temporal.getState().futureStates).toHaveLength(1) + end() + }, + ) test("an adopted draft's own writes record nothing; a foreign write records it as it was", () => { const end = beginSceneHistoryDraft(itemId, node(itemId)!) diff --git a/packages/core/src/store/history-invalidation.test.ts b/packages/core/src/store/history-invalidation.test.ts index 49f23b8f18..1bf2e63d9f 100644 --- a/packages/core/src/store/history-invalidation.test.ts +++ b/packages/core/src/store/history-invalidation.test.ts @@ -27,35 +27,37 @@ const ids = (before: Record, after: Record) => 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.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] }) @@ -88,79 +90,78 @@ describe('history dependency closure', () => { 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([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(['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 [ - { + 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], - [8, 0], - [8, 4], + [4, 0], + [4, 4], [0, 4], ], - }, - schema === SlabNode ? { elevation: 1 } : { height: 4 }, - { - holes: [ - [ - [1, 1], - [2, 1], - [2, 2], + }) + const before = nodes(level, wall, remote, surface) + for (const patch of [ + { + polygon: [ + [0, 0], + [8, 0], + [8, 4], + [0, 4], ], - ], - }, - ]) { - expect(ids(before, { ...before, [surface.id]: { ...surface, ...patch } as AnyNode })).toEqual( - [surface.id, level.id].sort(), - ) - } - }) + }, + 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) diff --git a/packages/core/src/store/use-scene-room-invariants.test.ts b/packages/core/src/store/use-scene-room-invariants.test.ts index fdedffaf6c..2cfea89166 100644 --- a/packages/core/src/store/use-scene-room-invariants.test.ts +++ b/packages/core/src/store/use-scene-room-invariants.test.ts @@ -329,33 +329,33 @@ test('I4/I9: split preserves larger-side ids and redistributes ceiling children; } }) -test.each([ - false, - true, -])('I5: deleting a manual cover restores the connected plate (partition=%s)', (partition) => { - closeRoom() - if (partition) { - useScene - .getState() - .createNode( - WallNode.parse({ id: 'wall_room_partition', start: [2, 0], end: [2, 4] }), - levelId, - ) - } - expect(surfaces().filter((node) => node.type === 'slab')).toHaveLength(1) - const manual = SlabNode.parse({ id: 'slab_room_manual', polygon, autoFromWalls: false }) - useScene.getState().createNode(manual, levelId) - expect( - surfaces() - .filter((node) => node.type === 'slab' && node.plateRole !== 'base') - .map((node) => node.id), - ).toEqual([manual.id]) - useScene.getState().deleteNode(manual.id) - expect(surfaces().filter((node) => node.type === 'slab')).toHaveLength(1) - moveRightWall(8.3) - expect(surfaces().filter((node) => node.type === 'slab')).toHaveLength(1) - expect(surfaces().filter((node) => node.type === 'ceiling')).toHaveLength(partition ? 2 : 1) -}) +test.each([false, true])( + 'I5: deleting a manual cover restores the connected plate (partition=%s)', + (partition) => { + closeRoom() + if (partition) { + useScene + .getState() + .createNode( + WallNode.parse({ id: 'wall_room_partition', start: [2, 0], end: [2, 4] }), + levelId, + ) + } + expect(surfaces().filter((node) => node.type === 'slab')).toHaveLength(1) + const manual = SlabNode.parse({ id: 'slab_room_manual', polygon, autoFromWalls: false }) + useScene.getState().createNode(manual, levelId) + expect( + surfaces() + .filter((node) => node.type === 'slab' && node.plateRole !== 'base') + .map((node) => node.id), + ).toEqual([manual.id]) + useScene.getState().deleteNode(manual.id) + expect(surfaces().filter((node) => node.type === 'slab')).toHaveLength(1) + moveRightWall(8.3) + expect(surfaces().filter((node) => node.type === 'slab')).toHaveLength(1) + expect(surfaces().filter((node) => node.type === 'ceiling')).toHaveLength(partition ? 2 : 1) + }, +) test('I6: a polygon-only store edit is ignored; explicit tool demotion protects later edits', () => { closeRoom() @@ -566,60 +566,60 @@ test('separator edits and zone deletion reconcile without generating per-separat expect(surfaces().filter((node) => node.type === 'ceiling')).toHaveLength(1) }) -test.each([ - false, - true, -])('host patches never derive, including an external temporal pause (%s)', (paused) => { - const walls = openRoom() - if (paused) useScene.temporal.getState().pause() - try { - expect( - applySceneOperationPatch({ - materialChanges: [], - nodeCreates: [{ node: walls[3]!, position: 3 }], - nodeUpdates: [], - nodeDeletes: [], - }), - ).toBe(true) - expect(rooms()).toHaveLength(0) - expect(surfaces()).toHaveLength(0) - expect(useScene.temporal.getState().pastStates).toHaveLength(0) - expect(useScene.temporal.getState().isTracking).toBe(!paused) - } finally { - useScene.temporal.getState().resume() - } -}) +test.each([false, true])( + 'host patches never derive, including an external temporal pause (%s)', + (paused) => { + const walls = openRoom() + if (paused) useScene.temporal.getState().pause() + try { + expect( + applySceneOperationPatch({ + materialChanges: [], + nodeCreates: [{ node: walls[3]!, position: 3 }], + nodeUpdates: [], + nodeDeletes: [], + }), + ).toBe(true) + expect(rooms()).toHaveLength(0) + expect(surfaces()).toHaveLength(0) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + expect(useScene.temporal.getState().isTracking).toBe(!paused) + } finally { + useScene.temporal.getState().resume() + } + }, +) -test.each([ - false, - true, -])('explicit ceiling deletion persists zone opt-out in one undo step (paused=%s)', (paused) => { - closeRoom() - const before = useScene.getState().nodes - const zone = rooms()[0]! - const ceiling = surfaces().find((node) => node.type === 'ceiling')! - clearSceneHistory() - const commits: SceneCommit[] = [] - stopCommits = subscribeSceneCommits((commit) => commits.push(commit)) - if (paused) pauseSpaceDetection() - try { - useScene.getState().deleteNode(ceiling.id) - } finally { - if (paused) resumeSpaceDetection() - } - expect(rooms()[0]).toMatchObject({ id: zone.id, hasCeiling: false }) - expect(surfaces().filter((node) => node.type === 'ceiling')).toEqual([]) - expect(useScene.temporal.getState().pastStates).toHaveLength(1) - expect(commits.at(-1)!.current.nodes[zone.id]).toMatchObject({ hasCeiling: false }) - if (!paused) expect(commits).toHaveLength(1) - useScene.temporal.getState().undo() - expect(useScene.getState().nodes).toEqual(before) - useScene.temporal.getState().redo() - moveRightWall(9) - expect(surfaces().filter((node) => node.type === 'ceiling')).toEqual([]) - useScene.getState().updateNode(zone.id, { hasCeiling: true }) - expect(surfaces().filter((node) => node.type === 'ceiling')).toHaveLength(1) -}) +test.each([false, true])( + 'explicit ceiling deletion persists zone opt-out in one undo step (paused=%s)', + (paused) => { + closeRoom() + const before = useScene.getState().nodes + const zone = rooms()[0]! + const ceiling = surfaces().find((node) => node.type === 'ceiling')! + clearSceneHistory() + const commits: SceneCommit[] = [] + stopCommits = subscribeSceneCommits((commit) => commits.push(commit)) + if (paused) pauseSpaceDetection() + try { + useScene.getState().deleteNode(ceiling.id) + } finally { + if (paused) resumeSpaceDetection() + } + expect(rooms()[0]).toMatchObject({ id: zone.id, hasCeiling: false }) + expect(surfaces().filter((node) => node.type === 'ceiling')).toEqual([]) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + expect(commits.at(-1)!.current.nodes[zone.id]).toMatchObject({ hasCeiling: false }) + if (!paused) expect(commits).toHaveLength(1) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes).toEqual(before) + useScene.temporal.getState().redo() + moveRightWall(9) + expect(surfaces().filter((node) => node.type === 'ceiling')).toEqual([]) + useScene.getState().updateNode(zone.id, { hasCeiling: true }) + expect(surfaces().filter((node) => node.type === 'ceiling')).toHaveLength(1) + }, +) test('a pre-existing manual ceiling prevents automatic ceiling creation on close', () => { const walls = openRoom() diff --git a/packages/core/src/systems/wall/wall-topology.test.ts b/packages/core/src/systems/wall/wall-topology.test.ts index 4c6b9362ad..3e16a8b6f6 100644 --- a/packages/core/src/systems/wall/wall-topology.test.ts +++ b/packages/core/src/systems/wall/wall-topology.test.ts @@ -537,54 +537,60 @@ describe('planJoinOpenWallEnd', () => { [133, 135, 0.4, false], [88, 90, 3, true], [89.5, 90, 20, false], - ] as const)('joins %s° at exactly %s° while retaining the anchored junction', (angle, exact, height, reversed) => { - const nodes = cornerScene(angle, height, 0.09, reversed) - const source = nodes.wall_source as WallNode - const target = nodes.wall_target as WallNode - const end = reversed ? 'start' : 'end' - const fixedEnd = reversed ? 'end' : 'start' - expect(rooms(nodes)).toHaveLength(0) - const open = endOf(nodes, source.id, end) - const result = planJoinOpenWallEnd(nodes, open) - expect(result.ok).toBe(true) - if (!result.ok) return - const after = apply(nodes, result.plan.changes) - const joined = after[source.id] as WallNode - expect(joined[fixedEnd]).toEqual(source[fixedEnd]) - expect(joined[end][1]).toBeCloseTo(0, 10) - expect(joined[end][0]).toBeCloseTo(2 + height / Math.tan((exact * Math.PI) / 180), 10) - expect(open.candidate!.point).toEqual(joined[end]) - expect(after.wall_anchor).toEqual(nodes.wall_anchor) - expect(after.wall_left).toEqual(nodes.wall_left) - const targetSegments = Object.values(after).filter( - (node): node is WallNode => - node.type === 'wall' && node.id !== source.id && node.start[1] === 0 && node.end[1] === 0, - ) - expect(targetSegments).toHaveLength(2) - expect(targetSegments[0]!.start).toEqual(target.start) - expect(targetSegments[1]!.end).toEqual(target.end) - expect(rooms(after)).toHaveLength(1) - expect(endOf(after, source.id, end)).toBeUndefined() - }) + ] as const)( + 'joins %s° at exactly %s° while retaining the anchored junction', + (angle, exact, height, reversed) => { + const nodes = cornerScene(angle, height, 0.09, reversed) + const source = nodes.wall_source as WallNode + const target = nodes.wall_target as WallNode + const end = reversed ? 'start' : 'end' + const fixedEnd = reversed ? 'end' : 'start' + expect(rooms(nodes)).toHaveLength(0) + const open = endOf(nodes, source.id, end) + const result = planJoinOpenWallEnd(nodes, open) + expect(result.ok).toBe(true) + if (!result.ok) return + const after = apply(nodes, result.plan.changes) + const joined = after[source.id] as WallNode + expect(joined[fixedEnd]).toEqual(source[fixedEnd]) + expect(joined[end][1]).toBeCloseTo(0, 10) + expect(joined[end][0]).toBeCloseTo(2 + height / Math.tan((exact * Math.PI) / 180), 10) + expect(open.candidate!.point).toEqual(joined[end]) + expect(after.wall_anchor).toEqual(nodes.wall_anchor) + expect(after.wall_left).toEqual(nodes.wall_left) + const targetSegments = Object.values(after).filter( + (node): node is WallNode => + node.type === 'wall' && node.id !== source.id && node.start[1] === 0 && node.end[1] === 0, + ) + expect(targetSegments).toHaveLength(2) + expect(targetSegments[0]!.start).toEqual(target.start) + expect(targetSegments[1]!.end).toEqual(target.end) + expect(rooms(after)).toHaveLength(1) + expect(endOf(after, source.id, end)).toBeUndefined() + }, + ) test.each([ [80, 3], [84, 1], [88, 6], [88, 10], - ] as const)('keeps the straight join for %s° at height %s when angle or movement exceeds the limit', (angle, height) => { - const nodes = cornerScene(angle, height) - const source = nodes.wall_source as WallNode - const open = endOf(nodes, source.id, 'end') - const result = planJoinOpenWallEnd(nodes, open) - expect(result.ok).toBe(true) - if (!result.ok) return - const joined = apply(nodes, result.plan.changes)[source.id] as WallNode - expect(joined.start).toEqual(source.start) - expect(joined.end[0]).toBeCloseTo(2 + height / Math.tan((angle * Math.PI) / 180), 10) - expect(joined.end[1]).toBeCloseTo(0, 10) - expect(open.candidate!.point).toEqual(joined.end) - }) + ] as const)( + 'keeps the straight join for %s° at height %s when angle or movement exceeds the limit', + (angle, height) => { + const nodes = cornerScene(angle, height) + const source = nodes.wall_source as WallNode + const open = endOf(nodes, source.id, 'end') + const result = planJoinOpenWallEnd(nodes, open) + expect(result.ok).toBe(true) + if (!result.ok) return + const joined = apply(nodes, result.plan.changes)[source.id] as WallNode + expect(joined.start).toEqual(source.start) + expect(joined.end[0]).toBeCloseTo(2 + height / Math.tan((angle * Math.PI) / 180), 10) + expect(joined.end[1]).toBeCloseTo(0, 10) + expect(open.candidate!.point).toEqual(joined.end) + }, + ) test('measures the squaring limit from where the straight join lands', () => { // 3 m wall, 2° off square, 20 cm short: the corner is ~22 cm from the open end but @@ -681,34 +687,34 @@ describe('planJoinOpenWallEnd', () => { expect(rooms(after)).toHaveLength(1) }) - test.each([ - 'start', - 'end', - ] as const)('squaring the open %s preserves hosted openings and closes the room', (end) => { - const nodes = cornerScene(88, 3, 0.09, end === 'start') - const source = nodes.wall_source as WallNode - const door = DoorNode.parse({ - id: 'door_source', - parentId: source.id, - wallId: source.id, - position: [1, 1.05, 0], - width: 0.8, - }) - nodes[source.id] = { ...source, children: [door.id] } - nodes[door.id] = door - const open = endOf(nodes, source.id, end) - const result = planJoinOpenWallEnd(nodes, open) - expect(result.ok).toBe(true) - if (!result.ok) return - const after = apply(nodes, result.plan.changes) - const joined = after[source.id] as WallNode - expect(joined[end][0]).toBeCloseTo(2, 10) - expect(joined.children).toContain(door.id) - expect( - checkOpeningWithinWall(after[door.id] as ReturnType, joined, 3), - ).toEqual([]) - expect(rooms(after)).toHaveLength(1) - }) + test.each(['start', 'end'] as const)( + 'squaring the open %s preserves hosted openings and closes the room', + (end) => { + const nodes = cornerScene(88, 3, 0.09, end === 'start') + const source = nodes.wall_source as WallNode + const door = DoorNode.parse({ + id: 'door_source', + parentId: source.id, + wallId: source.id, + position: [1, 1.05, 0], + width: 0.8, + }) + nodes[source.id] = { ...source, children: [door.id] } + nodes[door.id] = door + const open = endOf(nodes, source.id, end) + const result = planJoinOpenWallEnd(nodes, open) + expect(result.ok).toBe(true) + if (!result.ok) return + const after = apply(nodes, result.plan.changes) + const joined = after[source.id] as WallNode + expect(joined[end][0]).toBeCloseTo(2, 10) + expect(joined.children).toContain(door.id) + expect( + checkOpeningWithinWall(after[door.id] as ReturnType, joined, 3), + ).toEqual([]) + expect(rooms(after)).toHaveLength(1) + }, + ) test('falls back to a straight join when squaring would push an opening past the anchored end', () => { const nodes = cornerScene(88) diff --git a/packages/core/src/utils/exterior-threshold-migration.test.ts b/packages/core/src/utils/exterior-threshold-migration.test.ts index 66e3fe0bef..20fa25b076 100644 --- a/packages/core/src/utils/exterior-threshold-migration.test.ts +++ b/packages/core/src/utils/exterior-threshold-migration.test.ts @@ -91,17 +91,17 @@ describe('exterior threshold ownership and load parity', () => { expect(cellAt(migrated, [2, -0.05]).finish).toBe(RED) expect(cellAt(migrated, [2, 1]).finish).toBe(TILE) }) - test.each([ - 'door', - 'opening', - ] as const)('%s belongs to the sole room across the full wall thickness', (kind) => { - const { nodes } = fixture(kind) - for (const z of [-0.09, 0, 0.09]) { - expect(cellAt(nodes, [2, z]).role).toBe('room:zone_a') - expect(cellAt(nodes, [2, z]).finish).toBe(TILE) - } - expect(cellAt(nodes, [3, 0]).role).toBe('surface') - }) + test.each(['door', 'opening'] as const)( + '%s belongs to the sole room across the full wall thickness', + (kind) => { + const { nodes } = fixture(kind) + for (const z of [-0.09, 0, 0.09]) { + expect(cellAt(nodes, [2, z]).role).toBe('room:zone_a') + expect(cellAt(nodes, [2, z]).finish).toBe(TILE) + } + expect(cellAt(nodes, [3, 0]).role).toBe('surface') + }, + ) test('painted regions extend through the strip and later regions win', () => { const { nodes } = fixture() const zone = nodes.zone_a as ZoneNode diff --git a/packages/core/src/utils/floor-plate-corpus.test.ts b/packages/core/src/utils/floor-plate-corpus.test.ts index 50c2935bc9..c70868070c 100644 --- a/packages/core/src/utils/floor-plate-corpus.test.ts +++ b/packages/core/src/utils/floor-plate-corpus.test.ts @@ -120,38 +120,39 @@ function expectPreservedSlab( } describe('M4/M5 trimmed production levels', () => { - test.each( - fixtures, - )('$sceneId $levelId is deterministic, pure and stable across the server reload', ({ nodes }) => { - const original = JSON.stringify(nodes) - const warning = spyOn(console, 'warn').mockImplementation(() => {}) - try { - const after = migrate(nodes) - expect(JSON.stringify(nodes)).toBe(original) - const reversed = migrate(Object.fromEntries(Object.entries(nodes).reverse())) - expect(plates(reversed)).toEqual(plates(after)) - const reloaded = migrate(migrateVerticalSceneNodes(healSceneNodes(after).nodes).nodes) - expect(reloaded).toEqual(after) - expect( - warning.mock.calls.filter( - ([message, details]) => - message === '[floor plates] Keeping existing level construction' && - (details as { error: unknown }).error !== 'recessed-legacy: preserved verbatim', - ), - ).toEqual([]) - for (const plate of plates(after)) { - if (!plate.zoneIds!.length) expect(plate.plateRole).toBe('base') - expect(plate.zoneIds).toEqual([...plate.zoneIds!].sort()) + test.each(fixtures)( + '$sceneId $levelId is deterministic, pure and stable across the server reload', + ({ nodes }) => { + const original = JSON.stringify(nodes) + const warning = spyOn(console, 'warn').mockImplementation(() => {}) + try { + const after = migrate(nodes) + expect(JSON.stringify(nodes)).toBe(original) + const reversed = migrate(Object.fromEntries(Object.entries(nodes).reverse())) + expect(plates(reversed)).toEqual(plates(after)) + const reloaded = migrate(migrateVerticalSceneNodes(healSceneNodes(after).nodes).nodes) + expect(reloaded).toEqual(after) expect( - (after[plate.parentId!] as { children: string[] }).children.filter( - (id) => id === plate.id, + warning.mock.calls.filter( + ([message, details]) => + message === '[floor plates] Keeping existing level construction' && + (details as { error: unknown }).error !== 'recessed-legacy: preserved verbatim', ), - ).toHaveLength(1) + ).toEqual([]) + for (const plate of plates(after)) { + if (!plate.zoneIds!.length) expect(plate.plateRole).toBe('base') + expect(plate.zoneIds).toEqual([...plate.zoneIds!].sort()) + expect( + (after[plate.parentId!] as { children: string[] }).children.filter( + (id) => id === plate.id, + ), + ).toHaveLength(1) + } + } finally { + warning.mockRestore() } - } finally { - warning.mockRestore() - } - }) + }, + ) test.each(fixtures)('$sceneId $levelId preserves visible floor area within 1%', ({ nodes }) => { const before = visible(nodes), @@ -159,15 +160,16 @@ describe('M4/M5 trimmed production levels', () => { expect(Math.abs(after - before)).toBeLessThanOrEqual(Math.max(1e-6, before * 0.01)) }) - test.each([ - 'scene-23', - ])('%s keeps stale small floors without filling larger moved rooms', (sceneId) => { - const fixture = fixtures.find((fixture) => fixture.sceneId === sceneId)! - const after = migrate(fixture.nodes) - expect(plates(after)).toEqual([]) - for (const slab of Object.values(fixture.nodes).filter((node) => node.type === 'slab')) - expectPreservedSlab(fixture.nodes, after, slab, !!slab.autoFromWalls) - }) + test.each(['scene-23'])( + '%s keeps stale small floors without filling larger moved rooms', + (sceneId) => { + const fixture = fixtures.find((fixture) => fixture.sceneId === sceneId)! + const after = migrate(fixture.nodes) + expect(plates(after)).toEqual([]) + for (const slab of Object.values(fixture.nodes).filter((node) => node.type === 'slab')) + expectPreservedSlab(fixture.nodes, after, slab, !!slab.autoFromWalls) + }, + ) test('a room buried inside thick walls stays at zero visible floor area', () => { const fixture = fixtures.find(({ sceneId }) => sceneId === 'scene-11')! @@ -197,41 +199,44 @@ describe('M4/M5 trimmed production levels', () => { expect(Math.abs(visible(after) - visible(fixture.nodes))).toBeLessThan(0.01) }) - test.each( - fixtures.filter(({ sceneId }) => ['scene-05', 'scene-08'].includes(sceneId)), - )('$sceneId IDs do not depend on legacy material representation', ({ nodes }) => { - const materialized = Object.fromEntries( - Object.entries(nodes).map(([id, node]) => [ - id, - node.type === 'slab' && node.material !== undefined - ? { ...node, material: undefined, slots: { surface: `scene:material_${id}` } } - : node, - ]), - ) - const raw = migrate(nodes), - resolved = migrate(materialized) - expect(plates(raw).map((plate) => plate.id)).toEqual(plates(resolved).map((plate) => plate.id)) - if (Object.values(nodes).some((node) => node.type === 'slab' && node.autoFromWalls)) - expect( - plates(raw).length > 0 || - Object.values(nodes) - .filter((node) => node.type === 'slab' && node.autoFromWalls) - .every((slab) => raw[slab.id]?.type === 'slab' && !raw[slab.id].autoFromWalls), - ).toBe(true) - for (const source of Object.values(nodes)) { - if (source.type !== 'slab' || source.material === undefined || raw[source.id]) continue - const retained = Object.values(raw).flatMap((node) => - node.type === 'slab' - ? [node.material] - : node.type === 'zone' - ? [node.floor?.finish, ...(node.floor?.regions ?? []).map((region) => region.finish)] - : [], + test.each(fixtures.filter(({ sceneId }) => ['scene-05', 'scene-08'].includes(sceneId)))( + '$sceneId IDs do not depend on legacy material representation', + ({ nodes }) => { + const materialized = Object.fromEntries( + Object.entries(nodes).map(([id, node]) => [ + id, + node.type === 'slab' && node.material !== undefined + ? { ...node, material: undefined, slots: { surface: `scene:material_${id}` } } + : node, + ]), ) - expect( - retained.some((finish) => JSON.stringify(finish) === JSON.stringify(source.material)), - ).toBe(true) - } - }) + const raw = migrate(nodes), + resolved = migrate(materialized) + expect(plates(raw).map((plate) => plate.id)).toEqual( + plates(resolved).map((plate) => plate.id), + ) + if (Object.values(nodes).some((node) => node.type === 'slab' && node.autoFromWalls)) + expect( + plates(raw).length > 0 || + Object.values(nodes) + .filter((node) => node.type === 'slab' && node.autoFromWalls) + .every((slab) => raw[slab.id]?.type === 'slab' && !raw[slab.id].autoFromWalls), + ).toBe(true) + for (const source of Object.values(nodes)) { + if (source.type !== 'slab' || source.material === undefined || raw[source.id]) continue + const retained = Object.values(raw).flatMap((node) => + node.type === 'slab' + ? [node.material] + : node.type === 'zone' + ? [node.floor?.finish, ...(node.floor?.regions ?? []).map((region) => region.finish)] + : [], + ) + expect( + retained.some((finish) => JSON.stringify(finish) === JSON.stringify(source.material)), + ).toBe(true) + } + }, + ) test('recessed legacy construction is byte-identical and its room is excluded', () => { const fixture = fixtures.find(({ nodes }) => @@ -320,103 +325,106 @@ describe('M4/M5 trimmed production levels', () => { expect(result[0]!.zoneIds).toHaveLength(4) }) - test.each([ - 'scene-20', - ])('%s does not plate a room entirely inside a legacy opening', (sceneId) => { - const fixture = fixtures.find((fixture) => fixture.sceneId === sceneId)! - const after = migrate(fixture.nodes) - for (const plate of plates(after)) { - expect(area(union([slabFootprint(plate)]))).toBeGreaterThan(1e-6) - for (const id of plate.zoneIds!) { - const zone = after[id] as ZoneNode - expect( - Object.values(fixture.nodes).some( - (node) => - node.type === 'slab' && - node.autoFromWalls && - footprintIoU(slabFootprint(node), slabFootprint(zone)) > 0, - ), - ).toBe(true) + test.each(['scene-20'])( + '%s does not plate a room entirely inside a legacy opening', + (sceneId) => { + const fixture = fixtures.find((fixture) => fixture.sceneId === sceneId)! + const after = migrate(fixture.nodes) + for (const plate of plates(after)) { + expect(area(union([slabFootprint(plate)]))).toBeGreaterThan(1e-6) + for (const id of plate.zoneIds!) { + const zone = after[id] as ZoneNode + expect( + Object.values(fixture.nodes).some( + (node) => + node.type === 'slab' && + node.autoFromWalls && + footprintIoU(slabFootprint(node), slabFootprint(zone)) > 0, + ), + ).toBe(true) + } } - } - }) + }, + ) - test.each([ - 'scene-01', - ])('%s demotes unmatched construction without changing its geometry or id', (sceneId) => { - const fixture = fixtures.find((fixture) => fixture.sceneId === sceneId)! - const after = migrate(fixture.nodes) - let demoted = 0 - for (const source of Object.values(fixture.nodes)) { - if ( - source.type !== 'slab' || - !source.autoFromWalls || - !after[source.id] || - (after[source.id] as SlabNode).plateRole - ) - continue - expectPreservedSlab(fixture.nodes, after, source, true) - demoted++ - } - expect(demoted).toBeGreaterThan(0) - }) + test.each(['scene-01'])( + '%s demotes unmatched construction without changing its geometry or id', + (sceneId) => { + const fixture = fixtures.find((fixture) => fixture.sceneId === sceneId)! + const after = migrate(fixture.nodes) + let demoted = 0 + for (const source of Object.values(fixture.nodes)) { + if ( + source.type !== 'slab' || + !source.autoFromWalls || + !after[source.id] || + (after[source.id] as SlabNode).plateRole + ) + continue + expectPreservedSlab(fixture.nodes, after, source, true) + demoted++ + } + expect(demoted).toBeGreaterThan(0) + }, + ) - test.each( - fixtures.filter(({ sceneId }) => sceneId === 'scene-25'), - )('$sceneId $levelId stored plates cover mitered faces outside existing cutouts', ({ nodes }) => { - const before = rooms(nodes), - after = migrate(nodes) - const walls = Object.values(nodes).filter((node): node is WallNode => node.type === 'wall') - const miters = calculateLevelMiters(walls), - faces = extractRooms(walls) - const stored = plates(after) - if (!stored.length) { - for (const source of Object.values(nodes).filter( - (node) => node.type === 'slab' && node.autoFromWalls, - )) - expectPreservedSlab(nodes, after, source, true) - expect(Math.abs(visible(after) - visible(nodes))).toBeLessThanOrEqual( - Math.max(1e-6, visible(nodes) * 0.01), - ) - return - } - const cutouts = Object.values(nodes) - .filter((node): node is SlabNode => node.type === 'slab') - .flatMap((slab) => (slab.holes ?? []).map((outer) => ({ outer, holes: [] }))) - let checked = 0 - for (const room of faces) { - const plate = stored.find((plate) => - plate.zoneIds!.some( - (id) => - footprintIoU(slabFootprint(before[id] as ZoneNode), { - outer: room.referencePolygon, - holes: room.holes, - }) >= 0.9, - ), - ) - if (!plate) continue - for (const span of room.spans) { - const wall = walls.find((wall) => wall.id === span.boundaryId)! - const endpoints = getWallMiterBoundaryPoints(wall, miters)! - for (let i = 0; i <= 8; i++) { - const t = span.t0 + ((span.t1 - span.t0) * i) / 8 - const start = span.face === 'a' ? endpoints.startLeft : endpoints.startRight - const end = span.face === 'a' ? endpoints.endLeft : endpoints.endRight - const frame = getWallCurveFrameAt(wall, t), - offset = getWallFaceOffsets(wall)[span.face] - const p: [number, number] = wall.curveOffset - ? [frame.point.x + frame.normal.x * offset, frame.point.y + frame.normal.y * offset] - : [start.x + (end.x - start.x) * t, start.y + (end.y - start.y) * t] - if (containsPoint(cutouts, p)) continue - const geometry = stored.map(slabFootprint) - expect( - containsPoint(geometry, p) || distanceToBoundary(geometry, p) <= 0.005, - `${wall.id} ${span.face} ${t}`, - ).toBe(true) - checked++ + test.each(fixtures.filter(({ sceneId }) => sceneId === 'scene-25'))( + '$sceneId $levelId stored plates cover mitered faces outside existing cutouts', + ({ nodes }) => { + const before = rooms(nodes), + after = migrate(nodes) + const walls = Object.values(nodes).filter((node): node is WallNode => node.type === 'wall') + const miters = calculateLevelMiters(walls), + faces = extractRooms(walls) + const stored = plates(after) + if (!stored.length) { + for (const source of Object.values(nodes).filter( + (node) => node.type === 'slab' && node.autoFromWalls, + )) + expectPreservedSlab(nodes, after, source, true) + expect(Math.abs(visible(after) - visible(nodes))).toBeLessThanOrEqual( + Math.max(1e-6, visible(nodes) * 0.01), + ) + return + } + const cutouts = Object.values(nodes) + .filter((node): node is SlabNode => node.type === 'slab') + .flatMap((slab) => (slab.holes ?? []).map((outer) => ({ outer, holes: [] }))) + let checked = 0 + for (const room of faces) { + const plate = stored.find((plate) => + plate.zoneIds!.some( + (id) => + footprintIoU(slabFootprint(before[id] as ZoneNode), { + outer: room.referencePolygon, + holes: room.holes, + }) >= 0.9, + ), + ) + if (!plate) continue + for (const span of room.spans) { + const wall = walls.find((wall) => wall.id === span.boundaryId)! + const endpoints = getWallMiterBoundaryPoints(wall, miters)! + for (let i = 0; i <= 8; i++) { + const t = span.t0 + ((span.t1 - span.t0) * i) / 8 + const start = span.face === 'a' ? endpoints.startLeft : endpoints.startRight + const end = span.face === 'a' ? endpoints.endLeft : endpoints.endRight + const frame = getWallCurveFrameAt(wall, t), + offset = getWallFaceOffsets(wall)[span.face] + const p: [number, number] = wall.curveOffset + ? [frame.point.x + frame.normal.x * offset, frame.point.y + frame.normal.y * offset] + : [start.x + (end.x - start.x) * t, start.y + (end.y - start.y) * t] + if (containsPoint(cutouts, p)) continue + const geometry = stored.map(slabFootprint) + expect( + containsPoint(geometry, p) || distanceToBoundary(geometry, p) <= 0.005, + `${wall.id} ${span.face} ${t}`, + ).toBe(true) + checked++ + } } } - } - expect(checked).toBeGreaterThan(0) - }) + expect(checked).toBeGreaterThan(0) + }, + ) }) diff --git a/packages/core/src/utils/floor-plate-load.test.ts b/packages/core/src/utils/floor-plate-load.test.ts index a196146a5a..bc75b1829d 100644 --- a/packages/core/src/utils/floor-plate-load.test.ts +++ b/packages/core/src/utils/floor-plate-load.test.ts @@ -127,36 +127,39 @@ test.each( fixtures.flatMap((fixture) => ['none', 'before', 'after'].map((mount) => ({ ...fixture, mount })), ), -)('$sceneId loads with opening systems mounted $mount hydration and stable floor geometry', async (fixture) => { - const bytes = JSON.stringify(fixture.nodes) - const server = migrate(fixture.nodes) - expect(JSON.stringify(fixture.nodes)).toBe(bytes) - expect(migrate(server)).toEqual(server) - const mountOpenings = () => { - const stopStair = initializeStairOpeningSync() - const stopElevator = initializeElevatorOpeningSync() - stopOpenings = () => { - stopStair() - stopElevator() +)( + '$sceneId loads with opening systems mounted $mount hydration and stable floor geometry', + async (fixture) => { + const bytes = JSON.stringify(fixture.nodes) + const server = migrate(fixture.nodes) + expect(JSON.stringify(fixture.nodes)).toBe(bytes) + expect(migrate(server)).toEqual(server) + const mountOpenings = () => { + const stopStair = initializeStairOpeningSync() + const stopElevator = initializeElevatorOpeningSync() + stopOpenings = () => { + stopStair() + stopElevator() + } } - } - if (fixture.mount === 'before') mountOpenings() - useScene.getState().setScene(structuredClone(fixture.nodes), fixture.rootNodeIds) - await new Promise((resolve) => queueMicrotask(resolve)) - if (fixture.mount === 'after') { - mountOpenings() + if (fixture.mount === 'before') mountOpenings() + useScene.getState().setScene(structuredClone(fixture.nodes), fixture.rootNodeIds) await new Promise((resolve) => queueMicrotask(resolve)) - } - const client = useScene.getState().nodes - expect(geometry(client)).toEqual(geometry(server)) - for (const node of Object.values(server)) { - if (node.type === 'stair-segment') - expect((client[node.id] as StairSegmentNode).height).toBe(node.height) - } - useScene.getState().setScene(client, fixture.rootNodeIds) - await new Promise((resolve) => queueMicrotask(resolve)) - expect(geometry(useScene.getState().nodes)).toEqual(geometry(server)) -}) + if (fixture.mount === 'after') { + mountOpenings() + await new Promise((resolve) => queueMicrotask(resolve)) + } + const client = useScene.getState().nodes + expect(geometry(client)).toEqual(geometry(server)) + for (const node of Object.values(server)) { + if (node.type === 'stair-segment') + expect((client[node.id] as StairSegmentNode).height).toBe(node.height) + } + useScene.getState().setScene(client, fixture.rootNodeIds) + await new Promise((resolve) => queueMicrotask(resolve)) + expect(geometry(useScene.getState().nodes)).toEqual(geometry(server)) + }, +) test('missing level children are reconstructed from existing parent links before height migration', () => { const fixture = fixtures.find(({ sceneId }) => sceneId === 'scene-13')! @@ -165,98 +168,101 @@ test('missing level children are reconstructed from existing parent links before expect(healSceneNodes(healed).nodes).toEqual(healed) }) -test.each([ - 'none', - 'stair', - 'elevator', -] as const)('load ensures missing stair/elevator holes without redrawing existing ones (%s present)', async (present) => { - const building = BuildingNode.parse({}) - const lower = LevelNode.parse({ parentId: building.id, level: 0, height: 3 }) - const upper = LevelNode.parse({ parentId: building.id, level: 1, height: 3 }) - const stair = StairNode.parse({ - parentId: lower.id, - position: [10, 0, 10], - fromLevelId: lower.id, - toLevelId: upper.id, - slabOpeningMode: 'destination', - }) - const segment = StairSegmentNode.parse({ parentId: stair.id, height: 3, length: 4 }) - const elevator = ElevatorNode.parse({ - parentId: building.id, - position: [3, 0, 3], - fromLevelId: lower.id, - toLevelId: upper.id, - }) - const savedHole: [number, number][] = [ - [1.123_456_789, 1], - [2, 1], - [2, 2], - [1.123_456_789, 2], - ] - const slab = SlabNode.parse({ - parentId: upper.id, - polygon: [ - [0, 0], - [20, 0], - [20, 20], - [0, 20], - ], - holes: present === 'none' ? [] : [savedHole], - holeMetadata: - present === 'none' - ? [] - : [ - { - source: present, - ...(present === 'stair' ? { stairId: stair.id } : { elevatorId: elevator.id }), - }, - ], - }) - if (present === 'none') delete (slab as Partial).holes - building.children = [lower.id, upper.id, elevator.id] - const floor = SlabNode.parse({ parentId: lower.id, elevation: 0.4, polygon: slab.polygon }) - lower.children = [stair.id, floor.id] - upper.children = [slab.id] - stair.children = [segment.id] - const nodes = Object.fromEntries( - [building, lower, upper, stair, segment, elevator, slab, floor].map((node) => [node.id, node]), - ) - const before = JSON.stringify(nodes) - const ensured = { nodes: materializeLegacyAutoOpenings(nodes) as Record } - expect(JSON.stringify(nodes)).toBe(before) - expect(materializeLegacyAutoOpenings(ensured.nodes)).toBe(ensured.nodes) - expect(ensured.nodes[slab.id]).toHaveProperty('holes') - useScene.getState().setScene(nodes, [building.id]) - await new Promise((resolve) => queueMicrotask(resolve)) - const result = useScene.getState().nodes[slab.id] as SlabNode - const owned = Object.values(useScene.getState().nodes).filter( - (node) => - node.type === 'floor-opening' && (node.ownerId === stair.id || node.ownerId === elevator.id), - ) - expect(owned.some((node) => node.type === 'floor-opening' && node.ownerId === stair.id)).toBe( - true, - ) - expect(owned.some((node) => node.type === 'floor-opening' && node.ownerId === elevator.id)).toBe( - true, - ) - expect(result.holes).toHaveLength(2) - expect((useScene.getState().nodes[segment.id] as StairSegmentNode).height).toBeCloseTo(2.6) - expect(result.holes).toEqual((ensured.nodes[slab.id] as SlabNode).holes) - expect(result.holeMetadata.every((entry) => entry.source === 'floor-opening')).toBe(true) - if (present !== 'none') { - expect(result.holes[0]).toEqual(savedHole) - expect(result.holeMetadata?.[0]?.openingId).toBeTruthy() - } - const snapshot = JSON.stringify(result) - const stopStair = initializeStairOpeningSync() - const stopElevator = initializeElevatorOpeningSync() - stopOpenings = () => { - stopStair() - stopElevator() - } - await new Promise((resolve) => queueMicrotask(resolve)) - expect(JSON.stringify(useScene.getState().nodes[slab.id])).toBe(snapshot) - useScene.getState().setScene(useScene.getState().nodes, [building.id]) - await new Promise((resolve) => queueMicrotask(resolve)) - expect(JSON.stringify(useScene.getState().nodes[slab.id])).toBe(snapshot) -}) +test.each(['none', 'stair', 'elevator'] as const)( + 'load ensures missing stair/elevator holes without redrawing existing ones (%s present)', + async (present) => { + const building = BuildingNode.parse({}) + const lower = LevelNode.parse({ parentId: building.id, level: 0, height: 3 }) + const upper = LevelNode.parse({ parentId: building.id, level: 1, height: 3 }) + const stair = StairNode.parse({ + parentId: lower.id, + position: [10, 0, 10], + fromLevelId: lower.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + }) + const segment = StairSegmentNode.parse({ parentId: stair.id, height: 3, length: 4 }) + const elevator = ElevatorNode.parse({ + parentId: building.id, + position: [3, 0, 3], + fromLevelId: lower.id, + toLevelId: upper.id, + }) + const savedHole: [number, number][] = [ + [1.123_456_789, 1], + [2, 1], + [2, 2], + [1.123_456_789, 2], + ] + const slab = SlabNode.parse({ + parentId: upper.id, + polygon: [ + [0, 0], + [20, 0], + [20, 20], + [0, 20], + ], + holes: present === 'none' ? [] : [savedHole], + holeMetadata: + present === 'none' + ? [] + : [ + { + source: present, + ...(present === 'stair' ? { stairId: stair.id } : { elevatorId: elevator.id }), + }, + ], + }) + if (present === 'none') delete (slab as Partial).holes + building.children = [lower.id, upper.id, elevator.id] + const floor = SlabNode.parse({ parentId: lower.id, elevation: 0.4, polygon: slab.polygon }) + lower.children = [stair.id, floor.id] + upper.children = [slab.id] + stair.children = [segment.id] + const nodes = Object.fromEntries( + [building, lower, upper, stair, segment, elevator, slab, floor].map((node) => [ + node.id, + node, + ]), + ) + const before = JSON.stringify(nodes) + const ensured = { nodes: materializeLegacyAutoOpenings(nodes) as Record } + expect(JSON.stringify(nodes)).toBe(before) + expect(materializeLegacyAutoOpenings(ensured.nodes)).toBe(ensured.nodes) + expect(ensured.nodes[slab.id]).toHaveProperty('holes') + useScene.getState().setScene(nodes, [building.id]) + await new Promise((resolve) => queueMicrotask(resolve)) + const result = useScene.getState().nodes[slab.id] as SlabNode + const owned = Object.values(useScene.getState().nodes).filter( + (node) => + node.type === 'floor-opening' && + (node.ownerId === stair.id || node.ownerId === elevator.id), + ) + expect(owned.some((node) => node.type === 'floor-opening' && node.ownerId === stair.id)).toBe( + true, + ) + expect( + owned.some((node) => node.type === 'floor-opening' && node.ownerId === elevator.id), + ).toBe(true) + expect(result.holes).toHaveLength(2) + expect((useScene.getState().nodes[segment.id] as StairSegmentNode).height).toBeCloseTo(2.6) + expect(result.holes).toEqual((ensured.nodes[slab.id] as SlabNode).holes) + expect(result.holeMetadata.every((entry) => entry.source === 'floor-opening')).toBe(true) + if (present !== 'none') { + expect(result.holes[0]).toEqual(savedHole) + expect(result.holeMetadata?.[0]?.openingId).toBeTruthy() + } + const snapshot = JSON.stringify(result) + const stopStair = initializeStairOpeningSync() + const stopElevator = initializeElevatorOpeningSync() + stopOpenings = () => { + stopStair() + stopElevator() + } + await new Promise((resolve) => queueMicrotask(resolve)) + expect(JSON.stringify(useScene.getState().nodes[slab.id])).toBe(snapshot) + useScene.getState().setScene(useScene.getState().nodes, [building.id]) + await new Promise((resolve) => queueMicrotask(resolve)) + expect(JSON.stringify(useScene.getState().nodes[slab.id])).toBe(snapshot) + }, +) diff --git a/packages/core/src/utils/floor-plate-migration.test.ts b/packages/core/src/utils/floor-plate-migration.test.ts index b575d746f4..b5135377c1 100644 --- a/packages/core/src/utils/floor-plate-migration.test.ts +++ b/packages/core/src/utils/floor-plate-migration.test.ts @@ -256,30 +256,29 @@ test('M4/M5 are pure, deterministic and idempotent; finishes, holes and hosts su expect(migrateSlabSlots(slots.nodes).nodes).toBe(slots.nodes) }) -test.each([ - 'elevation', - 'thickness', - 'fillToTerrain', -] as const)('legacy %s keeps its wall support or derives one base', (field) => { - const { nodes } = twoRooms() - const source = slabs(nodes)[0]! - nodes[source.id] = { - ...source, - [field]: field === 'elevation' || field === 'thickness' ? 0.4 : true, - } - const result = migrateFloorPlates(nodes) - expect(slabs(result.nodes)).toHaveLength(field === 'elevation' ? 2 : 1) - if (field === 'elevation') { - expect(result.nodes[source.id]).toMatchObject({ - elevation: 0.4, - autoFromWalls: false, - polygon: source.polygon, - }) - expect(slabs(result.nodes).some((slab) => slab.plateRole === 'base')).toBe(false) - } else - expect(slabs(result.nodes).find((slab) => slab.plateRole === 'base')?.zoneIds).toHaveLength(2) - expect(migrateFloorPlates(result.nodes).nodes).toBe(result.nodes) -}) +test.each(['elevation', 'thickness', 'fillToTerrain'] as const)( + 'legacy %s keeps its wall support or derives one base', + (field) => { + const { nodes } = twoRooms() + const source = slabs(nodes)[0]! + nodes[source.id] = { + ...source, + [field]: field === 'elevation' || field === 'thickness' ? 0.4 : true, + } + const result = migrateFloorPlates(nodes) + expect(slabs(result.nodes)).toHaveLength(field === 'elevation' ? 2 : 1) + if (field === 'elevation') { + expect(result.nodes[source.id]).toMatchObject({ + elevation: 0.4, + autoFromWalls: false, + polygon: source.polygon, + }) + expect(slabs(result.nodes).some((slab) => slab.plateRole === 'base')).toBe(false) + } else + expect(slabs(result.nodes).find((slab) => slab.plateRole === 'base')?.zoneIds).toHaveLength(2) + expect(migrateFloorPlates(result.nodes).nodes).toBe(result.nodes) + }, +) test('hand-split floor becomes a finish region; a raised platform stays manual', () => { const { nodes, zones } = twoRooms() diff --git a/packages/core/src/utils/frozen-floor-gate.test.ts b/packages/core/src/utils/frozen-floor-gate.test.ts index 447072cf0e..90d53d1b7a 100644 --- a/packages/core/src/utils/frozen-floor-gate.test.ts +++ b/packages/core/src/utils/frozen-floor-gate.test.ts @@ -62,114 +62,121 @@ function visible(nodes: Record, levelId: string) { ) } -test.each( - fixtures.filter((fixture) => fixture.areas.length), -)('$sceneId M4 preserves visible area; subsequent loss is exactly legacy stair openings', (fixture) => { - const before = prefix(fixture.nodes) - const plates = migrateFloorPlates(before).nodes as Record - const ensured = { nodes: materializeLegacyAutoOpenings(plates) as Record } - for (const row of fixture.areas) { - const original = visible(before, row.levelId) - const migrated = visible(plates, row.levelId) - const opened = visible(ensured.nodes, row.levelId) - expect(Math.abs(area(migrated) - area(original))).toBeLessThanOrEqual(area(original) * 0.01) - expect(area(original)).toBeCloseTo(row.beforeVisible, 2) - expect(area(opened)).toBeCloseTo(row.afterVisible, 2) - const newHoles = Object.values(ensured.nodes).flatMap((node) => { - if (node.type !== 'slab' || node.parentId !== row.levelId) return [] - const previous = plates[node.id] as SlabNode - return node.holes.slice(previous.holes?.length ?? 0) - }) - expect(area(difference(difference(migrated, opened), union(newHoles)))).toBeLessThan(0.001) - } - expect(materializeLegacyAutoOpenings(ensured.nodes)).toBe(ensured.nodes) -}) +test.each(fixtures.filter((fixture) => fixture.areas.length))( + '$sceneId M4 preserves visible area; subsequent loss is exactly legacy stair openings', + (fixture) => { + const before = prefix(fixture.nodes) + const plates = migrateFloorPlates(before).nodes as Record + const ensured = { nodes: materializeLegacyAutoOpenings(plates) as Record } + for (const row of fixture.areas) { + const original = visible(before, row.levelId) + const migrated = visible(plates, row.levelId) + const opened = visible(ensured.nodes, row.levelId) + expect(Math.abs(area(migrated) - area(original))).toBeLessThanOrEqual(area(original) * 0.01) + expect(area(original)).toBeCloseTo(row.beforeVisible, 2) + expect(area(opened)).toBeCloseTo(row.afterVisible, 2) + const newHoles = Object.values(ensured.nodes).flatMap((node) => { + if (node.type !== 'slab' || node.parentId !== row.levelId) return [] + const previous = plates[node.id] as SlabNode + return node.holes.slice(previous.holes?.length ?? 0) + }) + expect(area(difference(difference(migrated, opened), union(newHoles)))).toBeLessThan(0.001) + } + expect(materializeLegacyAutoOpenings(ensured.nodes)).toBe(ensured.nodes) + }, +) -test.each( - fixtures.filter((fixture) => fixture.gaps.length), -)('$sceneId frozen gap samples are covered by stored plates before missing openings are ensured', (fixture) => { - const plates = migrateFloorPlates(prefix(fixture.nodes)).nodes as Record - const ensured = materializeLegacyAutoOpenings(plates) as Record - for (const gap of fixture.gaps) { - const wall = plates[gap.wallId] as WallNode - const walls = Object.values(plates).filter( - (node): node is WallNode => node.type === 'wall' && node.parentId === gap.levelId, - ) - const ends = getWallMiterBoundaryPoints(wall, calculateLevelMiters(walls))! - const count = isCurvedWall(wall) ? 24 : 1 - const polygon = getWallSurfacePolygon(wall, count, ends) - const lines = { a: polygon.slice(count + 1).reverse(), b: polygon.slice(0, count + 1) } - const owners = Object.values(plates).filter( - (node): node is SlabNode => node.type === 'slab' && node.parentId === gap.levelId, - ) - const geometry = owners.map(slabFootprint) - const additions = owners - .map((plate) => plate.id) - .flatMap((id) => (ensured[id] as SlabNode).holes.slice((plates[id] as SlabNode).holes.length)) - for (const face of gap.faces) - for (const index of face.uncoveredSamples) { - const t = gap.sampleStations[index]! - const segment = Math.min(Math.floor(t * count), count - 1) - const fraction = t * count - segment - const a = lines[face.face][segment]!, - b = lines[face.face][segment + 1]! - const point: [number, number] = [a.x + (b.x - a.x) * fraction, a.y + (b.y - a.y) * fraction] - expect(containsPoint(geometry, point) || distanceToBoundary(geometry, point) <= 0.005).toBe( - true, +test.each(fixtures.filter((fixture) => fixture.gaps.length))( + '$sceneId frozen gap samples are covered by stored plates before missing openings are ensured', + (fixture) => { + const plates = migrateFloorPlates(prefix(fixture.nodes)).nodes as Record + const ensured = materializeLegacyAutoOpenings(plates) as Record + for (const gap of fixture.gaps) { + const wall = plates[gap.wallId] as WallNode + const walls = Object.values(plates).filter( + (node): node is WallNode => node.type === 'wall' && node.parentId === gap.levelId, + ) + const ends = getWallMiterBoundaryPoints(wall, calculateLevelMiters(walls))! + const count = isCurvedWall(wall) ? 24 : 1 + const polygon = getWallSurfacePolygon(wall, count, ends) + const lines = { a: polygon.slice(count + 1).reverse(), b: polygon.slice(0, count + 1) } + const owners = Object.values(plates).filter( + (node): node is SlabNode => node.type === 'slab' && node.parentId === gap.levelId, + ) + const geometry = owners.map(slabFootprint) + const additions = owners + .map((plate) => plate.id) + .flatMap((id) => + (ensured[id] as SlabNode).holes.slice((plates[id] as SlabNode).holes.length), ) - expect( - containsPoint( - additions.map((outer) => ({ outer, holes: [] })), - point, - ), - ).toBe(true) - } - } -}) + for (const face of gap.faces) + for (const index of face.uncoveredSamples) { + const t = gap.sampleStations[index]! + const segment = Math.min(Math.floor(t * count), count - 1) + const fraction = t * count - segment + const a = lines[face.face][segment]!, + b = lines[face.face][segment + 1]! + const point: [number, number] = [ + a.x + (b.x - a.x) * fraction, + a.y + (b.y - a.y) * fraction, + ] + expect( + containsPoint(geometry, point) || distanceToBoundary(geometry, point) <= 0.005, + ).toBe(true) + expect( + containsPoint( + additions.map((outer) => ({ outer, holes: [] })), + point, + ), + ).toBe(true) + } + } + }, +) -test.each( - fixtures.filter(({ sceneId }) => ['scene-19', 'scene-10'].includes(sceneId)), -)('$sceneId level-parented elevator openings agree with client building reparenting and preserve saved holes', (fixture) => { - const before = migrateSlabSlots(migrateFloorPlates(prefix(fixture.nodes)).nodes).nodes as Record< - string, - AnyNode - > - const bytes = JSON.stringify(before) - const clientParents = { ...before } - const legacy = Object.values(before).filter( - (node): node is ElevatorNode => - node.type === 'elevator' && before[node.parentId!]?.type === 'level', - ) - expect(legacy.length).toBeGreaterThan(0) - for (const elevator of legacy) { - const building = Object.values(before).find( - (node) => node.type === 'building' && node.children.includes(elevator.parentId as AnyNodeId), - )! - clientParents[elevator.id] = { ...elevator, parentId: building.id } - } - const server = materializeLegacyAutoOpenings(before) as Record, - client = materializeLegacyAutoOpenings(clientParents) as Record - expect(JSON.stringify(before)).toBe(bytes) - expect( - Object.values(server) - .filter((node) => node.type === 'slab') - .map((node) => node.holeMetadata), - ).toEqual( - Object.values(client) - .filter((node) => node.type === 'slab') - .map((node) => node.holeMetadata), - ) - for (const elevator of legacy) { - expect( - Object.values(server).some( +test.each(fixtures.filter(({ sceneId }) => ['scene-19', 'scene-10'].includes(sceneId)))( + '$sceneId level-parented elevator openings agree with client building reparenting and preserve saved holes', + (fixture) => { + const before = migrateSlabSlots(migrateFloorPlates(prefix(fixture.nodes)).nodes) + .nodes as Record + const bytes = JSON.stringify(before) + const clientParents = { ...before } + const legacy = Object.values(before).filter( + (node): node is ElevatorNode => + node.type === 'elevator' && before[node.parentId!]?.type === 'level', + ) + expect(legacy.length).toBeGreaterThan(0) + for (const elevator of legacy) { + const building = Object.values(before).find( (node) => - node.type === 'slab' && node.holeMetadata?.some((m) => m.elevatorId === elevator.id), - ), - ).toBe(fixture.sceneId !== 'scene-19') - } - for (const node of Object.values(before)) { - if (node.type !== 'slab') continue - expect((server[node.id] as SlabNode).holes.slice(0, node.holes.length)).toEqual(node.holes) - } - expect(materializeLegacyAutoOpenings(server)).toBe(server) -}) + node.type === 'building' && node.children.includes(elevator.parentId as AnyNodeId), + )! + clientParents[elevator.id] = { ...elevator, parentId: building.id } + } + const server = materializeLegacyAutoOpenings(before) as Record, + client = materializeLegacyAutoOpenings(clientParents) as Record + expect(JSON.stringify(before)).toBe(bytes) + expect( + Object.values(server) + .filter((node) => node.type === 'slab') + .map((node) => node.holeMetadata), + ).toEqual( + Object.values(client) + .filter((node) => node.type === 'slab') + .map((node) => node.holeMetadata), + ) + for (const elevator of legacy) { + expect( + Object.values(server).some( + (node) => + node.type === 'slab' && node.holeMetadata?.some((m) => m.elevatorId === elevator.id), + ), + ).toBe(fixture.sceneId !== 'scene-19') + } + for (const node of Object.values(before)) { + if (node.type !== 'slab') continue + expect((server[node.id] as SlabNode).holes.slice(0, node.holes.length)).toEqual(node.holes) + } + expect(materializeLegacyAutoOpenings(server)).toBe(server) + }, +) diff --git a/packages/core/src/utils/vertical-scene-migration.test.ts b/packages/core/src/utils/vertical-scene-migration.test.ts index 56273d4734..c6378683cb 100644 --- a/packages/core/src/utils/vertical-scene-migration.test.ts +++ b/packages/core/src/utils/vertical-scene-migration.test.ts @@ -154,33 +154,38 @@ describe('legacy corpus loads as the editor drew it', () => { expect(legacyCorpus.length).toBeGreaterThan(0) }) - test.each(legacyCorpus)('$scene: storeys keep their height and no wall rises above its storey', ({ - nodes, - }) => { - const migrated = migrateVerticalSceneNodes(nodes).nodes as Record - for (const [levelId, level] of Object.entries(nodes as Record)) { - if (level?.type !== 'level' || 'height' in level) continue - const before = legacyStorey(levelId, nodes as Record) - const plane = migrated[levelId]!.height as number - const rounded = (value: number) => Math.round(value * 1e6) / 1e6 - expect({ levelId, height: rounded(plane) }).toEqual({ - levelId, - height: rounded(before.height), - }) - const slabs = (migrated[levelId]!.children as string[]) - .map((id) => migrated[id]) - .filter((child): child is LegacyNode => child?.type === 'slab') - const walls = (migrated[levelId]!.children as string[]) - .map((id) => migrated[id]) - .filter((child): child is LegacyNode => child?.type === 'wall') - for (const wall of walls) { - const base = computeWallSlabSupport(wall as never, slabs as never, walls as never).elevation - const top = resolveWallTop(wall as never, plane, base) - // Never above the storey it belongs to (it would cut the floor or - // roof above), and moved only by the snap onto the storey plane. - expect(top).toBeLessThanOrEqual(plane + 1e-9) - expect(Math.abs(top - before.wallTops.get(wall.id)!)).toBeLessThan(0.2) + test.each(legacyCorpus)( + '$scene: storeys keep their height and no wall rises above its storey', + ({ nodes }) => { + const migrated = migrateVerticalSceneNodes(nodes).nodes as Record + for (const [levelId, level] of Object.entries(nodes as Record)) { + if (level?.type !== 'level' || 'height' in level) continue + const before = legacyStorey(levelId, nodes as Record) + const plane = migrated[levelId]!.height as number + const rounded = (value: number) => Math.round(value * 1e6) / 1e6 + expect({ levelId, height: rounded(plane) }).toEqual({ + levelId, + height: rounded(before.height), + }) + const slabs = (migrated[levelId]!.children as string[]) + .map((id) => migrated[id]) + .filter((child): child is LegacyNode => child?.type === 'slab') + const walls = (migrated[levelId]!.children as string[]) + .map((id) => migrated[id]) + .filter((child): child is LegacyNode => child?.type === 'wall') + for (const wall of walls) { + const base = computeWallSlabSupport( + wall as never, + slabs as never, + walls as never, + ).elevation + const top = resolveWallTop(wall as never, plane, base) + // Never above the storey it belongs to (it would cut the floor or + // roof above), and moved only by the snap onto the storey plane. + expect(top).toBeLessThanOrEqual(plane + 1e-9) + expect(Math.abs(top - before.wallTops.get(wall.id)!)).toBeLessThan(0.2) + } } - } - }) + }, + ) }) diff --git a/packages/core/src/utils/wall-assembly-migration.test.ts b/packages/core/src/utils/wall-assembly-migration.test.ts index 19cc18a1a9..50db53fb12 100644 --- a/packages/core/src/utils/wall-assembly-migration.test.ts +++ b/packages/core/src/utils/wall-assembly-migration.test.ts @@ -103,34 +103,35 @@ describe('WS5 → F2 wall assembly migration', () => { expect(again.nodes).toBe(migrated.nodes) }) - test.each( - unboundedLegacyStacks, - )('preserves valid WS5 values without upper caps: %s', (_, assembly) => { - expect(WallAssembly.safeParse(assembly).success).toBe(true) - const legacy = { - ...WallNode.parse({ id: 'wall_unbounded', start: [0, 0], end: [4, 0] }), - assembly, - thickness: ws5.assemblyThickness(assembly), - } - const nodes = { [legacy.id]: legacy } - const result = migrateLegacyWallAssemblies(nodes) - const wall = WallNode.parse(result.nodes[legacy.id]) - const stack = wall.assembly! + test.each(unboundedLegacyStacks)( + 'preserves valid WS5 values without upper caps: %s', + (_, assembly) => { + expect(WallAssembly.safeParse(assembly).success).toBe(true) + const legacy = { + ...WallNode.parse({ id: 'wall_unbounded', start: [0, 0], end: [4, 0] }), + assembly, + thickness: ws5.assemblyThickness(assembly), + } + const nodes = { [legacy.id]: legacy } + const result = migrateLegacyWallAssemblies(nodes) + const wall = WallNode.parse(result.nodes[legacy.id]) + const stack = wall.assembly! - expect(result.changed).toBe(true) - expect(nodes[legacy.id]).toBe(legacy) - expect(nodes[legacy.id].assembly).toBe(assembly) - expect(wall.thickness).toBe(legacy.thickness) - expect(assemblyThickness(stack)).toBeCloseTo(legacy.thickness, 12) - expect(stack.presetId).toBe(assembly.preset) - expect(stack.cavityInsulation).toBe(assembly.cavityInsulation) - expect(wallAssemblyToLegacy(stack)).toEqual(assembly) - expect(resolveWallAssembly(wall)).toEqual(ws5Resolved(legacy)) - expect(migrateLegacyWallAssemblies(result.nodes)).toEqual({ - changed: false, - nodes: result.nodes, - }) - }) + expect(result.changed).toBe(true) + expect(nodes[legacy.id]).toBe(legacy) + expect(nodes[legacy.id].assembly).toBe(assembly) + expect(wall.thickness).toBe(legacy.thickness) + expect(assemblyThickness(stack)).toBeCloseTo(legacy.thickness, 12) + expect(stack.presetId).toBe(assembly.preset) + expect(stack.cavityInsulation).toBe(assembly.cavityInsulation) + expect(wallAssemblyToLegacy(stack)).toEqual(assembly) + expect(resolveWallAssembly(wall)).toEqual(ws5Resolved(legacy)) + expect(migrateLegacyWallAssemblies(result.nodes)).toEqual({ + changed: false, + nodes: result.nodes, + }) + }, + ) test('readers see the same stack: layers, sides, finish and preset note', () => { for (const [index, legacy] of legacyWalls.entries()) { diff --git a/packages/editor/src/components/editor/follow-pascal.test.tsx b/packages/editor/src/components/editor/follow-pascal.test.tsx index c732433e18..305532e6d0 100644 --- a/packages/editor/src/components/editor/follow-pascal.test.tsx +++ b/packages/editor/src/components/editor/follow-pascal.test.tsx @@ -153,24 +153,24 @@ async function followItem( test.each([ { fromUpper: false, mode: 'stacked' as const }, { fromUpper: true, mode: 'solo' as const }, -])('following a build on another floor selects it from $mode (fromUpper: $fromUpper)', async ({ - fromUpper, - mode, -}) => { - const { building, target } = await followItem(fromUpper, mode) - expect(useViewer.getState().selection).toMatchObject({ - buildingId: building.id, - levelId: target.id, - }) - expect(useViewer.getState().levelMode).toBe('stacked') - expect(useViewer.getState().hideLevelsAboveSelection).toBe(true) - // The build is over and no turn is running: the person has the floor they had. - useFollowPascal.getState().endBuild() - expect(useViewer.getState().selection).toMatchObject({ - buildingId: building.id, - levelId: fromUpper ? 'level_upper' : 'level_ground', - }) -}) +])( + 'following a build on another floor selects it from $mode (fromUpper: $fromUpper)', + async ({ fromUpper, mode }) => { + const { building, target } = await followItem(fromUpper, mode) + expect(useViewer.getState().selection).toMatchObject({ + buildingId: building.id, + levelId: target.id, + }) + expect(useViewer.getState().levelMode).toBe('stacked') + expect(useViewer.getState().hideLevelsAboveSelection).toBe(true) + // The build is over and no turn is running: the person has the floor they had. + useFollowPascal.getState().endBuild() + expect(useViewer.getState().selection).toMatchObject({ + buildingId: building.id, + levelId: fromUpper ? 'level_upper' : 'level_ground', + }) + }, +) test('an obstacle appearing after arrival causes one calm flight to a clear view', async () => { await followItem() @@ -201,33 +201,33 @@ test('an obstacle appearing after arrival causes one calm flight to a clear view ;(obstacle.material as MeshBasicMaterial).dispose() }) -test.each([ - 'camera-controls:interaction-start', - 'camera-controls:orbit-cw', -] as const)('$0 yields the camera, puts the floor back and does not select it again', async (event) => { - await followItem() - expect(poses.length).toBeGreaterThan(0) - expect(useViewer.getState().selection.levelId).toBe('level_upper') - emitter.emit(event) - const count = poses.length - expect(useFollowPascal.getState().paused).toBe(true) - expect(useViewer.getState().selection.levelId).toBe('level_ground') - await act(async () => { - runAsSceneCommitAuthor('agent', () => - useScene - .getState() - .createNode( - ItemNode.parse({ ...useScene.getState().nodes.item_follow, id: 'item_next' }), - 'level_roof', - ), - ) - driver!.tick(40) - await renderer!.advanceFrames(100, 0.05) - }) - expect(useFollowPascal.getState().paused).toBe(true) - expect(poses.length).toBe(count) - expect(useViewer.getState().selection.levelId).toBe('level_ground') -}) +test.each(['camera-controls:interaction-start', 'camera-controls:orbit-cw'] as const)( + '$0 yields the camera, puts the floor back and does not select it again', + async (event) => { + await followItem() + expect(poses.length).toBeGreaterThan(0) + expect(useViewer.getState().selection.levelId).toBe('level_upper') + emitter.emit(event) + const count = poses.length + expect(useFollowPascal.getState().paused).toBe(true) + expect(useViewer.getState().selection.levelId).toBe('level_ground') + await act(async () => { + runAsSceneCommitAuthor('agent', () => + useScene + .getState() + .createNode( + ItemNode.parse({ ...useScene.getState().nodes.item_follow, id: 'item_next' }), + 'level_roof', + ), + ) + driver!.tick(40) + await renderer!.advanceFrames(100, 0.05) + }) + expect(useFollowPascal.getState().paused).toBe(true) + expect(poses.length).toBe(count) + expect(useViewer.getState().selection.levelId).toBe('level_ground') + }, +) // The owner, 12 October, on 3114: after Follow Pascal stopped the walls looked invisible. It had // fallen back to cutaway when no eye saw its target, selected the floor being built and hidden the @@ -247,26 +247,27 @@ const ENDS: { name: string; end: () => unknown }[] = [ // The person is on the upper floor in the solo display, with the floors above not hidden; the build // is on the ground floor, which is not shown, so the reveal speaks at once. -test.each(ENDS)('when $name, the cutaway, the floor and the hidden floors are put back', async ({ - end, -}) => { - usePointAsk.getState().setBusy(true) - const { building } = await followItem(true, 'solo', { enclosed: true, hideAbove: false }) - expect(useViewer.getState().wallMode).toBe('cutaway') - expect(useViewer.getState().levelMode).toBe('stacked') - expect(useViewer.getState().hideLevelsAboveSelection).toBe(true) - expect(useViewer.getState().selection.levelId).toBe('level_ground') +test.each(ENDS)( + 'when $name, the cutaway, the floor and the hidden floors are put back', + async ({ end }) => { + usePointAsk.getState().setBusy(true) + const { building } = await followItem(true, 'solo', { enclosed: true, hideAbove: false }) + expect(useViewer.getState().wallMode).toBe('cutaway') + expect(useViewer.getState().levelMode).toBe('stacked') + expect(useViewer.getState().hideLevelsAboveSelection).toBe(true) + expect(useViewer.getState().selection.levelId).toBe('level_ground') - await act(async () => end()) + await act(async () => end()) - expect(useViewer.getState().wallMode).toBe('up') - expect(useViewer.getState().levelMode).toBe('solo') - expect(useViewer.getState().hideLevelsAboveSelection).toBe(false) - expect(useViewer.getState().selection).toMatchObject({ - buildingId: building.id, - levelId: 'level_upper', - }) -}) + expect(useViewer.getState().wallMode).toBe('up') + expect(useViewer.getState().levelMode).toBe('solo') + expect(useViewer.getState().hideLevelsAboveSelection).toBe(false) + expect(useViewer.getState().selection).toMatchObject({ + buildingId: building.id, + levelId: 'level_upper', + }) + }, +) test('leaving the editor puts them back', async () => { await followItem(false, 'stacked', { enclosed: true }) diff --git a/packages/editor/src/components/editor/snapshot-capture.test.ts b/packages/editor/src/components/editor/snapshot-capture.test.ts index 4ffe39ea6d..58bc2af618 100644 --- a/packages/editor/src/components/editor/snapshot-capture.test.ts +++ b/packages/editor/src/components/editor/snapshot-capture.test.ts @@ -240,62 +240,61 @@ describe('snapshot request correlation', () => { ]) }) - test.each([ - 'standard', - 'viewport', - 'area', - ] as const)('a manual %s shutter waits for a background upload; redundant autosaves are dropped', async (captureMode) => { - const enqueue = createSnapshotQueue() - const busy = { current: false } - const failures: SnapshotCaptureFailedEvent[] = [] - const events: string[] = [] - let finishUpload!: () => void - const uploading = new Promise((resolve) => { - finishUpload = resolve - }) - const background = enqueue({ requestId: 'background' }, () => - runSnapshotCapture( - 'background', - busy, - async () => { - events.push('background rendered') - await uploading - events.push('background saved') - }, - (failure) => failures.push(failure), - ), - ) - await Promise.resolve() - expect(busy.current).toBe(true) - const autosave = enqueue({}, async () => { - events.push('autosave') - }) - let overlayState = 'capturing' - const manual = enqueue({ captureMode }, () => - runSnapshotCapture( - undefined, - busy, - async () => { - events.push('manual saved') - overlayState = 'saved' - }, - (failure) => failures.push(failure), - ), - ) - await autosave - expect(overlayState).toBe('capturing') - expect(events).toEqual(['background rendered']) - finishUpload() - await Promise.all([background, manual]) - expect(events).toEqual(['background rendered', 'background saved', 'manual saved']) - expect(overlayState).toBe('saved') - expect(failures).toEqual([]) - expect(busy.current).toBe(false) - await enqueue({}, async () => { - events.push('idle autosave') - }) - expect(events.at(-1)).toBe('idle autosave') - }) + test.each(['standard', 'viewport', 'area'] as const)( + 'a manual %s shutter waits for a background upload; redundant autosaves are dropped', + async (captureMode) => { + const enqueue = createSnapshotQueue() + const busy = { current: false } + const failures: SnapshotCaptureFailedEvent[] = [] + const events: string[] = [] + let finishUpload!: () => void + const uploading = new Promise((resolve) => { + finishUpload = resolve + }) + const background = enqueue({ requestId: 'background' }, () => + runSnapshotCapture( + 'background', + busy, + async () => { + events.push('background rendered') + await uploading + events.push('background saved') + }, + (failure) => failures.push(failure), + ), + ) + await Promise.resolve() + expect(busy.current).toBe(true) + const autosave = enqueue({}, async () => { + events.push('autosave') + }) + let overlayState = 'capturing' + const manual = enqueue({ captureMode }, () => + runSnapshotCapture( + undefined, + busy, + async () => { + events.push('manual saved') + overlayState = 'saved' + }, + (failure) => failures.push(failure), + ), + ) + await autosave + expect(overlayState).toBe('capturing') + expect(events).toEqual(['background rendered']) + finishUpload() + await Promise.all([background, manual]) + expect(events).toEqual(['background rendered', 'background saved', 'manual saved']) + expect(overlayState).toBe('saved') + expect(failures).toEqual([]) + expect(busy.current).toBe(false) + await enqueue({}, async () => { + events.push('idle autosave') + }) + expect(events.at(-1)).toBe('idle autosave') + }, + ) test('queues a frame dispatched by a save callback until the previous capture releases its lock', async () => { const enqueue = createSnapshotQueue() diff --git a/packages/editor/src/components/tools/item/item-surface-strategy-resolver.test.ts b/packages/editor/src/components/tools/item/item-surface-strategy-resolver.test.ts index a12abb25a0..f144ac65c6 100644 --- a/packages/editor/src/components/tools/item/item-surface-strategy-resolver.test.ts +++ b/packages/editor/src/components/tools/item/item-surface-strategy-resolver.test.ts @@ -104,75 +104,74 @@ function onHost(ctx: PlacementContext): PlacementContext { test.each([ [4.01, 1, 1.5], [4, 1, 1.51], -] as [ - number, - number, - number, -][])('enter and move accept oversized objects with their centres on the host: %j', (width, height, depth) => { - const draft = ItemNode.parse({ - ...child, - asset: { ...asset, dimensions: [width, height, depth] }, - }) - const ctx = context(draft) - const event = hit() - const before = useScene.getState().nodes - expect(itemSurfaceStrategy.enter(ctx, event)).not.toBeNull() - const expected: { - position: [number, number, number] - worldPosition: [number, number, number] - } = - width === 4.01 - ? { - position: [0.5049999999999999, 0.8099999999999996, -0.25], - worldPosition: [4.434656461734313, 2.8017166019018256, -3.927442316539199], - } - : { - position: [0.5, 0.8099999999999996, -0.245], - worldPosition: [4.4318465044173845, 2.8017248933478855, -3.9175249544338326], - } - const moved = itemSurfaceStrategy.move(onHost(ctx), event)! - expect(moved.nodeUpdate).toEqual({ position: expected.position }) - expect(moved.cursorPosition).toEqual(expected.worldPosition) - expect(moved.cursorRotationY).toBe(ctx.currentCursorRotationY) - expect(resolver.mock.calls.map(([args]) => args.checkFootprint)).toEqual([true, true]) - expect(useScene.getState().nodes).toBe(before) -}) +] as [number, number, number][])( + 'enter and move accept oversized objects with their centres on the host: %j', + (width, height, depth) => { + const draft = ItemNode.parse({ + ...child, + asset: { ...asset, dimensions: [width, height, depth] }, + }) + const ctx = context(draft) + const event = hit() + const before = useScene.getState().nodes + expect(itemSurfaceStrategy.enter(ctx, event)).not.toBeNull() + const expected: { + position: [number, number, number] + worldPosition: [number, number, number] + } = + width === 4.01 + ? { + position: [0.5049999999999999, 0.8099999999999996, -0.25], + worldPosition: [4.434656461734313, 2.8017166019018256, -3.927442316539199], + } + : { + position: [0.5, 0.8099999999999996, -0.245], + worldPosition: [4.4318465044173845, 2.8017248933478855, -3.9175249544338326], + } + const moved = itemSurfaceStrategy.move(onHost(ctx), event)! + expect(moved.nodeUpdate).toEqual({ position: expected.position }) + expect(moved.cursorPosition).toEqual(expected.worldPosition) + expect(moved.cursorRotationY).toBe(ctx.currentCursorRotationY) + expect(resolver.mock.calls.map(([args]) => args.checkFootprint)).toEqual([true, true]) + expect(useScene.getState().nodes).toBe(before) + }, +) -test.each([ - 'grid', - 'off', -] as const)('accepted poses match the frozen live capture with snapping %s', (mode) => { - useEditor.getState().setSnappingMode('item', mode) - const ctx = context() - const event = hit() - const expected: { - position: [number, number, number] - worldPosition: [number, number, number] - rotationY: number - } = { - ...(mode === 'grid' - ? { - position: [0.25, 0.8099999999999996, -0.25], - worldPosition: [4.180930399588417, 2.808503845962115, -3.4850883647019826], - } - : { - position: [0.36999999999999966, 0.8099999999999996, -0.39000000000000146], - worldPosition: [4.239709121156868, 2.808804018339912, -3.728080076057168], - }), - rotationY: -0.84936351290154, - } - const entered = itemSurfaceStrategy.enter(ctx, event)! - expect(entered.nodeUpdate).toEqual({ - position: expected.position, - parentId: host.id, - rotation: [0.1, expected.rotationY, 0.3], - }) - expect(entered.cursorPosition).toEqual(expected.worldPosition) - expect(itemSurfaceStrategy.move(onHost(ctx), event)!.nodeUpdate).toEqual({ - position: expected.position, - }) - expect(resolver).toHaveBeenCalledTimes(2) -}) +test.each(['grid', 'off'] as const)( + 'accepted poses match the frozen live capture with snapping %s', + (mode) => { + useEditor.getState().setSnappingMode('item', mode) + const ctx = context() + const event = hit() + const expected: { + position: [number, number, number] + worldPosition: [number, number, number] + rotationY: number + } = { + ...(mode === 'grid' + ? { + position: [0.25, 0.8099999999999996, -0.25], + worldPosition: [4.180930399588417, 2.808503845962115, -3.4850883647019826], + } + : { + position: [0.36999999999999966, 0.8099999999999996, -0.39000000000000146], + worldPosition: [4.239709121156868, 2.808804018339912, -3.728080076057168], + }), + rotationY: -0.84936351290154, + } + const entered = itemSurfaceStrategy.enter(ctx, event)! + expect(entered.nodeUpdate).toEqual({ + position: expected.position, + parentId: host.id, + rotation: [0.1, expected.rotationY, 0.3], + }) + expect(entered.cursorPosition).toEqual(expected.worldPosition) + expect(itemSurfaceStrategy.move(onHost(ctx), event)!.nodeUpdate).toEqual({ + position: expected.position, + }) + expect(resolver).toHaveBeenCalledTimes(2) + }, +) test('resolver rejection leaves both enter and move drafts alone', () => { resolver.mockReturnValue(null) diff --git a/packages/editor/src/components/tools/item/shelf-surface-strategy-resolver.test.ts b/packages/editor/src/components/tools/item/shelf-surface-strategy-resolver.test.ts index b3c6f2f1f1..0f85759987 100644 --- a/packages/editor/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +++ b/packages/editor/src/components/tools/item/shelf-surface-strategy-resolver.test.ts @@ -117,39 +117,39 @@ function hit(y = 1, z = -0.1): ShelfEvent { } as ShelfEvent } -test.each([ - 'grid', - 'off', -] as const)('midpoint keeps the first declared row and preserves X/Z with grid %s', (mode) => { - useEditor.getState().setSnappingMode('item', mode) - for (const declared of [ - [0.5, 1.5], - [1.5, 0.5], - ]) { - rows = declared - const expected: [number, number, number] = - mode === 'grid' ? [0.25, rows[0]!, 0.125] : [0.37, rows[0]!, -0.1] - const ctx = context() - const entered = shelfSurfaceStrategy.enter(ctx, hit())! - expect(entered.nodeUpdate).toEqual({ - position: expected, - parentId: host.id, - rotation: [0.1, 0.7, 0.3], - }) - expect(entered.gridPosition).toEqual(expected) - expect(entered.cursorPosition).toEqual(expected) - const moved = shelfSurfaceStrategy.move(onShelf(ctx), hit())! - expect(moved.nodeUpdate).toEqual({ position: expected }) - expect(moved.cursorPosition).toEqual(expected) - const committed = shelfSurfaceStrategy.click( - { ...onShelf(ctx), gridPosition: new Vector3(...entered.gridPosition) }, - hit(), - )! - expect(committed.nodeUpdate.position).toEqual(expected) - expect(committed.nodeUpdate.parentId).toBe(host.id) - } - expect(resolver).toHaveBeenCalledTimes(6) -}) +test.each(['grid', 'off'] as const)( + 'midpoint keeps the first declared row and preserves X/Z with grid %s', + (mode) => { + useEditor.getState().setSnappingMode('item', mode) + for (const declared of [ + [0.5, 1.5], + [1.5, 0.5], + ]) { + rows = declared + const expected: [number, number, number] = + mode === 'grid' ? [0.25, rows[0]!, 0.125] : [0.37, rows[0]!, -0.1] + const ctx = context() + const entered = shelfSurfaceStrategy.enter(ctx, hit())! + expect(entered.nodeUpdate).toEqual({ + position: expected, + parentId: host.id, + rotation: [0.1, 0.7, 0.3], + }) + expect(entered.gridPosition).toEqual(expected) + expect(entered.cursorPosition).toEqual(expected) + const moved = shelfSurfaceStrategy.move(onShelf(ctx), hit())! + expect(moved.nodeUpdate).toEqual({ position: expected }) + expect(moved.cursorPosition).toEqual(expected) + const committed = shelfSurfaceStrategy.click( + { ...onShelf(ctx), gridPosition: new Vector3(...entered.gridPosition) }, + hit(), + )! + expect(committed.nodeUpdate.position).toEqual(expected) + expect(committed.nodeUpdate.parentId).toBe(host.id) + } + expect(resolver).toHaveBeenCalledTimes(6) + }, +) test.each([ [0.999, 0.5], @@ -182,29 +182,29 @@ test('a full-board object is refused when grid snapping puts its centre outside expect(shelfSurfaceStrategy.enter(context(draft), hit(1, -0.39))).toBeNull() }) -test.each([ - 'grid', - 'off', -] as const)('shelf frame conversion retains exact poses with grid %s', (mode) => { - useEditor.getState().setSnappingMode('item', mode) - const mesh = sceneRegistry.nodes.get(host.id)! - mesh.position.set(4, 2, -3) - mesh.rotation.set(0, Math.PI / 3, 0) - mesh.updateMatrixWorld(true) - const event = hit(1.2) - const local = mesh.worldToLocal(new Vector3(...event.position)) - const expected: [number, number, number] = - mode === 'grid' ? [0.25, 1.5, 0.125] : [local.x, 1.5, local.z] - const yaw = new Euler().setFromQuaternion(mesh.getWorldQuaternion(new Quaternion()), 'YXZ').y - const entered = shelfSurfaceStrategy.enter(context(), event)! - expect(entered.nodeUpdate).toEqual({ - position: expected, - parentId: host.id, - rotation: [0.1, 0.7 - yaw, 0.3], - }) - expect(entered.cursorPosition).toEqual(mesh.localToWorld(new Vector3(...expected)).toArray()) - expect(shelfSurfaceStrategy.move(onShelf(context()), event)!.gridPosition).toEqual(expected) -}) +test.each(['grid', 'off'] as const)( + 'shelf frame conversion retains exact poses with grid %s', + (mode) => { + useEditor.getState().setSnappingMode('item', mode) + const mesh = sceneRegistry.nodes.get(host.id)! + mesh.position.set(4, 2, -3) + mesh.rotation.set(0, Math.PI / 3, 0) + mesh.updateMatrixWorld(true) + const event = hit(1.2) + const local = mesh.worldToLocal(new Vector3(...event.position)) + const expected: [number, number, number] = + mode === 'grid' ? [0.25, 1.5, 0.125] : [local.x, 1.5, local.z] + const yaw = new Euler().setFromQuaternion(mesh.getWorldQuaternion(new Quaternion()), 'YXZ').y + const entered = shelfSurfaceStrategy.enter(context(), event)! + expect(entered.nodeUpdate).toEqual({ + position: expected, + parentId: host.id, + rotation: [0.1, 0.7 - yaw, 0.3], + }) + expect(entered.cursorPosition).toEqual(mesh.localToWorld(new Vector3(...expected)).toArray()) + expect(shelfSurfaceStrategy.move(onShelf(context()), event)!.gridPosition).toEqual(expected) + }, +) test('only enter requires an upward normal; moves keep electing rows', () => { for (const normal of [[1, 0, 0], [0, -1, 0], undefined] as ShelfEvent['normal'][]) { diff --git a/packages/editor/src/components/tools/item/use-draft-node.test.tsx b/packages/editor/src/components/tools/item/use-draft-node.test.tsx index f364c8c97c..0f2cc083ac 100644 --- a/packages/editor/src/components/tools/item/use-draft-node.test.tsx +++ b/packages/editor/src/components/tools/item/use-draft-node.test.tsx @@ -89,105 +89,105 @@ describe('useDraftNode block face commit', () => { finish, })), ), - )('an adopted $failure rejection retains owned history through $finish', ({ - failure, - finish, - }) => { - const item = ItemNode.parse({ - parentId: LEVEL_ID, - position: [1, 0, 1], - asset: { id: 'box', name: 'Box', category: 'decor', thumbnail: '', src: '/box.glb' }, - }) - useScene.getState().createNode(item, LEVEL_ID as AnyNodeId) - useScene.temporal.getState().clear() - const originalNodes = structuredClone(useScene.getState().nodes) - const draft = draftNode! - draft.adopt(item) - draft.updateSurface({ position: [2, 0, 2] }, null) - const commit = (position: ItemNode['position']) => { - const drop = beginSceneHistoryPauseSession(useScene, { gesture: item.id }) - try { - return drop.commitStep(() => draft.commit({ parentId: LEVEL_ID, position })) - } finally { - drop.end() - } - } - const updateNodes = useScene.getState().updateNodes - try { - if (failure === 'write') { - useScene.setState({ - updateNodes: (updates) => { - if ( - useScene.temporal.getState().isTracking && - updates.some( - (update) => - update.id === item.id && - 'position' in update.data && - update.data.position?.[0] === 3, - ) - ) { - throw new Error('Rejected tracked write before publication') - } - return updateNodes(updates) - }, - }) - } - expect(() => commit(failure === 'parse' ? [Number.NaN, 0, 3] : [3, 0, 3])).toThrow() - useScene.setState({ updateNodes }) - expect(draft.current?.id).toBe(item.id) - expect(useScene.getState().nodes).toEqual(originalNodes) - expect(useScene.temporal.getState().pastStates).toHaveLength(0) - expect(useScene.temporal.getState().isTracking).toBe(true) - expect(getSceneHistoryPauseDepth()).toBe(0) - - draft.updateSurface({ position: [4, 0, 4] }, null) - useScene.getState().updateNode(item.id, { name: 'Foreign rename' }) - expect(useScene.temporal.getState().pastStates).toHaveLength(1) - expect(useScene.temporal.getState().pastStates[0]?.nodes).toEqual(originalNodes) - const renamedNodes = { - ...originalNodes, - [item.id]: { ...originalNodes[item.id]!, name: 'Foreign rename' }, + )( + 'an adopted $failure rejection retains owned history through $finish', + ({ failure, finish }) => { + const item = ItemNode.parse({ + parentId: LEVEL_ID, + position: [1, 0, 1], + asset: { id: 'box', name: 'Box', category: 'decor', thumbnail: '', src: '/box.glb' }, + }) + useScene.getState().createNode(item, LEVEL_ID as AnyNodeId) + useScene.temporal.getState().clear() + const originalNodes = structuredClone(useScene.getState().nodes) + const draft = draftNode! + draft.adopt(item) + draft.updateSurface({ position: [2, 0, 2] }, null) + const commit = (position: ItemNode['position']) => { + const drop = beginSceneHistoryPauseSession(useScene, { gesture: item.id }) + try { + return drop.commitStep(() => draft.commit({ parentId: LEVEL_ID, position })) + } finally { + drop.end() + } } + const updateNodes = useScene.getState().updateNodes + try { + if (failure === 'write') { + useScene.setState({ + updateNodes: (updates) => { + if ( + useScene.temporal.getState().isTracking && + updates.some( + (update) => + update.id === item.id && + 'position' in update.data && + update.data.position?.[0] === 3, + ) + ) { + throw new Error('Rejected tracked write before publication') + } + return updateNodes(updates) + }, + }) + } + expect(() => commit(failure === 'parse' ? [Number.NaN, 0, 3] : [3, 0, 3])).toThrow() + useScene.setState({ updateNodes }) + expect(draft.current?.id).toBe(item.id) + expect(useScene.getState().nodes).toEqual(originalNodes) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + expect(useScene.temporal.getState().isTracking).toBe(true) + expect(getSceneHistoryPauseDepth()).toBe(0) - if (finish === 'retry') { - expect(commit([5, 0, 5])).toBe(item.id) - expect(draft.current).toBeNull() - const committedNodes = structuredClone(useScene.getState().nodes) - expect(committedNodes[item.id]).toMatchObject({ - name: 'Foreign rename', - position: [5, 0, 5], - }) - expect(useScene.temporal.getState().pastStates).toHaveLength(2) - useScene.temporal.getState().undo() - expect(useScene.getState().nodes).toEqual(renamedNodes) - useScene.temporal.getState().redo() - expect(useScene.getState().nodes).toEqual(committedNodes) - } else if (finish === 'cancel') { - draft.destroy() - expect(useScene.getState().nodes).toEqual(renamedNodes) + draft.updateSurface({ position: [4, 0, 4] }, null) + useScene.getState().updateNode(item.id, { name: 'Foreign rename' }) expect(useScene.temporal.getState().pastStates).toHaveLength(1) - useScene.temporal.getState().undo() - expect(useScene.getState().nodes).toEqual(originalNodes) - useScene.temporal.getState().redo() - expect(useScene.getState().nodes).toEqual(renamedNodes) - } else { - useScene.getState().updateNode(item.id, { position: [7, 0, 7] }) - const foreignNodes = structuredClone(useScene.getState().nodes) + expect(useScene.temporal.getState().pastStates[0]?.nodes).toEqual(originalNodes) + const renamedNodes = { + ...originalNodes, + [item.id]: { ...originalNodes[item.id]!, name: 'Foreign rename' }, + } + + if (finish === 'retry') { + expect(commit([5, 0, 5])).toBe(item.id) + expect(draft.current).toBeNull() + const committedNodes = structuredClone(useScene.getState().nodes) + expect(committedNodes[item.id]).toMatchObject({ + name: 'Foreign rename', + position: [5, 0, 5], + }) + expect(useScene.temporal.getState().pastStates).toHaveLength(2) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes).toEqual(renamedNodes) + useScene.temporal.getState().redo() + expect(useScene.getState().nodes).toEqual(committedNodes) + } else if (finish === 'cancel') { + draft.destroy() + expect(useScene.getState().nodes).toEqual(renamedNodes) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes).toEqual(originalNodes) + useScene.temporal.getState().redo() + expect(useScene.getState().nodes).toEqual(renamedNodes) + } else { + useScene.getState().updateNode(item.id, { position: [7, 0, 7] }) + const foreignNodes = structuredClone(useScene.getState().nodes) + draft.destroy() + expect(useScene.getState().nodes).toEqual(foreignNodes) + expect(useScene.temporal.getState().pastStates).toHaveLength(2) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes).toEqual(renamedNodes) + useScene.temporal.getState().redo() + expect(useScene.getState().nodes).toEqual(foreignNodes) + } + expect(useScene.temporal.getState().isTracking).toBe(true) + expect(getSceneHistoryPauseDepth()).toBe(0) + } finally { + useScene.setState({ updateNodes }) draft.destroy() - expect(useScene.getState().nodes).toEqual(foreignNodes) - expect(useScene.temporal.getState().pastStates).toHaveLength(2) - useScene.temporal.getState().undo() - expect(useScene.getState().nodes).toEqual(renamedNodes) - useScene.temporal.getState().redo() - expect(useScene.getState().nodes).toEqual(foreignNodes) } - expect(useScene.temporal.getState().isTracking).toBe(true) - expect(getSceneHistoryPauseDepth()).toBe(0) - } finally { - useScene.setState({ updateNodes }) - draft.destroy() - } - }) + }, + ) test('a drop on an undone host waits for a new valid placement target', () => { const item = ItemNode.parse({ diff --git a/packages/editor/src/components/tools/registry/item-surface-move.test.ts b/packages/editor/src/components/tools/registry/item-surface-move.test.ts index b2687bfea3..06c080759e 100644 --- a/packages/editor/src/components/tools/registry/item-surface-move.test.ts +++ b/packages/editor/src/components/tools/registry/item-surface-move.test.ts @@ -443,67 +443,66 @@ describe('registry item-surface move session', () => { expect((useScene.getState().nodes[host.id] as ItemNode).children as string[]).toEqual([]) }) - test.each([ - 'floor', - 'same host', - 'floor then host', - ])('a hosted move to %s commits once and undo restores the original parent and pose', (destination) => { - const initial = createRegistryItemSurfaceMove(original)! - const hostedPose = initial.enter(eventFor().event, dimensions, 0.3)! - const hosted = useScene.getState().nodes[original.id]! - const session = createRegistryItemSurfaceMove(hosted)! - let pose: { position: [number, number, number]; rotationY: number } = session.enter( - eventFor().event, - dimensions, - hostedPose.rotationY, - )! - if (destination !== 'same host') { - const world = sceneRegistry.nodes - .get('level_surface-move')! - .localToWorld(new Vector3(8, 0, 9)) - pose = session.detach(world.toArray(), pose.rotationY)! - const detached = useScene.getState().nodes[original.id]! - expect( - getFloorPlacedElevation({ - node: detached, - nodes: useScene.getState().nodes, - position: pose.position, - maxElevation: 0, - }), - ).toBe(0) - const footprints = getFloorPlacedFootprints( - nodeRegistry.get(original.type)!.capabilities.floorPlaced!, - detached, - { nodes: useScene.getState().nodes }, - ).map((footprint) => ({ ...footprint, position: footprint.position ?? pose.position })) - expect( - spatialGridManager.canPlaceOnFloorFootprints('level_surface-move', footprints, [ - original.id, - ]).valid, - ).toBe(true) - } - if (destination !== 'floor') { - pose = session.enter(eventFor(host, [0.6, 1, -0.4]).event, dimensions, pose.rotationY)! - } - const final = useScene.getState().nodes[original.id]! - const patch = { - ...final, - ...resolveSupportSlabPatch(final, useScene.getState().nodes, { maxElevation: 0 }), - } - session.restore() - useScene.temporal.getState().resume() - useScene.getState().updateNodes([{ id: original.id, data: patch }]) - useScene.temporal.getState().pause() - expect(useScene.getState().nodes[original.id]?.parentId).toBe( - destination === 'floor' ? 'level_surface-move' : host.id, - ) - expect(useScene.temporal.getState().pastStates).toHaveLength(1) - useScene.temporal.getState().undo() - expect(useScene.getState().nodes[original.id]).toEqual(hosted) - expect((useScene.getState().nodes[host.id] as ItemNode).children as string[]).toContain( - original.id, - ) - useScene.temporal.getState().redo() - expect(useScene.getState().nodes[original.id]).toEqual(patch) - }) + test.each(['floor', 'same host', 'floor then host'])( + 'a hosted move to %s commits once and undo restores the original parent and pose', + (destination) => { + const initial = createRegistryItemSurfaceMove(original)! + const hostedPose = initial.enter(eventFor().event, dimensions, 0.3)! + const hosted = useScene.getState().nodes[original.id]! + const session = createRegistryItemSurfaceMove(hosted)! + let pose: { position: [number, number, number]; rotationY: number } = session.enter( + eventFor().event, + dimensions, + hostedPose.rotationY, + )! + if (destination !== 'same host') { + const world = sceneRegistry.nodes + .get('level_surface-move')! + .localToWorld(new Vector3(8, 0, 9)) + pose = session.detach(world.toArray(), pose.rotationY)! + const detached = useScene.getState().nodes[original.id]! + expect( + getFloorPlacedElevation({ + node: detached, + nodes: useScene.getState().nodes, + position: pose.position, + maxElevation: 0, + }), + ).toBe(0) + const footprints = getFloorPlacedFootprints( + nodeRegistry.get(original.type)!.capabilities.floorPlaced!, + detached, + { nodes: useScene.getState().nodes }, + ).map((footprint) => ({ ...footprint, position: footprint.position ?? pose.position })) + expect( + spatialGridManager.canPlaceOnFloorFootprints('level_surface-move', footprints, [ + original.id, + ]).valid, + ).toBe(true) + } + if (destination !== 'floor') { + pose = session.enter(eventFor(host, [0.6, 1, -0.4]).event, dimensions, pose.rotationY)! + } + const final = useScene.getState().nodes[original.id]! + const patch = { + ...final, + ...resolveSupportSlabPatch(final, useScene.getState().nodes, { maxElevation: 0 }), + } + session.restore() + useScene.temporal.getState().resume() + useScene.getState().updateNodes([{ id: original.id, data: patch }]) + useScene.temporal.getState().pause() + expect(useScene.getState().nodes[original.id]?.parentId).toBe( + destination === 'floor' ? 'level_surface-move' : host.id, + ) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes[original.id]).toEqual(hosted) + expect((useScene.getState().nodes[host.id] as ItemNode).children as string[]).toContain( + original.id, + ) + useScene.temporal.getState().redo() + expect(useScene.getState().nodes[original.id]).toEqual(patch) + }, + ) }) diff --git a/packages/editor/src/components/tools/registry/item-surface-resolver.test.ts b/packages/editor/src/components/tools/registry/item-surface-resolver.test.ts index c36269aebf..dee08fd79c 100644 --- a/packages/editor/src/components/tools/registry/item-surface-resolver.test.ts +++ b/packages/editor/src/components/tools/registry/item-surface-resolver.test.ts @@ -219,32 +219,32 @@ test('the mover rejects an indirect descendant before asking the resolver', () = expect(useScene.getState().nodes).toBe(before) }) -test.each([ - 'host-local', - 'surface-local', -] as const)('writes the resolver pose in its %s child frame', (childFrame) => { - const placement: SurfacePlacement = { - position: [0.25, 1.5, -0.75], - rotationY: 0.4, - surfaceId: 'test-surface', - childFrame, - surfaceLocal: { position: [-0.5, 0, 0.25], rotationY: -0.2, rotation: [0.7, -0.2, 0.8] }, - } - resolver.mockReturnValue(placement) - const session = createRegistryItemSurfaceMove(child)! - const pose = session.enter(hit(), dimensions, 0.3)! - const expected = childFrame === 'surface-local' ? placement.surfaceLocal! : placement - expect(pose.position).toEqual([...expected.position]) - expect(pose.rotationY).toBe(expected.rotationY) - expect(useScene.getState().nodes[child.id]).toMatchObject({ - parentId: host.id, - position: expected.position, - rotation: - childFrame === 'surface-local' - ? placement.surfaceLocal!.rotation - : [0.1, placement.rotationY, 0.2], - }) -}) +test.each(['host-local', 'surface-local'] as const)( + 'writes the resolver pose in its %s child frame', + (childFrame) => { + const placement: SurfacePlacement = { + position: [0.25, 1.5, -0.75], + rotationY: 0.4, + surfaceId: 'test-surface', + childFrame, + surfaceLocal: { position: [-0.5, 0, 0.25], rotationY: -0.2, rotation: [0.7, -0.2, 0.8] }, + } + resolver.mockReturnValue(placement) + const session = createRegistryItemSurfaceMove(child)! + const pose = session.enter(hit(), dimensions, 0.3)! + const expected = childFrame === 'surface-local' ? placement.surfaceLocal! : placement + expect(pose.position).toEqual([...expected.position]) + expect(pose.rotationY).toBe(expected.rotationY) + expect(useScene.getState().nodes[child.id]).toMatchObject({ + parentId: host.id, + position: expected.position, + rotation: + childFrame === 'surface-local' + ? placement.surfaceLocal!.rotation + : [0.1, placement.rotationY, 0.2], + }) + }, +) test('non-item events never reach the resolver', () => { const session = createRegistryItemSurfaceMove(child)! diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index 225d3b727d..b5129b2c97 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -726,63 +726,64 @@ describe('createWallOnCurrentLevel', () => { expect(useScene.temporal.getState().pastStates.length - before).toBe(1) }) - test.each([ - 0.05, 0.18, - ])('a room divider preserves the footprint floor at %s and splits ceilings', (elevation) => { - const walls = [ - makeWall([0, 0], [8, 0], 'wall_bottom'), - makeWall([8, 0], [8, 6], 'wall_right'), - makeWall([8, 6], [0, 6], 'wall_top'), - makeWall([0, 6], [0, 0], 'wall_left'), - ] - const slab = SlabNode.parse({ - id: 'slab_auto', - parentId: LEVEL_ID, - polygon: [ - [0, 0], - [8, 0], - [8, 6], - [0, 6], - ], - elevation, - floorHeight: elevation, - thickness: 0.32, - autoFromWalls: true, - }) - const ceiling = CeilingNode.parse({ - id: 'ceiling_auto', - parentId: LEVEL_ID, - polygon: slab.polygon, - autoFromWalls: true, - }) - seedLevel(walls, [slab, ceiling]) - useScene.setState({ - nodes: migrateFloorPlates( - migrateCeilingRoomLinks(migrateRoomZones(useScene.getState().nodes).nodes).nodes, - ).nodes as Record, - }) - const stopDetection = initSpaceDetectionSync(useScene, useEditor) - - try { - expect(createWallOnCurrentLevel([4, 0], [4, 6])).not.toBeNull() - - const nodes = Object.values(useScene.getState().nodes) - const slabs = nodes.filter((node) => node.type === 'slab') - const ceilings = nodes.filter((node) => node.type === 'ceiling') - expect(slabs).toHaveLength(1) - expect(slabs[0]).toMatchObject({ plateRole: 'base', floorHeight: elevation }) - expect(slabs[0]!.zoneIds).toHaveLength(2) - expect(ceilings).toHaveLength(2) - expect( - slabs.every( - (node) => node.autoFromWalls && node.elevation === elevation && node.thickness === 0.32, - ), - ).toBe(true) - expect(ceilings.every((node) => node.autoFromWalls)).toBe(true) - } finally { - stopDetection() - } - }) + test.each([0.05, 0.18])( + 'a room divider preserves the footprint floor at %s and splits ceilings', + (elevation) => { + const walls = [ + makeWall([0, 0], [8, 0], 'wall_bottom'), + makeWall([8, 0], [8, 6], 'wall_right'), + makeWall([8, 6], [0, 6], 'wall_top'), + makeWall([0, 6], [0, 0], 'wall_left'), + ] + const slab = SlabNode.parse({ + id: 'slab_auto', + parentId: LEVEL_ID, + polygon: [ + [0, 0], + [8, 0], + [8, 6], + [0, 6], + ], + elevation, + floorHeight: elevation, + thickness: 0.32, + autoFromWalls: true, + }) + const ceiling = CeilingNode.parse({ + id: 'ceiling_auto', + parentId: LEVEL_ID, + polygon: slab.polygon, + autoFromWalls: true, + }) + seedLevel(walls, [slab, ceiling]) + useScene.setState({ + nodes: migrateFloorPlates( + migrateCeilingRoomLinks(migrateRoomZones(useScene.getState().nodes).nodes).nodes, + ).nodes as Record, + }) + const stopDetection = initSpaceDetectionSync(useScene, useEditor) + + try { + expect(createWallOnCurrentLevel([4, 0], [4, 6])).not.toBeNull() + + const nodes = Object.values(useScene.getState().nodes) + const slabs = nodes.filter((node) => node.type === 'slab') + const ceilings = nodes.filter((node) => node.type === 'ceiling') + expect(slabs).toHaveLength(1) + expect(slabs[0]).toMatchObject({ plateRole: 'base', floorHeight: elevation }) + expect(slabs[0]!.zoneIds).toHaveLength(2) + expect(ceilings).toHaveLength(2) + expect( + slabs.every( + (node) => node.autoFromWalls && node.elevation === elevation && node.thickness === 0.32, + ), + ).toBe(true) + expect(ceilings.every((node) => node.autoFromWalls)).toBe(true) + } finally { + stopDetection() + } + }, + ) test('close crossings reject the whole insertion without mutating the scene', () => { seedLevel([ diff --git a/packages/editor/src/hooks/use-auto-save.integration.test.tsx b/packages/editor/src/hooks/use-auto-save.integration.test.tsx index 2087235274..8ed52ce0a8 100644 --- a/packages/editor/src/hooks/use-auto-save.integration.test.tsx +++ b/packages/editor/src/hooks/use-auto-save.integration.test.tsx @@ -196,30 +196,29 @@ test('a save that came due mid-load is written once the load completes', async ( expect(statuses.at(-1)).toBe('saved') }) -test.each([ - 'undo', - 'redo', - 'delete', -] as const)('autosave permits a 5 to 4 single-node %s', async (action) => { - const { writes, statuses } = await mount(5) - if (action === 'undo') { - useScene.setState(graph(4)) - clearSceneHistory() - useScene.setState(graph(5)) - useScene.temporal.getState().undo() - } else if (action === 'redo') { - useScene.getState().deleteNode('column_autosave_1') - useScene.temporal.getState().undo() - useScene.temporal.getState().redo() - } else { - useScene.getState().deleteNode('column_autosave_1') - } - expect(Object.keys(useScene.getState().nodes)).toHaveLength(4) - await Bun.sleep(1100) - expect(statuses).not.toContain('error') - expect(writes).toEqual([4]) - expect(statuses.at(-1)).toBe('saved') -}) +test.each(['undo', 'redo', 'delete'] as const)( + 'autosave permits a 5 to 4 single-node %s', + async (action) => { + const { writes, statuses } = await mount(5) + if (action === 'undo') { + useScene.setState(graph(4)) + clearSceneHistory() + useScene.setState(graph(5)) + useScene.temporal.getState().undo() + } else if (action === 'redo') { + useScene.getState().deleteNode('column_autosave_1') + useScene.temporal.getState().undo() + useScene.temporal.getState().redo() + } else { + useScene.getState().deleteNode('column_autosave_1') + } + expect(Object.keys(useScene.getState().nodes)).toHaveLength(4) + await Bun.sleep(1100) + expect(statuses).not.toContain('error') + expect(writes).toEqual([4]) + expect(statuses.at(-1)).toBe('saved') + }, +) test('autosave permits a single-node deletion on exit', async () => { const { writes, statuses } = await mount(5) @@ -256,20 +255,20 @@ test('collaboration scaffold deletion waits for persistence and reports saved', expect(statuses).toEqual(['pending', 'saving', 'saved']) }) -test.each([ - 'pagehide', - 'unmount', -])('collaboration scaffold deletion still flushes on %s', async (event) => { - const { writes, statuses } = await mount(78, false) - useScene.setState(graph(4)) - if (event === 'pagehide') window.dispatchEvent(new Event(event)) - else { - await renderer!.unmount() - renderer = undefined - } - expect(statuses).not.toContain('error') - expect(writes).toEqual([4]) -}) +test.each(['pagehide', 'unmount'])( + 'collaboration scaffold deletion still flushes on %s', + async (event) => { + const { writes, statuses } = await mount(78, false) + useScene.setState(graph(4)) + if (event === 'pagehide') window.dispatchEvent(new Event(event)) + else { + await renderer!.unmount() + renderer = undefined + } + expect(statuses).not.toContain('error') + expect(writes).toEqual([4]) + }, +) test('collaboration guard exemption preserves preview pause and resume', async () => { const { writes, statuses, update } = await mount(78, false) diff --git a/packages/editor/src/hooks/use-ceiling-events.test.ts b/packages/editor/src/hooks/use-ceiling-events.test.ts index 2b5554f0de..d90a22fe4b 100644 --- a/packages/editor/src/hooks/use-ceiling-events.test.ts +++ b/packages/editor/src/hooks/use-ceiling-events.test.ts @@ -22,124 +22,129 @@ import useEditor from '../store/use-editor' import useInteractionScope from '../store/use-interaction-scope' import { useCeilingEvents } from './use-ceiling-events' -test.each([ - 'item', - 'procedural-item', -])('%s ceiling placement keeps move and commit hits while an unhovered ceiling is batched', async (kind) => { - const previousViewer = useViewer.getState() - const previousEditor = useEditor.getState() - const previousInteraction = useInteractionScope.getState() - const previousScene = useScene.getState() - const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT - actGlobal.IS_REACT_ACT_ENVIRONMENT = true - const canvas = Object.assign(new EventTarget(), { - getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), - }) as unknown as HTMLCanvasElement - const root = createRoot(canvas) - const camera = new PerspectiveCamera(60, 1, 0.1, 100) - camera.position.set(0, 1, 5) - camera.lookAt(0, 3, 0) - camera.updateMatrixWorld() - const level = LevelNode.parse({ id: 'level_ceiling_batch' }) - const ceiling = CeilingNode.parse({ - id: 'ceiling_placement_batch', - parentId: level.id, - polygon: [ - [-5, -5], - [5, -5], - [5, 5], - [-5, 5], - ], - }) - const surface = new Mesh(new PlaneGeometry(10, 10).rotateX(-Math.PI / 2), new MeshBasicMaterial()) - surface.position.y = 3 - surface.updateMatrixWorld(true) - const moves: CeilingEvent[] = [] - const clicks: CeilingEvent[] = [] - const onMove = (event: CeilingEvent) => moves.push(event) - const onClick = (event: CeilingEvent) => clicks.push(event) - emitter.on('ceiling:move', onMove) - emitter.on('ceiling:click', onClick) - function Placement() { - useCeilingEvents() - return null - } - const send = () => { - for (const type of ['pointermove', 'click']) { - canvas.dispatchEvent(Object.assign(new Event(type), { clientX: 55, clientY: 50, button: 0 })) - } - } - try { - sceneRegistry.nodes.set(ceiling.id, surface) - sceneRegistry.byType.ceiling!.add(ceiling.id) - useScene.setState({ nodes: { [level.id]: level, [ceiling.id]: ceiling } }) - useViewer.setState({ - hoveredId: null, - cameraDragging: false, - selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] }, +test.each(['item', 'procedural-item'])( + '%s ceiling placement keeps move and commit hits while an unhovered ceiling is batched', + async (kind) => { + const previousViewer = useViewer.getState() + const previousEditor = useEditor.getState() + const previousInteraction = useInteractionScope.getState() + const previousScene = useScene.getState() + const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT + actGlobal.IS_REACT_ACT_ENVIRONMENT = true + const canvas = Object.assign(new EventTarget(), { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), + }) as unknown as HTMLCanvasElement + const root = createRoot(canvas) + const camera = new PerspectiveCamera(60, 1, 0.1, 100) + camera.position.set(0, 1, 5) + camera.lookAt(0, 3, 0) + camera.updateMatrixWorld() + const level = LevelNode.parse({ id: 'level_ceiling_batch' }) + const ceiling = CeilingNode.parse({ + id: 'ceiling_placement_batch', + parentId: level.id, + polygon: [ + [-5, -5], + [5, -5], + [5, 5], + [-5, 5], + ], }) - if (kind === 'item') useEditor.setState({ selectedItem: { attachTo: 'ceiling' } as never }) - else { - useEditor.setState({ selectedItem: null }) - useEditor.getState().setMovingNode( - ProceduralItemNode.parse({ - recipe: { - ...shelfRecipe, - mounting: { attachTo: 'ceiling', reference: 'top' }, - surfaces: [ - { id: 'top', label: 'Top', position: [0, 'height', 0], size: ['width', 'depth'] }, - ], - }, - parentId: ceiling.id, - }) as never, - ) + const surface = new Mesh( + new PlaneGeometry(10, 10).rotateX(-Math.PI / 2), + new MeshBasicMaterial(), + ) + surface.position.y = 3 + surface.updateMatrixWorld(true) + const moves: CeilingEvent[] = [] + const clicks: CeilingEvent[] = [] + const onMove = (event: CeilingEvent) => moves.push(event) + const onClick = (event: CeilingEvent) => clicks.push(event) + emitter.on('ceiling:move', onMove) + emitter.on('ceiling:click', onClick) + function Placement() { + useCeilingEvents() + return null } - await root.configure({ - gl: { - domElement: canvas, - render() {}, - setSize() {}, - setPixelRatio() {}, - xr: { isPresenting: false }, - } as unknown as WebGLRenderer, - camera, - frameloop: 'never', - dpr: 1, - size: { width: 100, height: 100, top: 0, left: 0 }, - }) - await act(async () => { - root.render(createElement(Placement)) - }) - send() - hideFromScene(surface, 'batched') - send() - showInScene(surface, 'batched') - send() - expect(moves).toHaveLength(3) - expect(clicks).toHaveLength(3) - expect(moves[0]!.position[1]).toBeCloseTo(3) - for (const hit of [...moves, ...clicks]) { - expect(hit.node.id).toBe(ceiling.id) - expect(hit.position).toEqual(moves[0]!.position) - expect(hit.localPosition).toEqual(moves[0]!.localPosition) + const send = () => { + for (const type of ['pointermove', 'click']) { + canvas.dispatchEvent( + Object.assign(new Event(type), { clientX: 55, clientY: 50, button: 0 }), + ) + } } - expect(useViewer.getState().hoveredId).toBeNull() - } finally { - await act(async () => { - root.render(null) - }) - _roots.delete(canvas) - emitter.off('ceiling:move', onMove) - emitter.off('ceiling:click', onClick) - sceneRegistry.nodes.delete(ceiling.id) - sceneRegistry.byType.ceiling!.delete(ceiling.id) - surface.geometry.dispose() - surface.material.dispose() - useScene.setState(previousScene) - useEditor.setState(previousEditor) - useInteractionScope.setState(previousInteraction) - useViewer.setState(previousViewer) - actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct - } -}) + try { + sceneRegistry.nodes.set(ceiling.id, surface) + sceneRegistry.byType.ceiling!.add(ceiling.id) + useScene.setState({ nodes: { [level.id]: level, [ceiling.id]: ceiling } }) + useViewer.setState({ + hoveredId: null, + cameraDragging: false, + selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] }, + }) + if (kind === 'item') useEditor.setState({ selectedItem: { attachTo: 'ceiling' } as never }) + else { + useEditor.setState({ selectedItem: null }) + useEditor.getState().setMovingNode( + ProceduralItemNode.parse({ + recipe: { + ...shelfRecipe, + mounting: { attachTo: 'ceiling', reference: 'top' }, + surfaces: [ + { id: 'top', label: 'Top', position: [0, 'height', 0], size: ['width', 'depth'] }, + ], + }, + parentId: ceiling.id, + }) as never, + ) + } + await root.configure({ + gl: { + domElement: canvas, + render() {}, + setSize() {}, + setPixelRatio() {}, + xr: { isPresenting: false }, + } as unknown as WebGLRenderer, + camera, + frameloop: 'never', + dpr: 1, + size: { width: 100, height: 100, top: 0, left: 0 }, + }) + await act(async () => { + root.render(createElement(Placement)) + }) + send() + hideFromScene(surface, 'batched') + send() + showInScene(surface, 'batched') + send() + expect(moves).toHaveLength(3) + expect(clicks).toHaveLength(3) + expect(moves[0]!.position[1]).toBeCloseTo(3) + for (const hit of [...moves, ...clicks]) { + expect(hit.node.id).toBe(ceiling.id) + expect(hit.position).toEqual(moves[0]!.position) + expect(hit.localPosition).toEqual(moves[0]!.localPosition) + } + expect(useViewer.getState().hoveredId).toBeNull() + } finally { + await act(async () => { + root.render(null) + }) + _roots.delete(canvas) + emitter.off('ceiling:move', onMove) + emitter.off('ceiling:click', onClick) + sceneRegistry.nodes.delete(ceiling.id) + sceneRegistry.byType.ceiling!.delete(ceiling.id) + surface.geometry.dispose() + surface.material.dispose() + useScene.setState(previousScene) + useEditor.setState(previousEditor) + useInteractionScope.setState(previousInteraction) + useViewer.setState(previousViewer) + actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct + } + }, +) diff --git a/packages/editor/src/hooks/use-grid-events.test.ts b/packages/editor/src/hooks/use-grid-events.test.ts index c75f3b20ec..fe28733e59 100644 --- a/packages/editor/src/hooks/use-grid-events.test.ts +++ b/packages/editor/src/hooks/use-grid-events.test.ts @@ -129,114 +129,114 @@ test('grid moves throttle camera drags at 100 ms and resume immediately during t } }) -test.each([ - 'slab', - 'ceiling', -] as const)('grid moves keep the same %s hit when its source joins or leaves a batch', async (kind) => { - const previousViewer = useViewer.getState() - const previousScope = useInteractionScope.getState().scope - const restoreRegistry = nodeRegistry._snapshot() - const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT - actGlobal.IS_REACT_ACT_ENVIRONMENT = true - const canvas = Object.assign(new EventTarget(), { - getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), - }) as unknown as HTMLCanvasElement - const root = createRoot(canvas) - const camera = new PerspectiveCamera(60, 1, 0.1, 100) - camera.position.set(0, kind === 'ceiling' ? 1 : 10, 10) - camera.lookAt(0, 5, 0) - camera.updateMatrixWorld() - const surface = new Mesh( - new PlaneGeometry(20, 20).rotateX(-Math.PI / 2), - new MeshBasicMaterial({ side: DoubleSide }), - ) - surface.position.y = 5 - surface.updateMatrixWorld(true) - const surfaceId = `${kind}_grid_batch_test` as const - const wall = WallNode.parse({ start: [0, 0], end: [0, 2] }) - const delivered: GridEvent[] = [] - const onMove = (event: GridEvent) => delivered.push(event) - emitter.on('grid:move', onMove) - - function Grid() { - useRegistry(surfaceId, kind, { current: surface }) - useGridEvents(0) - return null - } - const send = () => { - const before = delivered.length - canvas.dispatchEvent( - Object.assign(new Event('pointermove'), { clientX: 55, clientY: 50, button: 0 }), +test.each(['slab', 'ceiling'] as const)( + 'grid moves keep the same %s hit when its source joins or leaves a batch', + async (kind) => { + const previousViewer = useViewer.getState() + const previousScope = useInteractionScope.getState().scope + const restoreRegistry = nodeRegistry._snapshot() + const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT + actGlobal.IS_REACT_ACT_ENVIRONMENT = true + const canvas = Object.assign(new EventTarget(), { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), + }) as unknown as HTMLCanvasElement + const root = createRoot(canvas) + const camera = new PerspectiveCamera(60, 1, 0.1, 100) + camera.position.set(0, kind === 'ceiling' ? 1 : 10, 10) + camera.lookAt(0, 5, 0) + camera.updateMatrixWorld() + const surface = new Mesh( + new PlaneGeometry(20, 20).rotateX(-Math.PI / 2), + new MeshBasicMaterial({ side: DoubleSide }), ) - expect(delivered).toHaveLength(before + 1) - return delivered.at(-1)! - } + surface.position.y = 5 + surface.updateMatrixWorld(true) + const surfaceId = `${kind}_grid_batch_test` as const + const wall = WallNode.parse({ start: [0, 0], end: [0, 2] }) + const delivered: GridEvent[] = [] + const onMove = (event: GridEvent) => delivered.push(event) + emitter.on('grid:move', onMove) - try { - nodeRegistry._reset() - registerNode({ - kind, - schemaVersion: 1, - category: 'structure', - capabilities: {}, - schema: kind === 'ceiling' ? CeilingNode : SlabNode, - defaults: () => ({}), - drafting: { surfaceQuery: true }, - extensions: { - [DRAFTING_SURFACE_EXTENSION_KEY]: { - kind, - ...(kind === 'ceiling' ? { raycast: 'underside' } : {}), + function Grid() { + useRegistry(surfaceId, kind, { current: surface }) + useGridEvents(0) + return null + } + const send = () => { + const before = delivered.length + canvas.dispatchEvent( + Object.assign(new Event('pointermove'), { clientX: 55, clientY: 50, button: 0 }), + ) + expect(delivered).toHaveLength(before + 1) + return delivered.at(-1)! + } + + try { + nodeRegistry._reset() + registerNode({ + kind, + schemaVersion: 1, + category: 'structure', + capabilities: {}, + schema: kind === 'ceiling' ? CeilingNode : SlabNode, + defaults: () => ({}), + drafting: { surfaceQuery: true }, + extensions: { + [DRAFTING_SURFACE_EXTENSION_KEY]: { + kind, + ...(kind === 'ceiling' ? { raycast: 'underside' } : {}), + }, }, - }, - }) - useViewer.setState({ - cameraDragging: false, - selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, - }) - await root.configure({ - gl: { - domElement: canvas, - render() {}, - setSize() {}, - setPixelRatio() {}, - xr: { isPresenting: false }, - } as unknown as WebGLRenderer, - camera, - frameloop: 'never', - dpr: 1, - size: { width: 100, height: 100, top: 0, left: 0 }, - }) - for (const scope of [ - { kind: 'moving', node: wall, nodeId: wall.id, nodeType: 'wall', view: '3d' }, - { kind: 'drafting', tool: kind }, - ] as const) { + }) + useViewer.setState({ + cameraDragging: false, + selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, + }) + await root.configure({ + gl: { + domElement: canvas, + render() {}, + setSize() {}, + setPixelRatio() {}, + xr: { isPresenting: false }, + } as unknown as WebGLRenderer, + camera, + frameloop: 'never', + dpr: 1, + size: { width: 100, height: 100, top: 0, left: 0 }, + }) + for (const scope of [ + { kind: 'moving', node: wall, nodeId: wall.id, nodeType: 'wall', view: '3d' }, + { kind: 'drafting', tool: kind }, + ] as const) { + await act(async () => { + useInteractionScope.setState({ scope }) + root.render(createElement(Grid)) + }) + const before = send() + expect(before.surfaceHit?.hostId).toBe(surfaceId) + expect(before.position[1]).toBeCloseTo(5) + hideFromScene(surface, 'batched') + const batched = send() + expect(batched.surfaceHit).toEqual(before.surfaceHit) + expect(batched.position).toEqual(before.position) + expect(batched.localPosition).toEqual(before.localPosition) + showInScene(surface, 'batched') + expect(send().position).toEqual(before.position) + } + } finally { await act(async () => { - useInteractionScope.setState({ scope }) - root.render(createElement(Grid)) + root.render(null) }) - const before = send() - expect(before.surfaceHit?.hostId).toBe(surfaceId) - expect(before.position[1]).toBeCloseTo(5) - hideFromScene(surface, 'batched') - const batched = send() - expect(batched.surfaceHit).toEqual(before.surfaceHit) - expect(batched.position).toEqual(before.position) - expect(batched.localPosition).toEqual(before.localPosition) - showInScene(surface, 'batched') - expect(send().position).toEqual(before.position) + emitter.off('grid:move', onMove) + _roots.delete(canvas) + surface.geometry.dispose() + surface.material.dispose() + restoreRegistry() + useInteractionScope.setState({ scope: previousScope }) + useViewer.setState(previousViewer) + actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct } - } finally { - await act(async () => { - root.render(null) - }) - emitter.off('grid:move', onMove) - _roots.delete(canvas) - surface.geometry.dispose() - surface.material.dispose() - restoreRegistry() - useInteractionScope.setState({ scope: previousScope }) - useViewer.setState(previousViewer) - actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct - } -}) + }, +) diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index b426355848..d31b6f4b26 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -95,21 +95,21 @@ function sceneWithVisibleAndHiddenBoxes(): { } describe('prepareSceneForExport', () => { - test.each([ - true, - false, - ])('omits registered draft geometry absent from the saved document (onlyVisible=%s)', (onlyVisible) => { - const { root, nodes } = sceneWithVisibleAndHiddenBoxes() - const draft = sceneRegistry.nodes.get('item_hidden')! - draft.name = 'Unplaced preview' - nodes.item_hidden!.visible = true - delete nodes.item_hidden - - const result = prepareSceneForExport(root, nodes, { onlyVisible }) - expect(result.scene.getObjectByName('Unplaced preview')).toBeUndefined() - expect(result.scene.children).toHaveLength(1) - expect(root.getObjectByName('Unplaced preview')).toBe(draft) - }) + test.each([true, false])( + 'omits registered draft geometry absent from the saved document (onlyVisible=%s)', + (onlyVisible) => { + const { root, nodes } = sceneWithVisibleAndHiddenBoxes() + const draft = sceneRegistry.nodes.get('item_hidden')! + draft.name = 'Unplaced preview' + nodes.item_hidden!.visible = true + delete nodes.item_hidden + + const result = prepareSceneForExport(root, nodes, { onlyVisible }) + expect(result.scene.getObjectByName('Unplaced preview')).toBeUndefined() + expect(result.scene.children).toHaveLength(1) + expect(root.getObjectByName('Unplaced preview')).toBe(draft) + }, + ) test('skips a refused subtree and reports its warning while exporting other objects', () => { const root = new THREE.Group() @@ -457,134 +457,133 @@ describe('prepareSceneForExport', () => { } }) - 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() + 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() - } - }) + 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() @@ -2373,29 +2372,32 @@ describe('plugin bake policies through export', () => { test.each([ ['sync', 'meadow-bake', ['bakeGeometry']], ['async', 'meadow-bake-async', ['bakeGeometryAsync']], - ] as const)('%s export strips, replaces and keeps static plugin kinds without touching the live tree', async (mode, bakedName, expectedCalls) => { - await withBakePlugin([pluginId], async ({ root, live, nodes, calls }) => { - const prepared = - mode === 'sync' - ? prepareSceneForExport(root, nodes) - : await prepareSceneForExportAsync(root, nodes) - try { - const scene = prepared.scene - expect(scene.getObjectByName(overlayId)).toBeUndefined() - expect(scene.getObjectByName(`${overlayId}-live`)).toBeUndefined() + ] as const)( + '%s export strips, replaces and keeps static plugin kinds without touching the live tree', + async (mode, bakedName, expectedCalls) => { + await withBakePlugin([pluginId], async ({ root, live, nodes, calls }) => { + const prepared = + mode === 'sync' + ? prepareSceneForExport(root, nodes) + : await prepareSceneForExportAsync(root, nodes) + try { + const scene = prepared.scene + expect(scene.getObjectByName(overlayId)).toBeUndefined() + expect(scene.getObjectByName(`${overlayId}-live`)).toBeUndefined() - const meadow = scene.getObjectByName(meadowId) - expect(meadow?.getObjectByName(bakedName)).toBeDefined() - expect(meadow?.getObjectByName(`${meadowId}-live`)).toBeUndefined() + const meadow = scene.getObjectByName(meadowId) + expect(meadow?.getObjectByName(bakedName)).toBeDefined() + expect(meadow?.getObjectByName(`${meadowId}-live`)).toBeUndefined() - expect(scene.getObjectByName(rockId)?.getObjectByName(`${rockId}-live`)).toBeDefined() - expect(calls).toEqual([...expectedCalls]) - expectLiveTreeUntouched(live) - } finally { - prepared.dispose() - } - }) - }) + expect(scene.getObjectByName(rockId)?.getObjectByName(`${rockId}-live`)).toBeDefined() + expect(calls).toEqual([...expectedCalls]) + expectLiveTreeUntouched(live) + } finally { + prepared.dispose() + } + }) + }, + ) test('an uninstalled plugin contributes nothing to the export', async () => { await withBakePlugin([], async ({ root, live, nodes, calls }) => { diff --git a/packages/editor/src/lib/history.test.ts b/packages/editor/src/lib/history.test.ts index 9f10f8b7fc..1207180ec7 100644 --- a/packages/editor/src/lib/history.test.ts +++ b/packages/editor/src/lib/history.test.ts @@ -109,11 +109,10 @@ describe('standalone history source invalidation', () => { `) }) - test.each([ - true, - false, - ])('discarded preview neighbours rebuild after undo/redo (joined: %s)', (joined) => { - runSourceHistoryTest(` + 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 = [] @@ -152,7 +151,8 @@ describe('standalone history source invalidation', () => { assert.deepEqual(Array.from(b.geometry.getAttribute('position').array), canonical) } `) - }) + }, + ) test('opening reparent dirties old and new walls on undo and redo', () => { runSourceHistoryTest(` diff --git a/packages/editor/src/lib/measurement-kind.test.ts b/packages/editor/src/lib/measurement-kind.test.ts index 1b34b62f18..13ed859949 100644 --- a/packages/editor/src/lib/measurement-kind.test.ts +++ b/packages/editor/src/lib/measurement-kind.test.ts @@ -19,12 +19,10 @@ describe('creatable measurement kinds', () => { } }) - test.each([ - 'smart', - 'unknown', - null, - undefined, - ])('falls back from a non-creatable persisted value: %s', (value) => { - expect(normalizeCreatableMeasurementKind(value)).toBe(DEFAULT_CREATABLE_MEASUREMENT_KIND) - }) + test.each(['smart', 'unknown', null, undefined])( + 'falls back from a non-creatable persisted value: %s', + (value) => { + expect(normalizeCreatableMeasurementKind(value)).toBe(DEFAULT_CREATABLE_MEASUREMENT_KIND) + }, + ) }) diff --git a/packages/geometry-script/src/compile.test.ts b/packages/geometry-script/src/compile.test.ts index 48029d768d..2f4f298aaf 100644 --- a/packages/geometry-script/src/compile.test.ts +++ b/packages/geometry-script/src/compile.test.ts @@ -501,18 +501,17 @@ return g;}`, expect(artifact.scene.children.length).toBeGreaterThan(0) }) -test.each([ - 'node:fs', - 'https://example.com/module.js', - 'three/addons/loaders/GLTFLoader.js', -])('rejects a non-allowlisted import after a same-line import: %s', async (specifier) => { - await expect( - compileGeometryScript({ - code: `import * as THREE from 'three'; import { unavailable } from '${specifier}'; +test.each(['node:fs', 'https://example.com/module.js', 'three/addons/loaders/GLTFLoader.js'])( + 'rejects a non-allowlisted import after a same-line import: %s', + async (specifier) => { + await expect( + compileGeometryScript({ + code: `import * as THREE from 'three'; import { unavailable } from '${specifier}'; export default function build() { return new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)); }`, - }), - ).rejects.toThrow(`Unsupported import ${specifier}`) -}) + }), + ).rejects.toThrow(`Unsupported import ${specifier}`) + }, +) // `Box3.isEmpty()` is false for a box with a NaN corner, so a mesh at a position that is not a // number used to compile into NaN bounds, which reached the item schema as a raw validation dump. diff --git a/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts b/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts index 6fc1c8c51e..26233c02d2 100644 --- a/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts +++ b/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts @@ -273,42 +273,45 @@ describe('save_scene', () => { test.each([ ['long', 'a'.repeat(81), 'a'.repeat(121)], ['empty', '', ''], - ])('saves %s legacy text and layers above the former F2 caps without loss', async (_, preset, cavityInsulation) => { - const wall = WallNode.parse({ id: 'wall_unboundedsave', start: [0, 0], end: [4, 0] }) - const graph = { - nodes: { - [wall.id]: { - ...wall, - assembly: { - preset, - cavityInsulation, - exterior: { finish: 'stone', thickness: 5.001 }, - sheathing: { material: 'osb', thickness: 5.001 }, - framing: { kind: 'wood', depth: 5.001 }, - interior: { finish: 'drywall', thickness: 5.001 }, + ])( + 'saves %s legacy text and layers above the former F2 caps without loss', + async (_, preset, cavityInsulation) => { + const wall = WallNode.parse({ id: 'wall_unboundedsave', start: [0, 0], end: [4, 0] }) + const graph = { + nodes: { + [wall.id]: { + ...wall, + assembly: { + preset, + cavityInsulation, + exterior: { finish: 'stone', thickness: 5.001 }, + sheathing: { material: 'osb', thickness: 5.001 }, + framing: { kind: 'wood', depth: 5.001 }, + interior: { finish: 'drywall', thickness: 5.001 }, + }, }, }, - }, - rootNodeIds: [wall.id], - } - const result = await client.callTool({ - name: 'save_scene', - arguments: { name: 'Unbounded legacy wall', includeCurrentScene: false, graph }, - }) + rootNodeIds: [wall.id], + } + const result = await client.callTool({ + name: 'save_scene', + arguments: { name: 'Unbounded legacy wall', includeCurrentScene: false, graph }, + }) - expect(result.isError).toBeFalsy() - const payload = parseToolText(result.content as StoredTextContent[]) - const saved = await store.load(payload.id as string) - const assembly = WallNode.parse(saved?.graph.nodes[wall.id]).assembly! - expect(assembly.presetId).toBe(preset) - expect(assembly.cavityInsulation).toBe(cavityInsulation) - expect(assembly.layers.map(({ id, thickness }) => [id, thickness])).toEqual([ - ['exterior', 5.001], - ['sheathing', 5.001], - ['framing', 5.001], - ['interior', 5.001], - ]) - }) + expect(result.isError).toBeFalsy() + const payload = parseToolText(result.content as StoredTextContent[]) + const saved = await store.load(payload.id as string) + const assembly = WallNode.parse(saved?.graph.nodes[wall.id]).assembly! + expect(assembly.presetId).toBe(preset) + expect(assembly.cavityInsulation).toBe(cavityInsulation) + expect(assembly.layers.map(({ id, thickness }) => [id, thickness])).toEqual([ + ['exterior', 5.001], + ['sheathing', 5.001], + ['framing', 5.001], + ['interior', 5.001], + ]) + }, + ) test('returns version_conflict when expectedVersion mismatches', async () => { const first = await client.callTool({ diff --git a/packages/nodes/src/box-vent/__tests__/geometry.test.ts b/packages/nodes/src/box-vent/__tests__/geometry.test.ts index a57ba4a5ea..90e1a4af74 100644 --- a/packages/nodes/src/box-vent/__tests__/geometry.test.ts +++ b/packages/nodes/src/box-vent/__tests__/geometry.test.ts @@ -22,21 +22,20 @@ describe('buildBoxVentGeometry', () => { expect(box.getAttribute('position').count).toBe(384) }) - test.each([ - 'box', - 'cap', - 'dome', - ] as const)('%s style separates the lower base from the upper cover', (style) => { - const geometry = buildBoxVentGeometry(BoxVentNode.parse({ style })) - const vertexCount = geometry.getAttribute('position').count - - expect(geometry.groups).toHaveLength(2) - expect(geometry.groups[0]).toMatchObject({ start: 0, materialIndex: 0 }) - expect(geometry.groups[1]).toMatchObject({ materialIndex: 1 }) - expect(geometry.groups[0]!.count).toBeGreaterThan(0) - expect(geometry.groups[1]!.count).toBeGreaterThan(0) - expect(geometry.groups[0]!.count + geometry.groups[1]!.count).toBe(vertexCount) - }) + test.each(['box', 'cap', 'dome'] as const)( + '%s style separates the lower base from the upper cover', + (style) => { + const geometry = buildBoxVentGeometry(BoxVentNode.parse({ style })) + const vertexCount = geometry.getAttribute('position').count + + expect(geometry.groups).toHaveLength(2) + expect(geometry.groups[0]).toMatchObject({ start: 0, materialIndex: 0 }) + expect(geometry.groups[1]).toMatchObject({ materialIndex: 1 }) + expect(geometry.groups[0]!.count).toBeGreaterThan(0) + expect(geometry.groups[1]!.count).toBeGreaterThan(0) + expect(geometry.groups[0]!.count + geometry.groups[1]!.count).toBe(vertexCount) + }, + ) test('box style: zero bevel still produces a valid closed solid', () => { // With bevel=0 the wall-edge dedupe drops the degenerate corner diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index 3c353df1cf..a61a7430fe 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -2467,129 +2467,130 @@ describe('cabinet handles', () => { expect(fixture.sceneApi.get(fixture.leg.id)!.position).toEqual(before) }) - test.each([ - 'left', - 'right', - ] as const)('resizing into a gap left by a deleted module keeps the neighbor fixed from the %s side', (side) => { - const run = CabinetNode.parse({ - id: 'cabinet_handle-gap-run', - children: ['cabinet-module_handle-gap-left', 'cabinet-module_handle-gap-right'], - }) - const left = CabinetModuleNode.parse({ - id: 'cabinet-module_handle-gap-left', - parentId: run.id, - position: [-0.5, 0.1, 0], - width: 0.5, - }) - const right = CabinetModuleNode.parse({ - id: 'cabinet-module_handle-gap-right', - parentId: run.id, - position: [0.2, 0.1, 0], - width: 0.5, - }) - const sceneApi = sceneApiFixture([run as AnyNode, left as AnyNode, right as AnyNode]) - const selected = side === 'right' ? left : right - const neighbor = side === 'right' ? right : left - const handles = - typeof cabinetModuleDefinition.handles === 'function' - ? cabinetModuleDefinition.handles(selected, sceneApi as never) - : (cabinetModuleDefinition.handles ?? []) - const widthHandle = handles.find( - (handle): handle is LinearResizeHandle => - handle.kind === 'linear-resize' && - handle.axis === 'x' && - handle.anchor === (side === 'right' ? 'min' : 'max'), - ) - - expect(widthHandle).toBeDefined() - expect(widthHandle!.magneticSnap).toBeDefined() - const snappedWidth = widthHandle!.magneticSnap!(selected, 0.65, sceneApi as never) - const unsnappedWidth = widthHandle!.magneticSnap!(selected, 0.6, sceneApi as never) - const patch = widthHandle!.apply(selected, snappedWidth, sceneApi as never) - const preview = widthHandle!.previewOverrides?.(selected, snappedWidth, sceneApi as never) ?? [] - const previewNeighbor = preview.find(([id]) => id === neighbor.id)?.[1] - - expect(snappedWidth).toBeCloseTo(0.7) - expect(unsnappedWidth).toBeCloseTo(0.6) - expect(patch.position?.[0]).toBeCloseTo(side === 'right' ? -0.4 : 0.1) - expect(previewNeighbor?.position?.[0]).toBeCloseTo(neighbor.position[0]) - - widthHandle!.commit?.(selected, patch, sceneApi as never) - expect(sceneApi.get(neighbor.id)?.position[0]).toBeCloseTo( - neighbor.position[0], - ) - }) + test.each(['left', 'right'] as const)( + 'resizing into a gap left by a deleted module keeps the neighbor fixed from the %s side', + (side) => { + const run = CabinetNode.parse({ + id: 'cabinet_handle-gap-run', + children: ['cabinet-module_handle-gap-left', 'cabinet-module_handle-gap-right'], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_handle-gap-left', + parentId: run.id, + position: [-0.5, 0.1, 0], + width: 0.5, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_handle-gap-right', + parentId: run.id, + position: [0.2, 0.1, 0], + width: 0.5, + }) + const sceneApi = sceneApiFixture([run as AnyNode, left as AnyNode, right as AnyNode]) + const selected = side === 'right' ? left : right + const neighbor = side === 'right' ? right : left + const handles = + typeof cabinetModuleDefinition.handles === 'function' + ? cabinetModuleDefinition.handles(selected, sceneApi as never) + : (cabinetModuleDefinition.handles ?? []) + const widthHandle = handles.find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && + handle.axis === 'x' && + handle.anchor === (side === 'right' ? 'min' : 'max'), + ) - test.each([ - 'left', - 'right', - ] as const)('Alt-resizing a run module leaves the neighbor independent from the %s side', (side) => { - const run = CabinetNode.parse({ - id: `cabinet_handle-alt-run-${side}`, - children: [ - `cabinet-module_handle-alt-left-${side}`, - `cabinet-module_handle-alt-right-${side}`, - ], - }) - const left = CabinetModuleNode.parse({ - id: `cabinet-module_handle-alt-left-${side}`, - parentId: run.id, - position: [-0.5, 0.1, 0], - width: 0.5, - }) - const right = CabinetModuleNode.parse({ - id: `cabinet-module_handle-alt-right-${side}`, - parentId: run.id, - position: [0.2, 0.1, 0], - width: 0.5, - }) - const sceneApi = sceneApiFixture([run as AnyNode, left as AnyNode, right as AnyNode]) - const selected = side === 'right' ? left : right - const neighbor = side === 'right' ? right : left - const handles = - typeof cabinetModuleDefinition.handles === 'function' - ? cabinetModuleDefinition.handles(selected, sceneApi as never) - : (cabinetModuleDefinition.handles ?? []) - const widthHandle = handles.find( - (handle): handle is LinearResizeHandle => - handle.kind === 'linear-resize' && - handle.axis === 'x' && - handle.anchor === (side === 'right' ? 'min' : 'max'), - ) + expect(widthHandle).toBeDefined() + expect(widthHandle!.magneticSnap).toBeDefined() + const snappedWidth = widthHandle!.magneticSnap!(selected, 0.65, sceneApi as never) + const unsnappedWidth = widthHandle!.magneticSnap!(selected, 0.6, sceneApi as never) + const patch = widthHandle!.apply(selected, snappedWidth, sceneApi as never) + const preview = + widthHandle!.previewOverrides?.(selected, snappedWidth, sceneApi as never) ?? [] + const previewNeighbor = preview.find(([id]) => id === neighbor.id)?.[1] + + expect(snappedWidth).toBeCloseTo(0.7) + expect(unsnappedWidth).toBeCloseTo(0.6) + expect(patch.position?.[0]).toBeCloseTo(side === 'right' ? -0.4 : 0.1) + expect(previewNeighbor?.position?.[0]).toBeCloseTo(neighbor.position[0]) + + widthHandle!.commit?.(selected, patch, sceneApi as never) + expect(sceneApi.get(neighbor.id)?.position[0]).toBeCloseTo( + neighbor.position[0], + ) + }, + ) + + test.each(['left', 'right'] as const)( + 'Alt-resizing a run module leaves the neighbor independent from the %s side', + (side) => { + const run = CabinetNode.parse({ + id: `cabinet_handle-alt-run-${side}`, + children: [ + `cabinet-module_handle-alt-left-${side}`, + `cabinet-module_handle-alt-right-${side}`, + ], + }) + const left = CabinetModuleNode.parse({ + id: `cabinet-module_handle-alt-left-${side}`, + parentId: run.id, + position: [-0.5, 0.1, 0], + width: 0.5, + }) + const right = CabinetModuleNode.parse({ + id: `cabinet-module_handle-alt-right-${side}`, + parentId: run.id, + position: [0.2, 0.1, 0], + width: 0.5, + }) + const sceneApi = sceneApiFixture([run as AnyNode, left as AnyNode, right as AnyNode]) + const selected = side === 'right' ? left : right + const neighbor = side === 'right' ? right : left + const handles = + typeof cabinetModuleDefinition.handles === 'function' + ? cabinetModuleDefinition.handles(selected, sceneApi as never) + : (cabinetModuleDefinition.handles ?? []) + const widthHandle = handles.find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && + handle.axis === 'x' && + handle.anchor === (side === 'right' ? 'min' : 'max'), + ) - expect(widthHandle).toBeDefined() - const applyWithAlt = widthHandle!.apply as unknown as ( - node: typeof selected, - width: number, - sceneApi: never, - modifiers: { altKey: boolean }, - ) => Partial - const previewWithAlt = widthHandle!.previewOverrides as unknown as ( - node: typeof selected, - width: number, - sceneApi: never, - modifiers: { altKey: boolean }, - ) => ReadonlyArray]> - const commitWithAlt = widthHandle!.commit as unknown as ( - node: typeof selected, - patch: Partial, - sceneApi: never, - modifiers: { altKey: boolean }, - ) => void - const patch = applyWithAlt(selected, 0.8, sceneApi as never, { altKey: true }) - const preview = new Map(previewWithAlt(selected, 0.8, sceneApi as never, { altKey: true })) - - expect(patch.width).toBeCloseTo(0.8) - expect(preview.has(neighbor.id as AnyNodeId)).toBe(false) - - commitWithAlt(selected, patch, sceneApi as never, { altKey: true }) - - expect(sceneApi.get(selected.id)?.width).toBeCloseTo(0.8) - expect(sceneApi.get(neighbor.id)?.width).toBeCloseTo(neighbor.width) - expect(sceneApi.get(neighbor.id)?.position[0]).toBeCloseTo( - neighbor.position[0], - ) - }) + expect(widthHandle).toBeDefined() + const applyWithAlt = widthHandle!.apply as unknown as ( + node: typeof selected, + width: number, + sceneApi: never, + modifiers: { altKey: boolean }, + ) => Partial + const previewWithAlt = widthHandle!.previewOverrides as unknown as ( + node: typeof selected, + width: number, + sceneApi: never, + modifiers: { altKey: boolean }, + ) => ReadonlyArray]> + const commitWithAlt = widthHandle!.commit as unknown as ( + node: typeof selected, + patch: Partial, + sceneApi: never, + modifiers: { altKey: boolean }, + ) => void + const patch = applyWithAlt(selected, 0.8, sceneApi as never, { altKey: true }) + const preview = new Map(previewWithAlt(selected, 0.8, sceneApi as never, { altKey: true })) + + expect(patch.width).toBeCloseTo(0.8) + expect(preview.has(neighbor.id as AnyNodeId)).toBe(false) + + commitWithAlt(selected, patch, sceneApi as never, { altKey: true }) + + expect(sceneApi.get(selected.id)?.width).toBeCloseTo(0.8) + expect(sceneApi.get(neighbor.id)?.width).toBeCloseTo(neighbor.width) + expect(sceneApi.get(neighbor.id)?.position[0]).toBeCloseTo( + neighbor.position[0], + ) + }, + ) test.each([ ['left', -Math.PI / 2], @@ -2707,134 +2708,134 @@ describe('cabinet handles', () => { expect(visibleHandles.some((handle) => handle.kind === 'arc-resize')).toBe(true) }) - test.each([ - 'left', - 'right', - ] as const)('source depth on an L %s changes only the source leg', (side) => { - const { depthHandles, leg, sceneApi, source, sourceModule } = generatedL(side) - const handle = depthHandles.find((candidate) => candidate.axis === 'z')! - const initialSourcePosition = [...source.position] - const initialLegPosition = [...leg.position] - const initialSourceBack = sourceModule.position[2] - sourceModule.depth / 2 - const patch = handle.apply(source, 0.78, sceneApi as never) - - expect(patch.position).toBeUndefined() - handle.commit?.(source, patch, sceneApi as never) - - expect(sceneApi.get(source.id)?.depth).toBeCloseTo(0.78) - expect(sceneApi.get(source.id)?.position).toEqual(initialSourcePosition) - const resizedSourceModule = sceneApi.get(sourceModule.id)! - expect(resizedSourceModule.position[2] - resizedSourceModule.depth / 2).toBeCloseTo( - initialSourceBack, - ) - expect(sceneApi.get(leg.id)?.depth).toBeCloseTo(leg.depth) - expect(sceneApi.get(leg.id)?.position).toEqual(initialLegPosition) - }) - - test.each([ - 'left', - 'right', - ] as const)('perpendicular depth on an L %s changes only the derived leg', (side) => { - const { depthHandles, leg, legModule, sceneApi, source } = generatedL(side) - const handle = depthHandles.find((candidate) => candidate.axis === 'x')! - const initialSourcePosition = [...source.position] - const initialLegPosition = [...leg.position] - const initialLegBack = legModule.position[2] - legModule.depth / 2 - const cornerWallFiller = Object.values(sceneApi.nodes()).find( - (node): node is CabinetModuleNode => - node.type === 'cabinet-module' && node.name === 'Corner Wall Filler', - )! - const initialCornerWallWorld = resolveCabinetWorldTransform( - cornerWallFiller, - sceneApi.nodes() as Record, - ) - const patch = handle.apply(source, 0.48, sceneApi as never) - - handle.commit?.(source, patch, sceneApi as never) - - expect(sceneApi.get(leg.id)?.depth).toBeCloseTo(0.48) - expect(sceneApi.get(leg.id)?.position).toEqual(initialLegPosition) - const resizedLegModule = sceneApi.get(legModule.id)! - expect(resizedLegModule.position[2] - resizedLegModule.depth / 2).toBeCloseTo(initialLegBack) - expect(sceneApi.get(source.id)?.depth).toBeCloseTo(source.depth) - expect(sceneApi.get(source.id)?.position).toEqual(initialSourcePosition) - const resizedCornerWallWorld = resolveCabinetWorldTransform( - sceneApi.get(cornerWallFiller.id)!, - sceneApi.nodes() as Record, - ) - expect(resizedCornerWallWorld.position[0]).toBeCloseTo(initialCornerWallWorld.position[0]) - expect(resizedCornerWallWorld.position[2]).toBeCloseTo(initialCornerWallWorld.position[2]) - }) - - test.each([ - 'left', - 'right', - ] as const)('chained L %s groups expose one centered depth arrow per run', (side) => { - const { depthHandles, leg, sceneApi, source, thirdRun } = generatedU(side) - const runs = [source, leg, thirdRun] - const sourceWorld = resolveCabinetWorldTransform( - source, - sceneApi.nodes() as Record, - ) - const sourceCos = Math.cos(sourceWorld.rotation) - const sourceSin = Math.sin(sourceWorld.rotation) - const targetIds = depthHandles.map( - (handle) => handle.overrideTarget?.(source, sceneApi as never) ?? source.id, - ) - - expect(new Set(targetIds)).toEqual(new Set(runs.map((run) => run.id))) - expect(depthHandles).toHaveLength(3) - - for (const run of runs) { - const modules = run.children - .map((id) => sceneApi.get(id as AnyNodeId)) - .filter((node): node is CabinetModuleNode => node?.type === 'cabinet-module') - const centerX = - (Math.min(...modules.map((module) => module.position[0] - module.width / 2)) + - Math.max(...modules.map((module) => module.position[0] + module.width / 2))) / - 2 - const frontZ = Math.max(...modules.map((module) => module.position[2] + module.depth / 2)) - const runWorld = resolveCabinetWorldTransform( - run, + test.each(['left', 'right'] as const)( + 'source depth on an L %s changes only the source leg', + (side) => { + const { depthHandles, leg, sceneApi, source, sourceModule } = generatedL(side) + const handle = depthHandles.find((candidate) => candidate.axis === 'z')! + const initialSourcePosition = [...source.position] + const initialLegPosition = [...leg.position] + const initialSourceBack = sourceModule.position[2] - sourceModule.depth / 2 + const patch = handle.apply(source, 0.78, sceneApi as never) + + expect(patch.position).toBeUndefined() + handle.commit?.(source, patch, sceneApi as never) + + expect(sceneApi.get(source.id)?.depth).toBeCloseTo(0.78) + expect(sceneApi.get(source.id)?.position).toEqual(initialSourcePosition) + const resizedSourceModule = sceneApi.get(sourceModule.id)! + expect(resizedSourceModule.position[2] - resizedSourceModule.depth / 2).toBeCloseTo( + initialSourceBack, + ) + expect(sceneApi.get(leg.id)?.depth).toBeCloseTo(leg.depth) + expect(sceneApi.get(leg.id)?.position).toEqual(initialLegPosition) + }, + ) + + test.each(['left', 'right'] as const)( + 'perpendicular depth on an L %s changes only the derived leg', + (side) => { + const { depthHandles, leg, legModule, sceneApi, source } = generatedL(side) + const handle = depthHandles.find((candidate) => candidate.axis === 'x')! + const initialSourcePosition = [...source.position] + const initialLegPosition = [...leg.position] + const initialLegBack = legModule.position[2] - legModule.depth / 2 + const cornerWallFiller = Object.values(sceneApi.nodes()).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Corner Wall Filler', + )! + const initialCornerWallWorld = resolveCabinetWorldTransform( + cornerWallFiller, sceneApi.nodes() as Record, ) - const frontWorld = localPointToWorld(runWorld, [centerX, 0, frontZ + 0.18]) - const dx = frontWorld[0] - sourceWorld.position[0] - const dz = frontWorld[2] - sourceWorld.position[2] - const expectedX = sourceCos * dx - sourceSin * dz - const expectedZ = sourceSin * dx + sourceCos * dz + const patch = handle.apply(source, 0.48, sceneApi as never) + + handle.commit?.(source, patch, sceneApi as never) + + expect(sceneApi.get(leg.id)?.depth).toBeCloseTo(0.48) + expect(sceneApi.get(leg.id)?.position).toEqual(initialLegPosition) + const resizedLegModule = sceneApi.get(legModule.id)! + expect(resizedLegModule.position[2] - resizedLegModule.depth / 2).toBeCloseTo(initialLegBack) + expect(sceneApi.get(source.id)?.depth).toBeCloseTo(source.depth) + expect(sceneApi.get(source.id)?.position).toEqual(initialSourcePosition) + const resizedCornerWallWorld = resolveCabinetWorldTransform( + sceneApi.get(cornerWallFiller.id)!, + sceneApi.nodes() as Record, + ) + expect(resizedCornerWallWorld.position[0]).toBeCloseTo(initialCornerWallWorld.position[0]) + expect(resizedCornerWallWorld.position[2]).toBeCloseTo(initialCornerWallWorld.position[2]) + }, + ) + + test.each(['left', 'right'] as const)( + 'chained L %s groups expose one centered depth arrow per run', + (side) => { + const { depthHandles, leg, sceneApi, source, thirdRun } = generatedU(side) + const runs = [source, leg, thirdRun] + const sourceWorld = resolveCabinetWorldTransform( + source, + sceneApi.nodes() as Record, + ) + const sourceCos = Math.cos(sourceWorld.rotation) + const sourceSin = Math.sin(sourceWorld.rotation) + const targetIds = depthHandles.map( + (handle) => handle.overrideTarget?.(source, sceneApi as never) ?? source.id, + ) + + expect(new Set(targetIds)).toEqual(new Set(runs.map((run) => run.id))) + expect(depthHandles).toHaveLength(3) + + for (const run of runs) { + const modules = run.children + .map((id) => sceneApi.get(id as AnyNodeId)) + .filter((node): node is CabinetModuleNode => node?.type === 'cabinet-module') + const centerX = + (Math.min(...modules.map((module) => module.position[0] - module.width / 2)) + + Math.max(...modules.map((module) => module.position[0] + module.width / 2))) / + 2 + const frontZ = Math.max(...modules.map((module) => module.position[2] + module.depth / 2)) + const runWorld = resolveCabinetWorldTransform( + run, + sceneApi.nodes() as Record, + ) + const frontWorld = localPointToWorld(runWorld, [centerX, 0, frontZ + 0.18]) + const dx = frontWorld[0] - sourceWorld.position[0] + const dz = frontWorld[2] - sourceWorld.position[2] + const expectedX = sourceCos * dx - sourceSin * dz + const expectedZ = sourceSin * dx + sourceCos * dz + const handle = depthHandles.find( + (candidate) => + (candidate.overrideTarget?.(source, sceneApi as never) ?? source.id) === run.id, + )! + const position = handle.placement.position(source, sceneApi as never) + + expect(position[0]).toBeCloseTo(expectedX) + expect(position[2]).toBeCloseTo(expectedZ) + } + }, + ) + + test.each(['left', 'right'] as const)( + 'depth resize on a chained L %s updates the connected corner width', + (side) => { + const { depthHandles, leg, sceneApi, source, thirdRun } = generatedU(side) const handle = depthHandles.find( (candidate) => - (candidate.overrideTarget?.(source, sceneApi as never) ?? source.id) === run.id, + (candidate.overrideTarget?.(source, sceneApi as never) ?? source.id) === leg.id, )! - const position = handle.placement.position(source, sceneApi as never) + const patch = handle.apply(source, 0.78, sceneApi as never) - expect(position[0]).toBeCloseTo(expectedX) - expect(position[2]).toBeCloseTo(expectedZ) - } - }) + handle.commit?.(source, patch, sceneApi as never) - test.each([ - 'left', - 'right', - ] as const)('depth resize on a chained L %s updates the connected corner width', (side) => { - const { depthHandles, leg, sceneApi, source, thirdRun } = generatedU(side) - const handle = depthHandles.find( - (candidate) => - (candidate.overrideTarget?.(source, sceneApi as never) ?? source.id) === leg.id, - )! - const patch = handle.apply(source, 0.78, sceneApi as never) - - handle.commit?.(source, patch, sceneApi as never) - - const connectedFiller = thirdRun.children - .map((id) => sceneApi.get(id as AnyNodeId)) - .find( - (node): node is CabinetModuleNode => - node?.type === 'cabinet-module' && node.name === 'Corner Filler', - )! - expect(connectedFiller.width).toBeCloseTo(0.78) - }) + const connectedFiller = thirdRun.children + .map((id) => sceneApi.get(id as AnyNodeId)) + .find( + (node): node is CabinetModuleNode => + node?.type === 'cabinet-module' && node.name === 'Corner Filler', + )! + expect(connectedFiller.width).toBeCloseTo(0.78) + }, + ) test('run rotation keeps the cabinet bounding-box center fixed', () => { const run = CabinetNode.parse({ diff --git a/packages/nodes/src/cabinet/__tests__/hosting-matrix.test.tsx b/packages/nodes/src/cabinet/__tests__/hosting-matrix.test.tsx index 9ad0a7f906..ec70b43888 100644 --- a/packages/nodes/src/cabinet/__tests__/hosting-matrix.test.tsx +++ b/packages/nodes/src/cabinet/__tests__/hosting-matrix.test.tsx @@ -384,137 +384,140 @@ for (const kind of [ } } -test.each([ - 'catalog', - 'procedural', -] as const)('registry %s defers an unrenderable, uncomposable provider host to the floor', async (assetType) => { - const schema = BaseNode.extend({ - id: objectId('test-surface'), - type: nodeType('test-surface'), - children: z.array(z.string()).default([]), - }) - let accepts = false - registerNode({ - kind: 'test-surface', - schemaVersion: 1, - schema, - category: 'furnish', - defaults: () => ({}), - capabilities: { - surfaces: { - hosting: { - childFrame: 'host-local', - resolveHit: () => ({ - id: 'top', - position: [0, 0.84, 0], - normal: [0, 1, 0], - region: { kind: 'rect', size: [1, 1] }, - }), - accepts: () => accepts, +test.each(['catalog', 'procedural'] as const)( + 'registry %s defers an unrenderable, uncomposable provider host to the floor', + async (assetType) => { + const schema = BaseNode.extend({ + id: objectId('test-surface'), + type: nodeType('test-surface'), + children: z.array(z.string()).default([]), + }) + let accepts = false + registerNode({ + kind: 'test-surface', + schemaVersion: 1, + schema, + category: 'furnish', + defaults: () => ({}), + capabilities: { + surfaces: { + hosting: { + childFrame: 'host-local', + resolveHit: () => ({ + id: 'top', + position: [0, 0.84, 0], + normal: [0, 1, 0], + region: { kind: 'rect', size: [1, 1] }, + }), + accepts: () => accepts, + }, }, }, - }, - } as never) - const host = schema.parse({ parentId: level.id }) as unknown as AnyNode - useScene.getState().createNode(host, level.id) - sceneRegistry.nodes.set(host.id, new Group()) - const child = assetType === 'catalog' ? catalog : design - const renderer = await create() - const event = hit(host as Cabinet, [0, 0.84, 0]) - try { - await act(async () => emitter.emit('test-surface:move' as never, event as never)) - expect(useScene.getState().nodes[child.id]!.parentId).toBe(level.id) - accepts = true - await act(async () => emitter.emit('test-surface:move' as never, event as never)) - expect(useScene.getState().nodes[child.id]!.parentId).toBe(level.id) - expect((useScene.getState().nodes[child.id] as typeof child).position[1]).toBe(0) - } finally { - await renderer.unmount() - } -}) + } as never) + const host = schema.parse({ parentId: level.id }) as unknown as AnyNode + useScene.getState().createNode(host, level.id) + sceneRegistry.nodes.set(host.id, new Group()) + const child = assetType === 'catalog' ? catalog : design + const renderer = await create() + const event = hit(host as Cabinet, [0, 0.84, 0]) + try { + await act(async () => emitter.emit('test-surface:move' as never, event as never)) + expect(useScene.getState().nodes[child.id]!.parentId).toBe(level.id) + accepts = true + await act(async () => emitter.emit('test-surface:move' as never, event as never)) + expect(useScene.getState().nodes[child.id]!.parentId).toBe(level.id) + expect((useScene.getState().nodes[child.id] as typeof child).position[1]).toBe(0) + } finally { + await renderer.unmount() + } + }, +) for (const mover of ['catalog', 'registry procedural'] as const) { for (const order of ['grid first', 'host first'] as const) { - test.each([ - 'fit', - 'cutout', - 'kind', - ] as const)(`${mover}, ${order}: %s accepts overhang or refuses without text and never silently commits to the floor`, async (reason) => { - const run = fixture({}, [ - CabinetModuleNode.parse({ - width: 0.8, - position: [0, 0.1, 0], - stack: [{ id: 'sink', type: 'sink' }], - }), - CabinetModuleNode.parse({ width: 0.8, position: [2, 0.1, 0] }), - ]) - let accepts = reason !== 'kind' - const capabilities = nodeRegistry.get('cabinet')!.capabilities - capabilities.surfaces = { - ...capabilities.surfaces, - hosting: { ...getSurfaceProvider(run), accepts: () => accepts }, - } - const child = mover === 'catalog' ? catalog : design - useEditor.getState().setMovingNode(child) - const renderer = await create( - mover === 'catalog' ? : , - ) - try { - const event = hit(run, [reason === 'fit' ? 2.39 : reason === 'cutout' ? 0 : 2, 0.85, 0.01]) - const nativeEvent = {} - event.nativeEvent = { nativeEvent } as CabinetEvent['nativeEvent'] - const grid = { - position: event.position, - localPosition: event.position, - nativeEvent, - } as never - await act(async () => { - if (order === 'grid first') emitter.emit('grid:move', grid) - emitter.emit('cabinet:move', event) - if (order === 'host first') emitter.emit('grid:move', grid) - }) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 5)) - }) - const root = renderer.scene.children[0]!.instance as Group - let invalidColor = false - root.traverse((object) => { - const material = (object as { material?: { color?: { getHex(): number } } }).material - if (material?.color?.getHex() === 0xef4444) invalidColor = true - }) - if (reason === 'fit') { - expect(invalidColor).toBe(false) - await act(async () => emitter.emit('cabinet:click', event)) - const placed = useScene.getState().nodes[child.id] as typeof child - expect(placed.parentId).toBe(run.id) - expect(placed.position[0]).toBeCloseTo(2.39) - expect(useScene.temporal.getState().pastStates).toHaveLength(1) - return + test.each(['fit', 'cutout', 'kind'] as const)( + `${mover}, ${order}: %s accepts overhang or refuses without text and never silently commits to the floor`, + async (reason) => { + const run = fixture({}, [ + CabinetModuleNode.parse({ + width: 0.8, + position: [0, 0.1, 0], + stack: [{ id: 'sink', type: 'sink' }], + }), + CabinetModuleNode.parse({ width: 0.8, position: [2, 0.1, 0] }), + ]) + let accepts = reason !== 'kind' + const capabilities = nodeRegistry.get('cabinet')!.capabilities + capabilities.surfaces = { + ...capabilities.surfaces, + hosting: { ...getSurfaceProvider(run), accepts: () => accepts }, } - expect(invalidColor).toBe(true) - await act(async () => - window.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Alt' })), - ) - await act(async () => { - emitter.emit('cabinet:click', event) - emitter.emit('grid:click', grid) - emitter.emit('node:click', event as never) - }) - expect(useScene.temporal.getState().pastStates).toHaveLength(0) - expect(getMovingNode()).not.toBeNull() - await act(async () => - window.dispatchEvent(Object.assign(new Event('keyup'), { key: 'Alt' })), + const child = mover === 'catalog' ? catalog : design + useEditor.getState().setMovingNode(child) + const renderer = await create( + mover === 'catalog' ? : , ) - accepts = true - await act(async () => emitter.emit('cabinet:move', hit(run, [2, 0.85, 0.01]))) - expect(getMovingNode()).not.toBeNull() - expect(useScene.getState().nodes[child.id]!.parentId).toBe(run.id) - await act(async () => emitter.emit('cabinet:click', hit(run, [2, 0.85, 0.01]))) - expect(useScene.temporal.getState().pastStates).toHaveLength(1) - } finally { - await renderer.unmount() - } - }) + try { + const event = hit(run, [ + reason === 'fit' ? 2.39 : reason === 'cutout' ? 0 : 2, + 0.85, + 0.01, + ]) + const nativeEvent = {} + event.nativeEvent = { nativeEvent } as CabinetEvent['nativeEvent'] + const grid = { + position: event.position, + localPosition: event.position, + nativeEvent, + } as never + await act(async () => { + if (order === 'grid first') emitter.emit('grid:move', grid) + emitter.emit('cabinet:move', event) + if (order === 'host first') emitter.emit('grid:move', grid) + }) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + }) + const root = renderer.scene.children[0]!.instance as Group + let invalidColor = false + root.traverse((object) => { + const material = (object as { material?: { color?: { getHex(): number } } }).material + if (material?.color?.getHex() === 0xef4444) invalidColor = true + }) + if (reason === 'fit') { + expect(invalidColor).toBe(false) + await act(async () => emitter.emit('cabinet:click', event)) + const placed = useScene.getState().nodes[child.id] as typeof child + expect(placed.parentId).toBe(run.id) + expect(placed.position[0]).toBeCloseTo(2.39) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + return + } + expect(invalidColor).toBe(true) + await act(async () => + window.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Alt' })), + ) + await act(async () => { + emitter.emit('cabinet:click', event) + emitter.emit('grid:click', grid) + emitter.emit('node:click', event as never) + }) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + expect(getMovingNode()).not.toBeNull() + await act(async () => + window.dispatchEvent(Object.assign(new Event('keyup'), { key: 'Alt' })), + ) + accepts = true + await act(async () => emitter.emit('cabinet:move', hit(run, [2, 0.85, 0.01]))) + expect(getMovingNode()).not.toBeNull() + expect(useScene.getState().nodes[child.id]!.parentId).toBe(run.id) + await act(async () => emitter.emit('cabinet:click', hit(run, [2, 0.85, 0.01]))) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + } finally { + await renderer.unmount() + } + }, + ) } } diff --git a/packages/nodes/src/cabinet/__tests__/hosting.test.tsx b/packages/nodes/src/cabinet/__tests__/hosting.test.tsx index 0ba7a8f5f0..02da018713 100644 --- a/packages/nodes/src/cabinet/__tests__/hosting.test.tsx +++ b/packages/nodes/src/cabinet/__tests__/hosting.test.tsx @@ -387,45 +387,45 @@ test('detaching restores floor support and cancel restores the original parent a supportSlabId: slab.id, }) }) -test.each([ - 'cabinet', - 'shelf', -] as const)('2D retains contained catalog and procedural children on their current %s surface', (kind) => { - const run = fixture() - const shelf = ShelfNode.parse({ parentId: level.id, width: 2, depth: 1, height: 2 }) - if (kind === 'shelf') useScene.getState().createNode(shelf, level.id) - const host = kind === 'cabinet' ? run : shelf - const y = getSurfaceProvider(host).surfaces!(host, { scene: createSceneApi(useScene) })[0]! - .position[1] - for (const child of [catalog, design]) { - const position: [number, number, number] = - child.type === 'item' ? [0, y, 0] : [-0.1, y - 0.2, -0.1] - useScene - .getState() - .updateNode(child.id, { parentId: host.id, position, supportSlabId: undefined }) - const node = useScene.getState().nodes[child.id]! - const session = - child.type === 'item' - ? itemFloorplanMoveTarget({ - node: node as ItemNode, - nodes: useScene.getState().nodes, - sceneApi: createSceneApi(useScene), - })! - : proceduralFloorplanMoveTarget({ - node: node as ProceduralItemNode, - nodes: useScene.getState().nodes, - sceneApi: createSceneApi(useScene), - })! - session.apply({ planPoint: [2, 3], modifiers } as never) - session.apply({ planPoint: [2.1, 3], modifiers } as never) - expect(useLiveNodeOverrides.getState().overrides.get(child.id)).toMatchObject({ - parentId: host.id, - supportSlabId: undefined, - }) - session.commit!() - expect(useScene.getState().nodes[child.id]!.parentId).toBe(host.id) - } -}) +test.each(['cabinet', 'shelf'] as const)( + '2D retains contained catalog and procedural children on their current %s surface', + (kind) => { + const run = fixture() + const shelf = ShelfNode.parse({ parentId: level.id, width: 2, depth: 1, height: 2 }) + if (kind === 'shelf') useScene.getState().createNode(shelf, level.id) + const host = kind === 'cabinet' ? run : shelf + const y = getSurfaceProvider(host).surfaces!(host, { scene: createSceneApi(useScene) })[0]! + .position[1] + for (const child of [catalog, design]) { + const position: [number, number, number] = + child.type === 'item' ? [0, y, 0] : [-0.1, y - 0.2, -0.1] + useScene + .getState() + .updateNode(child.id, { parentId: host.id, position, supportSlabId: undefined }) + const node = useScene.getState().nodes[child.id]! + const session = + child.type === 'item' + ? itemFloorplanMoveTarget({ + node: node as ItemNode, + nodes: useScene.getState().nodes, + sceneApi: createSceneApi(useScene), + })! + : proceduralFloorplanMoveTarget({ + node: node as ProceduralItemNode, + nodes: useScene.getState().nodes, + sceneApi: createSceneApi(useScene), + })! + session.apply({ planPoint: [2, 3], modifiers } as never) + session.apply({ planPoint: [2.1, 3], modifiers } as never) + expect(useLiveNodeOverrides.getState().overrides.get(child.id)).toMatchObject({ + parentId: host.id, + supportSlabId: undefined, + }) + session.commit!() + expect(useScene.getState().nodes[child.id]!.parentId).toBe(host.id) + } + }, +) test('attaching and live-moving hosted assets leaves the actual countertop geometry object intact', async () => { const run = fixture() const object = sceneRegistry.nodes.get(run.id)! diff --git a/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts b/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts index 6011b0e479..5ba2ce2e7c 100644 --- a/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts +++ b/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts @@ -6,15 +6,14 @@ import { cabinetModuleUsesFixedApplianceWidth, } from '../panel-visibility' -test.each([ - 'Corner Filler', - 'Wall Bridge Filler', - 'Corner Wall Filler', -])('%s supports a top or ceiling finish without relying on its parent run', (name) => { - const module = CabinetModuleNode.parse({ moduleKind: 'corner-filler', name }) - - expect(cabinetModuleSupportsTopFinish({ module, parentIsModule: false })).toBe(true) -}) +test.each(['Corner Filler', 'Wall Bridge Filler', 'Corner Wall Filler'])( + '%s supports a top or ceiling finish without relying on its parent run', + (name) => { + const module = CabinetModuleNode.parse({ moduleKind: 'corner-filler', name }) + + expect(cabinetModuleSupportsTopFinish({ module, parentIsModule: false })).toBe(true) + }, +) test('an ordinary base module still omits the top or ceiling finish controls', () => { const module = CabinetModuleNode.parse({ cabinetType: 'base' }) diff --git a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts index f16c808238..0e60f358c5 100644 --- a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts +++ b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts @@ -64,45 +64,45 @@ describe('cabinet quick actions', () => { ).toBe(false) }) - test.each([ - 'left', - 'right', - ] as const)('selects the outer base cabinet after an L %s action', (side) => { - const levelId = `level_quick-actions-select-outer-${side}` as AnyNodeId - const run = CabinetNode.parse({ - id: `cabinet_run-quick-actions-select-outer-${side}`, - parentId: levelId, - position: [0, 0, 0], - rotation: 0, - children: [`cabinet-module_source-quick-actions-select-outer-${side}`], - }) - const source = CabinetModuleNode.parse({ - id: `cabinet-module_source-quick-actions-select-outer-${side}`, - parentId: run.id, - position: [0, 0.1, 0], - width: 0.9, - depth: 0.58, - carcassHeight: 0.72, - }) - const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode]) - const action = cabinetQuickActions({ node: source, nodes: sceneApi.nodes() }).find( - (candidate) => candidate.id === `cabinet:add-corner-${side}`, - ) - - expect(action?.disabled).toBeFalsy() - const selectedId = action?.run({ sceneApi })?.selectedIds?.[0] - const selected = selectedId ? sceneApi.get(selectedId) : null - const derivedRun = selected?.parentId - ? sceneApi.get(selected.parentId as AnyNodeId) - : null - const sourceRun = sceneApi.get(run.id as AnyNodeId) - - expect(selected?.name).toBe('Base Cabinet') - expect(selected?.moduleKind).toBe('standard') - expect(derivedRun?.parentId).toBe(run.id) - expect(sourceRun?.children).toContain(derivedRun?.id as AnyNodeId) - expect(cabinetDefinition.relations?.hosts).toContain('cabinet') - }) + test.each(['left', 'right'] as const)( + 'selects the outer base cabinet after an L %s action', + (side) => { + const levelId = `level_quick-actions-select-outer-${side}` as AnyNodeId + const run = CabinetNode.parse({ + id: `cabinet_run-quick-actions-select-outer-${side}`, + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [`cabinet-module_source-quick-actions-select-outer-${side}`], + }) + const source = CabinetModuleNode.parse({ + id: `cabinet-module_source-quick-actions-select-outer-${side}`, + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode]) + const action = cabinetQuickActions({ node: source, nodes: sceneApi.nodes() }).find( + (candidate) => candidate.id === `cabinet:add-corner-${side}`, + ) + + expect(action?.disabled).toBeFalsy() + const selectedId = action?.run({ sceneApi })?.selectedIds?.[0] + const selected = selectedId ? sceneApi.get(selectedId) : null + const derivedRun = selected?.parentId + ? sceneApi.get(selected.parentId as AnyNodeId) + : null + const sourceRun = sceneApi.get(run.id as AnyNodeId) + + expect(selected?.name).toBe('Base Cabinet') + expect(selected?.moduleKind).toBe('standard') + expect(derivedRun?.parentId).toBe(run.id) + expect(sourceRun?.children).toContain(derivedRun?.id as AnyNodeId) + expect(cabinetDefinition.relations?.hosts).toContain('cabinet') + }, + ) test('offers and runs an L-corner action from run selection using the end module', () => { const levelId = 'level_quick_actions_corner' as AnyNodeId diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts index ac0650f829..212238e11d 100644 --- a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts +++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts @@ -200,35 +200,35 @@ describe('addCabinetModuleSide', () => { expect(added?.stack?.[1]?.shelfCount).toBe(2) }) - test.each([ - 'left', - 'right', - ] as const)('adds a standard cabinet beside a dishwasher on the %s instead of cloning the appliance', (side) => { - const run = CabinetNode.parse({ - id: 'cabinet_run-add-side-dishwasher', - children: ['cabinet-module_add-side-dishwasher'], - }) - const dishwasher = CabinetModuleNode.parse({ - id: 'cabinet-module_add-side-dishwasher', - parentId: run.id, - name: 'Dishwasher', - position: [0, 0.1, 0], - width: 0.6, - stack: [{ id: 'dishwasher-compartment-add-side', type: 'dishwasher', height: 0.72 }], - }) - const sceneApi = sceneApiFixture([run as AnyNode, dishwasher as AnyNode]) + test.each(['left', 'right'] as const)( + 'adds a standard cabinet beside a dishwasher on the %s instead of cloning the appliance', + (side) => { + const run = CabinetNode.parse({ + id: 'cabinet_run-add-side-dishwasher', + children: ['cabinet-module_add-side-dishwasher'], + }) + const dishwasher = CabinetModuleNode.parse({ + id: 'cabinet-module_add-side-dishwasher', + parentId: run.id, + name: 'Dishwasher', + position: [0, 0.1, 0], + width: 0.6, + stack: [{ id: 'dishwasher-compartment-add-side', type: 'dishwasher', height: 0.72 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, dishwasher as AnyNode]) - const addedId = addCabinetModuleSide({ - anchorModule: dishwasher, - run, - sceneApi, - side, - }) + const addedId = addCabinetModuleSide({ + anchorModule: dishwasher, + run, + sceneApi, + side, + }) - const added = sceneApi.get(addedId!) - expect(added?.name).toBe('Base Cabinet 2') - expect(added?.stack?.map((compartment) => compartment.type)).toEqual(['door']) - }) + const added = sceneApi.get(addedId!) + expect(added?.name).toBe('Base Cabinet 2') + expect(added?.stack?.map((compartment) => compartment.type)).toEqual(['door']) + }, + ) test('shrinks a newly added corner-end base cabinet to the remaining wall clearance', () => { const levelId = 'level_add-side-wall-clearance' as AnyNodeId @@ -1481,109 +1481,113 @@ describe('addCornerRun', () => { expect(resizedBack).toBeCloseTo(initialBack) }) - test.each([ - 'left', - 'right', - ] as const)('%s leg depth resizes its center-run source cabinet from the outer edge', (side) => { - const run = CabinetNode.parse({ - id: `cabinet_source-run-upstream-${side}`, - depth: 0.58, - children: [`cabinet-module_source-upstream-${side}`], - }) - const module = CabinetModuleNode.parse({ - id: `cabinet-module_source-upstream-${side}`, - parentId: run.id, - position: [0, 0.1, 0], - width: 0.9, - depth: 0.58, - }) - const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) - const selectedId = addCornerRun({ module, run, sceneApi, side })! - const selectedModule = sceneApi.get(selectedId)! - let leg = sceneApi.get(selectedModule.parentId as AnyNodeId)! - const initialLegDepth = leg.depth - const originalInnerEdge = - side === 'left' - ? module.position[0] + module.width / 2 - : module.position[0] - module.width / 2 - const initialSource = sceneApi.get(module.id)! - const initialWall = wallChildOf(initialSource, sceneApi.nodes())! - const initialBridge = Object.values(sceneApi.nodes()).find( - (node): node is CabinetModuleNode => - node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', - )! - const initialCornerWallFiller = Object.values(sceneApi.nodes()).find( - (node): node is CabinetModuleNode => - node.type === 'cabinet-module' && node.name === 'Corner Wall Filler', - )! - const originalCornerWallPosition = resolveCabinetWorldTransform( - initialCornerWallFiller, - sceneApi.nodes() as Record, - ).position - const initialBridgeWorld = resolveCabinetWorldTransform( - initialBridge, - sceneApi.nodes() as Record, - ) - const bridgeOuterDirection = side === 'right' ? 1 : -1 - const originalBridgeOuterEdge = [ - initialBridgeWorld.position[0] + - bridgeOuterDirection * Math.cos(initialBridgeWorld.rotation) * (initialBridge.width / 2), - initialBridgeWorld.position[2] - - bridgeOuterDirection * Math.sin(initialBridgeWorld.rotation) * (initialBridge.width / 2), - ] - sceneApi.update(initialWall.id as AnyNodeId, { - position: [initialWall.position[0], initialWall.position[1], initialWall.position[2] + 0.04], - }) - - for (const depth of [0.78, 0.48]) { - const overrides = previewCornerRunsFromRunSources({ - baseLayout: 'width-only', - initialOverrides: [ - ...backAlignedRunDepthOverrides(leg, sceneApi.nodes(), depth), - ...cornerSourceWidthOverridesForDerivedDepth(leg, sceneApi.nodes(), depth), - ], - run: { ...leg, depth }, - sceneApi, + test.each(['left', 'right'] as const)( + '%s leg depth resizes its center-run source cabinet from the outer edge', + (side) => { + const run = CabinetNode.parse({ + id: `cabinet_source-run-upstream-${side}`, + depth: 0.58, + children: [`cabinet-module_source-upstream-${side}`], }) - for (const [id, override] of overrides) sceneApi.update(id, override) - sceneApi.update(leg.id as AnyNodeId, { depth }) - leg = sceneApi.get(leg.id)! - - const source = sceneApi.get(module.id)! - const expectedWidth = 0.9 - (depth - initialLegDepth) - const innerEdge = + const module = CabinetModuleNode.parse({ + id: `cabinet-module_source-upstream-${side}`, + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + const selectedId = addCornerRun({ module, run, sceneApi, side })! + const selectedModule = sceneApi.get(selectedId)! + let leg = sceneApi.get(selectedModule.parentId as AnyNodeId)! + const initialLegDepth = leg.depth + const originalInnerEdge = side === 'left' - ? source.position[0] + source.width / 2 - : source.position[0] - source.width / 2 - expect(source.width).toBeCloseTo(expectedWidth) - expect(innerEdge).toBeCloseTo(originalInnerEdge) - const wall = wallChildOf(source, sceneApi.nodes())! - expect(wall.width).toBeCloseTo(expectedWidth) - expect(source.position[2] + wall.position[2] - wall.depth / 2).toBeCloseTo( - source.position[2] - source.depth / 2, - ) - const bridge = sceneApi.get(initialBridge.id)! - expect(bridge.width).toBeCloseTo(initialBridge.width + (depth - initialLegDepth)) - const bridgeWorld = resolveCabinetWorldTransform( - bridge, + ? module.position[0] + module.width / 2 + : module.position[0] - module.width / 2 + const initialSource = sceneApi.get(module.id)! + const initialWall = wallChildOf(initialSource, sceneApi.nodes())! + const initialBridge = Object.values(sceneApi.nodes()).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + )! + const initialCornerWallFiller = Object.values(sceneApi.nodes()).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Corner Wall Filler', + )! + const originalCornerWallPosition = resolveCabinetWorldTransform( + initialCornerWallFiller, + sceneApi.nodes() as Record, + ).position + const initialBridgeWorld = resolveCabinetWorldTransform( + initialBridge, sceneApi.nodes() as Record, ) - const bridgeOuterEdge = [ - bridgeWorld.position[0] + - bridgeOuterDirection * Math.cos(bridgeWorld.rotation) * (bridge.width / 2), - bridgeWorld.position[2] - - bridgeOuterDirection * Math.sin(bridgeWorld.rotation) * (bridge.width / 2), + const bridgeOuterDirection = side === 'right' ? 1 : -1 + const originalBridgeOuterEdge = [ + initialBridgeWorld.position[0] + + bridgeOuterDirection * Math.cos(initialBridgeWorld.rotation) * (initialBridge.width / 2), + initialBridgeWorld.position[2] - + bridgeOuterDirection * Math.sin(initialBridgeWorld.rotation) * (initialBridge.width / 2), ] - expect(bridgeOuterEdge[0]).toBeCloseTo(originalBridgeOuterEdge[0]!) - expect(bridgeOuterEdge[1]).toBeCloseTo(originalBridgeOuterEdge[1]!) - const cornerWallPosition = resolveCabinetWorldTransform( - sceneApi.get(initialCornerWallFiller.id)!, - sceneApi.nodes() as Record, - ).position - expect(cornerWallPosition[0]).toBeCloseTo(originalCornerWallPosition[0]) - expect(cornerWallPosition[2]).toBeCloseTo(originalCornerWallPosition[2]) - } - }) + sceneApi.update(initialWall.id as AnyNodeId, { + position: [ + initialWall.position[0], + initialWall.position[1], + initialWall.position[2] + 0.04, + ], + }) + + for (const depth of [0.78, 0.48]) { + const overrides = previewCornerRunsFromRunSources({ + baseLayout: 'width-only', + initialOverrides: [ + ...backAlignedRunDepthOverrides(leg, sceneApi.nodes(), depth), + ...cornerSourceWidthOverridesForDerivedDepth(leg, sceneApi.nodes(), depth), + ], + run: { ...leg, depth }, + sceneApi, + }) + for (const [id, override] of overrides) sceneApi.update(id, override) + sceneApi.update(leg.id as AnyNodeId, { depth }) + leg = sceneApi.get(leg.id)! + + const source = sceneApi.get(module.id)! + const expectedWidth = 0.9 - (depth - initialLegDepth) + const innerEdge = + side === 'left' + ? source.position[0] + source.width / 2 + : source.position[0] - source.width / 2 + expect(source.width).toBeCloseTo(expectedWidth) + expect(innerEdge).toBeCloseTo(originalInnerEdge) + const wall = wallChildOf(source, sceneApi.nodes())! + expect(wall.width).toBeCloseTo(expectedWidth) + expect(source.position[2] + wall.position[2] - wall.depth / 2).toBeCloseTo( + source.position[2] - source.depth / 2, + ) + const bridge = sceneApi.get(initialBridge.id)! + expect(bridge.width).toBeCloseTo(initialBridge.width + (depth - initialLegDepth)) + const bridgeWorld = resolveCabinetWorldTransform( + bridge, + sceneApi.nodes() as Record, + ) + const bridgeOuterEdge = [ + bridgeWorld.position[0] + + bridgeOuterDirection * Math.cos(bridgeWorld.rotation) * (bridge.width / 2), + bridgeWorld.position[2] - + bridgeOuterDirection * Math.sin(bridgeWorld.rotation) * (bridge.width / 2), + ] + expect(bridgeOuterEdge[0]).toBeCloseTo(originalBridgeOuterEdge[0]!) + expect(bridgeOuterEdge[1]).toBeCloseTo(originalBridgeOuterEdge[1]!) + const cornerWallPosition = resolveCabinetWorldTransform( + sceneApi.get(initialCornerWallFiller.id)!, + sceneApi.nodes() as Record, + ).position + expect(cornerWallPosition[0]).toBeCloseTo(originalCornerWallPosition[0]) + expect(cornerWallPosition[2]).toBeCloseTo(originalCornerWallPosition[2]) + } + }, + ) test('propagates front styling into linked runs even when the corner re-layout bails', () => { const levelId = 'level_corner-style-layout-bail' as AnyNodeId @@ -1679,71 +1683,73 @@ describe('addCornerRun', () => { expect(allCabinets.every((node) => node.frontStyle === 'raised-arch')).toBe(true) }) - test.each([ - 'left', - 'right', - ] as const)('%s corner filler resizes without changing connected cabinet widths', (side) => { - const run = CabinetNode.parse({ - id: `cabinet_source-run-extended-depth-${side}`, - depth: 0.58, - children: [`cabinet-module_source-extended-depth-${side}`], - }) - const module = CabinetModuleNode.parse({ - id: `cabinet-module_source-extended-depth-${side}`, - parentId: run.id, - position: [0, 0.1, 0], - width: 0.9, - depth: 0.58, - }) - const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) - const connectedId = addCornerRun({ module, run, sceneApi, side })! - const connected = sceneApi.get(connectedId)! - const leg = sceneApi.get(connected.parentId as AnyNodeId)! - const extraId = addCabinetModuleSide({ - anchorModule: connected, - run: leg, - sceneApi, - side, - })! - const initialExtra = sceneApi.get(extraId)! - const initialLegModules = cabinetModulesForRun(leg, sceneApi.nodes()) - const initialFiller = initialLegModules.find((entry) => entry.name === 'Corner Filler')! - const initialConnected = initialLegModules.find((entry) => entry.name === 'Base Cabinet')! - const initialConnectedWidth = initialConnected.width - - for (const depth of [0.48, 0.68]) { - const resizedRun = { ...sceneApi.get(run.id)!, depth } - for (const [id, override] of backAlignedRunDepthOverrides( - sceneApi.get(run.id)!, - sceneApi.nodes(), - depth, - )) { - sceneApi.update(id, override) - } - sceneApi.update(run.id as AnyNodeId, { depth }) - syncCornerRunsFromRunSources({ baseLayout: 'width-only', run: resizedRun, sceneApi }) + test.each(['left', 'right'] as const)( + '%s corner filler resizes without changing connected cabinet widths', + (side) => { + const run = CabinetNode.parse({ + id: `cabinet_source-run-extended-depth-${side}`, + depth: 0.58, + children: [`cabinet-module_source-extended-depth-${side}`], + }) + const module = CabinetModuleNode.parse({ + id: `cabinet-module_source-extended-depth-${side}`, + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + const connectedId = addCornerRun({ module, run, sceneApi, side })! + const connected = sceneApi.get(connectedId)! + const leg = sceneApi.get(connected.parentId as AnyNodeId)! + const extraId = addCabinetModuleSide({ + anchorModule: connected, + run: leg, + sceneApi, + side, + })! + const initialExtra = sceneApi.get(extraId)! + const initialLegModules = cabinetModulesForRun(leg, sceneApi.nodes()) + const initialFiller = initialLegModules.find((entry) => entry.name === 'Corner Filler')! + const initialConnected = initialLegModules.find((entry) => entry.name === 'Base Cabinet')! + const initialConnectedWidth = initialConnected.width + + for (const depth of [0.48, 0.68]) { + const resizedRun = { ...sceneApi.get(run.id)!, depth } + for (const [id, override] of backAlignedRunDepthOverrides( + sceneApi.get(run.id)!, + sceneApi.nodes(), + depth, + )) { + sceneApi.update(id, override) + } + sceneApi.update(run.id as AnyNodeId, { depth }) + syncCornerRunsFromRunSources({ baseLayout: 'width-only', run: resizedRun, sceneApi }) - const liveLeg = sceneApi.get(leg.id)! - const liveModules = cabinetModulesForRun(liveLeg, sceneApi.nodes()).sort( - (a, b) => a.position[0] - b.position[0], - ) - const filler = liveModules.find((entry) => entry.name === 'Corner Filler')! - const liveConnected = liveModules.find((entry) => entry.name === 'Base Cabinet')! - const liveExtra = sceneApi.get(extraId)! - - expect(filler.width).toBeCloseTo(depth) - expect(liveConnected.width).toBeCloseTo(initialConnectedWidth) - expect(wallChildOf(liveConnected, sceneApi.nodes())?.width).toBeCloseTo(initialConnectedWidth) - expect(liveExtra.width).toBeCloseTo(initialExtra.width) - for (let index = 1; index < liveModules.length; index++) { - const previous = liveModules[index - 1]! - const current = liveModules[index]! - expect(previous.position[0] + previous.width / 2).toBeCloseTo( - current.position[0] - current.width / 2, + const liveLeg = sceneApi.get(leg.id)! + const liveModules = cabinetModulesForRun(liveLeg, sceneApi.nodes()).sort( + (a, b) => a.position[0] - b.position[0], ) + const filler = liveModules.find((entry) => entry.name === 'Corner Filler')! + const liveConnected = liveModules.find((entry) => entry.name === 'Base Cabinet')! + const liveExtra = sceneApi.get(extraId)! + + expect(filler.width).toBeCloseTo(depth) + expect(liveConnected.width).toBeCloseTo(initialConnectedWidth) + expect(wallChildOf(liveConnected, sceneApi.nodes())?.width).toBeCloseTo( + initialConnectedWidth, + ) + expect(liveExtra.width).toBeCloseTo(initialExtra.width) + for (let index = 1; index < liveModules.length; index++) { + const previous = liveModules[index - 1]! + const current = liveModules[index]! + expect(previous.position[0] + previous.width / 2).toBeCloseTo( + current.position[0] - current.width / 2, + ) + } } - } - }) + }, + ) test('anchors the right bridge filler to the live source wall cabinet edge', () => { const levelId = 'level_corner-bridge-anchor-right' as AnyNodeId diff --git a/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts b/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts index 5cf8268b28..f1ba2cc5e7 100644 --- a/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts +++ b/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts @@ -330,271 +330,374 @@ describe('cabinet preset run reflow', () => { test.each([ { cornerSide: 'left', openDirection: -1, wallX: 0.5 }, { cornerSide: 'right', openDirection: 1, wallX: -0.5 }, - ] as const)('moves a linked $cornerSide L layout toward its unconstrained side', ({ - cornerSide, - openDirection, - wallX, - }) => { - const level = LevelNode.parse({ id: `level_reflow-l-${cornerSide}` }) - const run = CabinetNode.parse({ - id: `cabinet_reflow-l-${cornerSide}`, - parentId: level.id, - children: [ - `cabinet-module_reflow-l-${cornerSide}-left`, - `cabinet-module_reflow-l-${cornerSide}-right`, - ], - }) - const sourceIsLeft = cornerSide === 'left' - const left = CabinetModuleNode.parse({ - id: `cabinet-module_reflow-l-${cornerSide}-left`, - parentId: run.id, - position: sourceIsLeft ? [-0.4, 0.1, 0] : [-0.25, 0.1, 0], - width: sourceIsLeft ? 0.8 : 0.5, - }) - const right = CabinetModuleNode.parse({ - id: `cabinet-module_reflow-l-${cornerSide}-right`, - parentId: run.id, - position: sourceIsLeft ? [0.25, 0.1, 0] : [0.4, 0.1, 0], - width: sourceIsLeft ? 0.5 : 0.8, - }) - const source = sourceIsLeft ? left : right - const selected = sourceIsLeft ? right : left - const wall = WallNode.parse({ - id: `wall_reflow-l-${cornerSide}`, - parentId: level.id, - start: [wallX, -1], - end: [wallX, 1], - }) - seedScene([level, run, left, right, wall] as AnyNode[], level.id as AnyNodeId) - const sceneApi = createSceneApi(useScene) - expect(addCornerRun({ module: source, run, sceneApi, side: cornerSide })).toBeTruthy() - - const nodesBefore = useScene.getState().nodes - const liveRun = nodesBefore[run.id] as ReturnType - const liveModules = liveRun.children - .map((id) => nodesBefore[id]) - .filter((node): node is ReturnType => - Boolean(node?.type === 'cabinet-module'), + ] as const)( + 'moves a linked $cornerSide L layout toward its unconstrained side', + ({ cornerSide, openDirection, wallX }) => { + const level = LevelNode.parse({ id: `level_reflow-l-${cornerSide}` }) + const run = CabinetNode.parse({ + id: `cabinet_reflow-l-${cornerSide}`, + parentId: level.id, + children: [ + `cabinet-module_reflow-l-${cornerSide}-left`, + `cabinet-module_reflow-l-${cornerSide}-right`, + ], + }) + const sourceIsLeft = cornerSide === 'left' + const left = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-${cornerSide}-left`, + parentId: run.id, + position: sourceIsLeft ? [-0.4, 0.1, 0] : [-0.25, 0.1, 0], + width: sourceIsLeft ? 0.8 : 0.5, + }) + const right = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-${cornerSide}-right`, + parentId: run.id, + position: sourceIsLeft ? [0.25, 0.1, 0] : [0.4, 0.1, 0], + width: sourceIsLeft ? 0.5 : 0.8, + }) + const source = sourceIsLeft ? left : right + const selected = sourceIsLeft ? right : left + const wall = WallNode.parse({ + id: `wall_reflow-l-${cornerSide}`, + parentId: level.id, + start: [wallX, -1], + end: [wallX, 1], + }) + seedScene([level, run, left, right, wall] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: cornerSide })).toBeTruthy() + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const liveSelected = nodesBefore[selected.id] as ReturnType + const derivedBaseRun = Object.values(nodesBefore).find( + (node): node is ReturnType => + node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', + )! + const before = worldPosition(derivedBaseRun, nodesBefore) + const sourceXBefore = (nodesBefore[source.id] as ReturnType) + .position[0] + const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) + + expect(wallConstraintFlags(constraints)).toEqual( + cornerSide === 'left' ? { left: false, right: true } : { left: true, right: false }, ) - const liveSelected = nodesBefore[selected.id] as ReturnType - const derivedBaseRun = Object.values(nodesBefore).find( - (node): node is ReturnType => - node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', - )! - const before = worldPosition(derivedBaseRun, nodesBefore) - const sourceXBefore = (nodesBefore[source.id] as ReturnType) - .position[0] - const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) - - expect(wallConstraintFlags(constraints)).toEqual( - cornerSide === 'left' ? { left: false, right: true } : { left: true, right: false }, - ) - expect( - reflowRunModules({ - modules: liveModules, - parentRun: liveRun, - patch: cabinetPresetById('fridge-single').createPatch(liveRun), - scene: useScene.getState(), - selected: liveSelected, - }), - ).toBe(true) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: cabinetPresetById('fridge-single').createPatch(liveRun), + scene: useScene.getState(), + selected: liveSelected, + }), + ).toBe(true) - const nodesAfter = useScene.getState().nodes - const after = worldPosition( - nodesAfter[derivedBaseRun.id] as ReturnType, - nodesAfter, - ) - const sourceAfter = nodesAfter[source.id] as ReturnType - expect(sourceAfter.position[0] - sourceXBefore).toBeCloseTo(openDirection * 0.26) - expect((sourceAfter.metadata as Record).cabinetCornerSourceLink).toBeDefined() - expect(after[0] - before[0]).toBeCloseTo(openDirection * 0.26) - expect(after[2]).toBeCloseTo(before[2]) - expect(sourceAfter.width).toBeCloseTo(0.8) - }) + const nodesAfter = useScene.getState().nodes + const after = worldPosition( + nodesAfter[derivedBaseRun.id] as ReturnType, + nodesAfter, + ) + const sourceAfter = nodesAfter[source.id] as ReturnType + expect(sourceAfter.position[0] - sourceXBefore).toBeCloseTo(openDirection * 0.26) + expect( + (sourceAfter.metadata as Record).cabinetCornerSourceLink, + ).toBeDefined() + expect(after[0] - before[0]).toBeCloseTo(openDirection * 0.26) + expect(after[2]).toBeCloseTo(before[2]) + expect(sourceAfter.width).toBeCloseTo(0.8) + }, + ) test.each([ { cornerSide: 'left', openDirection: -1, turnSide: 'left', wallX: 0.8 }, { cornerSide: 'left', openDirection: -1, turnSide: 'right', wallX: 0.8 }, { cornerSide: 'right', openDirection: 1, turnSide: 'left', wallX: -0.8 }, { cornerSide: 'right', openDirection: 1, turnSide: 'right', wallX: -0.8 }, - ] as const)('moves a linked $cornerSide L layout turning $turnSide when its source grows', ({ - cornerSide, - openDirection, - turnSide, - wallX, - }) => { - const level = LevelNode.parse({ id: `level_reflow-l-source-${cornerSide}-${turnSide}` }) - const run = CabinetNode.parse({ - id: `cabinet_reflow-l-source-${cornerSide}-${turnSide}`, - parentId: level.id, - children: [ - `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-left`, - `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-right`, - ], - }) - const sourceIsLeft = cornerSide === 'left' - const left = CabinetModuleNode.parse({ - id: `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-left`, - parentId: run.id, - position: sourceIsLeft ? [-0.25, 0.1, 0] : [-0.4, 0.1, 0], - width: sourceIsLeft ? 0.5 : 0.8, - }) - const right = CabinetModuleNode.parse({ - id: `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-right`, - parentId: run.id, - position: sourceIsLeft ? [0.4, 0.1, 0] : [0.25, 0.1, 0], - width: sourceIsLeft ? 0.8 : 0.5, - }) - const source = sourceIsLeft ? left : right - const wall = WallNode.parse({ - id: `wall_reflow-l-source-${cornerSide}-${turnSide}`, - parentId: level.id, - start: [wallX, -1], - end: [wallX, 1], - }) - seedScene([level, run, left, right, wall] as AnyNode[], level.id as AnyNodeId) - const sceneApi = createSceneApi(useScene) - expect(addCornerRun({ module: source, run, sceneApi, side: turnSide })).toBeTruthy() - - const nodesBefore = useScene.getState().nodes - const liveRun = nodesBefore[run.id] as ReturnType - const liveModules = liveRun.children - .map((id) => nodesBefore[id]) - .filter((node): node is ReturnType => - Boolean(node?.type === 'cabinet-module'), + ] as const)( + 'moves a linked $cornerSide L layout turning $turnSide when its source grows', + ({ cornerSide, openDirection, turnSide, wallX }) => { + const level = LevelNode.parse({ id: `level_reflow-l-source-${cornerSide}-${turnSide}` }) + const run = CabinetNode.parse({ + id: `cabinet_reflow-l-source-${cornerSide}-${turnSide}`, + parentId: level.id, + children: [ + `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-left`, + `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-right`, + ], + }) + const sourceIsLeft = cornerSide === 'left' + const left = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-left`, + parentId: run.id, + position: sourceIsLeft ? [-0.25, 0.1, 0] : [-0.4, 0.1, 0], + width: sourceIsLeft ? 0.5 : 0.8, + }) + const right = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-right`, + parentId: run.id, + position: sourceIsLeft ? [0.4, 0.1, 0] : [0.25, 0.1, 0], + width: sourceIsLeft ? 0.8 : 0.5, + }) + const source = sourceIsLeft ? left : right + const wall = WallNode.parse({ + id: `wall_reflow-l-source-${cornerSide}-${turnSide}`, + parentId: level.id, + start: [wallX, -1], + end: [wallX, 1], + }) + seedScene([level, run, left, right, wall] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: turnSide })).toBeTruthy() + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const liveSource = nodesBefore[source.id] as ReturnType + const derivedBaseRun = Object.values(nodesBefore).find( + (node): node is ReturnType => + node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', + )! + const derivedPositionBefore = worldPosition(derivedBaseRun, nodesBefore) + const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) + + expect(wallConstraintFlags(constraints)).toEqual( + cornerSide === 'left' ? { left: false, right: true } : { left: true, right: false }, ) - const liveSource = nodesBefore[source.id] as ReturnType - const derivedBaseRun = Object.values(nodesBefore).find( - (node): node is ReturnType => - node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', - )! - const derivedPositionBefore = worldPosition(derivedBaseRun, nodesBefore) - const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) - - expect(wallConstraintFlags(constraints)).toEqual( - cornerSide === 'left' ? { left: false, right: true } : { left: true, right: false }, - ) - expect( - reflowRunModules({ - modules: liveModules, - parentRun: liveRun, - patch: { cabinetType: 'tall', width: 0.76 }, - scene: useScene.getState(), - selected: liveSource, - }), - ).toBe(true) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: { cabinetType: 'tall', width: 0.76 }, + scene: useScene.getState(), + selected: liveSource, + }), + ).toBe(true) - const nodesAfter = useScene.getState().nodes - const derivedPositionAfter = worldPosition( - nodesAfter[derivedBaseRun.id] as ReturnType, - nodesAfter, - ) - expect(derivedPositionAfter[0] - derivedPositionBefore[0]).toBeCloseTo(openDirection * 0.26) - expect(derivedPositionAfter[2]).toBeCloseTo(derivedPositionBefore[2]) - }) + const nodesAfter = useScene.getState().nodes + const derivedPositionAfter = worldPosition( + nodesAfter[derivedBaseRun.id] as ReturnType, + nodesAfter, + ) + expect(derivedPositionAfter[0] - derivedPositionBefore[0]).toBeCloseTo(openDirection * 0.26) + expect(derivedPositionAfter[2]).toBeCloseTo(derivedPositionBefore[2]) + }, + ) test.each([ { cornerSide: 'left', turnSide: 'left' }, { cornerSide: 'left', turnSide: 'right' }, { cornerSide: 'right', turnSide: 'left' }, { cornerSide: 'right', turnSide: 'right' }, - ] as const)('respects source-wall anchoring for a constrained $cornerSide-end/$turnSide-turn L', ({ - cornerSide, - turnSide, - }) => { - const level = LevelNode.parse({ id: `level_reflow-l-constrained-${cornerSide}` }) - const room = SiteNode.parse({ - id: `site_reflow-l-constrained-${cornerSide}`, - parentId: level.id, - }) - const run = CabinetNode.parse({ - id: `cabinet_reflow-l-constrained-${cornerSide}`, - parentId: level.id, - children: [ - `cabinet-module_reflow-l-constrained-${cornerSide}-left`, - `cabinet-module_reflow-l-constrained-${cornerSide}-right`, - ], - }) - const sourceIsLeft = cornerSide === 'left' - const left = CabinetModuleNode.parse({ - id: `cabinet-module_reflow-l-constrained-${cornerSide}-left`, - parentId: run.id, - position: sourceIsLeft ? [-0.4, 0.1, 0] : [-0.25, 0.1, 0], - width: sourceIsLeft ? 0.8 : 0.5, - }) - const right = CabinetModuleNode.parse({ - id: `cabinet-module_reflow-l-constrained-${cornerSide}-right`, - parentId: run.id, - position: sourceIsLeft ? [0.25, 0.1, 0] : [0.4, 0.1, 0], - width: sourceIsLeft ? 0.5 : 0.8, - }) - const source = sourceIsLeft ? left : right - const selected = sourceIsLeft ? right : left - const outerEdges = sourceIsLeft ? [-0.8, 0.5] : [-0.5, 0.8] - const walls = outerEdges.map((edge, index) => { - const x = edge + (index === 0 ? -0.1 : 0.1) - return WallNode.parse({ - id: `wall_reflow-l-constrained-${cornerSide}-${index}`, - parentId: room.id, - start: [x, -1], - end: [x, 1], + ] as const)( + 'respects source-wall anchoring for a constrained $cornerSide-end/$turnSide-turn L', + ({ cornerSide, turnSide }) => { + const level = LevelNode.parse({ id: `level_reflow-l-constrained-${cornerSide}` }) + const room = SiteNode.parse({ + id: `site_reflow-l-constrained-${cornerSide}`, + parentId: level.id, }) - }) - seedScene([level, room, run, left, right] as AnyNode[], level.id as AnyNodeId) - const sceneApi = createSceneApi(useScene) - expect(addCornerRun({ module: source, run, sceneApi, side: turnSide })).toBeTruthy() - for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId) + const run = CabinetNode.parse({ + id: `cabinet_reflow-l-constrained-${cornerSide}`, + parentId: level.id, + children: [ + `cabinet-module_reflow-l-constrained-${cornerSide}-left`, + `cabinet-module_reflow-l-constrained-${cornerSide}-right`, + ], + }) + const sourceIsLeft = cornerSide === 'left' + const left = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-constrained-${cornerSide}-left`, + parentId: run.id, + position: sourceIsLeft ? [-0.4, 0.1, 0] : [-0.25, 0.1, 0], + width: sourceIsLeft ? 0.8 : 0.5, + }) + const right = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-constrained-${cornerSide}-right`, + parentId: run.id, + position: sourceIsLeft ? [0.25, 0.1, 0] : [0.4, 0.1, 0], + width: sourceIsLeft ? 0.5 : 0.8, + }) + const source = sourceIsLeft ? left : right + const selected = sourceIsLeft ? right : left + const outerEdges = sourceIsLeft ? [-0.8, 0.5] : [-0.5, 0.8] + const walls = outerEdges.map((edge, index) => { + const x = edge + (index === 0 ? -0.1 : 0.1) + return WallNode.parse({ + id: `wall_reflow-l-constrained-${cornerSide}-${index}`, + parentId: room.id, + start: [x, -1], + end: [x, 1], + }) + }) + seedScene([level, room, run, left, right] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: turnSide })).toBeTruthy() + for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId) + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const derivedBaseRun = Object.values(nodesBefore).find( + (node): node is ReturnType => + node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', + )! + const footprintBefore = runModuleBounds(derivedBaseRun.id, nodesBefore) + const derivedPositionBefore = worldPosition(derivedBaseRun, nodesBefore) + const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) + const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) } + + expect(wallConstraintFlags(constraints)).toEqual({ left: true, right: true }) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: { cabinetType: 'tall', width: 0.76 }, + scene: useScene.getState(), + selected: nodesBefore[selected.id] as ReturnType, + }), + ).toBe(true) - const nodesBefore = useScene.getState().nodes - const liveRun = nodesBefore[run.id] as ReturnType - const liveModules = liveRun.children - .map((id) => nodesBefore[id]) - .filter((node): node is ReturnType => - Boolean(node?.type === 'cabinet-module'), + const nodesAfter = useScene.getState().nodes + const sourceAfter = nodesAfter[source.id] as ReturnType + const liveModulesAfter = liveRun.children + .map((id) => nodesAfter[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const footprintAfter = runModuleBounds(derivedBaseRun.id, nodesAfter) + const derivedPositionAfter = worldPosition( + nodesAfter[derivedBaseRun.id] as ReturnType, + nodesAfter, ) - const derivedBaseRun = Object.values(nodesBefore).find( - (node): node is ReturnType => - node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', - )! - const footprintBefore = runModuleBounds(derivedBaseRun.id, nodesBefore) - const derivedPositionBefore = worldPosition(derivedBaseRun, nodesBefore) - const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) - const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) } - - expect(wallConstraintFlags(constraints)).toEqual({ left: true, right: true }) - expect( - reflowRunModules({ - modules: liveModules, - parentRun: liveRun, - patch: { cabinetType: 'tall', width: 0.76 }, - scene: useScene.getState(), - selected: nodesBefore[selected.id] as ReturnType, - }), - ).toBe(true) + expect(sourceAfter.width).toBeCloseTo(0.54) + expect( + (nodesAfter[selected.id] as ReturnType).width, + ).toBeCloseTo(0.76) + expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX) + expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX) + const sideWallInnerFace = + cornerSide === 'left' + ? walls[0]!.start[0] + (walls[0]!.thickness ?? 0.2) / 2 + : walls[1]!.start[0] - (walls[1]!.thickness ?? 0.2) / 2 + if (turnSide === cornerSide) { + if (cornerSide === 'left') { + expect(footprintBefore.minX).toBeLessThan(sideWallInnerFace) + expect(footprintAfter.minX).toBeGreaterThanOrEqual(sideWallInnerFace - 1e-4) + } else { + expect(footprintBefore.maxX).toBeGreaterThan(sideWallInnerFace) + expect(footprintAfter.maxX).toBeLessThanOrEqual(sideWallInnerFace + 1e-4) + } + } else { + expect(derivedPositionAfter[0]).toBeCloseTo(derivedPositionBefore[0]) + expect(derivedPositionAfter[2]).toBeCloseTo(derivedPositionBefore[2]) + } + expect(footprintAfter.minZ).toBeCloseTo(footprintBefore.minZ) + expect(footprintAfter.maxZ).toBeCloseTo(footprintBefore.maxZ) + }, + ) + + test.each(['left', 'right'] as const)( + 'reanchors a two-wall %s L when its corner source donates', + (cornerSide) => { + const level = LevelNode.parse({ id: `level_reflow-l-slack-${cornerSide}` }) + const run = CabinetNode.parse({ + id: `cabinet_reflow-l-slack-${cornerSide}`, + parentId: level.id, + children: [ + `cabinet-module_reflow-l-slack-${cornerSide}-left`, + `cabinet-module_reflow-l-slack-${cornerSide}-right`, + ], + }) + const sourceIsLeft = cornerSide === 'left' + const left = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-slack-${cornerSide}-left`, + parentId: run.id, + position: sourceIsLeft ? [-0.4, 0.1, 0] : [-0.25, 0.1, 0], + width: sourceIsLeft ? 0.8 : 0.5, + }) + const right = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-slack-${cornerSide}-right`, + parentId: run.id, + position: sourceIsLeft ? [0.25, 0.1, 0] : [0.4, 0.1, 0], + width: sourceIsLeft ? 0.5 : 0.8, + }) + const source = sourceIsLeft ? left : right + const selected = sourceIsLeft ? right : left + const outerEdges = sourceIsLeft ? [-0.8, 0.5] : [-0.5, 0.8] + const walls = outerEdges.map((edge, index) => { + const side = index === 0 ? -1 : 1 + const x = edge + side * 0.23 + return WallNode.parse({ + id: `wall_reflow-l-slack-${cornerSide}-${index}`, + parentId: level.id, + start: [x, -1], + end: [x, 1], + thickness: 0.2, + }) + }) + seedScene([level, run, left, right] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: cornerSide })).toBeTruthy() + for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId) + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const liveSelected = nodesBefore[selected.id] as ReturnType + const derivedBaseRun = Object.values(nodesBefore).find( + (node): node is ReturnType => + node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', + )! + const footprintBefore = runModuleBounds(derivedBaseRun.id, nodesBefore) + const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) + const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) } + + expect(constraints.left.slack).toBeCloseTo(0.13) + expect(constraints.right.slack).toBeCloseTo(0.13) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: cabinetPresetById('fridge-single').createPatch(liveRun), + scene: useScene.getState(), + selected: liveSelected, + }), + ).toBe(true) - const nodesAfter = useScene.getState().nodes - const sourceAfter = nodesAfter[source.id] as ReturnType - const liveModulesAfter = liveRun.children - .map((id) => nodesAfter[id]) - .filter((node): node is ReturnType => - Boolean(node?.type === 'cabinet-module'), - ) - const footprintAfter = runModuleBounds(derivedBaseRun.id, nodesAfter) - const derivedPositionAfter = worldPosition( - nodesAfter[derivedBaseRun.id] as ReturnType, - nodesAfter, - ) - expect(sourceAfter.width).toBeCloseTo(0.54) - expect( - (nodesAfter[selected.id] as ReturnType).width, - ).toBeCloseTo(0.76) - expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX) - expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX) - const sideWallInnerFace = - cornerSide === 'left' - ? walls[0]!.start[0] + (walls[0]!.thickness ?? 0.2) / 2 - : walls[1]!.start[0] - (walls[1]!.thickness ?? 0.2) / 2 - if (turnSide === cornerSide) { + const nodesAfter = useScene.getState().nodes + const sourceAfter = nodesAfter[source.id] as ReturnType + const liveModulesAfter = liveRun.children + .map((id) => nodesAfter[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const footprintAfter = runModuleBounds(derivedBaseRun.id, nodesAfter) + expect(sourceAfter.width).toBeCloseTo(0.54) + expect( + (nodesAfter[selected.id] as ReturnType).width, + ).toBeCloseTo(0.76) + expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX) + expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX) + const sideWall = cornerSide === 'left' ? walls[0]! : walls[1]! + const sideWallInnerFace = + sideWall.start[0] + ((cornerSide === 'left' ? 1 : -1) * (sideWall.thickness ?? 0.2)) / 2 if (cornerSide === 'left') { expect(footprintBefore.minX).toBeLessThan(sideWallInnerFace) expect(footprintAfter.minX).toBeGreaterThanOrEqual(sideWallInnerFace - 1e-4) @@ -602,114 +705,10 @@ describe('cabinet preset run reflow', () => { expect(footprintBefore.maxX).toBeGreaterThan(sideWallInnerFace) expect(footprintAfter.maxX).toBeLessThanOrEqual(sideWallInnerFace + 1e-4) } - } else { - expect(derivedPositionAfter[0]).toBeCloseTo(derivedPositionBefore[0]) - expect(derivedPositionAfter[2]).toBeCloseTo(derivedPositionBefore[2]) - } - expect(footprintAfter.minZ).toBeCloseTo(footprintBefore.minZ) - expect(footprintAfter.maxZ).toBeCloseTo(footprintBefore.maxZ) - }) - - test.each([ - 'left', - 'right', - ] as const)('reanchors a two-wall %s L when its corner source donates', (cornerSide) => { - const level = LevelNode.parse({ id: `level_reflow-l-slack-${cornerSide}` }) - const run = CabinetNode.parse({ - id: `cabinet_reflow-l-slack-${cornerSide}`, - parentId: level.id, - children: [ - `cabinet-module_reflow-l-slack-${cornerSide}-left`, - `cabinet-module_reflow-l-slack-${cornerSide}-right`, - ], - }) - const sourceIsLeft = cornerSide === 'left' - const left = CabinetModuleNode.parse({ - id: `cabinet-module_reflow-l-slack-${cornerSide}-left`, - parentId: run.id, - position: sourceIsLeft ? [-0.4, 0.1, 0] : [-0.25, 0.1, 0], - width: sourceIsLeft ? 0.8 : 0.5, - }) - const right = CabinetModuleNode.parse({ - id: `cabinet-module_reflow-l-slack-${cornerSide}-right`, - parentId: run.id, - position: sourceIsLeft ? [0.25, 0.1, 0] : [0.4, 0.1, 0], - width: sourceIsLeft ? 0.5 : 0.8, - }) - const source = sourceIsLeft ? left : right - const selected = sourceIsLeft ? right : left - const outerEdges = sourceIsLeft ? [-0.8, 0.5] : [-0.5, 0.8] - const walls = outerEdges.map((edge, index) => { - const side = index === 0 ? -1 : 1 - const x = edge + side * 0.23 - return WallNode.parse({ - id: `wall_reflow-l-slack-${cornerSide}-${index}`, - parentId: level.id, - start: [x, -1], - end: [x, 1], - thickness: 0.2, - }) - }) - seedScene([level, run, left, right] as AnyNode[], level.id as AnyNodeId) - const sceneApi = createSceneApi(useScene) - expect(addCornerRun({ module: source, run, sceneApi, side: cornerSide })).toBeTruthy() - for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId) - - const nodesBefore = useScene.getState().nodes - const liveRun = nodesBefore[run.id] as ReturnType - const liveModules = liveRun.children - .map((id) => nodesBefore[id]) - .filter((node): node is ReturnType => - Boolean(node?.type === 'cabinet-module'), - ) - const liveSelected = nodesBefore[selected.id] as ReturnType - const derivedBaseRun = Object.values(nodesBefore).find( - (node): node is ReturnType => - node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', - )! - const footprintBefore = runModuleBounds(derivedBaseRun.id, nodesBefore) - const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) - const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) } - - expect(constraints.left.slack).toBeCloseTo(0.13) - expect(constraints.right.slack).toBeCloseTo(0.13) - expect( - reflowRunModules({ - modules: liveModules, - parentRun: liveRun, - patch: cabinetPresetById('fridge-single').createPatch(liveRun), - scene: useScene.getState(), - selected: liveSelected, - }), - ).toBe(true) - - const nodesAfter = useScene.getState().nodes - const sourceAfter = nodesAfter[source.id] as ReturnType - const liveModulesAfter = liveRun.children - .map((id) => nodesAfter[id]) - .filter((node): node is ReturnType => - Boolean(node?.type === 'cabinet-module'), - ) - const footprintAfter = runModuleBounds(derivedBaseRun.id, nodesAfter) - expect(sourceAfter.width).toBeCloseTo(0.54) - expect( - (nodesAfter[selected.id] as ReturnType).width, - ).toBeCloseTo(0.76) - expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX) - expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX) - const sideWall = cornerSide === 'left' ? walls[0]! : walls[1]! - const sideWallInnerFace = - sideWall.start[0] + ((cornerSide === 'left' ? 1 : -1) * (sideWall.thickness ?? 0.2)) / 2 - if (cornerSide === 'left') { - expect(footprintBefore.minX).toBeLessThan(sideWallInnerFace) - expect(footprintAfter.minX).toBeGreaterThanOrEqual(sideWallInnerFace - 1e-4) - } else { - expect(footprintBefore.maxX).toBeGreaterThan(sideWallInnerFace) - expect(footprintAfter.maxX).toBeLessThanOrEqual(sideWallInnerFace + 1e-4) - } - expect(footprintAfter.minZ).toBeCloseTo(footprintBefore.minZ) - expect(footprintAfter.maxZ).toBeCloseTo(footprintBefore.maxZ) - }) + expect(footprintAfter.minZ).toBeCloseTo(footprintBefore.minZ) + expect(footprintAfter.maxZ).toBeCloseTo(footprintBefore.maxZ) + }, + ) test('keeps the native L footprint fixed when its corner source wins donor selection', () => { const level = LevelNode.parse({ id: 'level_reflow-native-l' }) @@ -908,97 +907,97 @@ describe('cabinet preset run reflow', () => { { endSide: 'left', turnSide: 'right' }, { endSide: 'right', turnSide: 'left' }, { endSide: 'right', turnSide: 'right' }, - ] as const)('honors derived-leg walls for an open $endSide-end/$turnSide-turn source run', ({ - endSide, - turnSide, - }) => { - const suffix = `${endSide}-${turnSide}` - const level = LevelNode.parse({ id: `level_reflow-l-leg-base-${suffix}` }) - const run = CabinetNode.parse({ - id: `cabinet_reflow-l-leg-base-${suffix}`, - parentId: level.id, - children: - endSide === 'left' - ? [ - `cabinet-module_reflow-l-leg-base-source-${suffix}`, - `cabinet-module_reflow-l-leg-base-donor-${suffix}`, - ] - : [ - `cabinet-module_reflow-l-leg-base-donor-${suffix}`, - `cabinet-module_reflow-l-leg-base-source-${suffix}`, - ], - }) - const source = CabinetModuleNode.parse({ - id: `cabinet-module_reflow-l-leg-base-source-${suffix}`, - parentId: run.id, - position: [endSide === 'left' ? -0.4 : 0.4, 0.1, 0], - width: 0.5, - }) - const donor = CabinetModuleNode.parse({ - id: `cabinet-module_reflow-l-leg-base-donor-${suffix}`, - parentId: run.id, - position: [endSide === 'left' ? 0.25 : -0.25, 0.1, 0], - width: 0.8, - }) - seedScene([level, run, source, donor] as AnyNode[], level.id as AnyNodeId) - const sceneApi = createSceneApi(useScene) - expect(addCornerRun({ module: source, run, sceneApi, side: turnSide })).toBeTruthy() - - const nodesBeforeWalls = useScene.getState().nodes - const legRun = derivedBaseRunForSource(source.id, nodesBeforeWalls) - const legModules = legRun.children - .map((id) => nodesBeforeWalls[id]) - .filter((node): node is ReturnType => - Boolean(node?.type === 'cabinet-module'), - ) - const selected = legModules.find((module) => module.name === 'Base Cabinet')! - const transform = worldTransform(legRun, nodesBeforeWalls) - const cos = Math.cos(transform.rotation) - const sin = Math.sin(transform.rotation) - const wallAxis: [number, number] = [sin, cos] - for (const [index, localX] of [runMinX(legModules), runMaxX(legModules)].entries()) { - const x = transform.position[0] + localX * cos - const z = transform.position[2] - localX * sin - sceneApi.upsert( - WallNode.parse({ - id: `wall_reflow-l-leg-base-${suffix}-${index}`, - parentId: level.id, - start: [x - wallAxis[0], z - wallAxis[1]], - end: [x + wallAxis[0], z + wallAxis[1]], - }) as AnyNode, - level.id as AnyNodeId, - ) - } - - const nodesBefore = useScene.getState().nodes - const footprintBefore = moduleSubtreeBounds(legRun.id, nodesBefore) - expect(wallConstraintFlags(runWallConstraints(run, [source, donor], nodesBefore))).toEqual({ - left: false, - right: false, - }) - expect(wallConstraintFlags(runWallConstraints(legRun, legModules, nodesBefore))).toEqual({ - left: true, - right: true, - }) - expect( - reflowRunModules({ - modules: legModules, - parentRun: legRun, - patch: cabinetPresetById('fridge-single').createPatch(legRun), - scene: useScene.getState(), - selected, - }), - ).toBe(true) + ] as const)( + 'honors derived-leg walls for an open $endSide-end/$turnSide-turn source run', + ({ endSide, turnSide }) => { + const suffix = `${endSide}-${turnSide}` + const level = LevelNode.parse({ id: `level_reflow-l-leg-base-${suffix}` }) + const run = CabinetNode.parse({ + id: `cabinet_reflow-l-leg-base-${suffix}`, + parentId: level.id, + children: + endSide === 'left' + ? [ + `cabinet-module_reflow-l-leg-base-source-${suffix}`, + `cabinet-module_reflow-l-leg-base-donor-${suffix}`, + ] + : [ + `cabinet-module_reflow-l-leg-base-donor-${suffix}`, + `cabinet-module_reflow-l-leg-base-source-${suffix}`, + ], + }) + const source = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-leg-base-source-${suffix}`, + parentId: run.id, + position: [endSide === 'left' ? -0.4 : 0.4, 0.1, 0], + width: 0.5, + }) + const donor = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-leg-base-donor-${suffix}`, + parentId: run.id, + position: [endSide === 'left' ? 0.25 : -0.25, 0.1, 0], + width: 0.8, + }) + seedScene([level, run, source, donor] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: turnSide })).toBeTruthy() + + const nodesBeforeWalls = useScene.getState().nodes + const legRun = derivedBaseRunForSource(source.id, nodesBeforeWalls) + const legModules = legRun.children + .map((id) => nodesBeforeWalls[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const selected = legModules.find((module) => module.name === 'Base Cabinet')! + const transform = worldTransform(legRun, nodesBeforeWalls) + const cos = Math.cos(transform.rotation) + const sin = Math.sin(transform.rotation) + const wallAxis: [number, number] = [sin, cos] + for (const [index, localX] of [runMinX(legModules), runMaxX(legModules)].entries()) { + const x = transform.position[0] + localX * cos + const z = transform.position[2] - localX * sin + sceneApi.upsert( + WallNode.parse({ + id: `wall_reflow-l-leg-base-${suffix}-${index}`, + parentId: level.id, + start: [x - wallAxis[0], z - wallAxis[1]], + end: [x + wallAxis[0], z + wallAxis[1]], + }) as AnyNode, + level.id as AnyNodeId, + ) + } - const nodesAfter = useScene.getState().nodes - expect( - (nodesAfter[selected.id] as ReturnType).width, - ).toBeCloseTo(0.76) - const footprintAfter = moduleSubtreeBounds(legRun.id, nodesAfter) - const footprintLength = (bounds: ReturnType) => - bounds.maxX - bounds.minX + (bounds.maxZ - bounds.minZ) - expect(footprintLength(footprintAfter) - footprintLength(footprintBefore)).toBeCloseTo(0) - }) + const nodesBefore = useScene.getState().nodes + const footprintBefore = moduleSubtreeBounds(legRun.id, nodesBefore) + expect(wallConstraintFlags(runWallConstraints(run, [source, donor], nodesBefore))).toEqual({ + left: false, + right: false, + }) + expect(wallConstraintFlags(runWallConstraints(legRun, legModules, nodesBefore))).toEqual({ + left: true, + right: true, + }) + expect( + reflowRunModules({ + modules: legModules, + parentRun: legRun, + patch: cabinetPresetById('fridge-single').createPatch(legRun), + scene: useScene.getState(), + selected, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + expect( + (nodesAfter[selected.id] as ReturnType).width, + ).toBeCloseTo(0.76) + const footprintAfter = moduleSubtreeBounds(legRun.id, nodesAfter) + const footprintLength = (bounds: ReturnType) => + bounds.maxX - bounds.minX + (bounds.maxZ - bounds.minZ) + expect(footprintLength(footprintAfter) - footprintLength(footprintBefore)).toBeCloseTo(0) + }, + ) test('uses only the real source wall when both source ends have L returns', () => { const level = LevelNode.parse({ id: 'level_reflow-two-corners' }) diff --git a/packages/nodes/src/cabinet/__tests__/stack.test.ts b/packages/nodes/src/cabinet/__tests__/stack.test.ts index afd0dfeebe..d598c88d9b 100644 --- a/packages/nodes/src/cabinet/__tests__/stack.test.ts +++ b/packages/nodes/src/cabinet/__tests__/stack.test.ts @@ -617,44 +617,44 @@ describe('appliance compartments', () => { ) }) - test.each([ - 'shelf', - 'drawer', - ] as const)('switching a pull-out pantry to %s restores a default base cabinet', (type) => { - const parentRun = CabinetNode.parse({ - carcassHeight: 0.72, - depth: 0.58, - plinthHeight: 0.1, - toeKickDepth: 0.075, - }) - const node = CabinetModuleNode.parse({ - cabinetType: 'tall', - width: PULL_OUT_PANTRY_STANDARD_WIDTH, - carcassHeight: TALL_CABINET_CARCASS_HEIGHT, - stack: [newCabinetCompartment('pull-out-pantry')], - }) - - const transition = resolveCompartmentTransition({ - node, - parentRun, - index: 0, - next: { ...newCabinetCompartment(type), id: node.stack![0]!.id }, - }) - - expect(transition.stack).toHaveLength(1) - expect(transition.stack[0]!.type).toBe(type) - expect(transition.stack[0]!.height).toBeUndefined() - expect(transition.modulePatch).toEqual( - expect.objectContaining({ - cabinetType: 'base', - width: 0.5, - depth: parentRun.depth, - carcassHeight: parentRun.carcassHeight, - plinthHeight: parentRun.plinthHeight, - toeKickDepth: parentRun.toeKickDepth, - }), - ) - }) + test.each(['shelf', 'drawer'] as const)( + 'switching a pull-out pantry to %s restores a default base cabinet', + (type) => { + const parentRun = CabinetNode.parse({ + carcassHeight: 0.72, + depth: 0.58, + plinthHeight: 0.1, + toeKickDepth: 0.075, + }) + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: PULL_OUT_PANTRY_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [newCabinetCompartment('pull-out-pantry')], + }) + + const transition = resolveCompartmentTransition({ + node, + parentRun, + index: 0, + next: { ...newCabinetCompartment(type), id: node.stack![0]!.id }, + }) + + expect(transition.stack).toHaveLength(1) + expect(transition.stack[0]!.type).toBe(type) + expect(transition.stack[0]!.height).toBeUndefined() + expect(transition.modulePatch).toEqual( + expect.objectContaining({ + cabinetType: 'base', + width: 0.5, + depth: parentRun.depth, + carcassHeight: parentRun.carcassHeight, + plinthHeight: parentRun.plinthHeight, + toeKickDepth: parentRun.toeKickDepth, + }), + ) + }, + ) test.each([ ['fridge-single', 'shelf'], @@ -932,58 +932,61 @@ describe('reflowCabinetRunModules', () => { expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.75) }) - test.each([ - 'left', - 'right', - ] as const)('consumes a constrained %s wall gap before growing toward the open end', (side) => { - const modules = [ - { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 }, - { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, - { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 }, - ] - - const reflowed = reflowCabinetRunModules(modules, 'middle', 0.7, { - wallConstraints: { - left: { constrained: side === 'left', slack: side === 'left' ? 0.1 : 0 }, - right: { constrained: side === 'right', slack: side === 'right' ? 0.1 : 0 }, - }, - }) - - expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.7, 0.5]) - expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-0.85) - expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.85) - }) + test.each(['left', 'right'] as const)( + 'consumes a constrained %s wall gap before growing toward the open end', + (side) => { + const modules = [ + { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.7, { + wallConstraints: { + left: { constrained: side === 'left', slack: side === 'left' ? 0.1 : 0 }, + right: { constrained: side === 'right', slack: side === 'right' ? 0.1 : 0 }, + }, + }) + + expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.7, 0.5]) + expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-0.85) + expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.85) + }, + ) test.each([ ['left', 'right', -1], ['right', 'left', 1], - ] as const)('manual %s resize uses only the dragged wall gap', (side, oppositeSide, direction) => { - const modules = [ - { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 }, - { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, - { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 }, - ] - const reflowed = reflowCabinetRunModules(modules, 'middle', 0.58, { - resizeSide: side, - wallConstraints: { - left: { constrained: true, slack: side === 'left' ? 0.1 : 0.2 }, - right: { constrained: true, slack: side === 'right' ? 0.1 : 0.2 }, - }, - }) - const left = reflowed.find((module) => module.id === 'left')! - const right = reflowed.find((module) => module.id === 'right')! - const oppositeEdge = reflowed.find((module) => module.id === oppositeSide)! - - expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.58, 0.5]) - expect(oppositeEdge.width).toBeCloseTo(0.5) - if (direction > 0) { - expect(right.position[0]).toBeGreaterThan(0.5) - expect(left.position[0]).toBeCloseTo(-0.5) - } else { - expect(left.position[0]).toBeLessThan(-0.5) - expect(right.position[0]).toBeCloseTo(0.5) - } - }) + ] as const)( + 'manual %s resize uses only the dragged wall gap', + (side, oppositeSide, direction) => { + const modules = [ + { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.58, { + resizeSide: side, + wallConstraints: { + left: { constrained: true, slack: side === 'left' ? 0.1 : 0.2 }, + right: { constrained: true, slack: side === 'right' ? 0.1 : 0.2 }, + }, + }) + const left = reflowed.find((module) => module.id === 'left')! + const right = reflowed.find((module) => module.id === 'right')! + const oppositeEdge = reflowed.find((module) => module.id === oppositeSide)! + + expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.58, 0.5]) + expect(oppositeEdge.width).toBeCloseTo(0.5) + if (direction > 0) { + expect(right.position[0]).toBeGreaterThan(0.5) + expect(left.position[0]).toBeCloseTo(-0.5) + } else { + expect(left.position[0]).toBeLessThan(-0.5) + expect(right.position[0]).toBeCloseTo(0.5) + } + }, + ) test('detects perpendicular wall constraints at each run end', () => { const level = LevelNode.parse({ id: 'level_run-constraints' }) diff --git a/packages/nodes/src/cabinet/__tests__/surfaces.test.ts b/packages/nodes/src/cabinet/__tests__/surfaces.test.ts index 719090d6c4..b866b58782 100644 --- a/packages/nodes/src/cabinet/__tests__/surfaces.test.ts +++ b/packages/nodes/src/cabinet/__tests__/surfaces.test.ts @@ -154,18 +154,18 @@ describe('cabinet hosting surfaces', () => { dispose(checkMeshAgreement(f)) }) - test.each([ - 'wall', - 'tall', - ] as const)('%s runs publish nothing even with countertop and bar flags', (runTier) => { - const f = fixture([moduleAt(0)], { - runTier, - withCountertop: true, - barLedge: { edge: 'back', height: 1.1, depth: 0.3 }, - }) - expect(f.surfaces).toEqual([]) - dispose(checkMeshAgreement(f)) - }) + test.each(['wall', 'tall'] as const)( + '%s runs publish nothing even with countertop and bar flags', + (runTier) => { + const f = fixture([moduleAt(0)], { + runTier, + withCountertop: true, + barLedge: { edge: 'back', height: 1.1, depth: 0.3 }, + }) + expect(f.surfaces).toEqual([]) + dispose(checkMeshAgreement(f)) + }, + ) test('empty runs, all-tall modules, disabled and zero-thickness countertops publish nothing', () => { for (const f of [ @@ -179,72 +179,71 @@ describe('cabinet hosting surfaces', () => { } }) - test.each([ - 'single', - 'double', - 'double-offset', - ] as const)('%s sink openings match the cut mesh, including the rim reveal', (sinkLayout) => { - const sink = moduleAt(0.8, { - width: 0.8, - position: [0.8, 0.1, 0.15], - depth: 0.7, - stack: [{ id: 'sink', type: 'sink', sinkLayout }], - }) - const f = fixture([sink], { countertopBackOverhang: 0.3 }) - const surface = f.surfaces[0]! - expect(surface.region!.holes).toHaveLength(sinkLayout === 'single' ? 2 : 3) - const geometry = checkMeshAgreement(f) - const mesh = geometry.getObjectByName('cabinet-run-countertop')! - const ray = new Raycaster() - const hits = (x: number, z: number) => { - ray.set(new Vector3(x, 3, z), new Vector3(0, -1, 0)) - return ray.intersectObject(mesh, false) - } - for (const hole of surface.region!.holes!.slice(0, -1)) { - const minX = hole[0]![0] - const maxX = hole[1]![0] - const minZ = hole[0]![1] - const maxZ = hole[2]![1] - const x = (minX + maxX) / 2 - const z = (minZ + maxZ) / 2 - expect(contains(surface, x, z)).toBe(false) - expect(hits(x, z)).toHaveLength(0) - expect(hits(minX + 0.002, z)).toHaveLength(0) - expect(hits(minX - 0.002, z)[0]!.point.y).toBeCloseTo(surface.position[1], 6) - expect(hits(x, maxZ - 0.002)).toHaveLength(0) - expect(hits(x, maxZ + 0.002)[0]!.point.y).toBeCloseTo(surface.position[1], 6) - } - const faucet = surface.region!.holes!.at(-1)! - const faucetX = faucet.reduce((sum, point) => sum + point[0], 0) / faucet.length - const faucetZ = faucet.reduce((sum, point) => sum + point[1], 0) / faucet.length - expect(contains(surface, faucetX, faucetZ)).toBe(false) - expect(hits(faucetX, faucetZ)[0]!.point.y).toBeCloseTo(surface.position[1], 6) - dispose(geometry) - }) + test.each(['single', 'double', 'double-offset'] as const)( + '%s sink openings match the cut mesh, including the rim reveal', + (sinkLayout) => { + const sink = moduleAt(0.8, { + width: 0.8, + position: [0.8, 0.1, 0.15], + depth: 0.7, + stack: [{ id: 'sink', type: 'sink', sinkLayout }], + }) + const f = fixture([sink], { countertopBackOverhang: 0.3 }) + const surface = f.surfaces[0]! + expect(surface.region!.holes).toHaveLength(sinkLayout === 'single' ? 2 : 3) + const geometry = checkMeshAgreement(f) + const mesh = geometry.getObjectByName('cabinet-run-countertop')! + const ray = new Raycaster() + const hits = (x: number, z: number) => { + ray.set(new Vector3(x, 3, z), new Vector3(0, -1, 0)) + return ray.intersectObject(mesh, false) + } + for (const hole of surface.region!.holes!.slice(0, -1)) { + const minX = hole[0]![0] + const maxX = hole[1]![0] + const minZ = hole[0]![1] + const maxZ = hole[2]![1] + const x = (minX + maxX) / 2 + const z = (minZ + maxZ) / 2 + expect(contains(surface, x, z)).toBe(false) + expect(hits(x, z)).toHaveLength(0) + expect(hits(minX + 0.002, z)).toHaveLength(0) + expect(hits(minX - 0.002, z)[0]!.point.y).toBeCloseTo(surface.position[1], 6) + expect(hits(x, maxZ - 0.002)).toHaveLength(0) + expect(hits(x, maxZ + 0.002)[0]!.point.y).toBeCloseTo(surface.position[1], 6) + } + const faucet = surface.region!.holes!.at(-1)! + const faucetX = faucet.reduce((sum, point) => sum + point[0], 0) / faucet.length + const faucetZ = faucet.reduce((sum, point) => sum + point[1], 0) / faucet.length + expect(contains(surface, faucetX, faucetZ)).toBe(false) + expect(hits(faucetX, faucetZ)[0]!.point.y).toBeCloseTo(surface.position[1], 6) + dispose(geometry) + }, + ) - test.each([ - 'cooktop-gas', - 'cooktop-induction', - ] as const)('%s footprint is a hole matching the rendered frame', (type) => { - const module = moduleAt(0.7, { width: 0.8, stack: [{ id: 'cooktop', type }] }) - const f = fixture([module]) - const surface = f.surfaces[0]! - expect(surface.region!.holes).toHaveLength(1) - expect(contains(surface, 0.7, 0)).toBe(false) - expect(contains(surface, 0.7, 0.31)).toBe(true) - const geometry = buildCabinetGeometry(module, { ...f.ctx, parent: f.run }) - geometry.position.set(...module.position) - geometry.updateMatrixWorld(true) - const frames = geometry.children.filter((child) => child.name.includes('-frame-')) - const frame = new Box3() - for (const object of frames) frame.union(new Box3().setFromObject(object)) - const hole = surface.region!.holes![0]! - expect(hole[0]![0]).toBeCloseTo(frame.min.x, 6) - expect(hole[0]![1]).toBeCloseTo(frame.min.z, 6) - expect(hole[2]![0]).toBeCloseTo(frame.max.x, 6) - expect(hole[2]![1]).toBeCloseTo(frame.max.z, 6) - dispose(geometry) - }) + test.each(['cooktop-gas', 'cooktop-induction'] as const)( + '%s footprint is a hole matching the rendered frame', + (type) => { + const module = moduleAt(0.7, { width: 0.8, stack: [{ id: 'cooktop', type }] }) + const f = fixture([module]) + const surface = f.surfaces[0]! + expect(surface.region!.holes).toHaveLength(1) + expect(contains(surface, 0.7, 0)).toBe(false) + expect(contains(surface, 0.7, 0.31)).toBe(true) + const geometry = buildCabinetGeometry(module, { ...f.ctx, parent: f.run }) + geometry.position.set(...module.position) + geometry.updateMatrixWorld(true) + const frames = geometry.children.filter((child) => child.name.includes('-frame-')) + const frame = new Box3() + for (const object of frames) frame.union(new Box3().setFromObject(object)) + const hole = surface.region!.holes![0]! + expect(hole[0]![0]).toBeCloseTo(frame.min.x, 6) + expect(hole[0]![1]).toBeCloseTo(frame.min.z, 6) + expect(hole[2]![0]).toBeCloseTo(frame.max.x, 6) + expect(hole[2]![1]).toBeCloseTo(frame.max.z, 6) + dispose(geometry) + }, + ) test('under-counter appliances keep the usable countertop', () => { const f = fixture([ @@ -271,26 +270,25 @@ describe('cabinet hosting surfaces', () => { dispose(checkMeshAgreement(f)) }) - test.each([ - 'back', - 'left', - 'right', - ] as const)('%s bar ledge has a distinct id, footprint and height', (edge) => { - const f = fixture([moduleAt(0), moduleAt(0.9)], { - withFinishedBack: true, - countertopBackOverhang: 0.3, - barLedge: { edge, height: 1.1, depth: 0.35 }, - }) - const bars = f.surfaces.filter((surface) => surface.label === 'Bar ledge') - expect(bars).toHaveLength(edge === 'back' ? 2 : 1) - expect(new Set(f.surfaces.map((surface) => surface.id)).size).toBe(f.surfaces.length) - for (const bar of bars) expect(bar.position[1]).toBeCloseTo(1.1) - for (const top of f.surfaces.filter((surface) => surface.label === 'Countertop')) { - expect(top.position[1]).toBeCloseTo(0.85) - expect(extent(top).minZ).toBeCloseTo(edge === 'back' ? -0.3 : -0.6) - } - dispose(checkMeshAgreement(f)) - }) + test.each(['back', 'left', 'right'] as const)( + '%s bar ledge has a distinct id, footprint and height', + (edge) => { + const f = fixture([moduleAt(0), moduleAt(0.9)], { + withFinishedBack: true, + countertopBackOverhang: 0.3, + barLedge: { edge, height: 1.1, depth: 0.35 }, + }) + const bars = f.surfaces.filter((surface) => surface.label === 'Bar ledge') + expect(bars).toHaveLength(edge === 'back' ? 2 : 1) + expect(new Set(f.surfaces.map((surface) => surface.id)).size).toBe(f.surfaces.length) + for (const bar of bars) expect(bar.position[1]).toBeCloseTo(1.1) + for (const top of f.surfaces.filter((surface) => surface.label === 'Countertop')) { + expect(top.position[1]).toBeCloseTo(0.85) + expect(extent(top).minZ).toBeCloseTo(edge === 'back' ? -0.3 : -0.6) + } + dispose(checkMeshAgreement(f)) + }, + ) test('a bar still publishes when the main countertop is disabled, as the mesh does', () => { const f = fixture([moduleAt(0)], { @@ -427,48 +425,51 @@ describe('cabinet hosting surfaces', () => { ['Countertop', 'procedural-item', 0.85, 0.113], ['Bar ledge', 'item', 1.1, -0.437], ['Bar ledge', 'procedural-item', 1.1, -0.437], - ] as const)('%s grid-snaps a %s in the host frame without electing a centre', (label, childKind, y, z) => { - const f = fixture([moduleAt(2), moduleAt(2.6)], { - position: [4, 0.4, -3], - rotation: Math.PI / 3, - barLedge: { edge: 'back', height: 1.1, depth: 0.35 }, - }) - const args = { - host: f.run, - childKind, - childId: `${childKind}_existing`, - childFootprint: { size: [0.1, 0.2, 0.1] as const, rotationY: 0.3 }, - hit: { point: [2.123, y, z] as const, normalWorldY: 1 }, - scene: f.scene as SceneApi, - } - const surface = f.surfaces.find((candidate) => candidate.label === label)! - const unsnapped = resolveSurfacePlacement(args) - expect(unsnapped).toMatchObject({ - position: [2.123, surface.position[1], z], - rotationY: 0.3, - childFrame: 'host-local', - surfaceId: surface.id, - surfaceLocal: { rotationY: 0.3, rotation: [0, 0.3, 0] }, - }) - expect(unsnapped!.position[1]).toBeCloseTo(y) - expect(unsnapped!.surfaceLocal!.position[0]).toBe(2.123) - expect(unsnapped!.surfaceLocal!.position[1]).toBeCloseTo(0) - expect(unsnapped!.surfaceLocal!.position[2]).toBe(z) - const snapScalar = mock( - (value: number, dimension: number) => - Math.round((value - dimension / 2) / 0.05) * 0.05 + dimension / 2, - ) - const placed = resolveSurfacePlacement({ ...args, snapScalar }) - expect(snapScalar.mock.calls).toEqual([ - [2.123, 0.1], - [z, 0.1], - ]) - expect(placed?.position[0]).toBeCloseTo(2.1) - expect(placed?.position[1]).toBeCloseTo(y) - expect(placed?.position[2]).toBeCloseTo(label === 'Countertop' ? 0.1 : -0.45) - expect(placed?.childFrame).toBe('host-local') - expect(placed?.surfaceId).toBe(surface.id) - }) + ] as const)( + '%s grid-snaps a %s in the host frame without electing a centre', + (label, childKind, y, z) => { + const f = fixture([moduleAt(2), moduleAt(2.6)], { + position: [4, 0.4, -3], + rotation: Math.PI / 3, + barLedge: { edge: 'back', height: 1.1, depth: 0.35 }, + }) + const args = { + host: f.run, + childKind, + childId: `${childKind}_existing`, + childFootprint: { size: [0.1, 0.2, 0.1] as const, rotationY: 0.3 }, + hit: { point: [2.123, y, z] as const, normalWorldY: 1 }, + scene: f.scene as SceneApi, + } + const surface = f.surfaces.find((candidate) => candidate.label === label)! + const unsnapped = resolveSurfacePlacement(args) + expect(unsnapped).toMatchObject({ + position: [2.123, surface.position[1], z], + rotationY: 0.3, + childFrame: 'host-local', + surfaceId: surface.id, + surfaceLocal: { rotationY: 0.3, rotation: [0, 0.3, 0] }, + }) + expect(unsnapped!.position[1]).toBeCloseTo(y) + expect(unsnapped!.surfaceLocal!.position[0]).toBe(2.123) + expect(unsnapped!.surfaceLocal!.position[1]).toBeCloseTo(0) + expect(unsnapped!.surfaceLocal!.position[2]).toBe(z) + const snapScalar = mock( + (value: number, dimension: number) => + Math.round((value - dimension / 2) / 0.05) * 0.05 + dimension / 2, + ) + const placed = resolveSurfacePlacement({ ...args, snapScalar }) + expect(snapScalar.mock.calls).toEqual([ + [2.123, 0.1], + [z, 0.1], + ]) + expect(placed?.position[0]).toBeCloseTo(2.1) + expect(placed?.position[1]).toBeCloseTo(y) + expect(placed?.position[2]).toBeCloseTo(label === 'Countertop' ? 0.1 : -0.45) + expect(placed?.childFrame).toBe('host-local') + expect(placed?.surfaceId).toBe(surface.id) + }, + ) test('preserves the full XYZ surface-local rotation', () => { const f = fixture([moduleAt(0)]) diff --git a/packages/nodes/src/cabinet/__tests__/top-finish.test.ts b/packages/nodes/src/cabinet/__tests__/top-finish.test.ts index 1f8a7cf41d..ee03335991 100644 --- a/packages/nodes/src/cabinet/__tests__/top-finish.test.ts +++ b/packages/nodes/src/cabinet/__tests__/top-finish.test.ts @@ -56,22 +56,21 @@ test('trim finish adds a solid ceiling closure', () => { geometry.clear() }) -test.each([ - 'Corner Filler', - 'Wall Bridge Filler', - 'Corner Wall Filler', -])('%s renders its selected top cabinet finish', (name) => { - const geometry = buildCabinetGeometry( - CabinetModuleNode.parse({ - moduleKind: 'corner-filler', - name, - topFinish: 'top-cabinet', - }), - ) - - expect(geometry.getObjectByName('cabinet-top-cabinet-top')).toBeDefined() - geometry.clear() -}) +test.each(['Corner Filler', 'Wall Bridge Filler', 'Corner Wall Filler'])( + '%s renders its selected top cabinet finish', + (name) => { + const geometry = buildCabinetGeometry( + CabinetModuleNode.parse({ + moduleKind: 'corner-filler', + name, + topFinish: 'top-cabinet', + }), + ) + + expect(geometry.getObjectByName('cabinet-top-cabinet-top')).toBeDefined() + geometry.clear() + }, +) test.each([ ['Corner Filler', 'left'], @@ -105,27 +104,27 @@ test.each([ geometry.clear() }) -test.each([ - 'left', - 'right', -] as const)('top cabinet mirrors the parent cabinet open %s side', (openSide) => { - const module = CabinetModuleNode.parse({ - openSide, - topFinish: 'top-cabinet', - }) - const geometry = buildCabinetGeometry(module) - const closedSide = openSide === 'left' ? 'right' : 'left' - const expectedInteriorCenterX = - openSide === 'left' ? -module.boardThickness / 2 : module.boardThickness / 2 - - expect(geometry.getObjectByName(`cabinet-side-${openSide}`)).toBeUndefined() - expect(geometry.getObjectByName(`cabinet-top-cabinet-side-${openSide}`)).toBeUndefined() - expect(geometry.getObjectByName(`cabinet-top-cabinet-side-${closedSide}`)).toBeDefined() - expect(geometry.getObjectByName('cabinet-top-cabinet-bottom')?.position.x).toBeCloseTo( - expectedInteriorCenterX, - ) - geometry.clear() -}) +test.each(['left', 'right'] as const)( + 'top cabinet mirrors the parent cabinet open %s side', + (openSide) => { + const module = CabinetModuleNode.parse({ + openSide, + topFinish: 'top-cabinet', + }) + const geometry = buildCabinetGeometry(module) + const closedSide = openSide === 'left' ? 'right' : 'left' + const expectedInteriorCenterX = + openSide === 'left' ? -module.boardThickness / 2 : module.boardThickness / 2 + + expect(geometry.getObjectByName(`cabinet-side-${openSide}`)).toBeUndefined() + expect(geometry.getObjectByName(`cabinet-top-cabinet-side-${openSide}`)).toBeUndefined() + expect(geometry.getObjectByName(`cabinet-top-cabinet-side-${closedSide}`)).toBeDefined() + expect(geometry.getObjectByName('cabinet-top-cabinet-bottom')?.position.x).toBeCloseTo( + expectedInteriorCenterX, + ) + geometry.clear() + }, +) test('top cabinet doors reuse the parent overlay and inset reveal rules', () => { const overlayNode = CabinetModuleNode.parse({ diff --git a/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts b/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts index 4acff7a300..a3cd475fa6 100644 --- a/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts +++ b/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts @@ -880,252 +880,270 @@ describe('wall cabinet depth handles', () => { test.each([ ['left', 'max', -1], ['right', 'min', 1], - ] as const)('resizes the first connected %s cabinet inversely in preview and commit', (side, anchor, direction) => { - const { baseA, nodes, root, sceneApi, wallA } = wallDepthFixture() - const neighbor = CabinetModuleNode.parse({ - id: `cabinet-module_inverse-${side}-neighbor`, - parentId: root.id, - children: [`cabinet-module_inverse-${side}-wall`], - position: [direction * baseA.width, baseA.position[1], baseA.position[2]], - }) - const neighborWall = CabinetModuleNode.parse({ - id: `cabinet-module_inverse-${side}-wall`, - name: 'Wall Cabinet', - parentId: neighbor.id, - position: [0, 1.35, -0.13], - depth: 0.32, - }) - const fartherCabinet = CabinetModuleNode.parse({ - id: `cabinet-module_inverse-${side}-farther`, - parentId: root.id, - position: [direction * baseA.width * 2, baseA.position[1], baseA.position[2]], - }) - nodes[neighbor.id as AnyNodeId] = neighbor as AnyNode - nodes[neighborWall.id as AnyNodeId] = neighborWall as AnyNode - nodes[fartherCabinet.id as AnyNodeId] = fartherCabinet as AnyNode - nodes[root.id as AnyNodeId] = { - ...root, - children: [baseA.id, neighbor.id, fartherCabinet.id], - } as AnyNode - const buildModuleHandles = cabinetModuleDefinition.handles as ( - node: CabinetModuleNodeType, - sceneApi: SceneApi, - ) => HandleDescriptor[] - const widthHandle = buildModuleHandles(baseA, sceneApi).find( - (handle): handle is LinearResizeHandle => - handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === anchor, - )! - const delta = 0.1 - const nextWidth = baseA.width + delta - const neighborPositionX = neighbor.position[0] - const selectedPatch = widthHandle.apply(baseA, nextWidth, sceneApi) - const previewOverrides = new Map( - widthHandle.previewOverrides?.(baseA, nextWidth, sceneApi) ?? [], - ) + ] as const)( + 'resizes the first connected %s cabinet inversely in preview and commit', + (side, anchor, direction) => { + const { baseA, nodes, root, sceneApi, wallA } = wallDepthFixture() + const neighbor = CabinetModuleNode.parse({ + id: `cabinet-module_inverse-${side}-neighbor`, + parentId: root.id, + children: [`cabinet-module_inverse-${side}-wall`], + position: [direction * baseA.width, baseA.position[1], baseA.position[2]], + }) + const neighborWall = CabinetModuleNode.parse({ + id: `cabinet-module_inverse-${side}-wall`, + name: 'Wall Cabinet', + parentId: neighbor.id, + position: [0, 1.35, -0.13], + depth: 0.32, + }) + const fartherCabinet = CabinetModuleNode.parse({ + id: `cabinet-module_inverse-${side}-farther`, + parentId: root.id, + position: [direction * baseA.width * 2, baseA.position[1], baseA.position[2]], + }) + nodes[neighbor.id as AnyNodeId] = neighbor as AnyNode + nodes[neighborWall.id as AnyNodeId] = neighborWall as AnyNode + nodes[fartherCabinet.id as AnyNodeId] = fartherCabinet as AnyNode + nodes[root.id as AnyNodeId] = { + ...root, + children: [baseA.id, neighbor.id, fartherCabinet.id], + } as AnyNode + const buildModuleHandles = cabinetModuleDefinition.handles as ( + node: CabinetModuleNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const widthHandle = buildModuleHandles(baseA, sceneApi).find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === anchor, + )! + const delta = 0.1 + const nextWidth = baseA.width + delta + const neighborPositionX = neighbor.position[0] + const selectedPatch = widthHandle.apply(baseA, nextWidth, sceneApi) + const previewOverrides = new Map( + widthHandle.previewOverrides?.(baseA, nextWidth, sceneApi) ?? [], + ) - expect(previewOverrides.get(wallA.id as AnyNodeId)?.width).toBeCloseTo(nextWidth) - expect(previewOverrides.get(neighbor.id as AnyNodeId)?.width).toBeCloseTo(neighbor.width) - expect(previewOverrides.get(neighbor.id as AnyNodeId)?.position?.[0]).not.toBeCloseTo( - neighborPositionX, - ) - expect(previewOverrides.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo(neighbor.width) - expect(previewOverrides.has(fartherCabinet.id as AnyNodeId)).toBe(true) - expect(sceneApi.get(neighbor.id as AnyNodeId)?.width).toBe( - neighbor.width, - ) + expect(previewOverrides.get(wallA.id as AnyNodeId)?.width).toBeCloseTo(nextWidth) + expect(previewOverrides.get(neighbor.id as AnyNodeId)?.width).toBeCloseTo(neighbor.width) + expect(previewOverrides.get(neighbor.id as AnyNodeId)?.position?.[0]).not.toBeCloseTo( + neighborPositionX, + ) + expect(previewOverrides.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo(neighbor.width) + expect(previewOverrides.has(fartherCabinet.id as AnyNodeId)).toBe(true) + expect(sceneApi.get(neighbor.id as AnyNodeId)?.width).toBe( + neighbor.width, + ) - widthHandle.commit?.(baseA, selectedPatch, sceneApi) + widthHandle.commit?.(baseA, selectedPatch, sceneApi) - expect(sceneApi.get(baseA.id as AnyNodeId)?.width).toBeCloseTo(nextWidth) - expect(sceneApi.get(neighbor.id as AnyNodeId)?.width).toBeCloseTo( - neighbor.width, - ) - expect( - sceneApi.get(neighbor.id as AnyNodeId)?.position[0], - ).not.toBeCloseTo(neighborPositionX) - expect(sceneApi.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo( - neighbor.width, - ) - expect(sceneApi.get(fartherCabinet.id as AnyNodeId)?.width).toBe( - fartherCabinet.width, - ) - }) + expect(sceneApi.get(baseA.id as AnyNodeId)?.width).toBeCloseTo( + nextWidth, + ) + expect(sceneApi.get(neighbor.id as AnyNodeId)?.width).toBeCloseTo( + neighbor.width, + ) + expect( + sceneApi.get(neighbor.id as AnyNodeId)?.position[0], + ).not.toBeCloseTo(neighborPositionX) + expect(sceneApi.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo( + neighbor.width, + ) + expect(sceneApi.get(fartherCabinet.id as AnyNodeId)?.width).toBe( + fartherCabinet.width, + ) + }, + ) test.each([ ['left', 'max', -1], ['right', 'min', 1], - ] as const)('resizes the attached top cabinet from the %s without changing its bottom run', (side, anchor, direction) => { - const { baseA, nodes, root, sceneApi, wallA } = wallDepthFixture() - const neighborBase = CabinetModuleNode.parse({ - id: `cabinet-module_wall-inverse-${side}-base`, - parentId: root.id, - children: [`cabinet-module_wall-inverse-${side}-wall`], - position: [direction * baseA.width, baseA.position[1], baseA.position[2]], - }) - const neighborWall = CabinetModuleNode.parse({ - id: `cabinet-module_wall-inverse-${side}-wall`, - name: 'Wall Cabinet', - parentId: neighborBase.id, - position: [0, wallA.position[1], wallA.position[2]], - depth: wallA.depth, - }) - const fartherBase = CabinetModuleNode.parse({ - id: `cabinet-module_wall-inverse-${side}-farther-base`, - parentId: root.id, - children: [`cabinet-module_wall-inverse-${side}-farther-wall`], - position: [direction * baseA.width * 2, baseA.position[1], baseA.position[2]], - }) - const fartherWall = CabinetModuleNode.parse({ - id: `cabinet-module_wall-inverse-${side}-farther-wall`, - name: 'Wall Cabinet', - parentId: fartherBase.id, - position: [0, wallA.position[1], wallA.position[2]], - depth: wallA.depth, - }) - for (const node of [neighborBase, neighborWall, fartherBase, fartherWall]) { - nodes[node.id as AnyNodeId] = node as AnyNode - } - nodes[root.id as AnyNodeId] = { - ...root, - children: [baseA.id, neighborBase.id, fartherBase.id], - } as AnyNode - const buildModuleHandles = cabinetModuleDefinition.handles as ( - node: CabinetModuleNodeType, - sceneApi: SceneApi, - ) => HandleDescriptor[] - const widthHandle = buildModuleHandles(wallA, sceneApi).find( - (handle): handle is LinearResizeHandle => - handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === anchor, - )! - const delta = 0.1 - const nextWidth = wallA.width + delta - const expectedPositionX = wallA.position[0] + (direction * delta) / 2 - const selectedPatch = widthHandle.apply(wallA, nextWidth, sceneApi) - const previewOverrides = new Map( - widthHandle.previewOverrides?.(wallA, nextWidth, sceneApi) ?? [], - ) + ] as const)( + 'resizes the attached top cabinet from the %s without changing its bottom run', + (side, anchor, direction) => { + const { baseA, nodes, root, sceneApi, wallA } = wallDepthFixture() + const neighborBase = CabinetModuleNode.parse({ + id: `cabinet-module_wall-inverse-${side}-base`, + parentId: root.id, + children: [`cabinet-module_wall-inverse-${side}-wall`], + position: [direction * baseA.width, baseA.position[1], baseA.position[2]], + }) + const neighborWall = CabinetModuleNode.parse({ + id: `cabinet-module_wall-inverse-${side}-wall`, + name: 'Wall Cabinet', + parentId: neighborBase.id, + position: [0, wallA.position[1], wallA.position[2]], + depth: wallA.depth, + }) + const fartherBase = CabinetModuleNode.parse({ + id: `cabinet-module_wall-inverse-${side}-farther-base`, + parentId: root.id, + children: [`cabinet-module_wall-inverse-${side}-farther-wall`], + position: [direction * baseA.width * 2, baseA.position[1], baseA.position[2]], + }) + const fartherWall = CabinetModuleNode.parse({ + id: `cabinet-module_wall-inverse-${side}-farther-wall`, + name: 'Wall Cabinet', + parentId: fartherBase.id, + position: [0, wallA.position[1], wallA.position[2]], + depth: wallA.depth, + }) + for (const node of [neighborBase, neighborWall, fartherBase, fartherWall]) { + nodes[node.id as AnyNodeId] = node as AnyNode + } + nodes[root.id as AnyNodeId] = { + ...root, + children: [baseA.id, neighborBase.id, fartherBase.id], + } as AnyNode + const buildModuleHandles = cabinetModuleDefinition.handles as ( + node: CabinetModuleNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const widthHandle = buildModuleHandles(wallA, sceneApi).find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === anchor, + )! + const delta = 0.1 + const nextWidth = wallA.width + delta + const expectedPositionX = wallA.position[0] + (direction * delta) / 2 + const selectedPatch = widthHandle.apply(wallA, nextWidth, sceneApi) + const previewOverrides = new Map( + widthHandle.previewOverrides?.(wallA, nextWidth, sceneApi) ?? [], + ) - expect(selectedPatch.width).toBeCloseTo(nextWidth) - expect(selectedPatch.position?.[0]).toBeCloseTo(expectedPositionX) - expect(previewOverrides.get(root.id as AnyNodeId)).toEqual({}) - expect(previewOverrides.has(baseA.id as AnyNodeId)).toBe(false) - expect(previewOverrides.has(neighborBase.id as AnyNodeId)).toBe(false) - expect(previewOverrides.has(neighborWall.id as AnyNodeId)).toBe(false) - expect(previewOverrides.has(fartherWall.id as AnyNodeId)).toBe(false) + expect(selectedPatch.width).toBeCloseTo(nextWidth) + expect(selectedPatch.position?.[0]).toBeCloseTo(expectedPositionX) + expect(previewOverrides.get(root.id as AnyNodeId)).toEqual({}) + expect(previewOverrides.has(baseA.id as AnyNodeId)).toBe(false) + expect(previewOverrides.has(neighborBase.id as AnyNodeId)).toBe(false) + expect(previewOverrides.has(neighborWall.id as AnyNodeId)).toBe(false) + expect(previewOverrides.has(fartherWall.id as AnyNodeId)).toBe(false) - widthHandle.commit?.(wallA, selectedPatch, sceneApi) + widthHandle.commit?.(wallA, selectedPatch, sceneApi) - expect(sceneApi.get(wallA.id as AnyNodeId)?.width).toBeCloseTo(nextWidth) - expect(sceneApi.get(wallA.id as AnyNodeId)?.position[0]).toBeCloseTo( - expectedPositionX, - ) - expect(sceneApi.get(baseA.id as AnyNodeId)?.width).toBeCloseTo( - baseA.width, - ) - expect(sceneApi.get(baseA.id as AnyNodeId)?.position).toEqual( - baseA.position, - ) - expect(sceneApi.get(neighborBase.id as AnyNodeId)?.width).toBe( - neighborBase.width, - ) - expect(sceneApi.get(neighborBase.id as AnyNodeId)?.position).toEqual( - neighborBase.position, - ) - expect(sceneApi.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo( - neighborWall.width, - ) - expect(sceneApi.get(fartherWall.id as AnyNodeId)?.width).toBe( - fartherWall.width, - ) - }) + expect(sceneApi.get(wallA.id as AnyNodeId)?.width).toBeCloseTo( + nextWidth, + ) + expect(sceneApi.get(wallA.id as AnyNodeId)?.position[0]).toBeCloseTo( + expectedPositionX, + ) + expect(sceneApi.get(baseA.id as AnyNodeId)?.width).toBeCloseTo( + baseA.width, + ) + expect(sceneApi.get(baseA.id as AnyNodeId)?.position).toEqual( + baseA.position, + ) + expect(sceneApi.get(neighborBase.id as AnyNodeId)?.width).toBe( + neighborBase.width, + ) + expect(sceneApi.get(neighborBase.id as AnyNodeId)?.position).toEqual( + neighborBase.position, + ) + expect(sceneApi.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo( + neighborWall.width, + ) + expect(sceneApi.get(fartherWall.id as AnyNodeId)?.width).toBe( + fartherWall.width, + ) + }, + ) test.each([ ['left', 'max', -1], ['right', 'min', 1], - ] as const)('magnetically snaps an attached top cabinet across a %s gap without resizing its bottom run', (side, anchor, direction) => { - const { baseA, nodes, root, sceneApi, wallA } = wallDepthFixture() - const gap = 0.2 - const shortenedWall = { - ...wallA, - width: wallA.width - gap, - position: [(-direction * gap) / 2, wallA.position[1], wallA.position[2]] as [ - number, - number, - number, - ], - } - const neighborBase = CabinetModuleNode.parse({ - id: `cabinet-module_wall-gap-${side}-base`, - parentId: root.id, - children: [`cabinet-module_wall-gap-${side}-wall`], - position: [direction * baseA.width, baseA.position[1], baseA.position[2]], - }) - const neighborWall = CabinetModuleNode.parse({ - id: `cabinet-module_wall-gap-${side}-wall`, - name: 'Wall Cabinet', - parentId: neighborBase.id, - position: [0, wallA.position[1], wallA.position[2]], - depth: wallA.depth, - }) - nodes[shortenedWall.id as AnyNodeId] = shortenedWall as AnyNode - nodes[neighborBase.id as AnyNodeId] = neighborBase as AnyNode - nodes[neighborWall.id as AnyNodeId] = neighborWall as AnyNode - nodes[root.id as AnyNodeId] = { - ...root, - children: side === 'left' ? [neighborBase.id, baseA.id] : [baseA.id, neighborBase.id], - } as AnyNode - const buildModuleHandles = cabinetModuleDefinition.handles as ( - node: CabinetModuleNodeType, - sceneApi: SceneApi, - ) => HandleDescriptor[] - const widthHandle = buildModuleHandles(shortenedWall, sceneApi).find( - (handle): handle is LinearResizeHandle => - handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === anchor, - )! - const targetWidth = shortenedWall.width + gap - const requestedWidth = targetWidth - 0.03 - const unsnappedWidth = targetWidth - 0.1 - const snappedWidth = widthHandle.magneticSnap?.(shortenedWall, requestedWidth, sceneApi) - const selectedPatch = widthHandle.apply(shortenedWall, snappedWidth ?? requestedWidth, sceneApi) - const previewOverrides = new Map( - widthHandle.previewOverrides?.(shortenedWall, snappedWidth ?? requestedWidth, sceneApi) ?? [], - ) - const expectedWidth = targetWidth - const expectedPositionX = - shortenedWall.position[0] + (direction * (expectedWidth - shortenedWall.width)) / 2 + ] as const)( + 'magnetically snaps an attached top cabinet across a %s gap without resizing its bottom run', + (side, anchor, direction) => { + const { baseA, nodes, root, sceneApi, wallA } = wallDepthFixture() + const gap = 0.2 + const shortenedWall = { + ...wallA, + width: wallA.width - gap, + position: [(-direction * gap) / 2, wallA.position[1], wallA.position[2]] as [ + number, + number, + number, + ], + } + const neighborBase = CabinetModuleNode.parse({ + id: `cabinet-module_wall-gap-${side}-base`, + parentId: root.id, + children: [`cabinet-module_wall-gap-${side}-wall`], + position: [direction * baseA.width, baseA.position[1], baseA.position[2]], + }) + const neighborWall = CabinetModuleNode.parse({ + id: `cabinet-module_wall-gap-${side}-wall`, + name: 'Wall Cabinet', + parentId: neighborBase.id, + position: [0, wallA.position[1], wallA.position[2]], + depth: wallA.depth, + }) + nodes[shortenedWall.id as AnyNodeId] = shortenedWall as AnyNode + nodes[neighborBase.id as AnyNodeId] = neighborBase as AnyNode + nodes[neighborWall.id as AnyNodeId] = neighborWall as AnyNode + nodes[root.id as AnyNodeId] = { + ...root, + children: side === 'left' ? [neighborBase.id, baseA.id] : [baseA.id, neighborBase.id], + } as AnyNode + const buildModuleHandles = cabinetModuleDefinition.handles as ( + node: CabinetModuleNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const widthHandle = buildModuleHandles(shortenedWall, sceneApi).find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === anchor, + )! + const targetWidth = shortenedWall.width + gap + const requestedWidth = targetWidth - 0.03 + const unsnappedWidth = targetWidth - 0.1 + const snappedWidth = widthHandle.magneticSnap?.(shortenedWall, requestedWidth, sceneApi) + const selectedPatch = widthHandle.apply( + shortenedWall, + snappedWidth ?? requestedWidth, + sceneApi, + ) + const previewOverrides = new Map( + widthHandle.previewOverrides?.(shortenedWall, snappedWidth ?? requestedWidth, sceneApi) ?? + [], + ) + const expectedWidth = targetWidth + const expectedPositionX = + shortenedWall.position[0] + (direction * (expectedWidth - shortenedWall.width)) / 2 - expect(snappedWidth).toBeCloseTo(targetWidth) - expect(widthHandle.magneticSnap?.(shortenedWall, unsnappedWidth, sceneApi)).toBeCloseTo( - unsnappedWidth, - ) - expect(selectedPatch.width).toBeCloseTo(expectedWidth) - expect(selectedPatch.position?.[0]).toBeCloseTo(expectedPositionX) - expect(previewOverrides.get(root.id as AnyNodeId)).toEqual({}) - expect(previewOverrides.has(baseA.id as AnyNodeId)).toBe(false) - expect(previewOverrides.has(neighborBase.id as AnyNodeId)).toBe(false) - expect(previewOverrides.has(neighborWall.id as AnyNodeId)).toBe(false) - - widthHandle.commit?.(shortenedWall, selectedPatch, sceneApi) - - const selected = sceneApi.get(shortenedWall.id as AnyNodeId)! - const neighbor = sceneApi.get(neighborWall.id as AnyNodeId)! - expect(selected.width).toBeCloseTo(expectedWidth) - expect(selected.position[0]).toBeCloseTo(expectedPositionX) - expect(sceneApi.get(baseA.id as AnyNodeId)?.width).toBeCloseTo( - baseA.width, - ) - expect(sceneApi.get(baseA.id as AnyNodeId)?.position).toEqual( - baseA.position, - ) - expect(sceneApi.get(neighborBase.id as AnyNodeId)?.width).toBeCloseTo( - neighborBase.width, - ) - expect(sceneApi.get(neighborBase.id as AnyNodeId)?.position).toEqual( - neighborBase.position, - ) - expect(neighbor.width).toBeCloseTo(neighborWall.width) - expect(neighbor.position).toEqual(neighborWall.position) - }) + expect(snappedWidth).toBeCloseTo(targetWidth) + expect(widthHandle.magneticSnap?.(shortenedWall, unsnappedWidth, sceneApi)).toBeCloseTo( + unsnappedWidth, + ) + expect(selectedPatch.width).toBeCloseTo(expectedWidth) + expect(selectedPatch.position?.[0]).toBeCloseTo(expectedPositionX) + expect(previewOverrides.get(root.id as AnyNodeId)).toEqual({}) + expect(previewOverrides.has(baseA.id as AnyNodeId)).toBe(false) + expect(previewOverrides.has(neighborBase.id as AnyNodeId)).toBe(false) + expect(previewOverrides.has(neighborWall.id as AnyNodeId)).toBe(false) + + widthHandle.commit?.(shortenedWall, selectedPatch, sceneApi) + + const selected = sceneApi.get(shortenedWall.id as AnyNodeId)! + const neighbor = sceneApi.get(neighborWall.id as AnyNodeId)! + expect(selected.width).toBeCloseTo(expectedWidth) + expect(selected.position[0]).toBeCloseTo(expectedPositionX) + expect(sceneApi.get(baseA.id as AnyNodeId)?.width).toBeCloseTo( + baseA.width, + ) + expect(sceneApi.get(baseA.id as AnyNodeId)?.position).toEqual( + baseA.position, + ) + expect(sceneApi.get(neighborBase.id as AnyNodeId)?.width).toBeCloseTo( + neighborBase.width, + ) + expect(sceneApi.get(neighborBase.id as AnyNodeId)?.position).toEqual( + neighborBase.position, + ) + expect(neighbor.width).toBeCloseTo(neighborWall.width) + expect(neighbor.position).toEqual(neighborWall.position) + }, + ) test('shows wall depth arrows on group selection alongside the base arrows', () => { const { bridge, bridgeModule, root, sceneApi, wallA, wallB, wallC } = wallDepthFixture() diff --git a/packages/nodes/src/dormer/__tests__/geometry.test.ts b/packages/nodes/src/dormer/__tests__/geometry.test.ts index 9b73b75b8b..7cf90fe486 100644 --- a/packages/nodes/src/dormer/__tests__/geometry.test.ts +++ b/packages/nodes/src/dormer/__tests__/geometry.test.ts @@ -82,23 +82,23 @@ describe('buildDormerGhostGeometry (placement preview)', () => { ['gambrel', 3], ['mansard', 3], ['dutch', 4], - ] satisfies [ - RoofType, - number, - ][])('builds the canonical %s height profile', (roofType, levels) => { - const wallHeight = 1 - const geo = buildDormerGhostGeometry( - DormerNode.parse({ roofType, width: 4, depth: 3, height: wallHeight, roofHeight: 1.2 }), - ) - const position = geo.getAttribute('position') - const roofLevels = new Set() - for (let index = 0; index < position.count; index++) { - const y = position.getY(index) - if (y >= wallHeight - 0.001) roofLevels.add(Math.round(y * 1000)) - } + ] satisfies [RoofType, number][])( + 'builds the canonical %s height profile', + (roofType, levels) => { + const wallHeight = 1 + const geo = buildDormerGhostGeometry( + DormerNode.parse({ roofType, width: 4, depth: 3, height: wallHeight, roofHeight: 1.2 }), + ) + const position = geo.getAttribute('position') + const roofLevels = new Set() + for (let index = 0; index < position.count; index++) { + const y = position.getY(index) + if (y >= wallHeight - 0.001) roofLevels.add(Math.round(y * 1000)) + } - expect(roofLevels.size).toBe(levels) - }) + expect(roofLevels.size).toBe(levels) + }, + ) test('assigns roof faces to the roof material slot', () => { const geo = buildDormerGhostGeometry(DormerNode.parse({ roofType: 'mansard' })) diff --git a/packages/nodes/src/item/floorplan-move-history.test.tsx b/packages/nodes/src/item/floorplan-move-history.test.tsx index 8827f96891..46550be99b 100644 --- a/packages/nodes/src/item/floorplan-move-history.test.tsx +++ b/packages/nodes/src/item/floorplan-move-history.test.tsx @@ -495,130 +495,136 @@ describe('2D item move history', () => { [SlabNode, slabDefinition], [CeilingNode, ceilingDefinition], [ZoneNode, zoneDefinition], - ] as const)('a rejected polygon drop retains ownership for an undoable retry (%s)', async (schema, definition) => { - if (!nodeRegistry.get(definition.type)) registerNode(definition) - const polygon = [ - [6, 6], - [8, 6], - [8, 8], - [6, 8], - ] - const node = schema.parse({ - name: 'Moved polygon', - parentId: LEVEL_ID, - polygon, - autoFromWalls: false, - }) - useScene.getState().createNode(node, LEVEL_ID) - clearSceneHistory() - const before = useScene.getState().nodes - const listeners = spyOn(window, 'addEventListener') - useEditor.getState().setMovingNode(node) - await act(async () => { - renderer = await create() - }) - const release = listeners.mock.calls.filter(([type]) => type === 'pointerup').at(-1)![1] as ( - event: PointerEvent, - ) => void - listeners.mockRestore() - await pointer('pointermove', 7, 7) - await pointer('pointermove', 9, 9) - - const updateNodes = useScene.getState().updateNodes - const fault = new Error('Commit write rejected before publication') - const writes = spyOn(useScene.getState(), 'updateNodes').mockImplementation((updates) => { - if (useScene.temporal.getState().isTracking && updates.some(({ id }) => id === node.id)) { - throw fault - } - return updateNodes(updates) - }) - let caught: unknown - try { + ] as const)( + 'a rejected polygon drop retains ownership for an undoable retry (%s)', + async (schema, definition) => { + if (!nodeRegistry.get(definition.type)) registerNode(definition) + const polygon = [ + [6, 6], + [8, 6], + [8, 8], + [6, 8], + ] + const node = schema.parse({ + name: 'Moved polygon', + parentId: LEVEL_ID, + polygon, + autoFromWalls: false, + }) + useScene.getState().createNode(node, LEVEL_ID) + clearSceneHistory() + const before = useScene.getState().nodes + const listeners = spyOn(window, 'addEventListener') + useEditor.getState().setMovingNode(node) await act(async () => { - try { - // Invoke the actual mounted handler so EventTarget cannot defer the exception. - release({ button: 0, clientX: 9, clientY: 9 } as PointerEvent) - } catch (error) { - caught = error + renderer = await create() + }) + const release = listeners.mock.calls.filter(([type]) => type === 'pointerup').at(-1)![1] as ( + event: PointerEvent, + ) => void + listeners.mockRestore() + await pointer('pointermove', 7, 7) + await pointer('pointermove', 9, 9) + + const updateNodes = useScene.getState().updateNodes + const fault = new Error('Commit write rejected before publication') + const writes = spyOn(useScene.getState(), 'updateNodes').mockImplementation((updates) => { + if (useScene.temporal.getState().isTracking && updates.some(({ id }) => id === node.id)) { + throw fault } + return updateNodes(updates) }) - } finally { - writes.mockRestore() - useScene.setState({ updateNodes }) - } - expect(caught).toBe(fault) - expect(useScene.getState().nodes).toEqual(before) - expect(useScene.temporal.getState().pastStates).toHaveLength(0) - expect(getSceneHistoryPauseDepth()).toBe(0) - expect(useScene.temporal.getState().isTracking).toBe(true) + let caught: unknown + try { + await act(async () => { + try { + // Invoke the actual mounted handler so EventTarget cannot defer the exception. + release({ button: 0, clientX: 9, clientY: 9 } as PointerEvent) + } catch (error) { + caught = error + } + }) + } finally { + writes.mockRestore() + useScene.setState({ updateNodes }) + } + expect(caught).toBe(fault) + expect(useScene.getState().nodes).toEqual(before) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + expect(getSceneHistoryPauseDepth()).toBe(0) + expect(useScene.temporal.getState().isTracking).toBe(true) - useScene.getState().updateNode(node.id, { name: 'Renamed while retrying' }) - expect(useScene.temporal.getState().pastStates).toHaveLength(1) - await pointer('pointermove', 10, 10) - await pointer('pointerup', 10, 10) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(useScene.getState().nodes[node.id]).toMatchObject({ - name: 'Renamed while retrying', - polygon: [ - [9, 9], - [11, 9], - [11, 11], - [9, 11], - ], - }) - expect(useScene.temporal.getState().pastStates).toHaveLength(2) - const committed = useScene.getState().nodes - useScene.temporal.getState().undo() - expect(useScene.getState().nodes).toEqual({ - ...before, - [node.id]: { ...before[node.id], name: 'Renamed while retrying' }, - }) - useScene.temporal.getState().redo() - expect(useScene.getState().nodes).toEqual(committed) - }) + useScene.getState().updateNode(node.id, { name: 'Renamed while retrying' }) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + await pointer('pointermove', 10, 10) + await pointer('pointerup', 10, 10) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(useScene.getState().nodes[node.id]).toMatchObject({ + name: 'Renamed while retrying', + polygon: [ + [9, 9], + [11, 9], + [11, 11], + [9, 11], + ], + }) + expect(useScene.temporal.getState().pastStates).toHaveLength(2) + const committed = useScene.getState().nodes + useScene.temporal.getState().undo() + expect(useScene.getState().nodes).toEqual({ + ...before, + [node.id]: { ...before[node.id], name: 'Renamed while retrying' }, + }) + useScene.temporal.getState().redo() + expect(useScene.getState().nodes).toEqual(committed) + }, + ) test.each([ [SlabNode, slabDefinition], [CeilingNode, ceilingDefinition], [ZoneNode, zoneDefinition], - ] as const)('a polygon drop restores selection and records one undo step (%s)', async (schema, definition) => { - if (!nodeRegistry.get(definition.type)) registerNode(definition) - const polygon = [ - [6, 6], - [8, 6], - [8, 8], - [6, 8], - ] - const node = schema.parse({ - name: 'Moved polygon', - parentId: LEVEL_ID, - polygon, - autoFromWalls: false, - }) - useScene.getState().createNode(node, LEVEL_ID) - clearSceneHistory() - useEditor.getState().setMovingNode(node) - await act(async () => { - renderer = await create() - }) - await pointer('pointermove', 7, 7) - await pointer('pointermove', 9, 9) - expect(useScene.temporal.getState().pastStates).toHaveLength(0) - await pointer('pointerup', 9, 9) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(useViewer.getState().selection.selectedIds).toEqual([node.id]) - expect(useScene.temporal.getState().pastStates).toHaveLength(1) - expect(useScene.getState().nodes[node.id]).toMatchObject({ - polygon: [ + ] as const)( + 'a polygon drop restores selection and records one undo step (%s)', + async (schema, definition) => { + if (!nodeRegistry.get(definition.type)) registerNode(definition) + const polygon = [ + [6, 6], + [8, 6], [8, 8], - [10, 8], - [10, 10], - [8, 10], - ], - }) - useScene.temporal.getState().undo() - expect(useScene.getState().nodes[node.id]).toMatchObject({ polygon }) - }) + [6, 8], + ] + const node = schema.parse({ + name: 'Moved polygon', + parentId: LEVEL_ID, + polygon, + autoFromWalls: false, + }) + useScene.getState().createNode(node, LEVEL_ID) + clearSceneHistory() + useEditor.getState().setMovingNode(node) + await act(async () => { + renderer = await create() + }) + await pointer('pointermove', 7, 7) + await pointer('pointermove', 9, 9) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + await pointer('pointerup', 9, 9) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(useViewer.getState().selection.selectedIds).toEqual([node.id]) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + expect(useScene.getState().nodes[node.id]).toMatchObject({ + polygon: [ + [8, 8], + [10, 8], + [10, 10], + [8, 10], + ], + }) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes[node.id]).toMatchObject({ polygon }) + }, + ) test('a wall added mid-carry is its own reconciled step; one undo reverts only the drop', async () => { expect(nodesOfType('zone')).toHaveLength(1) diff --git a/packages/nodes/src/item/wall-object-observations.test.ts b/packages/nodes/src/item/wall-object-observations.test.ts index 2ceb7527d5..eaa3320e04 100644 --- a/packages/nodes/src/item/wall-object-observations.test.ts +++ b/packages/nodes/src/item/wall-object-observations.test.ts @@ -175,22 +175,22 @@ describe('rendered wall object observations', () => { expect(observations.filter((issue) => issue.type === 'item_wall_coplanar_face')).toEqual([]) }) - test.each([ - 'picture', - 'mirror', - ])('correct catalog %s uses actual corrective model bounds', (name) => { - const scene = mountedScene(Math.PI) - scene.root.clear() - scene.item.asset.attachTo = 'wall' - scene.item.asset.dimensions = [0.8, 0.6, 0.04] - scene.item.asset.offset = [0.013, 0, 0.151] - scene.root.position.z = 0 - const correction = new Group() - correction.position.set(...scene.item.asset.offset) - box(correction, 0.8, 0.6, 0.04, -0.013, 0.3, -0.02) - scene.root.add(correction) - expect(wallObjectRenderedObservations(currentGraph(scene.nodes))).toEqual([]) - }) + test.each(['picture', 'mirror'])( + 'correct catalog %s uses actual corrective model bounds', + (name) => { + const scene = mountedScene(Math.PI) + scene.root.clear() + scene.item.asset.attachTo = 'wall' + scene.item.asset.dimensions = [0.8, 0.6, 0.04] + scene.item.asset.offset = [0.013, 0, 0.151] + scene.root.position.z = 0 + const correction = new Group() + correction.position.set(...scene.item.asset.offset) + box(correction, 0.8, 0.6, 0.04, -0.013, 0.3, -0.02) + scene.root.add(correction) + expect(wallObjectRenderedObservations(currentGraph(scene.nodes))).toEqual([]) + }, + ) test('windows and geometry entirely in their cutout remain silent', () => { const scene = mountedScene() diff --git a/packages/nodes/src/lean-to-extension/assembly.test.ts b/packages/nodes/src/lean-to-extension/assembly.test.ts index 5a2380218a..22a02b5008 100644 --- a/packages/nodes/src/lean-to-extension/assembly.test.ts +++ b/packages/nodes/src/lean-to-extension/assembly.test.ts @@ -834,42 +834,42 @@ describe('lean-to assembly', () => { expect(assembly.gutter.endCapLeft).toBe(false) }) - test.each([ - 'gable', - 'butterfly', - ] as const)('replaces duplicate %s corner posts with one shared post on each support row', (canopyForm) => { - const level = LevelNode.parse({ id: `level_${canopyForm}_shared_posts`, level: 0 }) - const first = resolveLeanToFreestandingRunPlacement( - level.id, - [0, 0], - [8, 0], - false, - canopyForm, - )! - const second = resolveLeanToFreestandingRunPlacement( - level.id, - [8, 0], - [8, 8], - false, - canopyForm, - )! - const nodes = Object.fromEntries( - [level, first, second].map((node) => [node.id, node]), - ) as Record - const assemblies = [ - createLeanToAssembly(first, undefined, nodes), - createLeanToAssembly(second, undefined, nodes), - ] - const posts = assemblies.flatMap((assembly) => assembly.posts) - const sharedPosts = posts.filter((post) => { - const index = managedLeanToPostIndex(post) - return index === leanToCornerPostIndex('left') || index === leanToCornerPostIndex('right') - }) - - expect(posts).toHaveLength(resolveLeanToLayout(first).postXs.length * 4 - 2) - expect(sharedPosts).toHaveLength(2) - expect(sharedPosts.map(managedLeanToPostSide).sort()).toEqual(['high', 'low']) - }) + test.each(['gable', 'butterfly'] as const)( + 'replaces duplicate %s corner posts with one shared post on each support row', + (canopyForm) => { + const level = LevelNode.parse({ id: `level_${canopyForm}_shared_posts`, level: 0 }) + const first = resolveLeanToFreestandingRunPlacement( + level.id, + [0, 0], + [8, 0], + false, + canopyForm, + )! + const second = resolveLeanToFreestandingRunPlacement( + level.id, + [8, 0], + [8, 8], + false, + canopyForm, + )! + const nodes = Object.fromEntries( + [level, first, second].map((node) => [node.id, node]), + ) as Record + const assemblies = [ + createLeanToAssembly(first, undefined, nodes), + createLeanToAssembly(second, undefined, nodes), + ] + const posts = assemblies.flatMap((assembly) => assembly.posts) + const sharedPosts = posts.filter((post) => { + const index = managedLeanToPostIndex(post) + return index === leanToCornerPostIndex('left') || index === leanToCornerPostIndex('right') + }) + + expect(posts).toHaveLength(resolveLeanToLayout(first).postXs.length * 4 - 2) + expect(sharedPosts).toHaveLength(2) + expect(sharedPosts.map(managedLeanToPostSide).sort()).toEqual(['high', 'low']) + }, + ) test('joins every valid freestanding direction for every canopy form', () => { for (const canopyForm of ['mono', 'gable', 'butterfly'] as const) { diff --git a/packages/nodes/src/lean-to-extension/canopy-joint.test.ts b/packages/nodes/src/lean-to-extension/canopy-joint.test.ts index c78a69c5b5..3b361543b8 100644 --- a/packages/nodes/src/lean-to-extension/canopy-joint.test.ts +++ b/packages/nodes/src/lean-to-extension/canopy-joint.test.ts @@ -35,21 +35,21 @@ function jointCutPoint( } describe('freestanding canopy corner joints', () => { - test.each([ - 'gable', - 'butterfly', - ] as const)('resolves reciprocal 90-degree %s cuts on the inside canopy halves', (canopyForm) => { - const { first, second, nodes } = joinedRuns(canopyForm) - const firstJoint = resolveFreestandingCanopyJoints(first, nodes).right - const secondJoint = resolveFreestandingCanopyJoints(second, nodes).left - - expect(firstJoint).toMatchObject({ neighborId: second.id, innerCanopySide: 'positive' }) - expect(secondJoint).toMatchObject({ neighborId: first.id, innerCanopySide: 'positive' }) - expect(firstJoint?.trimX).toBeCloseTo(first.projection + first.lowOverhang, 8) - expect(firstJoint?.trimZ).toBeCloseTo(first.projection + first.lowOverhang, 8) - expect(firstJoint?.gutterMitre).toBeCloseTo(-Math.PI / 4, 8) - expect(firstJoint?.sharedPostOwner).not.toBe(secondJoint?.sharedPostOwner) - }) + test.each(['gable', 'butterfly'] as const)( + 'resolves reciprocal 90-degree %s cuts on the inside canopy halves', + (canopyForm) => { + const { first, second, nodes } = joinedRuns(canopyForm) + const firstJoint = resolveFreestandingCanopyJoints(first, nodes).right + const secondJoint = resolveFreestandingCanopyJoints(second, nodes).left + + expect(firstJoint).toMatchObject({ neighborId: second.id, innerCanopySide: 'positive' }) + expect(secondJoint).toMatchObject({ neighborId: first.id, innerCanopySide: 'positive' }) + expect(firstJoint?.trimX).toBeCloseTo(first.projection + first.lowOverhang, 8) + expect(firstJoint?.trimZ).toBeCloseTo(first.projection + first.lowOverhang, 8) + expect(firstJoint?.gutterMitre).toBeCloseTo(-Math.PI / 4, 8) + expect(firstJoint?.sharedPostOwner).not.toBe(secondJoint?.sharedPostOwner) + }, + ) test('uses the angle bisector for non-square turns', () => { const { first, nodes } = joinedRuns('gable', [6, 2 * Math.sqrt(3)]) @@ -122,26 +122,26 @@ describe('freestanding canopy corner joints', () => { expect(resolveFreestandingCanopyJoints(first, nodes)).toEqual({}) }) - test.each([ - 'gable', - 'butterfly', - ] as const)('retreats the inner %s plane and extends the outer plane to the hip', (canopyForm) => { - const { first, nodes } = joinedRuns(canopyForm) - const run = first.projection + first.lowOverhang - const inner = resolveCanopyRoofPlaneJointLayout(first, nodes, 'positive') - const outer = resolveCanopyRoofPlaneJointLayout(first, nodes, 'negative') - - expect(inner.width).toBeCloseTo(first.span + first.leftOverhang + first.rightOverhang) - expect(outer.width).toBeCloseTo(inner.width + run - first.rightOverhang) - expect(outer.centerX).toBeCloseTo((run - first.rightOverhang) / 2) - if (canopyForm === 'gable') { - expect(inner.trim.frontRightX).toBeCloseTo(run) - expect(outer.trim.backLeftX).toBeCloseTo(run) - } else { - expect(inner.trim.backLeftX).toBeCloseTo(run) - expect(outer.trim.frontRightX).toBeCloseTo(run) - } - }) + test.each(['gable', 'butterfly'] as const)( + 'retreats the inner %s plane and extends the outer plane to the hip', + (canopyForm) => { + const { first, nodes } = joinedRuns(canopyForm) + const run = first.projection + first.lowOverhang + const inner = resolveCanopyRoofPlaneJointLayout(first, nodes, 'positive') + const outer = resolveCanopyRoofPlaneJointLayout(first, nodes, 'negative') + + expect(inner.width).toBeCloseTo(first.span + first.leftOverhang + first.rightOverhang) + expect(outer.width).toBeCloseTo(inner.width + run - first.rightOverhang) + expect(outer.centerX).toBeCloseTo((run - first.rightOverhang) / 2) + if (canopyForm === 'gable') { + expect(inner.trim.frontRightX).toBeCloseTo(run) + expect(outer.trim.backLeftX).toBeCloseTo(run) + } else { + expect(inner.trim.backLeftX).toBeCloseTo(run) + expect(outer.trim.frontRightX).toBeCloseTo(run) + } + }, + ) test('extends the outside gable eave while retreating the inside eave', () => { const { first, nodes } = joinedRuns('gable') diff --git a/packages/nodes/src/lean-to-extension/floorplan.test.ts b/packages/nodes/src/lean-to-extension/floorplan.test.ts index 79e65b472d..4ceee7c187 100644 --- a/packages/nodes/src/lean-to-extension/floorplan.test.ts +++ b/packages/nodes/src/lean-to-extension/floorplan.test.ts @@ -171,47 +171,47 @@ describe('curved lean-to floorplan', () => { expect(geometry.children.filter((child) => child.kind === 'rect')).toHaveLength(6) }) - test.each([ - 'gable', - 'butterfly', - ] as const)('draws the continuous %s roof footprint to the shared diagonal seam', (canopyForm) => { - const level = LevelNode.parse({ id: `level_${canopyForm}_floorplan_joint`, level: 0 }) - const first = resolveLeanToFreestandingRunPlacement( - level.id, - [0, 0], - [4, 0], - false, - canopyForm, - )! - const second = resolveLeanToFreestandingRunPlacement( - level.id, - [4, 0], - [4, 4], - false, - canopyForm, - )! - const geometry = buildLeanToExtensionFloorplan(first, { - children: [], - parent: level, - resolve: () => undefined, - siblings: [second], - } as GeometryContext) + test.each(['gable', 'butterfly'] as const)( + 'draws the continuous %s roof footprint to the shared diagonal seam', + (canopyForm) => { + const level = LevelNode.parse({ id: `level_${canopyForm}_floorplan_joint`, level: 0 }) + const first = resolveLeanToFreestandingRunPlacement( + level.id, + [0, 0], + [4, 0], + false, + canopyForm, + )! + const second = resolveLeanToFreestandingRunPlacement( + level.id, + [4, 0], + [4, 4], + false, + canopyForm, + )! + const geometry = buildLeanToExtensionFloorplan(first, { + children: [], + parent: level, + resolve: () => undefined, + siblings: [second], + } as GeometryContext) - expect(geometry?.kind).toBe('group') - if (geometry?.kind !== 'group') return - const roof = geometry.children.find((child) => child.kind === 'polygon') - expect(roof?.kind).toBe('polygon') - if (roof?.kind !== 'polygon') return - const run = first.projection + first.lowOverhang - expect(roof.points).toContainEqual([4, 0]) - expect( - roof.points.some(([x, z]) => Math.abs(x - (4 - run)) < 1e-8 && Math.abs(z - run) < 1e-8), - ).toBe(true) - expect( - roof.points.some(([x, z]) => Math.abs(x - (4 + run)) < 1e-8 && Math.abs(z + run) < 1e-8), - ).toBe(true) - expect(roof.points).not.toContainEqual([4 + first.rightOverhang, run]) - }) + expect(geometry?.kind).toBe('group') + if (geometry?.kind !== 'group') return + const roof = geometry.children.find((child) => child.kind === 'polygon') + expect(roof?.kind).toBe('polygon') + if (roof?.kind !== 'polygon') return + const run = first.projection + first.lowOverhang + expect(roof.points).toContainEqual([4, 0]) + expect( + roof.points.some(([x, z]) => Math.abs(x - (4 - run)) < 1e-8 && Math.abs(z - run) < 1e-8), + ).toBe(true) + expect( + roof.points.some(([x, z]) => Math.abs(x - (4 + run)) < 1e-8 && Math.abs(z + run) < 1e-8), + ).toBe(true) + expect(roof.points).not.toContainEqual([4 + first.rightOverhang, run]) + }, + ) test('draws the continuous mono roof footprint to the shared diagonal seam', () => { const level = LevelNode.parse({ id: 'level_mono_floorplan_joint', level: 0 }) diff --git a/packages/nodes/src/lean-to-extension/joint-framing.test.ts b/packages/nodes/src/lean-to-extension/joint-framing.test.ts index f4c45d81b8..915aaa9b39 100644 --- a/packages/nodes/src/lean-to-extension/joint-framing.test.ts +++ b/packages/nodes/src/lean-to-extension/joint-framing.test.ts @@ -78,11 +78,11 @@ describe('freestanding canopy joint framing', () => { expect(exposedCornerFraming('mono', -4)).toEqual([]) }) - test.each([ - 'gable', - 'butterfly', - ] as const)('keeps both %s roof-half framings below either internal turn', (canopyForm) => { - expect(exposedCornerFraming(canopyForm, -4)).toEqual([]) - expect(exposedCornerFraming(canopyForm, 4)).toEqual([]) - }) + test.each(['gable', 'butterfly'] as const)( + 'keeps both %s roof-half framings below either internal turn', + (canopyForm) => { + expect(exposedCornerFraming(canopyForm, -4)).toEqual([]) + expect(exposedCornerFraming(canopyForm, 4)).toEqual([]) + }, + ) }) diff --git a/packages/nodes/src/lean-to-extension/mono-j-rendering.test.ts b/packages/nodes/src/lean-to-extension/mono-j-rendering.test.ts index 20e2a2eb10..c8f96d3a06 100644 --- a/packages/nodes/src/lean-to-extension/mono-j-rendering.test.ts +++ b/packages/nodes/src/lean-to-extension/mono-j-rendering.test.ts @@ -221,33 +221,34 @@ describe('continuous mono canopy rendering', () => { expect(extendedChain.overlaps).toBe(0) }) - test.each( - Array.from({ length: 90 }, (_, index) => 90 - index), - )('miters either turn and slope direction at %i degrees', (angle) => { - const radians = (angle * Math.PI) / 180 - const corner: Pt = [8, 0] - const forwardEnd: Pt = [8 + 6 * Math.cos(radians), 6 * Math.sin(radians)] - const mirroredEnd: Pt = [8 + 6 * Math.cos(radians), -6 * Math.sin(radians)] - const paths = [ - [[0, 0] as Pt, corner, forwardEnd], - [forwardEnd, corner, [0, 0] as Pt], - [[0, 0] as Pt, corner, mirroredEnd], - [mirroredEnd, corner, [0, 0] as Pt], - ] - - for (const [pathIndex, points] of paths.entries()) { - for (const flipProjection of [false, true]) { - const result = buildCanopy( - `angle_${angle}_path_${pathIndex}_flip_${flipProjection}`, - points, - flipProjection, - ) - expect(result.holes).toBe(0) - expect(result.overlaps).toBe(0) - expect(result.totalVertical).toBeLessThan(0.05) + test.each(Array.from({ length: 90 }, (_, index) => 90 - index))( + 'miters either turn and slope direction at %i degrees', + (angle) => { + const radians = (angle * Math.PI) / 180 + const corner: Pt = [8, 0] + const forwardEnd: Pt = [8 + 6 * Math.cos(radians), 6 * Math.sin(radians)] + const mirroredEnd: Pt = [8 + 6 * Math.cos(radians), -6 * Math.sin(radians)] + const paths = [ + [[0, 0] as Pt, corner, forwardEnd], + [forwardEnd, corner, [0, 0] as Pt], + [[0, 0] as Pt, corner, mirroredEnd], + [mirroredEnd, corner, [0, 0] as Pt], + ] + + for (const [pathIndex, points] of paths.entries()) { + for (const flipProjection of [false, true]) { + const result = buildCanopy( + `angle_${angle}_path_${pathIndex}_flip_${flipProjection}`, + points, + flipProjection, + ) + expect(result.holes).toBe(0) + expect(result.overlaps).toBe(0) + expect(result.totalVertical).toBeLessThan(0.05) + } } - } - }) + }, + ) // J-shapes and closed loops use the same corner partitioning as an L at each end. const multiJointShapes: Record = { diff --git a/packages/nodes/src/lean-to-extension/placement.test.ts b/packages/nodes/src/lean-to-extension/placement.test.ts index d738f791d3..e5a244aa77 100644 --- a/packages/nodes/src/lean-to-extension/placement.test.ts +++ b/packages/nodes/src/lean-to-extension/placement.test.ts @@ -136,70 +136,69 @@ describe('lean-to canopy placement', () => { expect(resolveLeanToFreestandingRunPlacement('level_ground', [0, 0], [0.2, 0])).toBeNull() }) - test.each([ - 'gable', - 'butterfly', - ] as const)('keeps the %s canopy form throughout a continuous run', (canopyForm) => { - const node = resolveLeanToFreestandingRunPlacement( - 'level_ground', - [0, 0], - [4, 0], - false, - canopyForm, - ) - const target = resolveLeanToFreestandingRunTarget({ - activeLevelId: 'level_ground', - canopyForm, - start: [4, 0], - end: [4, 4], - nodes: node ? { [node.id]: node } : {}, - }) - - expect(node?.canopyForm).toBe(canopyForm) - expect(target?.node.canopyForm).toBe(canopyForm) - }) - - test.each([ - 'mono', - 'gable', - 'butterfly', - ] as const)('magnetically closes a continuous %s loop at an exposed endpoint', (canopyForm) => { - const first = resolveLeanToFreestandingRunPlacement( - 'level_ground', - [0, 0], - [4, 0], - false, - canopyForm, - )! - const second = resolveLeanToFreestandingRunPlacement( - 'level_ground', - [4, 0], - [4, 4], - false, - canopyForm, - )! - const third = resolveLeanToFreestandingRunPlacement( - 'level_ground', - [4, 4], - [0, 4], - false, - canopyForm, - )! - const snap = resolveLeanToFreestandingRunEndpointSnap({ - activeLevelId: 'level_ground', - canopyForm, - maxDistance: 0.5, - nodes: Object.fromEntries([first, second, third].map((node) => [node.id, node])), - proposedEnd: [0.18, 0.12], - start: [0, 4], - }) - - expect(snap).toMatchObject({ - nodeId: first.id, - point: [0, 0], - side: 'left', - }) - }) + test.each(['gable', 'butterfly'] as const)( + 'keeps the %s canopy form throughout a continuous run', + (canopyForm) => { + const node = resolveLeanToFreestandingRunPlacement( + 'level_ground', + [0, 0], + [4, 0], + false, + canopyForm, + ) + const target = resolveLeanToFreestandingRunTarget({ + activeLevelId: 'level_ground', + canopyForm, + start: [4, 0], + end: [4, 4], + nodes: node ? { [node.id]: node } : {}, + }) + + expect(node?.canopyForm).toBe(canopyForm) + expect(target?.node.canopyForm).toBe(canopyForm) + }, + ) + + test.each(['mono', 'gable', 'butterfly'] as const)( + 'magnetically closes a continuous %s loop at an exposed endpoint', + (canopyForm) => { + const first = resolveLeanToFreestandingRunPlacement( + 'level_ground', + [0, 0], + [4, 0], + false, + canopyForm, + )! + const second = resolveLeanToFreestandingRunPlacement( + 'level_ground', + [4, 0], + [4, 4], + false, + canopyForm, + )! + const third = resolveLeanToFreestandingRunPlacement( + 'level_ground', + [4, 4], + [0, 4], + false, + canopyForm, + )! + const snap = resolveLeanToFreestandingRunEndpointSnap({ + activeLevelId: 'level_ground', + canopyForm, + maxDistance: 0.5, + nodes: Object.fromEntries([first, second, third].map((node) => [node.id, node])), + proposedEnd: [0.18, 0.12], + start: [0, 4], + }) + + expect(snap).toMatchObject({ + nodeId: first.id, + point: [0, 0], + side: 'left', + }) + }, + ) test('does not magnetize to an occupied, incompatible, or out-of-range endpoint', () => { const occupied = { diff --git a/packages/nodes/src/lean-to-extension/system.test.ts b/packages/nodes/src/lean-to-extension/system.test.ts index 7853a8b466..bc7639d72e 100644 --- a/packages/nodes/src/lean-to-extension/system.test.ts +++ b/packages/nodes/src/lean-to-extension/system.test.ts @@ -402,96 +402,97 @@ describe('lean-to scene commit boundary', () => { expect(cornerPosts).toHaveLength(1) }) - test.each([ - 'gable', - 'butterfly', - ] as const)('synchronizes continuous %s roof and gutter miters after both runs exist', (canopyForm) => { - stopSync() - const level = LevelNode.parse({ id: `level_${canopyForm}_run_sync`, level: 0 }) - const first = resolveLeanToFreestandingRunPlacement( - level.id, - [0, 0], - [4, 0], - false, - canopyForm, - )! - const second = resolveLeanToFreestandingRunPlacement( - level.id, - [4, 0], - [4, 4], - false, - canopyForm, - )! - const firstAssembly = createLeanToAssembly(first) - const secondAssembly = createLeanToAssembly(second) - const nodes = Object.fromEntries( - [ - { ...level, children: [first.id, second.id] }, - firstAssembly.extension, - ...firstAssembly.children, - secondAssembly.extension, - ...secondAssembly.children, - ].map((node) => [node.id, node]), - ) as Record - useScene.setState({ - collections: {}, - dirtyNodes: new Set(), - materials: {}, - nodes, - readOnly: false, - rootNodeIds: [level.id], - } as never) - clearSceneHistory() - stopSync = initializeLeanToExtensionSync(createSceneApi(useScene)) - - const syncedNodes = useScene.getState().nodes - const syncedFirst = syncedNodes[first.id as AnyNodeId] - expect(syncedFirst).toMatchObject({ - type: 'lean-to-extension', - rightEndCondition: 'joined', - metadata: { leanToFreestandingCanopyJoints: { right: {} } }, - }) - const jointMetadata = syncedFirst?.metadata as - | { leanToFreestandingCanopyJoints?: { right?: { gutterMitre?: number } } } - | undefined - expect(jointMetadata?.leanToFreestandingCanopyJoints?.right?.gutterMitre).toBeCloseTo( - -Math.PI / 4, - 12, - ) - if (syncedFirst?.type !== 'lean-to-extension') return - const roof = syncedFirst.children - .map((id) => syncedNodes[id as AnyNodeId]) - .find((node) => node?.type === 'roof') - const segments = - roof?.type === 'roof' - ? roof.children - .map((id) => syncedNodes[id as AnyNodeId]) - .filter((node) => node?.type === 'roof-segment') - : [] - const primary = segments.find( - (segment) => - (segment.metadata as Record | undefined)?.leanToRoofPlane !== 'opposite', - ) - expect(primary?.trim.right + primary?.trim.left).toBeCloseTo(first.rightOverhang) - expect( - canopyForm === 'gable' ? primary?.trim.frontRightX : primary?.trim.backLeftX, - ).toBeCloseTo(first.projection + first.lowOverhang) - const gutter = primary?.children - .map((id) => syncedNodes[id as AnyNodeId]) - .find( - (node) => - node?.type === 'gutter' && - (node.metadata as Record | undefined)?.leanToDrainageSide !== 'opposite', + test.each(['gable', 'butterfly'] as const)( + 'synchronizes continuous %s roof and gutter miters after both runs exist', + (canopyForm) => { + stopSync() + const level = LevelNode.parse({ id: `level_${canopyForm}_run_sync`, level: 0 }) + const first = resolveLeanToFreestandingRunPlacement( + level.id, + [0, 0], + [4, 0], + false, + canopyForm, + )! + const second = resolveLeanToFreestandingRunPlacement( + level.id, + [4, 0], + [4, 4], + false, + canopyForm, + )! + const firstAssembly = createLeanToAssembly(first) + const secondAssembly = createLeanToAssembly(second) + const nodes = Object.fromEntries( + [ + { ...level, children: [first.id, second.id] }, + firstAssembly.extension, + ...firstAssembly.children, + secondAssembly.extension, + ...secondAssembly.children, + ].map((node) => [node.id, node]), + ) as Record + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + materials: {}, + nodes, + readOnly: false, + rootNodeIds: [level.id], + } as never) + clearSceneHistory() + stopSync = initializeLeanToExtensionSync(createSceneApi(useScene)) + + const syncedNodes = useScene.getState().nodes + const syncedFirst = syncedNodes[first.id as AnyNodeId] + expect(syncedFirst).toMatchObject({ + type: 'lean-to-extension', + rightEndCondition: 'joined', + metadata: { leanToFreestandingCanopyJoints: { right: {} } }, + }) + const jointMetadata = syncedFirst?.metadata as + | { leanToFreestandingCanopyJoints?: { right?: { gutterMitre?: number } } } + | undefined + expect(jointMetadata?.leanToFreestandingCanopyJoints?.right?.gutterMitre).toBeCloseTo( + -Math.PI / 4, + 12, ) - expect(gutter?.endCapLeft && gutter?.endCapRight).toBe(false) - const gutterMetadata = gutter?.metadata as - | { leanToGutterMitres?: { left?: number } } - | undefined - expect(gutterMetadata?.leanToGutterMitres?.left).toBeCloseTo( - canopyForm === 'butterfly' ? -Math.PI / 4 : 0, - 12, - ) - }) + if (syncedFirst?.type !== 'lean-to-extension') return + const roof = syncedFirst.children + .map((id) => syncedNodes[id as AnyNodeId]) + .find((node) => node?.type === 'roof') + const segments = + roof?.type === 'roof' + ? roof.children + .map((id) => syncedNodes[id as AnyNodeId]) + .filter((node) => node?.type === 'roof-segment') + : [] + const primary = segments.find( + (segment) => + (segment.metadata as Record | undefined)?.leanToRoofPlane !== 'opposite', + ) + expect(primary?.trim.right + primary?.trim.left).toBeCloseTo(first.rightOverhang) + expect( + canopyForm === 'gable' ? primary?.trim.frontRightX : primary?.trim.backLeftX, + ).toBeCloseTo(first.projection + first.lowOverhang) + const gutter = primary?.children + .map((id) => syncedNodes[id as AnyNodeId]) + .find( + (node) => + node?.type === 'gutter' && + (node.metadata as Record | undefined)?.leanToDrainageSide !== + 'opposite', + ) + expect(gutter?.endCapLeft && gutter?.endCapRight).toBe(false) + const gutterMetadata = gutter?.metadata as + | { leanToGutterMitres?: { left?: number } } + | undefined + expect(gutterMetadata?.leanToGutterMitres?.left).toBeCloseTo( + canopyForm === 'butterfly' ? -Math.PI / 4 : 0, + 12, + ) + }, + ) test('synchronizes an edge-snapped straight run with open gutters and one joint pillar', () => { stopSync() diff --git a/packages/nodes/src/pipe-fitting/geometry.test.ts b/packages/nodes/src/pipe-fitting/geometry.test.ts index ed7f0608bb..573672ebc0 100644 --- a/packages/nodes/src/pipe-fitting/geometry.test.ts +++ b/packages/nodes/src/pipe-fitting/geometry.test.ts @@ -23,33 +23,31 @@ function meshNames(fittingType: PipeFittingNode['fittingType']) { } describe('DWV fitting geometry', () => { - test.each([ - 'elbow', - 'wye', - 'sanitary-tee', - 'cross', - ] as const)('builds socket and rim details for every %s port', (fittingType) => { - const node = fitting(fittingType) - const names = meshNames(fittingType) - for (const port of localPipeFittingPorts(node)) { - expect(names).toContain(`pipe-fitting-socket-${port.id}`) - expect(names).toContain(`pipe-fitting-shoulder-${port.id}`) - expect(names).toContain(`pipe-fitting-rim-${port.id}`) - } - }) + test.each(['elbow', 'wye', 'sanitary-tee', 'cross'] as const)( + 'builds socket and rim details for every %s port', + (fittingType) => { + const node = fitting(fittingType) + const names = meshNames(fittingType) + for (const port of localPipeFittingPorts(node)) { + expect(names).toContain(`pipe-fitting-socket-${port.id}`) + expect(names).toContain(`pipe-fitting-shoulder-${port.id}`) + expect(names).toContain(`pipe-fitting-rim-${port.id}`) + } + }, + ) test('models an elbow as a continuous sweep', () => { expect(meshNames('elbow')).toContain('pipe-fitting-elbow-sweep') }) - test.each([ - 'wye', - 'sanitary-tee', - ] as const)('models %s with a straight run and swept branch', (fittingType) => { - const names = meshNames(fittingType) - expect(names).toContain(`pipe-fitting-${fittingType}-run`) - expect(names).toContain(`pipe-fitting-${fittingType}-branch-sweep`) - }) + test.each(['wye', 'sanitary-tee'] as const)( + 'models %s with a straight run and swept branch', + (fittingType) => { + const names = meshNames(fittingType) + expect(names).toContain(`pipe-fitting-${fittingType}-run`) + expect(names).toContain(`pipe-fitting-${fittingType}-branch-sweep`) + }, + ) test('models a cross with two independently swept branches', () => { const names = meshNames('cross') diff --git a/packages/nodes/src/shared/accessory-catalog.test.ts b/packages/nodes/src/shared/accessory-catalog.test.ts index 8f83b0bd80..4ca4756001 100644 --- a/packages/nodes/src/shared/accessory-catalog.test.ts +++ b/packages/nodes/src/shared/accessory-catalog.test.ts @@ -26,25 +26,24 @@ function nodes(...items: AnyNode[]): Record { } describe('accessory catalog', () => { - test.each([ - 'round', - 'rect', - 'oval', - ] as const)('duct caps close the %s opening while sleeves stay hollow', (shape) => { - const cap = DuctFittingNode.parse({ fittingType: 'end-cap', shape }) - expect(getDuctFittingPorts(cap)).toHaveLength(1) - const group = buildDuctFittingGeometry(cap) - group.updateMatrixWorld(true) - const ray = new Raycaster(new Vector3(-1, 0, 0), new Vector3(1, 0, 0)) - expect( - ray.intersectObject(group, true).some((hit) => hit.object.name === 'end-cap-closure'), - ).toBe(true) - const coupling = buildDuctFittingGeometry( - DuctFittingNode.parse({ fittingType: 'coupling', shape }), - ) - coupling.updateMatrixWorld(true) - expect(ray.intersectObject(coupling, true)).toHaveLength(0) - }) + test.each(['round', 'rect', 'oval'] as const)( + 'duct caps close the %s opening while sleeves stay hollow', + (shape) => { + const cap = DuctFittingNode.parse({ fittingType: 'end-cap', shape }) + expect(getDuctFittingPorts(cap)).toHaveLength(1) + const group = buildDuctFittingGeometry(cap) + group.updateMatrixWorld(true) + const ray = new Raycaster(new Vector3(-1, 0, 0), new Vector3(1, 0, 0)) + expect( + ray.intersectObject(group, true).some((hit) => hit.object.name === 'end-cap-closure'), + ).toBe(true) + const coupling = buildDuctFittingGeometry( + DuctFittingNode.parse({ fittingType: 'coupling', shape }), + ) + coupling.updateMatrixWorld(true) + expect(ray.intersectObject(coupling, true)).toHaveLength(0) + }, + ) test('damper blade opens independently of its connection ports', () => { const closed = DuctFittingNode.parse({ fittingType: 'damper' }) diff --git a/packages/nodes/src/shared/distribution-run-tool.test.tsx b/packages/nodes/src/shared/distribution-run-tool.test.tsx index eaef1626e3..a50efb0d1a 100644 --- a/packages/nodes/src/shared/distribution-run-tool.test.tsx +++ b/packages/nodes/src/shared/distribution-run-tool.test.tsx @@ -5,117 +5,117 @@ import { useThree } from '@react-three/fiber' import { act, create } from '@react-three/test-renderer' import { useDistributionRunTool } from './distribution-run-tool' -test.each([ - 'duct-segment', - 'pipe-segment', -] as const)('%s releases its wall plane after an outward commit and retains it along the wall', async (toolName) => { - const previousScene = useScene.getState() - const previousEditor = useEditor.getState() - const previousScope = useInteractionScope.getState() - const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') - const previousActEnvironment = Object.getOwnPropertyDescriptor( - globalThis, - 'IS_REACT_ACT_ENVIRONMENT', - ) - Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { - value: true, - configurable: true, - writable: true, - }) - if (!previousWindow) - Object.defineProperty(globalThis, 'window', { value: new EventTarget(), configurable: true }) - const wall = WallNode.parse({ parentId: 'level_test', start: [0, 0], end: [10, 0] }) - let tool: ReturnType | undefined - const commits: Array<[number, number, number]> = [] - let acceptCommit = true - function Host() { - const { gl } = useThree() - gl.domElement.getBoundingClientRect = () => - ({ left: 0, top: 0, width: 100, height: 100 }) as DOMRect - tool = useDistributionRunTool({ - active: true, - levelId: 'level_test', - toolName, - getPorts: () => [], - findBody: () => null, - surfaceClearance: () => 0.1, - commit: ({ end }) => { - if (!acceptCommit) return null - commits.push(end) - return { nextStart: end, nextConnection: { port: null, body: null } } - }, - }) - return null - } - let renderer: Awaited> | undefined - const wallEvent = (x: number): GridEvent => ({ - position: [x, 1, 0], - localPosition: [x, 1, 0], - surfaceNormal: [0, 0, 1], - surfaceHit: { - kind: 'wall', - hostId: wall.id, - levelId: 'level_test', - face: 'side', - side: 'front', - }, - nativeEvent: {} as GridEvent['nativeEvent'], - }) - try { - useScene.setState({ nodes: { [wall.id]: wall } }) - useEditor.setState({ snappingModeByContext: { wall: 'off', polygon: 'off', item: 'off' } }) - renderer = await create() - await act(async () => { - emitter.emit('grid:click', wallEvent(1)) - }) - await act(async () => { - emitter.emit('grid:click', wallEvent(2)) +test.each(['duct-segment', 'pipe-segment'] as const)( + '%s releases its wall plane after an outward commit and retains it along the wall', + async (toolName) => { + const previousScene = useScene.getState() + const previousEditor = useEditor.getState() + const previousScope = useInteractionScope.getState() + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') + const previousActEnvironment = Object.getOwnPropertyDescriptor( + globalThis, + 'IS_REACT_ACT_ENVIRONMENT', + ) + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + value: true, + configurable: true, + writable: true, }) - expect(commits).toEqual([[2, 1, 0.1]]) - await act(async () => { - emitter.emit('grid:move', { - position: [3, 0, 4], - localPosition: [3, 0, 4], - localRay: { origin: [3, 2, 2.1], direction: [0, -0.5, -1] }, - nativeEvent: {} as GridEvent['nativeEvent'], + if (!previousWindow) + Object.defineProperty(globalThis, 'window', { value: new EventTarget(), configurable: true }) + const wall = WallNode.parse({ parentId: 'level_test', start: [0, 0], end: [10, 0] }) + let tool: ReturnType | undefined + const commits: Array<[number, number, number]> = [] + let acceptCommit = true + function Host() { + const { gl } = useThree() + gl.domElement.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 100, height: 100 }) as DOMRect + tool = useDistributionRunTool({ + active: true, + levelId: 'level_test', + toolName, + getPorts: () => [], + findBody: () => null, + surfaceClearance: () => 0.1, + commit: ({ end }) => { + if (!acceptCommit) return null + commits.push(end) + return { nextStart: end, nextConnection: { port: null, body: null } } + }, }) - }) - expect(tool?.cursor?.[2]).toBeCloseTo(0.1) - await act(async () => { - tool?.onDirectionSelect([0, 0, 1]) - }) - const outward: GridEvent = { - position: [2, 0, 2.1], - localPosition: [2, 0, 2.1], - localRay: { origin: [2, 3, 2.1], direction: [0, -1, 0] }, - nativeEvent: {} as GridEvent['nativeEvent'], + return null } - acceptCommit = false - await act(async () => { - emitter.emit('grid:click', outward) - }) - expect(commits).toHaveLength(1) - acceptCommit = true - await act(async () => { - emitter.emit('grid:click', outward) + let renderer: Awaited> | undefined + const wallEvent = (x: number): GridEvent => ({ + position: [x, 1, 0], + localPosition: [x, 1, 0], + surfaceNormal: [0, 0, 1], + surfaceHit: { + kind: 'wall', + hostId: wall.id, + levelId: 'level_test', + face: 'side', + side: 'front', + }, + nativeEvent: {} as GridEvent['nativeEvent'], }) - expect(commits[1]?.[2]).toBeCloseTo(2.1) - await act(async () => { - emitter.emit('grid:move', { - ...outward, - localRay: { origin: [4, 3, 2.1], direction: [0, -1, 0] }, + try { + useScene.setState({ nodes: { [wall.id]: wall } }) + useEditor.setState({ snappingModeByContext: { wall: 'off', polygon: 'off', item: 'off' } }) + renderer = await create() + await act(async () => { + emitter.emit('grid:click', wallEvent(1)) }) - }) - expect(tool?.cursor).toEqual([4, 1, 2.1]) - expect(tool?.surfaceTarget).toBeNull() - } finally { - await renderer?.unmount() - useScene.setState(previousScene, true) - useEditor.setState(previousEditor, true) - useInteractionScope.setState(previousScope, true) - if (!previousWindow) Reflect.deleteProperty(globalThis, 'window') - if (previousActEnvironment) - Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousActEnvironment) - else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT') - } -}) + await act(async () => { + emitter.emit('grid:click', wallEvent(2)) + }) + expect(commits).toEqual([[2, 1, 0.1]]) + await act(async () => { + emitter.emit('grid:move', { + position: [3, 0, 4], + localPosition: [3, 0, 4], + localRay: { origin: [3, 2, 2.1], direction: [0, -0.5, -1] }, + nativeEvent: {} as GridEvent['nativeEvent'], + }) + }) + expect(tool?.cursor?.[2]).toBeCloseTo(0.1) + await act(async () => { + tool?.onDirectionSelect([0, 0, 1]) + }) + const outward: GridEvent = { + position: [2, 0, 2.1], + localPosition: [2, 0, 2.1], + localRay: { origin: [2, 3, 2.1], direction: [0, -1, 0] }, + nativeEvent: {} as GridEvent['nativeEvent'], + } + acceptCommit = false + await act(async () => { + emitter.emit('grid:click', outward) + }) + expect(commits).toHaveLength(1) + acceptCommit = true + await act(async () => { + emitter.emit('grid:click', outward) + }) + expect(commits[1]?.[2]).toBeCloseTo(2.1) + await act(async () => { + emitter.emit('grid:move', { + ...outward, + localRay: { origin: [4, 3, 2.1], direction: [0, -1, 0] }, + }) + }) + expect(tool?.cursor).toEqual([4, 1, 2.1]) + expect(tool?.surfaceTarget).toBeNull() + } finally { + await renderer?.unmount() + useScene.setState(previousScene, true) + useEditor.setState(previousEditor, true) + useInteractionScope.setState(previousScope, true) + if (!previousWindow) Reflect.deleteProperty(globalThis, 'window') + if (previousActEnvironment) + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousActEnvironment) + else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT') + } + }, +) diff --git a/packages/nodes/src/shared/node-batch/node-batch.test.ts b/packages/nodes/src/shared/node-batch/node-batch.test.ts index ae52453244..3cd9ec4731 100644 --- a/packages/nodes/src/shared/node-batch/node-batch.test.ts +++ b/packages/nodes/src/shared/node-batch/node-batch.test.ts @@ -215,31 +215,30 @@ test('separates both shadow flags and preserves them through capacity growth', ( expect(batches(root).every((batch) => batch.userData.pascalExport === 'strip')).toBe(true) }) -test.each([ - 'ceiling', - 'slab', - 'item', -])('unselected %s live move releases immediately and rejoins on clear', (kind) => { - const { root, meshes } = setup(kind) - settle() - expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(false) - useLiveTransforms.getState().set(`${kind}_0`, { position: [5, 0, 2], rotation: 0 }) - expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(true) - meshes[0]!.position.set(5, 0, 2) - settle() - expect(collectBatchCandidate(`${kind}_0`)).toBeNull() - expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(true) - useLiveTransforms.getState().clear(`${kind}_0`) - settle() - expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(false) - const matrix = new Matrix4() - const batch = batches(root)[0]! - const translations = [] - for (let i = 0; i < batch.instanceCount; i++) { - if (batch.getMatrixAt(i, matrix)) translations.push(matrix.elements[12]) - } - expect(translations).toContain(5) -}) +test.each(['ceiling', 'slab', 'item'])( + 'unselected %s live move releases immediately and rejoins on clear', + (kind) => { + const { root, meshes } = setup(kind) + settle() + expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(false) + useLiveTransforms.getState().set(`${kind}_0`, { position: [5, 0, 2], rotation: 0 }) + expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(true) + meshes[0]!.position.set(5, 0, 2) + settle() + expect(collectBatchCandidate(`${kind}_0`)).toBeNull() + expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(true) + useLiveTransforms.getState().clear(`${kind}_0`) + settle() + expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(false) + const matrix = new Matrix4() + const batch = batches(root)[0]! + const translations = [] + for (let i = 0; i < batch.instanceCount; i++) { + if (batch.getMatrixAt(i, matrix)) translations.push(matrix.elements[12]) + } + expect(translations).toContain(5) + }, +) test('paint fan-out releases secondary targets before swapping and never settles preview material', () => { const { root, meshes, material } = setup() @@ -296,28 +295,28 @@ test('overlapping preview holds end only after the final restore; failed preview expect(isSlotPaintPreviewActive('ceiling_0')).toBe(false) }) -test.each([ - 'mode', - 'selected-level', -])('shadow-only candidates are re-offered on %s restoration', (change) => { - const { root, meshes } = setup() - useViewer.setState({ - levelMode: 'solo', - selection: { ...useViewer.getState().selection, levelId: 'level_other' }, - } as never) - applyShadowOnly(root) - settle() - expect(batches(root)).toHaveLength(0) - clearShadowOnly(root) - if (change === 'mode') useViewer.setState({ levelMode: 'stacked' }) - else +test.each(['mode', 'selected-level'])( + 'shadow-only candidates are re-offered on %s restoration', + (change) => { + const { root, meshes } = setup() useViewer.setState({ - selection: { ...useViewer.getState().selection, levelId: 'level_test' }, + levelMode: 'solo', + selection: { ...useViewer.getState().selection, levelId: 'level_other' }, } as never) - settle() - expect(batches(root)).toHaveLength(1) - expect(meshes.every((mesh) => !mesh.layers.isEnabled(SCENE_LAYER))).toBe(true) -}) + applyShadowOnly(root) + settle() + expect(batches(root)).toHaveLength(0) + clearShadowOnly(root) + if (change === 'mode') useViewer.setState({ levelMode: 'stacked' }) + else + useViewer.setState({ + selection: { ...useViewer.getState().selection, levelId: 'level_test' }, + } as never) + settle() + expect(batches(root)).toHaveLength(1) + expect(meshes.every((mesh) => !mesh.layers.isEnabled(SCENE_LAYER))).toBe(true) + }, +) test('external selection releases sources for outline masks and reoffers after clearing', () => { const { meshes } = setup() @@ -350,57 +349,53 @@ test('a node still revealing draws itself, then joins once the reveal ends', () } }) -test.each([ - 'item', - 'door', - 'window', - 'ceiling', - 'slab', - 'column', -])('a batched %s whose reverse play begins draws itself again, and stays out until it is gone', (kind) => { - const { meshes } = setup(kind, 4) - if (kind === 'door' || kind === 'window') { - // An opening belongs to its wall; its level is the wall's. - const ids = meshes.map((_, index) => `${kind}_${index}`) - const nodes = useScene.getState().nodes - useScene.setState({ - nodes: { - ...nodes, - wall_host: { - id: 'wall_host', - type: 'wall', - parentId: 'level_test', - visible: true, - children: ids, +test.each(['item', 'door', 'window', 'ceiling', 'slab', 'column'])( + 'a batched %s whose reverse play begins draws itself again, and stays out until it is gone', + (kind) => { + const { meshes } = setup(kind, 4) + if (kind === 'door' || kind === 'window') { + // An opening belongs to its wall; its level is the wall's. + const ids = meshes.map((_, index) => `${kind}_${index}`) + const nodes = useScene.getState().nodes + useScene.setState({ + nodes: { + ...nodes, + wall_host: { + id: 'wall_host', + type: 'wall', + parentId: 'level_test', + visible: true, + children: ids, + }, + ...Object.fromEntries( + ids.map((id) => [id, { ...nodes[id as AnyNodeId], parentId: 'wall_host' }]), + ), }, - ...Object.fromEntries( - ids.map((id) => [id, { ...nodes[id as AnyNodeId], parentId: 'wall_host' }]), - ), - }, - } as never) - } - settle() - expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(false) - expect(meshes[1]!.layers.isEnabled(SCENE_LAYER)).toBe(false) - let going = true - const revealing = spyOn(viewerExports, 'isNodeRevealing').mockImplementation( - (id) => going && id === `${kind}_0`, - ) - try { - frame() - // Its own meshes get the reverse play's pose; the batch copy would stay whole until the host took it out. - expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(true) - expect(meshes[1]!.layers.isEnabled(SCENE_LAYER)).toBe(false) - settle() - expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(true) - - going = false + } as never) + } settle() expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(false) - } finally { - revealing.mockRestore() - } -}) + expect(meshes[1]!.layers.isEnabled(SCENE_LAYER)).toBe(false) + let going = true + const revealing = spyOn(viewerExports, 'isNodeRevealing').mockImplementation( + (id) => going && id === `${kind}_0`, + ) + try { + frame() + // Its own meshes get the reverse play's pose; the batch copy would stay whole until the host took it out. + expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(true) + expect(meshes[1]!.layers.isEnabled(SCENE_LAYER)).toBe(false) + settle() + expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(true) + + going = false + settle() + expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(false) + } finally { + revealing.mockRestore() + } + }, +) test('items retain loading, animation, transparency, hidden-hitbox and dirty-rejoin guards', () => { const { meshes } = setup('item') @@ -430,33 +425,33 @@ test('items retain loading, animation, transparency, hidden-hitbox and dirty-rej itemClipRegistry.delete('item_0') }) -test.each([ - 'door', - 'window', -])('%s retains host tint/override and active-animation exclusions', (kind) => { - setup(kind) - useScene.setState({ - nodes: { - ...useScene.getState().nodes, - wall_host: { - id: 'wall_host', - type: 'wall', - parentId: 'level_test', - visible: true, - children: [`${kind}_0`], +test.each(['door', 'window'])( + '%s retains host tint/override and active-animation exclusions', + (kind) => { + setup(kind) + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + wall_host: { + id: 'wall_host', + type: 'wall', + parentId: 'level_test', + visible: true, + children: [`${kind}_0`], + }, + [`${kind}_0`]: { ...useScene.getState().nodes[`${kind}_0`], parentId: 'wall_host' }, }, - [`${kind}_0`]: { ...useScene.getState().nodes[`${kind}_0`], parentId: 'wall_host' }, - }, - } as never) - expect(candidate(`${kind}_0`).levelId).toBe('level_test') - useViewer.setState({ externalSelectedIds: ['wall_host'] } as never) - expect(collectTintedNodes(new Set([`${kind}_0`]))).toEqual(new Set([`${kind}_0`])) - useLiveNodeOverrides.getState().set('wall_host', { visible: true } as Partial) - expect(collectBatchCandidate(`${kind}_0`)).toBeNull() - useLiveNodeOverrides.getState().clearAll() - useInteractive.setState({ [`${kind}Animations`]: { [`${kind}_0`]: {} } } as never) - expect(collectBatchCandidate(`${kind}_0`)).toBeNull() -}) + } as never) + expect(candidate(`${kind}_0`).levelId).toBe('level_test') + useViewer.setState({ externalSelectedIds: ['wall_host'] } as never) + expect(collectTintedNodes(new Set([`${kind}_0`]))).toEqual(new Set([`${kind}_0`])) + useLiveNodeOverrides.getState().set('wall_host', { visible: true } as Partial) + expect(collectBatchCandidate(`${kind}_0`)).toBeNull() + useLiveNodeOverrides.getState().clearAll() + useInteractive.setState({ [`${kind}Animations`]: { [`${kind}_0`]: {} } } as never) + expect(collectBatchCandidate(`${kind}_0`)).toBeNull() + }, +) test('paint interaction apply then drop ends every fan-out hold without restoring committed materials', () => { const { meshes } = setup() @@ -804,41 +799,41 @@ test('a throwing preview listener rolls back its hold before preview creation', } }) -test.each([ - true, - false, -])('dirty hosts retain the global quiet clock with initial build = %s (drain batching deferred)', (initial) => { - const { root } = setup('item') - useScene.setState({ - nodes: { - ...useScene.getState().nodes, - wall_host: { - id: 'wall_host', - type: 'wall', - parentId: 'level_test', - children: ['door_hosted'], +test.each([true, false])( + 'dirty hosts retain the global quiet clock with initial build = %s (drain batching deferred)', + (initial) => { + const { root } = setup('item') + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + wall_host: { + id: 'wall_host', + type: 'wall', + parentId: 'level_test', + children: ['door_hosted'], + }, + door_hosted: { id: 'door_hosted', type: 'door', parentId: 'wall_host', children: [] }, }, - door_hosted: { id: 'door_hosted', type: 'door', parentId: 'wall_host', children: [] }, - }, - } as never) - useScene.setState({ hydrationToken: initial ? {} : null }) - expect(isWallInitialBuildActive()).toBe(initial) - frame() - for (const time of [100, 200, 300]) { - now = time - useScene.getState().dirtyNodes.add('wall_host' as never) - captureChangedNodes() - useScene.getState().dirtyNodes.clear() + } as never) + useScene.setState({ hydrationToken: initial ? {} : null }) + expect(isWallInitialBuildActive()).toBe(initial) + frame() + for (const time of [100, 200, 300]) { + now = time + useScene.getState().dirtyNodes.add('wall_host' as never) + captureChangedNodes() + useScene.getState().dirtyNodes.clear() + frame() + expect(batches(root)).toHaveLength(0) + } + now = 479 frame() expect(batches(root)).toHaveLength(0) - } - now = 479 - frame() - expect(batches(root)).toHaveLength(0) - now = 481 - frame() - expect(batches(root)).toHaveLength(1) -}) + now = 481 + frame() + expect(batches(root)).toHaveLength(1) + }, +) function patchNodes(patch: (node: AnyNode) => Partial | null) { const nodes = useScene.getState().nodes @@ -885,32 +880,32 @@ test('procedural items batch while static; a playing motion or part lights draw expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(false) }) -test.each([ - 'block', - 'imported-mesh', -])('%s batches per node and a rebuild replaces its slot', (kind) => { - registerBatchable({ - block: blockDefinition.capabilities.batchable!, - 'imported-mesh': importedMeshDefinition.capabilities.batchable!, - }) - const { root, meshes, material } = setup(kind) - const store = new NodeBatchStore(() => root) - stores.push(store) - store.join( - meshes.map((_, i) => candidate(`${kind}_${i}`)), - 1, - ) - expect(candidate(`${kind}_0`).entries[0]!.allocationKey).toBe(`${kind}_0:0`) - expect(batches(root)).toHaveLength(1) - store.release(`${kind}_0`) - meshes[0]!.geometry = new BoxGeometry() - store.join([candidate(`${kind}_0`)], 1) - expect(store.stats().geometryReplacements).toBe(1) - expect(store.stats().overflowRebuilds).toBe(0) - // A multi-slot block draws with a material array and keeps its own draw. - meshes[1]!.material = [material, material] - expect(collectBatchCandidate(`${kind}_1`)).toBeNull() -}) +test.each(['block', 'imported-mesh'])( + '%s batches per node and a rebuild replaces its slot', + (kind) => { + registerBatchable({ + block: blockDefinition.capabilities.batchable!, + 'imported-mesh': importedMeshDefinition.capabilities.batchable!, + }) + const { root, meshes, material } = setup(kind) + const store = new NodeBatchStore(() => root) + stores.push(store) + store.join( + meshes.map((_, i) => candidate(`${kind}_${i}`)), + 1, + ) + expect(candidate(`${kind}_0`).entries[0]!.allocationKey).toBe(`${kind}_0:0`) + expect(batches(root)).toHaveLength(1) + store.release(`${kind}_0`) + meshes[0]!.geometry = new BoxGeometry() + store.join([candidate(`${kind}_0`)], 1) + expect(store.stats().geometryReplacements).toBe(1) + expect(store.stats().overflowRebuilds).toBe(0) + // A multi-slot block draws with a material array and keeps its own draw. + meshes[1]!.material = [material, material] + expect(collectBatchCandidate(`${kind}_1`)).toBeNull() + }, +) test('a built single-slot block joins the batch; a painted multi-slot block keeps its own draw', () => { registerBatchable({ block: blockDefinition.capabilities.batchable! }) diff --git a/packages/nodes/src/shared/opening-move-history.test.ts b/packages/nodes/src/shared/opening-move-history.test.ts index df53483428..fe4fdd57ed 100644 --- a/packages/nodes/src/shared/opening-move-history.test.ts +++ b/packages/nodes/src/shared/opening-move-history.test.ts @@ -209,163 +209,165 @@ describe('opening move history session', () => { [doorDefinition, windowDefinition].flatMap((definition) => (['cancel', 'unmount', 'commit'] as const).map((finish) => ({ definition, finish })), ), - )('a mounted $definition.kind mover saves the original until $finish and releases its draft', async ({ - definition, - finish, - }) => { - const globals = ['window', 'document'] as const - const descriptors = globals.map((key) => Object.getOwnPropertyDescriptor(globalThis, key)) - globalThis.window = new EventTarget() as Window & typeof globalThis - globalThis.document = { body: { style: { cursor: '' } } } as Document - const original = - definition.kind === 'door' - ? node(DOOR_ID) - : WindowNode.parse({ - id: 'window_history', - parentId: WALL_A_ID, - wallId: WALL_A_ID, - position: [1.5, 1.05, 0], + )( + 'a mounted $definition.kind mover saves the original until $finish and releases its draft', + async ({ definition, finish }) => { + const globals = ['window', 'document'] as const + const descriptors = globals.map((key) => Object.getOwnPropertyDescriptor(globalThis, key)) + globalThis.window = new EventTarget() as Window & typeof globalThis + globalThis.document = { body: { style: { cursor: '' } } } as Document + const original = + definition.kind === 'door' + ? node(DOOR_ID) + : WindowNode.parse({ + id: 'window_history', + parentId: WALL_A_ID, + wallId: WALL_A_ID, + position: [1.5, 1.05, 0], + }) + if (definition.kind === 'window') { + useScene.getState().deleteNode(DOOR_ID) + useScene.getState().createNode(original, WALL_A_ID) + clearSceneHistory() + } + const initialViewer = useViewer.getState() + const initialEditor = useEditor.getState() + registerNode(definition) + useEditor.setState({ phase: 'building', mode: 'select', tool: null }) + useViewer.getState().setSelection({ levelId: LEVEL_ID }) + useEditor.getState().setMovingNode(original) + await preloadRegistryToolModules(definition.kind) + const renderer = await create(createElement(ToolManager)) + const now = spyOn(performance, 'now').mockReturnValue(performance.now() + 1000) + try { + expect(useScene.getState().nodes[original.id]!.metadata?.isTransient).toBe(true) + expect(getSavedSceneDocument().nodes[original.id]).toEqual(original) + await act(async () => + emitter.emit('grid:move', { + position: [8, 0, 8], + localPosition: [8, 0, 8], + nativeEvent: {} as never, + }), + ) + expect(useScene.getState().nodes[original.id]!.parentId).toBe(LEVEL_ID) + expect(getSavedSceneDocument().nodes[original.id]).toEqual(original) + if (finish === 'cancel') await act(async () => emitter.emit('tool:cancel')) + if (finish === 'commit') { + const event = { + node: node(WALL_B_ID) as WallNode, + position: [3, 1.05, -2.5] as [number, number, number], + localPosition: [3, 1.05, 0] as [number, number, number], + normal: [0, 0, 1] as [number, number, number], + object: new Group(), + stopPropagation: () => {}, + nativeEvent: {} as never, + } + await act(async () => { + emitter.emit('wall:move', event) + emitter.emit('wall:click', event) }) - if (definition.kind === 'window') { - useScene.getState().deleteNode(DOOR_ID) - useScene.getState().createNode(original, WALL_A_ID) - clearSceneHistory() - } - const initialViewer = useViewer.getState() - const initialEditor = useEditor.getState() - registerNode(definition) - useEditor.setState({ phase: 'building', mode: 'select', tool: null }) - useViewer.getState().setSelection({ levelId: LEVEL_ID }) - useEditor.getState().setMovingNode(original) - await preloadRegistryToolModules(definition.kind) - const renderer = await create(createElement(ToolManager)) - const now = spyOn(performance, 'now').mockReturnValue(performance.now() + 1000) - try { - expect(useScene.getState().nodes[original.id]!.metadata?.isTransient).toBe(true) - expect(getSavedSceneDocument().nodes[original.id]).toEqual(original) - await act(async () => - emitter.emit('grid:move', { - position: [8, 0, 8], - localPosition: [8, 0, 8], - nativeEvent: {} as never, - }), - ) - expect(useScene.getState().nodes[original.id]!.parentId).toBe(LEVEL_ID) - expect(getSavedSceneDocument().nodes[original.id]).toEqual(original) - if (finish === 'cancel') await act(async () => emitter.emit('tool:cancel')) - if (finish === 'commit') { - const event = { - node: node(WALL_B_ID) as WallNode, - position: [3, 1.05, -2.5] as [number, number, number], - localPosition: [3, 1.05, 0] as [number, number, number], - normal: [0, 0, 1] as [number, number, number], - object: new Group(), - stopPropagation: () => {}, - nativeEvent: {} as never, + expect(useScene.getState().nodes[original.id]!.parentId).toBe(WALL_B_ID) + expect(getSavedSceneDocument().nodes[original.id]).toEqual( + useScene.getState().nodes[original.id], + ) } - await act(async () => { - emitter.emit('wall:move', event) - emitter.emit('wall:click', event) - }) - expect(useScene.getState().nodes[original.id]!.parentId).toBe(WALL_B_ID) + await renderer.unmount() expect(getSavedSceneDocument().nodes[original.id]).toEqual( useScene.getState().nodes[original.id], ) + useScene.getState().updateNode(original.id, { position: [4, 1, 0] } as never) + expect(getSavedSceneDocument().nodes[original.id]).toEqual( + useScene.getState().nodes[original.id], + ) + } finally { + now.mockRestore() + await renderer.unmount() + useEditor.getState().setMovingNode(null) + useEditor.setState(initialEditor) + useViewer.setState(initialViewer) + globals.forEach((key, index) => { + const descriptor = descriptors[index] + if (descriptor) Object.defineProperty(globalThis, key, descriptor) + else Reflect.deleteProperty(globalThis, key) + }) } - await renderer.unmount() - expect(getSavedSceneDocument().nodes[original.id]).toEqual( - useScene.getState().nodes[original.id], - ) - useScene.getState().updateNode(original.id, { position: [4, 1, 0] } as never) - expect(getSavedSceneDocument().nodes[original.id]).toEqual( - useScene.getState().nodes[original.id], - ) - } finally { - now.mockRestore() - await renderer.unmount() - useEditor.getState().setMovingNode(null) - useEditor.setState(initialEditor) - useViewer.setState(initialViewer) - globals.forEach((key, index) => { - const descriptor = descriptors[index] - if (descriptor) Object.defineProperty(globalThis, key, descriptor) - else Reflect.deleteProperty(globalThis, key) - }) - } - }) + }, + ) test.each( [doorDefinition, windowDefinition].flatMap((definition) => (['cancel', 'unmount', 'commit'] as const).map((finish) => ({ definition, finish })), ), - )('a mounted $definition.kind draw tool excludes its hover draft until $finish', async ({ - definition, - finish, - }) => { - const globals = ['window', 'document'] as const - const descriptors = globals.map((key) => Object.getOwnPropertyDescriptor(globalThis, key)) - globalThis.window = new EventTarget() as Window & typeof globalThis - globalThis.document = { body: { style: { cursor: '' } } } as Document - const initialViewer = useViewer.getState() - const initialEditor = useEditor.getState() - registerNode(definition) - useEditor.getState().armToolMode({ mode: 'build', tool: definition.kind as 'door' | 'window' }) - useViewer.getState().setSelection({ levelId: LEVEL_ID }) - await preloadRegistryToolModules(definition.kind) - const renderer = await create(createElement(ToolManager)) - const event = { - node: node(WALL_B_ID) as WallNode, - position: [3, 1.05, -2.5] as [number, number, number], - localPosition: [3, 1.05, 0] as [number, number, number], - normal: [0, 0, 1] as [number, number, number], - object: new Group(), - stopPropagation: () => {}, - nativeEvent: {} as never, - } - try { - const baseline = getSavedSceneDocument().nodes - const published: Array['nodes']> = [] - const stop = useScene.subscribe(() => published.push(getSavedSceneDocument().nodes)) - try { - await act(async () => { - emitter.emit('wall:move', event) - // Dimension labels require a DOM; the draft and its host do not. - useOpeningGuides.getState().clear() - }) - } finally { - stop() + )( + 'a mounted $definition.kind draw tool excludes its hover draft until $finish', + async ({ definition, finish }) => { + const globals = ['window', 'document'] as const + const descriptors = globals.map((key) => Object.getOwnPropertyDescriptor(globalThis, key)) + globalThis.window = new EventTarget() as Window & typeof globalThis + globalThis.document = { body: { style: { cursor: '' } } } as Document + const initialViewer = useViewer.getState() + const initialEditor = useEditor.getState() + registerNode(definition) + useEditor + .getState() + .armToolMode({ mode: 'build', tool: definition.kind as 'door' | 'window' }) + useViewer.getState().setSelection({ levelId: LEVEL_ID }) + await preloadRegistryToolModules(definition.kind) + const renderer = await create(createElement(ToolManager)) + const event = { + node: node(WALL_B_ID) as WallNode, + position: [3, 1.05, -2.5] as [number, number, number], + localPosition: [3, 1.05, 0] as [number, number, number], + normal: [0, 0, 1] as [number, number, number], + object: new Group(), + stopPropagation: () => {}, + nativeEvent: {} as never, } - const drafts = Object.values(useScene.getState().nodes).filter( - (entry) => entry.metadata?.isTransient, - ) - expect(drafts).toHaveLength(1) - expect(published.length).toBeGreaterThan(0) - for (const snapshot of published) expect(snapshot).toEqual(baseline) - expect(getSavedSceneDocument().nodes).toEqual(baseline) - if (finish === 'cancel') await act(async () => emitter.emit('tool:cancel')) - if (finish === 'commit') { - await act(async () => emitter.emit('wall:click', event)) - const saved = Object.values(getSavedSceneDocument().nodes).filter( - (entry) => !baseline[entry.id], + try { + const baseline = getSavedSceneDocument().nodes + const published: Array['nodes']> = [] + const stop = useScene.subscribe(() => published.push(getSavedSceneDocument().nodes)) + try { + await act(async () => { + emitter.emit('wall:move', event) + // Dimension labels require a DOM; the draft and its host do not. + useOpeningGuides.getState().clear() + }) + } finally { + stop() + } + const drafts = Object.values(useScene.getState().nodes).filter( + (entry) => entry.metadata?.isTransient, ) - expect(saved).toHaveLength(1) - expect(saved[0]!.type).toBe(definition.kind) + expect(drafts).toHaveLength(1) + expect(published.length).toBeGreaterThan(0) + for (const snapshot of published) expect(snapshot).toEqual(baseline) + expect(getSavedSceneDocument().nodes).toEqual(baseline) + if (finish === 'cancel') await act(async () => emitter.emit('tool:cancel')) + if (finish === 'commit') { + await act(async () => emitter.emit('wall:click', event)) + const saved = Object.values(getSavedSceneDocument().nodes).filter( + (entry) => !baseline[entry.id], + ) + expect(saved).toHaveLength(1) + expect(saved[0]!.type).toBe(definition.kind) + } + await renderer.unmount() + expect(useScene.getState().nodes[drafts[0]!.id]).toBeUndefined() + expect(getSavedSceneDocument().nodes).toEqual(useScene.getState().nodes) + } finally { + await renderer.unmount() + useEditor.getState().setMovingNode(null) + useEditor.setState(initialEditor) + useViewer.setState(initialViewer) + globals.forEach((key, index) => { + const descriptor = descriptors[index] + if (descriptor) Object.defineProperty(globalThis, key, descriptor) + else Reflect.deleteProperty(globalThis, key) + }) } - await renderer.unmount() - expect(useScene.getState().nodes[drafts[0]!.id]).toBeUndefined() - expect(getSavedSceneDocument().nodes).toEqual(useScene.getState().nodes) - } finally { - await renderer.unmount() - useEditor.getState().setMovingNode(null) - useEditor.setState(initialEditor) - useViewer.setState(initialViewer) - globals.forEach((key, index) => { - const descriptor = descriptors[index] - if (descriptor) Object.defineProperty(globalThis, key, descriptor) - else Reflect.deleteProperty(globalThis, key) - }) - } - }) + }, + ) test('a completed gesture is EXACTLY ONE undo entry; undo restores the pre-drag state', () => { const cooperating = attachCooperatingSubscriber() diff --git a/packages/nodes/src/shared/pipe-vertical-offset.test.ts b/packages/nodes/src/shared/pipe-vertical-offset.test.ts index 5ec78c3fda..b210d07615 100644 --- a/packages/nodes/src/shared/pipe-vertical-offset.test.ts +++ b/packages/nodes/src/shared/pipe-vertical-offset.test.ts @@ -165,48 +165,48 @@ describe('planPipeVerticalOffsets', () => { { fittingType: 'wye' as const, portId: 'branch' }, { fittingType: 'sanitary-tee' as const, portId: 'branch' }, { fittingType: 'cross' as const, portId: 'branch' }, - ])('routes a vertical offset from a stationary $fittingType collar', ({ - fittingType, - portId, - }) => { - const fitting = branchFitting(fittingType) - const ports = getPipeFittingPorts(fitting) - const branch = ports.find((p) => p.id === portId)! - const moved = drain([ - [...branch.position], - [ - branch.position[0] + branch.direction[0] * 4, - branch.position[1] + branch.direction[1] * 4, - branch.position[2] + branch.direction[2] * 4, - ], - ]) - - const result = planVerticalOffsets({ - pipe: moved, - dy: 1.2, - profile: { diameter: moved.diameter, pipeMaterial: moved.pipeMaterial }, - connections: [fittingConnection(fitting)], - scenePorts: ports.map((p) => ({ ...p, nodeId: fitting.id })), - nodesById: { - [moved.id]: moved as AnyNode, - [fitting.id]: fitting as AnyNode, - }, - }) - - expect(result?.status).toBe('valid') - if (result?.status !== 'valid') return - expect(result.plan.fittings).toHaveLength(2) - expect(result.plan.risers).toHaveLength(1) - expect(result.plan.updates.some((u) => u.id === fitting.id)).toBe(false) - - const bottomPorts = getPipeFittingPorts(result.plan.fittings[0]!) - const topPorts = getPipeFittingPorts(result.plan.fittings[1]!) - const riser = result.plan.risers[0]! - expect(bottomPorts.some((p) => distSq(p.position, branch.position) < 1e-9)).toBe(true) - expect(bottomPorts.some((p) => distSq(p.position, riser.path[0]!) < 1e-9)).toBe(true) - expect(topPorts.some((p) => distSq(p.position, riser.path[1]!) < 1e-9)).toBe(true) - expect(topPorts.some((p) => distSq(p.position, result.plan.pipePath[0]!) < 1e-9)).toBe(true) - }) + ])( + 'routes a vertical offset from a stationary $fittingType collar', + ({ fittingType, portId }) => { + const fitting = branchFitting(fittingType) + const ports = getPipeFittingPorts(fitting) + const branch = ports.find((p) => p.id === portId)! + const moved = drain([ + [...branch.position], + [ + branch.position[0] + branch.direction[0] * 4, + branch.position[1] + branch.direction[1] * 4, + branch.position[2] + branch.direction[2] * 4, + ], + ]) + + const result = planVerticalOffsets({ + pipe: moved, + dy: 1.2, + profile: { diameter: moved.diameter, pipeMaterial: moved.pipeMaterial }, + connections: [fittingConnection(fitting)], + scenePorts: ports.map((p) => ({ ...p, nodeId: fitting.id })), + nodesById: { + [moved.id]: moved as AnyNode, + [fitting.id]: fitting as AnyNode, + }, + }) + + expect(result?.status).toBe('valid') + if (result?.status !== 'valid') return + expect(result.plan.fittings).toHaveLength(2) + expect(result.plan.risers).toHaveLength(1) + expect(result.plan.updates.some((u) => u.id === fitting.id)).toBe(false) + + const bottomPorts = getPipeFittingPorts(result.plan.fittings[0]!) + const topPorts = getPipeFittingPorts(result.plan.fittings[1]!) + const riser = result.plan.risers[0]! + expect(bottomPorts.some((p) => distSq(p.position, branch.position) < 1e-9)).toBe(true) + expect(bottomPorts.some((p) => distSq(p.position, riser.path[0]!) < 1e-9)).toBe(true) + expect(topPorts.some((p) => distSq(p.position, riser.path[1]!) < 1e-9)).toBe(true) + expect(topPorts.some((p) => distSq(p.position, result.plan.pipePath[0]!) < 1e-9)).toBe(true) + }, + ) test('continues routing after a pipe riser collapse without needing a new drag', () => { const bottom = planPipeElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 1, 0], 3, 'pvc') diff --git a/packages/nodes/src/shared/surface-raycast-batching.test.ts b/packages/nodes/src/shared/surface-raycast-batching.test.ts index 5c4f5a92b1..8a052af587 100644 --- a/packages/nodes/src/shared/surface-raycast-batching.test.ts +++ b/packages/nodes/src/shared/surface-raycast-batching.test.ts @@ -15,34 +15,34 @@ import { resolveWallRole } from '../wall/paint' import { accessoryCursor } from './accessory-cursor' import { resolveSlotByReRaycast } from './slot-paint' -test.each([ - 'door', - 'window', -] as const)('%s paint resolves the real slot before a batched proxy target is released', (kind) => { - const id = `${kind}_paint_batch` as const - const root = new Group() - const mesh = new Mesh(new BoxGeometry(1, 1, 0.1), new MeshBasicMaterial()) - mesh.userData.slotId = 'frame' - root.add(mesh) - root.updateMatrixWorld(true) - const args = { - node: { id, type: kind }, - hitObject: { userData: {} }, - ray: new Ray(new Vector3(0, 0, 2), new Vector3(0, 0, -1)), - } as unknown as PaintResolveArgs - sceneRegistry.nodes.set(id, root) - try { - for (const batched of [false, true, false]) { - if (batched) hideFromScene(mesh, 'batched') - else showInScene(mesh, 'batched') - expect(resolveSlotByReRaycast(args)).toBe('frame') +test.each(['door', 'window'] as const)( + '%s paint resolves the real slot before a batched proxy target is released', + (kind) => { + const id = `${kind}_paint_batch` as const + const root = new Group() + const mesh = new Mesh(new BoxGeometry(1, 1, 0.1), new MeshBasicMaterial()) + mesh.userData.slotId = 'frame' + root.add(mesh) + root.updateMatrixWorld(true) + const args = { + node: { id, type: kind }, + hitObject: { userData: {} }, + ray: new Ray(new Vector3(0, 0, 2), new Vector3(0, 0, -1)), + } as unknown as PaintResolveArgs + sceneRegistry.nodes.set(id, root) + try { + for (const batched of [false, true, false]) { + if (batched) hideFromScene(mesh, 'batched') + else showInScene(mesh, 'batched') + expect(resolveSlotByReRaycast(args)).toBe('frame') + } + } finally { + sceneRegistry.nodes.delete(id) + mesh.geometry.dispose() + mesh.material.dispose() } - } finally { - sceneRegistry.nodes.delete(id) - mesh.geometry.dispose() - mesh.material.dispose() - } -}) + }, +) test('wall paint resolves a batched trim before hover release', () => { const node = WallNode.parse({ id: 'wall_paint_batch', start: [0, 0], end: [1, 0] }) diff --git a/packages/nodes/src/shared/vertical-offset.test.ts b/packages/nodes/src/shared/vertical-offset.test.ts index f32f116039..2096e6ec2d 100644 --- a/packages/nodes/src/shared/vertical-offset.test.ts +++ b/packages/nodes/src/shared/vertical-offset.test.ts @@ -289,58 +289,59 @@ describe('planVerticalOffsets', () => { test.each([ { label: 'up', dy: 1 }, { label: 'down', dy: -0.5 }, - ])('$label moves an elbow-connected top run by stretching the existing vertical riser', ({ - dy, - }) => { - const elbow = DuctFittingNode.parse({ - object: 'node', - parentId: null, - visible: true, - metadata: {}, - name: 'Top corner elbow', - fittingType: 'elbow', - shape: 'rect', - width: RECT_PROFILE.width, - height: RECT_PROFILE.height, - diameter: profileDiameterIn(RECT_PROFILE), - diameter2: profileDiameterIn(RECT_PROFILE), - ductMaterial: 'sheet-metal', - system: 'supply', - position: [0, 2, 0], - rotation: [0, 0, 0], - angle: 90, - }) - const inlet = getDuctFittingPorts(elbow).find((p) => p.id === 'inlet')! - const outlet = getDuctFittingPorts(elbow).find((p) => p.id === 'outlet')! - const moved = rectRun([ - [...inlet.position], - [inlet.position[0] - 4, inlet.position[1], inlet.position[2]], - ]) - const riser = rectRun([[outlet.position[0], 0, outlet.position[2]], [...outlet.position]]) + ])( + '$label moves an elbow-connected top run by stretching the existing vertical riser', + ({ dy }) => { + const elbow = DuctFittingNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: 'Top corner elbow', + fittingType: 'elbow', + shape: 'rect', + width: RECT_PROFILE.width, + height: RECT_PROFILE.height, + diameter: profileDiameterIn(RECT_PROFILE), + diameter2: profileDiameterIn(RECT_PROFILE), + ductMaterial: 'sheet-metal', + system: 'supply', + position: [0, 2, 0], + rotation: [0, 0, 0], + angle: 90, + }) + const inlet = getDuctFittingPorts(elbow).find((p) => p.id === 'inlet')! + const outlet = getDuctFittingPorts(elbow).find((p) => p.id === 'outlet')! + const moved = rectRun([ + [...inlet.position], + [inlet.position[0] - 4, inlet.position[1], inlet.position[2]], + ]) + const riser = rectRun([[outlet.position[0], 0, outlet.position[2]], [...outlet.position]]) - const result = planVerticalOffsets({ - duct: moved, - dy, - profile: RECT_PROFILE, - connections: [fittingConnection(elbow), runConnection(riser)], - scenePorts: [ - { ...inlet, nodeId: elbow.id }, - { ...outlet, nodeId: elbow.id }, - runPort(riser, [...outlet.position], [0, 1, 0]), - ], - nodesById: { - [moved.id]: moved as AnyNode, - [elbow.id]: elbow as AnyNode, - [riser.id]: riser as AnyNode, - }, - }) + const result = planVerticalOffsets({ + duct: moved, + dy, + profile: RECT_PROFILE, + connections: [fittingConnection(elbow), runConnection(riser)], + scenePorts: [ + { ...inlet, nodeId: elbow.id }, + { ...outlet, nodeId: elbow.id }, + runPort(riser, [...outlet.position], [0, 1, 0]), + ], + nodesById: { + [moved.id]: moved as AnyNode, + [elbow.id]: elbow as AnyNode, + [riser.id]: riser as AnyNode, + }, + }) - expect(result?.status).toBe('valid') - if (result?.status !== 'valid') return - expect(result.plan.fittings).toHaveLength(0) - expect(result.plan.risers).toHaveLength(0) - expect(result.plan.followPath[0]?.[1]).toBeCloseTo(inlet.position[1] + dy, 6) - }) + expect(result?.status).toBe('valid') + if (result?.status !== 'valid') return + expect(result.plan.fittings).toHaveLength(0) + expect(result.plan.risers).toHaveLength(0) + expect(result.plan.followPath[0]?.[1]).toBeCloseTo(inlet.position[1] + dy, 6) + }, + ) test('collapses an elbow-riser-elbow side into one elbow when the top run aligns downward', () => { const moved = rectRun([ diff --git a/packages/nodes/src/shelf/__tests__/surface-regions.test.ts b/packages/nodes/src/shelf/__tests__/surface-regions.test.ts index 9f104b8d25..528e101939 100644 --- a/packages/nodes/src/shelf/__tests__/surface-regions.test.ts +++ b/packages/nodes/src/shelf/__tests__/surface-regions.test.ts @@ -31,41 +31,41 @@ function sceneFor(nodes: AnyNode[]): SceneApi { } for (const style of ['wall-shelf', 'bookshelf', 'open-rack', 'cubby'] as const) { - test.each([ - false, - true, - ])(`${style}, bottom %s: every row region matches the board mesh`, (withBottom) => { - for (const withSides of [false, true]) { - const host = ShelfNode.parse({ - style, - withBottom, - withSides, - rows: 3, - width: 1.2, - depth: 0.5, - }) - const mesh = buildShelfGeometry(host) - const boards = mesh.children.filter((m) => m.name.startsWith('shelf-board-')) - const surfaces = getSurfaceProvider(host).surfaces!(host, { scene: sceneFor([host]) }) - expect(surfaces).toHaveLength(boards.length) - for (const surface of surfaces) { - const bottom = withBottom && (style === 'bookshelf' || style === 'cubby') - const index = Number(surface.id.split(':')[1]) - const board = boards.find( - (m) => - m.name === - (bottom && index === 0 - ? 'shelf-board-bottom' - : `shelf-board-${index - (bottom ? 1 : 0)}`), - )! - const box = new Box3().setFromObject(board) - expect(surface.region.size![0] * 2).toBeCloseTo(box.max.x - box.min.x, 6) - expect(surface.region.size![1] * 2).toBeCloseTo(box.max.z - box.min.z, 6) - expect(surface.region.center ?? [0, 0]).toEqual([0, 0]) + test.each([false, true])( + `${style}, bottom %s: every row region matches the board mesh`, + (withBottom) => { + for (const withSides of [false, true]) { + const host = ShelfNode.parse({ + style, + withBottom, + withSides, + rows: 3, + width: 1.2, + depth: 0.5, + }) + const mesh = buildShelfGeometry(host) + const boards = mesh.children.filter((m) => m.name.startsWith('shelf-board-')) + const surfaces = getSurfaceProvider(host).surfaces!(host, { scene: sceneFor([host]) }) + expect(surfaces).toHaveLength(boards.length) + for (const surface of surfaces) { + const bottom = withBottom && (style === 'bookshelf' || style === 'cubby') + const index = Number(surface.id.split(':')[1]) + const board = boards.find( + (m) => + m.name === + (bottom && index === 0 + ? 'shelf-board-bottom' + : `shelf-board-${index - (bottom ? 1 : 0)}`), + )! + const box = new Box3().setFromObject(board) + expect(surface.region.size![0] * 2).toBeCloseTo(box.max.x - box.min.x, 6) + expect(surface.region.size![1] * 2).toBeCloseTo(box.max.z - box.min.z, 6) + expect(surface.region.center ?? [0, 0]).toEqual([0, 0]) + } + mesh.traverse((object) => (object as Mesh).geometry?.dispose()) } - mesh.traverse((object) => (object as Mesh).geometry?.dispose()) - } - }) + }, + ) } test('every declaring provider publishes regions; ray-derived providers declare no surfaces', () => { diff --git a/packages/nodes/src/site/terrain-mesh.test.ts b/packages/nodes/src/site/terrain-mesh.test.ts index 9dd3b45581..14ec1b5535 100644 --- a/packages/nodes/src/site/terrain-mesh.test.ts +++ b/packages/nodes/src/site/terrain-mesh.test.ts @@ -176,46 +176,91 @@ describe('applyTerrainPatch — conservative bounds and locality', () => { } } - 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]), + 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) } - const previous = field - const previousHeights = field.heights.slice() - field = applyHeightPatch(field, patch) - const expectedHeights = field.heights.slice() - applyTerrainPatch(target, field, patch) + } finally { + disposeTerrainGeometry(target) + } + }, + ) - const surface = buildTerrainMesh(field) - const skirt = buildTerrainSkirt(field) + 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) @@ -223,52 +268,11 @@ describe('applyTerrainPatch — conservative bounds and locality', () => { 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) } - } 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 }) diff --git a/packages/nodes/src/slab/__tests__/floor-support-hosting.test.tsx b/packages/nodes/src/slab/__tests__/floor-support-hosting.test.tsx index 2fb2f3717a..0422bdb8d6 100644 --- a/packages/nodes/src/slab/__tests__/floor-support-hosting.test.tsx +++ b/packages/nodes/src/slab/__tests__/floor-support-hosting.test.tsx @@ -158,24 +158,23 @@ function CatalogMover() { return withoutLabels(MoveItemTool({ node })) } -test.each([ - 'item', - 'procedural-item', - 'cabinet', -])('slabs defer %s to floor support without a surface refusal', (childKind) => { - const refusals: string[] = [] - expect( - resolveSurfacePlacement({ - host: slab, - childKind, - childFootprint: { size: [0.4, 0.6, 0.4], rotationY: 0 }, - hit: { point: [0, slab.elevation, 0], normalWorldY: 1 }, - scene: createSceneApi(useScene), - onReject: (reason) => refusals.push(reason), - }), - ).toBeNull() - expect(refusals).toEqual([]) -}) +test.each(['item', 'procedural-item', 'cabinet'])( + 'slabs defer %s to floor support without a surface refusal', + (childKind) => { + const refusals: string[] = [] + expect( + resolveSurfacePlacement({ + host: slab, + childKind, + childFootprint: { size: [0.4, 0.6, 0.4], rotationY: 0 }, + hit: { point: [0, slab.elevation, 0], normalWorldY: 1 }, + scene: createSceneApi(useScene), + onReject: (reason) => refusals.push(reason), + }), + ).toBeNull() + expect(refusals).toEqual([]) + }, +) for (const mover of [ 'catalog', diff --git a/packages/nodes/src/slab/__tests__/plate-paint.test.ts b/packages/nodes/src/slab/__tests__/plate-paint.test.ts index a8f985b416..eb8581c523 100644 --- a/packages/nodes/src/slab/__tests__/plate-paint.test.ts +++ b/packages/nodes/src/slab/__tests__/plate-paint.test.ts @@ -327,27 +327,27 @@ test('base surface paint routes to a covering room and never writes the protecte expect(slabNow(plate.id).slots).toBeUndefined() }) -test.each([ - true, - false, -])('foundation paint uses one undo step, including a new material: %s', (custom) => { - plate = { ...plate, plateRole: 'base', foundation: { type: 'solid', material: WOOD } } - useScene.setState({ nodes: { ...useScene.getState().nodes, [plate.id]: plate } }) - useScene.temporal.getState().clear() - const before = useScene.getState().nodes - slabPaint.commit?.({ - node: plate, - role: 'foundation', - material: custom ? MaterialSchema.parse({ properties: { color: '#123456' } }) : undefined, - materialPreset: custom ? undefined : TILE, - }) - expect(useScene.temporal.getState().pastStates).toHaveLength(1) - expect(slabNow(plate.id).foundation?.material).not.toBe(WOOD) - if (custom) expect(Object.keys(useScene.getState().materials)).toHaveLength(1) - useScene.temporal.getState().undo() - expect(useScene.getState().nodes).toEqual(before) - expect(useScene.getState().materials).toEqual({}) -}) +test.each([true, false])( + 'foundation paint uses one undo step, including a new material: %s', + (custom) => { + plate = { ...plate, plateRole: 'base', foundation: { type: 'solid', material: WOOD } } + useScene.setState({ nodes: { ...useScene.getState().nodes, [plate.id]: plate } }) + useScene.temporal.getState().clear() + const before = useScene.getState().nodes + slabPaint.commit?.({ + node: plate, + role: 'foundation', + material: custom ? MaterialSchema.parse({ properties: { color: '#123456' } }) : undefined, + materialPreset: custom ? undefined : TILE, + }) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + expect(slabNow(plate.id).foundation?.material).not.toBe(WOOD) + if (custom) expect(Object.keys(useScene.getState().materials)).toHaveLength(1) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes).toEqual(before) + expect(useScene.getState().materials).toEqual({}) + }, +) test('a zoneless base surface returns a notice instead of throwing or recording history', () => { plate = { ...plate, plateRole: 'base', zoneIds: [] } diff --git a/packages/nodes/src/wall/split-preview.test.ts b/packages/nodes/src/wall/split-preview.test.ts index d46cb9c88b..d5cbdd0f91 100644 --- a/packages/nodes/src/wall/split-preview.test.ts +++ b/packages/nodes/src/wall/split-preview.test.ts @@ -35,28 +35,29 @@ describe('wall split marker', () => { expect([preview.frames[0]!.point.x, preview.frames[0]!.point.y]).toEqual(plan.point) expect(preview.frames).toEqual(wallSplitPreview(nodes, wall, [5]).frames) }) - test.each([ - 1, -1, - ])('curved wall mouse target agrees with arc-length widget, offset %p', (offset) => { - const wall = WallNode.parse({ - parentId: level.id, - start: [2, 3], - end: [10, 5], - curveOffset: offset, - }) - for (const t of [0.1, 0.37, 0.9]) { - const frame = getWallCurveFrameAt(wall, t) - const distance = wallSplitDistance(wall, [ - frame.point.x + frame.normal.x * 0.15, - frame.point.y + frame.normal.y * 0.15, - ]) - expect(distance).toBeCloseTo(t * getWallCurveLength(wall), 7) - const preview = wallSplitPreview(graph(wall), wall, [distance]) - expect(preview.valid).toBe(true) - expect(preview.frames[0]!.point.x).toBeCloseTo(frame.point.x) - expect(preview.frames[0]!.point.y).toBeCloseTo(frame.point.y) - } - }) + test.each([1, -1])( + 'curved wall mouse target agrees with arc-length widget, offset %p', + (offset) => { + const wall = WallNode.parse({ + parentId: level.id, + start: [2, 3], + end: [10, 5], + curveOffset: offset, + }) + for (const t of [0.1, 0.37, 0.9]) { + const frame = getWallCurveFrameAt(wall, t) + const distance = wallSplitDistance(wall, [ + frame.point.x + frame.normal.x * 0.15, + frame.point.y + frame.normal.y * 0.15, + ]) + expect(distance).toBeCloseTo(t * getWallCurveLength(wall), 7) + const preview = wallSplitPreview(graph(wall), wall, [distance]) + expect(preview.valid).toBe(true) + expect(preview.frames[0]!.point.x).toBeCloseTo(frame.point.x) + expect(preview.frames[0]!.point.y).toBeCloseTo(frame.point.y) + } + }, + ) test('invalid cut stays where the pointer is and does not silently snap past an opening', () => { const wall = WallNode.parse({ parentId: level.id, start: [0, 0], end: [8, 0] }) const window = WindowNode.parse({ diff --git a/packages/nodes/src/wall/treatments.test.ts b/packages/nodes/src/wall/treatments.test.ts index 5c60338274..f4bdc4f482 100644 --- a/packages/nodes/src/wall/treatments.test.ts +++ b/packages/nodes/src/wall/treatments.test.ts @@ -89,69 +89,68 @@ describe('wall treatment miters', () => { } }) - test.each([ - 'skirting', - 'crown', - 'chairRail', - ] as const)('preserves %s position bytes with cached per-wall endpoint data', (kind) => { - const cases = [ - [wall('A', [0, 0], [3, 0])], - [wall('A', [0, 0], [3, 0]), wall('B', [3, 0], [3, 3])], - [wall('A', [0, 0], [3, 0]), wall('B', [0, 0], [1, 3])], - [wall('A', [0, 0], [0, 3]), wall('B', [-3, 0], [3, 0])], - [{ ...wall('A', [0, 0], [3, 0]), curveOffset: 0.4 }], - ] - for (const walls of cases) { - const node = { ...walls[0]!, [kind]: trim } - const proudOffsets = wallTreatmentProudOffsets(node) - const prouds = new Set([0, ...proudOffsets.map((proud) => Math.round(proud * 1e6) / 1e6)]) - const reference: WallTreatmentLevelData = { - walls, - miterDataByProud: new Map( - [...prouds].map((proud) => [ - proud, - calculateLevelMiters( - proud === 0 - ? [...walls] - : walls.map((entry) => ({ - ...entry, - thickness: getWallThickness(entry) + proud * 2, - })), - ), - ]), - ), - } - const data = buildWallTreatmentLevelData('level_test', walls, proudOffsets) - const select = createWallTreatmentSelector(node, proudOffsets) - const slice = select({ - ...useWallTreatmentLevelData.getState(), - byLevelId: new Map([['level_test', data]]), - })! - for (const side of ['a', 'b'] as const) { - const openings = [ - { - type: 'door', - width: 0.8, - height: 2, - position: [1.5, 1, 0] as [number, number, number], - }, - ] - const before = buildTrimGeometry(node, side, trim, kind, openings, reference)! - const after = buildTrimGeometry(node, side, trim, kind, openings, slice)! - expect(before).not.toBeNull() - expect(after).not.toBeNull() - const beforeArray = before.getAttribute('position').array - const afterArray = after.getAttribute('position').array - expect( - new Uint8Array(afterArray.buffer, afterArray.byteOffset, afterArray.byteLength), - ).toEqual( - new Uint8Array(beforeArray.buffer, beforeArray.byteOffset, beforeArray.byteLength), - ) - before.dispose() - after.dispose() + test.each(['skirting', 'crown', 'chairRail'] as const)( + 'preserves %s position bytes with cached per-wall endpoint data', + (kind) => { + const cases = [ + [wall('A', [0, 0], [3, 0])], + [wall('A', [0, 0], [3, 0]), wall('B', [3, 0], [3, 3])], + [wall('A', [0, 0], [3, 0]), wall('B', [0, 0], [1, 3])], + [wall('A', [0, 0], [0, 3]), wall('B', [-3, 0], [3, 0])], + [{ ...wall('A', [0, 0], [3, 0]), curveOffset: 0.4 }], + ] + for (const walls of cases) { + const node = { ...walls[0]!, [kind]: trim } + const proudOffsets = wallTreatmentProudOffsets(node) + const prouds = new Set([0, ...proudOffsets.map((proud) => Math.round(proud * 1e6) / 1e6)]) + const reference: WallTreatmentLevelData = { + walls, + miterDataByProud: new Map( + [...prouds].map((proud) => [ + proud, + calculateLevelMiters( + proud === 0 + ? [...walls] + : walls.map((entry) => ({ + ...entry, + thickness: getWallThickness(entry) + proud * 2, + })), + ), + ]), + ), + } + const data = buildWallTreatmentLevelData('level_test', walls, proudOffsets) + const select = createWallTreatmentSelector(node, proudOffsets) + const slice = select({ + ...useWallTreatmentLevelData.getState(), + byLevelId: new Map([['level_test', data]]), + })! + for (const side of ['a', 'b'] as const) { + const openings = [ + { + type: 'door', + width: 0.8, + height: 2, + position: [1.5, 1, 0] as [number, number, number], + }, + ] + const before = buildTrimGeometry(node, side, trim, kind, openings, reference)! + const after = buildTrimGeometry(node, side, trim, kind, openings, slice)! + expect(before).not.toBeNull() + expect(after).not.toBeNull() + const beforeArray = before.getAttribute('position').array + const afterArray = after.getAttribute('position').array + expect( + new Uint8Array(afterArray.buffer, afterArray.byteOffset, afterArray.byteLength), + ).toEqual( + new Uint8Array(beforeArray.buffer, beforeArray.byteOffset, beforeArray.byteLength), + ) + before.dispose() + after.dispose() + } } - } - }) + }, + ) test.each([ ['skirting', 0.0624], diff --git a/packages/nodes/src/wall/wall-batch-system.test.ts b/packages/nodes/src/wall/wall-batch-system.test.ts index 585f32f587..5dccd40c56 100644 --- a/packages/nodes/src/wall/wall-batch-system.test.ts +++ b/packages/nodes/src/wall/wall-batch-system.test.ts @@ -144,16 +144,14 @@ describe('collectTintedWalls', () => { // mode and in paint mode alike. const wallIds = new Set(['wall_a', 'wall_b']) - test.each([ - 'default', - 'paint-ready', - 'paint-disabled', - 'delete', - ])('a %s hover takes the wall out of its batch', (hoverHighlightMode) => { - useViewer.setState({ hoverHighlightMode, hoveredId: 'wall_a' } as never) - - expect([...collectTintedWalls(wallIds)]).toEqual(['wall_a']) - }) + test.each(['default', 'paint-ready', 'paint-disabled', 'delete'])( + 'a %s hover takes the wall out of its batch', + (hoverHighlightMode) => { + useViewer.setState({ hoverHighlightMode, hoveredId: 'wall_a' } as never) + + expect([...collectTintedWalls(wallIds)]).toEqual(['wall_a']) + }, + ) test('a hover over a non-wall node tints nothing', () => { useViewer.setState({ hoverHighlightMode: 'default', hoveredId: 'slab_a' } as never) diff --git a/packages/viewer/src/components/viewer/registered-systems.test.tsx b/packages/viewer/src/components/viewer/registered-systems.test.tsx index 86883d21e8..838b38b3d1 100644 --- a/packages/viewer/src/components/viewer/registered-systems.test.tsx +++ b/packages/viewer/src/components/viewer/registered-systems.test.tsx @@ -30,72 +30,73 @@ function registerSystem(kind: string, module: NonNullable { - const error = new Error(`broken system ${phase}`) - const log = spyOn(console, 'error').mockImplementation(() => {}) - const report = spyOn(globalThis, 'reportError').mockImplementation(() => {}) - let mounts = 0 - let unmounts = 0 - let frames = 0 - function HealthySystem() { - useEffect(() => { - mounts++ - return () => { - unmounts++ - } - }, []) - useFrame(() => { - frames++ +test.each(['render', 'effect', 'load'] as const)( + 'a system failing during %s disables itself while siblings keep running without remounting', + async (phase) => { + const error = new Error(`broken system ${phase}`) + const log = spyOn(console, 'error').mockImplementation(() => {}) + const report = spyOn(globalThis, 'reportError').mockImplementation(() => {}) + let mounts = 0 + let unmounts = 0 + let frames = 0 + function HealthySystem() { + useEffect(() => { + mounts++ + return () => { + unmounts++ + } + }, []) + useFrame(() => { + frames++ + }) + return + } + function BrokenSystem() { + useEffect(() => { + if (phase === 'effect') throw error + }, []) + if (phase === 'render') throw error + return + } + registerSystem('healthy-system', async () => ({ default: HealthySystem })) + registerSystem('broken-system', async () => { + if (phase === 'load') throw error + return { default: BrokenSystem } }) - return - } - function BrokenSystem() { - useEffect(() => { - if (phase === 'effect') throw error - }, []) - if (phase === 'render') throw error - return - } - registerSystem('healthy-system', async () => ({ default: HealthySystem })) - registerSystem('broken-system', async () => { - if (phase === 'load') throw error - return { default: BrokenSystem } - }) - try { - const renderer = await create() try { - expect(renderer.scene.findAllByProps({ name: 'healthy-system' })).toHaveLength(1) - expect(renderer.scene.findAllByProps({ name: 'broken-system' })).toHaveLength(0) - expect( - log.mock.calls.some( - ([message, caught]) => - String(message).includes('Disabled system broken-system') && caught === error, - ), - ).toBe(true) - expect(mounts).toBe(1) - await renderer.advanceFrames(1, 0.016) - expect(frames).toBe(1) - await renderer.update() - await act(async () => { - useScene.setState({ installedPlugins: ['unrelated-plugin'] }) - registerSystem('late-system', async () => ({ default: () => })) - }) - expect(renderer.scene.findAllByProps({ name: 'late-system' })).toHaveLength(1) - expect(renderer.scene.findAllByProps({ name: 'broken-system' })).toHaveLength(0) - expect(mounts).toBe(1) - expect(unmounts).toBe(0) - await renderer.advanceFrames(1, 0.016) - expect(frames).toBe(2) + const renderer = await create() + try { + expect(renderer.scene.findAllByProps({ name: 'healthy-system' })).toHaveLength(1) + expect(renderer.scene.findAllByProps({ name: 'broken-system' })).toHaveLength(0) + expect( + log.mock.calls.some( + ([message, caught]) => + String(message).includes('Disabled system broken-system') && caught === error, + ), + ).toBe(true) + expect(mounts).toBe(1) + await renderer.advanceFrames(1, 0.016) + expect(frames).toBe(1) + await renderer.update() + await act(async () => { + useScene.setState({ installedPlugins: ['unrelated-plugin'] }) + registerSystem('late-system', async () => ({ + default: () => , + })) + }) + expect(renderer.scene.findAllByProps({ name: 'late-system' })).toHaveLength(1) + expect(renderer.scene.findAllByProps({ name: 'broken-system' })).toHaveLength(0) + expect(mounts).toBe(1) + expect(unmounts).toBe(0) + await renderer.advanceFrames(1, 0.016) + expect(frames).toBe(2) + } finally { + await renderer.unmount() + } + expect(unmounts).toBe(1) } finally { - await renderer.unmount() + log.mockRestore() + report.mockRestore() } - expect(unmounts).toBe(1) - } finally { - log.mockRestore() - report.mockRestore() - } -}) + }, +) From 6908fe4fd85a81d0f34e1448f9a158a3b15004a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 13:17:45 +0000 Subject: [PATCH 3/3] test: use non-null assertions where Biome 2.5 flags unsafe optional chaining --- apps/editor/lib/graph-schema.test.ts | 4 ++-- packages/core/src/store/use-scene-commits.test.ts | 8 ++++---- .../systems/elevator/elevator-opening-system.test.ts | 2 +- .../src/systems/roof/roof-elevation-system.test.ts | 4 ++-- .../renderers/floorplan-dimension-edit-overlay.test.ts | 2 +- packages/mcp/src/tools/check-collisions.test.ts | 2 +- .../mcp/src/tools/scene-lifecycle/save-scene.test.ts | 2 +- packages/nodes/src/cabinet/__tests__/hosting.test.tsx | 2 +- packages/nodes/src/cabinet/__tests__/run-ops.test.ts | 2 +- packages/nodes/src/cabinet/__tests__/wall-snap.test.ts | 2 +- packages/nodes/src/duct-fitting/parametrics.test.ts | 10 +++++----- packages/nodes/src/lean-to-extension/system.test.ts | 2 +- packages/nodes/src/pipe-fitting/parametrics.test.ts | 6 +++--- 13 files changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/editor/lib/graph-schema.test.ts b/apps/editor/lib/graph-schema.test.ts index 7260486630..70ed0b687c 100644 --- a/apps/editor/lib/graph-schema.test.ts +++ b/apps/editor/lib/graph-schema.test.ts @@ -68,7 +68,7 @@ test('keeps plugin child ids in the parsed graph', () => { const res = apiGraphSchema.safeParse(graph) expect(res.success).toBe(true) - expect((res.data?.nodes[LEVEL_ID] as { children: string[] }).children).toEqual([TREE_ID]) + expect((res.data!.nodes[LEVEL_ID] as { children: string[] }).children).toEqual([TREE_ID]) }) test('preserves installedPlugins alongside a plugin node', () => { @@ -178,7 +178,7 @@ test('migrates legacy wall assemblies before validation and persists canonical l const result = apiGraphSchema.safeParse(graph) expect(result.success).toBe(true) - expect((result.data?.nodes[wall.id] as WallNode).assembly).toEqual({ + expect((result.data!.nodes[wall.id] as WallNode).assembly).toEqual({ face: 'exterior', layers: [{ id: 'framing', role: 'structure', thickness: 0.14, core: true, material: 'wood' }], }) diff --git a/packages/core/src/store/use-scene-commits.test.ts b/packages/core/src/store/use-scene-commits.test.ts index 1a5cc118d6..922b2e4849 100644 --- a/packages/core/src/store/use-scene-commits.test.ts +++ b/packages/core/src/store/use-scene-commits.test.ts @@ -184,8 +184,8 @@ describe('scene commit boundary', () => { useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial) expect(commits).toHaveLength(1) expect(commits[0]?.origin).toBe('local') - expect((commits[0]?.before.nodes[LEVEL_ID] as { level: number }).level).toBe(0) - expect((commits[0]?.current.nodes[LEVEL_ID] as { level: number }).level).toBe(1) + expect((commits[0]!.before.nodes[LEVEL_ID] as { level: number }).level).toBe(0) + expect((commits[0]!.current.nodes[LEVEL_ID] as { level: number }).level).toBe(1) expect(useScene.temporal.getState().pastStates).toHaveLength(1) useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial) @@ -295,8 +295,8 @@ describe('scene commit boundary', () => { }) expect(commits).toHaveLength(1) - expect((commits[0]?.before.nodes[LEVEL_ID] as { level: number }).level).toBe(0) - expect((commits[0]?.current.nodes[LEVEL_ID] as { level: number }).level).toBe(2) + expect((commits[0]!.before.nodes[LEVEL_ID] as { level: number }).level).toBe(0) + expect((commits[0]!.current.nodes[LEVEL_ID] as { level: number }).level).toBe(2) expect(useScene.temporal.getState().pastStates).toHaveLength(1) useScene.temporal.getState().undo() diff --git a/packages/core/src/systems/elevator/elevator-opening-system.test.ts b/packages/core/src/systems/elevator/elevator-opening-system.test.ts index e8fdc5ad34..df2a2aec42 100644 --- a/packages/core/src/systems/elevator/elevator-opening-system.test.ts +++ b/packages/core/src/systems/elevator/elevator-opening-system.test.ts @@ -126,7 +126,7 @@ describe('ElevatorOpeningSystem scene commit boundary', () => { expect(commits[0]?.origin).toBe('local') expect(commits[0]?.before.nodes[ELEVATOR_ID]?.visible).toBe(false) expect(commits[0]?.current.nodes[ELEVATOR_ID]?.visible).toBe(true) - expect((commits[0]?.before.nodes[UPPER_SLAB_ID] as { holes?: unknown[] }).holes).toEqual([]) + expect((commits[0]!.before.nodes[UPPER_SLAB_ID] as { holes?: unknown[] }).holes).toEqual([]) expect(getOpeningCenter(commits[0]!.current.nodes)).toEqual([2, 1.5]) expect( Object.values(commits[0]!.current.nodes).some( diff --git a/packages/core/src/systems/roof/roof-elevation-system.test.ts b/packages/core/src/systems/roof/roof-elevation-system.test.ts index fae9c30233..f1abc50048 100644 --- a/packages/core/src/systems/roof/roof-elevation-system.test.ts +++ b/packages/core/src/systems/roof/roof-elevation-system.test.ts @@ -94,8 +94,8 @@ describe('RoofElevationSystem', () => { 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]!.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() diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts index d3b59e19e5..8d9446464b 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts @@ -126,7 +126,7 @@ describe('commitDimensionValue', () => { expect(applied).toEqual([]) expect(updated).toHaveLength(1) expect(updated[0]?.id).toBe('dim_1' as AnyNodeId) - expect(typeof (updated[0]?.data as { textOverride?: unknown }).textOverride).toBe('string') + expect(typeof (updated[0]!.data as { textOverride?: unknown }).textOverride).toBe('string') }) }) diff --git a/packages/mcp/src/tools/check-collisions.test.ts b/packages/mcp/src/tools/check-collisions.test.ts index dcfb06a34e..45253f2c9b 100644 --- a/packages/mcp/src/tools/check-collisions.test.ts +++ b/packages/mcp/src/tools/check-collisions.test.ts @@ -125,7 +125,7 @@ describe('check_collisions', () => { idempotentHint: true, openWorldHint: false, }) - const candidate = (tool?.inputSchema.properties as Record).candidate as { + const candidate = (tool!.inputSchema.properties as Record).candidate as { properties: Record } for (const field of ['position', 'dimensions']) { diff --git a/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts b/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts index 26233c02d2..7944148ab2 100644 --- a/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts +++ b/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts @@ -255,7 +255,7 @@ describe('save_scene', () => { expect(result.isError).toBeFalsy() const payload = parseToolText(result.content as StoredTextContent[]) const saved = await store.load(payload.id as string) - expect((saved?.graph.nodes[wall.id] as WallNode).assembly).toEqual({ + expect((saved!.graph.nodes[wall.id] as WallNode).assembly).toEqual({ face: 'exterior', layers: [{ id: 'framing', role: 'structure', thickness: 0.14, core: true, material: 'wood' }], }) diff --git a/packages/nodes/src/cabinet/__tests__/hosting.test.tsx b/packages/nodes/src/cabinet/__tests__/hosting.test.tsx index 02da018713..86582f95f1 100644 --- a/packages/nodes/src/cabinet/__tests__/hosting.test.tsx +++ b/packages/nodes/src/cabinet/__tests__/hosting.test.tsx @@ -521,7 +521,7 @@ test('resize handle previews carry child heights and can be cancelled without a const before = useScene.getState().nodes binding.apply(0.92, { altKey: false }) expect( - (useLiveNodeOverrides.getState().overrides.get(catalog.id)?.position as number[])[1], + (useLiveNodeOverrides.getState().overrides.get(catalog.id)!.position as number[])[1], ).toBeCloseTo(1.05) expect(useLiveNodeOverrides.getState().overrides.has(design.id)).toBe(true) binding.clearPreview() diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts index 212238e11d..895595ab43 100644 --- a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts +++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts @@ -1325,7 +1325,7 @@ describe('addCornerRun', () => { } const sourceLink = ( - sceneApi.get(module.id)?.metadata as Record + sceneApi.get(module.id)!.metadata as Record ).cabinetCornerSourceLink as { linkedRunIds: AnyNodeId[] } expect(sourceLink.linkedRunIds).toHaveLength(6) diff --git a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts index 2bd1845137..49889ee4f7 100644 --- a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts +++ b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts @@ -185,7 +185,7 @@ describe('already-placed cabinet grid snap', () => { [level, wall, door, run, module].map((node) => [node.id, node as AnyNode]), ) as Record const isValidPosition = ( - cabinetDefinition.capabilities?.movable as unknown as { + cabinetDefinition.capabilities!.movable as unknown as { isValidPosition?: (args: Record) => boolean } ).isValidPosition diff --git a/packages/nodes/src/duct-fitting/parametrics.test.ts b/packages/nodes/src/duct-fitting/parametrics.test.ts index 39f5d2f9ea..187d1d9c5c 100644 --- a/packages/nodes/src/duct-fitting/parametrics.test.ts +++ b/packages/nodes/src/duct-fitting/parametrics.test.ts @@ -94,8 +94,8 @@ describe('ductFittingParametrics', () => { const ductUpdate = updates.find((u) => u.id === duct.id) expect(ductUpdate).toBeDefined() - expect((ductUpdate?.data as Partial).path).toBeDefined() - expect((ductUpdate?.data as Partial).roll).toBeUndefined() + expect((ductUpdate!.data as Partial).path).toBeDefined() + expect((ductUpdate!.data as Partial).roll).toBeUndefined() }) test('resizing a fitting refreshes a connected duct auto-offset base path', () => { @@ -164,10 +164,10 @@ describe('ductFittingParametrics', () => { const inletUpdate = updates.find((u) => u.id === inletRun.id) // Both mated endpoints snap back to the junction (the original corner). - expect((outletUpdate?.data as Partial).path?.[0]).toEqual([ + expect((outletUpdate!.data as Partial).path?.[0]).toEqual([ ...fitting.position, ]) - expect((inletUpdate?.data as Partial).path?.[0]).toEqual([...fitting.position]) + expect((inletUpdate!.data as Partial).path?.[0]).toEqual([...fitting.position]) }) test('delete repair matches the 5 cm live connectivity mate tolerance', () => { @@ -186,7 +186,7 @@ describe('ductFittingParametrics', () => { const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? [] const outletUpdate = updates.find((u) => u.id === outletRun.id) - expect((outletUpdate?.data as Partial).path?.[0]).toEqual([ + expect((outletUpdate!.data as Partial).path?.[0]).toEqual([ ...fitting.position, ]) }) diff --git a/packages/nodes/src/lean-to-extension/system.test.ts b/packages/nodes/src/lean-to-extension/system.test.ts index bc7639d72e..2d9b6edfeb 100644 --- a/packages/nodes/src/lean-to-extension/system.test.ts +++ b/packages/nodes/src/lean-to-extension/system.test.ts @@ -98,7 +98,7 @@ describe('lean-to scene commit boundary', () => { expect(commits).toHaveLength(1) expect(commits[0]?.current.nodes[segmentId]?.type).toBe('roof-segment') - expect((commits[0]?.current.nodes[segmentId] as { depth: number }).depth).toBeCloseTo(4.27) + expect((commits[0]!.current.nodes[segmentId] as { depth: number }).depth).toBeCloseTo(4.27) expect(useScene.temporal.getState().pastStates).toHaveLength(1) stopCommits() }) diff --git a/packages/nodes/src/pipe-fitting/parametrics.test.ts b/packages/nodes/src/pipe-fitting/parametrics.test.ts index 2fe815824c..ec12b0bb95 100644 --- a/packages/nodes/src/pipe-fitting/parametrics.test.ts +++ b/packages/nodes/src/pipe-fitting/parametrics.test.ts @@ -66,8 +66,8 @@ describe('pipeFittingParametrics', () => { const inletUpdate = updates.find((u) => u.id === inletRun.id) const outletUpdate = updates.find((u) => u.id === outletRun.id) - expect((inletUpdate?.data as Partial).path?.[1]).toEqual([...fitting.position]) - expect((outletUpdate?.data as Partial).path?.[0]).toEqual([ + expect((inletUpdate!.data as Partial).path?.[1]).toEqual([...fitting.position]) + expect((outletUpdate!.data as Partial).path?.[0]).toEqual([ ...fitting.position, ]) }) @@ -86,7 +86,7 @@ describe('pipeFittingParametrics', () => { const updates = pipeFittingParametrics.onDelete?.(fitting, nodes) ?? [] - expect((updates[0]?.data as Partial).path?.[1]).toEqual([...fitting.position]) + expect((updates[0]!.data as Partial).path?.[1]).toEqual([...fitting.position]) }) test('deleting a branch fitting leaves mated pipe ends untouched', () => {