diff --git a/.github/labels.json b/.github/labels.json new file mode 100644 index 000000000..d972796e3 --- /dev/null +++ b/.github/labels.json @@ -0,0 +1,57 @@ +[ + { + "name": "bug", + "color": "d73a4a", + "description": "Something is broken or behaving incorrectly." + }, + { + "name": "enhancement", + "color": "a2eeef", + "description": "Requested improvement or new capability." + }, + { + "name": "documentation", + "color": "0075ca", + "description": "Improvements or additions to documentation." + }, + { + "name": "needs-info", + "color": "c5def5", + "description": "Waiting on the author for details needed to review." + }, + { + "name": "DO NOT MERGE", + "color": "b60205", + "description": "Not ready to merge. State what must change before it is ready." + }, + { + "name": "size:XS", + "color": "0e8a16", + "description": "0-9 effective changed lines." + }, + { + "name": "size:S", + "color": "5ebd3e", + "description": "10-29 effective changed lines." + }, + { + "name": "size:M", + "color": "fbca04", + "description": "30-99 effective changed lines." + }, + { + "name": "size:L", + "color": "fe7d37", + "description": "100-499 effective changed lines." + }, + { + "name": "size:XL", + "color": "d93f0b", + "description": "500-999 effective changed lines." + }, + { + "name": "size:XXL", + "color": "b60205", + "description": "1,000+ effective changed lines." + } +] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..a894e729b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,35 @@ + + +## What changed + + + +## Why + + + +## UI changes + + + +## Verification + + + +## Risk + + + +## Checklist + +- [ ] The scope is focused; related changes belong together +- [ ] I explained what changed and why +- [ ] I included before/after images for visible changes +- [ ] I included a video for motion or interaction changes + + diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 000000000..c7eaba1c2 --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,75 @@ +name: iOS + +on: + push: + branches: [main] + paths: + - "ios/**" + - ".github/workflows/ios.yml" + pull_request: + paths: + - "ios/**" + - ".github/workflows/ios.yml" + +permissions: + contents: read + +concurrency: + group: ios-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + ios-build: + name: iOS build + # macos-26 (arm64) is the newest GA macOS image. The xcode-27 image is still a preview. + runs-on: macos-26 + timeout-minutes: 30 + env: + # Newest stable Xcode on macos-26 per images/macos/macos-26-arm64-Readme.md (image 20260907). + DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer + # Homebrew only installs the latest XcodeGen. The release zip gives an exact version, + # and the drift check below needs the same version the committed project was made with. + XCODEGEN_VERSION: 2.46.0 + XCODEGEN_SHA256: 4d9e34b62172d645eed6457cac13fc222569974098ef4ee9c3368bedf0196806 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Show toolchain + run: | + if [ ! -d "$DEVELOPER_DIR" ]; then + echo "::error::$DEVELOPER_DIR is not on this runner image. Pick an installed Xcode from the image readme." + exit 1 + fi + xcodebuild -version + swift --version + + - name: Install XcodeGen + run: | + curl -fsSL -o "$RUNNER_TEMP/xcodegen.zip" \ + "https://github.com/yonaskolb/XcodeGen/releases/download/${XCODEGEN_VERSION}/xcodegen.zip" + echo "${XCODEGEN_SHA256} $RUNNER_TEMP/xcodegen.zip" | shasum -a 256 -c - + unzip -q "$RUNNER_TEMP/xcodegen.zip" -d "$RUNNER_TEMP" + echo "$RUNNER_TEMP/xcodegen/bin" >> "$GITHUB_PATH" + + - name: Generate project + run: xcodegen generate --spec ios/project.yml + + - name: Check committed project matches project.yml + run: | + if ! git diff --exit-code -- ios; then + echo "::error::HouseScan.xcodeproj differs from project.yml. Run 'make ios-project' (XcodeGen ${XCODEGEN_VERSION}) and commit the result." + exit 1 + fi + untracked="$(git ls-files --others --exclude-standard -- ios)" + if [ -n "$untracked" ]; then + echo "::error::xcodegen generate wrote files that are not committed. Run 'make ios-project' and commit them." + echo "$untracked" + exit 1 + fi + + - name: Build + run: >- + xcodebuild -project ios/HouseScan.xcodeproj -scheme HouseScan -configuration Debug + -destination "generic/platform=iOS" CODE_SIGNING_ALLOWED=NO build diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml new file mode 100644 index 000000000..90a8400a2 --- /dev/null +++ b/.github/workflows/labels.yml @@ -0,0 +1,74 @@ +name: Labels + +on: + push: + branches: [main] + paths: + - .github/labels.json + - .github/workflows/labels.yml + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: labels + +jobs: + sync: + name: Sync label definitions + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Create or update every label in the manifest + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const manifest = require(`${process.env.GITHUB_WORKSPACE}/.github/labels.json`); + const { owner, repo } = context.repo; + const tooLong = manifest.filter((label) => (label.description ?? "").length > 100); + const badColor = manifest.filter((label) => !/^[0-9a-fA-F]{6}$/.test(label.color ?? "")); + const names = manifest.map((label) => label.name); + const duplicates = names.filter((name, index) => names.indexOf(name) !== index); + const nameSet = new Set(names); + const badRename = manifest.filter((label) => label.renamedFrom && nameSet.has(label.renamedFrom)); + if (tooLong.length || badColor.length || duplicates.length || badRename.length) { + core.setFailed( + [ + tooLong.length ? `Description over 100 chars: ${tooLong.map((label) => label.name).join(", ")}` : "", + badColor.length ? `Color is not 6 hex digits: ${badColor.map((label) => label.name).join(", ")}` : "", + duplicates.length ? `Duplicate name: ${[...new Set(duplicates)].join(", ")}` : "", + badRename.length ? `renamedFrom points at a live name: ${badRename.map((label) => label.name).join(", ")}` : "", + ].filter(Boolean).join("; "), + ); + return; + } + + const getLabel = async (name) => { + try { + return (await github.rest.issues.getLabel({ owner, repo, name })).data; + } catch (error) { + if (error.status !== 404) throw error; + return null; + } + }; + + for (const label of manifest) { + const existing = await getLabel(label.name); + if (!existing && label.renamedFrom) { + const previous = await getLabel(label.renamedFrom); + if (previous) { + await github.rest.issues.updateLabel({ + owner, repo, name: label.renamedFrom, new_name: label.name, + color: label.color, description: label.description, + }); + continue; + } + } + if (!existing) { + await github.rest.issues.createLabel({ owner, repo, name: label.name, color: label.color, description: label.description }); + } else if (existing.color.toLowerCase() !== label.color.toLowerCase() || (existing.description ?? "") !== label.description) { + await github.rest.issues.updateLabel({ owner, repo, name: label.name, color: label.color, description: label.description }); + } + } diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml new file mode 100644 index 000000000..088e521ec --- /dev/null +++ b/.github/workflows/pr-size.yml @@ -0,0 +1,143 @@ +name: PR Size + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] + +permissions: + contents: read + +jobs: + label: + name: Label PR size + runs-on: ubuntu-24.04 + permissions: + contents: read + issues: read + pull-requests: write + concurrency: + group: pr-size-${{ github.event.pull_request.number }} + cancel-in-progress: true + steps: + # This workflow runs with a write token. It reads the base branch and fetches + # the PR head only as Git data. Do not run PR scripts or install dependencies here. + - name: Checkout base repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - name: Sync PR size label + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { execFileSync } = require("node:child_process"); + + const issueNumber = context.payload.pull_request.number; + const baseSha = context.payload.pull_request.base.sha; + const headSha = context.payload.pull_request.head.sha; + const headTrackingRef = `refs/remotes/pr-size/${issueNumber}`; + const manifest = require(`${process.env.GITHUB_WORKSPACE}/.github/labels.json`); + const managedLabelNames = new Set( + manifest.map((label) => label.name).filter((name) => name.startsWith("size:")), + ); + // Test layouts here: Swift (Tests/, *Tests.swift), TypeScript (*.test.*), Python (tests/, test_*.py). + const testExcludePathspecs = [ + ":(glob,exclude)**/Tests/**", + ":(glob,exclude)**/*Tests.swift", + ":(glob,exclude)**/__tests__/**", + ":(glob,exclude)**/test/**", + ":(glob,exclude)**/tests/**", + ":(glob,exclude)**/*.test.*", + ":(glob,exclude)**/*.spec.*", + ":(glob,exclude)**/test_*.py", + ":(glob,exclude)**/*_test.py", + ]; + + const sumNumstat = (text) => + text + .split("\n") + .filter(Boolean) + .reduce((total, line) => { + const [insertionsRaw = "0", deletionsRaw = "0"] = line.split("\t"); + const additions = insertionsRaw === "-" ? 0 : Number.parseInt(insertionsRaw, 10) || 0; + const deletions = deletionsRaw === "-" ? 0 : Number.parseInt(deletionsRaw, 10) || 0; + return total + additions + deletions; + }, 0); + + const resolveSizeLabel = (totalChangedLines) => { + if (totalChangedLines < 10) return "size:XS"; + if (totalChangedLines < 30) return "size:S"; + if (totalChangedLines < 100) return "size:M"; + if (totalChangedLines < 500) return "size:L"; + if (totalChangedLines < 1000) return "size:XL"; + return "size:XXL"; + }; + + execFileSync("git", ["fetch", "--no-tags", "origin", baseSha], { stdio: "inherit" }); + execFileSync( + "git", + ["fetch", "--no-tags", "origin", `+refs/pull/${issueNumber}/head:${headTrackingRef}`], + { stdio: "inherit" }, + ); + + const resolvedHeadSha = execFileSync("git", ["rev-parse", headTrackingRef], { + encoding: "utf8", + }).trim(); + if (resolvedHeadSha !== headSha) { + core.warning(`Fetched head SHA ${resolvedHeadSha} does not match pull request head SHA ${headSha}.`); + } + + execFileSync("git", ["cat-file", "-e", `${baseSha}^{commit}`], { stdio: "inherit" }); + const diffArgs = ["diff", "--numstat", "--ignore-all-space", "--ignore-blank-lines", `${baseSha}...${resolvedHeadSha}`]; + const totalChangedLines = sumNumstat(execFileSync("git", diffArgs, { encoding: "utf8" })); + const nonTestChangedLines = sumNumstat( + execFileSync("git", [...diffArgs, "--", ".", ...testExcludePathspecs], { encoding: "utf8" }), + ); + const testChangedLines = Math.max(0, totalChangedLines - nonTestChangedLines); + const changedLines = nonTestChangedLines === 0 ? testChangedLines : nonTestChangedLines; + const nextLabelName = resolveSizeLabel(changedLines); + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + const definedLabels = await github.paginate(github.rest.issues.listLabelsForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + }); + if (!definedLabels.some((label) => label.name === nextLabelName)) { + core.setFailed(`${nextLabelName} is not defined. Run the Labels workflow to sync .github/labels.json.`); + return; + } + + for (const label of currentLabels) { + if (!managedLabelNames.has(label.name) || label.name === nextLabelName) continue; + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: label.name, + }); + } catch (error) { + if (error.status !== 404) throw error; + } + } + if (!currentLabels.some((label) => label.name === nextLabelName)) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [nextLabelName], + }); + } + + const classification = + nonTestChangedLines === 0 + ? testChangedLines > 0 ? "test-only PR" : "no line changes" + : testChangedLines > 0 ? "test lines excluded" : "all non-test changes"; + core.info( + `PR #${issueNumber}: ${nonTestChangedLines} non-test lines, ${testChangedLines} test lines, ${changedLines} effective lines -> ${nextLabelName} (${classification})`, + ); diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml new file mode 100644 index 000000000..6ba31f0cc --- /dev/null +++ b/.github/workflows/server.yml @@ -0,0 +1,40 @@ +name: Server + +on: + push: + branches: [main] + paths: + - "server/**" + - ".github/workflows/server.yml" + pull_request: + paths: + - "server/**" + - ".github/workflows/server.yml" + +permissions: + contents: read + +concurrency: + group: server-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + server-checks: + name: Server checks + runs-on: ubuntu-24.04 + timeout-minutes: 10 + defaults: + run: + working-directory: server + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Reads tool.uv.required-version from server/pyproject.toml to pick the uv version. + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + working-directory: server + - run: uv sync --locked + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run pytest -q diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml new file mode 100644 index 000000000..2f0f0b928 --- /dev/null +++ b/.github/workflows/web.yml @@ -0,0 +1,47 @@ +name: Web + +on: + push: + branches: [main] + paths: + - "web/**" + - ".github/workflows/web.yml" + pull_request: + paths: + - "web/**" + - ".github/workflows/web.yml" + +permissions: + contents: read + +concurrency: + group: web-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + web-checks: + name: Web checks + runs-on: ubuntu-24.04 + timeout-minutes: 15 + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Installs the pnpm version named in web/package.json "packageManager". + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + package_json_file: web/package.json + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: web/.node-version + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + + - run: pnpm run check diff --git a/.gitignore b/.gitignore index 07d422f2d..66bcedbbe 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,8 @@ xcuserdata/ # Recognition prompts are loaded at runtime from private/, never committed server/prompts/ + +# Experiment outputs, local-only notes and secrets +data/ +.local/ +.env.* diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..d01f1bfac --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "sites/landing"] + path = sites/landing + url = https://github.com/SamGu-NRX/house-scanning-landing.git diff --git a/AGENTS.md b/AGENTS.md index 1048e2aa1..941b545ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,3 +32,14 @@ Options: (a) drop Expo for native Swift; (b) Expo shell + a custom native Swift (c) Expo for the non-AR screens only. Don't build AR capture in either place until the team picks one. `apps/mobile/AGENTS.md` has the Expo-specific rules for that folder. + +The setup pull request adds a native Swift skeleton at `ios/` as the recommended answer; its description gives the reasons. Remove this section once the team agrees. + +## Working in this repo + +- Layout: `ios/` is the capture app (native Swift, project generated by XcodeGen). `server/` is the Python server (uv). `web/` is a browser toolchain for the reviewer view and zero-install capture experiments. `experiments/` holds one folder per experiment. `sites/landing` is the landing page, a submodule; change it in its own repository. +- `make check` runs every suite. `make ios`, `make server` and `make web` run one each. `CONTRIBUTING.md` lists the matching CI checks. +- Branch from `main`, keep one writer per branch, and open a pull request. People merge; agents never push to `main` or merge. +- Teammates own lanes A–D (`docs/01-feature-map.md`). AI agents own setup, experiments and challenger reviews, and don't edit a lane's files without its owner's OK. +- Don't pick a signing team in Xcode's Signing & Capabilities pane: it writes into `project.pbxproj` and fails the CI drift check. Put `DEVELOPMENT_TEAM` in `ios/Config/Local.xcconfig` instead (see `ios/README.md`). +- After editing `ios/project.yml`, run `make ios-project` (needs XcodeGen 2.46.0) and commit the regenerated project. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..0e2e1ceb9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# Contributing + +## Branches and worktrees + +Branch from `main` and open a pull request; nobody pushes to `main` directly. One person or agent writes to a branch at a time. Parallel work uses separate worktrees: + +```sh +git worktree add ../house-scanning- -b / origin/main +``` + +Clone with the landing page submodule, or fetch it later with `git submodule update --init sites/landing`. + +## This repository is public + +Never commit photos, video, scans or measurements of a real home, street addresses, meter or account numbers, or the materials Base gave the team. Base's materials stay in `private/`, real captures in `captures/` or `fixtures/real/`, and experiment outputs in `data/`. Git ignores all four. Test fixtures are synthetic. + +## Pull requests + +Keep a pull request focused. A larger change is fine when its parts belong together. + +State what the old code did before describing the new behavior. Link the reviewed files where that helps. Show only changed UI behavior, list known gaps, and report commands that actually ran with their results. + +The `size:*` label describes the effective diff. It is a review signal, not a merge gate. Do not set it by hand. + +## Checks + +| Check | Runs on GitHub when | Local command | +| --- | --- | --- | +| Server checks | `server/` changes | `make server` | +| Web checks | `web/` changes | `make web` | +| iOS build | `ios/` changes | `make ios` | +| Sync label definitions | `.github/labels.json` changes on `main` | none | +| Label PR size | a pull request opens or updates | none | + +`make check` runs the three local suites. They need uv, Node 24 with pnpm, and Xcode 26 or newer; each directory's README has details. No check is required by branch rules yet. Don't call one required until the rules require its status. + +Keep workflows that run pull-request code away from production credentials and destructive external systems. A green CI run is evidence for the checks it ran, not proof that a capture works on a real house. diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..13bca16c8 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +# Each suite target runs the commands of its CI job in .github/workflows/. +# `make check` runs all three. + +XCODEGEN ?= xcodegen +# Must match XCODEGEN_VERSION in .github/workflows/ios.yml; other versions write a different project. +XCODEGEN_VERSION := 2.46.0 + +.PHONY: check server web ios ios-project + +check: server web ios + +server: + cd server && uv sync --locked + cd server && uv run ruff check . + cd server && uv run ruff format --check . + cd server && uv run pytest -q + +web: + cd web && pnpm install --frozen-lockfile + cd web && pnpm run check + +# CI also regenerates the project and fails on drift; run `make ios-project` for that. +ios: + xcodebuild -project ios/HouseScan.xcodeproj -scheme HouseScan -configuration Debug \ + -destination "generic/platform=iOS" CODE_SIGNING_ALLOWED=NO build + +# Regenerates ios/HouseScan.xcodeproj from ios/project.yml. Point XCODEGEN at another binary +# if the one on PATH is not $(XCODEGEN_VERSION): make ios-project XCODEGEN=/path/to/xcodegen +ios-project: + @found="$$($(XCODEGEN) --version 2>/dev/null)"; \ + if [ "$$found" != "Version: $(XCODEGEN_VERSION)" ]; then \ + echo "ios-project needs XcodeGen $(XCODEGEN_VERSION), but $(XCODEGEN) reports: $${found:-nothing (not installed?)}." >&2; \ + echo "Download https://github.com/yonaskolb/XcodeGen/releases/download/$(XCODEGEN_VERSION)/xcodegen.zip, check its SHA-256 against .github/workflows/ios.yml, and rerun with XCODEGEN=/path/to/xcodegen/bin/xcodegen." >&2; \ + exit 1; \ + fi + $(XCODEGEN) generate --spec ios/project.yml diff --git a/experiments/README.md b/experiments/README.md new file mode 100644 index 000000000..b47eacaf9 --- /dev/null +++ b/experiments/README.md @@ -0,0 +1,9 @@ +# Experiments + +An experiment answers one question the team needs settled before building on an assumption. For example: can an iPhone without LiDAR measure the gap between a wall and a fence to within a few inches? + +- Give each experiment its own folder, `experiments//`. +- Its README states the question, the method and the pass criteria before the run, then the result and what it changes after. Keep the result when it fails; a failure tells us which part to change next. +- Keep dependencies inside the folder, in its own uv, pnpm or Xcode project. Don't add them to `server/`, `web/` or `ios/`. +- Write outputs to `data/`, which git ignores. Never commit photos, video or measurements of a real home, or Base's materials. +- Read and write the team's `scene.json` once it exists, so every method's numbers compare on the same terms. diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 000000000..56c060007 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,10 @@ +# Per-person signing overrides; see Config/Local.xcconfig.example. +Config/Local.xcconfig + +# Per-user Xcode state inside the committed project. +xcuserdata/ +*.xcuserstate + +# Build output when building with -derivedDataPath or into the source tree. +DerivedData/ +build/ diff --git a/ios/Config/Info.plist b/ios/Config/Info.plist new file mode 100644 index 000000000..f3aff65c1 --- /dev/null +++ b/ios/Config/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + House Scan + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + NSCameraUsageDescription + House Scan uses the camera to measure the outside wall around your electric meter. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIRequiredDeviceCapabilities + + arkit + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/ios/Config/Local.xcconfig.example b/ios/Config/Local.xcconfig.example new file mode 100644 index 000000000..516f8145d --- /dev/null +++ b/ios/Config/Local.xcconfig.example @@ -0,0 +1,6 @@ +// Copy to Local.xcconfig (gitignored) to sign for a device. +// DEVELOPMENT_TEAM: the 10-character Team ID from developer.apple.com > Membership. +// BUNDLE_ID_PREFIX: a reverse-DNS prefix your team can register; the app id becomes .housescan. + +DEVELOPMENT_TEAM = ABCDE12345 +BUNDLE_ID_PREFIX = com.example.yourname diff --git a/ios/Config/Shared.xcconfig b/ios/Config/Shared.xcconfig new file mode 100644 index 000000000..e545d9aae --- /dev/null +++ b/ios/Config/Shared.xcconfig @@ -0,0 +1,10 @@ +// Build settings shared by Debug and Release. project.yml attaches this file to the HouseScan target. +// +// A bundle identifier can be registered by only one Apple Developer team, so each person +// signing on a device sets their own BUNDLE_ID_PREFIX and DEVELOPMENT_TEAM in Local.xcconfig +// (gitignored; copy Local.xcconfig.example). Keeping them here instead of in the project file +// stops signing changes from showing up as project.pbxproj diffs. + +BUNDLE_ID_PREFIX = dev.housescanning + +#include? "Local.xcconfig" diff --git a/ios/HouseScan.xcodeproj/project.pbxproj b/ios/HouseScan.xcodeproj/project.pbxproj new file mode 100644 index 000000000..71d3ee054 --- /dev/null +++ b/ios/HouseScan.xcodeproj/project.pbxproj @@ -0,0 +1,327 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 0C4096780A5627C9A53A29B2 /* HouseScan.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = HouseScan.app; sourceTree = BUILT_PRODUCTS_DIR; }; + EEAA26E1BB7E90A7AD7923C7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + F30ACBC67A4159A449077AF5 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 050A16F154C46B951098B8C8 /* HouseScan */ = { + isa = PBXFileSystemSynchronizedRootGroup; + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = HouseScan; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXGroup section */ + 15857B17057201C5A1934D04 /* Products */ = { + isa = PBXGroup; + children = ( + 0C4096780A5627C9A53A29B2 /* HouseScan.app */, + ); + name = Products; + sourceTree = ""; + }; + 8AD6B37B63CD5C86504E5E20 /* Config */ = { + isa = PBXGroup; + children = ( + EEAA26E1BB7E90A7AD7923C7 /* Info.plist */, + F30ACBC67A4159A449077AF5 /* Shared.xcconfig */, + ); + path = Config; + sourceTree = ""; + }; + F45504E255654984698A6926 = { + isa = PBXGroup; + children = ( + 8AD6B37B63CD5C86504E5E20 /* Config */, + 050A16F154C46B951098B8C8 /* HouseScan */, + 15857B17057201C5A1934D04 /* Products */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + C5240AAFE2CE18E751FB21F9 /* HouseScan */ = { + isa = PBXNativeTarget; + buildConfigurationList = BC75D335CF0AD12595EFD903 /* Build configuration list for PBXNativeTarget "HouseScan" */; + buildPhases = ( + 535AA09D0DE15B25D4976AD1 /* Sources */, + E009F9206BA5E923B8F3E03B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 050A16F154C46B951098B8C8 /* HouseScan */, + ); + name = HouseScan; + packageProductDependencies = ( + ); + productName = HouseScan; + productReference = 0C4096780A5627C9A53A29B2 /* HouseScan.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 0BC33671F32C9E42F1E3A643 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 2600; + TargetAttributes = { + C5240AAFE2CE18E751FB21F9 = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 0274DCB965114EB4DAAAAA8C /* Build configuration list for PBXProject "HouseScan" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = F45504E255654984698A6926; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 15857B17057201C5A1934D04 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + C5240AAFE2CE18E751FB21F9 /* HouseScan */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + E009F9206BA5E923B8F3E03B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 535AA09D0DE15B25D4976AD1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + AC6CD8DF3FFE7F4BCC4D6275 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + B803A7DB8AD53F4263509876 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F30ACBC67A4159A449077AF5 /* Shared.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = Config/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).housescan"; + PRODUCT_NAME = HouseScan; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + CCEF4C242B48063435A8C20E /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F30ACBC67A4159A449077AF5 /* Shared.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = Config/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).housescan"; + PRODUCT_NAME = HouseScan; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + EE18EE856B3ACBECC802FAED /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 0274DCB965114EB4DAAAAA8C /* Build configuration list for PBXProject "HouseScan" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AC6CD8DF3FFE7F4BCC4D6275 /* Debug */, + EE18EE856B3ACBECC802FAED /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + BC75D335CF0AD12595EFD903 /* Build configuration list for PBXNativeTarget "HouseScan" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B803A7DB8AD53F4263509876 /* Debug */, + CCEF4C242B48063435A8C20E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 0BC33671F32C9E42F1E3A643 /* Project object */; +} diff --git a/ios/HouseScan.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/HouseScan.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/ios/HouseScan.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/HouseScan.xcodeproj/xcshareddata/xcschemes/HouseScan.xcscheme b/ios/HouseScan.xcodeproj/xcshareddata/xcschemes/HouseScan.xcscheme new file mode 100644 index 000000000..ea132a850 --- /dev/null +++ b/ios/HouseScan.xcodeproj/xcshareddata/xcschemes/HouseScan.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/HouseScan/ARCaptureView.swift b/ios/HouseScan/ARCaptureView.swift new file mode 100644 index 000000000..7f2010328 --- /dev/null +++ b/ios/HouseScan/ARCaptureView.swift @@ -0,0 +1,33 @@ +import ARKit +import RealityKit +import SwiftUI + +/// RealityKit's ARView running world tracking with plane detection. +/// +/// The app configures the session itself (`automaticallyConfigureSession: false`) so it controls +/// exactly what runs: no scene reconstruction and no scene depth, which keeps it working on +/// iPhones without LiDAR. +struct ARCaptureView: UIViewRepresentable { + let model: CaptureSessionModel + + func makeCoordinator() -> SessionDelegate { + SessionDelegate(model: model) + } + + func makeUIView(context: Context) -> ARView { + let view = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: false) + view.session.delegateQueue = DispatchQueue(label: "HouseScan.ARSessionDelegate") + view.session.delegate = context.coordinator + + let configuration = ARWorldTrackingConfiguration() + configuration.planeDetection = [.horizontal, .vertical] + view.session.run(configuration) + return view + } + + func updateUIView(_ view: ARView, context: Context) {} + + static func dismantleUIView(_ view: ARView, coordinator: SessionDelegate) { + view.session.pause() + } +} diff --git a/ios/HouseScan/CaptureScreen.swift b/ios/HouseScan/CaptureScreen.swift new file mode 100644 index 000000000..b47a11b82 --- /dev/null +++ b/ios/HouseScan/CaptureScreen.swift @@ -0,0 +1,87 @@ +import ARKit +import SwiftUI + +/// Full-screen camera with a status panel, or a plain message when AR cannot run. +struct CaptureScreen: View { + @State private var model = CaptureSessionModel() + + var body: some View { + if !ARWorldTrackingConfiguration.isSupported { + ContentUnavailableView( + "AR isn't available", + systemImage: "arkit", + description: Text("House Scan needs an iPhone that supports ARKit world tracking. The Simulator can't run it.") + ) + } else if let failure = model.failure { + FailureView(failure: failure) + } else { + ZStack(alignment: .top) { + ARCaptureView(model: model) + .ignoresSafeArea() + TrackingStatusPanel(model: model) + .padding() + } + } + } +} + +private struct TrackingStatusPanel: View { + let model: CaptureSessionModel + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(model.isInterrupted ? "Camera paused" : model.trackingState.instruction) + .font(.headline) + Text("Horizontal surfaces: \(model.horizontalPlaneCount)") + Text("Vertical surfaces: \(model.verticalPlaneCount)") + } + .font(.subheadline) + .monospacedDigit() + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.regularMaterial, in: .rect(cornerRadius: 12)) + .accessibilityElement(children: .combine) + } +} + +private struct FailureView: View { + let failure: SessionFailure + @Environment(\.openURL) private var openURL + + var body: some View { + switch failure { + case .cameraAccessDenied: + ContentUnavailableView { + Label("Camera access is off", systemImage: "camera") + } description: { + Text("House Scan needs the camera to measure your wall. Turn on Camera for House Scan in Settings.") + } actions: { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } + } + } + case .other(let message): + ContentUnavailableView( + "The camera session stopped", + systemImage: "exclamationmark.triangle", + description: Text("\(message) Close and reopen House Scan to try again.") + ) + } + } +} + +private extension TrackingState { + var instruction: String { + switch self { + case .notAvailable: "Waiting for the camera" + case .normal: "Tracking" + case .limited(.initializing): "Move your phone slowly to start" + case .limited(.excessiveMotion): "Slow down" + case .limited(.insufficientFeatures): "Move your phone slowly" + case .limited(.relocalizing): "Point back at where you were" + case .limited(.unknown): "Hold your phone steady" + } + } +} diff --git a/ios/HouseScan/CaptureSessionModel.swift b/ios/HouseScan/CaptureSessionModel.swift new file mode 100644 index 000000000..4f656706c --- /dev/null +++ b/ios/HouseScan/CaptureSessionModel.swift @@ -0,0 +1,120 @@ +import ARKit +import Observation + +/// What the AR session currently reports, for the capture screen to display. +/// +/// `SessionDelegate` writes to it from ARKit callbacks; SwiftUI reads it. +@MainActor +@Observable +final class CaptureSessionModel { + private(set) var trackingState: TrackingState = .notAvailable + private(set) var isInterrupted = false + private(set) var failure: SessionFailure? + + /// Whether this device can produce LiDAR scene depth. Recorded for later capture code; + /// the session never turns depth on and the app does not require it. + let lidarAvailable: Bool + + // Plane identifiers rather than running counts, so a repeated add or remove cannot skew a count. + private var horizontalPlanes: Set = [] + private var verticalPlanes: Set = [] + + var horizontalPlaneCount: Int { horizontalPlanes.count } + var verticalPlaneCount: Int { verticalPlanes.count } + + init(lidarAvailable: Bool = ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth)) { + self.lidarAvailable = lidarAvailable + } + + func trackingChanged(to state: TrackingState) { + trackingState = state + } + + func planesAdded(_ planes: [DetectedPlane]) { + for plane in planes { + switch plane.orientation { + case .horizontal: horizontalPlanes.insert(plane.id) + case .vertical: verticalPlanes.insert(plane.id) + } + } + } + + func planesRemoved(_ planes: [DetectedPlane]) { + for plane in planes { + switch plane.orientation { + case .horizontal: horizontalPlanes.remove(plane.id) + case .vertical: verticalPlanes.remove(plane.id) + } + } + } + + func interruptionChanged(isInterrupted: Bool) { + self.isInterrupted = isInterrupted + } + + func sessionFailed(_ error: any Error) { + if let arError = error as? ARError, arError.code == .cameraUnauthorized { + failure = .cameraAccessDenied + } else { + failure = .other(error.localizedDescription) + } + } +} + +enum TrackingState: Equatable, Sendable { + case notAvailable + case limited(LimitedReason) + case normal + + enum LimitedReason: Equatable, Sendable { + case initializing + case excessiveMotion + case insufficientFeatures + case relocalizing + case unknown + } + + init(_ state: ARCamera.TrackingState) { + switch state { + case .notAvailable: + self = .notAvailable + case .normal: + self = .normal + case .limited(let reason): + switch reason { + case .initializing: self = .limited(.initializing) + case .excessiveMotion: self = .limited(.excessiveMotion) + case .insufficientFeatures: self = .limited(.insufficientFeatures) + case .relocalizing: self = .limited(.relocalizing) + @unknown default: self = .limited(.unknown) + } + } + } +} + +/// A plane anchor reduced to the fields the model counts, so it can cross to the main actor. +struct DetectedPlane: Sendable { + enum Orientation: Sendable { + case horizontal + case vertical + } + + let id: UUID + let orientation: Orientation + + /// Returns nil for anchors that are not planes and for alignments added after iOS 26. + init?(_ anchor: ARAnchor) { + guard let plane = anchor as? ARPlaneAnchor else { return nil } + switch plane.alignment { + case .horizontal: orientation = .horizontal + case .vertical: orientation = .vertical + @unknown default: return nil + } + id = plane.identifier + } +} + +enum SessionFailure: Equatable, Sendable { + case cameraAccessDenied + case other(String) +} diff --git a/ios/HouseScan/HouseScanApp.swift b/ios/HouseScan/HouseScanApp.swift new file mode 100644 index 000000000..d791cf645 --- /dev/null +++ b/ios/HouseScan/HouseScanApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct HouseScanApp: App { + var body: some Scene { + WindowGroup { + CaptureScreen() + } + } +} diff --git a/ios/HouseScan/SessionDelegate.swift b/ios/HouseScan/SessionDelegate.swift new file mode 100644 index 000000000..00ca7866e --- /dev/null +++ b/ios/HouseScan/SessionDelegate.swift @@ -0,0 +1,46 @@ +import ARKit + +/// Receives ARSession callbacks and forwards them to `CaptureSessionModel`. +/// +/// ARKit calls these methods on the session's `delegateQueue`, which `ARCaptureView` sets to a +/// private serial queue so per-frame work added later stays off the main thread. Each callback +/// copies what it needs into Sendable values on that queue, then hops to the main actor with +/// `Task { @MainActor in ... }`. The model is main-actor isolated and therefore Sendable, so +/// no `@unchecked Sendable` or `nonisolated(unsafe)` is needed. ARKit's own objects stay on the +/// delegate queue. +final class SessionDelegate: NSObject, ARSessionDelegate { + private let model: CaptureSessionModel + + init(model: CaptureSessionModel) { + self.model = model + } + + func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) { + let state = TrackingState(camera.trackingState) + Task { @MainActor [model] in model.trackingChanged(to: state) } + } + + func session(_ session: ARSession, didAdd anchors: [ARAnchor]) { + let planes = anchors.compactMap(DetectedPlane.init) + guard !planes.isEmpty else { return } + Task { @MainActor [model] in model.planesAdded(planes) } + } + + func session(_ session: ARSession, didRemove anchors: [ARAnchor]) { + let planes = anchors.compactMap(DetectedPlane.init) + guard !planes.isEmpty else { return } + Task { @MainActor [model] in model.planesRemoved(planes) } + } + + func sessionWasInterrupted(_ session: ARSession) { + Task { @MainActor [model] in model.interruptionChanged(isInterrupted: true) } + } + + func sessionInterruptionEnded(_ session: ARSession) { + Task { @MainActor [model] in model.interruptionChanged(isInterrupted: false) } + } + + func session(_ session: ARSession, didFailWithError error: any Error) { + Task { @MainActor [model] in model.sessionFailed(error) } + } +} diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 000000000..2e97ed655 --- /dev/null +++ b/ios/README.md @@ -0,0 +1,61 @@ +# House Scan for iOS + +Native iPhone app for the AR capture walk. It currently runs an ARKit world-tracking session and shows whether tracking works. It does not capture, save or upload anything yet. + +## What the app does now + +- Opens a full-screen camera view running `ARWorldTrackingConfiguration` with horizontal and vertical plane detection. +- Shows the tracking state in plain words ("Move your phone slowly", "Slow down") and how many horizontal and vertical surfaces ARKit has found. +- Records whether the device supports LiDAR scene depth (`CaptureSessionModel.lidarAvailable`) without turning depth on. The app runs on any iPhone that supports ARKit world tracking. +- Shows a message instead of the camera when world tracking is unsupported, for example in the Simulator, and a Settings link when camera access is off. +- Pauses the AR session when the camera view goes away. + +## Layout + +| Path | Contents | +| --- | --- | +| `project.yml` | XcodeGen spec, the source of truth for the project | +| `HouseScan.xcodeproj` | Generated from `project.yml` and committed | +| `HouseScan/` | Swift sources, a synchronized folder | +| `Config/Shared.xcconfig` | Settings for all configurations; includes `Local.xcconfig` if present | +| `Config/Local.xcconfig.example` | Template for per-person signing | +| `Config/Info.plist` | Camera prompt, `arkit` capability, portrait only | + +## Requirements + +- Xcode 26 or newer. The deployment target is iOS 26.0 and the code uses only iOS 26 SDK APIs. +- An iPhone that supports ARKit to run it. LiDAR is not required. +- XcodeGen 2.46.0, only if you change `project.yml`. + +## Commands + +Build without signing, as CI does, from the repository root: + +```sh +xcodebuild -project ios/HouseScan.xcodeproj -scheme HouseScan -configuration Debug \ + -destination "generic/platform=iOS" CODE_SIGNING_ALLOWED=NO build +``` + +`make ios` runs the same build. + +After editing `project.yml`, regenerate the project from the repository root and commit it: + +```sh +make ios-project +``` + +CI regenerates the project with XcodeGen 2.46.0 and fails if the result differs from the committed project. Other XcodeGen versions can write a different file, so `make ios-project` refuses to run with any other version. + +## Signing and running on a device + +1. `cp ios/Config/Local.xcconfig.example ios/Config/Local.xcconfig` +2. Set `DEVELOPMENT_TEAM` to your Team ID and `BUNDLE_ID_PREFIX` to a prefix your team can register. The app id becomes `.housescan`. +3. Open `HouseScan.xcodeproj`, pick your iPhone, and run. + +Git ignores `Local.xcconfig`. Leave the team field in Xcode's Signing & Capabilities tab empty. Setting it there writes your team into `project.pbxproj`, and the CI drift check then fails. + +## Conventions + +- Add Swift files under `HouseScan/`. Xcode includes them without a project change. Edit `project.yml` only for settings, targets or files outside that folder. +- Swift 6 language mode with complete concurrency checking. UI and `CaptureSessionModel` run on the main actor. ARKit delegate callbacks run on a private serial queue and send Sendable values to the main actor (see `SessionDelegate.swift`). +- Never commit captures, photos or measurements of a real home. See the repository CONTRIBUTING.md. diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 000000000..bb96827ca --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,56 @@ +# Source of truth for HouseScan.xcodeproj. After editing, run `make ios-project` from the repo root +# and commit the regenerated project; CI fails if the committed project differs. +name: HouseScan + +options: + # CI installs this exact version; older releases write a different project file. + minimumXcodeGenVersion: 2.46.0 + # Written into the project and scheme as LastUpgradeCheck, so Xcode 26 does not offer + # a settings upgrade that would edit the generated file. + xcodeVersion: "26.0" + deploymentTarget: + iOS: "26.0" + developmentLanguage: en + +settings: + base: + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + SWIFT_APPROACHABLE_CONCURRENCY: YES + +targets: + HouseScan: + type: application + platform: iOS + sources: + # Synchronized folder: Xcode picks up files added to HouseScan/ without project-file edits. + - path: HouseScan + type: syncedFolder + - path: Config/Info.plist + buildPhase: none + configFiles: + Debug: Config/Shared.xcconfig + Release: Config/Shared.xcconfig + settings: + base: + PRODUCT_NAME: HouseScan + PRODUCT_BUNDLE_IDENTIFIER: $(BUNDLE_ID_PREFIX).housescan + INFOPLIST_FILE: Config/Info.plist + MARKETING_VERSION: 0.1.0 + CURRENT_PROJECT_VERSION: 1 + TARGETED_DEVICE_FAMILY: "1" + SUPPORTS_MACCATALYST: NO + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO + CODE_SIGN_STYLE: Automatic + CODE_SIGN_IDENTITY: Apple Development + +schemes: + HouseScan: + build: + targets: + HouseScan: all + run: + config: Debug + archive: + config: Release diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 000000000..6cff60015 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ diff --git a/server/.python-version b/server/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/server/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/server/README.md b/server/README.md new file mode 100644 index 000000000..b614c3225 --- /dev/null +++ b/server/README.md @@ -0,0 +1,24 @@ +# server + +The FastAPI server from `docs/02-implementation-plan.md` (lanes B and C). It will take a capture from the phone, build the wall geometry, run the recognition checks and pick a battery placement. Modules sit flat in this directory (`ingest.py`, `geometry.py`, `solver.py`, `rules.yaml` and so on) as the lanes add them. + +For now it holds `units.py`. ARKit reports meters, while `scene.json` and the rules use feet and inches; convert with `units.py` and format lengths for people with `format_ft_in`. + +## Requirements + +[uv](https://docs.astral.sh/uv/) 0.11. It installs Python 3.12 (`.python-version`) and the locked dev tools. + +## Commands + +Run from `server/`: + +| Command | What it does | +| --- | --- | +| `uv sync --locked` | Create `.venv` from `uv.lock` | +| `uv run ruff check .` | Lint | +| `uv run ruff format .` | Format; CI runs `ruff format --check .` | +| `uv run pytest -q` | Tests in `tests/` | + +`make server` from the repository root runs what CI (`.github/workflows/server.yml`, job "Server checks") runs. Add dependencies with `uv add ` and commit `uv.lock`. + +Recognition prompts go in `server/prompts/`, which git ignores; they are never committed. diff --git a/server/pyproject.toml b/server/pyproject.toml new file mode 100644 index 000000000..3d32bde13 --- /dev/null +++ b/server/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "house-scanning-server" +version = "0.1.0" +description = "FastAPI server that turns a phone capture into a battery placement (lanes B and C)" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [] + +[dependency-groups] +dev = [ + "pytest>=9.1.1", + "ruff>=0.16.9", +] + +[tool.uv] +# Modules sit flat in this directory and run in place; there is nothing to build or install. +package = false +# setup-uv reads this in CI, so local and CI runs use the same uv minor version as uv.lock. +required-version = ">=0.11.15,<0.12" + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = ["--strict-markers", "--strict-config"] diff --git a/server/tests/test_units.py b/server/tests/test_units.py new file mode 100644 index 000000000..d0752ecbe --- /dev/null +++ b/server/tests/test_units.py @@ -0,0 +1,95 @@ +import math + +import pytest + +from units import format_ft_in, ft_to_in, ft_to_m, in_to_ft, m_to_ft + + +def test_one_foot_is_exactly_0_3048_meters() -> None: + assert ft_to_m(1) == 0.3048 + assert m_to_ft(0.3048) == 1.0 + + +def test_meter_foot_round_trip_values() -> None: + assert ft_to_m(10) == 3.048 + assert m_to_ft(3.048) == 10.0 + + +def test_inch_foot_conversions() -> None: + assert ft_to_in(1) == 12 + assert ft_to_in(0.5) == 6.0 + assert in_to_ft(6) == 0.5 + assert in_to_ft(12) == 1.0 + + +def test_zero_converts_to_zero() -> None: + assert m_to_ft(0) == 0.0 + assert ft_to_m(0) == 0.0 + assert in_to_ft(0) == 0.0 + assert ft_to_in(0) == 0.0 + assert format_ft_in(0) == "0 ft 0 in" + + +def test_format_whole_feet_and_inches() -> None: + assert format_ft_in(8 + 4 / 12) == "8 ft 4 in" + assert format_ft_in(3) == "3 ft 0 in" + + +def test_format_carries_twelve_inches_into_the_next_foot() -> None: + # 0.99 ft is 11.88 in, which rounds to 12 in. + assert format_ft_in(0.99) == "1 ft 0 in" + assert format_ft_in(2.99) == "3 ft 0 in" + + +@pytest.mark.parametrize( + ("feet", "expected"), + [ + # Each value times 12 is exactly x.5 in binary floating point. + (0.375, "0 ft 5 in"), # 4.5 in; round() would give 4 + (0.875, "0 ft 11 in"), # 10.5 in; round() would give 10 + (1.125, "1 ft 2 in"), # 13.5 in + ], +) +def test_format_rounds_half_an_inch_up(feet: float, expected: str) -> None: + assert format_ft_in(feet) == expected + + +@pytest.mark.parametrize( + ("inches", "expected"), + [(0.5, "0 ft 1 in"), (4.5, "0 ft 5 in"), (11.5, "1 ft 0 in"), (100.5, "8 ft 5 in")], +) +def test_format_rounds_half_an_inch_up_after_converting_from_inches( + inches: float, expected: str +) -> None: + # x.5 / 12 is not exact in binary; the half must still round up. + assert format_ft_in(in_to_ft(inches)) == expected + + +@pytest.mark.parametrize( + ("feet", "expected"), + [ + (math.nextafter(0.375, 0), "0 ft 4 in"), # 4.499999999999999 in + (0.37, "0 ft 4 in"), # 4.44 in + (math.nextafter(0.5 / 12, 0), "0 ft 0 in"), # 0.4999999999999999 in + ], +) +def test_format_rounds_below_half_an_inch_down(feet: float, expected: str) -> None: + assert format_ft_in(feet) == expected + + +ALL_FUNCTIONS = [m_to_ft, ft_to_m, in_to_ft, ft_to_in, format_ft_in] + + +@pytest.mark.parametrize("func", ALL_FUNCTIONS) +def test_negative_input_raises_naming_the_value(func) -> None: + with pytest.raises(ValueError, match=r"must not be negative, got -1\.5$"): + func(-1.5) + + +@pytest.mark.parametrize("func", ALL_FUNCTIONS) +@pytest.mark.parametrize( + ("value", "shown"), [(math.nan, "nan"), (math.inf, "inf"), (-math.inf, "-inf")] +) +def test_non_finite_input_raises_naming_the_value(func, value: float, shown: str) -> None: + with pytest.raises(ValueError, match=rf"must be a finite number, got {shown}$"): + func(value) diff --git a/server/units.py b/server/units.py new file mode 100644 index 000000000..279792ae5 --- /dev/null +++ b/server/units.py @@ -0,0 +1,55 @@ +"""Length conversions between ARKit's meters and the feet and inches of scene.json and the rules. + +Every function takes a distance, so negative and non-finite input raises ValueError instead of +producing a length nobody can measure. +""" + +import math +from decimal import ROUND_HALF_UP, Decimal + +# Exact by definition (international yard and pound agreement, 1959). +METERS_PER_FOOT = 0.3048 +INCHES_PER_FOOT = 12 + + +def _require_length(value: float, name: str) -> None: + if not math.isfinite(value): + raise ValueError(f"{name} must be a finite number, got {value!r}") + if value < 0: + raise ValueError(f"{name} must not be negative, got {value!r}") + + +def m_to_ft(meters: float) -> float: + _require_length(meters, "meters") + return meters / METERS_PER_FOOT + + +def ft_to_m(feet: float) -> float: + _require_length(feet, "feet") + return feet * METERS_PER_FOOT + + +def in_to_ft(inches: float) -> float: + _require_length(inches, "inches") + return inches / INCHES_PER_FOOT + + +def ft_to_in(feet: float) -> float: + _require_length(feet, "feet") + return feet * INCHES_PER_FOOT + + +def format_ft_in(feet: float) -> str: + """Format feet as whole feet and inches, for example "8 ft 4 in". + + Rounds to the nearest inch with halves rounding up, then splits, so 0.99 ft (11.88 in) reads + "1 ft 0 in" rather than "0 ft 12 in". Python's round() sends halves to the even neighbour + (round(4.5) == 4) and floor(x + 0.5) can misround next to a half, so the float inch count is + converted exactly to Decimal and rounded there. Rounding the float product, not the exact + product of feet and 12, keeps format_ft_in(in_to_ft(0.5)) at "0 ft 1 in": 0.5 / 12 is stored + slightly below 1/24 ft, and multiplying by 12 in floating point lands back on 0.5. + """ + _require_length(feet, "feet") + total_inches = int(Decimal(feet * INCHES_PER_FOOT).to_integral_value(rounding=ROUND_HALF_UP)) + whole_feet, inches = divmod(total_inches, INCHES_PER_FOOT) + return f"{whole_feet} ft {inches} in" diff --git a/server/uv.lock b/server/uv.lock new file mode 100644 index 000000000..9a0f1c8a1 --- /dev/null +++ b/server/uv.lock @@ -0,0 +1,108 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "house-scanning-server" +version = "0.1.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.16.9" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] diff --git a/sites/landing b/sites/landing new file mode 160000 index 000000000..046f220d3 --- /dev/null +++ b/sites/landing @@ -0,0 +1 @@ +Subproject commit 046f220d3e9b9194cf556dc3e108ceb89d2d35a6 diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 000000000..6b72902c8 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +coverage/ +*.tsbuildinfo +*.local diff --git a/web/.node-version b/web/.node-version new file mode 100644 index 000000000..a45fd52cc --- /dev/null +++ b/web/.node-version @@ -0,0 +1 @@ +24 diff --git a/web/README.md b/web/README.md new file mode 100644 index 000000000..9ede33fb9 --- /dev/null +++ b/web/README.md @@ -0,0 +1,32 @@ +# web + +Browser app for zero-install capture experiments. Right now it holds a placeholder page and `src/lib/units.ts`, which converts meters (what ARKit reports) to feet and inches for display. + +## Setup + +Requires Node 24 or later (`.node-version`) and the pnpm version pinned in `package.json` `packageManager`. Corepack and pnpm's own version switching both pick up that pin. + +```sh +pnpm install +``` + +## Commands + +| Command | What it does | +| ---------------- | ------------------------------------------------------ | +| `pnpm dev` | Vite dev server | +| `pnpm build` | `tsc -b`, then `vite build` into `dist/` | +| `pnpm typecheck` | `tsc -b` (no emit) | +| `pnpm lint` | `biome ci .`: lint and format check, no writes | +| `pnpm format` | `biome format --write .` | +| `pnpm test` | `vitest run` | +| `pnpm check` | lint, typecheck, test, build; stops at first failure | + +CI (`.github/workflows/web.yml`, job "Web checks") runs `pnpm install --frozen-lockfile` and then `pnpm run check`. + +## Conventions + +- Lengths are stored in meters. Convert to feet and inches only for display, using `src/lib/units.ts`. +- TypeScript runs in strict mode with `noUncheckedIndexedAccess`. +- Tests live next to their source files as `*.test.ts`. +- Use synthetic data only. The repository is public, so never commit real home photos, addresses or meter numbers. diff --git a/web/biome.json b/web/biome.json new file mode 100644 index 000000000..71d70060e --- /dev/null +++ b/web/biome.json @@ -0,0 +1,36 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 000000000..a1db43110 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + House Scan: web capture + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 000000000..c4dc2e3e1 --- /dev/null +++ b/web/package.json @@ -0,0 +1,33 @@ +{ + "name": "@house-scanning/web", + "private": true, + "version": "0.0.0", + "type": "module", + "packageManager": "pnpm@11.27.1", + "engines": { + "node": ">=24" + }, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc -b", + "lint": "biome ci .", + "format": "biome format --write .", + "test": "vitest run", + "check": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build" + }, + "dependencies": { + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@biomejs/biome": "2.5.14", + "@types/node": "^24.0.0", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "typescript": "~6.0.3", + "vite": "^8.3.1", + "vitest": "^5.0.0" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 000000000..6b2078929 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,847 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + react: + specifier: ^19.3.0 + version: 19.3.0 + react-dom: + specifier: ^19.3.0 + version: 19.3.0(react@19.3.0) + devDependencies: + '@biomejs/biome': + specifier: 2.5.14 + version: 2.5.14 + '@types/node': + specifier: ^24.0.0 + version: 24.13.6 + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: ^6.1.1 + version: 6.1.1(vite@8.3.1(@types/node@24.13.6)) + typescript: + specifier: ~6.0.3 + version: 6.0.3 + vite: + specifier: ^8.3.1 + version: 8.3.1(@types/node@24.13.6) + vitest: + specifier: ^5.0.0 + version: 5.0.1(@types/node@24.13.6)(vite@8.3.1(@types/node@24.13.6)) + +packages: + + '@biomejs/biome@2.5.14': + resolution: {integrity: sha512-0FabLIjd4M/dm8VFI86RMaLLdgepzbdfiAL2R8cr7V81OYYrP1w7Z73KfAwPK5h9SrEBXTzNG2y+mBwPo9xRnw==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.14': + resolution: {integrity: sha512-UnzaXO65L4tsZimFITFP2M121GyhDcWFrT3pL5ZJ5U4XcS/0L5VHYytVohdCf/gnDUFHgl2I9xnt5bV/J1kxHQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.14': + resolution: {integrity: sha512-kiy8qA16K93J7uvFfWi4LgjqDpnRKyePAna6A0Y4jxyUga35SYpIZ2cNWlhy0lsfgqRenCpfymnJWEZ6mgpRgA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.14': + resolution: {integrity: sha512-SJ9PrZkBnnH9dHJDnxk34vKs0GB2dbsioaft6/hPhWJ7AHpwYH83Isnhj0FSRpFkGgpVfJ1lds23ApB2czUDLQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.14': + resolution: {integrity: sha512-vO/9BaU1n30CiFNLx49gMTMtbCAAqlo/EqFoG0BU3deQInTbJrmXSmTQ9e3FoDZIKQnvSLDVNL4lx1ci7y1T1Q==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.14': + resolution: {integrity: sha512-2kI5PrMgW5dcEZYrstLPmUmCwkUwZY39rP4BN93Vxbwcs2O57LDQOktUZZYPvvspN5i0mhGr3TJtF5sU26NUWg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.14': + resolution: {integrity: sha512-VHZRa7CCQBUWKxNwUvHJeuW03WFRgN5NWO//SDGOitc9QIeNyfA42V9zlKm+x82xFSG9Bzi5fi+hbhm9anO8yA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.14': + resolution: {integrity: sha512-pHgAFffmZtaYoxavEsWEYNvcA7NwOIjLyqw2HXyLbt16xYryX0L4fMV2u0G9ag6LNYPIoqWaWqzUcnc6rLGGXg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.14': + resolution: {integrity: sha512-oJWmBhoHsnhUKIke+0gXDX0mltJrWHA1UyHsrTlXwX0TL64ilVZAo+TYZm97baecV0esBdpzy3k96q+09JaZUQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.151.0': + resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + + '@rolldown/binding-android-arm-eabi@1.2.11': + resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.11': + resolution: {integrity: sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.11': + resolution: {integrity: sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.11': + resolution: {integrity: sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.11': + resolution: {integrity: sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + resolution: {integrity: sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.11': + resolution: {integrity: sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.11': + resolution: {integrity: sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + resolution: {integrity: sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.11': + resolution: {integrity: sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.11': + resolution: {integrity: sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.11': + resolution: {integrity: sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.11': + resolution: {integrity: sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.11': + resolution: {integrity: sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.11': + resolution: {integrity: sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.13.6': + resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/mocker@5.0.1': + resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.1': + resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + peerDependencies: + react: ^19.3.0 + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + rolldown@1.2.11: + resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + tinybench@6.1.4: + resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + vite@8.3.1: + resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.1: + resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.1 + '@vitest/browser-preview': 5.0.1 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.1 + '@vitest/coverage-v8': 5.0.1 + '@vitest/ui': 5.0.1 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@biomejs/biome@2.5.14': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.14 + '@biomejs/cli-darwin-x64': 2.5.14 + '@biomejs/cli-linux-arm64': 2.5.14 + '@biomejs/cli-linux-arm64-musl': 2.5.14 + '@biomejs/cli-linux-x64': 2.5.14 + '@biomejs/cli-linux-x64-musl': 2.5.14 + '@biomejs/cli-win32-arm64': 2.5.14 + '@biomejs/cli-win32-x64': 2.5.14 + + '@biomejs/cli-darwin-arm64@2.5.14': + optional: true + + '@biomejs/cli-darwin-x64@2.5.14': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.14': + optional: true + + '@biomejs/cli-linux-arm64@2.5.14': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.14': + optional: true + + '@biomejs/cli-linux-x64@2.5.14': + optional: true + + '@biomejs/cli-win32-arm64@2.5.14': + optional: true + + '@biomejs/cli-win32-x64@2.5.14': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.151.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.11': + optional: true + + '@rolldown/binding-android-arm64@1.2.11': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.11': + optional: true + + '@rolldown/binding-darwin-x64@1.2.11': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.11': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.11': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.11': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.11': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.11': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.11': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.13.6': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@6.1.1(vite@8.3.1(@types/node@24.13.6))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.1(@types/node@24.13.6) + + '@vitest/mocker@5.0.1(vite@8.3.1(@types/node@24.13.6))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.1 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: 8.3.1(@types/node@24.13.6) + + '@vitest/spy@5.0.1': {} + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + csstype@3.2.3: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.3.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + nanoid@3.3.19: {} + + obug@2.2.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.3.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react@19.3.0: {} + + rolldown@1.2.11: + dependencies: + '@oxc-project/types': 0.151.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.11 + '@rolldown/binding-android-arm64': 1.2.11 + '@rolldown/binding-darwin-arm64': 1.2.11 + '@rolldown/binding-darwin-x64': 1.2.11 + '@rolldown/binding-freebsd-x64': 1.2.11 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.11 + '@rolldown/binding-linux-arm64-gnu': 1.2.11 + '@rolldown/binding-linux-arm64-musl': 1.2.11 + '@rolldown/binding-linux-ppc64-gnu': 1.2.11 + '@rolldown/binding-linux-s390x-gnu': 1.2.11 + '@rolldown/binding-linux-x64-gnu': 1.2.11 + '@rolldown/binding-linux-x64-musl': 1.2.11 + '@rolldown/binding-openharmony-arm64': 1.2.11 + '@rolldown/binding-win32-arm64-msvc': 1.2.11 + '@rolldown/binding-win32-x64-msvc': 1.2.11 + + scheduler@0.28.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + tinybench@6.1.4: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + typescript@6.0.3: {} + + undici-types@7.18.2: {} + + vite@8.3.1(@types/node@24.13.6): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.11 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.6 + fsevents: 2.3.3 + + vitest@5.0.1(@types/node@24.13.6)(vite@8.3.1(@types/node@24.13.6)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.1(vite@8.3.1(@types/node@24.13.6)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 8.3.1(@types/node@24.13.6) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.6 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 000000000..d0ba8ff97 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,8 @@ +export function App() { + return ( +
+

