From 688957f326401cb34f5c02b77384a79364a709b8 Mon Sep 17 00:00:00 2001 From: elpepitolindo Date: Wed, 11 Nov 2026 13:38:45 -0500 Subject: [PATCH 1/2] Fix walkability door opening for Blender panels Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 + docs/how-it-works.md | 11 + package-lock.json | 89 ++-- package.json | 2 + src/client/townhall.html | 46 ++ src/client/townhall.ts | 365 ++++++++++++++ src/client/townhall/door-classification.ts | 42 ++ src/client/townhall/door-motion.ts | 68 +++ src/client/townhall/walkability-test.ts | 523 +++++++++++++++++++++ tests/walkability-doors.test.ts | 57 +++ vite.config.ts | 2 + 11 files changed, 1169 insertions(+), 38 deletions(-) create mode 100644 src/client/townhall.html create mode 100644 src/client/townhall.ts create mode 100644 src/client/townhall/door-classification.ts create mode 100644 src/client/townhall/door-motion.ts create mode 100644 src/client/townhall/walkability-test.ts create mode 100644 tests/walkability-doors.test.ts diff --git a/README.md b/README.md index fac9441a5..9fcfcd859 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ agent-office setup # the first-start walkthrough again Every option is in [docs/configuration.md](docs/configuration.md). Choosing models and providers per worker is in [docs/agents.md](docs/agents.md). +For a standalone GLB walkthrough and walkability audit, see [the local Town Hall tester](docs/how-it-works.md#town-hall-walkability-tester). + To run it from a clone instead: ```bash diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 471783328..d66994a50 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -58,3 +58,14 @@ Anyone who can sign in can drive Claude Code, OpenCode, Codex, Grok, Muse, DeepS - Workers don't inherit the office password or any parent agent-session variables. - Workers hired by an account run on that account's own sign-ins (`CLAUDE_CONFIG_DIR`, `GH_CONFIG_DIR` and `GIT_CONFIG_GLOBAL` under `.agent-office/homes//`), and never see the machine's own Claude or GitHub credentials (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, `GH_TOKEN` and the like are taken out of their environment). But every worker runs as the same OS user, so one person's worker *can* read another person's folder. Per-person sign-ins keep work attributed and plans separate. They don't keep teammates' secrets from each other. - The picture fetcher (`/api/image`) needs an office session. It fetches any http(s) link it's given, from the office's machine. That gives nobody new reach: anyone signed in can already run `curl` from a shell worker. Pictures are served with a sandboxing `Content-Security-Policy`, so an SVG can't run script on the office's origin. +## Town Hall walkability tester + +The standalone walkthrough is available at `/townhall.html` when running the Vite development server. Place +the Blender export at `src/client/public/town-hall-shell.glb`; this project-specific GLB is a local asset and +is not included in the repository. The page builds its Recast navigation mesh from that geometry. + +For temporary walkability testing, `WALKABILITY_OPEN_DOORS` in `src/client/townhall.ts` opens named door +panels at runtime before Recast builds navigation. Explicit `SLIDE` names and elevator doors slide; unmarked +`door_leaf` and left/right door-panel names are treated as hinged. Frames, handles, anchors, and other +non-panel objects are left alone, and ambiguous door meshes are logged and left unchanged. This does not +modify the Blender export. diff --git a/package-lock.json b/package-lock.json index e3f643997..1e44b24e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "license": "MIT", "dependencies": { "@lydell/node-pty": "^1.2.0-beta.15", + "@recast-navigation/three": "^0.44.0", "@xterm/addon-serialize": "^0.14.0", "@xterm/headless": "^6.0.0", + "recast-navigation": "^0.44.0", "selfsigned": "^5.5.0", "ws": "^8.21.3" }, @@ -122,7 +124,6 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", - "dev": true, "license": "Apache-2.0" }, "node_modules/@esbuild/aix-ppc64": { @@ -1836,6 +1837,45 @@ "dev": true, "license": "MIT" }, + "node_modules/@recast-navigation/core": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@recast-navigation/core/-/core-0.44.0.tgz", + "integrity": "sha512-N4YO9JBXrGFf3LkrhXmABa7pEwLHvq/gq6o7pLKKu5A4JAp+A5y/9424ps3cVpO1m7ibh3zlXmeewTut9LeXQw==", + "license": "MIT", + "dependencies": { + "@recast-navigation/wasm": "0.44.0" + } + }, + "node_modules/@recast-navigation/generators": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@recast-navigation/generators/-/generators-0.44.0.tgz", + "integrity": "sha512-i2N/sCfuq7IJcSuMmHuX2Uer6oonr9PzFZTrPpwS/e2t+ZpVLHb3DHFY9evKrL2X8diO7sDUDBOpTfW/4Nwucw==", + "license": "MIT", + "dependencies": { + "@recast-navigation/core": "0.44.0", + "@recast-navigation/wasm": "0.44.0" + } + }, + "node_modules/@recast-navigation/three": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@recast-navigation/three/-/three-0.44.0.tgz", + "integrity": "sha512-7PYy9sAJMK66+0g87xJ4x+jUL9Lv4DrtOwXrJYo1W5YB29f6HjSTVGXw5LooPgqqQcf+RUpoeQaSSul0cvVw/A==", + "license": "MIT", + "dependencies": { + "@recast-navigation/core": "0.44.0", + "@recast-navigation/generators": "0.44.0" + }, + "peerDependencies": { + "@types/three": "0.x.x", + "three": "0.x.x" + } + }, + "node_modules/@recast-navigation/wasm": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@recast-navigation/wasm/-/wasm-0.44.0.tgz", + "integrity": "sha512-VNv7xv50cFCdsGd2LdQWZqtLcWXB7nyi4Ilq9t5GMn3GEfCmJEbbeZWSXX5Mgdc4yj71QueTv15V7hPDAQ9gaw==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", @@ -1946,9 +1986,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1966,9 +2003,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1986,9 +2020,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2006,9 +2037,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2026,9 +2054,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2046,9 +2071,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2120,7 +2142,6 @@ "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3": { @@ -2456,14 +2477,12 @@ "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", - "dev": true, "license": "MIT" }, "node_modules/@types/three": { "version": "0.186.0", "resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz", "integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==", - "dev": true, "license": "MIT", "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", @@ -2486,7 +2505,6 @@ "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", - "dev": true, "license": "MIT" }, "node_modules/@types/ws": { @@ -3879,7 +3897,6 @@ "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "dev": true, "license": "MIT" }, "node_modules/fill-range": { @@ -4333,9 +4350,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4357,9 +4371,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4381,9 +4392,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4405,9 +4413,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4602,7 +4607,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", - "dev": true, "license": "MIT" }, "node_modules/multimath": { @@ -4973,6 +4977,16 @@ } } }, + "node_modules/recast-navigation": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/recast-navigation/-/recast-navigation-0.44.0.tgz", + "integrity": "sha512-Qu5ETIZZo69uk3lvjJpdgopPUFp95dTmKljckjpSdL1IbdX26837R8j4nC2oHT6tEKViVwkIvy9Zbzyxr27GiQ==", + "license": "MIT", + "dependencies": { + "@recast-navigation/core": "0.44.0", + "@recast-navigation/generators": "0.44.0" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -5206,7 +5220,6 @@ "version": "0.186.1", "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz", "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==", - "dev": true, "license": "MIT" }, "node_modules/tinyexec": { diff --git a/package.json b/package.json index 66e7bd2e9..8099c367a 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,10 @@ "license": "MIT", "dependencies": { "@lydell/node-pty": "^1.2.0-beta.15", + "@recast-navigation/three": "^0.44.0", "@xterm/addon-serialize": "^0.14.0", "@xterm/headless": "^6.0.0", + "recast-navigation": "^0.44.0", "selfsigned": "^5.5.0", "ws": "^8.21.3" }, diff --git a/src/client/townhall.html b/src/client/townhall.html new file mode 100644 index 000000000..43408ba8f --- /dev/null +++ b/src/client/townhall.html @@ -0,0 +1,46 @@ + + + + + + Town Hall Walkthrough + + + +
+ Blender Town Hall walkthrough + + + +
+ + + + + +
+
Loading your Blender building…
+

+      
Press R to start the exhaustive all-floor audit; press R again to stop it. Use WASD to walk and Space to jump. In first person, drag the mouse or use a two-finger trackpad gesture to look around. Overview: one-finger drag to orbit, pinch or scroll to zoom, and two-finger/right-button drag to pan.
+
+ + + + diff --git a/src/client/townhall.ts b/src/client/townhall.ts new file mode 100644 index 000000000..333ab9ee4 --- /dev/null +++ b/src/client/townhall.ts @@ -0,0 +1,365 @@ +import * as THREE from 'three'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { init, NavMeshQuery } from 'recast-navigation'; +import { NavMeshHelper, threeToSoloNavMesh } from '@recast-navigation/three'; +import { createWalkabilityTest, getVerticalTransitionLinks, type WalkPoint } from './townhall/walkability-test'; +import { getWalkabilityDoorType, isDoorFrameOrMarker, isWalkabilityDoorName } from './townhall/door-classification'; +import { openHingedDoor, openSlidingDoor } from './townhall/door-motion'; + +const canvas = document.querySelector('#townhall') as HTMLCanvasElement; +const runButton = document.querySelector('#run') as HTMLButtonElement; +const overviewButton = document.querySelector('#overview') as HTMLButtonElement; +const status = document.querySelector('#status') as HTMLDivElement; +const report = document.querySelector('#report') as HTMLPreElement; + +const WALKABILITY_OPEN_DOORS = true; + +const scene = new THREE.Scene(); +scene.background = new THREE.Color('#a8c9d8'); +scene.fog = new THREE.Fog('#a8c9d8', 45, 110); +const camera = new THREE.PerspectiveCamera(70, 1, 0.05, 150); +const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); +renderer.outputColorSpace = THREE.SRGBColorSpace; +renderer.shadowMap.enabled = true; + +scene.add(new THREE.HemisphereLight('#ffffff', '#53606b', 2)); +const sun = new THREE.DirectionalLight('#fff3d6', 3); +sun.position.set(-20, 30, 15); +sun.castShadow = true; +scene.add(sun); + +const shell = new THREE.Group(); +shell.name = 'Town_Hall_Blender_Only'; +scene.add(shell); +const orbit = new OrbitControls(camera, canvas); +orbit.enableDamping = true; +orbit.enabled = false; +// Blender-like inspection controls: drag to orbit, two-finger pinch/scroll to zoom, and +// two-finger/right-button dragging to pan around the building. +orbit.enablePan = true; +orbit.screenSpacePanning = true; +orbit.rotateSpeed = 0.8; +orbit.zoomSpeed = 0.9; +orbit.panSpeed = 0.8; +orbit.mouseButtons.LEFT = THREE.MOUSE.ROTATE; +orbit.mouseButtons.RIGHT = THREE.MOUSE.PAN; +orbit.touches.ONE = THREE.TOUCH.ROTATE; +orbit.touches.TWO = THREE.TOUCH.DOLLY_PAN; +let overview = false; +let buildingBounds = new THREE.Box3(); + +let meshes: THREE.Mesh[] = []; +let query: NavMeshQuery | undefined; +let helper: NavMeshHelper | undefined; +let walkability: ReturnType | undefined; +let player: WalkPoint = { x: 0, y: 0, z: 9 }; +let currentPoly = 0; +// Three.js camera yaw 0 looks toward -Z, which is the building's interior direction from the lobby. +// The lobby spawn is on the negative-Z/front side of the GLB; face +Z so W +// moves into the building when the walkthrough begins. +let yaw = Math.PI; +let pitch = -0.08; +const keys = new Set(); +const PLAYER_EYE_HEIGHT = 1.4; +const JUMP_SPEED = 5.2; +const GRAVITY = 14; +let jumpOffset = 0; +let jumpVelocity = 0; + +const doorOriginalTransforms = new Map(); + +function isExportHelper(name: string) { + return name.startsWith('CUTTER_') || name.startsWith('COLL_') || name.startsWith('NAV_'); +} + +function restoreDoorsToClosedState() { + for (const [door, original] of doorOriginalTransforms) { + door.position.copy(original.position); + door.quaternion.copy(original.quaternion); + door.scale.copy(original.scale); + door.updateMatrixWorld(true); + } +} + +function openDoorsForWalkabilityTest() { + const openedDoors: string[] = []; + const skippedDoors: string[] = []; + const warnings: string[] = []; + let detectedDoors = 0; + + shell.traverse((object) => { + const name = object.name; + if (!name || !object.isObject3D) return; + if (!isWalkabilityDoorName(name) || isDoorFrameOrMarker(name)) return; + if (!(object instanceof THREE.Mesh)) { + skippedDoors.push(name); + return; + } + + detectedDoors += 1; + const doorType = getWalkabilityDoorType(name, object.userData); + if (!doorType) { + skippedDoors.push(name); + warnings.push(`Skipped ambiguous door mesh "${name}" without a safe door type.`); + return; + } + + if (!doorOriginalTransforms.has(object)) { + doorOriginalTransforms.set(object, { + position: object.position.clone(), + quaternion: object.quaternion.clone(), + scale: object.scale.clone(), + }); + } + + if (doorType === 'hinged') openHingedDoor(object); + else openSlidingDoor(object); + openedDoors.push(name); + }); + + console.log(`[Town Hall walkability] Doors detected: ${detectedDoors}`); + console.log('[Town Hall walkability] Doors opened:', openedDoors); + console.log('[Town Hall walkability] Doors skipped:', skippedDoors); + if (!openedDoors.length && detectedDoors > 0) { + console.warn('[Town Hall walkability] No walkability door panels were opened; check the naming and custom properties.'); + } + if (!detectedDoors) { + console.warn('[Town Hall walkability] Warning: no door panels were found in the GLB for walkability testing.'); + } + if (warnings.length) { + console.warn('[Town Hall walkability] Door warnings:', warnings); + } +} + +function makeMaterialsDoubleSided(object: THREE.Mesh) { + const materials = Array.isArray(object.material) ? object.material : [object.material]; + for (const material of materials) { + material.side = THREE.DoubleSide; + material.needsUpdate = true; + } +} + +function resize() { + renderer.setSize(window.innerWidth, window.innerHeight, false); + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); +} + +function closest(point: WalkPoint) { + return query?.findClosestPoint(point, { halfExtents: { x: 1.5, y: 2.5, z: 1.5 } }); +} + +async function buildNavigation() { + await init(); + const result = threeToSoloNavMesh(meshes, { + cs: 0.15, + ch: 0.1, + walkableSlopeAngle: 45, + walkableHeight: 18, + walkableClimb: 4, + // The exported stair flights are narrower than the surrounding corridors. + // A 0.30m agent radius (2 recast cells at cs=0.15) keeps them walkable. + walkableRadius: 2, + maxEdgeLen: 12, + maxSimplificationError: 1.3, + minRegionArea: 8, + mergeRegionArea: 20, + maxVertsPerPoly: 6, + detailSampleDist: 6, + detailSampleMaxError: 1, + offMeshConnections: getVerticalTransitionLinks(shell), + }); + if (!result.success || !result.navMesh) throw new Error(result.error ?? 'Could not build a navigation mesh from the Blender GLB.'); + query = new NavMeshQuery(result.navMesh); + helper = new NavMeshHelper(result.navMesh, { + navMeshMaterial: new THREE.MeshBasicMaterial({ color: 0x25d989, transparent: true, opacity: 0.14, depthWrite: false, side: THREE.DoubleSide }), + }); + helper.name = 'Town_Hall_Walkable_NavMesh'; + helper.renderOrder = 10; + scene.add(helper); + + const lobbyAnchor = shell.getObjectByName('SPAWN_F1_lobby'); + if (lobbyAnchor) { + const anchorPosition = new THREE.Vector3(); + lobbyAnchor.getWorldPosition(anchorPosition); + player = { x: anchorPosition.x, y: anchorPosition.y, z: anchorPosition.z }; + } + const spawn = closest(player); + if (!spawn?.success) throw new Error('The Town Hall lobby spawn point is not on a walkable surface.'); + player = spawn.point; + currentPoly = spawn.polyRef; + walkability = createWalkabilityTest({ + scene, + shell, + camera, + canvas, + meshes, + getQuery: () => query, + getCurrentPosition: () => player, + }); +} + +function runTests() { + walkability?.runStandardTests(); +} + +function move(dt: number) { + if (!query || !currentPoly) return; + let forward = 0; + let side = 0; + if (keys.has('KeyW')) forward += 1; + if (keys.has('KeyS')) forward -= 1; + if (keys.has('KeyA')) side -= 1; + if (keys.has('KeyD')) side += 1; + if (!forward && !side) return; + const length = Math.hypot(forward, side); + forward /= length; + side /= length; + const speed = keys.has('ShiftLeft') || keys.has('ShiftRight') ? 7 : 4.5; + // Camera forward is local -Z. This keeps W moving where the player is looking. + const forwardX = -Math.sin(yaw); + const forwardZ = -Math.cos(yaw); + const rightX = Math.cos(yaw); + const rightZ = -Math.sin(yaw); + const desired = { + x: player.x + (forward * forwardX + side * rightX) * speed * dt, + y: player.y, + z: player.z + (forward * forwardZ + side * rightZ) * speed * dt, + }; + const nearest = query.findNearestPoly(player, { halfExtents: { x: 1.2, y: 2.5, z: 1.2 } }); + if (!nearest.success) return; + currentPoly = nearest.nearestRef; + const start = nearest.isOverPoly ? player : nearest.nearestPoint; + const moved = query.moveAlongSurface(currentPoly, start, desired, { maxVisitedSize: 1024 }); + if (moved.success) { + player = moved.resultPosition; + const after = query.findNearestPoly(player, { halfExtents: { x: 1.2, y: 2.5, z: 1.2 } }); + if (after.success) currentPoly = after.nearestRef; + } +} + +function updateJump(dt: number) { + if (overview || (jumpOffset <= 0 && jumpVelocity <= 0)) return; + jumpVelocity -= GRAVITY * dt; + jumpOffset = Math.max(0, jumpOffset + jumpVelocity * dt); + if (jumpOffset === 0) jumpVelocity = 0; +} + +function draw() { + if (overview) { + orbit.update(); + renderer.render(scene, camera); + return; + } + camera.position.set(player.x, player.y + PLAYER_EYE_HEIGHT + jumpOffset, player.z); + camera.rotation.order = 'YXZ'; + camera.rotation.set(pitch, yaw, 0); + renderer.render(scene, camera); +} + +function showOverview() { + overview = !overview; + document.exitPointerLock(); + orbit.enabled = overview; + if (overview) { + const center = buildingBounds.getCenter(new THREE.Vector3()); + const size = buildingBounds.getSize(new THREE.Vector3()); + const radius = Math.max(size.x, size.y, size.z, 10); + camera.position.set(center.x + radius * 1.35, center.y + radius * 0.95, center.z + radius * 1.35); + camera.near = 0.05; + camera.far = Math.max(150, radius * 8); + camera.updateProjectionMatrix(); + orbit.target.copy(center); + orbit.maxDistance = radius * 5; + orbit.minDistance = Math.max(2, radius * 0.15); + orbit.update(); + overviewButton.textContent = 'Return to walkthrough'; + status.textContent = 'Exterior overview. Drag to orbit, scroll to zoom, or return to walkthrough.'; + } else { + overviewButton.textContent = 'View whole building'; + status.textContent = 'Walkthrough mode. Click the building, then use WASD.'; + } +} + +let last = performance.now(); +function frame(now: number) { + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + move(dt); + updateJump(dt); + draw(); + requestAnimationFrame(frame); +} + +window.addEventListener('resize', resize); +window.addEventListener('keydown', (event) => keys.add(event.code)); +window.addEventListener('keyup', (event) => keys.delete(event.code)); +window.addEventListener('keydown', (event) => { + if (event.code !== 'Space' || event.repeat || overview) return; + event.preventDefault(); + if (jumpOffset === 0 && jumpVelocity === 0) jumpVelocity = JUMP_SPEED; +}); +canvas.addEventListener('click', () => { + if (!overview) canvas.requestPointerLock(); +}); +document.addEventListener('mousemove', (event) => { + if (document.pointerLockElement !== canvas) return; + yaw -= event.movementX * 0.0022; + pitch = THREE.MathUtils.clamp(pitch - event.movementY * 0.0022, -Math.PI / 2 + 0.05, Math.PI / 2 - 0.05); +}); +// On macOS, a two-finger trackpad drag is delivered as a wheel gesture rather +// than mouse movement. In first-person mode, use that gesture to look around +// even after Escape has released pointer lock. +canvas.addEventListener('wheel', (event) => { + if (overview) return; + event.preventDefault(); + const sensitivity = 0.003; + yaw -= event.deltaX * sensitivity; + pitch = THREE.MathUtils.clamp( + pitch - event.deltaY * sensitivity, + -Math.PI / 2 + 0.05, + Math.PI / 2 - 0.05, + ); +}, { passive: false }); +runButton.addEventListener('click', runTests); +overviewButton.addEventListener('click', showOverview); +window.addEventListener('keydown', (event) => { + if (event.code === 'KeyO') showOverview(); + if (event.code === 'KeyR' && !event.repeat) { + event.preventDefault(); + walkability?.runStandardTests(); + } +}); + +resize(); +requestAnimationFrame(frame); +// Cache-bust the GLB so every reload fetches the latest Blender export. +const shellUrl = `/town-hall-shell.glb?v=${Date.now()}`; +new GLTFLoader().loadAsync(shellUrl).then(async (gltf) => { + shell.add(gltf.scene); + gltf.scene.updateMatrixWorld(true); + gltf.scene.traverse((object) => { + if (isExportHelper(object.name)) { + object.visible = false; + return; + } + const mesh = object as THREE.Mesh; + if (!mesh.isMesh || !mesh.geometry?.attributes.position) return; + makeMaterialsDoubleSided(mesh); + mesh.castShadow = true; + mesh.receiveShadow = true; + meshes.push(mesh); + }); + if (!meshes.length) throw new Error('The GLB loaded, but it contains no mesh geometry.'); + if (WALKABILITY_OPEN_DOORS) { + openDoorsForWalkabilityTest(); + } + buildingBounds = new THREE.Box3().setFromObject(shell); + await buildNavigation(); + status.textContent = 'Ready. Press R or click Run walkability test to start one complete audit.'; +}).catch((error: unknown) => { + status.textContent = 'Could not load the Blender Town Hall.'; + report.textContent = error instanceof Error ? error.message : String(error); + console.error('[Town Hall walkthrough]', error); +}); diff --git a/src/client/townhall/door-classification.ts b/src/client/townhall/door-classification.ts new file mode 100644 index 000000000..183b30451 --- /dev/null +++ b/src/client/townhall/door-classification.ts @@ -0,0 +1,42 @@ +export type DoorType = 'hinged' | 'sliding'; + +function normalizeDoorName(name: string) { + return name.toUpperCase().replace(/[^A-Z0-9]+/g, '_'); +} + +export function isWalkabilityDoorName(name: string) { + const normalized = normalizeDoorName(name); + return /(^|_)DOOR(_|$)/.test(normalized) || /(^|_)(SLID|HINGE)(_|$)/.test(normalized); +} + +export function isDoorFrameOrMarker(name: string) { + const normalized = normalizeDoorName(name); + return ['FRAME', 'MARKER', 'ANCHOR', 'SPAWN', 'SIGN', 'DECOR', 'EMPTY', 'HANDLE', 'KNOB'].some((part) => + normalized.includes(part), + ); +} + +export function getWalkabilityDoorType(name: string, customData: Record = {}): DoorType | null { + const values = [ + customData.door_type, + customData.doorType, + customData.type, + customData.mesh_type, + customData.animationType, + customData.anim_type, + customData.door_kind, + ]; + + for (const value of values) { + if (typeof value !== 'string') continue; + const normalized = value.toLowerCase(); + if (normalized.includes('slid')) return 'sliding'; + if (normalized.includes('hinge')) return 'hinged'; + } + + const normalized = normalizeDoorName(name); + if (normalized.includes('SLID') || normalized.includes('ELEVATOR_DOOR')) return 'sliding'; + if (normalized.includes('HINGE') || (normalized.includes('DOOR') && normalized.includes('LEAF'))) return 'hinged'; + if (normalized.includes('DOOR') && /(^|_)(LEFT|RIGHT)(_|$)/.test(normalized)) return 'hinged'; + return null; +} diff --git a/src/client/townhall/door-motion.ts b/src/client/townhall/door-motion.ts new file mode 100644 index 000000000..e3d675577 --- /dev/null +++ b/src/client/townhall/door-motion.ts @@ -0,0 +1,68 @@ +import * as THREE from 'three'; + +function getPanelBounds(door: THREE.Mesh) { + const { geometry } = door; + if (!geometry.boundingBox) geometry.computeBoundingBox(); + if (!geometry.boundingBox) throw new Error(`Door mesh "${door.name}" has no bounding box.`); + return geometry.boundingBox; +} + +function getPanelHorizontalAxis(door: THREE.Mesh) { + const bounds = getPanelBounds(door); + const size = bounds.getSize(new THREE.Vector3()); + const isX = size.x >= size.z; + const axisName: 'x' | 'z' = isX ? 'x' : 'z'; + return { + bounds, + axis: new THREE.Vector3(isX ? 1 : 0, 0, isX ? 0 : 1), + axisName, + width: isX ? size.x : size.z, + }; +} + +function hingeSide(name: string, userData: Record) { + const configured = userData.hinge_side ?? userData.hingeSide; + if (typeof configured === 'string') { + if (configured.toLowerCase() === 'right') return 1; + if (configured.toLowerCase() === 'left') return -1; + } + + const normalized = name.toUpperCase().replace(/[^A-Z0-9]+/g, '_'); + if (/(^|_)RIGHT(_|$)/.test(normalized) || /(?:LEAF|PANEL)_2(?:_|$)/.test(normalized) || /(?:LEAF|PANEL)_2001(?:_|$)/.test(normalized)) return 1; + return -1; +} + +export function openHingedDoor(door: THREE.Mesh, angle = Math.PI / 2) { + const { bounds, axis, axisName } = getPanelHorizontalAxis(door); + const side = hingeSide(door.name, door.userData); + const localHinge = bounds.getCenter(new THREE.Vector3()); + localHinge[axisName] = side < 0 ? bounds.min[axisName] : bounds.max[axisName]; + + door.updateWorldMatrix(true, false); + const hingeBefore = door.localToWorld(localHinge.clone()); + door.rotateOnWorldAxis(new THREE.Vector3(0, 1, 0), angle); + door.updateWorldMatrix(true, false); + const hingeAfter = door.localToWorld(localHinge.clone()); + + const parent = door.parent; + if (parent) { + const beforeParent = parent.worldToLocal(hingeBefore); + const afterParent = parent.worldToLocal(hingeAfter); + door.position.add(beforeParent.sub(afterParent)); + } else { + door.position.add(hingeBefore.sub(hingeAfter)); + } + door.updateMatrixWorld(true); +} + +export function openSlidingDoor(door: THREE.Mesh) { + const { axis, width } = getPanelHorizontalAxis(door); + const worldDirection = axis.applyQuaternion(door.getWorldQuaternion(new THREE.Quaternion())); + worldDirection.y = 0; + worldDirection.normalize(); + + const worldPosition = door.getWorldPosition(new THREE.Vector3()).addScaledVector(worldDirection, Math.max(1.2, width) * 1.5); + if (door.parent) door.parent.worldToLocal(worldPosition); + door.position.copy(worldPosition); + door.updateMatrixWorld(true); +} diff --git a/src/client/townhall/walkability-test.ts b/src/client/townhall/walkability-test.ts new file mode 100644 index 000000000..cf1b4ecd0 --- /dev/null +++ b/src/client/townhall/walkability-test.ts @@ -0,0 +1,523 @@ +import * as THREE from 'three'; +import type { NavMeshQuery } from 'recast-navigation'; + +export type WalkPoint = { x: number; y: number; z: number }; + +export type VerticalLink = { + startPosition: WalkPoint; + endPosition: WalkPoint; + radius: number; + bidirectional: boolean; + area: number; + flags: number; + userId: number; +}; + +export type WalkabilityRoute = { + name: string; + start: WalkPoint; + end: WalkPoint; +}; + +export type RouteMetric = { + name: string; + status: 'PASS' | 'FAIL'; + pathLength: number; + pathPoints: WalkPoint[]; + failureReason?: string; + adaWarnings: string[]; + narrowestClearWidth: number | null; +}; + +export type FloorMetric = { + floor: string; + sampledPoints: number; + reachablePoints: number; + coveragePercent: number; + warnings: string[]; +}; + +export type WalkabilityReport = { + auditNumber: number; + generatedAt: string; + auditMode: 'exhaustive-sampled'; + sampledWalkablePoints: number; + pairRouteTests: number; + adaClearWidthMinimumMeters: number; + routes: RouteMetric[]; + customTests: RouteMetric[]; + floors: FloorMetric[]; + adaWarnings: string[]; +}; + +export type WalkabilityTestOptions = { + scene: THREE.Scene; + shell: THREE.Group; + camera: THREE.PerspectiveCamera; + canvas: HTMLCanvasElement; + meshes: THREE.Mesh[]; + getQuery: () => NavMeshQuery | undefined; + getCurrentPosition: () => WalkPoint; +}; + +const ADA_CLEAR_WIDTH = 0.9; +// A continuous building has infinitely many possible start/end pairs. This is +// deliberately dense enough to expose disconnected rooms while keeping the +// browser responsive and the exported report usable. +const EXHAUSTIVE_SAMPLE_STEP = 3.5; +const FLOOR_BANDS = [ + { id: 'F1', min: -0.4, max: 4.05 }, + { id: 'F2', min: 3.95, max: 8.55 }, + { id: 'F3', min: 8.45, max: 12.55 }, + { id: 'Roof', min: 12.45, max: 20 }, +] as const; + +const FLOOR_ANALYSIS: Array<{ floor: string; y: number; slabName: string; anchorName: string; origin: WalkPoint }> = [ + { floor: 'F1', y: 0, slabName: 'F1_Slab', anchorName: 'SPAWN_F1_lobby', origin: { x: 0, y: 0, z: 9 } }, + { floor: 'F2', y: 4, slabName: 'F2_Slab', anchorName: 'SPAWN_E2', origin: { x: 1.5, y: 4, z: -4.5 } }, + { floor: 'F3', y: 8.5, slabName: 'F3_Slab', anchorName: 'SPAWN_E3', origin: { x: 1.5, y: 8.5, z: -4.5 } }, + { floor: 'Roof', y: 12.5, slabName: 'Roof_Slab', anchorName: 'ELEV_door_roof', origin: { x: 2, y: 12.5, z: -4.5 } }, +] as const; + +export const STANDARD_ROUTES: WalkabilityRoute[] = [ + { name: 'F1 lobby → atrium', start: { x: 0, y: 0, z: 9 }, end: { x: 0, y: 0, z: 3 } }, + { name: 'F1 lobby → core', start: { x: 0, y: 0, z: 9 }, end: { x: 0, y: 0, z: -4.5 } }, + { name: 'F1 west → east', start: { x: -8, y: 0, z: 9 }, end: { x: 8, y: 0, z: 9 } }, + { name: 'F2 front → rear', start: { x: -9, y: 4, z: 11 }, end: { x: 9, y: 4, z: -11 } }, + { name: 'F3 west → east', start: { x: -9, y: 8.5, z: 11 }, end: { x: 9, y: 8.5, z: 11 } }, + { name: 'Roof stair exit → perimeter', start: { x: -2, y: 12.5, z: -3 }, end: { x: 8, y: 12.5, z: 10 } }, + { name: 'F1 → F2 elevator transition', start: { x: 1.5, y: 0, z: -4.5 }, end: { x: 1.5, y: 4, z: -4.5 } }, + { name: 'F2 → F3 elevator transition', start: { x: 1.5, y: 4, z: -4.5 }, end: { x: 1.5, y: 8.5, z: -4.5 } }, + { name: 'F3 → Roof elevator transition', start: { x: 2, y: 8.5, z: -4.5 }, end: { x: 2, y: 12.5, z: -4.5 } }, +]; + +function pointFromObject(object: THREE.Object3D): WalkPoint { + const position = new THREE.Vector3(); + object.getWorldPosition(position); + return { x: position.x, y: position.y, z: position.z }; +} + +type WorldTransform = { + toWorld: (point: WalkPoint) => WalkPoint; +}; + +/** + * Blender's contract coordinates are preserved in the exported anchor names, + * while the GLB may contain an exporter scale, vertical offset, and a flipped + * depth axis. Derive that conversion from two known spawn anchors instead of + * hard-coding the current export transform. + */ +function getWorldTransform(shell: THREE.Group): WorldTransform { + const lobby = shell.getObjectByName('SPAWN_F1_lobby'); + const floor2 = shell.getObjectByName('SPAWN_E2'); + if (!lobby || !floor2) return { toWorld: (point) => point }; + + const lobbyWorld = pointFromObject(lobby); + const floor2World = pointFromObject(floor2); + const scale = (floor2World.y - lobbyWorld.y) / 4; + if (!Number.isFinite(scale) || Math.abs(scale) < 0.01) return { toWorld: (point) => point }; + + return { + toWorld: (point) => ({ + x: lobbyWorld.x + scale * point.x, + y: lobbyWorld.y + scale * point.y, + z: lobbyWorld.z - scale * (point.z - 9), + }), + }; +} + +/** Builds bidirectional Recast links from the exported elevator/stair anchors. */ +export function getVerticalTransitionLinks(shell: THREE.Group): VerticalLink[] { + const worldTransform = getWorldTransform(shell); + const anchorNames = ['ELEV_door_F1', 'ELEV_door_F2', 'ELEV_door_F3', 'ELEV_door_roof']; + const fallbackAnchors: WalkPoint[] = [ + { x: 2, y: 0, z: -4.5 }, + { x: 2, y: 4, z: -4.5 }, + { x: 2, y: 8.5, z: -4.5 }, + { x: 2, y: 12.5, z: -4.5 }, + ].map(worldTransform.toWorld); + const anchors = anchorNames.map((name, index) => shell.getObjectByName(name) ?? fallbackAnchors[index]); + const links: VerticalLink[] = []; + const asWalkPoint = (anchor: THREE.Object3D | WalkPoint): WalkPoint => + typeof anchor === 'object' && 'isObject3D' in anchor && anchor.isObject3D + ? pointFromObject(anchor as THREE.Object3D) + : anchor as WalkPoint; + for (let i = 0; i + 1 < anchors.length; i++) { + const startPosition = asWalkPoint(anchors[i]); + const endPosition = asWalkPoint(anchors[i + 1]); + links.push({ + startPosition, + endPosition, + radius: 1.5, + bidirectional: true, + area: 0, + flags: 1, + userId: i + 1, + }); + } + return links; +} + +function distance(a: WalkPoint, b: WalkPoint) { + return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); +} + +function pathLength(path: WalkPoint[]) { + let length = 0; + for (let i = 1; i < path.length; i++) length += distance(path[i - 1], path[i]); + return length; +} + +function disposeObject(object: THREE.Object3D) { + object.traverse((child) => { + const mesh = child as THREE.Mesh; + if (!mesh.isMesh) return; + mesh.geometry.dispose(); + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + for (const material of materials) material.dispose(); + }); +} + +export function createWalkabilityTest(options: WalkabilityTestOptions) { + const { scene, shell, camera, canvas, meshes, getQuery, getCurrentPosition } = options; + const worldTransform = getWorldTransform(shell); + const raycaster = new THREE.Raycaster(); + const debug = new THREE.Group(); + debug.name = 'WALKTEST_Debug'; + debug.renderOrder = 20; + scene.add(debug); + + const originalVisibility = new Map(); + for (const mesh of meshes) originalVisibility.set(mesh, mesh.visible); + + type AuditRun = { cancelled: boolean }; + let activeAudit: AuditRun | null = null; + const AUDIT_CANCELLED = Symbol('AUDIT_CANCELLED'); + let latestReport: WalkabilityReport = { + auditNumber: 0, + generatedAt: new Date().toISOString(), + auditMode: 'exhaustive-sampled', + sampledWalkablePoints: 0, + pairRouteTests: 0, + adaClearWidthMinimumMeters: ADA_CLEAR_WIDTH, + routes: [], + customTests: [], + floors: [], + adaWarnings: [], + }; + + const reportElement = document.querySelector('#report') as HTMLPreElement | null; + const statusElement = document.querySelector('#status') as HTMLDivElement | null; + const runButton = document.querySelector('#run') as HTMLButtonElement | null; + + const resetDebug = () => { + for (const child of [...debug.children]) { + debug.remove(child); + disposeObject(child); + } + }; + + const addDebugLine = (name: string, points: WalkPoint[], color: number) => { + if (points.length < 2) return; + const geometry = new THREE.BufferGeometry().setFromPoints(points.map((point) => new THREE.Vector3(point.x, point.y + 0.08, point.z))); + const line = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color, depthTest: false })); + line.name = `WALKTEST_${name}`; + line.renderOrder = 20; + debug.add(line); + }; + + /** Downward raycast used before Recast queries to remove elevation mismatches. */ + const surfaceSnap = (point: WalkPoint, yTolerance = 3): WalkPoint => { + const bounds = new THREE.Box3().setFromObject(shell); + const origin = new THREE.Vector3(point.x, Math.max(bounds.max.y + 5, point.y + 8), point.z); + raycaster.set(origin, new THREE.Vector3(0, -1, 0)); + const hits = raycaster.intersectObjects(meshes, true) + .filter((hit) => hit.point.y >= point.y - yTolerance && hit.point.y <= point.y + yTolerance) + .sort((a, b) => Math.abs(a.point.y - point.y) - Math.abs(b.point.y - point.y)); + const hit = hits[0]; + return hit ? { x: hit.point.x, y: hit.point.y, z: hit.point.z } : point; + }; + + const nearestWalkable = (point: WalkPoint, yTolerance = 1.5, horizontalSearchRadius = 2.5) => { + const query = getQuery(); + if (!query) return { requested: point, snapped: point, nearest: undefined }; + const offsets = [{ x: 0, z: 0 }]; + for (let radius = 0.5; radius <= horizontalSearchRadius; radius += 0.5) { + offsets.push( + { x: radius, z: 0 }, { x: -radius, z: 0 }, + { x: 0, z: radius }, { x: 0, z: -radius }, + { x: radius, z: radius }, { x: -radius, z: radius }, + { x: radius, z: -radius }, { x: -radius, z: -radius }, + ); + } + let best: { snapped: WalkPoint; nearest: WalkPoint; score: number } | undefined; + for (const offset of offsets) { + const candidate = { x: point.x + offset.x, y: point.y, z: point.z + offset.z }; + const snapped = surfaceSnap(candidate, yTolerance + 1.5); + const nearest = query.findClosestPoint(snapped, { halfExtents: { x: 1.5, y: Math.max(3, yTolerance), z: 1.5 } }); + if (!nearest.success) continue; + const verticalError = Math.abs(nearest.point.y - point.y); + const horizontalError = Math.hypot(nearest.point.x - point.x, nearest.point.z - point.z); + if (verticalError > yTolerance || horizontalError > horizontalSearchRadius + 1.25) continue; + const score = horizontalError + verticalError * 0.25; + if (!best || score < best.score) best = { snapped, nearest: nearest.point, score }; + } + return { requested: point, snapped: best?.snapped ?? surfaceSnap(point, yTolerance), nearest: best?.nearest }; + }; + + const clearanceAt = (point: WalkPoint, tangent: THREE.Vector2) => { + const normal = new THREE.Vector3(-tangent.y, 0, tangent.x).normalize(); + const distances: number[] = []; + for (const sign of [-1, 1]) { + const origin = new THREE.Vector3(point.x + normal.x * sign * 0.03, point.y + 0.9, point.z + normal.z * sign * 0.03); + raycaster.set(origin, normal.clone().multiplyScalar(sign)); + const hit = raycaster.intersectObjects(meshes, true).find((candidate) => Math.abs(candidate.point.y - origin.y) < 1.2 && candidate.distance < 4); + distances.push(hit?.distance ?? 4); + } + return distances[0] + distances[1]; + }; + + const auditADA = (path: WalkPoint[]) => { + const warnings: string[] = []; + if (path.length < 2) return { warnings, narrowest: null }; + let narrowest = Number.POSITIVE_INFINITY; + for (let i = 0; i < path.length; i++) { + const previous = path[Math.max(0, i - 1)]; + const next = path[Math.min(path.length - 1, i + 1)]; + const tangent = new THREE.Vector2(next.x - previous.x, next.z - previous.z); + if (tangent.lengthSq() < 1e-6) continue; + tangent.normalize(); + const width = clearanceAt(path[i], tangent); + narrowest = Math.min(narrowest, width); + if (width < ADA_CLEAR_WIDTH) warnings.push(`Narrow pinch point near (${path[i].x.toFixed(2)}, ${path[i].y.toFixed(2)}, ${path[i].z.toFixed(2)}): ${width.toFixed(2)}m clear width`); + } + return { warnings: [...new Set(warnings)], narrowest: Number.isFinite(narrowest) ? narrowest : null }; + }; + + const evaluateRoute = (route: WalkabilityRoute, drawDebug = true): RouteMetric => { + const query = getQuery(); + const verticalRoute = Math.abs(route.start.y - route.end.y) > 1; + const yTolerance = verticalRoute ? 6 : 1.5; + const start = nearestWalkable(route.start, yTolerance, verticalRoute ? 3 : 2.5); + const end = nearestWalkable(route.end, yTolerance, verticalRoute ? 3 : 2.5); + if (!query || !start.nearest || !end.nearest) { + const snapLines: WalkPoint[] = []; + if (start.nearest) snapLines.push(route.start, start.nearest); + if (end.nearest) snapLines.push(route.end, end.nearest); + if (drawDebug && snapLines.length) addDebugLine(`${route.name}_nearest`, snapLines, 0xf4d35e); + return { name: route.name, status: 'FAIL', pathLength: 0, pathPoints: [], failureReason: 'start or destination is not on a walkable surface', adaWarnings: [], narrowestClearWidth: null }; + } + const result = query.computePath(start.nearest, end.nearest, { maxPathPolys: 4096, maxStraightPathPoints: 1024 }); + const path = result.success ? result.path : []; + const ada = auditADA(path); + const metric: RouteMetric = { + name: route.name, + status: result.success && path.length > 1 ? 'PASS' : 'FAIL', + pathLength: pathLength(path), + pathPoints: path, + failureReason: result.success ? undefined : result.error?.name ?? 'no connected path', + adaWarnings: ada.warnings, + narrowestClearWidth: ada.narrowest, + }; + if (drawDebug) { + if (distance(route.start, start.nearest) > 0.2) addDebugLine(`${route.name}_start_snap`, [route.start, start.nearest], 0xf4d35e); + if (distance(route.end, end.nearest) > 0.2) addDebugLine(`${route.name}_end_snap`, [route.end, end.nearest], 0xf4d35e); + addDebugLine(route.name, path.length > 1 ? path : [route.start, route.end], metric.status === 'PASS' ? 0x39e58c : 0xff4f5e); + } else if (metric.status === 'FAIL') { + addDebugLine(`FAIL_${route.name}`, path.length > 1 ? path : [route.start, route.end], 0xff4f5e); + } + return metric; + }; + + const floorBounds = (floor: typeof FLOOR_ANALYSIS[number]) => { + const slab = shell.getObjectByName(floor.slabName); + const bounds = slab ? new THREE.Box3().setFromObject(slab) : undefined; + return { + minX: bounds ? bounds.min.x + 1 : -10, + maxX: bounds ? bounds.max.x - 1 : 10, + minZ: bounds ? bounds.min.z + 1 : -13, + maxZ: bounds ? bounds.max.z - 1 : 13, + }; + }; + + type FloorSample = WalkPoint & { floor: string }; + + const collectFloorSamples = (step: number): FloorSample[] => { + const samples: FloorSample[] = []; + for (const floor of FLOOR_ANALYSIS) { + const bounds = floorBounds(floor); + const worldFloorY = worldTransform.toWorld({ x: 0, y: floor.y, z: 0 }).y; + for (let x = bounds.minX; x <= bounds.maxX + 0.001; x += step) { + for (let z = bounds.minZ; z <= bounds.maxZ + 0.001; z += step) { + const target = nearestWalkable({ x, y: worldFloorY, z }, 1.5, 2.5).nearest; + if (!target || Math.abs(target.y - worldFloorY) > 0.9 || Math.hypot(target.x - x, target.z - z) > 2.5) continue; + const duplicate = samples.some((sample) => sample.floor === floor.floor && Math.hypot(sample.x - target.x, sample.z - target.z) < 0.75); + if (!duplicate) samples.push({ ...target, floor: floor.floor }); + } + } + } + return samples; + }; + + const auditFloorCoverage = (): FloorMetric[] => { + const query = getQuery(); + if (!query) return []; + return FLOOR_ANALYSIS.map(({ floor, y, slabName, anchorName, origin }) => { + const anchor = shell.getObjectByName(anchorName); + const worldFloorY = worldTransform.toWorld({ x: 0, y, z: 0 }).y; + const floorOrigin = anchor ? pointFromObject(anchor) : worldTransform.toWorld(origin); + const start = nearestWalkable(floorOrigin, 1.5, 3).nearest; + if (!start) return { floor, sampledPoints: 0, reachablePoints: 0, coveragePercent: 0, warnings: [`${floor}: the floor origin is not walkable`] }; + + const { minX, maxX, minZ, maxZ } = floorBounds({ floor, y, slabName, anchorName, origin }); + let sampledPoints = 0; + let reachablePoints = 0; + for (let x = minX; x <= maxX + 0.001; x += 2.5) for (let z = minZ; z <= maxZ + 0.001; z += 2.5) { + const target = nearestWalkable({ x, y: worldFloorY, z }, 1.5, 2.5).nearest; + if (!target || Math.abs(target.y - worldFloorY) > 0.9 || Math.hypot(target.x - x, target.z - z) > 2.5) continue; + sampledPoints++; + const path = query.computePath(start, target, { maxPathPolys: 4096, maxStraightPathPoints: 1024 }); + const elevations = path.path.map((point) => point.y); + const remainsOnFloor = path.success && elevations.length > 1 && Math.max(...elevations) - Math.min(...elevations) <= 1.0 && elevations.every((elevation) => Math.abs(elevation - worldFloorY) <= 0.9); + if (remainsOnFloor && path.path.length > 1) reachablePoints++; + } + const coveragePercent = sampledPoints ? Math.round((reachablePoints / sampledPoints) * 1000) / 10 : 0; + const warnings = coveragePercent < 100 ? [`${floor}: ${reachablePoints}/${sampledPoints} sampled walkable points connect to the floor origin`] : []; + return { floor, sampledPoints, reachablePoints, coveragePercent, warnings }; + }); + }; + + const yieldToBrowser = () => new Promise((resolve) => window.setTimeout(resolve, 0)); + + const auditExhaustiveRoutes = async (setProgress: (message: string) => void, run: AuditRun) => { + const samples = collectFloorSamples(EXHAUSTIVE_SAMPLE_STEP); + const routes: RouteMetric[] = []; + const totalPairs = (samples.length * (samples.length - 1)) / 2; + let completedPairs = 0; + for (let startIndex = 0; startIndex < samples.length; startIndex++) { + for (let endIndex = startIndex + 1; endIndex < samples.length; endIndex++) { + if (run.cancelled) throw AUDIT_CANCELLED; + const start = samples[startIndex]; + const end = samples[endIndex]; + const metric = evaluateRoute({ + name: `${start.floor} (${start.x.toFixed(1)},${start.z.toFixed(1)}) ↔ ${end.floor} (${end.x.toFixed(1)},${end.z.toFixed(1)})`, + start, + end, + }, false); + routes.push(metric); + completedPairs++; + if (completedPairs % 25 === 0) { + setProgress(`Testing exhaustive building connectivity: ${completedPairs.toLocaleString()}/${totalPairs.toLocaleString()} point pairs…`); + await yieldToBrowser(); + } + } + } + return { samples, routes }; + }; + + const renderReport = () => { + const allRoutes = [...latestReport.routes, ...latestReport.customTests]; + if (statusElement) statusElement.textContent = `Audit #${latestReport.auditNumber} complete at ${new Date(latestReport.generatedAt).toLocaleTimeString()}: ${allRoutes.filter((route) => route.status === 'PASS').length}/${allRoutes.length} route tests passed across ${latestReport.sampledWalkablePoints} sampled points. Green = success, red = failure, yellow = snapped point.`; + if (!reportElement) return; + const routeLines = allRoutes.map((route) => `${route.status} ${route.name} — ${route.pathLength.toFixed(2)}m${route.failureReason ? ` — ${route.failureReason}` : ''}${route.adaWarnings.length ? `\n ADA: ${route.adaWarnings.join('; ')}` : ''}`); + const floorLines = latestReport.floors.map((floor) => `${floor.coveragePercent === 100 ? 'PASS' : 'WARN'} ${floor.floor} coverage — ${floor.reachablePoints}/${floor.sampledPoints} points (${floor.coveragePercent}%)${floor.warnings.length ? `\n ${floor.warnings.join('; ')}` : ''}`); + reportElement.textContent = [...routeLines, '', 'FLOOR-BY-FLOOR COVERAGE', ...floorLines].join('\n'); + }; + + const runStandardTests = async () => { + // R and the Run button are a toggle: pressing them during an audit cancels it. + if (activeAudit) { + activeAudit.cancelled = true; + if (statusElement) statusElement.textContent = 'Stopping audit…'; + return latestReport; + } + const run: AuditRun = { cancelled: false }; + activeAudit = run; + latestReport.auditNumber++; + if (runButton) { + runButton.disabled = false; + runButton.textContent = 'Stop audit'; + } + if (statusElement) statusElement.textContent = `Running complete audit #${latestReport.auditNumber} across every route and floor…`; + try { + resetDebug(); + const standardRoutes = STANDARD_ROUTES.map((route) => evaluateRoute({ + ...route, + start: worldTransform.toWorld(route.start), + end: worldTransform.toWorld(route.end), + })); + const floors = auditFloorCoverage(); + const exhaustive = await auditExhaustiveRoutes((message) => { + if (statusElement) statusElement.textContent = message; + }, run); + if (run.cancelled) throw AUDIT_CANCELLED; + const routes = [...standardRoutes, ...exhaustive.routes]; + latestReport = { + ...latestReport, + generatedAt: new Date().toISOString(), + sampledWalkablePoints: exhaustive.samples.length, + pairRouteTests: exhaustive.routes.length, + routes, + floors, + customTests: [], + adaWarnings: [], + }; + latestReport.adaWarnings = [...new Set(latestReport.routes.flatMap((route) => route.adaWarnings))]; + latestReport.adaWarnings.push(...latestReport.floors.flatMap((floor) => floor.warnings)); + latestReport.adaWarnings = [...new Set(latestReport.adaWarnings)]; + renderReport(); + return latestReport; + } catch (error) { + if (error === AUDIT_CANCELLED) { + resetDebug(); + if (statusElement) statusElement.textContent = 'Audit stopped. Press R to start a new audit.'; + return latestReport; + } + throw error; + } finally { + if (activeAudit === run) { + activeAudit = null; + if (runButton) { + runButton.disabled = false; + runButton.textContent = 'Run exhaustive audit'; + } + } + } + }; + + const runCustomTest = (destination: WalkPoint) => { + const custom = evaluateRoute({ name: `Custom click → (${destination.x.toFixed(2)}, ${destination.y.toFixed(2)}, ${destination.z.toFixed(2)})`, start: getCurrentPosition(), end: destination }); + latestReport = { ...latestReport, generatedAt: new Date().toISOString(), customTests: [...latestReport.customTests, custom], adaWarnings: [...new Set([...latestReport.adaWarnings, ...custom.adaWarnings])] }; + renderReport(); + return custom; + }; + + const exportButton = document.querySelector('#export-report') as HTMLButtonElement | null; + exportButton?.addEventListener('click', () => { + const blob = new Blob([JSON.stringify(latestReport, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `town-hall-walkability-${new Date().toISOString().replaceAll(':', '-')}.json`; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); + }); + + const levelButtons = document.querySelectorAll('[data-level]'); + const setLevel = (level: string) => { + const band = FLOOR_BANDS.find((candidate) => candidate.id === level); + for (const mesh of meshes) { + const original = originalVisibility.get(mesh) ?? true; + if (level === 'All' || !band) { + mesh.visible = original; + continue; + } + const bounds = new THREE.Box3().setFromObject(mesh); + mesh.visible = original && bounds.max.y >= band.min && bounds.min.y <= band.max; + } + for (const button of levelButtons) button.dataset.active = button.dataset.level === level ? 'true' : 'false'; + }; + for (const button of levelButtons) button.addEventListener('click', () => setLevel(button.dataset.level ?? 'All')); + + return { runStandardTests, runCustomTest, setLevel, getLatestReport: () => latestReport }; +} diff --git a/tests/walkability-doors.test.ts b/tests/walkability-doors.test.ts new file mode 100644 index 000000000..ead04bc77 --- /dev/null +++ b/tests/walkability-doors.test.ts @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as THREE from 'three'; +import { getWalkabilityDoorType, isDoorFrameOrMarker, isWalkabilityDoorName } from '../src/client/townhall/door-classification.ts'; +import { openHingedDoor, openSlidingDoor } from '../src/client/townhall/door-motion.ts'; + +test('door panel names are recognized independent of case and separators', () => { + assert.equal(isWalkabilityDoorName('Gallery lobby door leaf 1.001'), true); + assert.equal(isWalkabilityDoorName('DOOR_ext_slide_main'), true); +}); + +test('unmarked door leaves and left/right panels are treated as hinged', () => { + assert.equal(getWalkabilityDoorType('Gallery_lobby_door_leaf_1001'), 'hinged'); + assert.equal(getWalkabilityDoorType('North room door 1 leaf 1.001'), 'hinged'); + assert.equal(getWalkabilityDoorType('Entry door right'), 'hinged'); + assert.equal(getWalkabilityDoorType('Door_Main_Left'), 'hinged'); +}); + +test('explicit sliding names and elevator doors are treated as sliding', () => { + assert.equal(getWalkabilityDoorType('DOOR_ext_slide_main'), 'sliding'); + assert.equal(getWalkabilityDoorType('CIRC_Elevator_Door_F1'), 'sliding'); +}); + +test('custom Blender door type takes precedence over the name', () => { + assert.equal(getWalkabilityDoorType('Door leaf left', { door_type: 'sliding' }), 'sliding'); +}); + +test('ambiguous doors are left unchanged and frames, handles, and anchors are excluded', () => { + assert.equal(getWalkabilityDoorType('DOOR_security_F3'), null); + assert.equal(isDoorFrameOrMarker('Gallery lobby door frame head.001'), true); + assert.equal(isDoorFrameOrMarker('Gallery lobby door handle 1.001'), true); + assert.equal(isDoorFrameOrMarker('Elevator anchor F1'), true); +}); + +test('hinged panels rotate around their edge instead of their centered mesh origin', () => { + const door = new THREE.Mesh(new THREE.BoxGeometry(1, 2, 0.08), new THREE.MeshBasicMaterial()); + door.name = 'Entry door left'; + door.position.set(2, 0, 3); + door.updateMatrixWorld(true); + const hingeBefore = door.localToWorld(new THREE.Vector3(-0.5, 0, 0)); + + openHingedDoor(door); + + const hingeAfter = door.localToWorld(new THREE.Vector3(-0.5, 0, 0)); + assert.ok(hingeAfter.distanceTo(hingeBefore) < 1e-6); + assert.ok(Math.abs(door.rotation.y) > 1); +}); + +test('sliding panels move along their doorway width axis', () => { + const door = new THREE.Mesh(new THREE.BoxGeometry(0.08, 2, 1), new THREE.MeshBasicMaterial()); + const start = door.position.clone(); + + openSlidingDoor(door); + + assert.ok(door.position.z > start.z + 1); + assert.equal(door.position.x, start.x); +}); diff --git a/vite.config.ts b/vite.config.ts index 38f15039c..44122fe94 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -53,6 +53,8 @@ export default defineConfig({ }, input: { main: resolve(import.meta.dirname, 'src/client/index.html'), + townhall: resolve(import.meta.dirname, 'src/client/townhall.html'), + executive: resolve(import.meta.dirname, 'src/client/executive.html'), lite: resolve(import.meta.dirname, 'src/client/lite.html'), login: resolve(import.meta.dirname, 'src/client/login.html'), claim: resolve(import.meta.dirname, 'src/client/claim.html'), From 8f734ab6be16bf4defa1fabbf85365b3075a815e Mon Sep 17 00:00:00 2001 From: elpepitolindo Date: Wed, 11 Nov 2026 13:46:55 -0500 Subject: [PATCH 2/2] Remove stale executive Vite entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vite.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index 44122fe94..b4ec5b113 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -54,7 +54,6 @@ export default defineConfig({ input: { main: resolve(import.meta.dirname, 'src/client/index.html'), townhall: resolve(import.meta.dirname, 'src/client/townhall.html'), - executive: resolve(import.meta.dirname, 'src/client/executive.html'), lite: resolve(import.meta.dirname, 'src/client/lite.html'), login: resolve(import.meta.dirname, 'src/client/login.html'), claim: resolve(import.meta.dirname, 'src/client/claim.html'),