House Scan: web capture

+

Browser capture experiments live here.

+
+ ); +} diff --git a/web/src/lib/units.test.ts b/web/src/lib/units.test.ts new file mode 100644 index 000000000..d13392961 --- /dev/null +++ b/web/src/lib/units.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { feetToMeters, formatFeetInches, metersToFeet } from "./units.ts"; + +describe("metersToFeet", () => { + it("converts exact multiples of a foot", () => { + expect(metersToFeet(0.3048)).toBe(1); + expect(metersToFeet(0.9144)).toBe(3); + expect(metersToFeet(3.048)).toBe(10); + }); + + it("returns 0 for 0", () => { + expect(metersToFeet(0)).toBe(0); + }); +}); + +describe("feetToMeters", () => { + it("uses 1 ft = 0.3048 m exactly", () => { + expect(feetToMeters(1)).toBe(0.3048); + expect(feetToMeters(10)).toBe(3.048); + expect(feetToMeters(3)).toBeCloseTo(0.9144, 12); + }); + + it("returns 0 for 0", () => { + expect(feetToMeters(0)).toBe(0); + }); + + it("round-trips through metersToFeet", () => { + expect(metersToFeet(feetToMeters(7.25))).toBeCloseTo(7.25, 12); + }); +}); + +describe("formatFeetInches", () => { + it.each([ + [0.3048, "1 ft 0 in"], + [0.9144, "3 ft 0 in"], + [2.54, "8 ft 4 in"], + [0.0254, "0 ft 1 in"], + ])("formats %s m as %s", (m, expected) => { + expect(formatFeetInches(m)).toBe(expected); + }); + + it("carries 12 rounded inches into the next foot", () => { + // 0.3 m is 11.81 in, which rounds to 12 in. + expect(formatFeetInches(0.3)).toBe("1 ft 0 in"); + // 3.35 m is 131.89 in, which rounds to 132 in. + expect(formatFeetInches(3.35)).toBe("11 ft 0 in"); + }); + + it("formats zero", () => { + expect(formatFeetInches(0)).toBe("0 ft 0 in"); + }); + + it("rounds half an inch up", () => { + expect(formatFeetInches(0.0127)).toBe("0 ft 1 in"); // 0.5 in + expect(formatFeetInches(0.1651)).toBe("0 ft 7 in"); // 6.5 in + expect(formatFeetInches(0.3175)).toBe("1 ft 1 in"); // 12.5 in + }); + + it("rounds just under half an inch down", () => { + expect(formatFeetInches(0.0126)).toBe("0 ft 0 in"); // 0.496 in + expect(formatFeetInches(0.3174)).toBe("1 ft 0 in"); // 12.496 in + }); + + it("rounds every half inch up from 0.5 in to 1999.5 in", () => { + for (let n = 0; n < 2000; n++) { + // Build the decimal literal a caller would write, e.g. 0.0381 for 1.5 in. + const m = Number((((2 * n + 1) * 127) / 10000).toFixed(7)); + const total = n + 1; + const expected = `${Math.floor(total / 12)} ft ${total % 12} in`; + expect(formatFeetInches(m), `${m} m`).toBe(expected); + } + }); + + it("uses ASCII only", () => { + expect(formatFeetInches(2.54)).toMatch(/^[\x20-\x7e]+$/); + }); +}); + +describe("input validation", () => { + const invalid = [-0.01, -1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]; + + it.each(invalid)("formatFeetInches(%s) throws RangeError", (value) => { + expect(() => formatFeetInches(value)).toThrow(RangeError); + }); + + it.each(invalid)("metersToFeet(%s) throws RangeError", (value) => { + expect(() => metersToFeet(value)).toThrow(RangeError); + }); + + it.each(invalid)("feetToMeters(%s) throws RangeError", (value) => { + expect(() => feetToMeters(value)).toThrow(RangeError); + }); +}); diff --git a/web/src/lib/units.ts b/web/src/lib/units.ts new file mode 100644 index 000000000..fb1a72ae0 --- /dev/null +++ b/web/src/lib/units.ts @@ -0,0 +1,39 @@ +// ARKit reports lengths in meters; people read feet and inches. +// Both constants are exact by definition (international yard and pound, 1959). +const METERS_PER_FOOT = 0.3048; +const METERS_PER_INCH = 0.0254; +const INCHES_PER_FOOT = 12; + +// These lengths are distances, so every function rejects negative and +// non-finite input instead of formatting a meaningless value. +function assertLength(value: number, name: string): void { + if (!Number.isFinite(value)) { + throw new RangeError(`${name} must be a finite number, got ${value}`); + } + if (value < 0) { + throw new RangeError(`${name} must not be negative, got ${value}`); + } +} + +export function metersToFeet(m: number): number { + assertLength(m, "meters"); + return m / METERS_PER_FOOT; +} + +export function feetToMeters(ft: number): number { + assertLength(ft, "feet"); + return ft * METERS_PER_FOOT; +} + +/** + * Formats a length in meters as whole feet and inches, e.g. "8 ft 4 in". + * Rounds to the nearest inch (half an inch rounds up) before splitting, so + * 11.81 in reads "1 ft 0 in" rather than "0 ft 12 in". + */ +export function formatFeetInches(m: number): string { + assertLength(m, "meters"); + const totalInches = Math.round(m / METERS_PER_INCH); + const feet = Math.floor(totalInches / INCHES_PER_FOOT); + const inches = totalInches % INCHES_PER_FOOT; + return `${feet} ft ${inches} in`; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 000000000..9b362a591 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.tsx"; + +const rootElement = document.getElementById("root"); +if (rootElement === null) { + throw new Error('index.html is missing the
mount point'); +} + +createRoot(rootElement).render( + + + , +); diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 000000000..1f46905e1 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 000000000..d32ff6820 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 000000000..5f6af8443 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 000000000..b96b41deb --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,9 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + }, +});